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>
ab639b6 parent: d0cf0eb modified
.gitignore +1 -0 | @@ -35,3 +35,4 @@ __pycache__/ | ||
| 35 | 35 | # The mirror `just serve` keeps of the Modal-built web bundle. |
| 36 | 36 | .web-local/ |
| 37 | 37 | 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.js | 37 | flutter/web/frq_core.js |
| 38 | +flutter/web/client-metadata.json | ||
modified
.modal/web/Dockerfile +8 -0 | @@ -32,6 +32,14 @@ COPY . . | ||
| 32 | 32 | # because `flutter build web` copies that directory into the bundle -- which |
| 33 | 33 | # is what makes the page's `<script src="frq_core.js">` resolve the same |
| 34 | 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 | 43 | RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\ |
| 36 | 44 | mkdir -p /src/build/web && cd /src/nim && \ |
| 37 | 45 | 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 -- which | 32 | # 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 same | 33 | # 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 @@ | ||
| 10 | 10 | // * the socket. The core says where it wants to be connected; this opens a |
| 11 | 11 | // WebSocket to freeq's own bridge, feeds every line in, and sends |
| 12 | 12 | // 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. | |
| 16 | 17 | // * the profiles, which the core asks for through `fetch` (in the Nim, not |
| 17 | 18 | // here), so there is nothing to do for them. |
| 18 | 19 | // |
| @@ -83,17 +84,43 @@ | ||
| 83 | 84 | } |
| 84 | 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, 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); | |
| 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 | })(); |
| @@ -10,9 +10,10 @@ | |||
| 10 | // * the socket. The core says where it wants to be connected; this opens a | 10 | // * 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 sends | 11 | // 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 a | 13 | +// * 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, not | 17 | // * 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, and | 90 | + // 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 whatever | 91 | + // 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 @@ | ||
| 32 | 32 | as it starts, and a core that might not have parsed yet is a race nobody |
| 33 | 33 | would enjoy debugging. |
| 34 | 34 | |
| 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. | |
| 38 | 39 | --> |
| 39 | 40 | <script src="frq_core.js"></script> |
| 41 | + <script src="frq_dpop.js"></script> | |
| 42 | + <script src="frq_oauth.js"></script> | |
| 40 | 43 | <script src="frq_host.js"></script> |
| 41 | 44 | </head> |
| 42 | 45 | <body> |
| @@ -32,11 +32,14 @@ | |||
| 32 | as it starts, and a core that might not have parsed yet is a race nobody | 32 | 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 WebCrypto | 35 | + `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 work | 36 | + 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: | ||
| 182 | 182 | cd "{{root}}" |
| 183 | 183 | just _nim-js |
| 184 | 184 | 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 | 196 | exec "{{tc}}" exec -- bash -euo pipefail -c ' |
| 186 | 197 | cd flutter |
| 187 | 198 | flutter pub get |
| @@ -182,6 +182,17 @@ _web-bundle: | |||
| 182 | cd "{{root}}" | 182 | cd "{{root}}" |
| 183 | just _nim-js | 183 | just _nim-js |
| 184 | cp build/web/frq_core.js flutter/web/frq_core.js | 184 | 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 flutter | 197 | cd flutter |
| 187 | flutter pub get | 198 | flutter pub get |
modified
nim/src/frq/atprotocore.nim +24 -1 | @@ -14,7 +14,8 @@ const | ||
| 14 | 14 | |
| 15 | 15 | type |
| 16 | 16 | 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 | 20 | Session* = object |
| 20 | 21 | kind*: SessionKind |
| @@ -23,6 +24,15 @@ type | ||
| 23 | 24 | accessJwt*: string |
| 24 | 25 | pds*: string |
| 25 | 26 | 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 | 37 | AtprotoError* = object of CatchableError |
| 28 | 38 | |
| @@ -54,6 +64,19 @@ proc saslResponse*(s: Session, nonce: string): string = | ||
| 54 | 64 | case s.kind |
| 55 | 65 | of skWebToken: |
| 56 | 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 | 80 | else: |
| 58 | 81 | b64url($(%*{"did": s.did, |
| 59 | 82 | "signature": s.accessJwt, |
| @@ -14,7 +14,8 @@ const | |||
| 14 | 14 | ||
| 15 | type | 15 | type |
| 16 | SessionKind* = enum | 16 | 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* = object | 20 | Session* = object |
| 20 | kind*: SessionKind | 21 | kind*: SessionKind |
| @@ -23,6 +24,15 @@ type | |||
| 23 | accessJwt*: string | 24 | accessJwt*: string |
| 24 | pds*: string | 25 | pds*: string |
| 25 | token*: string ## a web-token from the broker, where that is the kind | 26 | 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 CatchableError | 37 | AtprotoError* = object of CatchableError |
| 28 | 38 | ||
| @@ -54,6 +64,19 @@ proc saslResponse*(s: Session, nonce: string): string = | |||
| 54 | case s.kind | 64 | 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 | ||
| 88 | 88 | # The durable half of an OAuth sign-in. The web-token beside it is |
| 89 | 89 | # single-use, so a reconnect mints a fresh one from this rather than |
| 90 | 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 | 96 | brokerToken*: string |
| 92 | 97 | apiBearer*: string |
| 93 | 98 | 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 is | 88 | # 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 than | 89 | # 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*: string | 96 | brokerToken*: string |
| 92 | apiBearer*: string | 97 | apiBearer*: string |
| 93 | loginUrl*: string ## shown while the browser is open | 98 | loginUrl*: string ## shown while the browser is open |
modified
nim/src/frq/oauth.nim +12 -1 | @@ -18,7 +18,13 @@ import frq/[trace, eintr] | ||
| 18 | 18 | import frq/oauthcore |
| 19 | 19 | export oauthcore |
| 20 | 20 | |
| 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`. | |
| 22 | 28 | |
| 23 | 29 | proc refreshSession*(broker, brokerToken: string): Tokens = |
| 24 | 30 | ## Mint a fresh web-token from the durable broker token. |
| @@ -233,6 +239,11 @@ proc begin*(broker, handle: string, openBrowser = true) = | ||
| 233 | 239 | LoginReq(broker: broker, handle: handle, |
| 234 | 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 | 247 | proc cancel*() = |
| 237 | 248 | ## Stop waiting. The thread notices within the second it is sleeping in. |
| 238 | 249 | if running: cancelled = true |
| @@ -18,7 +18,13 @@ import frq/[trace, eintr] | |||
| 18 | import frq/oauthcore | 18 | import frq/oauthcore |
| 19 | export oauthcore | 19 | export oauthcore |
| 20 | 20 | ||
| 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`. | ||
| 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 = true | 249 | if running: cancelled = true |
modified
nim/src/frq/reducer.nim +77 -0 | @@ -47,6 +47,10 @@ proc wantFace(m: Message) | ||
| 47 | 47 | ## And this because `sendDraft` is: our own line wants a face as much as |
| 48 | 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 | 54 | proc send(line: string) = |
| 51 | 55 | trace("out", line) |
| 52 | 56 | tr.send(line) |
| @@ -145,6 +149,58 @@ proc rememberRooms(force = false) = | ||
| 145 | 149 | roomsWritten = digest |
| 146 | 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 | 204 | proc restore*() = |
| 149 | 205 | ## What a previous run left on disk, back in the state. |
| 150 | 206 | ## |
| @@ -160,6 +216,7 @@ proc restore*() = | ||
| 160 | 216 | # tab. The nick and handle come along so the screen says who it is about |
| 161 | 217 | # before the broker is asked. |
| 162 | 218 | app.brokerToken = saved.brokerToken |
| 219 | + app.hasSession = saved.brokerToken.len > 0 | |
| 163 | 220 | app.authMode = amBluesky |
| 164 | 221 | if saved.handle.len > 0: app.formHandle = saved.handle |
| 165 | 222 | if saved.nick.len > 0: app.formNick = saved.nick |
| @@ -181,6 +238,7 @@ proc adoptTokens(t: oa.Tokens) = | ||
| 181 | 238 | # Only the durable half is written: the web-token beside it is single-use |
| 182 | 239 | # and would be a stale secret on disk by the time anything read it. |
| 183 | 240 | app.brokerToken = t.brokerToken |
| 241 | + app.hasSession = true | |
| 184 | 242 | discard saveSession(SavedSession(brokerToken: t.brokerToken, |
| 185 | 243 | handle: app.formHandle, did: t.did, |
| 186 | 244 | nick: app.formNick)) |
| @@ -220,6 +278,23 @@ proc signIn(): bool = | ||
| 220 | 278 | app.connecting = false |
| 221 | 279 | false |
| 222 | 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 | 298 | # A remembered broker token is the whole reason to keep one: it buys a |
| 224 | 299 | # fresh web-token without a browser, so a second run connects with no |
| 225 | 300 | # 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 | 410 | of "session.forget": |
| 336 | 411 | app.brokerToken = "" |
| 412 | + app.hasSession = false | |
| 413 | + forgetHostSession() | |
| 337 | 414 | clearSession() |
| 338 | 415 | app.loginUrl = "" |
| 339 | 416 | oa.cancel() |
| @@ -47,6 +47,10 @@ proc wantFace(m: Message) | |||
| 47 | ## And this because `sendDraft` is: our own line wants a face as much as | 47 | ## 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 = digest | 149 | 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 about | 216 | # 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.brokerToken | 218 | app.brokerToken = saved.brokerToken |
| 219 | + app.hasSession = saved.brokerToken.len > 0 | ||
| 163 | app.authMode = amBluesky | 220 | app.authMode = amBluesky |
| 164 | if saved.handle.len > 0: app.formHandle = saved.handle | 221 | if saved.handle.len > 0: app.formHandle = saved.handle |
| 165 | if saved.nick.len > 0: app.formNick = saved.nick | 222 | 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-use | 238 | # 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.brokerToken | 240 | 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 = false | 278 | app.connecting = false |
| 221 | false | 279 | 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 a | 298 | # 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 no | 299 | # 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, and | 300 | # 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": | ||
| 53 | 53 | check payload["signature"].getStr() == "tok" |
| 54 | 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 | 71 | suite "saslLines": |
| 57 | 72 | test "a short payload is one line": |
| 58 | 73 | 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. | |
| 2 | 2 | ## |
| 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. | |
| 9 | 9 | ## |
| 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. | |
| 12 | 22 | |
| 13 | -import std/[deques, strutils] | |
| 23 | +import std/[deques, json, strutils] | |
| 14 | 24 | import frq/[oauthcore, trace] |
| 15 | 25 | 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 | 32 | var |
| 18 | - events: Deque[string] ## "url: …" | "ok: …" | "error: …" | |
| 33 | + events: Deque[string] ## "ok: …" | "error: …", as the desktop's | |
| 19 | 34 | 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. | |
| 42 | 39 | |
| 43 | 40 | 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. | |
| 47 | 46 | 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 | |
| 52 | 68 | |
| 53 | 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 | 73 | if payload.len == 0: return |
| 56 | 74 | running = true |
| 57 | 75 | events.addLast("ok: " & payload.strip()) |
| @@ -60,6 +78,17 @@ proc failed*(reason: string) = | ||
| 60 | 78 | running = true |
| 61 | 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 | 92 | proc cancel*() = running = false |
| 64 | 93 | proc finished*() = running = false |
| 65 | 94 | proc waiting*(): bool = running |
| @@ -67,7 +96,7 @@ proc tryEvent*(): (bool, string) = | ||
| 67 | 96 | if events.len == 0: (false, "") else: (true, events.popFirst()) |
| 68 | 97 | |
| 69 | 98 | 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 a | 3 | +## 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 being | 5 | +## 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 back | 6 | +## 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 calls | 7 | +## 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 oauthcore | 25 | 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 | var | 32 | var |
| 18 | - events: Deque[string] ## "url: …" | "ok: …" | "error: …" | 33 | + events: Deque[string] ## "ok: …" | "error: …", as the desktop's |
| 19 | running: bool | 34 | 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 to | 41 | + ## 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 the | 42 | + ## 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 = true | 46 | 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: return | 73 | if payload.len == 0: return |
| 56 | running = true | 74 | running = true |
| 57 | events.addLast("ok: " & payload.strip()) | 75 | events.addLast("ok: " & payload.strip()) |
| @@ -60,6 +78,17 @@ proc failed*(reason: string) = | |||
| 60 | running = true | 78 | 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 = false | 92 | proc cancel*() = running = false |
| 64 | proc finished*() = running = false | 93 | proc finished*() = running = false |
| 65 | proc waiting*(): bool = running | 94 | 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 a | 99 | + ## 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 hands | 100 | + ## 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 = | ||
| 46 | 46 | # the names written here. |
| 47 | 47 | |
| 48 | 48 | 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. | |
| 53 | 53 | restore() |
| 54 | - let p = $payload | |
| 55 | - if p.len > 0: oa.handoff(p) | |
| 54 | + discard payload | |
| 56 | 55 | |
| 57 | 56 | proc frqRender(): cstring {.exportc.} = currentTree().cstring |
| 58 | 57 | ## The current screen as a widget tree, in JSON. |
| @@ -101,9 +100,36 @@ proc frqBrokerToken(): cstring {.exportc.} = app.brokerToken.cstring | ||
| 101 | 100 | ## The remembered token, for the host to spend against the broker — `fetch` |
| 102 | 101 | ## is asynchronous, so the core cannot spend it itself. |
| 103 | 102 | |
| 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) | |
| 107 | 133 | |
| 108 | 134 | proc frqSignInFailed(reason: cstring) {.exportc.} = oa.failed($reason) |
| 109 | 135 | |
| @@ -150,6 +176,11 @@ globalThis.frq = { | ||
| 150 | 176 | takeOutbound: frqTakeOutbound, |
| 151 | 177 | brokerToken: frqBrokerToken, |
| 152 | 178 | handoff: frqHandoff, |
| 179 | + restoreSession: frqRestoreSession, | |
| 180 | + wantedSignIn: frqWantedSignIn, | |
| 181 | + needProof: frqNeedProof, | |
| 182 | + needForget: frqNeedForget, | |
| 183 | + proofReady: frqProofReady, | |
| 153 | 184 | signInFailed: frqSignInFailed, |
| 154 | 185 | trace: frqTrace, |
| 155 | 186 | 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 came | 49 | + ## 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 the | 50 | + ## 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 a | 51 | + ## 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 = $payload | 54 | + discard payload |
| 55 | - if p.len > 0: oa.handoff(p) | ||
| 56 | 55 | ||
| 57 | proc frqRender(): cstring {.exportc.} = currentTree().cstring | 56 | 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 own | 104 | + ## 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")); | ||
| 68 | 68 | check("the store writes through localStorage", |
| 69 | 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 | 136 | console.log(failures === 0 ? "all ok" : failures + " failed"); |
| 72 | 137 | 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)) | ||