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.

E2E Bluesky login works

Hooks it all up and I've verified it works end 2 end. Sick.

Johanna Larsson (Jun 15, 2026, 9:39 PM +0100) 7888282f bce0c7c8

+875 -202
+5
config/config.exs
··· 60 60 # Use Jason for JSON parsing in Phoenix 61 61 config :phoenix, :json_library, Jason 62 62 63 + config :annot_at, AnnotAt.Atproto.OAuth.Config, 64 + scope: "atproto", 65 + signing_jwk: 66 + ~s({"crv":"P-256","d":"h0MvqcXLcKqWZFnqUCAuc6Bmt6gGzj5F5sFOCaUD4Jw","kty":"EC","x":"u0_K5EPDBIlGVp_rUUKucDviS-Owhiv4jnpMCeI7ojY","y":"v_XGrdUIww1wsRA7TUqMWIAJXmi2V8mnoF24Vg5OkvQ"}) 67 + 63 68 # Import environment specific config. This must remain at the bottom 64 69 # of this file so it overrides the configuration defined above. 65 70 import_config "#{config_env()}.exs"
+1
config/dev.exs
··· 17 17 # watchers to your application. For example, we can use it 18 18 # to bundle .js and .css sources. 19 19 config :annot_at, AnnotAtWeb.Endpoint, 20 + # url: [host: "tlaloc.haddock-carp.ts.net", scheme: "https", port: 443], 20 21 # Binding to loopback ipv4 address prevents access from other machines. 21 22 # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 23 http: [ip: {127, 0, 0, 1}],
+3
config/runtime.exs
··· 55 55 56 56 host = System.get_env("PHX_HOST") || "example.com" 57 57 58 + config :annot_at, AnnotAt.Atproto.OAuth.Config, 59 + signing_jwk: System.fetch_env!("ATPROTO_CLIENT_PRIVATE_JWK") 60 + 58 61 config :annot_at, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 59 62 60 63 config :annot_at, AnnotAtWeb.Endpoint,
+39
lib/annot_at/accounts.ex
··· 6 6 import Ecto.Query, only: [from: 2] 7 7 8 8 alias AnnotAt.Accounts.AtprotoSession 9 + alias AnnotAt.Accounts.OAuthLoginRequest 9 10 alias AnnotAt.Accounts.User 10 11 alias AnnotAt.Repo 11 12 12 13 @spec get_user!(integer()) :: User.t() 13 14 def get_user!(id) do 14 15 Repo.get!(User, id) 16 + end 17 + 18 + def get_user(id) do 19 + Repo.get(User, id) 15 20 end 16 21 17 22 @spec get_user_by_did(String.t()) :: User.t() | nil ··· 68 73 conflict_target: :user_id, 69 74 returning: true 70 75 ) 76 + end 77 + 78 + @doc """ 79 + Stores an in-progress login, keyed by its OAuth `state`. 80 + """ 81 + @spec create_login_request(map()) :: {:ok, OAuthLoginRequest.t()} | {:error, Ecto.Changeset.t()} 82 + def create_login_request(request_attrs) do 83 + Repo.insert(OAuthLoginRequest.changeset(%OAuthLoginRequest{}, request_attrs)) 84 + end 85 + 86 + @doc """ 87 + Atomically fetches and deletes the login request for `state` or `nil`. 88 + 89 + Single use: a `state` is consumed exactly once, even under concurrent 90 + callbacks, since the delete and read happen in one statement. 91 + """ 92 + @spec take_login_request(String.t()) :: OAuthLoginRequest.t() | nil 93 + def take_login_request(state) do 94 + query = from(r in OAuthLoginRequest, where: r.state == ^state, select: r) 95 + 96 + case Repo.delete_all(query) do 97 + {1, [request]} -> request 98 + {0, []} -> nil 99 + end 100 + end 101 + 102 + @doc """ 103 + Deletes login requests older than `max_age_seconds` (abandoned logins.) 104 + """ 105 + @spec delete_expired_login_requests(pos_integer()) :: non_neg_integer() 106 + def delete_expired_login_requests(max_age_seconds) do 107 + cutoff = DateTime.add(DateTime.utc_now(), -max_age_seconds, :second) 108 + {count, _} = Repo.delete_all(from(r in OAuthLoginRequest, where: r.inserted_at < ^cutoff)) 109 + count 71 110 end 72 111 end
+43
lib/annot_at/accounts/oauth_login_request.ex
··· 1 + defmodule AnnotAt.Accounts.OAuthLoginRequest do 2 + use Ecto.Schema 3 + 4 + import Ecto.Changeset 5 + 6 + schema "oauth_login_requests" do 7 + # OAuth `state`, the single-use lookup key for the callback 8 + field :state, :string 9 + field :did, :string 10 + field :handle, :string 11 + field :pds_host, :string 12 + field :auth_server_issuer, :string 13 + # PKCE verifier, its challenge went out in PAR, the verifier redeems the code 14 + field :pkce_verifier, :string 15 + # Per-session DPoP key (serialized), tokens get bound to it at exchange 16 + field :dpop_private_jwk, :string 17 + 18 + timestamps(type: :utc_datetime) 19 + end 20 + 21 + def changeset(request, attrs) do 22 + request 23 + |> cast(attrs, [ 24 + :state, 25 + :did, 26 + :handle, 27 + :pds_host, 28 + :auth_server_issuer, 29 + :pkce_verifier, 30 + :dpop_private_jwk 31 + ]) 32 + |> validate_required([ 33 + :state, 34 + :did, 35 + :handle, 36 + :pds_host, 37 + :auth_server_issuer, 38 + :pkce_verifier, 39 + :dpop_private_jwk 40 + ]) 41 + |> unique_constraint(:state) 42 + end 43 + end
+15
lib/annot_at/accounts/scope.ex
··· 1 + defmodule AnnotAt.Accounts.Scope do 2 + @moduledoc """ 3 + The current authentication scope: the logged-in user, or nil for a guest. 4 + """ 5 + 6 + alias AnnotAt.Accounts.User 7 + 8 + defstruct [:user] 9 + 10 + @type t :: %__MODULE__{user: User.t()} 11 + 12 + @spec for_user(User.t() | nil) :: t() | nil 13 + def for_user(%User{} = user), do: %__MODULE__{user: user} 14 + def for_user(nil), do: nil 15 + end
+46
lib/annot_at/atproto/oauth/config.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.Config do 2 + @moduledoc """ 3 + Reads the atproto OAuth client configuration: the client id, redirect URI, 4 + scope, and the ES256 signing key. 5 + 6 + Confgiured under `config :annot_at, AnnotAt.Atproto.OAuth.Config` with 7 + `:scope` and `:signing_jwk` (a JSON-encoded private JWK). 8 + """ 9 + 10 + @client_metadata_path "/oauth-client-metadata.json" 11 + @callback_path "/auth/callback" 12 + 13 + @doc """ 14 + The client id: the URL where the client metadata document is published. 15 + """ 16 + def client_id, do: base_url() <> @client_metadata_path 17 + 18 + @doc """ 19 + OAuth callback URL. 20 + """ 21 + def redirect_uri, do: base_url() <> @callback_path 22 + 23 + @doc """ 24 + The scopes requested at login. 25 + """ 26 + def scope, do: fetch!(:scope) 27 + 28 + @doc """ 29 + The ES256 signing key. 30 + """ 31 + @spec signing_key() :: JOSE.JWK.t() 32 + def signing_key do 33 + :signing_jwk 34 + |> fetch!() 35 + |> Jason.decode!() 36 + |> JOSE.JWK.from() 37 + end 38 + 39 + defp base_url, do: AnnotAtWeb.Endpoint.url() 40 + 41 + defp fetch!(key) do 42 + :annot_at 43 + |> Application.fetch_env!(__MODULE__) 44 + |> Keyword.fetch!(key) 45 + end 46 + end
+5
lib/annot_at/atproto/oauth/flow.ex
··· 17 17 alias AnnotAt.Atproto.OAuth.Session 18 18 alias AnnotAt.Atproto.OAuth.TokenResponse 19 19 20 + require Logger 21 + 20 22 @type request_error :: 21 23 {:transport, term()} 22 24 | :invalid_json ··· 125 127 code: code, 126 128 redirect_uri: redirect_uri, 127 129 code_verifier: code_verifier, 130 + client_id: client_id, 128 131 client_assertion_type: ClientAssertion.assertion_type(), 129 132 client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) 130 133 ] ··· 164 167 [ 165 168 grant_type: "refresh_token", 166 169 refresh_token: session.refresh_token, 170 + client_id: client_id, 167 171 client_assertion_type: ClientAssertion.assertion_type(), 168 172 client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) 169 173 ] ··· 190 194 retry_with_nonce(url, build_form, dpop_key, headers) 191 195 192 196 Map.has_key?(body, "error") -> 197 + Logger.warning("atproto OAuth error from #{url}: #{inspect(body)}") 193 198 {:error, {:oauth_error, Map.fetch!(body, "error")}} 194 199 195 200 true ->
+194
lib/annot_at/atproto/oauth/login.ex
··· 1 + defmodule AnnotAt.Atproto.OAuth.Login do 2 + @moduledoc """ 3 + Orchestrates the atproto OAuth login, resolving the account, discovering its 4 + authorization server, running the pushed-authorization and token-exchange 5 + legs of the flow, and persisting the result. 6 + 7 + The integration layer above `Flow` (OAuth primitives), `Identity`/`Discovery` 8 + (resolution), `Config` (client settings), and `Accounts` (persistence). 9 + """ 10 + 11 + alias AnnotAt.Accounts 12 + alias AnnotAt.Accounts.User 13 + alias AnnotAt.Atproto.Identity 14 + alias AnnotAt.Atproto.OAuth.Config 15 + alias AnnotAt.Atproto.OAuth.Discovery 16 + alias AnnotAt.Atproto.OAuth.DPoP 17 + alias AnnotAt.Atproto.OAuth.Flow 18 + alias AnnotAt.Atproto.OAuth.PKCE 19 + 20 + require Logger 21 + 22 + @doc """ 23 + Begins a login for `handle`: resolves the identity, discovers the 24 + authorization server, pushes the authorization request, stashes the 25 + in-progress state, and returns the URL to redirect the browser to. 26 + """ 27 + @spec start_login(String.t()) :: {:ok, String.t()} | {:error, :invalid_handle | :login_failed} 28 + def start_login(handle) when is_binary(handle) do 29 + Logger.info("atproto login starting for #{handle}") 30 + 31 + verifier = PKCE.generate_verifier() 32 + dpop_key = DPoP.generate_key() 33 + state = random_state() 34 + 35 + result = 36 + with {:ok, identity} <- Identity.resolve_handle(handle), 37 + {:ok, server} <- Discovery.discover(identity.pds_endpoint), 38 + {:ok, request_uri} <- request_par(server, identity, verifier, dpop_key, state), 39 + {:ok, _request} <- store_login_request(server, identity, verifier, dpop_key, state) do 40 + Logger.info( 41 + "atproto login redirecting #{identity.handle} (#{identity.did}) to #{server.issuer}" 42 + ) 43 + 44 + {:ok, Flow.authorization_url(server, Config.client_id(), request_uri)} 45 + end 46 + 47 + with {:error, reason} <- result do 48 + Logger.warning("atproto login start failed for #{handle}: #{inspect(reason)}") 49 + 50 + {:error, login_error(reason)} 51 + end 52 + end 53 + 54 + @doc """ 55 + Completes a login from the callback parameters. Validates the stored state, 56 + exchanges the code for a session, and persists the user and session. 57 + """ 58 + @spec complete_login(map()) :: 59 + {:ok, User.t()} 60 + | {:error, 61 + {:oauth_error, String.t()} | :invalid_callback | :invalid_state | :login_failed} 62 + def complete_login(%{"error" => error}), do: {:error, {:oauth_error, error}} 63 + 64 + def complete_login(%{"code" => code, "state" => state, "iss" => iss}) do 65 + result = 66 + with {:ok, request} <- take_request(state), 67 + :ok <- verify_issuer(request, iss), 68 + {:ok, server} <- rediscover(request), 69 + {:ok, session} <- exchange(server, request, code), 70 + {:ok, user} <- persist(request, session) do 71 + Logger.info("atproto login completed for #{user.handle} (#{user.did})") 72 + 73 + {:ok, user} 74 + end 75 + 76 + with {:error, reason} <- result do 77 + Logger.warning("atproto OAuth callback failed: #{inspect(reason)}") 78 + {:error, callback_error(reason)} 79 + end 80 + end 81 + 82 + def complete_login(_), do: {:error, :invalid_callback} 83 + 84 + @doc """ 85 + Logs out by deleting the user's atproto session. The browser session 86 + is cleared by the caller. 87 + """ 88 + @spec logout(User.t()) :: :ok 89 + def logout(%User{} = user) do 90 + Logger.info("atproto logout for #{user.handle}") 91 + 92 + Accounts.delete_atproto_session(user) 93 + end 94 + 95 + defp request_par(server, identity, verifier, dpop_key, state) do 96 + Flow.par(server, 97 + client_id: Config.client_id(), 98 + client_jwk: Config.signing_key(), 99 + redirect_uri: Config.redirect_uri(), 100 + scope: Config.scope(), 101 + state: state, 102 + code_challenge: PKCE.challenge(verifier), 103 + dpop_key: dpop_key, 104 + login_hint: identity.handle 105 + ) 106 + end 107 + 108 + defp store_login_request(server, identity, verifier, dpop_key, state) do 109 + Accounts.create_login_request(%{ 110 + state: state, 111 + did: identity.did, 112 + handle: identity.handle, 113 + pds_host: identity.pds_endpoint, 114 + auth_server_issuer: server.issuer, 115 + pkce_verifier: verifier, 116 + dpop_private_jwk: dump_jwk(dpop_key) 117 + }) 118 + end 119 + 120 + defp take_request(state) do 121 + case Accounts.take_login_request(state) do 122 + nil -> {:error, :invalid_state} 123 + request -> {:ok, request} 124 + end 125 + end 126 + 127 + defp verify_issuer(%{auth_server_issuer: iss}, iss), do: :ok 128 + defp verify_issuer(_request, _iss), do: {:error, :issuer_mismatch} 129 + 130 + defp rediscover(request) do 131 + with {:ok, server} <- Discovery.discover(request.pds_host) do 132 + if server.issuer == request.auth_server_issuer do 133 + {:ok, server} 134 + else 135 + {:error, :issuer_mismatch} 136 + end 137 + end 138 + end 139 + 140 + defp exchange(server, request, code) do 141 + Flow.exchange_code(server, 142 + client_id: Config.client_id(), 143 + client_jwk: Config.signing_key(), 144 + redirect_uri: Config.redirect_uri(), 145 + code: code, 146 + code_verifier: request.pkce_verifier, 147 + dpop_key: load_jwk(request.dpop_private_jwk), 148 + expected_did: request.did, 149 + pds_endpoint: request.pds_host 150 + ) 151 + end 152 + 153 + defp persist(request, session) do 154 + Accounts.upsert_login( 155 + %{ 156 + did: session.did, 157 + handle: request.handle, 158 + pds_host: session.pds_endpoint, 159 + handle_verified_at: DateTime.utc_now(:second) 160 + }, 161 + %{ 162 + auth_server_issuer: session.issuer, 163 + granted_scopes: session.scope, 164 + access_token: session.access_token, 165 + refresh_token: session.refresh_token, 166 + dpop_private_jwk: dump_jwk(session.dpop_key), 167 + expires_at: session.expires_at 168 + } 169 + ) 170 + end 171 + 172 + defp dump_jwk(jwk) do 173 + {_, map} = JOSE.JWK.to_map(jwk) 174 + Jason.encode!(map) 175 + end 176 + 177 + defp load_jwk(json) do 178 + json 179 + |> Jason.decode!() 180 + |> JOSE.JWK.from() 181 + end 182 + 183 + defp random_state do 184 + 24 185 + |> :crypto.strong_rand_bytes() 186 + |> Base.url_encode64(padding: false) 187 + end 188 + 189 + defp login_error(:invalid_handle), do: :invalid_handle 190 + defp login_error(_reason), do: :login_failed 191 + 192 + defp callback_error(:invalid_state), do: :invalid_state 193 + defp callback_error(_reason), do: :login_failed 194 + end
+63
lib/annot_at_web/controllers/auth_controller.ex
··· 1 + defmodule AnnotAtWeb.AuthController do 2 + use AnnotAtWeb, :controller 3 + 4 + import AnnotAtWeb.UserAuth, only: [log_in_user: 2, log_out_user: 1] 5 + 6 + alias AnnotAt.Atproto.OAuth.ClientMetadata 7 + alias AnnotAt.Atproto.OAuth.Config 8 + alias AnnotAt.Atproto.OAuth.Login 9 + 10 + def new(conn, _params) do 11 + render(conn, :new, handle: "") 12 + end 13 + 14 + def create(conn, %{"handle" => handle}) do 15 + case Login.start_login(handle) do 16 + {:ok, url} -> 17 + redirect(conn, external: url) 18 + 19 + {:error, reason} -> 20 + conn 21 + |> put_flash(:error, error_message(reason)) 22 + |> render(:new, handle: handle) 23 + end 24 + end 25 + 26 + def callback(conn, params) do 27 + case Login.complete_login(params) do 28 + {:ok, user} -> 29 + log_in_user(conn, user) 30 + 31 + {:error, reason} -> 32 + conn 33 + |> put_flash(:error, error_message(reason)) 34 + |> redirect(to: ~p"/login") 35 + end 36 + end 37 + 38 + def delete(conn, _params) do 39 + if scope = conn.assigns.current_scope do 40 + Login.logout(scope.user) 41 + end 42 + 43 + log_out_user(conn) 44 + end 45 + 46 + def client_metadata(conn, _params) do 47 + metadata = 48 + ClientMetadata.build( 49 + client_id: Config.client_id(), 50 + redirect_uris: [Config.redirect_uri()], 51 + scope: Config.scope(), 52 + jwk: Config.signing_key(), 53 + client_name: "annot.at" 54 + ) 55 + 56 + json(conn, metadata) 57 + end 58 + 59 + defp error_message(:invalid_handle), do: "That doesn't look like a valid handle." 60 + defp error_message(:invalid_state), do: "Your login link expired. Please try again." 61 + defp error_message({:oauth_error, _}), do: "Authorization was denied or failed." 62 + defp error_message(_reason), do: "Something went wrong. Please try again." 63 + end
+5
lib/annot_at_web/controllers/auth_html.ex
··· 1 + defmodule AnnotAtWeb.AuthHTML do 2 + use AnnotAtWeb, :html 3 + 4 + embed_templates "auth_html/*" 5 + end
+16
lib/annot_at_web/controllers/auth_html/new.html.heex
··· 1 + <Layouts.app flash={@flash} current_scope={@current_scope}> 2 + <div class="mx-auto max-w-sm space-y-6 py-12"> 3 + <h1 class="text-2xl font-semibold">Sign in with Bluesky</h1> 4 + 5 + <.form for={%{}} action={~p"/login"} method="post" id="login-form" class="space-y-4"> 6 + <.input 7 + name="handle" 8 + value={@handle} 9 + label="Handle" 10 + placeholder="alice.bsky.social" 11 + required 12 + /> 13 + <.button class="w-full">Continue</.button> 14 + </.form> 15 + </div> 16 + </Layouts.app>
+11 -200
lib/annot_at_web/controllers/page_html/home.html.heex
··· 1 - <Layouts.flash_group flash={@flash} /> 2 - <div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]"> 3 - <svg 4 - viewBox="0 0 1480 957" 5 - fill="none" 6 - aria-hidden="true" 7 - class="absolute inset-0 h-full w-full" 8 - preserveAspectRatio="xMinYMid slice" 9 - > 10 - <path fill="#EE7868" d="M0 0h1480v957H0z" /> 11 - <path 12 - d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z" 13 - fill="#FF9F92" 14 - /> 15 - <path 16 - d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z" 17 - fill="#FA8372" 18 - /> 19 - <path 20 - d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z" 21 - fill="#E96856" 22 - fill-opacity=".6" 23 - /> 24 - <path 25 - d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z" 26 - fill="#C42652" 27 - fill-opacity=".2" 28 - /> 29 - <path 30 - d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z" 31 - fill="#A41C42" 32 - fill-opacity=".2" 33 - /> 34 - <path 35 - d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z" 36 - fill="#A41C42" 37 - fill-opacity=".2" 38 - /> 39 - </svg> 40 - </div> 41 - <div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32"> 42 - <div class="mx-auto max-w-xl lg:mx-0"> 43 - <svg viewBox="0 0 71 48" class="h-12" aria-hidden="true"> 44 - <path 45 - d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z" 46 - fill="#FD4F00" 47 - /> 48 - </svg> 49 - <div class="mt-10 flex justify-between items-center"> 50 - <h1 class="flex items-center text-sm font-semibold leading-6"> 51 - Phoenix Framework 52 - <small class="badge badge-warning badge-sm ml-3"> 53 - v{Application.spec(:phoenix, :vsn)} 54 - </small> 55 - </h1> 56 - <Layouts.theme_toggle /> 57 - </div> 1 + <Layouts.app flash={@flash} current_scope={@current_scope}> 2 + <div class="mx-auto max-w-md space-y-6 py-16 text-center"> 3 + <h1 class="text-3xl font-bold">annot.at</h1> 58 4 59 - <p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance"> 60 - Peace of mind from prototype to production. 61 - </p> 62 - <p class="mt-4 leading-7 text-base-content/70"> 63 - Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. 64 - </p> 65 - <div class="flex"> 66 - <div class="w-full sm:w-auto"> 67 - <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3"> 68 - <a 69 - href="https://hexdocs.pm/phoenix/overview.html" 70 - class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6" 71 - > 72 - <span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"> 73 - </span> 74 - <span class="relative flex items-center gap-4 sm:flex-col"> 75 - <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6"> 76 - <path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" /> 77 - <path 78 - d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2" 79 - stroke="currentColor" 80 - stroke-width="2" 81 - stroke-linecap="round" 82 - stroke-linejoin="round" 83 - /> 84 - </svg> 85 - Guides &amp; Docs 86 - </span> 87 - </a> 88 - <a 89 - href="https://github.com/phoenixframework/phoenix" 90 - class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6" 91 - > 92 - <span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"> 93 - </span> 94 - <span class="relative flex items-center gap-4 sm:flex-col"> 95 - <svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6"> 96 - <path 97 - fill="currentColor" 98 - fill-rule="evenodd" 99 - clip-rule="evenodd" 100 - d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z" 101 - /> 102 - </svg> 103 - Source Code 104 - </span> 105 - </a> 106 - <a 107 - href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"} 108 - class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6" 109 - > 110 - <span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"> 111 - </span> 112 - <span class="relative flex items-center gap-4 sm:flex-col"> 113 - <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6"> 114 - <path 115 - d="M12 1v6M12 17v6" 116 - stroke="currentColor" 117 - stroke-width="2" 118 - stroke-linecap="round" 119 - stroke-linejoin="round" 120 - /> 121 - <circle 122 - cx="12" 123 - cy="12" 124 - r="4" 125 - fill="currentColor" 126 - fill-opacity=".15" 127 - stroke="currentColor" 128 - stroke-width="2" 129 - stroke-linecap="round" 130 - stroke-linejoin="round" 131 - /> 132 - </svg> 133 - Changelog 134 - </span> 135 - </a> 136 - </div> 137 - <div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2"> 138 - <div> 139 - <a 140 - href="https://elixirforum.com" 141 - class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" 142 - > 143 - <svg 144 - viewBox="0 0 16 16" 145 - aria-hidden="true" 146 - class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" 147 - > 148 - <path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" /> 149 - </svg> 150 - Discuss on the Elixir Forum 151 - </a> 152 - </div> 153 - <div> 154 - <a 155 - href="https://discord.gg/elixir" 156 - class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" 157 - > 158 - <svg 159 - viewBox="0 0 16 16" 160 - aria-hidden="true" 161 - class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" 162 - > 163 - <path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" /> 164 - </svg> 165 - Join our Discord server 166 - </a> 167 - </div> 168 - <div> 169 - <a 170 - href="https://elixir-slack.community/" 171 - class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" 172 - > 173 - <svg 174 - viewBox="0 0 16 16" 175 - aria-hidden="true" 176 - class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" 177 - > 178 - <path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" /> 179 - </svg> 180 - Join us on Slack 181 - </a> 182 - </div> 183 - <div> 184 - <a 185 - href="https://fly.io/docs/elixir/getting-started/" 186 - class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" 187 - > 188 - <svg 189 - viewBox="0 0 20 20" 190 - aria-hidden="true" 191 - class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" 192 - > 193 - <path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" /> 194 - </svg> 195 - Deploy your application 196 - </a> 197 - </div> 198 - </div> 199 - </div> 200 - </div> 5 + <%= if @current_scope do %> 6 + <p>Signed in as <span class="font-semibold">{@current_scope.user.handle}</span></p> 7 + <p class="text-sm opacity-70">{@current_scope.user.did}</p> 8 + <.link href={~p"/logout"} method="delete" class="underline">Log out</.link> 9 + <% else %> 10 + <.link navigate={~p"/login"} class="underline">Sign in with Bluesky</.link> 11 + <% end %> 201 12 </div> 202 - </div> 13 + </Layouts.app>
+14
lib/annot_at_web/router.ex
··· 1 1 defmodule AnnotAtWeb.Router do 2 2 use AnnotAtWeb, :router 3 3 4 + import AnnotAtWeb.UserAuth 5 + 4 6 pipeline :browser do 5 7 plug :accepts, ["html"] 6 8 plug :fetch_session ··· 8 10 plug :put_root_layout, html: {AnnotAtWeb.Layouts, :root} 9 11 plug :protect_from_forgery 10 12 plug :put_secure_browser_headers 13 + plug :fetch_current_scope 11 14 end 12 15 13 16 pipeline :api do ··· 18 21 pipe_through :browser 19 22 20 23 get "/", PageController, :home 24 + 25 + get "/login", AuthController, :new 26 + post "/login", AuthController, :create 27 + get "/auth/callback", AuthController, :callback 28 + delete "/logout", AuthController, :delete 29 + end 30 + 31 + scope "/", AnnotAtWeb do 32 + pipe_through :api 33 + 34 + get "/oauth-client-metadata.json", AuthController, :client_metadata 21 35 end 22 36 23 37 # Other scopes may use custom stacks.
+45
lib/annot_at_web/user_auth.ex
··· 1 + defmodule AnnotAtWeb.UserAuth do 2 + @moduledoc false 3 + 4 + use AnnotAtWeb, :verified_routes 5 + 6 + import Plug.Conn 7 + import Phoenix.Controller 8 + 9 + alias AnnotAt.Accounts 10 + alias AnnotAt.Accounts.Scope 11 + 12 + @doc """ 13 + Assigns :current_scope from the user_id in the session. 14 + """ 15 + def fetch_current_scope(conn, _opts) do 16 + user = 17 + if user_id = get_session(conn, :user_id) do 18 + Accounts.get_user(user_id) 19 + end 20 + 21 + assign(conn, :current_scope, Scope.for_user(user)) 22 + end 23 + 24 + @doc """ 25 + Renews the session, stores ID, redirects. 26 + """ 27 + def log_in_user(conn, user) do 28 + conn 29 + |> renew_session() 30 + |> put_session(:user_id, user.id) 31 + |> redirect(to: ~p"/") 32 + end 33 + 34 + def log_out_user(conn) do 35 + conn 36 + |> renew_session() 37 + |> redirect(to: ~p"/") 38 + end 39 + 40 + defp renew_session(conn) do 41 + conn 42 + |> configure_session(renew: true) 43 + |> clear_session() 44 + end 45 + end
+19
priv/repo/migrations/20260615144354_create_oauth_login_requests.exs
··· 1 + defmodule AnnotAt.Repo.Migrations.CreateOauthLoginRequests do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:oauth_login_requests) do 6 + add :state, :text, null: false 7 + add :did, :text, null: false 8 + add :handle, :text, null: false 9 + add :pds_host, :text, null: false 10 + add :auth_server_issuer, :text, null: false 11 + add :pkce_verifier, :text, null: false 12 + add :dpop_private_jwk, :text, null: false 13 + 14 + timestamps(type: :utc_datetime) 15 + end 16 + 17 + create unique_index(:oauth_login_requests, [:state]) 18 + end 19 + end
+49
test/annot_at/accounts_test.exs
··· 3 3 4 4 alias AnnotAt.Accounts 5 5 alias AnnotAt.Accounts.AtprotoSession 6 + alias AnnotAt.Accounts.OAuthLoginRequest 6 7 alias AnnotAt.Accounts.User 7 8 8 9 @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" ··· 92 93 93 94 refute Accounts.get_atproto_session(user.id) 94 95 assert Accounts.get_user!(user.id) 96 + end 97 + 98 + test "create_login_request/1 then take_login_request/1 round-trips a login" do 99 + {:ok, _} = Accounts.create_login_request(login_request_attrs()) 100 + 101 + request = Accounts.take_login_request("state-123") 102 + assert @did == request.did 103 + assert "verifier-123" == request.pkce_verifier 104 + end 105 + 106 + test "take_login_request/1 is single-use" do 107 + {:ok, _} = Accounts.create_login_request(login_request_attrs()) 108 + 109 + assert Accounts.take_login_request("state-123") 110 + refute Accounts.take_login_request("state-123") 111 + end 112 + 113 + test "take_login_request/1 returns nil for an unknown state" do 114 + refute Accounts.take_login_request("nope") 115 + end 116 + 117 + test "delete_expired_login_requests/1 removes only old requests" do 118 + {:ok, old} = Accounts.create_login_request(login_request_attrs(%{state: "old"})) 119 + {:ok, _} = Accounts.create_login_request(login_request_attrs(%{state: "fresh"})) 120 + 121 + Repo.update_all( 122 + from(r in OAuthLoginRequest, where: r.id == ^old.id), 123 + set: [inserted_at: ~U[2020-01-01 00:00:00Z]] 124 + ) 125 + 126 + assert 1 == Accounts.delete_expired_login_requests(3600) 127 + refute Accounts.take_login_request("old") 128 + assert Accounts.take_login_request("fresh") 129 + end 130 + 131 + defp login_request_attrs(overrides \\ %{}) do 132 + Map.merge( 133 + %{ 134 + state: "state-123", 135 + did: @did, 136 + handle: "alice.test", 137 + pds_host: "https://pds.example.com", 138 + auth_server_issuer: "https://bsky.social", 139 + pkce_verifier: "verifier-123", 140 + dpop_private_jwk: "{}" 141 + }, 142 + overrides 143 + ) 95 144 end 96 145 end
+23
test/annot_at/atproto/oauth/config_test.exs
··· 1 + defmodule AnnotAt.Atproto.OAuth.ConfigTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.OAuth.Config 5 + 6 + test "derives client_id and redirect_uri from the base URL" do 7 + assert "http://localhost:4002/oauth-client-metadata.json" == Config.client_id() 8 + assert "http://localhost:4002/auth/callback" == Config.redirect_uri() 9 + end 10 + 11 + test "scope/0 returns the configured scope" do 12 + assert "atproto" == Config.scope() 13 + end 14 + 15 + test "signing_key/0 parses the configured JWK" do 16 + jwk = Config.signing_key() 17 + {_, map} = JOSE.JWK.to_map(jwk) 18 + 19 + assert "EC" == map["kty"] 20 + assert "P-256" == map["crv"] 21 + assert map["d"] 22 + end 23 + end
+4
test/annot_at/atproto/oauth/flow_test.exs
··· 146 146 assert "authorization_code" == form[:grant_type] 147 147 assert "auth-code" == form[:code] 148 148 assert "https://annot.at/callback" == form[:redirect_uri] 149 + assert "https://annot.at/client" == form[:client_id] 150 + 149 151 assert "verifier-123" == form[:code_verifier] 150 152 assert ClientAssertion.assertion_type() == form[:client_assertion_type] 151 153 assert is_binary(form[:client_assertion]) ··· 207 209 assert "https://bsky.social/oauth/token" == url 208 210 assert "refresh_token" == form[:grant_type] 209 211 assert "old-refresh" == form[:refresh_token] 212 + assert "https://annot.at/client" == form[:client_id] 213 + 210 214 assert ClientAssertion.assertion_type() == form[:client_assertion_type] 211 215 assert is_binary(form[:client_assertion]) 212 216
+168
test/annot_at/atproto/oauth/login_test.exs
··· 1 + defmodule AnnotAt.Atproto.OAuth.LoginTest do 2 + use AnnotAt.DataCase, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Accounts 6 + alias AnnotAt.Accounts.OAuthLoginRequest 7 + alias AnnotAt.Atproto.Identity 8 + alias AnnotAt.Atproto.OAuth.Discovery 9 + alias AnnotAt.Atproto.OAuth.Flow 10 + alias AnnotAt.Atproto.OAuth.Login 11 + alias AnnotAt.Atproto.OAuth.ServerMetadata 12 + alias AnnotAt.Atproto.OAuth.Session 13 + 14 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 15 + @pds "https://enoki.us-east.host.bsky.network" 16 + @issuer "https://bsky.social" 17 + 18 + @dpop_jwk_json "../../../support/fixtures/atproto/es256_jwk.json" 19 + |> Path.expand(__DIR__) 20 + |> File.read!() 21 + 22 + setup do 23 + jwk = 24 + @dpop_jwk_json 25 + |> Jason.decode!() 26 + |> JOSE.JWK.from() 27 + 28 + [jwk: jwk] 29 + end 30 + 31 + describe "Login.start_login/1" do 32 + test "resolves, PARs, stores the request, and returns the authorize URL" do 33 + identity = %Identity{did: @did, handle: "jola.dev", pds_endpoint: @pds} 34 + server = server() 35 + 36 + expect(Identity, :resolve_handle, fn "jola.dev" -> {:ok, identity} end) 37 + expect(Discovery, :discover, fn @pds -> {:ok, server} end) 38 + expect(Flow, :par, fn ^server, _opts -> {:ok, "urn:req:abc"} end) 39 + 40 + assert {:ok, url} = Login.start_login("jola.dev") 41 + assert url =~ "#{@issuer}/oauth/authorize" 42 + assert url =~ "request_uri=urn" 43 + 44 + assert [request] = Repo.all(OAuthLoginRequest) 45 + assert @did == request.did 46 + assert "jola.dev" == request.handle 47 + assert @issuer == request.auth_server_issuer 48 + assert request.pkce_verifier 49 + assert request.dpop_private_jwk 50 + end 51 + 52 + test "maps an invalid handle to :invalid_handle" do 53 + expect(Identity, :resolve_handle, fn _ -> {:error, :invalid_handle} end) 54 + reject(&Discovery.discover/1) 55 + 56 + assert {:error, :invalid_handle} == Login.start_login("nope") 57 + end 58 + 59 + test "maps other resolution failures to :login_failed" do 60 + expect(Identity, :resolve_handle, fn _ -> {:error, :handle_not_found} end) 61 + 62 + assert {:error, :login_failed} == Login.start_login("jola.dev") 63 + end 64 + end 65 + 66 + describe "Login.complete_login/1" do 67 + test "exchanges the code and persists the user and session", %{jwk: jwk} do 68 + create_request() 69 + server = server() 70 + 71 + session = %Session{ 72 + did: @did, 73 + access_token: "access-1", 74 + refresh_token: "refresh-1", 75 + dpop_key: jwk, 76 + scope: "atproto", 77 + issuer: @issuer, 78 + pds_endpoint: @pds, 79 + expires_at: ~U[2026-01-01 01:00:00Z] 80 + } 81 + 82 + expect(Discovery, :discover, fn @pds -> {:ok, server} end) 83 + expect(Flow, :exchange_code, fn ^server, _opts -> {:ok, session} end) 84 + 85 + params = %{"code" => "code-1", "state" => "state-1", "iss" => @issuer} 86 + assert {:ok, user} = Login.complete_login(params) 87 + 88 + assert @did == user.did 89 + assert "access-1" == user.atproto_session.access_token 90 + refute Accounts.take_login_request("state-1") 91 + end 92 + 93 + test "returns :invalid_state for an unknown state" do 94 + reject(&Flow.exchange_code/2) 95 + 96 + params = %{"code" => "x", "state" => "nope", "iss" => @issuer} 97 + assert {:error, :invalid_state} == Login.complete_login(params) 98 + end 99 + 100 + test "rejects a callback whose iss does not match the stored issuer" do 101 + create_request() 102 + reject(&Flow.exchange_code/2) 103 + 104 + params = %{"code" => "c", "state" => "state-1", "iss" => "https://evil.example"} 105 + assert {:error, :login_failed} == Login.complete_login(params) 106 + end 107 + 108 + test "surfaces an authorization-server error" do 109 + assert {:error, {:oauth_error, "access_denied"}} == 110 + Login.complete_login(%{"error" => "access_denied"}) 111 + end 112 + 113 + test "returns :invalid_callback for malformed params" do 114 + assert {:error, :invalid_callback} == Login.complete_login(%{}) 115 + end 116 + end 117 + 118 + describe "Login.logout/1" do 119 + test "logout/1 deletes the user's atproto session", %{jwk: _jwk} do 120 + {:ok, user} = 121 + Accounts.upsert_login( 122 + %{did: @did, handle: "jola.dev", pds_host: @pds}, 123 + %{ 124 + auth_server_issuer: @issuer, 125 + granted_scopes: "atproto", 126 + access_token: "a", 127 + refresh_token: "r", 128 + dpop_private_jwk: "{}", 129 + expires_at: ~U[2026-01-01 01:00:00Z] 130 + } 131 + ) 132 + 133 + assert Accounts.get_atproto_session(user.id) 134 + assert :ok == Login.logout(user) 135 + refute Accounts.get_atproto_session(user.id) 136 + end 137 + end 138 + 139 + defp server do 140 + %ServerMetadata{ 141 + issuer: @issuer, 142 + authorization_endpoint: "#{@issuer}/oauth/authorize", 143 + token_endpoint: "#{@issuer}/oauth/token", 144 + par_endpoint: "#{@issuer}/oauth/par", 145 + scopes_supported: ["atproto"] 146 + } 147 + end 148 + 149 + defp create_request(overrides \\ %{}) do 150 + {:ok, request} = 151 + Accounts.create_login_request( 152 + Map.merge( 153 + %{ 154 + state: "state-1", 155 + did: @did, 156 + handle: "jola.dev", 157 + pds_host: @pds, 158 + auth_server_issuer: @issuer, 159 + pkce_verifier: "verifier-1", 160 + dpop_private_jwk: @dpop_jwk_json 161 + }, 162 + overrides 163 + ) 164 + ) 165 + 166 + request 167 + end 168 + end
+101
test/annot_at_web/controllers/auth_controller_test.exs
··· 1 + defmodule AnnotAtWeb.AuthControllerTest do 2 + use AnnotAtWeb.ConnCase, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Accounts 6 + alias AnnotAt.Atproto.OAuth.Login 7 + 8 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 9 + 10 + defp create_user do 11 + {:ok, user} = 12 + Accounts.upsert_login( 13 + %{did: @did, handle: "alice.test", pds_host: "https://pds.example.com"}, 14 + %{ 15 + auth_server_issuer: "https://bsky.social", 16 + granted_scopes: "atproto", 17 + access_token: "a", 18 + refresh_token: "r", 19 + dpop_private_jwk: "{}", 20 + expires_at: ~U[2026-01-01 01:00:00Z] 21 + } 22 + ) 23 + 24 + user 25 + end 26 + 27 + test "GET /oauth-client-metadata.json serves the client metadata", %{conn: conn} do 28 + conn = get(conn, ~p"/oauth-client-metadata.json") 29 + metadata = json_response(conn, 200) 30 + 31 + assert "http://localhost:4002/oauth-client-metadata.json" == metadata["client_id"] 32 + assert ["http://localhost:4002/auth/callback"] == metadata["redirect_uris"] 33 + assert "atproto" == metadata["scope"] 34 + assert true == metadata["dpop_bound_access_tokens"] 35 + assert "private_key_jwt" == metadata["token_endpoint_auth_method"] 36 + 37 + assert [key] = metadata["jwks"]["keys"] 38 + refute Map.has_key?(key, "d") 39 + end 40 + 41 + test "GET /login renders the form", %{conn: conn} do 42 + conn = get(conn, ~p"/login") 43 + assert html_response(conn, 200) =~ "login-form" 44 + end 45 + 46 + test "POST /login redirects to the authorization URL", %{conn: conn} do 47 + expect(Login, :start_login, fn "alice.test" -> 48 + {:ok, "https://bsky.social/oauth/authorize?x=1"} 49 + end) 50 + 51 + conn = post(conn, ~p"/login", %{"handle" => "alice.test"}) 52 + assert "https://bsky.social/oauth/authorize?x=1" == redirected_to(conn) 53 + end 54 + 55 + test "POST /login re-renders with an error for an invalid handle", %{conn: conn} do 56 + expect(Login, :start_login, fn _ -> {:error, :invalid_handle} end) 57 + 58 + conn = post(conn, ~p"/login", %{"handle" => "nope"}) 59 + assert html_response(conn, 200) =~ "valid handle" 60 + end 61 + 62 + test "GET /auth/callback logs in and redirects home", %{conn: conn} do 63 + user = create_user() 64 + expect(Login, :complete_login, fn _ -> {:ok, user} end) 65 + 66 + conn = get(conn, "/auth/callback?code=c&state=s&iss=i") 67 + assert ~p"/" == redirected_to(conn) 68 + assert user.id == get_session(conn, :user_id) 69 + end 70 + 71 + test "GET /auth/callback redirects to login on failure", %{conn: conn} do 72 + expect(Login, :complete_login, fn _ -> {:error, :invalid_state} end) 73 + 74 + conn = get(conn, "/auth/callback?state=bad") 75 + assert ~p"/login" == redirected_to(conn) 76 + end 77 + 78 + test "DELETE /logout clears the session", %{conn: conn} do 79 + user = create_user() 80 + stub(Login, :logout, fn _ -> :ok end) 81 + 82 + conn = 83 + conn 84 + |> init_test_session(%{user_id: user.id}) 85 + |> delete(~p"/logout") 86 + 87 + assert ~p"/" == redirected_to(conn) 88 + refute get_session(conn, :user_id) 89 + end 90 + 91 + test "home shows the DID when signed in", %{conn: conn} do 92 + user = create_user() 93 + 94 + conn = 95 + conn 96 + |> init_test_session(%{user_id: user.id}) 97 + |> get(~p"/") 98 + 99 + assert html_response(conn, 200) =~ @did 100 + end 101 + end
+1 -1
test/annot_at_web/controllers/page_controller_test.exs
··· 3 3 4 4 test "GET /", %{conn: conn} do 5 5 conn = get(conn, ~p"/") 6 - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 6 + assert html_response(conn, 200) =~ "annot.at" 7 7 end 8 8 end
+5 -1
test/test_helper.exs
··· 1 + Mimic.copy(AnnotAt.Atproto.DNS) 1 2 Mimic.copy(AnnotAt.Atproto.HTTP) 2 - Mimic.copy(AnnotAt.Atproto.DNS) 3 + Mimic.copy(AnnotAt.Atproto.Identity) 4 + Mimic.copy(AnnotAt.Atproto.OAuth.Discovery) 5 + Mimic.copy(AnnotAt.Atproto.OAuth.Flow) 6 + Mimic.copy(AnnotAt.Atproto.OAuth.Login) 3 7 4 8 ExUnit.start() 5 9 Ecto.Adapters.SQL.Sandbox.mode(AnnotAt.Repo, :manual)