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
)