nandi/frqpublic Fork 0
ab639b6
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

The page is its own OAuth client

`Invalid return_to URL`, from freeq's broker. It finishes a login by
redirecting, and it only redirects to hosts on its own allowlist — I
checked: loopback and its own freeq origins go through, everything else
is a 400. A desktop is loopback and is fine. A page served from
modal.run is not, and no amount of client-side care changes that.

So the page does the AT Protocol OAuth itself. What makes that possible
is whose allowlist applies: an authorization server fetches the client's
metadata from its `client_id` URL and takes *that document* as the
authority on where a code may be sent. We serve it, so the redirect URI
is ours to declare.

This is the shape the ClojureDart build had, and most of it is that code
ported rather than rediscovered — PAR because bsky.social requires it,
the `use_dpop_nonce` retry that is the protocol rather than a fallback,
the protected-resource probe that falls back to the PDS itself because a
shard and an all-in-one host answer differently, and the `whoami` round
trip that settles the handle, proves the proof will be accepted, and
collects the nonce all at once. `frq_dpop.js` is the same file it was,
minus a callback wrapper that existed because cljd could not await a
Promise.

The seam is the new part. Everything OAuth does is asynchronous —
`fetch`, and WebCrypto for the key a token is bound to — and the core is
not, so the core asks and the host answers: a sign-in to start, a proof
to mint before each connection, a session to forget. `hostSignsIn` is a
constant each host sets, so the reducer says which of the two shapes it
is looking at at compile time and the desktop carries none of this.

The proof is minted per connect, because it carries an `iat` and a
single-use `jti` — one kept from the sign-in would be refused by the
time a reconnect offered it.

