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>
015500d parent: a0dbf2e modified
common/frq/atproto/core.cljc +21 -1 | @@ -206,8 +206,28 @@ | ||
| 206 | 206 | sent empty rather than guessed at." |
| 207 | 207 | [session nonce] |
| 208 | 208 | (b64-encode |
| 209 | - (if (= :web-token (:kind session)) | |
| 209 | + (case (:kind session) | |
| 210 | + :web-token | |
| 210 | 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 | 231 | (json-object {"did" (:did session) |
| 212 | 232 | "signature" (:access-jwt session) |
| 213 | 233 | "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-encode | 208 | (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 @@ | ||
| 20 | 20 | [clojure.string :as str] |
| 21 | 21 | ["dart:io" :as dio] |
| 22 | 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 | 26 | ["package:path_provider/path_provider.dart" :as pp] |
| 24 | 27 | [cljd.flutter :as f] |
| 25 | 28 | [frq.hiccup :as h] |
| @@ -29,8 +32,14 @@ | ||
| 29 | 32 | ;; the same way the shared half asks for it. |
| 30 | 33 | [frq.io :as fio] |
| 31 | 34 | [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] | |
| 34 | 43 | [frq.clock :as clock] |
| 35 | 44 | [frq.media.core :as media-core] |
| 36 | 45 | [frq.media.dart :as media] |
| @@ -53,6 +62,9 @@ | ||
| 53 | 62 | [frq.upload.dart :as upload] |
| 54 | 63 | [frq.irc.mutate :as mutate] |
| 55 | 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 | 68 | [frq.oauth.dart :as oauth-dart] |
| 57 | 69 | [frq.irc.parse :as irc] |
| 58 | 70 | [frq.irc.handshake :as handshake])) |
| @@ -597,7 +609,14 @@ | ||
| 597 | 609 | the broker will redirect to and not a porting problem. So that case says so |
| 598 | 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 | 620 | (do |
| 602 | 621 | (reset! cells/status "Resuming your session…") |
| 603 | 622 | (let [tokens (oauth/refresh-session-parse |
| @@ -610,9 +629,11 @@ | ||
| 610 | 629 | ;; it does. |
| 611 | 630 | (store/save-session! tokens) |
| 612 | 631 | (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! | |
| 616 | 637 | oauth/default-broker |
| 617 | 638 | (str @cells/form-handle) |
| 618 | 639 | (fn [url] |
| @@ -625,7 +646,7 @@ | ||
| 625 | 646 | (reset! cells/login-url nil) |
| 626 | 647 | (reset! cells/broker-token (:broker-token tokens)) |
| 627 | 648 | (store/save-session! tokens) |
| 628 | - (assoc tokens :kind :web-token)))) | |
| 649 | + (assoc tokens :kind :web-token))))) | |
| 629 | 650 | |
| 630 | 651 | (defn ^:async sign-in! |
| 631 | 652 | "Fill `cells/session` for the mode that was chosen, or say why not. |
| @@ -692,6 +713,9 @@ | ||
| 692 | 713 | ;; us on the server as a guest, which looks like a success and is not the |
| 693 | 714 | ;; one that was asked for. Bluesky did exactly that until it was asked. |
| 694 | 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 | 719 | (let [n (swap! attempt inc)] |
| 696 | 720 | (reset! caps #{}) |
| 697 | 721 | (reset! cells/connecting? true) |
| @@ -1101,7 +1125,15 @@ | ||
| 1101 | 1125 | |
| 1102 | 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 | 1137 | (m/WidgetsFlutterBinding.ensureInitialized) |
| 1106 | 1138 | ;; Layout errors do not come back as exceptions — they happen after the |
| 1107 | 1139 | ;; build, so nothing can catch them, and what they leave is a blank screen |
| @@ -1110,9 +1142,23 @@ | ||
| 1110 | 1142 | (fn [^m/FlutterErrorDetails details] |
| 1111 | 1143 | (m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details))) |
| 1112 | 1144 | (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 | |
| 1116 | 1162 | ;; Ed25519 for the reactions freeq will not take on trust. Verified |
| 1117 | 1163 | ;; against RFC 8032 test 1 on the device: the same public key and the |
| 1118 | 1164 | ;; same signature OpenSSL gives on the desktop, so a signature minted |
| @@ -1283,10 +1329,13 @@ | ||
| 1283 | 1329 | ;; put a link to. |
| 1284 | 1330 | :image-path (fn [url] (media/path-when-ready url)) |
| 1285 | 1331 | :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)) | |
| 1290 | 1339 | :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) |
| 1291 | 1340 | |
| 1292 | 1341 | ;; Rewriting. The old text is the starting point rather than an empty |
| @@ -1369,6 +1418,17 @@ | ||
| 1369 | 1418 | (m/Scaffold) |
| 1370 | 1419 | .body |
| 1371 | 1420 | 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 | 1432 | (f/widget |
| 1373 | 1433 | :context ctx |
| 1374 | 1434 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it |
| @@ -1382,3 +1442,11 @@ | ||
| 1382 | 1442 | ;; beside the screens is floated over them where there is a pointer, |
| 1383 | 1443 | ;; and drawn where it stands where there is not. |
| 1384 | 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!))) | |
| @@ -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 so | 609 | 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 | (do | 620 | (do |
| 602 | (reset! cells/status "Resuming your session…") | 621 | (reset! cells/status "Resuming your session…") |
| 603 | (let [tokens (oauth/refresh-session-parse | 622 | (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-broker | 637 | 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 the | 713 | ;; 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 the | 1138 | ;; 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 screen | 1139 | ;; 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. Verified | 1162 | ;; 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 the | 1163 | ;; 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 minted | 1164 | ;; 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 targets | 1332 | + ;; 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 the | 1333 | + ;; 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 empty | 1341 | ;; Rewriting. The old text is the starting point rather than an empty |
| @@ -1369,6 +1418,17 @@ | |||
| 1369 | (m/Scaffold) | 1418 | (m/Scaffold) |
| 1370 | .body | 1419 | .body |
| 1371 | m/SafeArea | 1420 | 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/widget | 1432 | (f/widget |
| 1373 | :context ctx | 1433 | :context ctx |
| 1374 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it | 1434 | ;; 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 @@ | ||
| 18 | 18 | is the one thing Dart cannot do alone — `frq.io/open-url!` answers that." |
| 19 | 19 | (:require ["dart:io" :as io] |
| 20 | 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 | 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 | 30 | (defn- return-url |
| 25 | 31 | "What brings the app back to the front once the browser is done, or nil where |
| @@ -36,7 +42,7 @@ | ||
| 36 | 42 | button that does nothing. `core/capture-html` takes nil for precisely this |
| 37 | 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 | 47 | (defn ^:async await-callback! |
| 42 | 48 | "Serve the loopback capture until the browser posts the handoff back. |
| @@ -95,3 +101,9 @@ | ||
| 95 | 101 | nil))) |
| 96 | 102 | (await (.-future done)) |
| 97 | 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!})) | |
| @@ -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-url | 30 | (defn- return-url |
| 25 | "What brings the app back to the front once the browser is done, or nil where | 31 | "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 this | 42 | 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!})) | ||