diff --git a/assets/js/app.js b/assets/js/app.js --- a/assets/js/app.js +++ b/assets/js/app.js @@ -18,33 +18,48 @@ // To load it, simply add a second `` to your `root.html.heex` file. // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html" +import "phoenix_html"; // Establish Phoenix Socket and LiveView configuration. -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" -import {hooks as colocatedHooks} from "phoenix-colocated/annot_at" -import topbar from "../vendor/topbar" +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import { hooks as colocatedHooks } from "phoenix-colocated/annot_at"; +import topbar from "../vendor/topbar"; -const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute("content"); const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks}, -}) + params: { _csrf_token: csrfToken }, + hooks: { ...colocatedHooks }, +}); // Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => topbar.show(2000)); +window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); // connect if there are any LiveViews on the page -liveSocket.connect() +liveSocket.connect(); // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket +window.liveSocket = liveSocket; + +// Login is a DeadView form, this adds a nice little spinner for it +document.addEventListener("submit", (e) => { + if (e.target?.id !== "login-form") return; + + const btn = e.target.querySelector("button[type='submit']"); + + if (!btn) return; + + btn.disabled = true; + btn.innerHTML = + 'Connecting…'; +}); // The lines below enable quality of life phoenix_live_reload // development features: @@ -53,31 +68,37 @@ // 2. click on elements to jump to their definitions in your code editor // if (process.env.NODE_ENV === "development") { - window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { - // Enable server log streaming to client. - // Disable with reloader.disableServerLogs() - reloader.enableServerLogs() + window.addEventListener( + "phx:live_reload:attached", + ({ detail: reloader }) => { + // Enable server log streaming to client. + // Disable with reloader.disableServerLogs() + reloader.enableServerLogs(); - // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component - // - // * click with "c" key pressed to open at caller location - // * click with "d" key pressed to open at function component definition location - let keyDown - window.addEventListener("keydown", e => keyDown = e.key) - window.addEventListener("keyup", _e => keyDown = null) - window.addEventListener("click", e => { - if(keyDown === "c"){ - e.preventDefault() - e.stopImmediatePropagation() - reloader.openEditorAtCaller(e.target) - } else if(keyDown === "d"){ - e.preventDefault() - e.stopImmediatePropagation() - reloader.openEditorAtDef(e.target) - } - }, true) + // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component + // + // * click with "c" key pressed to open at caller location + // * click with "d" key pressed to open at function component definition location + let keyDown; + window.addEventListener("keydown", (e) => (keyDown = e.key)); + window.addEventListener("keyup", (_e) => (keyDown = null)); + window.addEventListener( + "click", + (e) => { + if (keyDown === "c") { + e.preventDefault(); + e.stopImmediatePropagation(); + reloader.openEditorAtCaller(e.target); + } else if (keyDown === "d") { + e.preventDefault(); + e.stopImmediatePropagation(); + reloader.openEditorAtDef(e.target); + } + }, + true, + ); - window.liveReloader = reloader - }) + window.liveReloader = reloader; + }, + ); } - diff --git a/test/annot_at/accounts_test.exs b/test/annot_at/accounts_test.exs --- a/test/annot_at/accounts_test.exs +++ b/test/annot_at/accounts_test.exs @@ -137,7 +137,8 @@ pds_host: "https://pds.example.com", auth_server_issuer: "https://bsky.social", pkce_verifier: "verifier-123", - dpop_private_jwk: "{}" + dpop_private_jwk: "{}", + token_endpoint: "somethnig" }, overrides ) diff --git a/lib/annot_at/accounts/oauth_login_request.ex b/lib/annot_at/accounts/oauth_login_request.ex --- a/lib/annot_at/accounts/oauth_login_request.ex +++ b/lib/annot_at/accounts/oauth_login_request.ex @@ -14,6 +14,7 @@ field :pkce_verifier, AnnotAt.Encrypted.Binary # Per-session DPoP key (serialized), tokens get bound to it at exchange field :dpop_private_jwk, AnnotAt.Encrypted.Binary + field :token_endpoint, :string timestamps(type: :utc_datetime) end @@ -27,7 +28,8 @@ :pds_host, :auth_server_issuer, :pkce_verifier, - :dpop_private_jwk + :dpop_private_jwk, + :token_endpoint ]) |> validate_required([ :state, @@ -36,7 +38,8 @@ :pds_host, :auth_server_issuer, :pkce_verifier, - :dpop_private_jwk + :dpop_private_jwk, + :token_endpoint ]) |> unique_constraint(:state) end diff --git a/lib/annot_at_web/components/site_components.ex b/lib/annot_at_web/components/site_components.ex --- a/lib/annot_at_web/components/site_components.ex +++ b/lib/annot_at_web/components/site_components.ex @@ -21,7 +21,9 @@
{@site.url}
{@site.feed_url}
+ text-ink/50"> + {@site.feed_url || "No feed selected"} + <.status_badge status={Site.status(@site)} /> diff --git a/priv/repo/migrations/20260626174635_add_token_endpoint_to_oauth_login_requests.exs b/priv/repo/migrations/20260626174635_add_token_endpoint_to_oauth_login_requests.exs new file mode 100644 --- /dev/null +++ b/priv/repo/migrations/20260626174635_add_token_endpoint_to_oauth_login_requests.exs @@ -0,0 +1,9 @@ +defmodule AnnotAt.Repo.Migrations.AddTokenEndpointToOauthLoginRequests do + use Ecto.Migration + + def change do + alter table(:oauth_login_requests) do + add :token_endpoint, :text + end + end +end diff --git a/lib/annot_at/atproto/oauth/flow.ex b/lib/annot_at/atproto/oauth/flow.ex --- a/lib/annot_at/atproto/oauth/flow.ex +++ b/lib/annot_at/atproto/oauth/flow.ex @@ -106,11 +106,11 @@ ## Optional - `:now` - base time for `expires_at` (defaults to the current time) """ - @spec exchange_code(ServerMetadata.t(), keyword()) :: + @spec exchange_code(keyword()) :: {:ok, Session.t()} | {:error, request_error() | {:missing, String.t()} | {:invalid, String.t()} | :did_mismatch} - def exchange_code(%ServerMetadata{} = server, opts) do + def exchange_code(opts) do client_id = Keyword.fetch!(opts, :client_id) client_jwk = Keyword.fetch!(opts, :client_jwk) redirect_uri = Keyword.fetch!(opts, :redirect_uri) @@ -120,6 +120,8 @@ expected_did = Keyword.fetch!(opts, :expected_did) pds_endpoint = Keyword.fetch!(opts, :pds_endpoint) now = Keyword.get_lazy(opts, :now, &DateTime.utc_now/0) + token_endpoint = Keyword.fetch!(opts, :token_endpoint) + issuer = Keyword.fetch!(opts, :issuer) build_form = fn -> [ @@ -129,14 +131,14 @@ code_verifier: code_verifier, client_id: client_id, client_assertion_type: ClientAssertion.assertion_type(), - client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) + client_assertion: ClientAssertion.sign(client_jwk, client_id, issuer) ] end - with {:ok, body} <- dpop_request(server.token_endpoint, build_form, dpop_key), + with {:ok, body} <- dpop_request(token_endpoint, build_form, dpop_key), {:ok, tokens} <- TokenResponse.parse(body), :ok <- verify_sub(tokens.sub, expected_did) do - {:ok, build_session(tokens, server, pds_endpoint, dpop_key, now)} + {:ok, build_session(tokens, issuer, pds_endpoint, dpop_key, now)} end end @@ -176,7 +178,7 @@ with {:ok, body} <- dpop_request(server.token_endpoint, build_form, session.dpop_key), {:ok, tokens} <- TokenResponse.parse(body), :ok <- verify_sub(tokens.sub, session.did) do - {:ok, build_session(tokens, server, session.pds_endpoint, session.dpop_key, now)} + {:ok, build_session(tokens, server.issuer, session.pds_endpoint, session.dpop_key, now)} end end @@ -233,14 +235,14 @@ defp verify_sub(sub, sub), do: :ok defp verify_sub(_sub, _expected), do: {:error, :did_mismatch} - defp build_session(tokens, server, pds_endpoint, dpop_key, now) do + defp build_session(tokens, issuer, pds_endpoint, dpop_key, now) do %Session{ did: tokens.sub, access_token: tokens.access_token, refresh_token: tokens.refresh_token, dpop_key: dpop_key, scope: tokens.scope, - issuer: server.issuer, + issuer: issuer, pds_endpoint: pds_endpoint, expires_at: DateTime.add(now, tokens.expires_in, :second) } diff --git a/lib/annot_at/atproto/oauth/login.ex b/lib/annot_at/atproto/oauth/login.ex --- a/lib/annot_at/atproto/oauth/login.ex +++ b/lib/annot_at/atproto/oauth/login.ex @@ -66,8 +66,7 @@ result = with {:ok, request} <- take_request(state), :ok <- verify_issuer(request, iss), - {:ok, server} <- rediscover(request), - {:ok, session} <- exchange(server, request, code), + {:ok, session} <- exchange(request, code), {:ok, user} <- persist(request, session) do Logger.info("atproto login completed for #{user.handle} (#{user.did})") @@ -114,7 +113,8 @@ pds_host: identity.pds_endpoint, auth_server_issuer: server.issuer, pkce_verifier: verifier, - dpop_private_jwk: DPoP.dump(dpop_key) + dpop_private_jwk: DPoP.dump(dpop_key), + token_endpoint: server.token_endpoint }) end @@ -128,18 +128,8 @@ defp verify_issuer(%{auth_server_issuer: iss}, iss), do: :ok defp verify_issuer(_request, _iss), do: {:error, :issuer_mismatch} - defp rediscover(request) do - with {:ok, server} <- Discovery.discover(request.pds_host) do - if server.issuer == request.auth_server_issuer do - {:ok, server} - else - {:error, :issuer_mismatch} - end - end - end - - defp exchange(server, request, code) do - Flow.exchange_code(server, + defp exchange(request, code) do + Flow.exchange_code( client_id: Config.client_id(), client_jwk: Config.signing_key(), redirect_uri: Config.redirect_uri(), @@ -147,7 +137,9 @@ code_verifier: request.pkce_verifier, dpop_key: DPoP.load(request.dpop_private_jwk), expected_did: request.did, - pds_endpoint: request.pds_host + pds_endpoint: request.pds_host, + issuer: request.auth_server_issuer, + token_endpoint: request.token_endpoint ) end diff --git a/lib/annot_at_web/controllers/auth_html/new.html.heex b/lib/annot_at_web/controllers/auth_html/new.html.heex --- a/lib/annot_at_web/controllers/auth_html/new.html.heex +++ b/lib/annot_at_web/controllers/auth_html/new.html.heex @@ -29,7 +29,7 @@ diff --git a/test/annot_at/atproto/oauth/flow_test.exs b/test/annot_at/atproto/oauth/flow_test.exs --- a/test/annot_at/atproto/oauth/flow_test.exs +++ b/test/annot_at/atproto/oauth/flow_test.exs @@ -125,13 +125,13 @@ end end - describe "PAR.exchange_code/2" do + describe "PAR.exchange_code/1" do test "exchanges the code for a session", %{jwk: jwk} do expect(HTTP, :post_form, fn _url, _form, _headers -> {:ok, %{status: 200, body: @token_response, headers: %{}}} end) - assert {:ok, session} = Flow.exchange_code(@server, exchange_opts(jwk)) + assert {:ok, session} = Flow.exchange_code(exchange_opts(jwk)) assert @did == session.did assert "atproto" == session.scope assert "https://bsky.social" == session.issuer @@ -155,7 +155,7 @@ {:ok, %{status: 200, body: @token_response, headers: %{}}} end) - assert {:ok, _session} = Flow.exchange_code(@server, exchange_opts(jwk)) + assert {:ok, _session} = Flow.exchange_code(exchange_opts(jwk)) end test "rejects a token whose sub does not match the expected DID", %{jwk: jwk} do @@ -164,10 +164,7 @@ end) assert {:error, :did_mismatch} == - Flow.exchange_code( - @server, - exchange_opts(jwk, expected_did: "did:plc:someoneelse") - ) + Flow.exchange_code(exchange_opts(jwk, expected_did: "did:plc:someoneelse")) end test "propagates a token response parse error", %{jwk: jwk} do @@ -184,7 +181,7 @@ {:ok, %{status: 200, body: body, headers: %{}}} end) - assert {:error, {:missing, "sub"}} == Flow.exchange_code(@server, exchange_opts(jwk)) + assert {:error, {:missing, "sub"}} == Flow.exchange_code(exchange_opts(jwk)) end end @@ -242,7 +239,9 @@ dpop_key: jwk, expected_did: @did, pds_endpoint: @pds, - now: ~U[2026-01-01 00:00:00Z] + now: ~U[2026-01-01 00:00:00Z], + token_endpoint: "https://bsky.social/oauth/token", + issuer: "https://bsky.social" ], overrides ) diff --git a/test/annot_at/atproto/oauth/login_test.exs b/test/annot_at/atproto/oauth/login_test.exs --- a/test/annot_at/atproto/oauth/login_test.exs +++ b/test/annot_at/atproto/oauth/login_test.exs @@ -67,7 +67,6 @@ describe "Login.complete_login/1" do test "exchanges the code and persists the user and session", %{jwk: jwk} do create_request() - server = server() session = %Session{ did: @did, @@ -80,8 +79,7 @@ expires_at: ~U[2026-01-01 01:00:00Z] } - expect(Discovery, :discover, fn @pds -> {:ok, server} end) - expect(Flow, :exchange_code, fn ^server, _opts -> {:ok, session} end) + expect(Flow, :exchange_code, fn _opts -> {:ok, session} end) expect(Profile, :fetch, fn "jola.dev" -> {:ok, %{display_name: "Johanna", avatar_url: "https://cdn/av.jpg"}} @@ -98,7 +96,7 @@ end test "returns :invalid_state for an unknown state" do - reject(&Flow.exchange_code/2) + reject(&Flow.exchange_code/1) params = %{"code" => "x", "state" => "nope", "iss" => @issuer} assert {:error, :invalid_state} == Login.complete_login(params) @@ -106,7 +104,7 @@ test "rejects a callback whose iss does not match the stored issuer" do create_request() - reject(&Flow.exchange_code/2) + reject(&Flow.exchange_code/1) params = %{"code" => "c", "state" => "state-1", "iss" => "https://evil.example"} assert {:error, :login_failed} == Login.complete_login(params) @@ -164,7 +162,8 @@ pds_host: @pds, auth_server_issuer: @issuer, pkce_verifier: "verifier-1", - dpop_private_jwk: @dpop_jwk_json + dpop_private_jwk: @dpop_jwk_json, + token_endpoint: "#{@issuer}/oauth/token" }, overrides )