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.

Finish atproto OAuth code

Avoided using Ecto.Changeset to make it easier to move this out at a later point

Johanna Larsson (Jun 13, 2026, 6:12 PM +0100) 92ff1f56 354d7d8e

+475
+121
lib/annot_at/atproto/oauth/server_metadata.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.ServerMetadata do 2 + @moduledoc """ 3 + Parses authorization server metadata (`/.well-known/oauth-authorization-server`) 4 + per the atproto OAuth profile. 5 + 6 + Validates the document's internal correctness only. The caller must also 7 + verify that the `issuer` matches the origin the document was fetched from. 8 + """ 9 + 10 + @enforce_keys [ 11 + :issuer, 12 + :authorization_endpoint, 13 + :token_endpoint, 14 + :par_endpoint, 15 + :scopes_supported 16 + ] 17 + defstruct @enforce_keys ++ [:revocation_endpoint] 18 + 19 + @type t :: %__MODULE__{ 20 + issuer: String.t(), 21 + authorization_endpoint: String.t(), 22 + token_endpoint: String.t(), 23 + par_endpoint: String.t(), 24 + revocation_endpoint: String.t() | nil, 25 + scopes_supported: [String.t()] 26 + } 27 + 28 + @doc """ 29 + Validates a decoded metadata document and extracts the fields we use. 30 + 31 + Checks every requirement the atproto OAuth spec places on authorization 32 + servers, except `none` in `token_endpoint_auth_methods_supported`, which 33 + only matters to public clients. 34 + 35 + Uses a manual validation layer instead of Ecto.Changeset because I want to 36 + split this out eventually. 37 + """ 38 + @spec parse(map()) :: {:ok, t()} | {:error, {:missing | :invalid, String.t()}} 39 + def parse(metadata) when is_map(metadata) do 40 + with :ok <- origin_url(metadata, "issuer"), 41 + :ok <- https_url(metadata, "authorization_endpoint"), 42 + :ok <- https_url(metadata, "token_endpoint"), 43 + :ok <- https_url(metadata, "pushed_authorization_request_endpoint"), 44 + :ok <- member(metadata, "response_types_supported", "code"), 45 + :ok <- member(metadata, "grant_types_supported", "authorization_code"), 46 + :ok <- member(metadata, "grant_types_supported", "refresh_token"), 47 + :ok <- member(metadata, "code_challenge_methods_supported", "S256"), 48 + :ok <- member(metadata, "token_endpoint_auth_methods_supported", "private_key_jwt"), 49 + :ok <- member(metadata, "token_endpoint_auth_signing_alg_values_supported", "ES256"), 50 + :ok <- member(metadata, "scopes_supported", "atproto"), 51 + :ok <- member(metadata, "dpop_signing_alg_values_supported", "ES256"), 52 + :ok <- flag(metadata, "require_pushed_authorization_requests"), 53 + :ok <- flag(metadata, "authorization_response_iss_parameter_supported"), 54 + :ok <- flag(metadata, "client_id_metadata_document_supported") do 55 + {:ok, 56 + %__MODULE__{ 57 + issuer: Map.fetch!(metadata, "issuer"), 58 + authorization_endpoint: Map.fetch!(metadata, "authorization_endpoint"), 59 + token_endpoint: Map.fetch!(metadata, "token_endpoint"), 60 + par_endpoint: Map.fetch!(metadata, "pushed_authorization_request_endpoint"), 61 + revocation_endpoint: Map.get(metadata, "revocation_endpoint"), 62 + scopes_supported: Map.fetch!(metadata, "scopes_supported") 63 + }} 64 + end 65 + end 66 + 67 + defp origin_url(metadata, field) do 68 + with :ok <- https_url(metadata, field) do 69 + url = Map.get(metadata, field) 70 + 71 + case URI.parse(url) do 72 + %URI{path: nil, query: nil, fragment: nil, userinfo: nil} -> 73 + :ok 74 + 75 + _ -> 76 + {:error, {:invalid, field}} 77 + end 78 + end 79 + end 80 + 81 + defp https_url(metadata, field) do 82 + case Map.get(metadata, field) do 83 + nil -> 84 + {:error, {:missing, field}} 85 + 86 + value when is_binary(value) -> 87 + case URI.parse(value) do 88 + %URI{scheme: "https", host: host} when is_binary(host) and host != "" -> :ok 89 + _ -> {:error, {:invalid, field}} 90 + end 91 + 92 + _ -> 93 + {:error, {:invalid, field}} 94 + end 95 + end 96 + 97 + defp member(metadata, field, value) do 98 + case Map.get(metadata, field) do 99 + nil -> 100 + {:error, {:missing, field}} 101 + 102 + list when is_list(list) -> 103 + if value in list do 104 + :ok 105 + else 106 + {:error, {:invalid, field}} 107 + end 108 + 109 + _ -> 110 + {:error, {:invalid, field}} 111 + end 112 + end 113 + 114 + defp flag(metadata, field) do 115 + case Map.get(metadata, field) do 116 + true -> :ok 117 + nil -> {:error, {:missing, field}} 118 + _ -> {:error, {:invalid, field}} 119 + end 120 + end 121 + end
+99
lib/annot_at/atproto/oauth/token_response.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.TokenResponse do 2 + @moduledoc """ 3 + Parses token endpoint responses per the atproto OAuth profile. 4 + """ 5 + 6 + @enforce_keys [ 7 + :access_token, 8 + :refresh_token, 9 + :expires_in, 10 + :scope, 11 + :sub 12 + ] 13 + defstruct @enforce_keys 14 + 15 + @type t :: %__MODULE__{ 16 + access_token: String.t(), 17 + refresh_token: String.t(), 18 + expires_in: pos_integer(), 19 + scope: String.t(), 20 + sub: String.t() 21 + } 22 + 23 + @doc """ 24 + Validates a decoded token response and extracts the fields we use. 25 + 26 + `refresh_token` and `expires_in` are optional in RFC 6749 but required 27 + here: background polling depends on refresh, and refresh scheduling 28 + depends on expiry, so a server omitting either fails at login instead 29 + of failing later. `token_type` must be `DPoP` (compared case-insensitively 30 + per RFC 6749) and carries no information once validated, so it is not 31 + kept on the struct. 32 + 33 + `sub` is only checked syntactically. The caller must verify it equals 34 + the DID resolved from the user's handle before trusting it. 35 + """ 36 + @spec parse(map()) :: {:ok, t()} | {:error, {:missing | :invalid, String.t()}} 37 + def parse(response) when is_map(response) do 38 + with :ok <- string(response, "access_token"), 39 + :ok <- string(response, "refresh_token"), 40 + :ok <- string(response, "scope"), 41 + :ok <- token_type(response), 42 + :ok <- expires_in(response), 43 + :ok <- did(response, "sub") do 44 + {:ok, 45 + %__MODULE__{ 46 + access_token: Map.fetch!(response, "access_token"), 47 + refresh_token: Map.fetch!(response, "refresh_token"), 48 + expires_in: Map.fetch!(response, "expires_in"), 49 + scope: Map.fetch!(response, "scope"), 50 + sub: Map.fetch!(response, "sub") 51 + }} 52 + end 53 + end 54 + 55 + defp string(response, field) do 56 + case Map.get(response, field) do 57 + nil -> {:error, {:missing, field}} 58 + value when is_binary(value) and value != "" -> :ok 59 + _ -> {:error, {:invalid, field}} 60 + end 61 + end 62 + 63 + defp token_type(response) do 64 + case Map.get(response, "token_type") do 65 + nil -> 66 + {:error, {:missing, "token_type"}} 67 + 68 + value when is_binary(value) -> 69 + if String.downcase(value) == "dpop" do 70 + :ok 71 + else 72 + {:error, {:invalid, "token_type"}} 73 + end 74 + 75 + _ -> 76 + {:error, {:invalid, "token_type"}} 77 + end 78 + end 79 + 80 + defp expires_in(response) do 81 + case Map.get(response, "expires_in") do 82 + nil -> {:error, {:missing, "expires_in"}} 83 + value when is_integer(value) and value > 0 -> :ok 84 + _ -> {:error, {:invalid, "expires_in"}} 85 + end 86 + end 87 + 88 + defp did(response, field) do 89 + with :ok <- string(response, field) do 90 + did = Map.fetch!(response, field) 91 + 92 + if String.starts_with?(did, "did:") do 93 + :ok 94 + else 95 + {:error, {:invalid, field}} 96 + end 97 + end 98 + end 99 + end
+74
test/annot_at/atproto/oauth/server_metadata_test.exs
··· 1 + defmodule AnnotAt.Atproto.OAuth.ServerMetadataTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.OAuth.ServerMetadata 5 + 6 + @fixture Path.expand("../../../support/fixtures/atproto/server_metadata.json", __DIR__) 7 + 8 + @required_fields [ 9 + "issuer", 10 + "authorization_endpoint", 11 + "token_endpoint", 12 + "pushed_authorization_request_endpoint", 13 + "response_types_supported", 14 + "grant_types_supported", 15 + "code_challenge_methods_supported", 16 + "token_endpoint_auth_methods_supported", 17 + "token_endpoint_auth_signing_alg_values_supported", 18 + "scopes_supported", 19 + "dpop_signing_alg_values_supported", 20 + "require_pushed_authorization_requests", 21 + "authorization_response_iss_parameter_supported", 22 + "client_id_metadata_document_supported" 23 + ] 24 + 25 + setup do 26 + metadata = 27 + @fixture 28 + |> File.read!() 29 + |> Jason.decode!() 30 + 31 + %{metadata: metadata} 32 + end 33 + 34 + test "parse/1 accepts the bsky.social document", %{metadata: metadata} do 35 + assert {:ok, parsed} = ServerMetadata.parse(metadata) 36 + 37 + assert parsed.issuer == "https://bsky.social" 38 + assert parsed.authorization_endpoint == "https://bsky.social/oauth/authorize" 39 + assert parsed.token_endpoint == "https://bsky.social/oauth/token" 40 + assert parsed.par_endpoint == "https://bsky.social/oauth/par" 41 + assert parsed.revocation_endpoint == "https://bsky.social/oauth/revoke" 42 + assert "atproto" in parsed.scopes_supported 43 + end 44 + 45 + test "parse/1 reports any missing required field", %{metadata: metadata} do 46 + for field <- @required_fields do 47 + assert ServerMetadata.parse(Map.delete(metadata, field)) == {:error, {:missing, field}} 48 + end 49 + end 50 + 51 + test "parse/1 rejects an issuer that is not a bare https origin", %{metadata: metadata} do 52 + for issuer <- [ 53 + "https://bsky.social/", 54 + "https://bsky.social/oauth", 55 + "https://bsky.social?x=1", 56 + "http://bsky.social" 57 + ] do 58 + assert ServerMetadata.parse(%{metadata | "issuer" => issuer}) == 59 + {:error, {:invalid, "issuer"}} 60 + end 61 + end 62 + 63 + test "parse/1 rejects servers missing required capabilities", %{metadata: metadata} do 64 + cases = [ 65 + {"code_challenge_methods_supported", ["plain"]}, 66 + {"dpop_signing_alg_values_supported", ["RS256"]}, 67 + {"require_pushed_authorization_requests", false} 68 + ] 69 + 70 + for {field, value} <- cases do 71 + assert ServerMetadata.parse(%{metadata | field => value}) == {:error, {:invalid, field}} 72 + end 73 + end 74 + end
+51
test/annot_at/atproto/oauth/token_response_test.exs
··· 1 + defmodule AnnotAt.Atproto.OAuth.TokenResponseTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.OAuth.TokenResponse 5 + 6 + @fixture Path.expand("../../../support/fixtures/atproto/token_response.json", __DIR__) 7 + 8 + setup do 9 + response = 10 + @fixture 11 + |> File.read!() 12 + |> Jason.decode!() 13 + 14 + %{response: response} 15 + end 16 + 17 + test "parse/1 accepts a DPoP token response", %{response: response} do 18 + assert {:ok, parsed} = TokenResponse.parse(response) 19 + 20 + assert parsed.access_token == 21 + "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiJ9.fake-payload.fake-signature" 22 + 23 + assert parsed.refresh_token == "ref-3C2EzDmzzkrcA9rerRmYeg5wBPCRZdnGRkRKUOvbLq" 24 + assert parsed.expires_in == 3599 25 + assert parsed.scope == "atproto" 26 + assert parsed.sub == "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 27 + end 28 + 29 + test "parse/1 reports any missing required field", %{response: response} do 30 + for field <- ["access_token", "token_type", "refresh_token", "scope", "expires_in", "sub"] do 31 + assert TokenResponse.parse(Map.delete(response, field)) == {:error, {:missing, field}} 32 + end 33 + end 34 + 35 + test "parse/1 rejects non-DPoP token types", %{response: response} do 36 + assert TokenResponse.parse(%{response | "token_type" => "Bearer"}) == 37 + {:error, {:invalid, "token_type"}} 38 + 39 + assert {:ok, _} = TokenResponse.parse(%{response | "token_type" => "dpop"}) 40 + end 41 + 42 + test "parse/1 rejects a sub that is not a DID", %{response: response} do 43 + assert TokenResponse.parse(%{response | "sub" => "alice.bsky.social"}) == 44 + {:error, {:invalid, "sub"}} 45 + end 46 + 47 + test "parse/1 rejects a non-integer expires_in", %{response: response} do 48 + assert TokenResponse.parse(%{response | "expires_in" => "3599"}) == 49 + {:error, {:invalid, "expires_in"}} 50 + end 51 + end
+26
test/support/fixtures/atproto/did_document.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/multikey/v1", 5 + "https://w3id.org/security/suites/secp256k1-2019/v1" 6 + ], 7 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 8 + "alsoKnownAs": [ 9 + "at://atproto.com" 10 + ], 11 + "verificationMethod": [ 12 + { 13 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto", 14 + "type": "Multikey", 15 + "controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 16 + "publicKeyMultibase": "zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w" 17 + } 18 + ], 19 + "service": [ 20 + { 21 + "id": "#atproto_pds", 22 + "type": "AtprotoPersonalDataServer", 23 + "serviceEndpoint": "https://enoki.us-east.host.bsky.network" 24 + } 25 + ] 26 + }
+96
test/support/fixtures/atproto/server_metadata.json
··· 1 + { 2 + "issuer": "https://bsky.social", 3 + "request_parameter_supported": true, 4 + "request_uri_parameter_supported": true, 5 + "require_request_uri_registration": true, 6 + "scopes_supported": [ 7 + "atproto", 8 + "transition:email", 9 + "transition:generic", 10 + "transition:chat.bsky" 11 + ], 12 + "subject_types_supported": [ 13 + "public" 14 + ], 15 + "response_types_supported": [ 16 + "code" 17 + ], 18 + "response_modes_supported": [ 19 + "query", 20 + "fragment", 21 + "form_post" 22 + ], 23 + "grant_types_supported": [ 24 + "authorization_code", 25 + "refresh_token" 26 + ], 27 + "code_challenge_methods_supported": [ 28 + "S256" 29 + ], 30 + "ui_locales_supported": [ 31 + "en-US" 32 + ], 33 + "display_values_supported": [ 34 + "page", 35 + "popup", 36 + "touch" 37 + ], 38 + "request_object_signing_alg_values_supported": [ 39 + "RS256", 40 + "RS384", 41 + "RS512", 42 + "PS256", 43 + "PS384", 44 + "PS512", 45 + "ES256", 46 + "ES256K", 47 + "ES384", 48 + "ES512", 49 + "none" 50 + ], 51 + "authorization_response_iss_parameter_supported": true, 52 + "request_object_encryption_alg_values_supported": [], 53 + "request_object_encryption_enc_values_supported": [], 54 + "jwks_uri": "https://bsky.social/oauth/jwks", 55 + "authorization_endpoint": "https://bsky.social/oauth/authorize", 56 + "token_endpoint": "https://bsky.social/oauth/token", 57 + "token_endpoint_auth_methods_supported": [ 58 + "none", 59 + "private_key_jwt" 60 + ], 61 + "token_endpoint_auth_signing_alg_values_supported": [ 62 + "RS256", 63 + "RS384", 64 + "RS512", 65 + "PS256", 66 + "PS384", 67 + "PS512", 68 + "ES256", 69 + "ES256K", 70 + "ES384", 71 + "ES512" 72 + ], 73 + "revocation_endpoint": "https://bsky.social/oauth/revoke", 74 + "pushed_authorization_request_endpoint": "https://bsky.social/oauth/par", 75 + "require_pushed_authorization_requests": true, 76 + "dpop_signing_alg_values_supported": [ 77 + "RS256", 78 + "RS384", 79 + "RS512", 80 + "PS256", 81 + "PS384", 82 + "PS512", 83 + "ES256", 84 + "ES256K", 85 + "ES384", 86 + "ES512" 87 + ], 88 + "client_id_metadata_document_supported": true, 89 + "prompt_values_supported": [ 90 + "none", 91 + "login", 92 + "consent", 93 + "select_account", 94 + "create" 95 + ] 96 + }
+8
test/support/fixtures/atproto/token_response.json
··· 1 + { 2 + "access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiJ9.fake-payload.fake-signature", 3 + "token_type": "DPoP", 4 + "refresh_token": "ref-3C2EzDmzzkrcA9rerRmYeg5wBPCRZdnGRkRKUOvbLq", 5 + "scope": "atproto", 6 + "expires_in": 3599, 7 + "sub": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 8 + }