annot.at is a service for syncing static websites, such as blogs, to atproto and standard.site annot.at
elixir atproto standardsite
3

Configure Feed

Select the types of activity you want to include in your feed.

Add token refresh

Johanna Larsson (Jun 14, 2026, 2:31 PM +0100) dba6c6c3 74d958d3

+101
+39
lib/annot_at/atproto/oauth/flow.ex
··· 137 137 end 138 138 end 139 139 140 + @doc """ 141 + Refreshes a session, returning a new session with rotated tokens. 142 + 143 + The refresh is DPoP-bound to the session's existing key (the tokens are 144 + bound to it), and the new `sub` must still match the session's DID. Refresh 145 + tokens are single-use, so the returned session carries the rotated tokens 146 + and must replace the old one. 147 + 148 + ## Required options 149 + - `:client_id`, `:client_jwk` - the confidential client's id and signing key 150 + 151 + ## Optional 152 + - `:now` - base time for `expires_at` (defaults to the current time) 153 + """ 154 + @spec refresh(ServerMetadata.t(), Session.t(), keyword()) :: 155 + {:ok, Session.t()} 156 + | {:error, 157 + request_error() | {:missing, String.t()} | {:invalid, String.t()} | :did_mismatch} 158 + def refresh(%ServerMetadata{} = server, %Session{} = session, opts) do 159 + client_id = Keyword.fetch!(opts, :client_id) 160 + client_jwk = Keyword.fetch!(opts, :client_jwk) 161 + now = Keyword.get_lazy(opts, :now, &DateTime.utc_now/0) 162 + 163 + build_form = fn -> 164 + [ 165 + grant_type: "refresh_token", 166 + refresh_token: session.refresh_token, 167 + client_assertion_type: ClientAssertion.assertion_type(), 168 + client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) 169 + ] 170 + end 171 + 172 + with {:ok, body} <- dpop_request(server.token_endpoint, build_form, session.dpop_key), 173 + {:ok, tokens} <- TokenResponse.parse(body), 174 + :ok <- verify_sub(tokens.sub, session.did) do 175 + {:ok, build_session(tokens, server, session.pds_endpoint, session.dpop_key, now)} 176 + end 177 + end 178 + 140 179 defp dpop_request(url, build_form, dpop_key, nonce \\ nil) do 141 180 proof = DPoP.proof(dpop_key, "POST", url, nonce: nonce) 142 181
+62
test/annot_at/atproto/oauth/flow_test.exs
··· 6 6 alias AnnotAt.Atproto.OAuth.ClientAssertion 7 7 alias AnnotAt.Atproto.OAuth.Flow 8 8 alias AnnotAt.Atproto.OAuth.ServerMetadata 9 + alias AnnotAt.Atproto.OAuth.Session 9 10 10 11 @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 11 12 @pds "https://enoki.us-east.host.bsky.network" ··· 185 186 end 186 187 end 187 188 189 + describe "Flow.refresh/3" do 190 + test "refreshes a session, rotating the tokens", %{jwk: jwk} do 191 + session = session_fixture(jwk) 192 + 193 + expect(HTTP, :post_form, fn _url, _form, _headers -> 194 + {:ok, %{status: 200, body: @token_response, headers: %{}}} 195 + end) 196 + 197 + assert {:ok, refreshed} = Flow.refresh(@server, session, refresh_opts(jwk)) 198 + assert @did == refreshed.did 199 + assert "ref-3C2EzDmzzkrcA9rerRmYeg5wBPCRZdnGRkRKUOvbLq" == refreshed.refresh_token 200 + assert refreshed.refresh_token != session.refresh_token 201 + assert jwk == refreshed.dpop_key 202 + assert ~U[2026-06-01 12:59:59Z] == refreshed.expires_at 203 + end 204 + 205 + test "sends the refresh parameters", %{jwk: jwk} do 206 + expect(HTTP, :post_form, fn url, form, _headers -> 207 + assert "https://bsky.social/oauth/token" == url 208 + assert "refresh_token" == form[:grant_type] 209 + assert "old-refresh" == form[:refresh_token] 210 + assert ClientAssertion.assertion_type() == form[:client_assertion_type] 211 + assert is_binary(form[:client_assertion]) 212 + 213 + {:ok, %{status: 200, body: @token_response, headers: %{}}} 214 + end) 215 + 216 + assert {:ok, _} = Flow.refresh(@server, session_fixture(jwk), refresh_opts(jwk)) 217 + end 218 + 219 + test "rejects a refresh whose sub does not match the session DID", %{jwk: jwk} do 220 + session = %{session_fixture(jwk) | did: "did:plc:someoneelse"} 221 + 222 + expect(HTTP, :post_form, fn _url, _form, _headers -> 223 + {:ok, %{status: 200, body: @token_response, headers: %{}}} 224 + end) 225 + 226 + assert {:error, :did_mismatch} == Flow.refresh(@server, session, refresh_opts(jwk)) 227 + end 228 + end 229 + 188 230 defp exchange_opts(jwk, overrides \\ []) do 189 231 Keyword.merge( 190 232 [ ··· 198 240 pds_endpoint: @pds, 199 241 now: ~U[2026-01-01 00:00:00Z] 200 242 ], 243 + overrides 244 + ) 245 + end 246 + 247 + defp session_fixture(jwk) do 248 + %Session{ 249 + did: @did, 250 + access_token: "old-access", 251 + refresh_token: "old-refresh", 252 + dpop_key: jwk, 253 + scope: "atproto", 254 + issuer: "https://bsky.social", 255 + pds_endpoint: @pds, 256 + expires_at: ~U[2026-01-01 00:00:00Z] 257 + } 258 + end 259 + 260 + defp refresh_opts(jwk, overrides \\ []) do 261 + Keyword.merge( 262 + [client_id: "https://annot.at/client", client_jwk: jwk, now: ~U[2026-06-01 12:00:00Z]], 201 263 overrides 202 264 ) 203 265 end