Two things the tests caught that reading would not have. `signIn` clears
`session` on every Connect, and on this host the sign-in happened on an
earlier page load — so the proof arrived and was fastened to an empty
session, and the payload said `pds-oauth` and carried nothing. The
browser session is kept apart from the per-connect one now. And
`conn.wanted` is sticky by design, so "did it dial" is a comparison and
not a read; two of my checks were asserting nothing until they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T10:27:05-07:00 Browse files
ab639b6 parent: d0cf0eb
modified .gitignore +1 -0
@@ -35,3 +35,4 @@ __pycache__/
3535 # The mirror `just serve` keeps of the Modal-built web bundle.
3636 .web-local/
3737 flutter/web/frq_core.js
38+flutter/web/client-metadata.json
@@ -35,3 +35,4 @@ __pycache__/
35 # The mirror `just serve` keeps of the Modal-built web bundle.35 # The mirror `just serve` keeps of the Modal-built web bundle.
36 .web-local/36 .web-local/
37 flutter/web/frq_core.js37 flutter/web/frq_core.js
38+flutter/web/client-metadata.json
modified .modal/web/Dockerfile +8 -0
@@ -32,6 +32,14 @@ COPY . .
3232 # because `flutter build web` copies that directory into the bundle -- which
3333 # is what makes the page's `<script src="frq_core.js">` resolve the same
3434 # either way.
35+# Where the deployed bundle answers, for the OAuth client metadata. An
36+# authorization server fetches that document from the `client_id` URL and
37+# takes it as the authority on where a code may be sent, so it has to name
38+# this origin — and a Dockerfile is where the build learns what it will be.
39+ARG FRQ_WEB_ORIGIN=https://codegod100--frq-web-serve.modal.run
40+RUN python3 tools/client-metadata.py "$FRQ_WEB_ORIGIN" \
41+ > flutter/web/client-metadata.json
42+
3543 RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\
3644 mkdir -p /src/build/web && cd /src/nim && \
3745 nim js -d:release --hints:off --path:src --path:web \
@@ -32,6 +32,14 @@ COPY . .
32 # because `flutter build web` copies that directory into the bundle -- which32 # because `flutter build web` copies that directory into the bundle -- which
33 # is what makes the page's `<script src="frq_core.js">` resolve the same33 # is what makes the page's `<script src="frq_core.js">` resolve the same
34 # either way.34 # either way.
35+# Where the deployed bundle answers, for the OAuth client metadata. An
36+# authorization server fetches that document from the `client_id` URL and
37+# takes it as the authority on where a code may be sent, so it has to name
38+# this origin — and a Dockerfile is where the build learns what it will be.
39+ARG FRQ_WEB_ORIGIN=https://codegod100--frq-web-serve.modal.run
40+RUN python3 tools/client-metadata.py "$FRQ_WEB_ORIGIN" \
41+ > flutter/web/client-metadata.json
42+
35 RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\43 RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\
36 mkdir -p /src/build/web && cd /src/nim && \44 mkdir -p /src/build/web && cd /src/nim && \
37 nim js -d:release --hints:off --path:src --path:web \45 nim js -d:release --hints:off --path:src --path:web \
added flutter/web/frq_dpop.js +121 -0
new file mode 100644
@@ -0,0 +1,121 @@
1+// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE.
2+//
3+// Unchanged from the build before this one, ClojureDart and all: what it does
4+// is WebCrypto — generateKey, sign, digest, exportKey — and that was already
5+// JavaScript then for the same reason it is now. Every one of those speaks in
6+// Promises, ArrayBuffers, JWK objects and algorithm records, and none of that
7+// crosses a language boundary well. What crosses here is a string.
8+//
9+// The Nim core never sees any of this. Signing is asynchronous and the core
10+// is not, which is the whole reason the browser sign-in lives out here.
11+(function () {
12+ 'use strict';
13+
14+ const enc = new TextEncoder();
15+
16+ const b64u = (buf) =>
17+ btoa(String.fromCharCode(...new Uint8Array(buf)))
18+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
19+
20+ const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
21+ const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
22+
23+ // The key pair this client proves it holds. One per sign-in, and it must
24+ // outlive a full-page redirect — the authorization leg leaves for the PDS
25+ // and comes back as a fresh load — so it is kept as JWK in localStorage
26+ // rather than as a non-extractable CryptoKey in IndexedDB.
27+ //
28+ // That is a deliberate trade and worth naming: an extractable key sits
29+ // beside the access token it is bound to, in the same store, and anything
30+ // that can read one can read the other. They share a lifetime and a blast
31+ // radius, so the key being extractable costs nothing the token does not
32+ // already cost — and IndexedDB interop through cljd would cost a great deal.
33+ const KEY = 'frq:dpop:jwk';
34+
35+ let cached = null;
36+
37+ async function keys() {
38+ if (cached) return cached;
39+ let jwk = null;
40+ try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; }
41+ if (!jwk) {
42+ const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
43+ jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
44+ try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ }
45+ }
46+ const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']);
47+ // The public half of the same key, which is what a proof carries in its
48+ // header. Derived from the private JWK by dropping the private fields
49+ // rather than exported separately, so the two cannot drift apart.
50+ const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
51+ cached = { priv, pub };
52+ return cached;
53+ }
54+
55+ async function jws(header, payload, priv) {
56+ const h = b64u(enc.encode(JSON.stringify(header)));
57+ const p = b64u(enc.encode(JSON.stringify(payload)));
58+ // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants —
59+ // no DER unwrapping, unlike most non-browser crypto libraries.
60+ const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p));
61+ return h + '.' + p + '.' + b64u(sig);
62+ }
63+
64+ // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no
65+ // convenient undefined, and an empty string is the honest "not this time".
66+ //
67+ // `ath` is the access token's SHA-256, and it is what lets a proof be
68+ // minted for a request this client will never make: freeq's SASL calls the
69+ // PDS's getSession on our behalf, with our token and our proof, and the PDS
70+ // checks that the proof names that token and that URL.
71+ async function proof(htm, htu, nonce, token) {
72+ const { priv, pub } = await keys();
73+ const payload = {
74+ jti: crypto.randomUUID(),
75+ htm: htm,
76+ htu: htu,
77+ iat: Math.floor(Date.now() / 1000),
78+ };
79+ if (nonce) payload.nonce = nonce;
80+ if (token) {
81+ payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token)));
82+ }
83+ return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv);
84+ }
85+
86+ // PKCE. The verifier is kept by the caller (it has to survive the redirect
87+ // and `frq.io` already knows how to keep things); this only makes the pair.
88+ function verifier() {
89+ return b64u(crypto.getRandomValues(new Uint8Array(32)));
90+ }
91+
92+ async function challenge(verifier) {
93+ return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier)));
94+ }
95+
96+ function random(n) {
97+ return b64u(crypto.getRandomValues(new Uint8Array(n)));
98+ }
99+
100+ // Forget the key. Called when a session is dropped: a DPoP key outliving
101+ // the token it was bound to is a key with nothing to prove.
102+ function forget() {
103+ cached = null;
104+ try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ }
105+ }
106+
107+ // Promises, plainly. This used to hand its answers back through node-style
108+ // `cb(err, value)` callbacks, for a reason that has gone: the caller was
109+ // ClojureDart, which could only reach JavaScript through `dart:js` — no
110+ // `promiseToFuture`, so a thenable could not be awaited from that side. The
111+ // caller is `frq_oauth.js` now, where a Promise is the native thing to
112+ // return and `await` is the native thing to do with it.
113+ window.frqDpop = {
114+ proof: proof,
115+ challenge: challenge,
116+ // Synchronous already: no crypto to await, just random bytes.
117+ verifier: verifier,
118+ random: random,
119+ forget: forget,
120+ };
121+})();
new file mode 100644
@@ -0,0 +1,121 @@
1+// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE.
2+//
3+// Unchanged from the build before this one, ClojureDart and all: what it does
4+// is WebCrypto — generateKey, sign, digest, exportKey — and that was already
5+// JavaScript then for the same reason it is now. Every one of those speaks in
6+// Promises, ArrayBuffers, JWK objects and algorithm records, and none of that
7+// crosses a language boundary well. What crosses here is a string.
8+//
9+// The Nim core never sees any of this. Signing is asynchronous and the core
10+// is not, which is the whole reason the browser sign-in lives out here.
11+(function () {
12+ 'use strict';
13+
14+ const enc = new TextEncoder();
15+
16+ const b64u = (buf) =>
17+ btoa(String.fromCharCode(...new Uint8Array(buf)))
18+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
19+
20+ const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
21+ const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
22+
23+ // The key pair this client proves it holds. One per sign-in, and it must
24+ // outlive a full-page redirect — the authorization leg leaves for the PDS
25+ // and comes back as a fresh load — so it is kept as JWK in localStorage
26+ // rather than as a non-extractable CryptoKey in IndexedDB.
27+ //
28+ // That is a deliberate trade and worth naming: an extractable key sits
29+ // beside the access token it is bound to, in the same store, and anything
30+ // that can read one can read the other. They share a lifetime and a blast
31+ // radius, so the key being extractable costs nothing the token does not
32+ // already cost — and IndexedDB interop through cljd would cost a great deal.
33+ const KEY = 'frq:dpop:jwk';
34+
35+ let cached = null;
36+
37+ async function keys() {
38+ if (cached) return cached;
39+ let jwk = null;
40+ try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; }
41+ if (!jwk) {
42+ const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
43+ jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
44+ try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ }
45+ }
46+ const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']);
47+ // The public half of the same key, which is what a proof carries in its
48+ // header. Derived from the private JWK by dropping the private fields
49+ // rather than exported separately, so the two cannot drift apart.
50+ const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
51+ cached = { priv, pub };
52+ return cached;
53+ }
54+
55+ async function jws(header, payload, priv) {
56+ const h = b64u(enc.encode(JSON.stringify(header)));
57+ const p = b64u(enc.encode(JSON.stringify(payload)));
58+ // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants —
59+ // no DER unwrapping, unlike most non-browser crypto libraries.
60+ const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p));
61+ return h + '.' + p + '.' + b64u(sig);
62+ }
63+
64+ // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no
65+ // convenient undefined, and an empty string is the honest "not this time".
66+ //
67+ // `ath` is the access token's SHA-256, and it is what lets a proof be
68+ // minted for a request this client will never make: freeq's SASL calls the
69+ // PDS's getSession on our behalf, with our token and our proof, and the PDS
70+ // checks that the proof names that token and that URL.
71+ async function proof(htm, htu, nonce, token) {
72+ const { priv, pub } = await keys();
73+ const payload = {
74+ jti: crypto.randomUUID(),
75+ htm: htm,
76+ htu: htu,
77+ iat: Math.floor(Date.now() / 1000),
78+ };
79+ if (nonce) payload.nonce = nonce;
80+ if (token) {
81+ payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token)));
82+ }
83+ return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv);
84+ }
85+
86+ // PKCE. The verifier is kept by the caller (it has to survive the redirect
87+ // and `frq.io` already knows how to keep things); this only makes the pair.
88+ function verifier() {
89+ return b64u(crypto.getRandomValues(new Uint8Array(32)));
90+ }
91+
92+ async function challenge(verifier) {
93+ return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier)));
94+ }
95+
96+ function random(n) {
97+ return b64u(crypto.getRandomValues(new Uint8Array(n)));
98+ }
99+
100+ // Forget the key. Called when a session is dropped: a DPoP key outliving
101+ // the token it was bound to is a key with nothing to prove.
102+ function forget() {
103+ cached = null;
104+ try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ }
105+ }
106+
107+ // Promises, plainly. This used to hand its answers back through node-style
108+ // `cb(err, value)` callbacks, for a reason that has gone: the caller was
109+ // ClojureDart, which could only reach JavaScript through `dart:js` — no
110+ // `promiseToFuture`, so a thenable could not be awaited from that side. The
111+ // caller is `frq_oauth.js` now, where a Promise is the native thing to
112+ // return and `await` is the native thing to do with it.
113+ window.frqDpop = {
114+ proof: proof,
115+ challenge: challenge,
116+ // Synchronous already: no crypto to await, just random bytes.
117+ verifier: verifier,
118+ random: random,
119+ forget: forget,
120+ };
121+})();
modified flutter/web/frq_host.js +41 -14
@@ -10,9 +10,10 @@
1010 // * the socket. The core says where it wants to be connected; this opens a
1111 // WebSocket to freeq's own bridge, feeds every line in, and sends
1212 // everything the core has queued.
13-// * the sign-in. The broker answers by redirecting the page back with a
14-// payload in the fragment, so a sign-in finishes on the *next* load —
15-// this reads it, hands it over, and takes it off the URL.
13+// * the sign-in, which `frq_oauth.js` does and this drives. The core asks
14+// for a sign-in, for a proof, or to be forgotten; each answer goes back
15+// through a function on the core. A sign-in finishes on the *next* load,
16+// because the authorization leg leaves the page.
1617 // * the profiles, which the core asks for through `fetch` (in the Nim, not
1718 // here), so there is nothing to do for them.
1819 //
@@ -83,17 +84,43 @@
8384 }
8485 }, 50);
8586
86- // The broker's answer, which arrives as a fragment on a fresh load.
87+ // The sign-in, which this page does itself — see `frq_oauth.js` for why
88+ // the broker cannot finish one here.
8789 //
88- // Taken off the URL once read: a payload carries a single-use token, and
89- // leaving it in the address bar means it is in the history, in whatever
90- // the reader pastes, and replayed by a refresh.
91- var payload = "";
92- if (window.location.hash) {
93- var h = window.location.hash.replace(/^#/, "");
94- payload = new URLSearchParams(h).get("oauth") || h.replace(/^oauth=/, "");
95- history.replaceState(null, "", window.location.pathname + window.location.search);
96- }
90+ // Three things it is asked for, each taken as it is read so that asking
91+ // twice does not do it twice: a sign-in to start, a proof to mint before a
92+ // connection, and a session to forget.
93+ setInterval(function () {
94+ var handle = frq.wantedSignIn();
95+ if (handle) {
96+ frqOauth.begin(handle).catch(function (e) {
97+ frq.signInFailed(String(e && e.message ? e.message : e));
98+ });
99+ }
100+ if (frq.needProof()) {
101+ frqOauth.prepare().then(
102+ function (s) { frq.proofReady(s.dpopProof); },
103+ function (e) {
104+ frq.signInFailed(String(e && e.message ? e.message : e));
105+ frq.proofReady("");
106+ });
107+ }
108+ if (frq.needForget()) frqOauth.forget();
109+ }, 50);
97110
98- frq.init(payload);
111+ frq.init("");
112+
113+ // Two ways a session arrives, and they mean different things. Coming back
114+ // from the authorization server is a sign-in the reader asked for and is
115+ // waiting on, so it connects; finding one in storage at load is not, so it
116+ // only fills the name in.
117+ frqOauth.resume().then(
118+ function (session) {
119+ if (session) frq.handoff(JSON.stringify(session));
120+ else {
121+ var saved = frqOauth.saved();
122+ if (saved) frq.restoreSession(JSON.stringify(saved));
123+ }
124+ },
125+ function (e) { frq.signInFailed(String(e && e.message ? e.message : e)); });
99126 })();
@@ -10,9 +10,10 @@
10 // * the socket. The core says where it wants to be connected; this opens a10 // * the socket. The core says where it wants to be connected; this opens a
11 // WebSocket to freeq's own bridge, feeds every line in, and sends11 // WebSocket to freeq's own bridge, feeds every line in, and sends
12 // everything the core has queued.12 // everything the core has queued.
13-// * the sign-in. The broker answers by redirecting the page back with a13+// * the sign-in, which `frq_oauth.js` does and this drives. The core asks
14-// payload in the fragment, so a sign-in finishes on the *next* load —14+// for a sign-in, for a proof, or to be forgotten; each answer goes back
15-// this reads it, hands it over, and takes it off the URL.15+// through a function on the core. A sign-in finishes on the *next* load,
16+// because the authorization leg leaves the page.
16 // * the profiles, which the core asks for through `fetch` (in the Nim, not17 // * the profiles, which the core asks for through `fetch` (in the Nim, not
17 // here), so there is nothing to do for them.18 // here), so there is nothing to do for them.
18 //19 //
@@ -83,17 +84,43 @@
83 }84 }
84 }, 50);85 }, 50);
85 86
86- // The broker's answer, which arrives as a fragment on a fresh load.87+ // The sign-in, which this page does itself — see `frq_oauth.js` for why
88+ // the broker cannot finish one here.
87 //89 //
88- // Taken off the URL once read: a payload carries a single-use token, and90+ // Three things it is asked for, each taken as it is read so that asking
89- // leaving it in the address bar means it is in the history, in whatever91+ // twice does not do it twice: a sign-in to start, a proof to mint before a
90- // the reader pastes, and replayed by a refresh.92+ // connection, and a session to forget.
91- var payload = "";93+ setInterval(function () {
92- if (window.location.hash) {94+ var handle = frq.wantedSignIn();
93- var h = window.location.hash.replace(/^#/, "");95+ if (handle) {
94- payload = new URLSearchParams(h).get("oauth") || h.replace(/^oauth=/, "");96+ frqOauth.begin(handle).catch(function (e) {
95- history.replaceState(null, "", window.location.pathname + window.location.search);97+ frq.signInFailed(String(e && e.message ? e.message : e));
96- }98+ });
99+ }
100+ if (frq.needProof()) {
101+ frqOauth.prepare().then(
102+ function (s) { frq.proofReady(s.dpopProof); },
103+ function (e) {
104+ frq.signInFailed(String(e && e.message ? e.message : e));
105+ frq.proofReady("");
106+ });
107+ }
108+ if (frq.needForget()) frqOauth.forget();
109+ }, 50);
97 110
98- frq.init(payload);111+ frq.init("");
112+
113+ // Two ways a session arrives, and they mean different things. Coming back
114+ // from the authorization server is a sign-in the reader asked for and is
115+ // waiting on, so it connects; finding one in storage at load is not, so it
116+ // only fills the name in.
117+ frqOauth.resume().then(
118+ function (session) {
119+ if (session) frq.handoff(JSON.stringify(session));
120+ else {
121+ var saved = frqOauth.saved();
122+ if (saved) frq.restoreSession(JSON.stringify(saved));
123+ }
124+ },
125+ function (e) { frq.signInFailed(String(e && e.message ? e.message : e)); });
99 })();126 })();
added flutter/web/frq_oauth.js +299 -0
new file mode 100644
@@ -0,0 +1,299 @@
1+// Signing in with Bluesky from a page, as an OAuth client of our own.
2+//
3+// Not freeq's broker. The broker finishes a login by redirecting to
4+// `return_to`, and it only redirects to hosts on its own allowlist — loopback
5+// and its own freeq origins. A build served from anywhere else can never
6+// finish a sign-in through it, whatever the client does; asking to be added
7+// to that list is somebody else's decision. The desktop is loopback and is
8+// fine. This page is not, and said so: `Invalid return_to URL`.
9+//
10+// So this does the AT Protocol OAuth itself. What makes that possible is
11+// whose allowlist applies: an authorization server fetches the client's
12+// metadata from its `client_id` URL and takes *that document* as the
13+// authority on where a code may be sent. We serve it — `client-metadata.json`
14+// beside this file — so the redirect URI is ours to declare.
15+//
16+// A public client with no secret, which a page could not keep anyway. What
17+// stands in for one is DPoP: every token is bound to a key this client proves
18+// it holds, which is also exactly what freeq's SASL `pds-oauth` verifies — it
19+// takes the token and a proof, calls the PDS's getSession with both, and
20+// believes the PDS.
21+//
22+// Ported from `flutter/src/frq/oauth/web.cljd`, which did this in
23+// ClojureDart. The comments that survive are the ones that cost somebody
24+// something to learn.
25+//
26+// The flow is two halves with a page load between them: `begin` leaves for
27+// the authorization server and does not return, and `resume` runs on the load
28+// that comes back.
29+
30+(function () {
31+ 'use strict';
32+
33+ const dpop = () => window.frqDpop;
34+
35+ // The app's root, which is both this client's identity and where a code
36+ // comes back. The origin and a bare slash — deliberately NOT
37+ // `location.pathname`: both values have to match `client-metadata.json`
38+ // exactly, and built from the current path a page opened at `/index.html`
39+ // asks for `/index.htmlclient-metadata.json` and is told, quite correctly,
40+ // Not Found.
41+ const origin = () => window.location.origin + '/';
42+ const clientId = () => origin() + 'client-metadata.json';
43+
44+ const PENDING = 'frq:oauth:pending';
45+ const SESSION = 'frq:oauth:session';
46+
47+ const load = (k) => {
48+ try { return JSON.parse(localStorage.getItem(k) || 'null'); }
49+ catch (e) { return null; }
50+ };
51+ const save = (k, v) => {
52+ try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* private mode */ }
53+ };
54+ const drop = (k) => {
55+ try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ }
56+ };
57+
58+ const trimSlash = (s) => String(s).replace(/\/+$/, '');
59+
60+ // One request, and two things a convenience wrapper would hide: a non-2xx
61+ // body, and the `DPoP-Nonce` header. Both are load-bearing — an
62+ // authorization server answers the first request of a flow with 400
63+ // `use_dpop_nonce` and the nonce to use, and that is not an error, it is
64+ // the handshake.
65+ async function http(method, url, headers, body) {
66+ const r = await fetch(url, { method: method, headers: headers, body: body });
67+ return {
68+ status: r.status,
69+ body: await r.text(),
70+ nonce: r.headers.get('dpop-nonce') || '',
71+ };
72+ }
73+
74+ const form = (pairs) =>
75+ pairs.map(([k, v]) => k + '=' + encodeURIComponent(String(v))).join('&');
76+
77+ // Which server authorizes for this PDS.
78+ //
79+ // Two shapes, and the difference is what a real account runs into. A PDS
80+ // shard — `puffball.us-east.host.bsky.network` and its siblings —
81+ // publishes `oauth-protected-resource` naming `https://bsky.social` as its
82+ // authorization server, and serves no authorization-server metadata of its
83+ // own. An all-in-one host like bsky.social IS the authorization server and
84+ // publishes no protected-resource document at all.
85+ //
86+ // So ask for the pointer, and fall back to the PDS itself when there is
87+ // none. Testing against bsky.social alone hid this entirely — the first
88+ // real handle went to a shard and stopped dead.
89+ async function authServer(base) {
90+ try {
91+ const r = await http('GET', base + '/.well-known/oauth-protected-resource', {}, null);
92+ if (r.status === 200) {
93+ const list = JSON.parse(r.body).authorization_servers;
94+ if (list && list.length) return list[0];
95+ }
96+ } catch (e) { /* fall through to the PDS itself */ }
97+ return base;
98+ }
99+
100+ async function discover(pds) {
101+ const base = trimSlash(pds);
102+ const as = trimSlash(await authServer(base));
103+ const r = await http('GET', as + '/.well-known/oauth-authorization-server', {}, null);
104+ if (r.status !== 200) throw new Error('No OAuth metadata at ' + as);
105+ const m = JSON.parse(r.body);
106+ return {
107+ par: m.pushed_authorization_request_endpoint,
108+ authorize: m.authorization_endpoint,
109+ token: m.token_endpoint,
110+ };
111+ }
112+
113+ // POST a form with a freshly minted proof, retrying once when the server
114+ // asks for a nonce. The retry is the protocol and not a fallback: a client
115+ // has no way to know the first nonce, so the first request of every flow is
116+ // answered with 400 `use_dpop_nonce` and the nonce to use.
117+ async function postForm(url, body, token) {
118+ const send = async (nonce) => {
119+ const p = await dpop().proof('POST', url, nonce, token || '');
120+ return http('POST', url,
121+ { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': p }, body);
122+ };
123+ const first = await send('');
124+ if (first.status >= 400 && first.body.includes('use_dpop_nonce') && first.nonce) {
125+ return send(first.nonce);
126+ }
127+ return first;
128+ }
129+
130+ // An authenticated GET carrying a proof, retrying once for a nonce — the
131+ // same handshake one method over. A PDS answers the first
132+ // DPoP-authenticated request of a session with 401 and the nonce it wants.
133+ async function getWithDpop(url, token, nonce) {
134+ const p = await dpop().proof('GET', url, nonce || '', token);
135+ const r = await http('GET', url,
136+ { 'Authorization': 'DPoP ' + token, 'DPoP': p }, null);
137+ if (r.status >= 400 && r.body.includes('use_dpop_nonce') && r.nonce && !nonce) {
138+ return getWithDpop(url, token, r.nonce);
139+ }
140+ return r;
141+ }
142+
143+ const sessionUrl = (pds) => trimSlash(pds) + '/xrpc/com.atproto.server.getSession';
144+
145+ // Who the token belongs to, asked of the PDS. Three things at once, which
146+ // is why it is worth a round trip.
147+ //
148+ // It settles the handle — the token response carries `sub`, a DID, and
149+ // nothing a person would recognise, and without a handle there is no nick
150+ // to derive, which is how an OAuth sign-in once arrived on the server
151+ // calling itself `frq-guest`.
152+ //
153+ // It proves the proof is accepted before one is handed to freeq, since this
154+ // is the very call freeq will make with it. And it collects the nonce the
155+ // PDS wants, so the proof minted at connect carries one already.
156+ async function whoami(pds, token) {
157+ const r = await getWithDpop(sessionUrl(pds), token, '');
158+ if (r.status !== 200) return null;
159+ const j = JSON.parse(r.body);
160+ return { handle: j.handle || '', did: j.did || '', nonce: r.nonce || '' };
161+ }
162+
163+ // Resolving an identity. The same two steps `frq/atproto.nim` takes on the
164+ // desktop, in the language that has `fetch`.
165+ async function resolveHandle(handle) {
166+ const h = String(handle).trim().replace(/^@/, '');
167+ const r = await http('GET',
168+ 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' +
169+ encodeURIComponent(h), {}, null);
170+ if (r.status !== 200) throw new Error('Could not resolve ' + h);
171+ return JSON.parse(r.body).did;
172+ }
173+
174+ async function pdsFor(did) {
175+ const url = did.startsWith('did:plc:')
176+ ? 'https://plc.directory/' + did
177+ : 'https://' + did.replace(/^did:web:/, '') + '/.well-known/did.json';
178+ const r = await http('GET', url, {}, null);
179+ if (r.status !== 200) throw new Error('Could not look up ' + did);
180+ const doc = JSON.parse(r.body);
181+ for (const svc of doc.service || []) {
182+ if (svc.type === 'AtprotoPersonalDataServer') return svc.serviceEndpoint;
183+ }
184+ throw new Error('No PDS endpoint for ' + did);
185+ }
186+
187+ // Push the request, then leave for the authorization server.
188+ //
189+ // PAR and not a plain authorize URL: `require_pushed_authorization_requests`
190+ // is true at bsky.social, so the parameters go up over the back channel
191+ // first and the browser carries only the `request_uri` that comes back.
192+ async function begin(handle) {
193+ const did = await resolveHandle(handle);
194+ const pds = await pdsFor(did);
195+ const ends = await discover(pds);
196+ const verifier = dpop().verifier();
197+ const challenge = await dpop().challenge(verifier);
198+ const state = dpop().random(16);
199+
200+ const body = form([
201+ ['client_id', clientId()],
202+ ['redirect_uri', origin()],
203+ ['response_type', 'code'],
204+ ['scope', 'atproto transition:generic'],
205+ ['state', state],
206+ ['code_challenge', challenge],
207+ ['code_challenge_method', 'S256'],
208+ // A hint and not an assertion — the reader still chooses at the
209+ // authorization page.
210+ ['login_hint', handle],
211+ ]);
212+ const r = await postForm(ends.par, body, null);
213+ if (r.status !== 201) throw new Error('Authorization request refused: ' + r.body);
214+
215+ const requestUri = JSON.parse(r.body).request_uri;
216+ save(PENDING, { verifier, state, did, handle, pds, token: ends.token });
217+ window.location.assign(
218+ ends.authorize + '?client_id=' + encodeURIComponent(clientId()) +
219+ '&request_uri=' + encodeURIComponent(requestUri));
220+ }
221+
222+ // Finish a sign-in that left this page and came back. Returns the session,
223+ // or null when this load is not one.
224+ async function resume() {
225+ const params = new URLSearchParams(window.location.search);
226+ const code = params.get('code');
227+ const state = params.get('state');
228+ const pending = load(PENDING);
229+ if (!code || !pending) return null;
230+
231+ // `replaceState` rather than assigning to `location`, which would push a
232+ // history entry and leave a Back button that redeems a spent code.
233+ try { history.replaceState(null, '', origin()); } catch (e) { /* nothing */ }
234+
235+ // State is the CSRF binding: a code arriving with a state we did not
236+ // issue is not ours, and redeeming it would be the attack this prevents.
237+ if (state !== pending.state) throw new Error('state did not match');
238+
239+ const r = await postForm(pending.token, form([
240+ ['grant_type', 'authorization_code'],
241+ ['code', code],
242+ ['redirect_uri', origin()],
243+ ['client_id', clientId()],
244+ ['code_verifier', pending.verifier],
245+ ]), null);
246+ if (r.status !== 200) throw new Error('Sign-in failed: ' + r.body);
247+
248+ const t = JSON.parse(r.body);
249+ const who = await whoami(pending.pds, t.access_token);
250+ const session = {
251+ did: (who && who.did) || t.sub || pending.did,
252+ handle: (who && who.handle) || pending.handle || '',
253+ accessJwt: t.access_token,
254+ refresh: t.refresh_token || '',
255+ pds: pending.pds,
256+ dpopNonce: (who && who.nonce) || '',
257+ };
258+ drop(PENDING);
259+ save(SESSION, session);
260+ return session;
261+ }
262+
263+ const saved = () => load(SESSION);
264+
265+ function forget() {
266+ drop(SESSION);
267+ drop(PENDING);
268+ // A DPoP key outliving the token it was bound to is a key with nothing
269+ // to prove.
270+ dpop().forget();
271+ }
272+
273+ // Mint the proof freeq will present to the PDS on our behalf.
274+ //
275+ // For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the
276+ // access token, because that is the exact request freeq makes with it.
277+ // Minted per connect: a proof has an `iat` and a single-use `jti`, so one
278+ // kept from sign-in would be refused by the time a reconnect offered it.
279+ //
280+ // The token is asked about first, for the reason the ClojureDart did: an
281+ // access token lives about an hour, and the only thing that comes back
282+ // through IRC when it has expired is a bare failure.
283+ async function prepare() {
284+ const s = saved();
285+ if (!s) throw new Error('not signed in');
286+ const who = await whoami(s.pds, s.accessJwt);
287+ if (who) {
288+ if (who.handle) s.handle = who.handle;
289+ if (who.did) s.did = who.did;
290+ if (who.nonce) s.dpopNonce = who.nonce;
291+ save(SESSION, s);
292+ }
293+ s.dpopProof = await dpop().proof(
294+ 'GET', sessionUrl(s.pds), s.dpopNonce || '', s.accessJwt);
295+ return s;
296+ }
297+
298+ window.frqOauth = { begin, resume, saved, forget, prepare };
299+})();
new file mode 100644
@@ -0,0 +1,299 @@
1+// Signing in with Bluesky from a page, as an OAuth client of our own.
2+//
3+// Not freeq's broker. The broker finishes a login by redirecting to
4+// `return_to`, and it only redirects to hosts on its own allowlist — loopback
5+// and its own freeq origins. A build served from anywhere else can never
6+// finish a sign-in through it, whatever the client does; asking to be added
7+// to that list is somebody else's decision. The desktop is loopback and is
8+// fine. This page is not, and said so: `Invalid return_to URL`.
9+//
10+// So this does the AT Protocol OAuth itself. What makes that possible is
11+// whose allowlist applies: an authorization server fetches the client's
12+// metadata from its `client_id` URL and takes *that document* as the
13+// authority on where a code may be sent. We serve it — `client-metadata.json`
14+// beside this file — so the redirect URI is ours to declare.
15+//
16+// A public client with no secret, which a page could not keep anyway. What
17+// stands in for one is DPoP: every token is bound to a key this client proves
18+// it holds, which is also exactly what freeq's SASL `pds-oauth` verifies — it
19+// takes the token and a proof, calls the PDS's getSession with both, and
20+// believes the PDS.
21+//
22+// Ported from `flutter/src/frq/oauth/web.cljd`, which did this in
23+// ClojureDart. The comments that survive are the ones that cost somebody
24+// something to learn.
25+//
26+// The flow is two halves with a page load between them: `begin` leaves for
27+// the authorization server and does not return, and `resume` runs on the load
28+// that comes back.
29+
30+(function () {
31+ 'use strict';
32+
33+ const dpop = () => window.frqDpop;
34+
35+ // The app's root, which is both this client's identity and where a code
36+ // comes back. The origin and a bare slash — deliberately NOT
37+ // `location.pathname`: both values have to match `client-metadata.json`
38+ // exactly, and built from the current path a page opened at `/index.html`
39+ // asks for `/index.htmlclient-metadata.json` and is told, quite correctly,
40+ // Not Found.
41+ const origin = () => window.location.origin + '/';
42+ const clientId = () => origin() + 'client-metadata.json';
43+
44+ const PENDING = 'frq:oauth:pending';
45+ const SESSION = 'frq:oauth:session';
46+
47+ const load = (k) => {
48+ try { return JSON.parse(localStorage.getItem(k) || 'null'); }
49+ catch (e) { return null; }
50+ };
51+ const save = (k, v) => {
52+ try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* private mode */ }
53+ };
54+ const drop = (k) => {
55+ try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ }
56+ };
57+
58+ const trimSlash = (s) => String(s).replace(/\/+$/, '');
59+
60+ // One request, and two things a convenience wrapper would hide: a non-2xx
61+ // body, and the `DPoP-Nonce` header. Both are load-bearing — an
62+ // authorization server answers the first request of a flow with 400
63+ // `use_dpop_nonce` and the nonce to use, and that is not an error, it is
64+ // the handshake.
65+ async function http(method, url, headers, body) {
66+ const r = await fetch(url, { method: method, headers: headers, body: body });
67+ return {
68+ status: r.status,
69+ body: await r.text(),
70+ nonce: r.headers.get('dpop-nonce') || '',
71+ };
72+ }
73+
74+ const form = (pairs) =>
75+ pairs.map(([k, v]) => k + '=' + encodeURIComponent(String(v))).join('&');
76+
77+ // Which server authorizes for this PDS.
78+ //
79+ // Two shapes, and the difference is what a real account runs into. A PDS
80+ // shard — `puffball.us-east.host.bsky.network` and its siblings —
81+ // publishes `oauth-protected-resource` naming `https://bsky.social` as its
82+ // authorization server, and serves no authorization-server metadata of its
83+ // own. An all-in-one host like bsky.social IS the authorization server and
84+ // publishes no protected-resource document at all.
85+ //
86+ // So ask for the pointer, and fall back to the PDS itself when there is
87+ // none. Testing against bsky.social alone hid this entirely — the first
88+ // real handle went to a shard and stopped dead.
89+ async function authServer(base) {
90+ try {
91+ const r = await http('GET', base + '/.well-known/oauth-protected-resource', {}, null);
92+ if (r.status === 200) {
93+ const list = JSON.parse(r.body).authorization_servers;
94+ if (list && list.length) return list[0];
95+ }
96+ } catch (e) { /* fall through to the PDS itself */ }
97+ return base;
98+ }
99+
100+ async function discover(pds) {
101+ const base = trimSlash(pds);
102+ const as = trimSlash(await authServer(base));
103+ const r = await http('GET', as + '/.well-known/oauth-authorization-server', {}, null);
104+ if (r.status !== 200) throw new Error('No OAuth metadata at ' + as);
105+ const m = JSON.parse(r.body);
106+ return {
107+ par: m.pushed_authorization_request_endpoint,
108+ authorize: m.authorization_endpoint,
109+ token: m.token_endpoint,
110+ };
111+ }
112+
113+ // POST a form with a freshly minted proof, retrying once when the server
114+ // asks for a nonce. The retry is the protocol and not a fallback: a client
115+ // has no way to know the first nonce, so the first request of every flow is
116+ // answered with 400 `use_dpop_nonce` and the nonce to use.
117+ async function postForm(url, body, token) {
118+ const send = async (nonce) => {
119+ const p = await dpop().proof('POST', url, nonce, token || '');
120+ return http('POST', url,
121+ { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': p }, body);
122+ };
123+ const first = await send('');
124+ if (first.status >= 400 && first.body.includes('use_dpop_nonce') && first.nonce) {
125+ return send(first.nonce);
126+ }
127+ return first;
128+ }
129+
130+ // An authenticated GET carrying a proof, retrying once for a nonce — the
131+ // same handshake one method over. A PDS answers the first
132+ // DPoP-authenticated request of a session with 401 and the nonce it wants.
133+ async function getWithDpop(url, token, nonce) {
134+ const p = await dpop().proof('GET', url, nonce || '', token);
135+ const r = await http('GET', url,
136+ { 'Authorization': 'DPoP ' + token, 'DPoP': p }, null);
137+ if (r.status >= 400 && r.body.includes('use_dpop_nonce') && r.nonce && !nonce) {
138+ return getWithDpop(url, token, r.nonce);
139+ }
140+ return r;
141+ }
142+
143+ const sessionUrl = (pds) => trimSlash(pds) + '/xrpc/com.atproto.server.getSession';
144+
145+ // Who the token belongs to, asked of the PDS. Three things at once, which
146+ // is why it is worth a round trip.
147+ //
148+ // It settles the handle — the token response carries `sub`, a DID, and
149+ // nothing a person would recognise, and without a handle there is no nick
150+ // to derive, which is how an OAuth sign-in once arrived on the server
151+ // calling itself `frq-guest`.
152+ //
153+ // It proves the proof is accepted before one is handed to freeq, since this
154+ // is the very call freeq will make with it. And it collects the nonce the
155+ // PDS wants, so the proof minted at connect carries one already.
156+ async function whoami(pds, token) {
157+ const r = await getWithDpop(sessionUrl(pds), token, '');
158+ if (r.status !== 200) return null;
159+ const j = JSON.parse(r.body);
160+ return { handle: j.handle || '', did: j.did || '', nonce: r.nonce || '' };
161+ }
162+
163+ // Resolving an identity. The same two steps `frq/atproto.nim` takes on the
164+ // desktop, in the language that has `fetch`.
165+ async function resolveHandle(handle) {
166+ const h = String(handle).trim().replace(/^@/, '');
167+ const r = await http('GET',
168+ 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' +
169+ encodeURIComponent(h), {}, null);
170+ if (r.status !== 200) throw new Error('Could not resolve ' + h);
171+ return JSON.parse(r.body).did;
172+ }
173+
174+ async function pdsFor(did) {
175+ const url = did.startsWith('did:plc:')
176+ ? 'https://plc.directory/' + did
177+ : 'https://' + did.replace(/^did:web:/, '') + '/.well-known/did.json';
178+ const r = await http('GET', url, {}, null);
179+ if (r.status !== 200) throw new Error('Could not look up ' + did);
180+ const doc = JSON.parse(r.body);
181+ for (const svc of doc.service || []) {
182+ if (svc.type === 'AtprotoPersonalDataServer') return svc.serviceEndpoint;
183+ }
184+ throw new Error('No PDS endpoint for ' + did);
185+ }
186+
187+ // Push the request, then leave for the authorization server.
188+ //
189+ // PAR and not a plain authorize URL: `require_pushed_authorization_requests`
190+ // is true at bsky.social, so the parameters go up over the back channel
191+ // first and the browser carries only the `request_uri` that comes back.
192+ async function begin(handle) {
193+ const did = await resolveHandle(handle);
194+ const pds = await pdsFor(did);
195+ const ends = await discover(pds);
196+ const verifier = dpop().verifier();
197+ const challenge = await dpop().challenge(verifier);
198+ const state = dpop().random(16);
199+
200+ const body = form([
201+ ['client_id', clientId()],
202+ ['redirect_uri', origin()],
203+ ['response_type', 'code'],
204+ ['scope', 'atproto transition:generic'],
205+ ['state', state],
206+ ['code_challenge', challenge],
207+ ['code_challenge_method', 'S256'],
208+ // A hint and not an assertion — the reader still chooses at the
209+ // authorization page.
210+ ['login_hint', handle],
211+ ]);
212+ const r = await postForm(ends.par, body, null);
213+ if (r.status !== 201) throw new Error('Authorization request refused: ' + r.body);
214+
215+ const requestUri = JSON.parse(r.body).request_uri;
216+ save(PENDING, { verifier, state, did, handle, pds, token: ends.token });
217+ window.location.assign(
218+ ends.authorize + '?client_id=' + encodeURIComponent(clientId()) +
219+ '&request_uri=' + encodeURIComponent(requestUri));
220+ }
221+
222+ // Finish a sign-in that left this page and came back. Returns the session,
223+ // or null when this load is not one.
224+ async function resume() {
225+ const params = new URLSearchParams(window.location.search);
226+ const code = params.get('code');
227+ const state = params.get('state');
228+ const pending = load(PENDING);
229+ if (!code || !pending) return null;
230+
231+ // `replaceState` rather than assigning to `location`, which would push a
232+ // history entry and leave a Back button that redeems a spent code.
233+ try { history.replaceState(null, '', origin()); } catch (e) { /* nothing */ }
234+
235+ // State is the CSRF binding: a code arriving with a state we did not
236+ // issue is not ours, and redeeming it would be the attack this prevents.
237+ if (state !== pending.state) throw new Error('state did not match');
238+
239+ const r = await postForm(pending.token, form([
240+ ['grant_type', 'authorization_code'],
241+ ['code', code],
242+ ['redirect_uri', origin()],
243+ ['client_id', clientId()],
244+ ['code_verifier', pending.verifier],
245+ ]), null);
246+ if (r.status !== 200) throw new Error('Sign-in failed: ' + r.body);
247+
248+ const t = JSON.parse(r.body);
249+ const who = await whoami(pending.pds, t.access_token);
250+ const session = {
251+ did: (who && who.did) || t.sub || pending.did,
252+ handle: (who && who.handle) || pending.handle || '',
253+ accessJwt: t.access_token,
254+ refresh: t.refresh_token || '',
255+ pds: pending.pds,
256+ dpopNonce: (who && who.nonce) || '',
257+ };
258+ drop(PENDING);
259+ save(SESSION, session);
260+ return session;
261+ }
262+
263+ const saved = () => load(SESSION);
264+
265+ function forget() {
266+ drop(SESSION);
267+ drop(PENDING);
268+ // A DPoP key outliving the token it was bound to is a key with nothing
269+ // to prove.
270+ dpop().forget();
271+ }
272+
273+ // Mint the proof freeq will present to the PDS on our behalf.
274+ //
275+ // For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the
276+ // access token, because that is the exact request freeq makes with it.
277+ // Minted per connect: a proof has an `iat` and a single-use `jti`, so one
278+ // kept from sign-in would be refused by the time a reconnect offered it.
279+ //
280+ // The token is asked about first, for the reason the ClojureDart did: an
281+ // access token lives about an hour, and the only thing that comes back
282+ // through IRC when it has expired is a bare failure.
283+ async function prepare() {
284+ const s = saved();
285+ if (!s) throw new Error('not signed in');
286+ const who = await whoami(s.pds, s.accessJwt);
287+ if (who) {
288+ if (who.handle) s.handle = who.handle;
289+ if (who.did) s.did = who.did;
290+ if (who.nonce) s.dpopNonce = who.nonce;
291+ save(SESSION, s);
292+ }
293+ s.dpopProof = await dpop().proof(
294+ 'GET', sessionUrl(s.pds), s.dpopNonce || '', s.accessJwt);
295+ return s;
296+ }
297+
298+ window.frqOauth = { begin, resume, saved, forget, prepare };
299+})();
modified flutter/web/index.html +6 -3
@@ -32,11 +32,14 @@
3232 as it starts, and a core that might not have parsed yet is a race nobody
3333 would enjoy debugging.
3434
35- This is where the ClojureDart build loaded `frq_dpop.js`, a WebCrypto
36- helper for an OAuth client written in Dart. The broker does that work
37- now, and the client asking for it is Nim.
35+ `frq_dpop.js` is the same WebCrypto helper the ClojureDart build loaded,
36+ unchanged: ES256 keys, DPoP proofs, PKCE. `frq_oauth.js` is the flow that
37+ uses it, which this page needs because freeq's broker will only redirect
38+ back to loopback or its own origins — and this is neither.
3839 -->
3940 <script src="frq_core.js"></script>
41+ <script src="frq_dpop.js"></script>
42+ <script src="frq_oauth.js"></script>
4043 <script src="frq_host.js"></script>
4144 </head>
4245 <body>
@@ -32,11 +32,14 @@
32 as it starts, and a core that might not have parsed yet is a race nobody32 as it starts, and a core that might not have parsed yet is a race nobody
33 would enjoy debugging.33 would enjoy debugging.
34 34
35- This is where the ClojureDart build loaded `frq_dpop.js`, a WebCrypto35+ `frq_dpop.js` is the same WebCrypto helper the ClojureDart build loaded,
36- helper for an OAuth client written in Dart. The broker does that work36+ unchanged: ES256 keys, DPoP proofs, PKCE. `frq_oauth.js` is the flow that
37- now, and the client asking for it is Nim.37+ uses it, which this page needs because freeq's broker will only redirect
38+ back to loopback or its own origins — and this is neither.
38 -->39 -->
39 <script src="frq_core.js"></script>40 <script src="frq_core.js"></script>
41+ <script src="frq_dpop.js"></script>
42+ <script src="frq_oauth.js"></script>
40 <script src="frq_host.js"></script>43 <script src="frq_host.js"></script>
41 </head>44 </head>
42 <body>45 <body>
modified justfile +11 -0
@@ -182,6 +182,17 @@ _web-bundle:
182182 cd "{{root}}"
183183 just _nim-js
184184 cp build/web/frq_core.js flutter/web/frq_core.js
185+ # Where this bundle will be served from. Every value in the client
186+ # metadata is absolute — the `client_id` has to equal the URL the document
187+ # is served from — so the origin is a build input rather than something
188+ # the page can work out for itself.
189+ #
190+ # The default is the dev server `just run web` starts. A page served from
191+ # there cannot complete a Bluesky sign-in: an authorization server will
192+ # not fetch client metadata over http from a non-loopback host, and this
193+ # is `localhost` only when it is. Guest works everywhere.
194+ python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \
195+ > flutter/web/client-metadata.json
185196 exec "{{tc}}" exec -- bash -euo pipefail -c '
186197 cd flutter
187198 flutter pub get
@@ -182,6 +182,17 @@ _web-bundle:
182 cd "{{root}}"182 cd "{{root}}"
183 just _nim-js183 just _nim-js
184 cp build/web/frq_core.js flutter/web/frq_core.js184 cp build/web/frq_core.js flutter/web/frq_core.js
185+ # Where this bundle will be served from. Every value in the client
186+ # metadata is absolute — the `client_id` has to equal the URL the document
187+ # is served from — so the origin is a build input rather than something
188+ # the page can work out for itself.
189+ #
190+ # The default is the dev server `just run web` starts. A page served from
191+ # there cannot complete a Bluesky sign-in: an authorization server will
192+ # not fetch client metadata over http from a non-loopback host, and this
193+ # is `localhost` only when it is. Guest works everywhere.
194+ python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \
195+ > flutter/web/client-metadata.json
185 exec "{{tc}}" exec -- bash -euo pipefail -c '196 exec "{{tc}}" exec -- bash -euo pipefail -c '
186 cd flutter197 cd flutter
187 flutter pub get198 flutter pub get
modified nim/src/frq/atprotocore.nim +24 -1
@@ -14,7 +14,8 @@ const
1414
1515 type
1616 SessionKind* = enum
17- skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token"
17+ skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token",
18+ skPdsOauth = "pds-oauth"
1819
1920 Session* = object
2021 kind*: SessionKind
@@ -23,6 +24,15 @@ type
2324 accessJwt*: string
2425 pds*: string
2526 token*: string ## a web-token from the broker, where that is the kind
27+ dpopProof*: string
28+ ## The proof freeq presents to the PDS on this client's behalf, for
29+ ## `pds-oauth`. Minted by the host rather than here: it is WebCrypto in
30+ ## a browser and this module does no I/O — and it is minted per connect,
31+ ## because a proof carries an `iat` and a single-use `jti`, so one kept
32+ ## from sign-in would be refused by the time a reconnect offered it.
33+ dpopNonce*: string
34+ ## The nonce the PDS last asked for, carried into the next proof so the
35+ ## first authenticated call does not cost a round trip to be told.
2636
2737 AtprotoError* = object of CatchableError
2838
@@ -54,6 +64,19 @@ proc saslResponse*(s: Session, nonce: string): string =
5464 case s.kind
5565 of skWebToken:
5666 b64url($(%*{"did": "", "method": "web-token", "signature": s.token}))
67+ of skPdsOauth:
68+ # An OAuth access token, which the server cannot simply present to the
69+ # PDS: a DPoP token is bound to a key, and the holder has to prove it. So
70+ # the proof travels with it. freeq calls getSession with our token and our
71+ # proof, and the PDS checks that the proof names that method, that URL and
72+ # that token — which is what lets a proof be minted for a request this
73+ # client never makes.
74+ b64url($(%*{"did": s.did,
75+ "signature": s.accessJwt,
76+ "method": "pds-oauth",
77+ "pds_url": s.pds,
78+ "dpop_proof": s.dpopProof,
79+ "challenge_nonce": nonce}))
5780 else:
5881 b64url($(%*{"did": s.did,
5982 "signature": s.accessJwt,
@@ -14,7 +14,8 @@ const
14 14
15 type15 type
16 SessionKind* = enum16 SessionKind* = enum
17- skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token"17+ skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token",
18+ skPdsOauth = "pds-oauth"
18 19
19 Session* = object20 Session* = object
20 kind*: SessionKind21 kind*: SessionKind
@@ -23,6 +24,15 @@ type
23 accessJwt*: string24 accessJwt*: string
24 pds*: string25 pds*: string
25 token*: string ## a web-token from the broker, where that is the kind26 token*: string ## a web-token from the broker, where that is the kind
27+ dpopProof*: string
28+ ## The proof freeq presents to the PDS on this client's behalf, for
29+ ## `pds-oauth`. Minted by the host rather than here: it is WebCrypto in
30+ ## a browser and this module does no I/O — and it is minted per connect,
31+ ## because a proof carries an `iat` and a single-use `jti`, so one kept
32+ ## from sign-in would be refused by the time a reconnect offered it.
33+ dpopNonce*: string
34+ ## The nonce the PDS last asked for, carried into the next proof so the
35+ ## first authenticated call does not cost a round trip to be told.
26 36
27 AtprotoError* = object of CatchableError37 AtprotoError* = object of CatchableError
28 38
@@ -54,6 +64,19 @@ proc saslResponse*(s: Session, nonce: string): string =
54 case s.kind64 case s.kind
55 of skWebToken:65 of skWebToken:
56 b64url($(%*{"did": "", "method": "web-token", "signature": s.token}))66 b64url($(%*{"did": "", "method": "web-token", "signature": s.token}))
67+ of skPdsOauth:
68+ # An OAuth access token, which the server cannot simply present to the
69+ # PDS: a DPoP token is bound to a key, and the holder has to prove it. So
70+ # the proof travels with it. freeq calls getSession with our token and our
71+ # proof, and the PDS checks that the proof names that method, that URL and
72+ # that token — which is what lets a proof be minted for a request this
73+ # client never makes.
74+ b64url($(%*{"did": s.did,
75+ "signature": s.accessJwt,
76+ "method": "pds-oauth",
77+ "pds_url": s.pds,
78+ "dpop_proof": s.dpopProof,
79+ "challenge_nonce": nonce}))
57 else:80 else:
58 b64url($(%*{"did": s.did,81 b64url($(%*{"did": s.did,
59 "signature": s.accessJwt,82 "signature": s.accessJwt,
modified nim/src/frq/cells.nim +5 -0
@@ -88,6 +88,11 @@ type
8888 # The durable half of an OAuth sign-in. The web-token beside it is
8989 # single-use, so a reconnect mints a fresh one from this rather than
9090 # replaying the old.
91+ hasSession*: bool
92+ ## Whether the host is holding a sign-in this client can connect with.
93+ ## A broker token on the desktop; an OAuth session in `localStorage` on
94+ ## the web, which the core never sees the whole of.
95+
9196 brokerToken*: string
9297 apiBearer*: string
9398 loginUrl*: string ## shown while the browser is open
@@ -88,6 +88,11 @@ type
88 # The durable half of an OAuth sign-in. The web-token beside it is88 # The durable half of an OAuth sign-in. The web-token beside it is
89 # single-use, so a reconnect mints a fresh one from this rather than89 # single-use, so a reconnect mints a fresh one from this rather than
90 # replaying the old.90 # replaying the old.
91+ hasSession*: bool
92+ ## Whether the host is holding a sign-in this client can connect with.
93+ ## A broker token on the desktop; an OAuth session in `localStorage` on
94+ ## the web, which the core never sees the whole of.
95+
91 brokerToken*: string96 brokerToken*: string
92 apiBearer*: string97 apiBearer*: string
93 loginUrl*: string ## shown while the browser is open98 loginUrl*: string ## shown while the browser is open
modified nim/src/frq/oauth.nim +12 -1
@@ -18,7 +18,13 @@ import frq/[trace, eintr]
1818 import frq/oauthcore
1919 export oauthcore
2020
21-const loginTimeout = 5 * 60 ## seconds; a login page nobody finishes
21+const
22+ loginTimeout = 5 * 60 ## seconds; a login page nobody finishes
23+
24+ hostSignsIn* = false
25+ ## This host signs in through freeq's broker, which is allowed to redirect
26+ ## to loopback — and a desktop is loopback. A browser is not, so the web
27+ ## build says `true` and does the OAuth itself. See `nim/web/frq/oauth`.
2228
2329 proc refreshSession*(broker, brokerToken: string): Tokens =
2430 ## Mint a fresh web-token from the durable broker token.
@@ -233,6 +239,11 @@ proc begin*(broker, handle: string, openBrowser = true) =
233239 LoginReq(broker: broker, handle: handle,
234240 openBrowser: openBrowser))
235241
242+proc forgetHostSession*() = discard
243+ ## Nothing of a sign-in lives on this side: the broker token is the core's,
244+ ## and `session.forget` has already dropped it. The web host holds a token
245+ ## and a key and has real work to do here.
246+
236247 proc cancel*() =
237248 ## Stop waiting. The thread notices within the second it is sleeping in.
238249 if running: cancelled = true
@@ -18,7 +18,13 @@ import frq/[trace, eintr]
18 import frq/oauthcore18 import frq/oauthcore
19 export oauthcore19 export oauthcore
20 20
21-const loginTimeout = 5 * 60 ## seconds; a login page nobody finishes21+const
22+ loginTimeout = 5 * 60 ## seconds; a login page nobody finishes
23+
24+ hostSignsIn* = false
25+ ## This host signs in through freeq's broker, which is allowed to redirect
26+ ## to loopback — and a desktop is loopback. A browser is not, so the web
27+ ## build says `true` and does the OAuth itself. See `nim/web/frq/oauth`.
22 28
23 proc refreshSession*(broker, brokerToken: string): Tokens =29 proc refreshSession*(broker, brokerToken: string): Tokens =
24 ## Mint a fresh web-token from the durable broker token.30 ## Mint a fresh web-token from the durable broker token.
@@ -233,6 +239,11 @@ proc begin*(broker, handle: string, openBrowser = true) =
233 LoginReq(broker: broker, handle: handle,239 LoginReq(broker: broker, handle: handle,
234 openBrowser: openBrowser))240 openBrowser: openBrowser))
235 241
242+proc forgetHostSession*() = discard
243+ ## Nothing of a sign-in lives on this side: the broker token is the core's,
244+ ## and `session.forget` has already dropped it. The web host holds a token
245+ ## and a key and has real work to do here.
246+
236 proc cancel*() =247 proc cancel*() =
237 ## Stop waiting. The thread notices within the second it is sleeping in.248 ## Stop waiting. The thread notices within the second it is sleeping in.
238 if running: cancelled = true249 if running: cancelled = true
modified nim/src/frq/reducer.nim +77 -0
@@ -47,6 +47,10 @@ proc wantFace(m: Message)
4747 ## And this because `sendDraft` is: our own line wants a face as much as
4848 ## anybody's.
4949
50+proc openSocket()
51+ ## And this because a browser sign-in finishes above it: the host answers
52+ ## asynchronously, and what it answers with is "now you may connect".
53+
5054 proc send(line: string) =
5155 trace("out", line)
5256 tr.send(line)
@@ -145,6 +149,58 @@ proc rememberRooms(force = false) =
145149 roomsWritten = digest
146150 discard saveRooms(app.rooms)
147151
152+when oa.hostSignsIn:
153+ # Only where the host is its own OAuth client. On the desktop the broker
154+ # answers these questions and there is nothing here to compile.
155+
156+ var webSession: Session
157+ ## Kept apart from `session`, which `signIn` clears on every Connect —
158+ ## and on this host the sign-in happened on an earlier page load, so
159+ ## there is nothing to clear it *from*. Without this the proof arrived
160+ ## and was fastened to an empty session: no DID, no token, and a SASL
161+ ## payload that said `pds-oauth` and carried nothing.
162+
163+ proc adoptWebSession*(j: JsonNode, thenConnect: bool) =
164+ ## A sign-in the browser host holds, as the core's idea of a session.
165+ ##
166+ ## The core never sees the whole of it: the access token, the refresh token
167+ ## and the key they are bound to stay on the host's side, and what arrives
168+ ## here is what SASL needs plus the name to put on screen. `thenConnect` is
169+ ## the difference between coming back from the authorization server — where
170+ ## the reader asked for this and is waiting — and finding a session in
171+ ## storage at load, where they have not asked for anything yet.
172+ webSession = Session(kind: skPdsOauth,
173+ did: j{"did"}.getStr(),
174+ handle: j{"handle"}.getStr(),
175+ accessJwt: j{"accessJwt"}.getStr(),
176+ pds: j{"pds"}.getStr(),
177+ dpopNonce: j{"dpopNonce"}.getStr(),
178+ dpopProof: j{"dpopProof"}.getStr())
179+ app.hasSession = webSession.accessJwt.len > 0
180+ app.authMode = amBluesky
181+ if webSession.handle.len > 0:
182+ app.formHandle = webSession.handle
183+ app.formNick = webSession.handle
184+ trace("oauth", "a browser session for " & webSession.handle)
185+ if thenConnect and app.hasSession:
186+ if webSession.dpopProof.len > 0:
187+ session = webSession
188+ openSocket()
189+ else:
190+ oa.askForProof()
191+
192+ proc proofReady*(proof: string) =
193+ ## The proof freeq will present to the PDS on this client's behalf, minted
194+ ## by the host because minting it is WebCrypto. The last thing a browser
195+ ## connection waits for.
196+ if proof.len == 0:
197+ setError("Could not prove the sign-in; try signing in again.")
198+ app.connecting = false
199+ return
200+ webSession.dpopProof = proof
201+ session = webSession
202+ openSocket()
203+
148204 proc restore*() =
149205 ## What a previous run left on disk, back in the state.
150206 ##
@@ -160,6 +216,7 @@ proc restore*() =
160216 # tab. The nick and handle come along so the screen says who it is about
161217 # before the broker is asked.
162218 app.brokerToken = saved.brokerToken
219+ app.hasSession = saved.brokerToken.len > 0
163220 app.authMode = amBluesky
164221 if saved.handle.len > 0: app.formHandle = saved.handle
165222 if saved.nick.len > 0: app.formNick = saved.nick
@@ -181,6 +238,7 @@ proc adoptTokens(t: oa.Tokens) =
181238 # Only the durable half is written: the web-token beside it is single-use
182239 # and would be a stale secret on disk by the time anything read it.
183240 app.brokerToken = t.brokerToken
241+ app.hasSession = true
184242 discard saveSession(SavedSession(brokerToken: t.brokerToken,
185243 handle: app.formHandle, did: t.did,
186244 nick: app.formNick))
@@ -220,6 +278,23 @@ proc signIn(): bool =
220278 app.connecting = false
221279 false
222280 of amBluesky:
281+ when oa.hostSignsIn:
282+ # A host that is its own OAuth client. Everything it does is
283+ # asynchronous — `fetch`, and WebCrypto for the key a DPoP token is
284+ # bound to — so nothing finishes on this line: either the page leaves
285+ # for the authorization server, or it mints the proof a connection
286+ # needs and says so through the drain.
287+ if not app.hasSession:
288+ app.status = "Signing in with Bluesky…"
289+ oa.begin("", app.formHandle)
290+ else:
291+ # Per connect, not per sign-in: a proof carries an `iat` and a
292+ # single-use `jti`, so one kept from the sign-in would be refused by
293+ # the time a reconnect offered it.
294+ app.status = "Preparing your sign-in…"
295+ oa.askForProof()
296+ return false
297+
223298 # A remembered broker token is the whole reason to keep one: it buys a
224299 # fresh web-token without a browser, so a second run connects with no
225300 # login page at all. Only when there is none does the browser open, and
@@ -334,6 +409,8 @@ proc dispatch*(event: JsonNode) =
334409
335410 of "session.forget":
336411 app.brokerToken = ""
412+ app.hasSession = false
413+ forgetHostSession()
337414 clearSession()
338415 app.loginUrl = ""
339416 oa.cancel()
@@ -47,6 +47,10 @@ proc wantFace(m: Message)
47 ## And this because `sendDraft` is: our own line wants a face as much as47 ## And this because `sendDraft` is: our own line wants a face as much as
48 ## anybody's.48 ## anybody's.
49 49
50+proc openSocket()
51+ ## And this because a browser sign-in finishes above it: the host answers
52+ ## asynchronously, and what it answers with is "now you may connect".
53+
50 proc send(line: string) =54 proc send(line: string) =
51 trace("out", line)55 trace("out", line)
52 tr.send(line)56 tr.send(line)
@@ -145,6 +149,58 @@ proc rememberRooms(force = false) =
145 roomsWritten = digest149 roomsWritten = digest
146 discard saveRooms(app.rooms)150 discard saveRooms(app.rooms)
147 151
152+when oa.hostSignsIn:
153+ # Only where the host is its own OAuth client. On the desktop the broker
154+ # answers these questions and there is nothing here to compile.
155+
156+ var webSession: Session
157+ ## Kept apart from `session`, which `signIn` clears on every Connect —
158+ ## and on this host the sign-in happened on an earlier page load, so
159+ ## there is nothing to clear it *from*. Without this the proof arrived
160+ ## and was fastened to an empty session: no DID, no token, and a SASL
161+ ## payload that said `pds-oauth` and carried nothing.
162+
163+ proc adoptWebSession*(j: JsonNode, thenConnect: bool) =
164+ ## A sign-in the browser host holds, as the core's idea of a session.
165+ ##
166+ ## The core never sees the whole of it: the access token, the refresh token
167+ ## and the key they are bound to stay on the host's side, and what arrives
168+ ## here is what SASL needs plus the name to put on screen. `thenConnect` is
169+ ## the difference between coming back from the authorization server — where
170+ ## the reader asked for this and is waiting — and finding a session in
171+ ## storage at load, where they have not asked for anything yet.
172+ webSession = Session(kind: skPdsOauth,
173+ did: j{"did"}.getStr(),
174+ handle: j{"handle"}.getStr(),
175+ accessJwt: j{"accessJwt"}.getStr(),
176+ pds: j{"pds"}.getStr(),
177+ dpopNonce: j{"dpopNonce"}.getStr(),
178+ dpopProof: j{"dpopProof"}.getStr())
179+ app.hasSession = webSession.accessJwt.len > 0
180+ app.authMode = amBluesky
181+ if webSession.handle.len > 0:
182+ app.formHandle = webSession.handle
183+ app.formNick = webSession.handle
184+ trace("oauth", "a browser session for " & webSession.handle)
185+ if thenConnect and app.hasSession:
186+ if webSession.dpopProof.len > 0:
187+ session = webSession
188+ openSocket()
189+ else:
190+ oa.askForProof()
191+
192+ proc proofReady*(proof: string) =
193+ ## The proof freeq will present to the PDS on this client's behalf, minted
194+ ## by the host because minting it is WebCrypto. The last thing a browser
195+ ## connection waits for.
196+ if proof.len == 0:
197+ setError("Could not prove the sign-in; try signing in again.")
198+ app.connecting = false
199+ return
200+ webSession.dpopProof = proof
201+ session = webSession
202+ openSocket()
203+
148 proc restore*() =204 proc restore*() =
149 ## What a previous run left on disk, back in the state.205 ## What a previous run left on disk, back in the state.
150 ##206 ##
@@ -160,6 +216,7 @@ proc restore*() =
160 # tab. The nick and handle come along so the screen says who it is about216 # tab. The nick and handle come along so the screen says who it is about
161 # before the broker is asked.217 # before the broker is asked.
162 app.brokerToken = saved.brokerToken218 app.brokerToken = saved.brokerToken
219+ app.hasSession = saved.brokerToken.len > 0
163 app.authMode = amBluesky220 app.authMode = amBluesky
164 if saved.handle.len > 0: app.formHandle = saved.handle221 if saved.handle.len > 0: app.formHandle = saved.handle
165 if saved.nick.len > 0: app.formNick = saved.nick222 if saved.nick.len > 0: app.formNick = saved.nick
@@ -181,6 +238,7 @@ proc adoptTokens(t: oa.Tokens) =
181 # Only the durable half is written: the web-token beside it is single-use238 # Only the durable half is written: the web-token beside it is single-use
182 # and would be a stale secret on disk by the time anything read it.239 # and would be a stale secret on disk by the time anything read it.
183 app.brokerToken = t.brokerToken240 app.brokerToken = t.brokerToken
241+ app.hasSession = true
184 discard saveSession(SavedSession(brokerToken: t.brokerToken,242 discard saveSession(SavedSession(brokerToken: t.brokerToken,
185 handle: app.formHandle, did: t.did,243 handle: app.formHandle, did: t.did,
186 nick: app.formNick))244 nick: app.formNick))
@@ -220,6 +278,23 @@ proc signIn(): bool =
220 app.connecting = false278 app.connecting = false
221 false279 false
222 of amBluesky:280 of amBluesky:
281+ when oa.hostSignsIn:
282+ # A host that is its own OAuth client. Everything it does is
283+ # asynchronous — `fetch`, and WebCrypto for the key a DPoP token is
284+ # bound to — so nothing finishes on this line: either the page leaves
285+ # for the authorization server, or it mints the proof a connection
286+ # needs and says so through the drain.
287+ if not app.hasSession:
288+ app.status = "Signing in with Bluesky…"
289+ oa.begin("", app.formHandle)
290+ else:
291+ # Per connect, not per sign-in: a proof carries an `iat` and a
292+ # single-use `jti`, so one kept from the sign-in would be refused by
293+ # the time a reconnect offered it.
294+ app.status = "Preparing your sign-in…"
295+ oa.askForProof()
296+ return false
297+
223 # A remembered broker token is the whole reason to keep one: it buys a298 # A remembered broker token is the whole reason to keep one: it buys a
224 # fresh web-token without a browser, so a second run connects with no299 # fresh web-token without a browser, so a second run connects with no
225 # login page at all. Only when there is none does the browser open, and300 # login page at all. Only when there is none does the browser open, and
@@ -334,6 +409,8 @@ proc dispatch*(event: JsonNode) =
334 409
335 of "session.forget":410 of "session.forget":
336 app.brokerToken = ""411 app.brokerToken = ""
412+ app.hasSession = false
413+ forgetHostSession()
337 clearSession()414 clearSession()
338 app.loginUrl = ""415 app.loginUrl = ""
339 oa.cancel()416 oa.cancel()
modified nim/tests/thandshake.nim +15 -0
@@ -53,6 +53,21 @@ suite "saslResponse":
5353 check payload["signature"].getStr() == "tok"
5454 check payload["did"].getStr() == ""
5555
56+ test "a pds-oauth carries the proof freeq will present for us":
57+ # freeq cannot simply hand a DPoP token to the PDS: the token is bound to
58+ # a key and the holder has to prove it. So the proof travels with the
59+ # token, minted for the exact call freeq is about to make.
60+ var s = signedIn()
61+ s.kind = skPdsOauth
62+ s.dpopProof = "eyJhbGciOiJFUzI1NiJ9.proof"
63+ let payload = parseJson(b64urlDecode(saslResponse(s, "N1")))
64+ check payload["method"].getStr() == "pds-oauth"
65+ check payload["did"].getStr() == "did:plc:abc"
66+ check payload["signature"].getStr() == "jwt-123"
67+ check payload["pds_url"].getStr() == "https://pds.example"
68+ check payload["dpop_proof"].getStr() == "eyJhbGciOiJFUzI1NiJ9.proof"
69+ check payload["challenge_nonce"].getStr() == "N1"
70+
5671 suite "saslLines":
5772 test "a short payload is one line":
5873 check saslLines("abc") == @["AUTHENTICATE abc"]
@@ -53,6 +53,21 @@ suite "saslResponse":
53 check payload["signature"].getStr() == "tok"53 check payload["signature"].getStr() == "tok"
54 check payload["did"].getStr() == ""54 check payload["did"].getStr() == ""
55 55
56+ test "a pds-oauth carries the proof freeq will present for us":
57+ # freeq cannot simply hand a DPoP token to the PDS: the token is bound to
58+ # a key and the holder has to prove it. So the proof travels with the
59+ # token, minted for the exact call freeq is about to make.
60+ var s = signedIn()
61+ s.kind = skPdsOauth
62+ s.dpopProof = "eyJhbGciOiJFUzI1NiJ9.proof"
63+ let payload = parseJson(b64urlDecode(saslResponse(s, "N1")))
64+ check payload["method"].getStr() == "pds-oauth"
65+ check payload["did"].getStr() == "did:plc:abc"
66+ check payload["signature"].getStr() == "jwt-123"
67+ check payload["pds_url"].getStr() == "https://pds.example"
68+ check payload["dpop_proof"].getStr() == "eyJhbGciOiJFUzI1NiJ9.proof"
69+ check payload["challenge_nonce"].getStr() == "N1"
70+
56 suite "saslLines":71 suite "saslLines":
57 test "a short payload is one line":72 test "a short payload is one line":
58 check saslLines("abc") == @["AUTHENTICATE abc"]73 check saslLines("abc") == @["AUTHENTICATE abc"]
modified nim/web/frq/oauth.nim +74 -45
@@ -1,57 +1,75 @@
1-## The broker handoff, in a browser — which is the easy half of it.
1+## Signing in with Bluesky, in a browser.
22 ##
3-## A desktop has to catch the broker's answer: bind a loopback port, open a
4-## browser at it, serve a page whose one job is to post the fragment back.
5-## None of that is needed here, because the browser *is* the thing being
6-## redirected. `begin` sends the page to the broker; the broker sends it back
7-## with the payload in the fragment; the host reads the fragment and calls
8-## `handoff`.
3+## Not through freeq's broker, and that is forced rather than chosen. The
4+## broker finishes a login by redirecting to `return_to`, and it only
5+## redirects to hosts on its own allowlist — loopback, and its own freeq
6+## origins. The desktop is loopback and is fine. A page served from anywhere
7+## else is refused before the reader sees a login form at all: `Invalid
8+## return_to URL`, which is what the deployed build said.
99 ##
10-## `oauthcore` holds the parts that are the same either way — the login URL,
11-## and the payload once it is in hand.
10+## So the page is its own OAuth client, which works because an authorization
11+## server takes the client's *own* metadata document as the authority on where
12+## a code may be sent — and that document is served from this origin.
13+##
14+## None of that is here. It is `flutter/web/frq_oauth.js`, because every step
15+## of it is asynchronous — `fetch`, and WebCrypto for the DPoP key — and this
16+## core is not. What this module does is what the desktop half does: say what
17+## is wanted, and take the answer when it comes.
18+##
19+## `oauthcore` is still shared, for the little of the broker flow that
20+## survives: nothing here builds a login URL any more, but `Tokens` and
21+## `OauthError` are the shape the reducer already knows.
1222
13-import std/[deques, strutils]
23+import std/[deques, json, strutils]
1424 import frq/[oauthcore, trace]
1525 export oauthcore
1626
27+const hostSignsIn* = true
28+ ## This host does its own OAuth; the reducer reads this to know which of
29+ ## the two shapes of sign-in it is looking at. The desktop's `oauth` says
30+ ## `false` and means the broker.
31+
1732 var
18- events: Deque[string] ## "url: …" | "ok: …" | "error: …"
33+ events: Deque[string] ## "ok: …" | "error: …", as the desktop's
1934 running: bool
20-
21-{.emit: """
22-function frqGoTo(url) { window.location.href = url; }
23-function frqHere() {
24- // Without the fragment: `return_to` is where the broker sends the reader
25- // back, and it must be this page rather than this page plus whatever is
26- // already hanging off it.
27- return window.location.origin + window.location.pathname;
28-}
29-function frqRefreshSession(broker, token, done) {
30- fetch("https://" + broker + "/session", {
31- method: "POST",
32- headers: {"Content-Type": "application/json"},
33- body: JSON.stringify({broker_token: token}),
34- }).then(function (r) { return r.text(); })
35- .then(function (t) { done(t); })
36- .catch(function (e) { done(""); });
37-}
38-""".}
39-
40-proc goTo(url: cstring) {.importc: "frqGoTo".}
41-proc here(): cstring {.importc: "frqHere".}
35+ wantSignIn: string
36+ ## The handle the host should start a sign-in for, or "".
37+ wantProof: bool
38+ ## Whether the host should mint the proof for a connection.
4239
4340 proc begin*(broker, handle: string, openBrowser = true) =
44- ## Leave for the broker. There is no waiting to do: this page is about to
45- ## stop existing, and what comes back comes back as a fresh load with the
46- ## payload in the fragment.
41+ ## Ask the host to sign in. `broker` is ignored — there is no broker on this
42+ ## path, and the argument stays so the seam is one signature.
43+ ##
44+ ## Nothing happens here and nothing is awaited: the host leaves the page for
45+ ## the authorization server, and what comes back comes back as a fresh load.
4746 running = true
48- let url = loginUrl(broker, handle, $here())
49- trace("oauth", "leaving for " & url)
50- events.addLast("url: " & url)
51- goTo(url.cstring)
47+ wantSignIn = handle.strip()
48+ trace("oauth", "asking the host to sign in as " & wantSignIn)
49+
50+proc wantedSignIn*(): string =
51+ ## The handle to sign in as, taken as it is read — a sign-in is started
52+ ## once, and a page that asked twice would leave for the authorization
53+ ## server twice.
54+ result = wantSignIn
55+ wantSignIn = ""
56+
57+proc needProof*(): bool =
58+ ## Whether a connection is waiting on a DPoP proof. Taken as it is read,
59+ ## for the reason above.
60+ result = wantProof
61+ wantProof = false
62+
63+proc askForProof*() =
64+ ## Before a connection: freeq presents a proof to the PDS on this client's
65+ ## behalf, and minting one is WebCrypto. Per connect, because a proof
66+ ## carries an `iat` and a single-use `jti`.
67+ wantProof = true
5268
5369 proc handoff*(payload: string) =
54- ## The fragment this page came back with, from the host.
70+ ## A finished sign-in, as JSON from the host. Not the broker's base64
71+ ## payload — there is no broker — so this is the one place the two hosts
72+ ## disagree about what a handoff looks like.
5573 if payload.len == 0: return
5674 running = true
5775 events.addLast("ok: " & payload.strip())
@@ -60,6 +78,17 @@ proc failed*(reason: string) =
6078 running = true
6179 events.addLast("error: " & reason)
6280
81+var wantForget: bool
82+
83+proc forgetHostSession*() = wantForget = true
84+ ## The host holds the access token, the refresh token and the key they are
85+ ## bound to. A key outliving the token it was bound to is a key with
86+ ## nothing to prove, so all three go together.
87+
88+proc needForget*(): bool =
89+ result = wantForget
90+ wantForget = false
91+
6392 proc cancel*() = running = false
6493 proc finished*() = running = false
6594 proc waiting*(): bool = running
@@ -67,7 +96,7 @@ proc tryEvent*(): (bool, string) =
6796 if events.len == 0: (false, "") else: (true, events.popFirst())
6897
6998 proc refreshSession*(broker, brokerToken: string): Tokens =
70- ## Not here. `fetch` is asynchronous and this is not, so on the web a
71- ## remembered token is spent by the host: it calls the broker, and hands
72- ## what comes back to `handoff` exactly as a fresh sign-in would.
73- raise newException(OauthError, "the host refreshes the session on the web")
99+ ## Not here, and not needed: a browser sign-in keeps its own session and
100+ ## renews it through the authorization server rather than through a broker
101+ ## token this client never had.
102+ raise newException(OauthError, "there is no broker on this path")
@@ -1,57 +1,75 @@
1-## The broker handoff, in a browser — which is the easy half of it.1+## Signing in with Bluesky, in a browser.
2 ##2 ##
3-## A desktop has to catch the broker's answer: bind a loopback port, open a3+## Not through freeq's broker, and that is forced rather than chosen. The
4-## browser at it, serve a page whose one job is to post the fragment back.4+## broker finishes a login by redirecting to `return_to`, and it only
5-## None of that is needed here, because the browser *is* the thing being5+## redirects to hosts on its own allowlist — loopback, and its own freeq
6-## redirected. `begin` sends the page to the broker; the broker sends it back6+## origins. The desktop is loopback and is fine. A page served from anywhere
7-## with the payload in the fragment; the host reads the fragment and calls7+## else is refused before the reader sees a login form at all: `Invalid
8-## `handoff`.8+## return_to URL`, which is what the deployed build said.
9 ##9 ##
10-## `oauthcore` holds the parts that are the same either way — the login URL,10+## So the page is its own OAuth client, which works because an authorization
11-## and the payload once it is in hand.11+## server takes the client's *own* metadata document as the authority on where
12+## a code may be sent — and that document is served from this origin.
13+##
14+## None of that is here. It is `flutter/web/frq_oauth.js`, because every step
15+## of it is asynchronous — `fetch`, and WebCrypto for the DPoP key — and this
16+## core is not. What this module does is what the desktop half does: say what
17+## is wanted, and take the answer when it comes.
18+##
19+## `oauthcore` is still shared, for the little of the broker flow that
20+## survives: nothing here builds a login URL any more, but `Tokens` and
21+## `OauthError` are the shape the reducer already knows.
12 22
13-import std/[deques, strutils]23+import std/[deques, json, strutils]
14 import frq/[oauthcore, trace]24 import frq/[oauthcore, trace]
15 export oauthcore25 export oauthcore
16 26
27+const hostSignsIn* = true
28+ ## This host does its own OAuth; the reducer reads this to know which of
29+ ## the two shapes of sign-in it is looking at. The desktop's `oauth` says
30+ ## `false` and means the broker.
31+
17 var32 var
18- events: Deque[string] ## "url: …" | "ok: …" | "error: …"33+ events: Deque[string] ## "ok: …" | "error: …", as the desktop's
19 running: bool34 running: bool
20-35+ wantSignIn: string
21-{.emit: """36+ ## The handle the host should start a sign-in for, or "".
22-function frqGoTo(url) { window.location.href = url; }37+ wantProof: bool
23-function frqHere() {38+ ## Whether the host should mint the proof for a connection.
24- // Without the fragment: `return_to` is where the broker sends the reader
25- // back, and it must be this page rather than this page plus whatever is
26- // already hanging off it.
27- return window.location.origin + window.location.pathname;
28-}
29-function frqRefreshSession(broker, token, done) {
30- fetch("https://" + broker + "/session", {
31- method: "POST",
32- headers: {"Content-Type": "application/json"},
33- body: JSON.stringify({broker_token: token}),
34- }).then(function (r) { return r.text(); })
35- .then(function (t) { done(t); })
36- .catch(function (e) { done(""); });
37-}
38-""".}
39-
40-proc goTo(url: cstring) {.importc: "frqGoTo".}
41-proc here(): cstring {.importc: "frqHere".}
42 39
43 proc begin*(broker, handle: string, openBrowser = true) =40 proc begin*(broker, handle: string, openBrowser = true) =
44- ## Leave for the broker. There is no waiting to do: this page is about to41+ ## Ask the host to sign in. `broker` is ignored — there is no broker on this
45- ## stop existing, and what comes back comes back as a fresh load with the42+ ## path, and the argument stays so the seam is one signature.
46- ## payload in the fragment.43+ ##
44+ ## Nothing happens here and nothing is awaited: the host leaves the page for
45+ ## the authorization server, and what comes back comes back as a fresh load.
47 running = true46 running = true
48- let url = loginUrl(broker, handle, $here())47+ wantSignIn = handle.strip()
49- trace("oauth", "leaving for " & url)48+ trace("oauth", "asking the host to sign in as " & wantSignIn)
50- events.addLast("url: " & url)49+
51- goTo(url.cstring)50+proc wantedSignIn*(): string =
51+ ## The handle to sign in as, taken as it is read — a sign-in is started
52+ ## once, and a page that asked twice would leave for the authorization
53+ ## server twice.
54+ result = wantSignIn
55+ wantSignIn = ""
56+
57+proc needProof*(): bool =
58+ ## Whether a connection is waiting on a DPoP proof. Taken as it is read,
59+ ## for the reason above.
60+ result = wantProof
61+ wantProof = false
62+
63+proc askForProof*() =
64+ ## Before a connection: freeq presents a proof to the PDS on this client's
65+ ## behalf, and minting one is WebCrypto. Per connect, because a proof
66+ ## carries an `iat` and a single-use `jti`.
67+ wantProof = true
52 68
53 proc handoff*(payload: string) =69 proc handoff*(payload: string) =
54- ## The fragment this page came back with, from the host.70+ ## A finished sign-in, as JSON from the host. Not the broker's base64
71+ ## payload — there is no broker — so this is the one place the two hosts
72+ ## disagree about what a handoff looks like.
55 if payload.len == 0: return73 if payload.len == 0: return
56 running = true74 running = true
57 events.addLast("ok: " & payload.strip())75 events.addLast("ok: " & payload.strip())
@@ -60,6 +78,17 @@ proc failed*(reason: string) =
60 running = true78 running = true
61 events.addLast("error: " & reason)79 events.addLast("error: " & reason)
62 80
81+var wantForget: bool
82+
83+proc forgetHostSession*() = wantForget = true
84+ ## The host holds the access token, the refresh token and the key they are
85+ ## bound to. A key outliving the token it was bound to is a key with
86+ ## nothing to prove, so all three go together.
87+
88+proc needForget*(): bool =
89+ result = wantForget
90+ wantForget = false
91+
63 proc cancel*() = running = false92 proc cancel*() = running = false
64 proc finished*() = running = false93 proc finished*() = running = false
65 proc waiting*(): bool = running94 proc waiting*(): bool = running
@@ -67,7 +96,7 @@ proc tryEvent*(): (bool, string) =
67 if events.len == 0: (false, "") else: (true, events.popFirst())96 if events.len == 0: (false, "") else: (true, events.popFirst())
68 97
69 proc refreshSession*(broker, brokerToken: string): Tokens =98 proc refreshSession*(broker, brokerToken: string): Tokens =
70- ## Not here. `fetch` is asynchronous and this is not, so on the web a99+ ## Not here, and not needed: a browser sign-in keeps its own session and
71- ## remembered token is spent by the host: it calls the broker, and hands100+ ## renews it through the authorization server rather than through a broker
72- ## what comes back to `handoff` exactly as a fresh sign-in would.101+ ## token this client never had.
73- raise newException(OauthError, "the host refreshes the session on the web")102+ raise newException(OauthError, "there is no broker on this path")
modified nim/web/frq_web.nim +40 -9
@@ -46,13 +46,12 @@ proc currentTree(): string =
4646 # the names written here.
4747
4848 proc frqInit(payload: cstring) {.exportc.} =
49- ## Once, before anything else. `payload` is the URL fragment this page came
50- ## back with, or empty — a browser catches the broker's answer by being the
51- ## page that was redirected, so a sign-in finishes here rather than on a
52- ## loopback socket.
49+ ## Once, before anything else. The argument is vestigial: it used to carry
50+ ## the broker's fragment, and this page is its own OAuth client now — the
51+ ## host reads the query string, finishes the exchange, and calls
52+ ## `handoff` or `restoreSession` with a session rather than a payload.
5353 restore()
54- let p = $payload
55- if p.len > 0: oa.handoff(p)
54+ discard payload
5655
5756 proc frqRender(): cstring {.exportc.} = currentTree().cstring
5857 ## The current screen as a widget tree, in JSON.
@@ -101,9 +100,36 @@ proc frqBrokerToken(): cstring {.exportc.} = app.brokerToken.cstring
101100 ## The remembered token, for the host to spend against the broker — `fetch`
102101 ## is asynchronous, so the core cannot spend it itself.
103102
104-proc frqHandoff(payload: cstring) {.exportc.} = oa.handoff($payload)
105- ## What the broker answered, whether from a redirect or from the host's own
106- ## call to `/session`.
103+proc frqHandoff(payload: cstring) {.exportc.} =
104+ ## A finished sign-in, as JSON from the host — the reader asked for this and
105+ ## is waiting, so it connects.
106+ try:
107+ adoptWebSession(parseJson($payload), thenConnect = true)
108+ except CatchableError as e:
109+ oa.failed(e.msg)
110+
111+proc frqRestoreSession(payload: cstring) {.exportc.} =
112+ ## A sign-in the host already had, at load. The same fields and a different
113+ ## meaning: nobody has pressed Connect, so this only puts the name on the
114+ ## screen and lights the Bluesky tab.
115+ try:
116+ adoptWebSession(parseJson($payload), thenConnect = false)
117+ except CatchableError as e:
118+ trace("oauth", "ignoring a stored session: " & e.msg)
119+
120+proc frqWantedSignIn(): cstring {.exportc.} = oa.wantedSignIn().cstring
121+ ## The handle the core is asking the host to sign in as, or empty.
122+
123+proc frqNeedProof(): bool {.exportc.} = oa.needProof()
124+ ## Whether a connection is waiting on a DPoP proof.
125+
126+proc frqNeedForget(): bool {.exportc.} = oa.needForget()
127+ ## Whether the reader has asked to be forgotten.
128+
129+proc frqProofReady(proof: cstring) {.exportc.} = proofReady($proof)
130+ ## The proof, minted. The last thing a browser connection waits for.
131+
132+proc frqSignInFailedWith(reason: cstring) {.exportc.} = oa.failed($reason)
107133
108134 proc frqSignInFailed(reason: cstring) {.exportc.} = oa.failed($reason)
109135
@@ -150,6 +176,11 @@ globalThis.frq = {
150176 takeOutbound: frqTakeOutbound,
151177 brokerToken: frqBrokerToken,
152178 handoff: frqHandoff,
179+ restoreSession: frqRestoreSession,
180+ wantedSignIn: frqWantedSignIn,
181+ needProof: frqNeedProof,
182+ needForget: frqNeedForget,
183+ proofReady: frqProofReady,
153184 signInFailed: frqSignInFailed,
154185 trace: frqTrace,
155186 demo: frqDemo,
@@ -46,13 +46,12 @@ proc currentTree(): string =
46 # the names written here.46 # the names written here.
47 47
48 proc frqInit(payload: cstring) {.exportc.} =48 proc frqInit(payload: cstring) {.exportc.} =
49- ## Once, before anything else. `payload` is the URL fragment this page came49+ ## Once, before anything else. The argument is vestigial: it used to carry
50- ## back with, or empty — a browser catches the broker's answer by being the50+ ## the broker's fragment, and this page is its own OAuth client now — the
51- ## page that was redirected, so a sign-in finishes here rather than on a51+ ## host reads the query string, finishes the exchange, and calls
52- ## loopback socket.52+ ## `handoff` or `restoreSession` with a session rather than a payload.
53 restore()53 restore()
54- let p = $payload54+ discard payload
55- if p.len > 0: oa.handoff(p)
56 55
57 proc frqRender(): cstring {.exportc.} = currentTree().cstring56 proc frqRender(): cstring {.exportc.} = currentTree().cstring
58 ## The current screen as a widget tree, in JSON.57 ## The current screen as a widget tree, in JSON.
@@ -101,9 +100,36 @@ proc frqBrokerToken(): cstring {.exportc.} = app.brokerToken.cstring
101 ## The remembered token, for the host to spend against the broker — `fetch`100 ## The remembered token, for the host to spend against the broker — `fetch`
102 ## is asynchronous, so the core cannot spend it itself.101 ## is asynchronous, so the core cannot spend it itself.
103 102
104-proc frqHandoff(payload: cstring) {.exportc.} = oa.handoff($payload)103+proc frqHandoff(payload: cstring) {.exportc.} =
105- ## What the broker answered, whether from a redirect or from the host's own104+ ## A finished sign-in, as JSON from the host — the reader asked for this and
106- ## call to `/session`.105+ ## is waiting, so it connects.
106+ try:
107+ adoptWebSession(parseJson($payload), thenConnect = true)
108+ except CatchableError as e:
109+ oa.failed(e.msg)
110+
111+proc frqRestoreSession(payload: cstring) {.exportc.} =
112+ ## A sign-in the host already had, at load. The same fields and a different
113+ ## meaning: nobody has pressed Connect, so this only puts the name on the
114+ ## screen and lights the Bluesky tab.
115+ try:
116+ adoptWebSession(parseJson($payload), thenConnect = false)
117+ except CatchableError as e:
118+ trace("oauth", "ignoring a stored session: " & e.msg)
119+
120+proc frqWantedSignIn(): cstring {.exportc.} = oa.wantedSignIn().cstring
121+ ## The handle the core is asking the host to sign in as, or empty.
122+
123+proc frqNeedProof(): bool {.exportc.} = oa.needProof()
124+ ## Whether a connection is waiting on a DPoP proof.
125+
126+proc frqNeedForget(): bool {.exportc.} = oa.needForget()
127+ ## Whether the reader has asked to be forgotten.
128+
129+proc frqProofReady(proof: cstring) {.exportc.} = proofReady($proof)
130+ ## The proof, minted. The last thing a browser connection waits for.
131+
132+proc frqSignInFailedWith(reason: cstring) {.exportc.} = oa.failed($reason)
107 133
108 proc frqSignInFailed(reason: cstring) {.exportc.} = oa.failed($reason)134 proc frqSignInFailed(reason: cstring) {.exportc.} = oa.failed($reason)
109 135
@@ -150,6 +176,11 @@ globalThis.frq = {
150 takeOutbound: frqTakeOutbound,176 takeOutbound: frqTakeOutbound,
151 brokerToken: frqBrokerToken,177 brokerToken: frqBrokerToken,
152 handoff: frqHandoff,178 handoff: frqHandoff,
179+ restoreSession: frqRestoreSession,
180+ wantedSignIn: frqWantedSignIn,
181+ needProof: frqNeedProof,
182+ needForget: frqNeedForget,
183+ proofReady: frqProofReady,
153 signInFailed: frqSignInFailed,184 signInFailed: frqSignInFailed,
154 trace: frqTrace,185 trace: frqTrace,
155 demo: frqDemo,186 demo: frqDemo,
modified nim/web/test/smoke.js +65 -0
@@ -68,5 +68,70 @@ check("a fed line reaches the screen", tree.includes("hello from the web"));
6868 check("the store writes through localStorage",
6969 Object.keys(window.localStorage._v).some(k => k.startsWith("frq.")));
7070
71+// The browser sign-in: the core asks the host to do the asynchronous parts
72+// and takes the answers back. None of this can be reached from the Nim suite,
73+// because the whole point of the seam is that the other side is JavaScript.
74+frq.demo();
75+frq.dispatch(JSON.stringify({ id: "screen.connect" }));
76+frq.dispatch(JSON.stringify({ id: "mode.bluesky" }));
77+frq.dispatch(JSON.stringify({ id: "handle.change", value: "alice.bsky.social" }));
78+frq.dispatch(JSON.stringify({ id: "connect" }));
79+check("with no session, it asks the host to sign in",
80+ frq.wantedSignIn() === "alice.bsky.social");
81+check("and asks only once", frq.wantedSignIn() === "");
82+
83+// A session the host already had: the name appears, and nothing connects.
84+frq.restoreSession(JSON.stringify({
85+ did: "did:plc:abc", handle: "alice.bsky.social",
86+ accessJwt: "tok", pds: "https://pds.example", dpopNonce: "n1",
87+}));
88+// `wanted` is the last socket the core asked for and stays set, which is how
89+// the host knows not to redial — so "did it dial" is a comparison.
90+const dialledBefore = frq.wanted();
91+check("a stored session does not connect by itself",
92+ frq.wanted() === dialledBefore);
93+check("but it does put the handle on the screen",
94+ frq.render().includes("alice.bsky.social"));
95+
96+// Now Connect: the proof is the last thing it waits for.
97+frq.dispatch(JSON.stringify({ id: "connect" }));
98+check("with a session, it asks for a proof", frq.needProof() === true);
99+check("and asks only once", frq.needProof() === false);
100+check("nothing is dialled until the proof is in",
101+ frq.wanted() === dialledBefore);
102+frq.proofReady("eyJhbGciOiJFUzI1NiJ9.proof");
103+check("the proof opens the socket", JSON.parse(frq.wanted() || "{}").host === "irc.freeq.at");
104+
105+// And the SASL payload carries it. Drained at every step rather than at the
106+// end: the core answers each line as it arrives, and a single take at the
107+// end would mix the registration in with the answer being checked.
108+frq.socketEvent("open");
109+frq.render();
110+frq.takeOutbound(); // CAP LS, NICK, USER
111+frq.feed("CAP * LS :sasl message-tags server-time");
112+frq.render();
113+frq.takeOutbound(); // CAP REQ
114+frq.feed("CAP * ACK :sasl message-tags server-time");
115+frq.render();
116+check("an acked sasl starts the exchange",
117+ frq.takeOutbound().trim() === "AUTHENTICATE ATPROTO-CHALLENGE");
118+
119+const challenge = Buffer.from(JSON.stringify({ nonce: "N1" })).toString("base64url");
120+frq.feed("AUTHENTICATE " + challenge);
121+frq.render();
122+const answer = frq.takeOutbound().trim();
123+let payload = null;
124+try {
125+ payload = JSON.parse(
126+ Buffer.from(answer.replace("AUTHENTICATE ", ""), "base64url").toString());
127+} catch (e) { /* left null, and the checks below say so */ }
128+check("the SASL payload is pds-oauth", payload && payload.method === "pds-oauth");
129+check("with the DID and the token the host holds",
130+ payload && payload.did === "did:plc:abc" && payload.signature === "tok");
131+check("the proof freeq will present to the PDS",
132+ payload && payload.dpop_proof === "eyJhbGciOiJFUzI1NiJ9.proof");
133+check("and the nonce it was challenged with",
134+ payload && payload.challenge_nonce === "N1");
135+
71136 console.log(failures === 0 ? "all ok" : failures + " failed");
72137 process.exit(failures === 0 ? 0 : 1);
@@ -68,5 +68,70 @@ check("a fed line reaches the screen", tree.includes("hello from the web"));
68 check("the store writes through localStorage",68 check("the store writes through localStorage",
69 Object.keys(window.localStorage._v).some(k => k.startsWith("frq.")));69 Object.keys(window.localStorage._v).some(k => k.startsWith("frq.")));
70 70
71+// The browser sign-in: the core asks the host to do the asynchronous parts
72+// and takes the answers back. None of this can be reached from the Nim suite,
73+// because the whole point of the seam is that the other side is JavaScript.
74+frq.demo();
75+frq.dispatch(JSON.stringify({ id: "screen.connect" }));
76+frq.dispatch(JSON.stringify({ id: "mode.bluesky" }));
77+frq.dispatch(JSON.stringify({ id: "handle.change", value: "alice.bsky.social" }));
78+frq.dispatch(JSON.stringify({ id: "connect" }));
79+check("with no session, it asks the host to sign in",
80+ frq.wantedSignIn() === "alice.bsky.social");
81+check("and asks only once", frq.wantedSignIn() === "");
82+
83+// A session the host already had: the name appears, and nothing connects.
84+frq.restoreSession(JSON.stringify({
85+ did: "did:plc:abc", handle: "alice.bsky.social",
86+ accessJwt: "tok", pds: "https://pds.example", dpopNonce: "n1",
87+}));
88+// `wanted` is the last socket the core asked for and stays set, which is how
89+// the host knows not to redial — so "did it dial" is a comparison.
90+const dialledBefore = frq.wanted();
91+check("a stored session does not connect by itself",
92+ frq.wanted() === dialledBefore);
93+check("but it does put the handle on the screen",
94+ frq.render().includes("alice.bsky.social"));
95+
96+// Now Connect: the proof is the last thing it waits for.
97+frq.dispatch(JSON.stringify({ id: "connect" }));
98+check("with a session, it asks for a proof", frq.needProof() === true);
99+check("and asks only once", frq.needProof() === false);
100+check("nothing is dialled until the proof is in",
101+ frq.wanted() === dialledBefore);
102+frq.proofReady("eyJhbGciOiJFUzI1NiJ9.proof");
103+check("the proof opens the socket", JSON.parse(frq.wanted() || "{}").host === "irc.freeq.at");
104+
105+// And the SASL payload carries it. Drained at every step rather than at the
106+// end: the core answers each line as it arrives, and a single take at the
107+// end would mix the registration in with the answer being checked.
108+frq.socketEvent("open");
109+frq.render();
110+frq.takeOutbound(); // CAP LS, NICK, USER
111+frq.feed("CAP * LS :sasl message-tags server-time");
112+frq.render();
113+frq.takeOutbound(); // CAP REQ
114+frq.feed("CAP * ACK :sasl message-tags server-time");
115+frq.render();
116+check("an acked sasl starts the exchange",
117+ frq.takeOutbound().trim() === "AUTHENTICATE ATPROTO-CHALLENGE");
118+
119+const challenge = Buffer.from(JSON.stringify({ nonce: "N1" })).toString("base64url");
120+frq.feed("AUTHENTICATE " + challenge);
121+frq.render();
122+const answer = frq.takeOutbound().trim();
123+let payload = null;
124+try {
125+ payload = JSON.parse(
126+ Buffer.from(answer.replace("AUTHENTICATE ", ""), "base64url").toString());
127+} catch (e) { /* left null, and the checks below say so */ }
128+check("the SASL payload is pds-oauth", payload && payload.method === "pds-oauth");
129+check("with the DID and the token the host holds",
130+ payload && payload.did === "did:plc:abc" && payload.signature === "tok");
131+check("the proof freeq will present to the PDS",
132+ payload && payload.dpop_proof === "eyJhbGciOiJFUzI1NiJ9.proof");
133+check("and the nonce it was challenged with",
134+ payload && payload.challenge_nonce === "N1");
135+
71 console.log(failures === 0 ? "all ok" : failures + " failed");136 console.log(failures === 0 ? "all ok" : failures + " failed");
72 process.exit(failures === 0 ? 0 : 1);137 process.exit(failures === 0 ? 0 : 1);
added tools/client-metadata.py +41 -0
new file mode 100755
@@ -0,0 +1,41 @@
1+#!/usr/bin/env python3
2+"""The OAuth client metadata document, for one origin.
3+
4+An authorization server fetches this from the URL in `client_id` and takes
5+*this document* as the authority on where a code may be sent. That is the
6+whole reason the browser build can sign in at all: freeq's broker only
7+redirects to its own allowlist, and this list is ours.
8+
9+Generated rather than committed because every value in it is absolute. The
10+`client_id` has to equal the URL this is served from, and `redirect_uris` has
11+to contain the page the reader comes back to — so a file with one origin
12+baked in is a file that is wrong everywhere else.
13+
14+ tools/client-metadata.py https://example.test > client-metadata.json
15+"""
16+
17+import json
18+import sys
19+
20+if len(sys.argv) != 2:
21+ raise SystemExit("usage: client-metadata.py <origin> # e.g. https://x.test")
22+
23+origin = sys.argv[1].rstrip("/")
24+
25+print(json.dumps({
26+ "client_id": f"{origin}/client-metadata.json",
27+ "client_name": "frq",
28+ "client_uri": f"{origin}/",
29+ "redirect_uris": [f"{origin}/"],
30+ "grant_types": ["authorization_code", "refresh_token"],
31+ "response_types": ["code"],
32+ # `atproto` is the identity scope freeq needs; `transition:generic` is
33+ # what a PDS still wants for ordinary reads and writes.
34+ "scope": "atproto transition:generic",
35+ # No secret. A page cannot keep one, and does not need to: what stands in
36+ # for it is DPoP, below — every token is bound to a key this client
37+ # proves it holds.
38+ "token_endpoint_auth_method": "none",
39+ "application_type": "web",
40+ "dpop_bound_access_tokens": True,
41+}, indent=2))
new file mode 100755
@@ -0,0 +1,41 @@
1+#!/usr/bin/env python3
2+"""The OAuth client metadata document, for one origin.
3+
4+An authorization server fetches this from the URL in `client_id` and takes
5+*this document* as the authority on where a code may be sent. That is the
6+whole reason the browser build can sign in at all: freeq's broker only
7+redirects to its own allowlist, and this list is ours.
8+
9+Generated rather than committed because every value in it is absolute. The
10+`client_id` has to equal the URL this is served from, and `redirect_uris` has
11+to contain the page the reader comes back to — so a file with one origin
12+baked in is a file that is wrong everywhere else.
13+
14+ tools/client-metadata.py https://example.test > client-metadata.json
15+"""
16+
17+import json
18+import sys
19+
20+if len(sys.argv) != 2:
21+ raise SystemExit("usage: client-metadata.py <origin> # e.g. https://x.test")
22+
23+origin = sys.argv[1].rstrip("/")
24+
25+print(json.dumps({
26+ "client_id": f"{origin}/client-metadata.json",
27+ "client_name": "frq",
28+ "client_uri": f"{origin}/",
29+ "redirect_uris": [f"{origin}/"],
30+ "grant_types": ["authorization_code", "refresh_token"],
31+ "response_types": ["code"],
32+ # `atproto` is the identity scope freeq needs; `transition:generic` is
33+ # what a PDS still wants for ordinary reads and writes.
34+ "scope": "atproto transition:generic",
35+ # No secret. A page cannot keep one, and does not need to: what stands in
36+ # for it is DPoP, below — every token is bound to a key this client
37+ # proves it holds.
38+ "token_endpoint_auth_method": "none",
39+ "application_type": "web",
40+ "dpop_bound_access_tokens": True,
41+}, indent=2))