···137137 end
138138 end
139139140140+ @doc """
141141+ Refreshes a session, returning a new session with rotated tokens.
142142+143143+ The refresh is DPoP-bound to the session's existing key (the tokens are
144144+ bound to it), and the new `sub` must still match the session's DID. Refresh
145145+ tokens are single-use, so the returned session carries the rotated tokens
146146+ and must replace the old one.
147147+148148+ ## Required options
149149+ - `:client_id`, `:client_jwk` - the confidential client's id and signing key
150150+151151+ ## Optional
152152+ - `:now` - base time for `expires_at` (defaults to the current time)
153153+ """
154154+ @spec refresh(ServerMetadata.t(), Session.t(), keyword()) ::
155155+ {:ok, Session.t()}
156156+ | {:error,
157157+ request_error() | {:missing, String.t()} | {:invalid, String.t()} | :did_mismatch}
158158+ def refresh(%ServerMetadata{} = server, %Session{} = session, opts) do
159159+ client_id = Keyword.fetch!(opts, :client_id)
160160+ client_jwk = Keyword.fetch!(opts, :client_jwk)
161161+ now = Keyword.get_lazy(opts, :now, &DateTime.utc_now/0)
162162+163163+ build_form = fn ->
164164+ [
165165+ grant_type: "refresh_token",
166166+ refresh_token: session.refresh_token,
167167+ client_assertion_type: ClientAssertion.assertion_type(),
168168+ client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer)
169169+ ]
170170+ end
171171+172172+ with {:ok, body} <- dpop_request(server.token_endpoint, build_form, session.dpop_key),
173173+ {:ok, tokens} <- TokenResponse.parse(body),
174174+ :ok <- verify_sub(tokens.sub, session.did) do
175175+ {:ok, build_session(tokens, server, session.pds_endpoint, session.dpop_key, now)}
176176+ end
177177+ end
178178+140179 defp dpop_request(url, build_form, dpop_key, nonce \\ nil) do
141180 proof = DPoP.proof(dpop_key, "POST", url, nonce: nonce)
142181
+62
test/annot_at/atproto/oauth/flow_test.exs
···66 alias AnnotAt.Atproto.OAuth.ClientAssertion
77 alias AnnotAt.Atproto.OAuth.Flow
88 alias AnnotAt.Atproto.OAuth.ServerMetadata
99+ alias AnnotAt.Atproto.OAuth.Session
9101011 @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
1112 @pds "https://enoki.us-east.host.bsky.network"
···185186 end
186187 end
187188189189+ describe "Flow.refresh/3" do
190190+ test "refreshes a session, rotating the tokens", %{jwk: jwk} do
191191+ session = session_fixture(jwk)
192192+193193+ expect(HTTP, :post_form, fn _url, _form, _headers ->
194194+ {:ok, %{status: 200, body: @token_response, headers: %{}}}
195195+ end)
196196+197197+ assert {:ok, refreshed} = Flow.refresh(@server, session, refresh_opts(jwk))
198198+ assert @did == refreshed.did
199199+ assert "ref-3C2EzDmzzkrcA9rerRmYeg5wBPCRZdnGRkRKUOvbLq" == refreshed.refresh_token
200200+ assert refreshed.refresh_token != session.refresh_token
201201+ assert jwk == refreshed.dpop_key
202202+ assert ~U[2026-06-01 12:59:59Z] == refreshed.expires_at
203203+ end
204204+205205+ test "sends the refresh parameters", %{jwk: jwk} do
206206+ expect(HTTP, :post_form, fn url, form, _headers ->
207207+ assert "https://bsky.social/oauth/token" == url
208208+ assert "refresh_token" == form[:grant_type]
209209+ assert "old-refresh" == form[:refresh_token]
210210+ assert ClientAssertion.assertion_type() == form[:client_assertion_type]
211211+ assert is_binary(form[:client_assertion])
212212+213213+ {:ok, %{status: 200, body: @token_response, headers: %{}}}
214214+ end)
215215+216216+ assert {:ok, _} = Flow.refresh(@server, session_fixture(jwk), refresh_opts(jwk))
217217+ end
218218+219219+ test "rejects a refresh whose sub does not match the session DID", %{jwk: jwk} do
220220+ session = %{session_fixture(jwk) | did: "did:plc:someoneelse"}
221221+222222+ expect(HTTP, :post_form, fn _url, _form, _headers ->
223223+ {:ok, %{status: 200, body: @token_response, headers: %{}}}
224224+ end)
225225+226226+ assert {:error, :did_mismatch} == Flow.refresh(@server, session, refresh_opts(jwk))
227227+ end
228228+ end
229229+188230 defp exchange_opts(jwk, overrides \\ []) do
189231 Keyword.merge(
190232 [
···198240 pds_endpoint: @pds,
199241 now: ~U[2026-01-01 00:00:00Z]
200242 ],
243243+ overrides
244244+ )
245245+ end
246246+247247+ defp session_fixture(jwk) do
248248+ %Session{
249249+ did: @did,
250250+ access_token: "old-access",
251251+ refresh_token: "old-refresh",
252252+ dpop_key: jwk,
253253+ scope: "atproto",
254254+ issuer: "https://bsky.social",
255255+ pds_endpoint: @pds,
256256+ expires_at: ~U[2026-01-01 00:00:00Z]
257257+ }
258258+ end
259259+260260+ defp refresh_opts(jwk, overrides \\ []) do
261261+ Keyword.merge(
262262+ [client_id: "https://annot.at/client", client_jwk: jwk, now: ~U[2026-06-01 12:00:00Z]],
201263 overrides
202264 )
203265 end