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 atproto OAuth flow

Covers Discovery, PAR, session, etc.

```
iex(8)> {:ok, server} = AnnotAt.Atproto.OAuth.Discovery.discover(id.pds_endpoint)
{:ok,
%AnnotAt.Atproto.OAuth.ServerMetadata{
issuer: "https://bsky.social",
authorization_endpoint: "https://bsky.social/oauth/authorize",
token_endpoint: "https://bsky.social/oauth/token",
par_endpoint: "https://bsky.social/oauth/par",
scopes_supported: ["atproto", "transition:email", "transition:generic",
"transition:chat.bsky"],
revocation_endpoint: "https://bsky.social/oauth/revoke"
}}
```

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

+603
+30
lib/annot_at/atproto/http.ex
··· 34 34 get_body(url) 35 35 end 36 36 37 + @doc """ 38 + POSTs `form` as `application/x-www-form-urlencoded` with `headers`, returning 39 + the raw status, body, and response headers. 40 + 41 + Unlike `get_json/1` this neither treats non-2xx as an error nor decodes the 42 + body: atproto OAuth endpoints return meaningful JSON and a `DPoP-Nonce` header 43 + on 4xx responses, which the caller must inspect to drive the none retry. 44 + """ 45 + @spec post_form(String.t(), keyword() | map(), [{String.t(), String.t()}]) :: 46 + {:ok, 47 + %{status: pos_integer(), body: binary(), headers: %{optional(binary()) => [binary()]}}} 48 + | {:error, {:transport, term()}} 49 + def post_form(url, form, headers \\ []) do 50 + options = [ 51 + url: url, 52 + form: form, 53 + headers: headers, 54 + decode_body: false, 55 + receive_timeout: @receive_timeout 56 + ] 57 + 58 + case Req.post(options) do 59 + {:ok, %Req.Response{status: status, body: body, headers: resp_headers}} -> 60 + {:ok, %{status: status, body: body, headers: resp_headers}} 61 + 62 + {:error, reason} -> 63 + {:error, {:transport, reason}} 64 + end 65 + end 66 + 37 67 defp get_body(url) when is_binary(url) do 38 68 case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 39 69 {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body}
+56
lib/annot_at/atproto/oauth/discovery.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.Discovery do 2 + @moduledoc """ 3 + Discovers the authorization server for a PDS and validates the binding 4 + between them, per the atproto OAuth profile. 5 + 6 + The PDS publishes `/.well-known/oauth-protected-resource` naming its 7 + authorization server, that server's metadata `issuer` must in turn match 8 + the URL it was discovered at. Both directions are checked before the 9 + metadata is trusted. 10 + """ 11 + 12 + alias AnnotAt.Atproto.HTTP 13 + alias AnnotAt.Atproto.OAuth.ServerMetadata 14 + 15 + @protected_resource_path "/.well-known/oauth-protected-resource" 16 + @auth_server_path "/.well-known/oauth-authorization-server" 17 + 18 + @doc """ 19 + Resolves a PDS endpoint to its authorization server metadata. 20 + """ 21 + @spec discover(String.t()) :: 22 + {:ok, ServerMetadata.t()} 23 + | {:error, 24 + :resource_mismatch 25 + | :no_authorization_server 26 + | :issuer_mismatch 27 + | {:http_status, pos_integer()} 28 + | {:transport, term()} 29 + | :invalid_json 30 + | {:missing, String.t()} 31 + | {:invalid, String.t()}} 32 + def discover(pds_endpoint) when is_binary(pds_endpoint) do 33 + with {:ok, resource} <- HTTP.get_json(pds_endpoint <> @protected_resource_path), 34 + {:ok, issuer} <- authorization_server(resource, pds_endpoint), 35 + {:ok, metadata} <- HTTP.get_json(issuer <> @auth_server_path), 36 + {:ok, server} <- ServerMetadata.parse(metadata), 37 + :ok <- verify_issuer(server, issuer) do 38 + {:ok, server} 39 + end 40 + end 41 + 42 + defp authorization_server(resource, pds_endpoint) do 43 + with :ok <- verify_resource(resource, pds_endpoint) do 44 + case Map.get(resource, "authorization_servers") do 45 + [issuer | _] when is_binary(issuer) -> {:ok, issuer} 46 + _ -> {:error, :no_authorization_server} 47 + end 48 + end 49 + end 50 + 51 + defp verify_resource(%{"resource" => resource}, resource), do: :ok 52 + defp verify_resource(_resource, _pds_endpoint), do: {:error, :resource_mismatch} 53 + 54 + defp verify_issuer(%ServerMetadata{issuer: issuer}, issuer), do: :ok 55 + defp verify_issuer(_server, _issuer), do: {:error, :issuer_mismatch} 56 + end
+204
lib/annot_at/atproto/oauth/flow.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.Flow do 2 + @moduledoc """ 3 + Drives the atproto OAuth flow against an authorization server: pushed 4 + authorization requests, the authorization redirect, token exchange, and 5 + refresh. 6 + 7 + Requests to the authorization server are DPoP-bound. The server requires a 8 + fresh DPoP nonce, so each request is attempted once without a nonce and 9 + retried once with the none the server returns. The client assertion and 10 + DPoP proof are regenreated on the retry, so neither `jti` is reused. 11 + """ 12 + 13 + alias AnnotAt.Atproto.HTTP 14 + alias AnnotAt.Atproto.OAuth.ClientAssertion 15 + alias AnnotAt.Atproto.OAuth.DPoP 16 + alias AnnotAt.Atproto.OAuth.ServerMetadata 17 + alias AnnotAt.Atproto.OAuth.Session 18 + alias AnnotAt.Atproto.OAuth.TokenResponse 19 + 20 + @type request_error :: 21 + {:transport, term()} 22 + | :invalid_json 23 + | :missing_dpop_nonce 24 + | :unexpected_response 25 + | {:oauth_error, String.t()} 26 + 27 + @doc """ 28 + Performs a pushed authorization request, returning the `request_uri`. 29 + 30 + ## Required options 31 + - `:client_id`, `:client_jwk` - the confidential client's id and signing key 32 + - `:redirect_uri`, `:scope`, `:state`, `:code_challenge` - auth params 33 + - `:dpop_key` - the per-session DPoP key 34 + 35 + ## Optional 36 + - `:login_hint` - the user's handle or DID 37 + """ 38 + 39 + @spec par(ServerMetadata.t(), keyword()) :: 40 + {:ok, String.t()} | {:error, request_error() | :invalid_par_response} 41 + def par(%ServerMetadata{} = server, opts) do 42 + client_id = Keyword.fetch!(opts, :client_id) 43 + client_jwk = Keyword.fetch!(opts, :client_jwk) 44 + redirect_uri = Keyword.fetch!(opts, :redirect_uri) 45 + scope = Keyword.fetch!(opts, :scope) 46 + state = Keyword.fetch!(opts, :state) 47 + code_challenge = Keyword.fetch!(opts, :code_challenge) 48 + dpop_key = Keyword.fetch!(opts, :dpop_key) 49 + login_hint = Keyword.get(opts, :login_hint) 50 + 51 + build_form = fn -> 52 + maybe_put( 53 + [ 54 + response_type: "code", 55 + client_id: client_id, 56 + redirect_uri: redirect_uri, 57 + scope: scope, 58 + state: state, 59 + code_challenge: code_challenge, 60 + code_challenge_method: "S256", 61 + client_assertion_type: ClientAssertion.assertion_type(), 62 + client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) 63 + ], 64 + :login_hint, 65 + login_hint 66 + ) 67 + end 68 + 69 + with {:ok, body} <- dpop_request(server.par_endpoint, build_form, dpop_key) do 70 + case body do 71 + %{"request_uri" => request_uri} when is_binary(request_uri) -> {:ok, request_uri} 72 + _ -> {:error, :invalid_par_response} 73 + end 74 + end 75 + end 76 + 77 + @doc """ 78 + Builds the authorization redirect URL from a PAR `request_uri`. 79 + 80 + The browser is sent here, per atproto only `client_id` and `request_uri` 81 + travel in the URL, since the real parameters were pushed durig PAR. 82 + """ 83 + @spec authorization_url(ServerMetadata.t(), String.t(), String.t()) :: String.t() 84 + def authorization_url(%ServerMetadata{} = server, client_id, request_uri) do 85 + query = URI.encode_query(client_id: client_id, request_uri: request_uri) 86 + server.authorization_endpoint <> "?" <> query 87 + end 88 + 89 + @doc """ 90 + Exchanges an authorization code for a session. 91 + 92 + Verifies the token response `sub` matches `expected_did` (the DID resolved 93 + before login) before building the session. The callback's `state` and `iss` 94 + are validated upstream, before this is called. 95 + 96 + ## Required options 97 + - `:client_id`, `:client_jwk` - the confidential client's id and signing key 98 + - `:redirect_uri` - must match the value sent during PAR 99 + - `:code`, `:core_verifier` - the authorization code and PKCE verifier 100 + - `:dpop_key` - the per-session DPoP key 101 + - `:expected_did` - the DID the `sub` must match 102 + - `:pds_endpoint` - the resource server, stored on the session 103 + 104 + ## Optional 105 + - `:now` - base time for `expires_at` (defaults to the current time) 106 + """ 107 + @spec exchange_code(ServerMetadata.t(), keyword()) :: 108 + {:ok, Session.t()} 109 + | {:error, 110 + request_error() | {:missing, String.t()} | {:invalid, String.t()} | :did_mismatch} 111 + def exchange_code(%ServerMetadata{} = server, opts) do 112 + client_id = Keyword.fetch!(opts, :client_id) 113 + client_jwk = Keyword.fetch!(opts, :client_jwk) 114 + redirect_uri = Keyword.fetch!(opts, :redirect_uri) 115 + code = Keyword.fetch!(opts, :code) 116 + code_verifier = Keyword.fetch!(opts, :code_verifier) 117 + dpop_key = Keyword.fetch!(opts, :dpop_key) 118 + expected_did = Keyword.fetch!(opts, :expected_did) 119 + pds_endpoint = Keyword.fetch!(opts, :pds_endpoint) 120 + now = Keyword.get_lazy(opts, :now, &DateTime.utc_now/0) 121 + 122 + build_form = fn -> 123 + [ 124 + grant_type: "authorization_code", 125 + code: code, 126 + redirect_uri: redirect_uri, 127 + code_verifier: code_verifier, 128 + client_assertion_type: ClientAssertion.assertion_type(), 129 + client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) 130 + ] 131 + end 132 + 133 + with {:ok, body} <- dpop_request(server.token_endpoint, build_form, dpop_key), 134 + {:ok, tokens} <- TokenResponse.parse(body), 135 + :ok <- verify_sub(tokens.sub, expected_did) do 136 + {:ok, build_session(tokens, server, pds_endpoint, dpop_key, now)} 137 + end 138 + end 139 + 140 + defp dpop_request(url, build_form, dpop_key, nonce \\ nil) do 141 + proof = DPoP.proof(dpop_key, "POST", url, nonce: nonce) 142 + 143 + with {:ok, %{status: status, body: raw, headers: headers}} <- 144 + HTTP.post_form(url, build_form.(), [{"dpop", proof}]), 145 + {:ok, body} <- decode_json(raw) do 146 + cond do 147 + status in 200..299 -> 148 + {:ok, body} 149 + 150 + retry_nonce?(body, nonce) -> 151 + retry_with_nonce(url, build_form, dpop_key, headers) 152 + 153 + Map.has_key?(body, "error") -> 154 + {:error, {:oauth_error, Map.fetch!(body, "error")}} 155 + 156 + true -> 157 + {:error, :unexpected_response} 158 + end 159 + end 160 + end 161 + 162 + defp retry_with_nonce(url, build_form, dpop_key, headers) do 163 + case nonce_header(headers) do 164 + nil -> {:error, :missing_dpop_nonce} 165 + nonce -> dpop_request(url, build_form, dpop_key, nonce) 166 + end 167 + end 168 + 169 + defp retry_nonce?(%{"error" => "use_dpop_nonce"}, nil), do: true 170 + defp retry_nonce?(_body, _nonce), do: false 171 + 172 + defp nonce_header(headers) do 173 + case Map.get(headers, "dpop-nonce") do 174 + [nonce | _] -> nonce 175 + _ -> nil 176 + end 177 + end 178 + 179 + defp decode_json(raw) do 180 + case Jason.decode(raw) do 181 + {:ok, json} -> {:ok, json} 182 + _ -> {:error, :invalid_json} 183 + end 184 + end 185 + 186 + defp maybe_put(map, _key, nil), do: map 187 + defp maybe_put(map, key, value), do: Keyword.put(map, key, value) 188 + 189 + defp verify_sub(sub, sub), do: :ok 190 + defp verify_sub(_sub, _expected), do: {:error, :did_mismatch} 191 + 192 + defp build_session(tokens, server, pds_endpoint, dpop_key, now) do 193 + %Session{ 194 + did: tokens.sub, 195 + access_token: tokens.access_token, 196 + refresh_token: tokens.refresh_token, 197 + dpop_key: dpop_key, 198 + scope: tokens.scope, 199 + issuer: server.issuer, 200 + pds_endpoint: pds_endpoint, 201 + expires_at: DateTime.add(now, tokens.expires_in, :second) 202 + } 203 + end 204 + end
+34
lib/annot_at/atproto/oauth/session.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.Session do 2 + @moduledoc """ 3 + An established atproto OAuth session: the credentials and binding needed to 4 + make authenticated PDS requests and to refresh the access token. 5 + 6 + The `dpop_key` is the per-session private key every request is signed with. 7 + The access and refresh tokens are bound to it. `issue` identifies the 8 + authorization server (for refresh and re-discovery), `pds_endpoint` is where 9 + authenticated XPRC calls go. 10 + """ 11 + 12 + @enforce_keys [ 13 + :did, 14 + :access_token, 15 + :refresh_token, 16 + :dpop_key, 17 + :scope, 18 + :issuer, 19 + :pds_endpoint, 20 + :expires_at 21 + ] 22 + defstruct @enforce_keys 23 + 24 + @type t :: %__MODULE__{ 25 + did: String.t(), 26 + access_token: String.t(), 27 + refresh_token: String.t(), 28 + dpop_key: JOSE.JWK.t(), 29 + scope: String.t(), 30 + issuer: String.t(), 31 + pds_endpoint: String.t(), 32 + expires_at: DateTime.t() 33 + } 34 + end
+64
test/annot_at/atproto/oauth/discovery_test.exs
··· 1 + defmodule AnnotAt.Atproto.OAuth.DiscoveryTest do 2 + use ExUnit.Case, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Atproto.HTTP 6 + alias AnnotAt.Atproto.OAuth.Discovery 7 + alias AnnotAt.Atproto.OAuth.ServerMetadata 8 + 9 + @pds "https://enoki.us-east.host.bsky.network" 10 + @issuer "https://bsky.social" 11 + 12 + @protected_resource "../../../support/fixtures/atproto/protected_resource.json" 13 + |> Path.expand(__DIR__) 14 + |> File.read!() 15 + |> Jason.decode!() 16 + 17 + @server_metadata "../../../support/fixtures/atproto/server_metadata.json" 18 + |> Path.expand(__DIR__) 19 + |> File.read!() 20 + |> Jason.decode!() 21 + 22 + test "discovers and validates the authorization server" do 23 + pr_url = @pds <> "/.well-known/oauth-protected-resource" 24 + as_url = @issuer <> "/.well-known/oauth-authorization-server" 25 + 26 + expect(HTTP, :get_json, fn ^pr_url -> {:ok, @protected_resource} end) 27 + expect(HTTP, :get_json, fn ^as_url -> {:ok, @server_metadata} end) 28 + 29 + assert {:ok, %ServerMetadata{} = server} = Discovery.discover(@pds) 30 + assert server.issuer == @issuer 31 + assert server.par_endpoint == @issuer <> "/oauth/par" 32 + assert server.token_endpoint == @issuer <> "/oauth/token" 33 + end 34 + 35 + test "rejects a protected resource whose resource does not match the PDS" do 36 + resource = %{"resource" => "https://attacker.example", "authorization_servers" => [@issuer]} 37 + expect(HTTP, :get_json, fn _ -> {:ok, resource} end) 38 + 39 + assert {:error, :resource_mismatch} == Discovery.discover(@pds) 40 + end 41 + 42 + test "rejects a protected resource with no authorization server" do 43 + resource = %{"resource" => @pds, "authorization_servers" => []} 44 + expect(HTTP, :get_json, fn _ -> {:ok, resource} end) 45 + 46 + assert {:error, :no_authorization_server} == Discovery.discover(@pds) 47 + end 48 + 49 + test "rejects when the server metadata issuer does not match the discovered URL" do 50 + resource = %{"resource" => @pds, "authorization_servers" => ["https://attacker.example"]} 51 + as_url = "https://attacker.example/.well-known/oauth-authorization-server" 52 + 53 + expect(HTTP, :get_json, fn _ -> {:ok, resource} end) 54 + expect(HTTP, :get_json, fn ^as_url -> {:ok, @server_metadata} end) 55 + 56 + assert {:error, :issuer_mismatch} = Discovery.discover(@pds) 57 + end 58 + 59 + test "propagates a transport error from the protected resource fetch" do 60 + expect(HTTP, :get_json, fn _ -> {:error, {:transport, :econnrefused}} end) 61 + 62 + assert {:error, {:transport, :econnrefused}} == Discovery.discover(@pds) 63 + end 64 + end
+204
test/annot_at/atproto/oauth/flow_test.exs
··· 1 + defmodule AnnotAt.Atproto.OAuth.FlowTest do 2 + use ExUnit.Case, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Atproto.HTTP 6 + alias AnnotAt.Atproto.OAuth.ClientAssertion 7 + alias AnnotAt.Atproto.OAuth.Flow 8 + alias AnnotAt.Atproto.OAuth.ServerMetadata 9 + 10 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 11 + @pds "https://enoki.us-east.host.bsky.network" 12 + 13 + @token_response "../../../support/fixtures/atproto/token_response.json" 14 + |> Path.expand(__DIR__) 15 + |> File.read!() 16 + 17 + @server %ServerMetadata{ 18 + issuer: "https://bsky.social", 19 + authorization_endpoint: "https://bsky.social/oauth/authorize", 20 + token_endpoint: "https://bsky.social/oauth/token", 21 + par_endpoint: "https://bsky.social/oauth/par", 22 + scopes_supported: ["atproto"] 23 + } 24 + 25 + setup do 26 + jwk = 27 + "../../../support/fixtures/atproto/es256_jwk.json" 28 + |> Path.expand(__DIR__) 29 + |> File.read!() 30 + |> Jason.decode!() 31 + |> JOSE.JWK.from() 32 + 33 + opts = [ 34 + client_id: "https://annot.at/client", 35 + client_jwk: jwk, 36 + redirect_uri: "https://annot.at/callback", 37 + scope: "atproto", 38 + state: "state-123", 39 + code_challenge: "challenge-123", 40 + dpop_key: jwk, 41 + login_hint: "jola.dev" 42 + ] 43 + 44 + [opts: opts, jwk: jwk] 45 + end 46 + 47 + describe "Flow.par/2" do 48 + test "sends the required PAR parameters", %{opts: opts} do 49 + expect(HTTP, :post_form, fn url, form, _headers -> 50 + assert "https://bsky.social/oauth/par" = url 51 + assert "code" == form[:response_type] 52 + assert "https://annot.at/client" == form[:client_id] 53 + assert "https://annot.at/callback" == form[:redirect_uri] 54 + assert "atproto" == form[:scope] 55 + assert "state-123" == form[:state] 56 + assert "challenge-123" == form[:code_challenge] 57 + assert "S256" == form[:code_challenge_method] 58 + assert ClientAssertion.assertion_type() == form[:client_assertion_type] 59 + assert is_binary(form[:client_assertion]) 60 + assert "jola.dev" == form[:login_hint] 61 + 62 + {:ok, 63 + %{status: 201, body: ~s({"request_uri":"urn:req:abc","expires_in":60}), headers: %{}}} 64 + end) 65 + 66 + assert {:ok, "urn:req:abc"} = Flow.par(@server, opts) 67 + end 68 + 69 + test "retries with the server nonce and returns the request_uri", %{opts: opts} do 70 + expect(HTTP, :post_form, fn _url, _form, _headers -> 71 + {:ok, 72 + %{ 73 + status: 400, 74 + body: ~s({"error":"use_dpop_nonce"}), 75 + headers: %{"dpop-nonce" => ["nonce-xyz"]} 76 + }} 77 + end) 78 + 79 + expect(HTTP, :post_form, fn _url, _form, headers -> 80 + {"dpop", proof} = List.keyfind(headers, "dpop", 0) 81 + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) 82 + assert claims["nonce"] == "nonce-xyz" 83 + 84 + {:ok, %{status: 201, body: ~s({"request_uri":"urn:req:retry"}), headers: %{}}} 85 + end) 86 + 87 + assert {:ok, "urn:req:retry"} = Flow.par(@server, opts) 88 + end 89 + 90 + test "returns :missing_dpop_nonce when the nonce error lacks the header", %{opts: opts} do 91 + expect(HTTP, :post_form, fn _url, _from, _headers -> 92 + {:ok, %{status: 400, body: ~s({"error":"use_dpop_nonce"}), headers: %{}}} 93 + end) 94 + 95 + assert {:error, :missing_dpop_nonce} = Flow.par(@server, opts) 96 + end 97 + 98 + test "surfaces an OAuth error from the server", %{opts: opts} do 99 + expect(HTTP, :post_form, fn _url, _form, _headers -> 100 + {:ok, %{status: 400, body: ~s({"error":"invalid_client"}), headers: %{}}} 101 + end) 102 + 103 + assert Flow.par(@server, opts) == {:error, {:oauth_error, "invalid_client"}} 104 + end 105 + 106 + test "returns :invalid_par_response when a success body has no request_uri", %{opts: opts} do 107 + expect(HTTP, :post_form, fn _url, _form, _headers -> 108 + {:ok, %{status: 200, body: ~s({"expires_in":60}), headers: %{}}} 109 + end) 110 + 111 + assert Flow.par(@server, opts) == {:error, :invalid_par_response} 112 + end 113 + end 114 + 115 + describe "PAR.authorization_url/3" do 116 + test "builds the authorization redirect URL from a request_uri" do 117 + url = Flow.authorization_url(@server, "https://annot.at/client", "urn:req:abc") 118 + uri = URI.parse(url) 119 + 120 + assert "https://bsky.social/oauth/authorize" == "#{uri.scheme}://#{uri.host}#{uri.path}" 121 + 122 + assert %{"client_id" => "https://annot.at/client", "request_uri" => "urn:req:abc"} == 123 + URI.decode_query(uri.query) 124 + end 125 + end 126 + 127 + describe "PAR.exchange_code/2" do 128 + test "exchanges the code for a session", %{jwk: jwk} do 129 + expect(HTTP, :post_form, fn _url, _form, _headers -> 130 + {:ok, %{status: 200, body: @token_response, headers: %{}}} 131 + end) 132 + 133 + assert {:ok, session} = Flow.exchange_code(@server, exchange_opts(jwk)) 134 + assert @did == session.did 135 + assert "atproto" == session.scope 136 + assert "https://bsky.social" == session.issuer 137 + assert @pds == session.pds_endpoint 138 + assert ~U[2026-01-01 00:59:59Z] == session.expires_at 139 + assert jwk == session.dpop_key 140 + end 141 + 142 + test "sends the token exchange parameters", %{jwk: jwk} do 143 + expect(HTTP, :post_form, fn url, form, _headers -> 144 + assert "https://bsky.social/oauth/token" == url 145 + assert "authorization_code" == form[:grant_type] 146 + assert "auth-code" == form[:code] 147 + assert "https://annot.at/callback" == form[:redirect_uri] 148 + assert "verifier-123" == form[:code_verifier] 149 + assert ClientAssertion.assertion_type() == form[:client_assertion_type] 150 + assert is_binary(form[:client_assertion]) 151 + 152 + {:ok, %{status: 200, body: @token_response, headers: %{}}} 153 + end) 154 + 155 + assert {:ok, _session} = Flow.exchange_code(@server, exchange_opts(jwk)) 156 + end 157 + 158 + test "rejects a token whose sub does not match the expected DID", %{jwk: jwk} do 159 + expect(HTTP, :post_form, fn _url, _form, _headers -> 160 + {:ok, %{status: 200, body: @token_response, headers: %{}}} 161 + end) 162 + 163 + assert {:error, :did_mismatch} == 164 + Flow.exchange_code( 165 + @server, 166 + exchange_opts(jwk, expected_did: "did:plc:someoneelse") 167 + ) 168 + end 169 + 170 + test "propagates a token response parse error", %{jwk: jwk} do 171 + body = 172 + Jason.encode!(%{ 173 + "token_type" => "DPoP", 174 + "access_token" => "x", 175 + "refresh_token" => "y", 176 + "expires_in" => 1, 177 + "scope" => "atproto" 178 + }) 179 + 180 + expect(HTTP, :post_form, fn _url, _form, _headers -> 181 + {:ok, %{status: 200, body: body, headers: %{}}} 182 + end) 183 + 184 + assert {:error, {:missing, "sub"}} == Flow.exchange_code(@server, exchange_opts(jwk)) 185 + end 186 + end 187 + 188 + defp exchange_opts(jwk, overrides \\ []) do 189 + Keyword.merge( 190 + [ 191 + client_id: "https://annot.at/client", 192 + client_jwk: jwk, 193 + redirect_uri: "https://annot.at/callback", 194 + code: "auth-code", 195 + code_verifier: "verifier-123", 196 + dpop_key: jwk, 197 + expected_did: @did, 198 + pds_endpoint: @pds, 199 + now: ~U[2026-01-01 00:00:00Z] 200 + ], 201 + overrides 202 + ) 203 + end 204 + end
+11
test/support/fixtures/atproto/protected_resource.json
··· 1 + { 2 + "resource": "https://enoki.us-east.host.bsky.network", 3 + "authorization_servers": [ 4 + "https://bsky.social" 5 + ], 6 + "scopes_supported": [], 7 + "bearer_methods_supported": [ 8 + "header" 9 + ], 10 + "resource_documentation": "https://atproto.com" 11 + }