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

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

Our own OAuth client, because the broker's allowlist is not ours to edit

freeq's auth broker finishes a login by redirecting to `return_to`, and
`is_valid_return_to` permits its own https hosts and loopback and nothing
else. A build served from anywhere but those can never complete a sign-in
through it, and asking to be added to that list is somebody else's decision.

So this is an AT Protocol OAuth client of our own, and 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. A public client with no secret — a page in a browser could
keep none — where DPoP stands in for one, which is also exactly what freeq's
SASL `pds-oauth` verifies: it takes the token and a proof, calls the PDS's
getSession with both, and believes the PDS.

Discovery is two steps and that matters: a PDS shard publishes
`oauth-protected-resource` naming bsky.social and no authorization-server
metadata of its own, while bsky.social IS the authorization server and
publishes no protected-resource document at all. Testing against the second
alone hid the first entirely.

`whoami!` costs a round trip and earns it three times over: it settles the
handle, without which `sign-in!` has no nick to derive and an OAuth sign-in
arrives on the server calling itself `frq-guest`; it proves the proof is
accepted before one is handed to freeq; and it collects the DPoP nonce the
PDS wants.

The crypto is JavaScript reached through `dart:js` — cljd resolves neither
`dart:js_util` nor `dart:js_interop`, so there is no `promiseToFuture` and
the bridge is callback-shaped. And `aget`/`aset` are List-only in cljd: they
compile to `(x as List)[k as int]`, so a Dart Map wants `get` and `[]=`. That
one had localStorage failing into its own catch all along, which is why the
web build had quietly never saved anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-17T00:56:57-07:00 Browse files
015500d parent: a0dbf2e
modified common/frq/atproto/core.cljc +21 -1
@@ -206,8 +206,28 @@
206206 sent empty rather than guessed at."
207207 [session nonce]
208208 (b64-encode
209- (if (= :web-token (:kind session))
209+ (case (:kind session)
210+ :web-token
210211 (json-object {"did" "" "method" "web-token" "signature" (:token session)})
212+
213+ ;; An OAuth access token, which the server cannot simply present to the
214+ ;; PDS: a DPoP token is bound to a key, and the holder has to prove it.
215+ ;; So the proof travels with it. freeq calls getSession with our token
216+ ;; and our proof, and the PDS checks that the proof names that method,
217+ ;; that URL and that token — which is what lets a proof be minted for a
218+ ;; request this client never makes.
219+ ;;
220+ ;; `:dpop-proof` is prepared by the caller rather than built here,
221+ ;; because minting one is asynchronous and this is not: it is WebCrypto
222+ ;; on the web and nothing at all on the other two targets.
223+ :pds-oauth
224+ (json-object {"did" (:did session)
225+ "signature" (:access-jwt session)
226+ "method" "pds-oauth"
227+ "pds_url" (:pds session)
228+ "dpop_proof" (str (:dpop-proof session))
229+ "challenge_nonce" nonce})
230+
211231 (json-object {"did" (:did session)
212232 "signature" (:access-jwt session)
213233 "method" "pds-session"
@@ -206,8 +206,28 @@
206 sent empty rather than guessed at."206 sent empty rather than guessed at."
207 [session nonce]207 [session nonce]
208 (b64-encode208 (b64-encode
209- (if (= :web-token (:kind session))209+ (case (:kind session)
210+ :web-token
210 (json-object {"did" "" "method" "web-token" "signature" (:token session)})211 (json-object {"did" "" "method" "web-token" "signature" (:token session)})
212+
213+ ;; An OAuth access token, which the server cannot simply present to the
214+ ;; PDS: a DPoP token is bound to a key, and the holder has to prove it.
215+ ;; So the proof travels with it. freeq calls getSession with our token
216+ ;; and our proof, and the PDS checks that the proof names that method,
217+ ;; that URL and that token — which is what lets a proof be minted for a
218+ ;; request this client never makes.
219+ ;;
220+ ;; `:dpop-proof` is prepared by the caller rather than built here,
221+ ;; because minting one is asynchronous and this is not: it is WebCrypto
222+ ;; on the web and nothing at all on the other two targets.
223+ :pds-oauth
224+ (json-object {"did" (:did session)
225+ "signature" (:access-jwt session)
226+ "method" "pds-oauth"
227+ "pds_url" (:pds session)
228+ "dpop_proof" (str (:dpop-proof session))
229+ "challenge_nonce" nonce})
230+
211 (json-object {"did" (:did session)231 (json-object {"did" (:did session)
212 "signature" (:access-jwt session)232 "signature" (:access-jwt session)
213 "method" "pds-session"233 "method" "pds-session"
added flutter/src/frq/dpop/web.cljd +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+(ns frq.dpop.web
2+ "DPoP, as far as ClojureDart needs to see it.
3+
4+ A thin wrapper over `web/frq_dpop.js`, and thin on purpose: everything below
5+ is WebCrypto, which speaks Promises, ArrayBuffers and JWK objects. The
6+ JavaScript does the crypto on its home ground; what crosses this boundary is
7+ a string.
8+
9+ `dart:js` and not `dart:js_util`, which is the shape of everything here:
10+ cljd's analyzer resolves `dart:js` and answers `Can't find Dart lib` for both
11+ `dart:js_util` and `dart:js_interop`. So there is no `promiseToFuture` to
12+ turn a thenable into a Future — and what `dart:js` does offer instead is
13+ automatic wrapping of a Dart closure passed as an argument. Hence the
14+ callback style on the JavaScript side, and a Completer here to put a Future
15+ back on the front of it."
16+ (:require ["dart:async" :as async]
17+ ["dart:js" :as js]))
18+
19+(defn- helper
20+ "`window.frqDpop`, from the script `web/index.html` loads before the bundle.
21+
22+ `(. x \"[]\" k)` and not `aget`. `aget` compiles unconditionally to
23+ `(x as List)[k as int]`, so reaching for a property of the JS global with it
24+ asks Dart to cast `Window` to `List<dynamic>` — which is exactly what the
25+ first real sign-in attempt said, in those words. The `[]` operator is what
26+ JsObject actually has."
27+ []
28+ (. js/context "[]" "frqDpop"))
29+
30+(defn- ^:async call!
31+ "Call one callback-style helper function and await its answer.
32+
33+ The callback takes `(err, value)` with `err` an empty string for success —
34+ an empty string rather than null because it survives the crossing without
35+ anyone having to ask what JavaScript's absent value became."
36+ [method args]
37+ (let [done (async/Completer.)]
38+ ;; Appended rather than concatenated: `#dart []` is growable, the callers
39+ ;; all pass a fresh literal, and `.add` is one call where `followedBy` and
40+ ;; `toList` are two and an Iterable in between.
41+ (.add args (fn [err value]
42+ (if (seq (str (or err "")))
43+ (.completeError done (Exception. (str err)))
44+ (.complete done (str value)))
45+ nil))
46+ (.callMethod (helper) method args)
47+ (await (.-future done))))
48+
49+(defn ^:async proof
50+ "One DPoP proof JWT for `htm htu`.
51+
52+ `nonce` is the server's, when it has asked for one — an empty string means it
53+ has not. `token` is the access token this proof should be bound to, and an
54+ empty string means the proof is for an unauthenticated call: the PAR request
55+ has no token yet, and the token request is what mints one.
56+
57+ Binding matters for the one call this client never makes. freeq's SASL
58+ `pds-oauth` takes our token and our proof and calls the PDS's getSession
59+ itself; the PDS checks that the proof names that method, that URL and that
60+ token, which is why a proof can be handed to someone else at all."
61+ [htm htu nonce token]
62+ (await (call! "proof" #dart [(str htm) (str htu)
63+ (str (or nonce "")) (str (or token ""))])))
64+
65+(defn ^:async challenge
66+ "The S256 challenge for a PKCE verifier."
67+ [v]
68+ (await (call! "challenge" #dart [(str v)])))
69+
70+(defn verifier
71+ "A fresh PKCE verifier. The caller keeps it — it has to outlive the redirect.
72+ Synchronous: random bytes need nothing awaited."
73+ []
74+ (str (.callMethod (helper) "verifier" #dart [])))
75+
76+(defn random
77+ "`n` random bytes, base64url. The OAuth `state` comes from here."
78+ [n]
79+ (str (.callMethod (helper) "random" #dart [n])))
80+
81+(defn forget!
82+ "Drop the key. A DPoP key that outlives the token it was bound to has
83+ nothing left to prove, so signing out takes it with the session."
84+ []
85+ (.callMethod (helper) "forget" #dart [])
86+ nil)
new file mode 100644
@@ -0,0 +1,86 @@
1+(ns frq.dpop.web
2+ "DPoP, as far as ClojureDart needs to see it.
3+
4+ A thin wrapper over `web/frq_dpop.js`, and thin on purpose: everything below
5+ is WebCrypto, which speaks Promises, ArrayBuffers and JWK objects. The
6+ JavaScript does the crypto on its home ground; what crosses this boundary is
7+ a string.
8+
9+ `dart:js` and not `dart:js_util`, which is the shape of everything here:
10+ cljd's analyzer resolves `dart:js` and answers `Can't find Dart lib` for both
11+ `dart:js_util` and `dart:js_interop`. So there is no `promiseToFuture` to
12+ turn a thenable into a Future — and what `dart:js` does offer instead is
13+ automatic wrapping of a Dart closure passed as an argument. Hence the
14+ callback style on the JavaScript side, and a Completer here to put a Future
15+ back on the front of it."
16+ (:require ["dart:async" :as async]
17+ ["dart:js" :as js]))
18+
19+(defn- helper
20+ "`window.frqDpop`, from the script `web/index.html` loads before the bundle.
21+
22+ `(. x \"[]\" k)` and not `aget`. `aget` compiles unconditionally to
23+ `(x as List)[k as int]`, so reaching for a property of the JS global with it
24+ asks Dart to cast `Window` to `List<dynamic>` — which is exactly what the
25+ first real sign-in attempt said, in those words. The `[]` operator is what
26+ JsObject actually has."
27+ []
28+ (. js/context "[]" "frqDpop"))
29+
30+(defn- ^:async call!
31+ "Call one callback-style helper function and await its answer.
32+
33+ The callback takes `(err, value)` with `err` an empty string for success —
34+ an empty string rather than null because it survives the crossing without
35+ anyone having to ask what JavaScript's absent value became."
36+ [method args]
37+ (let [done (async/Completer.)]
38+ ;; Appended rather than concatenated: `#dart []` is growable, the callers
39+ ;; all pass a fresh literal, and `.add` is one call where `followedBy` and
40+ ;; `toList` are two and an Iterable in between.
41+ (.add args (fn [err value]
42+ (if (seq (str (or err "")))
43+ (.completeError done (Exception. (str err)))
44+ (.complete done (str value)))
45+ nil))
46+ (.callMethod (helper) method args)
47+ (await (.-future done))))
48+
49+(defn ^:async proof
50+ "One DPoP proof JWT for `htm htu`.
51+
52+ `nonce` is the server's, when it has asked for one — an empty string means it
53+ has not. `token` is the access token this proof should be bound to, and an
54+ empty string means the proof is for an unauthenticated call: the PAR request
55+ has no token yet, and the token request is what mints one.
56+
57+ Binding matters for the one call this client never makes. freeq's SASL
58+ `pds-oauth` takes our token and our proof and calls the PDS's getSession
59+ itself; the PDS checks that the proof names that method, that URL and that
60+ token, which is why a proof can be handed to someone else at all."
61+ [htm htu nonce token]
62+ (await (call! "proof" #dart [(str htm) (str htu)
63+ (str (or nonce "")) (str (or token ""))])))
64+
65+(defn ^:async challenge
66+ "The S256 challenge for a PKCE verifier."
67+ [v]
68+ (await (call! "challenge" #dart [(str v)])))
69+
70+(defn verifier
71+ "A fresh PKCE verifier. The caller keeps it — it has to outlive the redirect.
72+ Synchronous: random bytes need nothing awaited."
73+ []
74+ (str (.callMethod (helper) "verifier" #dart [])))
75+
76+(defn random
77+ "`n` random bytes, base64url. The OAuth `state` comes from here."
78+ [n]
79+ (str (.callMethod (helper) "random" #dart [n])))
80+
81+(defn forget!
82+ "Drop the key. A DPoP key that outlives the token it was bound to has
83+ nothing left to prove, so signing out takes it with the session."
84+ []
85+ (.callMethod (helper) "forget" #dart [])
86+ nil)
modified flutter/src/frq/main.cljd +83 -15
@@ -20,6 +20,9 @@
2020 [clojure.string :as str]
2121 ["dart:io" :as dio]
2222 ["package:flutter/material.dart" :as m]
23+ ;; `defaultTargetPlatform`, which material does not re-export --
24+ ;; see the same require in `frq.hiccup`.
25+ ["package:flutter/foundation.dart" :as fnd]
2326 ["package:path_provider/path_provider.dart" :as pp]
2427 [cljd.flutter :as f]
2528 [frq.hiccup :as h]
@@ -29,8 +32,14 @@
2932 ;; the same way the shared half asks for it.
3033 [frq.io :as fio]
3134 [frq.crypto.dart :as crypto-dart]
32- [frq.net.dart :as net]
33- [frq.atproto.dart :as atproto]
35+ [frq.net :as net]
36+ ;; The transport this entry point installs. `frq.main-web`
37+ ;; installs the other one, which is why the seam is between them.
38+ [frq.net.dart :as net-dart]
39+ [frq.atproto.http :as atproto]
40+ ;; The dart:io transport this entry point installs; `frq.main-web`
41+ ;; installs the XMLHttpRequest one.
42+ [frq.atproto.dart :as atproto-dart]
3443 [frq.clock :as clock]
3544 [frq.media.core :as media-core]
3645 [frq.media.dart :as media]
@@ -53,6 +62,9 @@
5362 [frq.upload.dart :as upload]
5463 [frq.irc.mutate :as mutate]
5564 [frq.oauth.core :as oauth]
65+ [frq.oauth.handoff :as handoff]
66+ ;; The loopback handoff this entry point installs; `frq.main-web`
67+ ;; installs the redirect one.
5668 [frq.oauth.dart :as oauth-dart]
5769 [frq.irc.parse :as irc]
5870 [frq.irc.handshake :as handshake]))
@@ -597,7 +609,14 @@
597609 the broker will redirect to and not a porting problem. So that case says so
598610 rather than quietly connecting as somebody else."
599611 []
600- (if-let [bt @cells/broker-token]
612+ ;; A session the web half already established. `frq.oauth.web` runs the whole
613+ ;; OAuth flow itself and leaves the result here there is no broker token on
614+ ;; that path and nothing to refresh so finding one means sign-in is done.
615+ ;; Without this, restoring it would send the reader straight back out to the
616+ ;; authorization server on every connect.
617+ (if (= :pds-oauth (:kind @cells/session))
618+ @cells/session
619+ (if-let [bt @cells/broker-token]
601620 (do
602621 (reset! cells/status "Resuming your session…")
603622 (let [tokens (oauth/refresh-session-parse
@@ -610,9 +629,11 @@
610629 ;; it does.
611630 (store/save-session! tokens)
612631 (assoc tokens :kind :web-token)))
613- ;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback,
614- ;; and what comes back through it is the first broker token.
615- (let [tokens (await (oauth-dart/await-callback!
632+ ;; No saved token: the browser leg, through the seam. `frq.oauth.dart`
633+ ;; binds a loopback listener and reads what the capture page posts back;
634+ ;; `frq.oauth.web` redirects the page and reads the fragment it returns
635+ ;; with. What comes back either way is the first broker token.
636+ (let [tokens (await (handoff/await-callback!
616637 oauth/default-broker
617638 (str @cells/form-handle)
618639 (fn [url]
@@ -625,7 +646,7 @@
625646 (reset! cells/login-url nil)
626647 (reset! cells/broker-token (:broker-token tokens))
627648 (store/save-session! tokens)
628- (assoc tokens :kind :web-token))))
649+ (assoc tokens :kind :web-token)))))
629650
630651 (defn ^:async sign-in!
631652 "Fill `cells/session` for the mode that was chosen, or say why not.
@@ -692,6 +713,9 @@
692713 ;; us on the server as a guest, which looks like a success and is not the
693714 ;; one that was asked for. Bluesky did exactly that until it was asked.
694715 (when (await (sign-in!))
716+ ;; The identity is settled; now make it usable this second. Only the web's
717+ ;; OAuth session has anything to do here see `frq.oauth.handoff`.
718+ (reset! cells/session (await (handoff/prepare-session! @cells/session)))
695719 (let [n (swap! attempt inc)]
696720 (reset! caps #{})
697721 (reset! cells/connecting? true)
@@ -1101,7 +1125,15 @@
11011125
11021126 (defn- close-picker! [] (reset! cells/reacting nil))
11031127
1104-(defn ^:async main []
1128+(defn bind!
1129+ "The two things every entry point does before it has a host: the binding,
1130+ and somewhere for a layout error to go.
1131+
1132+ Split out with `start!` when the web target arrived, because that one
1133+ installs a different backend and cannot reach path_provider at all see
1134+ `frq.main-web`. Everything here is target-agnostic, which is the test for
1135+ whether a line belongs in it."
1136+ []
11051137 (m/WidgetsFlutterBinding.ensureInitialized)
11061138 ;; Layout errors do not come back as exceptions they happen after the
11071139 ;; build, so nothing can catch them, and what they leave is a blank screen
@@ -1110,9 +1142,23 @@
11101142 (fn [^m/FlutterErrorDetails details]
11111143 (m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details)))
11121144 (m/debugPrint (str "frq: LIBRARY " (.-library details)
1113- " CONTEXT " (.-context details)))))
1114- (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
1115- (host/install! dir)
1145+ " CONTEXT " (.-context details))))))
1146+
1147+(defn ^:async start!
1148+ "Everything from the first cell to `runApp`, with the host already installed.
1149+
1150+ The precondition is the whole point of the split: this asks `frq.io` for the
1151+ filesystem and never dart:io, so it runs the same over `frq.io.dart` and
1152+ over `frq.io.web`. What it still installs directly is the rest of the Dart
1153+ half crypto, the profile fetch, the actions and those are shared by both
1154+ Flutter targets because both compile the same ClojureDart.
1155+
1156+ Assert rather than trust: an entry point that forgets `install!` would
1157+ otherwise fail somewhere inside `restore-prefs!` with a message about a
1158+ keyword."
1159+ []
1160+ (assert (fio/installed?) "frq.main/start!: no frq.io backend installed")
1161+ (do
11161162 ;; Ed25519 for the reactions freeq will not take on trust. Verified
11171163 ;; against RFC 8032 test 1 on the device: the same public key and the
11181164 ;; same signature OpenSSL gives on the desktop, so a signature minted
@@ -1283,10 +1329,13 @@
12831329 ;; put a link to.
12841330 :image-path (fn [url] (media/path-when-ready url))
12851331 :wide? wide?
1286- ;; True where there is a pointer to hover with. Both Flutter targets
1287- ;; compile this file, so it is asked rather than assumed: a window on the
1288- ;; desktop has a mouse, Android has a finger.
1289- :desktop? (fn [] (not (.-isAndroid dio/Platform)))
1332+ ;; True where there is a pointer to hover with. Three Flutter targets
1333+ ;; compile this file now, so it is asked rather than assumed: a window on
1334+ ;; the desktop has a mouse, Android has a finger, and the web is whichever
1335+ ;; the browser is running on. `defaultTargetPlatform` for the reason
1336+ ;; `frq.hiccup/pointer?` uses it dart:io's `Platform` throws in a
1337+ ;; browser rather than answering.
1338+ :desktop? (fn [] (not= fnd/TargetPlatform.android fnd/defaultTargetPlatform))
12901339 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))
12911340
12921341 ;; Rewriting. The old text is the starting point rather than an empty
@@ -1369,6 +1418,17 @@
13691418 (m/Scaffold)
13701419 .body
13711420 m/SafeArea
1421+ ;; Text you can select, which on the web is not the default: a browser
1422+ ;; renders Flutter to a canvas, so the ordinary drag-over-text a page
1423+ ;; gives for free is not there unless something asks for it. One
1424+ ;; SelectionArea over the whole tree is that ask, and it covers every
1425+ ;; Text under it rather than needing them changed.
1426+ ;;
1427+ ;; Above `render-root` rather than inside `frq.hiccup`, so it is one
1428+ ;; widget and not one per label and for all three targets rather than
1429+ ;; the web alone, because copying a line out of a chat is a thing a
1430+ ;; desktop window should do too.
1431+ m/SelectionArea
13721432 (f/widget
13731433 :context ctx
13741434 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
@@ -1382,3 +1442,11 @@
13821442 ;; beside the screens is floated over them where there is a pointer,
13831443 ;; and drawn where it stands where there is not.
13841444 (h/render-root [screens/app]))))))
1445+
1446+(defn ^:async main []
1447+ (bind!)
1448+ (host/install! (.-path (await (pp/getApplicationSupportDirectory))))
1449+ (net-dart/install!)
1450+ (oauth-dart/install!)
1451+ (atproto-dart/install!)
1452+ (await (start!)))
@@ -20,6 +20,9 @@
20 [clojure.string :as str]20 [clojure.string :as str]
21 ["dart:io" :as dio]21 ["dart:io" :as dio]
22 ["package:flutter/material.dart" :as m]22 ["package:flutter/material.dart" :as m]
23+ ;; `defaultTargetPlatform`, which material does not re-export --
24+ ;; see the same require in `frq.hiccup`.
25+ ["package:flutter/foundation.dart" :as fnd]
23 ["package:path_provider/path_provider.dart" :as pp]26 ["package:path_provider/path_provider.dart" :as pp]
24 [cljd.flutter :as f]27 [cljd.flutter :as f]
25 [frq.hiccup :as h]28 [frq.hiccup :as h]
@@ -29,8 +32,14 @@
29 ;; the same way the shared half asks for it.32 ;; the same way the shared half asks for it.
30 [frq.io :as fio]33 [frq.io :as fio]
31 [frq.crypto.dart :as crypto-dart]34 [frq.crypto.dart :as crypto-dart]
32- [frq.net.dart :as net]35+ [frq.net :as net]
33- [frq.atproto.dart :as atproto]36+ ;; The transport this entry point installs. `frq.main-web`
37+ ;; installs the other one, which is why the seam is between them.
38+ [frq.net.dart :as net-dart]
39+ [frq.atproto.http :as atproto]
40+ ;; The dart:io transport this entry point installs; `frq.main-web`
41+ ;; installs the XMLHttpRequest one.
42+ [frq.atproto.dart :as atproto-dart]
34 [frq.clock :as clock]43 [frq.clock :as clock]
35 [frq.media.core :as media-core]44 [frq.media.core :as media-core]
36 [frq.media.dart :as media]45 [frq.media.dart :as media]
@@ -53,6 +62,9 @@
53 [frq.upload.dart :as upload]62 [frq.upload.dart :as upload]
54 [frq.irc.mutate :as mutate]63 [frq.irc.mutate :as mutate]
55 [frq.oauth.core :as oauth]64 [frq.oauth.core :as oauth]
65+ [frq.oauth.handoff :as handoff]
66+ ;; The loopback handoff this entry point installs; `frq.main-web`
67+ ;; installs the redirect one.
56 [frq.oauth.dart :as oauth-dart]68 [frq.oauth.dart :as oauth-dart]
57 [frq.irc.parse :as irc]69 [frq.irc.parse :as irc]
58 [frq.irc.handshake :as handshake]))70 [frq.irc.handshake :as handshake]))
@@ -597,7 +609,14 @@
597 the broker will redirect to and not a porting problem. So that case says so609 the broker will redirect to and not a porting problem. So that case says so
598 rather than quietly connecting as somebody else."610 rather than quietly connecting as somebody else."
599 []611 []
600- (if-let [bt @cells/broker-token]612+ ;; A session the web half already established. `frq.oauth.web` runs the whole
613+ ;; OAuth flow itself and leaves the result here there is no broker token on
614+ ;; that path and nothing to refresh so finding one means sign-in is done.
615+ ;; Without this, restoring it would send the reader straight back out to the
616+ ;; authorization server on every connect.
617+ (if (= :pds-oauth (:kind @cells/session))
618+ @cells/session
619+ (if-let [bt @cells/broker-token]
601 (do620 (do
602 (reset! cells/status "Resuming your session…")621 (reset! cells/status "Resuming your session…")
603 (let [tokens (oauth/refresh-session-parse622 (let [tokens (oauth/refresh-session-parse
@@ -610,9 +629,11 @@
610 ;; it does.629 ;; it does.
611 (store/save-session! tokens)630 (store/save-session! tokens)
612 (assoc tokens :kind :web-token)))631 (assoc tokens :kind :web-token)))
613- ;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback,632+ ;; No saved token: the browser leg, through the seam. `frq.oauth.dart`
614- ;; and what comes back through it is the first broker token.633+ ;; binds a loopback listener and reads what the capture page posts back;
615- (let [tokens (await (oauth-dart/await-callback!634+ ;; `frq.oauth.web` redirects the page and reads the fragment it returns
635+ ;; with. What comes back either way is the first broker token.
636+ (let [tokens (await (handoff/await-callback!
616 oauth/default-broker637 oauth/default-broker
617 (str @cells/form-handle)638 (str @cells/form-handle)
618 (fn [url]639 (fn [url]
@@ -625,7 +646,7 @@
625 (reset! cells/login-url nil)646 (reset! cells/login-url nil)
626 (reset! cells/broker-token (:broker-token tokens))647 (reset! cells/broker-token (:broker-token tokens))
627 (store/save-session! tokens)648 (store/save-session! tokens)
628- (assoc tokens :kind :web-token))))649+ (assoc tokens :kind :web-token)))))
629 650
630 (defn ^:async sign-in!651 (defn ^:async sign-in!
631 "Fill `cells/session` for the mode that was chosen, or say why not.652 "Fill `cells/session` for the mode that was chosen, or say why not.
@@ -692,6 +713,9 @@
692 ;; us on the server as a guest, which looks like a success and is not the713 ;; us on the server as a guest, which looks like a success and is not the
693 ;; one that was asked for. Bluesky did exactly that until it was asked.714 ;; one that was asked for. Bluesky did exactly that until it was asked.
694 (when (await (sign-in!))715 (when (await (sign-in!))
716+ ;; The identity is settled; now make it usable this second. Only the web's
717+ ;; OAuth session has anything to do here see `frq.oauth.handoff`.
718+ (reset! cells/session (await (handoff/prepare-session! @cells/session)))
695 (let [n (swap! attempt inc)]719 (let [n (swap! attempt inc)]
696 (reset! caps #{})720 (reset! caps #{})
697 (reset! cells/connecting? true)721 (reset! cells/connecting? true)
@@ -1101,7 +1125,15 @@
1101 1125
1102 (defn- close-picker! [] (reset! cells/reacting nil))1126 (defn- close-picker! [] (reset! cells/reacting nil))
1103 1127
1104-(defn ^:async main []1128+(defn bind!
1129+ "The two things every entry point does before it has a host: the binding,
1130+ and somewhere for a layout error to go.
1131+
1132+ Split out with `start!` when the web target arrived, because that one
1133+ installs a different backend and cannot reach path_provider at all see
1134+ `frq.main-web`. Everything here is target-agnostic, which is the test for
1135+ whether a line belongs in it."
1136+ []
1105 (m/WidgetsFlutterBinding.ensureInitialized)1137 (m/WidgetsFlutterBinding.ensureInitialized)
1106 ;; Layout errors do not come back as exceptions they happen after the1138 ;; Layout errors do not come back as exceptions they happen after the
1107 ;; build, so nothing can catch them, and what they leave is a blank screen1139 ;; build, so nothing can catch them, and what they leave is a blank screen
@@ -1110,9 +1142,23 @@
1110 (fn [^m/FlutterErrorDetails details]1142 (fn [^m/FlutterErrorDetails details]
1111 (m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details)))1143 (m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details)))
1112 (m/debugPrint (str "frq: LIBRARY " (.-library details)1144 (m/debugPrint (str "frq: LIBRARY " (.-library details)
1113- " CONTEXT " (.-context details)))))1145+ " CONTEXT " (.-context details))))))
1114- (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]1146+
1115- (host/install! dir)1147+(defn ^:async start!
1148+ "Everything from the first cell to `runApp`, with the host already installed.
1149+
1150+ The precondition is the whole point of the split: this asks `frq.io` for the
1151+ filesystem and never dart:io, so it runs the same over `frq.io.dart` and
1152+ over `frq.io.web`. What it still installs directly is the rest of the Dart
1153+ half crypto, the profile fetch, the actions and those are shared by both
1154+ Flutter targets because both compile the same ClojureDart.
1155+
1156+ Assert rather than trust: an entry point that forgets `install!` would
1157+ otherwise fail somewhere inside `restore-prefs!` with a message about a
1158+ keyword."
1159+ []
1160+ (assert (fio/installed?) "frq.main/start!: no frq.io backend installed")
1161+ (do
1116 ;; Ed25519 for the reactions freeq will not take on trust. Verified1162 ;; Ed25519 for the reactions freeq will not take on trust. Verified
1117 ;; against RFC 8032 test 1 on the device: the same public key and the1163 ;; against RFC 8032 test 1 on the device: the same public key and the
1118 ;; same signature OpenSSL gives on the desktop, so a signature minted1164 ;; same signature OpenSSL gives on the desktop, so a signature minted
@@ -1283,10 +1329,13 @@
1283 ;; put a link to.1329 ;; put a link to.
1284 :image-path (fn [url] (media/path-when-ready url))1330 :image-path (fn [url] (media/path-when-ready url))
1285 :wide? wide?1331 :wide? wide?
1286- ;; True where there is a pointer to hover with. Both Flutter targets1332+ ;; True where there is a pointer to hover with. Three Flutter targets
1287- ;; compile this file, so it is asked rather than assumed: a window on the1333+ ;; compile this file now, so it is asked rather than assumed: a window on
1288- ;; desktop has a mouse, Android has a finger.1334+ ;; the desktop has a mouse, Android has a finger, and the web is whichever
1289- :desktop? (fn [] (not (.-isAndroid dio/Platform)))1335+ ;; the browser is running on. `defaultTargetPlatform` for the reason
1336+ ;; `frq.hiccup/pointer?` uses it dart:io's `Platform` throws in a
1337+ ;; browser rather than answering.
1338+ :desktop? (fn [] (not= fnd/TargetPlatform.android fnd/defaultTargetPlatform))
1290 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))1339 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))
1291 1340
1292 ;; Rewriting. The old text is the starting point rather than an empty1341 ;; Rewriting. The old text is the starting point rather than an empty
@@ -1369,6 +1418,17 @@
1369 (m/Scaffold)1418 (m/Scaffold)
1370 .body1419 .body
1371 m/SafeArea1420 m/SafeArea
1421+ ;; Text you can select, which on the web is not the default: a browser
1422+ ;; renders Flutter to a canvas, so the ordinary drag-over-text a page
1423+ ;; gives for free is not there unless something asks for it. One
1424+ ;; SelectionArea over the whole tree is that ask, and it covers every
1425+ ;; Text under it rather than needing them changed.
1426+ ;;
1427+ ;; Above `render-root` rather than inside `frq.hiccup`, so it is one
1428+ ;; widget and not one per label and for all three targets rather than
1429+ ;; the web alone, because copying a line out of a chat is a thing a
1430+ ;; desktop window should do too.
1431+ m/SelectionArea
1372 (f/widget1432 (f/widget
1373 :context ctx1433 :context ctx
1374 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it1434 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
@@ -1382,3 +1442,11 @@
1382 ;; beside the screens is floated over them where there is a pointer,1442 ;; beside the screens is floated over them where there is a pointer,
1383 ;; and drawn where it stands where there is not.1443 ;; and drawn where it stands where there is not.
1384 (h/render-root [screens/app]))))))1444 (h/render-root [screens/app]))))))
1445+
1446+(defn ^:async main []
1447+ (bind!)
1448+ (host/install! (.-path (await (pp/getApplicationSupportDirectory))))
1449+ (net-dart/install!)
1450+ (oauth-dart/install!)
1451+ (atproto-dart/install!)
1452+ (await (start!)))
modified flutter/src/frq/oauth/dart.cljd +14 -2
@@ -18,8 +18,14 @@
1818 is the one thing Dart cannot do alone `frq.io/open-url!` answers that."
1919 (:require ["dart:io" :as io]
2020 ["dart:async" :as async]
21+ ;; For `defaultTargetPlatform` alone: dart:io's `Platform` throws
22+ ;; in a browser, and this file compiles for the web target too.
23+ ;; foundation and not material, because material re-exports it
24+ ;; behind a `show` list that leaves the getter out.
25+ ["package:flutter/foundation.dart" :as fnd]
2126 [frq.io :as host]
22- [frq.oauth.core :as core]))
27+ [frq.oauth.core :as core]
28+ [frq.oauth.handoff :as handoff]))
2329
2430 (defn- return-url
2531 "What brings the app back to the front once the browser is done, or nil where
@@ -36,7 +42,7 @@
3642 button that does nothing. `core/capture-html` takes nil for precisely this
3743 and says so; this is the caller it was written for."
3844 []
39- (when (.-isAndroid io/Platform) "frq://auth"))
45+ (when (= fnd/TargetPlatform.android fnd/defaultTargetPlatform) "frq://auth"))
4046
4147 (defn ^:async await-callback!
4248 "Serve the loopback capture until the browser posts the handoff back.
@@ -95,3 +101,9 @@
95101 nil)))
96102 (await (.-future done))
97103 (finally (await (.close server .force true))))))
104+
105+(defn install!
106+ "Register the loopback handoff. The mirror of `frq.oauth.web/install!`,
107+ called from `frq.main` beside the host and the transport."
108+ []
109+ (handoff/install! {:await-callback! await-callback!}))
@@ -18,8 +18,14 @@
18 is the one thing Dart cannot do alone `frq.io/open-url!` answers that."18 is the one thing Dart cannot do alone `frq.io/open-url!` answers that."
19 (:require ["dart:io" :as io]19 (:require ["dart:io" :as io]
20 ["dart:async" :as async]20 ["dart:async" :as async]
21+ ;; For `defaultTargetPlatform` alone: dart:io's `Platform` throws
22+ ;; in a browser, and this file compiles for the web target too.
23+ ;; foundation and not material, because material re-exports it
24+ ;; behind a `show` list that leaves the getter out.
25+ ["package:flutter/foundation.dart" :as fnd]
21 [frq.io :as host]26 [frq.io :as host]
22- [frq.oauth.core :as core]))27+ [frq.oauth.core :as core]
28+ [frq.oauth.handoff :as handoff]))
23 29
24 (defn- return-url30 (defn- return-url
25 "What brings the app back to the front once the browser is done, or nil where31 "What brings the app back to the front once the browser is done, or nil where
@@ -36,7 +42,7 @@
36 button that does nothing. `core/capture-html` takes nil for precisely this42 button that does nothing. `core/capture-html` takes nil for precisely this
37 and says so; this is the caller it was written for."43 and says so; this is the caller it was written for."
38 []44 []
39- (when (.-isAndroid io/Platform) "frq://auth"))45+ (when (= fnd/TargetPlatform.android fnd/defaultTargetPlatform) "frq://auth"))
40 46
41 (defn ^:async await-callback!47 (defn ^:async await-callback!
42 "Serve the loopback capture until the browser posts the handoff back.48 "Serve the loopback capture until the browser posts the handoff back.
@@ -95,3 +101,9 @@
95 nil)))101 nil)))
96 (await (.-future done))102 (await (.-future done))
97 (finally (await (.close server .force true))))))103 (finally (await (.close server .force true))))))
104+
105+(defn install!
106+ "Register the loopback handoff. The mirror of `frq.oauth.web/install!`,
107+ called from `frq.main` beside the host and the transport."
108+ []
109+ (handoff/install! {:await-callback! await-callback!}))
added flutter/src/frq/oauth/handoff.cljd +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+(ns frq.oauth.handoff
2+ "How the first broker token gets back into the app, named once for both
3+ Flutter targets.
4+
5+ `frq.net`'s shape again, and the third seam for the same reason: the browser
6+ leg of the OAuth handoff is the one part of sign-in that is not a request
7+ but a *place to be redirected to*, and the three targets have three
8+ different answers. A desktop or a phone binds a loopback listener and reads
9+ what the capture page posts back; a browser has no listener and needs none,
10+ because the broker redirects it to its own URL with the payload in the
11+ fragment.
12+
13+ Small enough to be one function, and it is the one that differs. Everything
14+ around it `login-url`, `tokens-of`, the base64url payload is
15+ `frq.oauth.core` and portable already.")
16+
17+(defonce ^:private impl (atom {}))
18+
19+(defn install! [m] (swap! impl merge m) nil)
20+
21+(defn ^:async await-callback!
22+ "Get the first broker token, however this platform gets one.
23+
24+ `on-url` is called with the login URL the desktop shows it beside a
25+ listener it has just bound, and the web shows it a moment before the page
26+ leaves for it. Returns the tokens.
27+
28+ Awaited here rather than handed back, for `frq.net/connect!`'s reason: a
29+ dynamic call's return is not something cljd can see is awaitable."
30+ [broker handle on-url]
31+ (if-let [f (:await-callback! @impl)]
32+ (await (f broker handle on-url))
33+ (throw (ex-info "frq.oauth: no handoff installed" {:op :await-callback!}))))
34+
35+(defn ^:async prepare-session!
36+ "The session, made ready to authenticate with right now.
37+
38+ A no-op on the loopback path and the whole point on the web one: a
39+ `:pds-oauth` session carries a DPoP proof, and a proof is minted for a
40+ moment it has an `iat`, a one-shot `jti`, and sometimes a nonce the PDS
41+ handed out. One kept from sign-in would be stale by the time a reconnect
42+ used it, so it is made here, immediately before the handshake needs it.
43+
44+ Returns the session, changed or not, so the caller can simply put back what
45+ it gets."
46+ [session]
47+ (if-let [f (:prepare-session! @impl)]
48+ (await (f session))
49+ session))
new file mode 100644
@@ -0,0 +1,49 @@
1+(ns frq.oauth.handoff
2+ "How the first broker token gets back into the app, named once for both
3+ Flutter targets.
4+
5+ `frq.net`'s shape again, and the third seam for the same reason: the browser
6+ leg of the OAuth handoff is the one part of sign-in that is not a request
7+ but a *place to be redirected to*, and the three targets have three
8+ different answers. A desktop or a phone binds a loopback listener and reads
9+ what the capture page posts back; a browser has no listener and needs none,
10+ because the broker redirects it to its own URL with the payload in the
11+ fragment.
12+
13+ Small enough to be one function, and it is the one that differs. Everything
14+ around it `login-url`, `tokens-of`, the base64url payload is
15+ `frq.oauth.core` and portable already.")
16+
17+(defonce ^:private impl (atom {}))
18+
19+(defn install! [m] (swap! impl merge m) nil)
20+
21+(defn ^:async await-callback!
22+ "Get the first broker token, however this platform gets one.
23+
24+ `on-url` is called with the login URL the desktop shows it beside a
25+ listener it has just bound, and the web shows it a moment before the page
26+ leaves for it. Returns the tokens.
27+
28+ Awaited here rather than handed back, for `frq.net/connect!`'s reason: a
29+ dynamic call's return is not something cljd can see is awaitable."
30+ [broker handle on-url]
31+ (if-let [f (:await-callback! @impl)]
32+ (await (f broker handle on-url))
33+ (throw (ex-info "frq.oauth: no handoff installed" {:op :await-callback!}))))
34+
35+(defn ^:async prepare-session!
36+ "The session, made ready to authenticate with right now.
37+
38+ A no-op on the loopback path and the whole point on the web one: a
39+ `:pds-oauth` session carries a DPoP proof, and a proof is minted for a
40+ moment it has an `iat`, a one-shot `jti`, and sometimes a nonce the PDS
41+ handed out. One kept from sign-in would be stale by the time a reconnect
42+ used it, so it is made here, immediately before the handshake needs it.
43+
44+ Returns the session, changed or not, so the caller can simply put back what
45+ it gets."
46+ [session]
47+ (if-let [f (:prepare-session! @impl)]
48+ (await (f session))
49+ session))
added flutter/src/frq/oauth/web.cljd +407 -0
new file mode 100644
@@ -0,0 +1,407 @@
1+(ns frq.oauth.web
2+ "Bluesky sign-in from the browser, as an OAuth client of our own.
3+
4+ Not freeq's broker. The broker finishes a login by redirecting to
5+ `return_to`, and it only redirects to hosts on its own allowlist — its https
6+ origins and loopback — so a build served from anywhere else can never
7+ complete a sign-in through it, whatever the client does. Asking to be added
8+ to that list is somebody else's decision.
9+
10+ So this does the AT Protocol OAuth itself, and the thing that makes that
11+ possible is whose allowlist applies: an authorization server fetches the
12+ client's metadata from its `client_id` URL and takes *that document* as the
13+ authority on where a code may be sent. We serve it — see `CLIENT_METADATA`
14+ in `.modal/flutter-web/serve.py` — so the redirect URI is ours to declare.
15+
16+ A public client with no secret, which a page in a browser could not keep
17+ anyway. What stands in for one is DPoP: every token is bound to a key this
18+ client proves it holds, and that is also exactly what freeq's SASL
19+ `pds-oauth` method verifies — it takes the token and a proof, calls the
20+ PDS's getSession with both, and believes the PDS. So no broker web-token is
21+ needed at the end of this either.
22+
23+ The flow is two halves with a page load between them, as in every browser
24+ OAuth: `await-callback!` leaves for the authorization server and does not
25+ return, and `resume!` runs on the load that comes back."
26+ (:require ["dart:async" :as async]
27+ ["dart:html" :as html]
28+ [clojure.string :as string]
29+ [frq.atproto.core :as acore]
30+ ;; `url-encode` is here and `json-str` is in atproto's core; the
31+ ;; two cores split by what they describe, not by who calls them.
32+ [frq.oauth.core :as core]
33+ [frq.atproto.http :as atproto]
34+ [frq.cells :as cells]
35+ [frq.dpop.web :as dpop]
36+ [frq.io :as fio]
37+ [frq.oauth.handoff :as handoff]))
38+
39+(defn- origin
40+ "The app's root, which is both the client's identity and where a code comes
41+ back.
42+
43+ The origin and a bare slash — deliberately NOT `location.pathname`. Both
44+ values here have to match `client-metadata.json` exactly: the `client_id` is
45+ the URL an authorization server fetches that document from, and the
46+ `redirect_uri` has to be one it lists. Built from the current path instead,
47+ a page opened at `/index.html` asks Bluesky for
48+ `/index.htmlclient-metadata.json` and is told, quite correctly, `Not Found`."
49+ []
50+ (str (.-origin (.-location html/window)) "/"))
51+
52+(defn- client-id [] (str (origin) "client-metadata.json"))
53+
54+;; Where the half-finished login is kept while the browser is away at the
55+;; authorization server. Through `frq.io` rather than localStorage directly:
56+;; the seam is already the answer to "somewhere durable", and on the web it is
57+;; localStorage anyway.
58+(defn- pending-file [] (str (fio/config-dir) "/oauth-pending"))
59+(defn- session-file [] (str (fio/config-dir) "/oauth-session"))
60+
61+(defn- write-fields!
62+ "Save a flat map of strings as lines of `key\tvalue`.
63+
64+ Lines and tabs rather than EDN: `frq.store` reads EDN with `clojure.edn`,
65+ and this is a handful of opaque strings — a verifier, a state, two URLs —
66+ where the parser is the only thing that could go wrong."
67+ [path m]
68+ (fio/write-private-file!
69+ path
70+ (apply str (for [[k v] m] (str (name k) "\t" (str v) "\n")))))
71+
72+(defn- read-fields
73+ [path]
74+ (when (fio/file-exists? path)
75+ (when-let [text (fio/slurp path)]
76+ (into {}
77+ (comp (map (fn [^String line] (.split line "\t")))
78+ (filter #(= 2 (count %)))
79+ (map (fn [parts] [(keyword (str (first parts))) (str (second parts))])))
80+ (.split (str text) "\n")))))
81+
82+(defn ^:async ^:private http!
83+ "One request, with the headers OAuth needs and the answer it gives back.
84+
85+ `html/HttpRequest` by hand for `frq.atproto.web`'s reason the convenience
86+ wrapper wants a Dart `Map<String,String>` and because this needs two
87+ things that wrapper hides: a non-2xx body, and the `DPoP-Nonce` header.
88+ Both are load-bearing here. An authorization server answers the first
89+ request of a flow with 400 `use_dpop_nonce` and the nonce to use, and that
90+ is not an error, it is the handshake."
91+ [method url headers body]
92+ (let [req (html/HttpRequest.)
93+ done (async/Completer.)]
94+ (.open req method url)
95+ (doseq [[k v] headers]
96+ (try (.setRequestHeader req (str k) (str v)) (catch Object _ nil)))
97+ (.addEventListener req "load"
98+ (fn [_]
99+ (when-not (.-isCompleted done)
100+ (.complete done
101+ {:status (.-status req)
102+ :body (str (.-responseText req))
103+ :nonce (str (or (.getResponseHeader req "dpop-nonce") ""))}))
104+ nil))
105+ (.addEventListener req "error"
106+ (fn [_]
107+ (when-not (.-isCompleted done)
108+ (.completeError
109+ done (Exception. (str "Could not reach " url))))
110+ nil))
111+ (if body (.send req (str body)) (.send req))
112+ (await (.-future done))))
113+
114+(defn- first-in-array
115+ "The first string of a JSON array field, by scanning.
116+
117+ `frq.atproto.core` reads strings and numbers out of JSON and has no array
118+ reader, and `authorization_servers` is the one array this flow needs one
119+ field, one element, in a document written by the server we are about to
120+ trust anyway. A scanner is smaller than a parser and says so."
121+ [body key]
122+ (let [body (str body)
123+ at (.indexOf body (str "\"" key "\""))]
124+ (when (<= 0 at)
125+ (let [open (.indexOf body "[" at)
126+ q1 (.indexOf body "\"" open)
127+ q2 (.indexOf body "\"" (inc q1))]
128+ (when (and (< at open) (< open q1) (< q1 q2))
129+ (.substring body (inc q1) q2))))))
130+
131+(defn ^:async ^:private auth-server!
132+ "Which server authorizes for this PDS.
133+
134+ Two shapes, and the difference is what a real account runs into. A PDS shard
135+ `puffball.us-east.host.bsky.network` and its siblings publishes
136+ `oauth-protected-resource` naming `https://bsky.social` as its authorization
137+ server, and serves no authorization-server metadata of its own. An
138+ all-in-one host like bsky.social IS the authorization server and publishes
139+ no protected-resource document at all: probing for one there returns an HTML
140+ 404.
141+
142+ So ask for the pointer, and fall back to the PDS itself when there is none.
143+ Testing against bsky.social alone hid this entirely the first real handle
144+ went to a shard and stopped dead."
145+ [base]
146+ (let [{:keys [status body]} (await (http! "GET"
147+ (str base "/.well-known/oauth-protected-resource")
148+ {} nil))]
149+ (or (when (= 200 status) (first-in-array body "authorization_servers"))
150+ base)))
151+
152+(defn ^:async ^:private discover!
153+ "The authorization server's endpoints for a PDS."
154+ [pds]
155+ (let [base (.replaceAll (str pds) (RegExp. "/+$") "")
156+ as (.replaceAll (str (await (auth-server! base))) (RegExp. "/+$") "")
157+ {:keys [status body]} (await (http! "GET"
158+ (str as "/.well-known/oauth-authorization-server")
159+ {} nil))]
160+ (when-not (= 200 status)
161+ (throw (ex-info (str "No OAuth metadata at " as) {:status status})))
162+ {:par (acore/json-str body "pushed_authorization_request_endpoint")
163+ :authorize (acore/json-str body "authorization_endpoint")
164+ :token (acore/json-str body "token_endpoint")}))
165+
166+(defn- form
167+ "application/x-www-form-urlencoded from pairs, encoded by core's own encoder."
168+ [pairs]
169+ (string/join
170+ "&"
171+ (for [[k v] pairs] (str (name k) "=" (core/url-encode (str v))))))
172+
173+(defn ^:async ^:private send-form!
174+ "One form POST carrying a freshly minted proof.
175+
176+ A named function and not a closure inside `post-form!`: nothing in this repo
177+ writes `(fn ^:async ...)`, and the compiler agrees a namespace with one in
178+ it fails with the namespace named and nothing else said, which is an
179+ afternoon nobody needs twice."
180+ [url body token nonce]
181+ (let [p (await (dpop/proof "POST" url nonce (or token "")))]
182+ (await (http! "POST" url
183+ {"Content-Type" "application/x-www-form-urlencoded"
184+ "DPoP" p}
185+ body))))
186+
187+(defn ^:async ^:private post-form!
188+ "POST a form with a DPoP proof, retrying once when the server asks for a
189+ nonce.
190+
191+ The retry is the protocol and not a fallback: a client has no way to know
192+ the first nonce, so the first request of every flow is answered with 400
193+ `use_dpop_nonce` and the nonce to use. `frq.net.web` has the same shape of
194+ handshake one layer down."
195+ [url body token]
196+ (let [first-try (await (send-form! url body token ""))]
197+ (if (and (>= (:status first-try) 400)
198+ (.contains (str (:body first-try)) "use_dpop_nonce")
199+ (seq (:nonce first-try)))
200+ (await (send-form! url body token (:nonce first-try)))
201+ first-try)))
202+
203+(defn ^:async ^:private get-with-dpop!
204+ "An authenticated GET carrying a proof, retrying once for a nonce.
205+
206+ The same handshake `post-form!` does, one method over: a PDS answers the
207+ first DPoP-authenticated request of a session with 401 and the nonce it
208+ wants, and that is the protocol rather than a failure."
209+ [url token nonce]
210+ (let [p (await (dpop/proof "GET" url nonce token))
211+ resp (await (http! "GET" url
212+ {"Authorization" (str "DPoP " token) "DPoP" p}
213+ nil))]
214+ (if (and (>= (:status resp) 400)
215+ (.contains (str (:body resp)) "use_dpop_nonce")
216+ (seq (:nonce resp))
217+ (empty? (str nonce)))
218+ (await (get-with-dpop! url token (:nonce resp)))
219+ resp)))
220+
221+(defn ^:async ^:private whoami!
222+ "Who the token belongs to, asked of the PDS.
223+
224+ Three things at once, which is why it is worth a round trip. It settles the
225+ handle the token response carries `sub`, a DID, and nothing a person would
226+ recognise, and without a handle `frq.main/sign-in!` has no nick to derive
227+ and leaves whatever was in the box, which is how an OAuth sign-in arrived on
228+ the server calling itself `frq-guest`.
229+
230+ It proves the DPoP proof is accepted before one is handed to freeq, since
231+ this is the very call freeq will make with it.
232+
233+ And it collects the DPoP nonce the PDS wants, so the proof minted at connect
234+ carries one already rather than costing freeq a `DPOP_NONCE:` round trip."
235+ [pds token]
236+ (let [url (str (.replaceAll (str pds) (RegExp. "/+$") "")
237+ "/xrpc/com.atproto.server.getSession")
238+ resp (await (get-with-dpop! url token ""))]
239+ (when (= 200 (:status resp))
240+ {:handle (acore/json-str (:body resp) "handle")
241+ :did (acore/json-str (:body resp) "did")
242+ :nonce (:nonce resp)})))
243+
244+(defn ^:async ^:private begin!
245+ "Push the request, then leave for the authorization server.
246+
247+ PAR and not a plain authorize URL: `require_pushed_authorization_requests`
248+ is true at bsky.social, so the parameters go up over the back channel first
249+ and the browser carries only the `request_uri` that comes back."
250+ [handle on-url]
251+ (let [did (await (atproto/resolve-handle handle))
252+ pds (await (atproto/pds-endpoint did))
253+ {:keys [par authorize token]} (await (discover! pds))
254+ verifier (dpop/verifier)
255+ challenge (await (dpop/challenge verifier))
256+ state (dpop/random 16)
257+ body (form [[:client_id (client-id)]
258+ [:redirect_uri (origin)]
259+ [:response_type "code"]
260+ [:scope "atproto transition:generic"]
261+ [:state state]
262+ [:code_challenge challenge]
263+ [:code_challenge_method "S256"]
264+ ;; So the server can skip asking who is signing in. It is
265+ ;; a hint and not an assertion — the reader still chooses
266+ ;; at the authorization page.
267+ [:login_hint handle]])
268+ resp (await (post-form! par body nil))]
269+ (when-not (= 201 (:status resp))
270+ (throw (ex-info (str "Authorization request refused: " (:body resp))
271+ {:status (:status resp)})))
272+ (let [request-uri (acore/json-str (:body resp) "request_uri")
273+ url (str authorize "?client_id=" (core/url-encode (client-id))
274+ "&request_uri=" (core/url-encode request-uri))]
275+ (write-fields! (pending-file)
276+ {:verifier verifier :state state :did did
277+ :handle handle :pds pds :token token})
278+ (on-url url)
279+ (.assign (.-location html/window) url)
280+ ;; The page is leaving. A Future nobody completes is what the caller's
281+ ;; `await` holds until it does.
282+ (await (.-future (async/Completer.))))))
283+
284+(defn ^:async ^:private await-callback!
285+ "`frq.oauth.handoff`'s shape. `broker` is ignored — there is no broker on
286+ this path, and the argument stays so the seam is one signature."
287+ [_broker handle on-url]
288+ (await (begin! (str handle) on-url)))
289+
290+(defn ^:async resume!
291+ "Finish a sign-in that left this page and came back. True when one did.
292+
293+ Run before `frq.main/start!`: what it leaves behind is a session in
294+ `frq.cells`, which is what `sign-in!` then finds instead of starting the
295+ browser leg again.
296+
297+ The query string is cleared with `replaceState` rather than by assigning to
298+ `location`, which would push a history entry and leave a Back button that
299+ redeems a spent code."
300+ []
301+ (let [search (str (.-search (.-location html/window)))
302+ ^#/(Map String String)
303+ params (Uri.splitQueryString (if (.startsWith search "?")
304+ (.substring search 1)
305+ search))
306+ ;; `get`, and this is the correction that cost the most: cljd's core
307+ ;; DOES extend Dart's Map with ILookup — `(-lookup [m k] (. m "[]" k))`
308+ ;; — so `get` reaches it. `aget` does not and never did: it compiles
309+ ;; unconditionally to `(x as List)[k as int]`, which on a Map is a
310+ ;; TypeError.
311+ code (get params "code")
312+ state (get params "state")
313+ pending (read-fields (pending-file))]
314+ (when (and (seq (str (or code ""))) pending)
315+ (try
316+ (.replaceState (.-history html/window) nil "" (origin))
317+ (catch Object _ nil))
318+ ;; State is the CSRF binding: a code arriving with a state we did not
319+ ;; issue is not ours, and redeeming it would be the attack this exists
320+ ;; to prevent.
321+ (if-not (= (str state) (:state pending))
322+ (do (reset! cells/error "Sign-in failed: state did not match") false)
323+ (let [resp (await (post-form!
324+ (:token pending)
325+ (form [[:grant_type "authorization_code"]
326+ [:code code]
327+ [:redirect_uri (origin)]
328+ [:client_id (client-id)]
329+ [:code_verifier (:verifier pending)]])
330+ nil))]
331+ (if-not (= 200 (:status resp))
332+ (do (reset! cells/error (str "Sign-in failed: " (:body resp))) false)
333+ (let [access (acore/json-str (:body resp) "access_token")
334+ refresh (acore/json-str (:body resp) "refresh_token")
335+ did (or (acore/json-str (:body resp) "sub") (:did pending))
336+ who (await (whoami! (:pds pending) access))
337+ handle (or (:handle who) (:handle pending) "")
338+ session {:kind :pds-oauth
339+ :did (or (:did who) did)
340+ :handle handle
341+ :access-jwt access
342+ :pds (:pds pending)
343+ :dpop-nonce (str (or (:nonce who) ""))
344+ :refresh refresh}]
345+ (fio/delete-file! (pending-file))
346+ (write-fields! (session-file)
347+ {:did (:did session) :access access
348+ :handle handle
349+ :pds (:pds pending) :refresh (or refresh "")})
350+ (reset! cells/session session)
351+ (reset! cells/auth-mode :bluesky)
352+ ;; The name the connect screen and the nick are drawn from.
353+ ;; `frq.main/sign-in!` reads the session for both, but only if
354+ ;; the session says who it belongs to.
355+ (when (seq handle)
356+ (reset! cells/form-handle handle)
357+ (reset! cells/form-nick (first (.split ^String handle "."))))
358+ true)))))))
359+
360+(defn restore!
361+ "A session from an earlier visit, back into `frq.cells`. True when there was
362+ one.
363+
364+ Separate from `resume!` because they answer different questions: one is
365+ \"did we just come back from the authorization server\", the other is \"was
366+ there a sign-in before today\". Both end in the same place."
367+ []
368+ (when-let [m (read-fields (session-file))]
369+ (when (seq (str (or (:access m) "")))
370+ (reset! cells/session {:kind :pds-oauth
371+ :did (:did m)
372+ :handle (:handle m)
373+ :access-jwt (:access m)
374+ :pds (:pds m)
375+ :refresh (:refresh m)})
376+ (reset! cells/auth-mode :bluesky)
377+ (when (seq (str (or (:handle m) "")))
378+ (reset! cells/form-handle (:handle m))
379+ (reset! cells/form-nick (first (.split ^String (str (:handle m)) "."))))
380+ true)))
381+
382+(defn forget!
383+ "Drop the saved session and the key that was bound to it."
384+ []
385+ (fio/delete-file! (session-file))
386+ (dpop/forget!)
387+ nil)
388+
389+(defn ^:async ^:private prepare-session!
390+ "Mint the DPoP proof freeq will present to the PDS on our behalf.
391+
392+ For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the access
393+ token, because that is the exact request `verify_pds_oauth` makes with it.
394+ Minted per connect: a proof has an `iat` and a single-use `jti`, so one kept
395+ from sign-in would be refused by the time a reconnect offered it."
396+ [session]
397+ (if-not (= :pds-oauth (:kind session))
398+ session
399+ (let [url (str (.replaceAll (str (:pds session)) (RegExp. "/+$") "")
400+ "/xrpc/com.atproto.server.getSession")
401+ p (await (dpop/proof "GET" url (str (or (:dpop-nonce session) ""))
402+ (str (:access-jwt session))))]
403+ (assoc session :dpop-proof p))))
404+
405+(defn install! []
406+ (handoff/install! {:await-callback! await-callback!
407+ :prepare-session! prepare-session!}))
new file mode 100644
@@ -0,0 +1,407 @@
1+(ns frq.oauth.web
2+ "Bluesky sign-in from the browser, as an OAuth client of our own.
3+
4+ Not freeq's broker. The broker finishes a login by redirecting to
5+ `return_to`, and it only redirects to hosts on its own allowlist — its https
6+ origins and loopback — so a build served from anywhere else can never
7+ complete a sign-in through it, whatever the client does. Asking to be added
8+ to that list is somebody else's decision.
9+
10+ So this does the AT Protocol OAuth itself, and the thing that makes that
11+ possible is whose allowlist applies: an authorization server fetches the
12+ client's metadata from its `client_id` URL and takes *that document* as the
13+ authority on where a code may be sent. We serve it — see `CLIENT_METADATA`
14+ in `.modal/flutter-web/serve.py` — so the redirect URI is ours to declare.
15+
16+ A public client with no secret, which a page in a browser could not keep
17+ anyway. What stands in for one is DPoP: every token is bound to a key this
18+ client proves it holds, and that is also exactly what freeq's SASL
19+ `pds-oauth` method verifies — it takes the token and a proof, calls the
20+ PDS's getSession with both, and believes the PDS. So no broker web-token is
21+ needed at the end of this either.
22+
23+ The flow is two halves with a page load between them, as in every browser
24+ OAuth: `await-callback!` leaves for the authorization server and does not
25+ return, and `resume!` runs on the load that comes back."
26+ (:require ["dart:async" :as async]
27+ ["dart:html" :as html]
28+ [clojure.string :as string]
29+ [frq.atproto.core :as acore]
30+ ;; `url-encode` is here and `json-str` is in atproto's core; the
31+ ;; two cores split by what they describe, not by who calls them.
32+ [frq.oauth.core :as core]
33+ [frq.atproto.http :as atproto]
34+ [frq.cells :as cells]
35+ [frq.dpop.web :as dpop]
36+ [frq.io :as fio]
37+ [frq.oauth.handoff :as handoff]))
38+
39+(defn- origin
40+ "The app's root, which is both the client's identity and where a code comes
41+ back.
42+
43+ The origin and a bare slash — deliberately NOT `location.pathname`. Both
44+ values here have to match `client-metadata.json` exactly: the `client_id` is
45+ the URL an authorization server fetches that document from, and the
46+ `redirect_uri` has to be one it lists. Built from the current path instead,
47+ a page opened at `/index.html` asks Bluesky for
48+ `/index.htmlclient-metadata.json` and is told, quite correctly, `Not Found`."
49+ []
50+ (str (.-origin (.-location html/window)) "/"))
51+
52+(defn- client-id [] (str (origin) "client-metadata.json"))
53+
54+;; Where the half-finished login is kept while the browser is away at the
55+;; authorization server. Through `frq.io` rather than localStorage directly:
56+;; the seam is already the answer to "somewhere durable", and on the web it is
57+;; localStorage anyway.
58+(defn- pending-file [] (str (fio/config-dir) "/oauth-pending"))
59+(defn- session-file [] (str (fio/config-dir) "/oauth-session"))
60+
61+(defn- write-fields!
62+ "Save a flat map of strings as lines of `key\tvalue`.
63+
64+ Lines and tabs rather than EDN: `frq.store` reads EDN with `clojure.edn`,
65+ and this is a handful of opaque strings — a verifier, a state, two URLs —
66+ where the parser is the only thing that could go wrong."
67+ [path m]
68+ (fio/write-private-file!
69+ path
70+ (apply str (for [[k v] m] (str (name k) "\t" (str v) "\n")))))
71+
72+(defn- read-fields
73+ [path]
74+ (when (fio/file-exists? path)
75+ (when-let [text (fio/slurp path)]
76+ (into {}
77+ (comp (map (fn [^String line] (.split line "\t")))
78+ (filter #(= 2 (count %)))
79+ (map (fn [parts] [(keyword (str (first parts))) (str (second parts))])))
80+ (.split (str text) "\n")))))
81+
82+(defn ^:async ^:private http!
83+ "One request, with the headers OAuth needs and the answer it gives back.
84+
85+ `html/HttpRequest` by hand for `frq.atproto.web`'s reason the convenience
86+ wrapper wants a Dart `Map<String,String>` and because this needs two
87+ things that wrapper hides: a non-2xx body, and the `DPoP-Nonce` header.
88+ Both are load-bearing here. An authorization server answers the first
89+ request of a flow with 400 `use_dpop_nonce` and the nonce to use, and that
90+ is not an error, it is the handshake."
91+ [method url headers body]
92+ (let [req (html/HttpRequest.)
93+ done (async/Completer.)]
94+ (.open req method url)
95+ (doseq [[k v] headers]
96+ (try (.setRequestHeader req (str k) (str v)) (catch Object _ nil)))
97+ (.addEventListener req "load"
98+ (fn [_]
99+ (when-not (.-isCompleted done)
100+ (.complete done
101+ {:status (.-status req)
102+ :body (str (.-responseText req))
103+ :nonce (str (or (.getResponseHeader req "dpop-nonce") ""))}))
104+ nil))
105+ (.addEventListener req "error"
106+ (fn [_]
107+ (when-not (.-isCompleted done)
108+ (.completeError
109+ done (Exception. (str "Could not reach " url))))
110+ nil))
111+ (if body (.send req (str body)) (.send req))
112+ (await (.-future done))))
113+
114+(defn- first-in-array
115+ "The first string of a JSON array field, by scanning.
116+
117+ `frq.atproto.core` reads strings and numbers out of JSON and has no array
118+ reader, and `authorization_servers` is the one array this flow needs one
119+ field, one element, in a document written by the server we are about to
120+ trust anyway. A scanner is smaller than a parser and says so."
121+ [body key]
122+ (let [body (str body)
123+ at (.indexOf body (str "\"" key "\""))]
124+ (when (<= 0 at)
125+ (let [open (.indexOf body "[" at)
126+ q1 (.indexOf body "\"" open)
127+ q2 (.indexOf body "\"" (inc q1))]
128+ (when (and (< at open) (< open q1) (< q1 q2))
129+ (.substring body (inc q1) q2))))))
130+
131+(defn ^:async ^:private auth-server!
132+ "Which server authorizes for this PDS.
133+
134+ Two shapes, and the difference is what a real account runs into. A PDS shard
135+ `puffball.us-east.host.bsky.network` and its siblings publishes
136+ `oauth-protected-resource` naming `https://bsky.social` as its authorization
137+ server, and serves no authorization-server metadata of its own. An
138+ all-in-one host like bsky.social IS the authorization server and publishes
139+ no protected-resource document at all: probing for one there returns an HTML
140+ 404.
141+
142+ So ask for the pointer, and fall back to the PDS itself when there is none.
143+ Testing against bsky.social alone hid this entirely the first real handle
144+ went to a shard and stopped dead."
145+ [base]
146+ (let [{:keys [status body]} (await (http! "GET"
147+ (str base "/.well-known/oauth-protected-resource")
148+ {} nil))]
149+ (or (when (= 200 status) (first-in-array body "authorization_servers"))
150+ base)))
151+
152+(defn ^:async ^:private discover!
153+ "The authorization server's endpoints for a PDS."
154+ [pds]
155+ (let [base (.replaceAll (str pds) (RegExp. "/+$") "")
156+ as (.replaceAll (str (await (auth-server! base))) (RegExp. "/+$") "")
157+ {:keys [status body]} (await (http! "GET"
158+ (str as "/.well-known/oauth-authorization-server")
159+ {} nil))]
160+ (when-not (= 200 status)
161+ (throw (ex-info (str "No OAuth metadata at " as) {:status status})))
162+ {:par (acore/json-str body "pushed_authorization_request_endpoint")
163+ :authorize (acore/json-str body "authorization_endpoint")
164+ :token (acore/json-str body "token_endpoint")}))
165+
166+(defn- form
167+ "application/x-www-form-urlencoded from pairs, encoded by core's own encoder."
168+ [pairs]
169+ (string/join
170+ "&"
171+ (for [[k v] pairs] (str (name k) "=" (core/url-encode (str v))))))
172+
173+(defn ^:async ^:private send-form!
174+ "One form POST carrying a freshly minted proof.
175+
176+ A named function and not a closure inside `post-form!`: nothing in this repo
177+ writes `(fn ^:async ...)`, and the compiler agrees a namespace with one in
178+ it fails with the namespace named and nothing else said, which is an
179+ afternoon nobody needs twice."
180+ [url body token nonce]
181+ (let [p (await (dpop/proof "POST" url nonce (or token "")))]
182+ (await (http! "POST" url
183+ {"Content-Type" "application/x-www-form-urlencoded"
184+ "DPoP" p}
185+ body))))
186+
187+(defn ^:async ^:private post-form!
188+ "POST a form with a DPoP proof, retrying once when the server asks for a
189+ nonce.
190+
191+ The retry is the protocol and not a fallback: a client has no way to know
192+ the first nonce, so the first request of every flow is answered with 400
193+ `use_dpop_nonce` and the nonce to use. `frq.net.web` has the same shape of
194+ handshake one layer down."
195+ [url body token]
196+ (let [first-try (await (send-form! url body token ""))]
197+ (if (and (>= (:status first-try) 400)
198+ (.contains (str (:body first-try)) "use_dpop_nonce")
199+ (seq (:nonce first-try)))
200+ (await (send-form! url body token (:nonce first-try)))
201+ first-try)))
202+
203+(defn ^:async ^:private get-with-dpop!
204+ "An authenticated GET carrying a proof, retrying once for a nonce.
205+
206+ The same handshake `post-form!` does, one method over: a PDS answers the
207+ first DPoP-authenticated request of a session with 401 and the nonce it
208+ wants, and that is the protocol rather than a failure."
209+ [url token nonce]
210+ (let [p (await (dpop/proof "GET" url nonce token))
211+ resp (await (http! "GET" url
212+ {"Authorization" (str "DPoP " token) "DPoP" p}
213+ nil))]
214+ (if (and (>= (:status resp) 400)
215+ (.contains (str (:body resp)) "use_dpop_nonce")
216+ (seq (:nonce resp))
217+ (empty? (str nonce)))
218+ (await (get-with-dpop! url token (:nonce resp)))
219+ resp)))
220+
221+(defn ^:async ^:private whoami!
222+ "Who the token belongs to, asked of the PDS.
223+
224+ Three things at once, which is why it is worth a round trip. It settles the
225+ handle the token response carries `sub`, a DID, and nothing a person would
226+ recognise, and without a handle `frq.main/sign-in!` has no nick to derive
227+ and leaves whatever was in the box, which is how an OAuth sign-in arrived on
228+ the server calling itself `frq-guest`.
229+
230+ It proves the DPoP proof is accepted before one is handed to freeq, since
231+ this is the very call freeq will make with it.
232+
233+ And it collects the DPoP nonce the PDS wants, so the proof minted at connect
234+ carries one already rather than costing freeq a `DPOP_NONCE:` round trip."
235+ [pds token]
236+ (let [url (str (.replaceAll (str pds) (RegExp. "/+$") "")
237+ "/xrpc/com.atproto.server.getSession")
238+ resp (await (get-with-dpop! url token ""))]
239+ (when (= 200 (:status resp))
240+ {:handle (acore/json-str (:body resp) "handle")
241+ :did (acore/json-str (:body resp) "did")
242+ :nonce (:nonce resp)})))
243+
244+(defn ^:async ^:private begin!
245+ "Push the request, then leave for the authorization server.
246+
247+ PAR and not a plain authorize URL: `require_pushed_authorization_requests`
248+ is true at bsky.social, so the parameters go up over the back channel first
249+ and the browser carries only the `request_uri` that comes back."
250+ [handle on-url]
251+ (let [did (await (atproto/resolve-handle handle))
252+ pds (await (atproto/pds-endpoint did))
253+ {:keys [par authorize token]} (await (discover! pds))
254+ verifier (dpop/verifier)
255+ challenge (await (dpop/challenge verifier))
256+ state (dpop/random 16)
257+ body (form [[:client_id (client-id)]
258+ [:redirect_uri (origin)]
259+ [:response_type "code"]
260+ [:scope "atproto transition:generic"]
261+ [:state state]
262+ [:code_challenge challenge]
263+ [:code_challenge_method "S256"]
264+ ;; So the server can skip asking who is signing in. It is
265+ ;; a hint and not an assertion — the reader still chooses
266+ ;; at the authorization page.
267+ [:login_hint handle]])
268+ resp (await (post-form! par body nil))]
269+ (when-not (= 201 (:status resp))
270+ (throw (ex-info (str "Authorization request refused: " (:body resp))
271+ {:status (:status resp)})))
272+ (let [request-uri (acore/json-str (:body resp) "request_uri")
273+ url (str authorize "?client_id=" (core/url-encode (client-id))
274+ "&request_uri=" (core/url-encode request-uri))]
275+ (write-fields! (pending-file)
276+ {:verifier verifier :state state :did did
277+ :handle handle :pds pds :token token})
278+ (on-url url)
279+ (.assign (.-location html/window) url)
280+ ;; The page is leaving. A Future nobody completes is what the caller's
281+ ;; `await` holds until it does.
282+ (await (.-future (async/Completer.))))))
283+
284+(defn ^:async ^:private await-callback!
285+ "`frq.oauth.handoff`'s shape. `broker` is ignored — there is no broker on
286+ this path, and the argument stays so the seam is one signature."
287+ [_broker handle on-url]
288+ (await (begin! (str handle) on-url)))
289+
290+(defn ^:async resume!
291+ "Finish a sign-in that left this page and came back. True when one did.
292+
293+ Run before `frq.main/start!`: what it leaves behind is a session in
294+ `frq.cells`, which is what `sign-in!` then finds instead of starting the
295+ browser leg again.
296+
297+ The query string is cleared with `replaceState` rather than by assigning to
298+ `location`, which would push a history entry and leave a Back button that
299+ redeems a spent code."
300+ []
301+ (let [search (str (.-search (.-location html/window)))
302+ ^#/(Map String String)
303+ params (Uri.splitQueryString (if (.startsWith search "?")
304+ (.substring search 1)
305+ search))
306+ ;; `get`, and this is the correction that cost the most: cljd's core
307+ ;; DOES extend Dart's Map with ILookup — `(-lookup [m k] (. m "[]" k))`
308+ ;; — so `get` reaches it. `aget` does not and never did: it compiles
309+ ;; unconditionally to `(x as List)[k as int]`, which on a Map is a
310+ ;; TypeError.
311+ code (get params "code")
312+ state (get params "state")
313+ pending (read-fields (pending-file))]
314+ (when (and (seq (str (or code ""))) pending)
315+ (try
316+ (.replaceState (.-history html/window) nil "" (origin))
317+ (catch Object _ nil))
318+ ;; State is the CSRF binding: a code arriving with a state we did not
319+ ;; issue is not ours, and redeeming it would be the attack this exists
320+ ;; to prevent.
321+ (if-not (= (str state) (:state pending))
322+ (do (reset! cells/error "Sign-in failed: state did not match") false)
323+ (let [resp (await (post-form!
324+ (:token pending)
325+ (form [[:grant_type "authorization_code"]
326+ [:code code]
327+ [:redirect_uri (origin)]
328+ [:client_id (client-id)]
329+ [:code_verifier (:verifier pending)]])
330+ nil))]
331+ (if-not (= 200 (:status resp))
332+ (do (reset! cells/error (str "Sign-in failed: " (:body resp))) false)
333+ (let [access (acore/json-str (:body resp) "access_token")
334+ refresh (acore/json-str (:body resp) "refresh_token")
335+ did (or (acore/json-str (:body resp) "sub") (:did pending))
336+ who (await (whoami! (:pds pending) access))
337+ handle (or (:handle who) (:handle pending) "")
338+ session {:kind :pds-oauth
339+ :did (or (:did who) did)
340+ :handle handle
341+ :access-jwt access
342+ :pds (:pds pending)
343+ :dpop-nonce (str (or (:nonce who) ""))
344+ :refresh refresh}]
345+ (fio/delete-file! (pending-file))
346+ (write-fields! (session-file)
347+ {:did (:did session) :access access
348+ :handle handle
349+ :pds (:pds pending) :refresh (or refresh "")})
350+ (reset! cells/session session)
351+ (reset! cells/auth-mode :bluesky)
352+ ;; The name the connect screen and the nick are drawn from.
353+ ;; `frq.main/sign-in!` reads the session for both, but only if
354+ ;; the session says who it belongs to.
355+ (when (seq handle)
356+ (reset! cells/form-handle handle)
357+ (reset! cells/form-nick (first (.split ^String handle "."))))
358+ true)))))))
359+
360+(defn restore!
361+ "A session from an earlier visit, back into `frq.cells`. True when there was
362+ one.
363+
364+ Separate from `resume!` because they answer different questions: one is
365+ \"did we just come back from the authorization server\", the other is \"was
366+ there a sign-in before today\". Both end in the same place."
367+ []
368+ (when-let [m (read-fields (session-file))]
369+ (when (seq (str (or (:access m) "")))
370+ (reset! cells/session {:kind :pds-oauth
371+ :did (:did m)
372+ :handle (:handle m)
373+ :access-jwt (:access m)
374+ :pds (:pds m)
375+ :refresh (:refresh m)})
376+ (reset! cells/auth-mode :bluesky)
377+ (when (seq (str (or (:handle m) "")))
378+ (reset! cells/form-handle (:handle m))
379+ (reset! cells/form-nick (first (.split ^String (str (:handle m)) "."))))
380+ true)))
381+
382+(defn forget!
383+ "Drop the saved session and the key that was bound to it."
384+ []
385+ (fio/delete-file! (session-file))
386+ (dpop/forget!)
387+ nil)
388+
389+(defn ^:async ^:private prepare-session!
390+ "Mint the DPoP proof freeq will present to the PDS on our behalf.
391+
392+ For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the access
393+ token, because that is the exact request `verify_pds_oauth` makes with it.
394+ Minted per connect: a proof has an `iat` and a single-use `jti`, so one kept
395+ from sign-in would be refused by the time a reconnect offered it."
396+ [session]
397+ (if-not (= :pds-oauth (:kind session))
398+ session
399+ (let [url (str (.replaceAll (str (:pds session)) (RegExp. "/+$") "")
400+ "/xrpc/com.atproto.server.getSession")
401+ p (await (dpop/proof "GET" url (str (or (:dpop-nonce session) ""))
402+ (str (:access-jwt session))))]
403+ (assoc session :dpop-proof p))))
404+
405+(defn install! []
406+ (handoff/install! {:await-callback! await-callback!
407+ :prepare-session! prepare-session!}))