Sign in with Bluesky OAuth, through freeq's broker
Sleek's flow, in jolt. frq binds a loopback port, puts it in return_to, and opens the broker's login URL; the broker runs the OAuth dance with the PDS and redirects back with the handoff in the URL fragment. The page served on that port has one job — POST the fragment back — which is what keeps the token out of query strings, logs and history. The web-token that comes back is single-use, so the durable broker_token is what a reconnect uses: /session mints a fresh one and the browser stays shut. A refusal drops both, since a stale broker token would fail the same way on every retry. SASL grew the second method to carry it. A web-token response sends an empty DID on purpose — the server resolves it through its own token store, and guessing would only be a way to be wrong. Connecting now happens off the UI thread. It had to: a loopback accept waiting on a browser is a minute of not painting, and the TLS handshake was already half a second of it. Verified as far as it can be without consenting in a browser: the loopback capture round-trips a payload end to end, and the broker answers our login URL with a 307 to bsky.social's authorize endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2d4a377 parent: 5192124 modified
README.md +26 -16 | @@ -12,7 +12,8 @@ tags rather than immediate-mode drawing code, and state lives in ratoms instead | ||
| 12 | 12 | of an `AppState` struct. |
| 13 | 13 | |
| 14 | 14 | ``` |
| 15 | -src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payload | |
| 15 | +src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads | |
| 16 | +src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | |
| 16 | 17 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 17 | 18 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 18 | 19 | src/frq/app.jolt the screens |
| @@ -38,22 +39,28 @@ cargo run --release --bin freeq-server # in the freeq checkout | ||
| 38 | 39 | |
| 39 | 40 | ## Signing in |
| 40 | 41 | |
| 41 | -Guest is the default. The **Bluesky** tab on the connect screen takes a handle | |
| 42 | -and an [app password](https://bsky.app/settings/app-passwords) and signs in | |
| 43 | -through AT Protocol: | |
| 42 | +Three modes on the connect screen. | |
| 44 | 43 | |
| 45 | -1. `com.atproto.identity.resolveHandle` turns the handle into a DID | |
| 46 | -2. the DID document (PLC directory, or the domain for `did:web`) gives its PDS | |
| 47 | -3. `com.atproto.server.createSession` mints a session token there | |
| 48 | -4. freeq's SASL `ATPROTO-CHALLENGE` carries that token as `method: | |
| 49 | - "pds-session"`, with the server's own nonce echoed back so it cannot be | |
| 50 | - replayed elsewhere | |
| 44 | +**Bluesky** (OAuth, the default way in) follows sleek's flow: frq binds a | |
| 45 | +loopback port, puts it in `return_to`, and opens | |
| 46 | +`auth.freeq.at/auth/login?handle=…`. The broker runs the OAuth dance with the | |
| 47 | +PDS and redirects back to that port with the handoff in the URL *fragment*, so | |
| 48 | +it never reaches a server as a query string. The page frq serves there has one | |
| 49 | +job: POST the fragment back to itself. What comes back is a single-use SASL | |
| 50 | +`web-token` and a durable `broker_token`; later connections mint a fresh token | |
| 51 | +from the durable one at `/session` and skip the browser. | |
| 51 | 52 | |
| 52 | -The app password goes to the user's own PDS and nowhere else — freeq is handed | |
| 53 | -only the token, and verifies it by asking that same PDS. It is not written to | |
| 54 | -disk, and is dropped from memory once the session exists. | |
| 53 | +**App password** signs in without a browser, straight to the user's own PDS: | |
| 54 | +`resolveHandle` → DID → PDS from the DID document → `createSession`. The | |
| 55 | +password goes to that PDS and nowhere else, is never written to disk, and is | |
| 56 | +dropped once the session exists. | |
| 55 | 57 | |
| 56 | -A refused sign-in is reported and the connection continues as a guest. | |
| 58 | +Either way freeq sees only a token. The SASL mechanism is | |
| 59 | +`ATPROTO-CHALLENGE` in both cases — `method: "web-token"`, which the server | |
| 60 | +resolves through its own token store, or `method: "pds-session"` with the | |
| 61 | +server's nonce echoed back so the token cannot be replayed elsewhere. | |
| 62 | + | |
| 63 | +A refused sign-in is reported and the connection carries on as a guest. | |
| 57 | 64 | |
| 58 | 65 | ## Android |
| 59 | 66 | |
| @@ -88,7 +95,10 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 88 | 95 | ## Limits |
| 89 | 96 | |
| 90 | 97 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. |
| 91 | -* **App-password sign-in only.** No OAuth broker, no `did:key` signing, no | |
| 92 | - credential gates, no E2EE. Sign-in needs TLS, so it is desktop-only. | |
| 98 | +* **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either | |
| 99 | + kind needs TLS, so it is desktop-only — the Android build connects as a | |
| 100 | + guest. | |
| 101 | +* **The broker token lives in memory.** Nothing is persisted, so a restart | |
| 102 | + means another trip through the browser. | |
| 93 | 103 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 94 | 104 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
| @@ -12,7 +12,8 @@ tags rather than immediate-mode drawing code, and state lives in ratoms instead | |||
| 12 | of an `AppState` struct. | 12 | of an `AppState` struct. |
| 13 | 13 | ||
| 14 | ``` | 14 | ``` |
| 15 | -src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payload | 15 | +src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | +src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | ||
| 16 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 17 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 17 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | 18 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 18 | src/frq/app.jolt the screens | 19 | src/frq/app.jolt the screens |
| @@ -38,22 +39,28 @@ cargo run --release --bin freeq-server # in the freeq checkout | |||
| 38 | 39 | ||
| 39 | ## Signing in | 40 | ## Signing in |
| 40 | 41 | ||
| 41 | -Guest is the default. The **Bluesky** tab on the connect screen takes a handle | 42 | +Three modes on the connect screen. |
| 42 | -and an [app password](https://bsky.app/settings/app-passwords) and signs in | ||
| 43 | -through AT Protocol: | ||
| 44 | 43 | ||
| 45 | -1. `com.atproto.identity.resolveHandle` turns the handle into a DID | 44 | +**Bluesky** (OAuth, the default way in) follows sleek's flow: frq binds a |
| 46 | -2. the DID document (PLC directory, or the domain for `did:web`) gives its PDS | 45 | +loopback port, puts it in `return_to`, and opens |
| 47 | -3. `com.atproto.server.createSession` mints a session token there | 46 | +`auth.freeq.at/auth/login?handle=…`. The broker runs the OAuth dance with the |
| 48 | -4. freeq's SASL `ATPROTO-CHALLENGE` carries that token as `method: | 47 | +PDS and redirects back to that port with the handoff in the URL *fragment*, so |
| 49 | - "pds-session"`, with the server's own nonce echoed back so it cannot be | 48 | +it never reaches a server as a query string. The page frq serves there has one |
| 50 | - replayed elsewhere | 49 | +job: POST the fragment back to itself. What comes back is a single-use SASL |
| 50 | +`web-token` and a durable `broker_token`; later connections mint a fresh token | ||
| 51 | +from the durable one at `/session` and skip the browser. | ||
| 51 | 52 | ||
| 52 | -The app password goes to the user's own PDS and nowhere else — freeq is handed | 53 | +**App password** signs in without a browser, straight to the user's own PDS: |
| 53 | -only the token, and verifies it by asking that same PDS. It is not written to | 54 | +`resolveHandle` → DID → PDS from the DID document → `createSession`. The |
| 54 | -disk, and is dropped from memory once the session exists. | 55 | +password goes to that PDS and nowhere else, is never written to disk, and is |
| 56 | +dropped once the session exists. | ||
| 55 | 57 | ||
| 56 | -A refused sign-in is reported and the connection continues as a guest. | 58 | +Either way freeq sees only a token. The SASL mechanism is |
| 59 | +`ATPROTO-CHALLENGE` in both cases — `method: "web-token"`, which the server | ||
| 60 | +resolves through its own token store, or `method: "pds-session"` with the | ||
| 61 | +server's nonce echoed back so the token cannot be replayed elsewhere. | ||
| 62 | + | ||
| 63 | +A refused sign-in is reported and the connection carries on as a guest. | ||
| 57 | 64 | ||
| 58 | ## Android | 65 | ## Android |
| 59 | 66 | ||
| @@ -88,7 +95,10 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 88 | ## Limits | 95 | ## Limits |
| 89 | 96 | ||
| 90 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. | 97 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. |
| 91 | -* **App-password sign-in only.** No OAuth broker, no `did:key` signing, no | 98 | +* **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either |
| 92 | - credential gates, no E2EE. Sign-in needs TLS, so it is desktop-only. | 99 | + kind needs TLS, so it is desktop-only — the Android build connects as a |
| 100 | + guest. | ||
| 101 | +* **The broker token lives in memory.** Nothing is persisted, so a restart | ||
| 102 | + means another trip through the browser. | ||
| 93 | * **No scrollback trimming, avatars, reactions, threads, or calls.** | 103 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 94 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. | 104 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
modified
src/frq/app.jolt +26 -4 | @@ -29,7 +29,7 @@ | ||
| 29 | 29 | |
| 30 | 30 | (defn- mode-tabs [] |
| 31 | 31 | [:hbox {:spacing 8} |
| 32 | - (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"]]] | |
| 32 | + (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]] | |
| 33 | 33 | [:button {:key k |
| 34 | 34 | :label label |
| 35 | 35 | :kind (if (= k @s/auth-mode) :primary :default) |
| @@ -69,10 +69,27 @@ | ||
| 69 | 69 | [error-note] |
| 70 | 70 | [:card {} |
| 71 | 71 | [mode-tabs] |
| 72 | - (if (= :bluesky @s/auth-mode) | |
| 72 | + (case @s/auth-mode | |
| 73 | + :bluesky | |
| 73 | 74 | [:vbox {:spacing 6} |
| 74 | 75 | [:title-2 {:label "Sign in with Bluesky"}] |
| 75 | - [:dim-label {:label "Your app password goes to your own PDS. freeq is handed the session it mints, and checks it with that same PDS."}] | |
| 76 | + [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}] | |
| 77 | + [:label {:label "Handle"}] | |
| 78 | + [:entry {:text @s/form-handle | |
| 79 | + :width-request 320 | |
| 80 | + :placeholder "alice.bsky.social" | |
| 81 | + :on-change #(reset! s/form-handle %)}] | |
| 82 | + (when @s/broker-token | |
| 83 | + [:dim-label {:label "Session remembered — Connect will not need the browser."}]) | |
| 84 | + (when-let [url @s/login-url] | |
| 85 | + [:vbox {:spacing 4} | |
| 86 | + [:dim-label {:label "If the browser did not open, visit:"}] | |
| 87 | + [:label {:label url}]])] | |
| 88 | + | |
| 89 | + :app-password | |
| 90 | + [:vbox {:spacing 6} | |
| 91 | + [:title-2 {:label "Sign in with an app password"}] | |
| 92 | + [:dim-label {:label "No browser. Your app password goes to your own PDS; freeq is handed the session it mints."}] | |
| 76 | 93 | [:label {:label "Handle"}] |
| 77 | 94 | [:entry {:text @s/form-handle |
| 78 | 95 | :width-request 320 |
| @@ -84,6 +101,7 @@ | ||
| 84 | 101 | :placeholder "xxxx-xxxx-xxxx-xxxx" |
| 85 | 102 | :on-change #(reset! s/form-app-password %)}] |
| 86 | 103 | [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]] |
| 104 | + | |
| 87 | 105 | [:vbox {:spacing 6} |
| 88 | 106 | [:title-2 {:label "Connect as guest"}] |
| 89 | 107 | [:label {:label "Nick"}] |
| @@ -204,7 +222,11 @@ | ||
| 204 | 222 | (if-let [sess @s/session] |
| 205 | 223 | [:vbox {:spacing 2} |
| 206 | 224 | [:label {:label (str "Signed in as " (:handle sess))}] |
| 207 | - [:dim-label {:label (:did sess)}]] | |
| 225 | + [:dim-label {:label (or (:did sess) "")}] | |
| 226 | + (when @s/broker-token | |
| 227 | + [:button {:label "Forget Bluesky session" | |
| 228 | + :kind :destructive | |
| 229 | + :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])] | |
| 208 | 230 | [:dim-label {:label "Guest — not signed in."}]) |
| 209 | 231 | [:separator {}] |
| 210 | 232 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
| @@ -29,7 +29,7 @@ | |||
| 29 | 29 | ||
| 30 | (defn- mode-tabs [] | 30 | (defn- mode-tabs [] |
| 31 | [:hbox {:spacing 8} | 31 | [:hbox {:spacing 8} |
| 32 | - (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"]]] | 32 | + (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]] |
| 33 | [:button {:key k | 33 | [:button {:key k |
| 34 | :label label | 34 | :label label |
| 35 | :kind (if (= k @s/auth-mode) :primary :default) | 35 | :kind (if (= k @s/auth-mode) :primary :default) |
| @@ -69,10 +69,27 @@ | |||
| 69 | [error-note] | 69 | [error-note] |
| 70 | [:card {} | 70 | [:card {} |
| 71 | [mode-tabs] | 71 | [mode-tabs] |
| 72 | - (if (= :bluesky @s/auth-mode) | 72 | + (case @s/auth-mode |
| 73 | + :bluesky | ||
| 73 | [:vbox {:spacing 6} | 74 | [:vbox {:spacing 6} |
| 74 | [:title-2 {:label "Sign in with Bluesky"}] | 75 | [:title-2 {:label "Sign in with Bluesky"}] |
| 75 | - [:dim-label {:label "Your app password goes to your own PDS. freeq is handed the session it mints, and checks it with that same PDS."}] | 76 | + [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}] |
| 77 | + [:label {:label "Handle"}] | ||
| 78 | + [:entry {:text @s/form-handle | ||
| 79 | + :width-request 320 | ||
| 80 | + :placeholder "alice.bsky.social" | ||
| 81 | + :on-change #(reset! s/form-handle %)}] | ||
| 82 | + (when @s/broker-token | ||
| 83 | + [:dim-label {:label "Session remembered — Connect will not need the browser."}]) | ||
| 84 | + (when-let [url @s/login-url] | ||
| 85 | + [:vbox {:spacing 4} | ||
| 86 | + [:dim-label {:label "If the browser did not open, visit:"}] | ||
| 87 | + [:label {:label url}]])] | ||
| 88 | + | ||
| 89 | + :app-password | ||
| 90 | + [:vbox {:spacing 6} | ||
| 91 | + [:title-2 {:label "Sign in with an app password"}] | ||
| 92 | + [:dim-label {:label "No browser. Your app password goes to your own PDS; freeq is handed the session it mints."}] | ||
| 76 | [:label {:label "Handle"}] | 93 | [:label {:label "Handle"}] |
| 77 | [:entry {:text @s/form-handle | 94 | [:entry {:text @s/form-handle |
| 78 | :width-request 320 | 95 | :width-request 320 |
| @@ -84,6 +101,7 @@ | |||
| 84 | :placeholder "xxxx-xxxx-xxxx-xxxx" | 101 | :placeholder "xxxx-xxxx-xxxx-xxxx" |
| 85 | :on-change #(reset! s/form-app-password %)}] | 102 | :on-change #(reset! s/form-app-password %)}] |
| 86 | [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]] | 103 | [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]] |
| 104 | + | ||
| 87 | [:vbox {:spacing 6} | 105 | [:vbox {:spacing 6} |
| 88 | [:title-2 {:label "Connect as guest"}] | 106 | [:title-2 {:label "Connect as guest"}] |
| 89 | [:label {:label "Nick"}] | 107 | [:label {:label "Nick"}] |
| @@ -204,7 +222,11 @@ | |||
| 204 | (if-let [sess @s/session] | 222 | (if-let [sess @s/session] |
| 205 | [:vbox {:spacing 2} | 223 | [:vbox {:spacing 2} |
| 206 | [:label {:label (str "Signed in as " (:handle sess))}] | 224 | [:label {:label (str "Signed in as " (:handle sess))}] |
| 207 | - [:dim-label {:label (:did sess)}]] | 225 | + [:dim-label {:label (or (:did sess) "")}] |
| 226 | + (when @s/broker-token | ||
| 227 | + [:button {:label "Forget Bluesky session" | ||
| 228 | + :kind :destructive | ||
| 229 | + :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])] | ||
| 208 | [:dim-label {:label "Guest — not signed in."}]) | 230 | [:dim-label {:label "Guest — not signed in."}]) |
| 209 | [:separator {}] | 231 | [:separator {}] |
| 210 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | 232 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
modified
src/frq/atproto.jolt +15 -8 | @@ -161,12 +161,19 @@ | ||
| 161 | 161 | (String. (byte-array (map unchecked-byte bytes))))) |
| 162 | 162 | |
| 163 | 163 | (defn sasl-response |
| 164 | - "The base64url SASL payload for a PDS session: the token, the DID it belongs | |
| 165 | - to, its PDS, and the server's own nonce echoed back so the token cannot be | |
| 166 | - replayed at another server." | |
| 164 | + "The base64url SASL payload for a session, either kind freeq takes. | |
| 165 | + | |
| 166 | + A `:pds-session` carries the PDS token, the DID it belongs to, its PDS, and | |
| 167 | + the server's own nonce echoed back so the token cannot be replayed at another | |
| 168 | + server. A `:web-token` from the auth broker carries only the token — the | |
| 169 | + server looks the DID up in its own token store, which is why the field is | |
| 170 | + sent empty rather than guessed at." | |
| 167 | 171 | [session nonce] |
| 168 | - (b64-encode (json-object {"did" (:did session) | |
| 169 | - "signature" (:access-jwt session) | |
| 170 | - "method" "pds-session" | |
| 171 | - "pds_url" (:pds session) | |
| 172 | - "challenge_nonce" nonce}))) | |
| 172 | + (b64-encode | |
| 173 | + (if (= :web-token (:kind session)) | |
| 174 | + (json-object {"did" "" "method" "web-token" "signature" (:token session)}) | |
| 175 | + (json-object {"did" (:did session) | |
| 176 | + "signature" (:access-jwt session) | |
| 177 | + "method" "pds-session" | |
| 178 | + "pds_url" (:pds session) | |
| 179 | + "challenge_nonce" nonce})))) | |
| @@ -161,12 +161,19 @@ | |||
| 161 | (String. (byte-array (map unchecked-byte bytes))))) | 161 | (String. (byte-array (map unchecked-byte bytes))))) |
| 162 | 162 | ||
| 163 | (defn sasl-response | 163 | (defn sasl-response |
| 164 | - "The base64url SASL payload for a PDS session: the token, the DID it belongs | 164 | + "The base64url SASL payload for a session, either kind freeq takes. |
| 165 | - to, its PDS, and the server's own nonce echoed back so the token cannot be | 165 | + |
| 166 | - replayed at another server." | 166 | + A `:pds-session` carries the PDS token, the DID it belongs to, its PDS, and |
| 167 | + the server's own nonce echoed back so the token cannot be replayed at another | ||
| 168 | + server. A `:web-token` from the auth broker carries only the token — the | ||
| 169 | + server looks the DID up in its own token store, which is why the field is | ||
| 170 | + sent empty rather than guessed at." | ||
| 167 | [session nonce] | 171 | [session nonce] |
| 168 | - (b64-encode (json-object {"did" (:did session) | 172 | + (b64-encode |
| 169 | - "signature" (:access-jwt session) | 173 | + (if (= :web-token (:kind session)) |
| 170 | - "method" "pds-session" | 174 | + (json-object {"did" "" "method" "web-token" "signature" (:token session)}) |
| 171 | - "pds_url" (:pds session) | 175 | + (json-object {"did" (:did session) |
| 172 | - "challenge_nonce" nonce}))) | 176 | + "signature" (:access-jwt session) |
| 177 | + "method" "pds-session" | ||
| 178 | + "pds_url" (:pds session) | ||
| 179 | + "challenge_nonce" nonce})))) | ||
added
src/frq/oauth.jolt +152 -0 | new file mode 100644 | ||
| @@ -0,0 +1,152 @@ | ||
| 1 | +(ns frq.oauth | |
| 2 | + "Bluesky OAuth through freeq's auth broker, the way sleek does it. | |
| 3 | + | |
| 4 | + The broker owns the OAuth dance with the user's PDS; this client only has to | |
| 5 | + get the handoff back. On desktop that is a loopback capture: bind a port, put | |
| 6 | + it in `return_to`, open the browser, and serve a page whose only job is to | |
| 7 | + POST the `#oauth=` fragment back — the fragment never leaves the browser as a | |
| 8 | + query string, so it stays out of logs and history. | |
| 9 | + | |
| 10 | + What comes back is a short-lived SASL `web-token` and a durable | |
| 11 | + `broker_token`. The web-token is single-use: `/session` mints a fresh one | |
| 12 | + from the broker token on every later connection." | |
| 13 | + (:require [clojure.string :as str] | |
| 14 | + [frq.atproto :as atproto] | |
| 15 | + [jolt.ffi :as ffi] | |
| 16 | + [jolt.host :as host] | |
| 17 | + [jolt.socket :as socket])) | |
| 18 | + | |
| 19 | +(def default-broker "https://auth.freeq.at") | |
| 20 | + | |
| 21 | +;; ------------------------------------------------------------------ urls | |
| 22 | + | |
| 23 | +(defn url-encode | |
| 24 | + "Percent-encode everything a handle could hold that a query string cannot." | |
| 25 | + [s] | |
| 26 | + (apply str | |
| 27 | + (for [b (.getBytes (or s "")) | |
| 28 | + :let [c (char (bit-and (int b) 0xff))]] | |
| 29 | + (if (or (Character/isLetterOrDigit c) (#{\- \_ \. \~} c)) | |
| 30 | + c | |
| 31 | + (format "%%%02X" (bit-and (int b) 0xff)))))) | |
| 32 | + | |
| 33 | +(defn login-url [broker handle return-to] | |
| 34 | + (let [base (str/replace (or broker default-broker) #"/+$" "") | |
| 35 | + handle (-> (or handle "") str/trim (str/replace #"^@" ""))] | |
| 36 | + (str base "/auth/login?handle=" (url-encode handle) | |
| 37 | + "&return_to=" (url-encode return-to)))) | |
| 38 | + | |
| 39 | +(defn open-browser! | |
| 40 | + "Hand the URL to the desktop. A failure here is not fatal — the caller shows | |
| 41 | + the URL so it can be opened by hand." | |
| 42 | + [url] | |
| 43 | + (try | |
| 44 | + (zero? (host/sh (str "xdg-open '" (str/replace url "'" "%27") "' >/dev/null 2>&1 &"))) | |
| 45 | + (catch Exception _ false))) | |
| 46 | + | |
| 47 | +;; ------------------------------------------------------------------ capture | |
| 48 | + | |
| 49 | +(def ^:private capture-html | |
| 50 | + (str "<!doctype html><meta charset=utf-8><title>frq</title>" | |
| 51 | + "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">" | |
| 52 | + "<p id=m>Finishing sign-in…</p><script>" | |
| 53 | + "var h=location.hash.replace(/^#/,'');" | |
| 54 | + "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');" | |
| 55 | + "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}" | |
| 56 | + "else{fetch('/capture',{method:'POST',body:p})" | |
| 57 | + ".then(function(){document.getElementById('m').textContent='Signed in — you can close this tab.';})" | |
| 58 | + ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}" | |
| 59 | + "</script></body>")) | |
| 60 | + | |
| 61 | +(defn- respond! [fd body content-type] | |
| 62 | + (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type | |
| 63 | + "\r\nConnection: close\r\nContent-Length: " | |
| 64 | + (count (.getBytes body)) "\r\n\r\n") | |
| 65 | + text (str head body)] | |
| 66 | + (ffi/with-c-string [p text] | |
| 67 | + (socket/c-send fd p (count (.getBytes text)) 0)))) | |
| 68 | + | |
| 69 | +(defn- read-request [fd] | |
| 70 | + (let [buf (ffi/alloc 16384) | |
| 71 | + n (socket/c-recv fd buf 16384 0)] | |
| 72 | + (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) ""))) | |
| 73 | + | |
| 74 | +(defn- bind-loopback! | |
| 75 | + "A listening socket on some free loopback port. Returns [fd port]." | |
| 76 | + [] | |
| 77 | + (loop [port 7390] | |
| 78 | + (when (> port 7420) | |
| 79 | + (throw (ex-info "No free loopback port for the OAuth handoff" {}))) | |
| 80 | + (let [fd (socket/c-socket 2 1 0)] | |
| 81 | + (if (and (>= fd 0) | |
| 82 | + (zero? (socket/c-bind fd (#'socket/make-sockaddr-in "127.0.0.1" port) 16)) | |
| 83 | + (zero? (socket/c-listen fd 4))) | |
| 84 | + [fd port] | |
| 85 | + (do (socket/c-close fd) (recur (inc port))))))) | |
| 86 | + | |
| 87 | +(defn tokens-of | |
| 88 | + "The broker's base64url JSON payload as {:token :broker-token :nick :did | |
| 89 | + :handle}." | |
| 90 | + [payload] | |
| 91 | + (let [json (atproto/b64-decode (str/trim payload)) | |
| 92 | + token (atproto/json-str json "token") | |
| 93 | + broker (atproto/json-str json "broker_token")] | |
| 94 | + (when-not (and token broker) | |
| 95 | + (throw (ex-info (or (atproto/json-str json "error") "Malformed sign-in payload") | |
| 96 | + {:body json}))) | |
| 97 | + {:token token | |
| 98 | + :broker-token broker | |
| 99 | + :nick (atproto/json-str json "nick") | |
| 100 | + :did (atproto/json-str json "did") | |
| 101 | + :handle (or (atproto/json-str json "handle") "")})) | |
| 102 | + | |
| 103 | +(defn await-callback! | |
| 104 | + "Serve the loopback capture until the browser posts the handoff back. | |
| 105 | + | |
| 106 | + Blocking, so run it off the UI thread. `on-url` is called with the login URL | |
| 107 | + once the port is known — that is what the caller shows and opens." | |
| 108 | + [broker handle on-url] | |
| 109 | + (let [[server port] (bind-loopback!) | |
| 110 | + url (login-url broker handle (str "http://127.0.0.1:" port))] | |
| 111 | + (try | |
| 112 | + (on-url url) | |
| 113 | + (loop [] | |
| 114 | + (let [fd (socket/c-accept server ffi/null ffi/null)] | |
| 115 | + (if (neg? fd) | |
| 116 | + (throw (ex-info "Loopback accept failed" {:port port})) | |
| 117 | + (let [req (read-request fd) | |
| 118 | + line (first (str/split-lines req))] | |
| 119 | + (if (str/starts-with? (or line "") "POST /capture") | |
| 120 | + (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))] | |
| 121 | + (respond! fd "ok" "text/plain") | |
| 122 | + (socket/c-close fd) | |
| 123 | + (tokens-of body)) | |
| 124 | + (do (respond! fd capture-html "text/html; charset=utf-8") | |
| 125 | + (socket/c-close fd) | |
| 126 | + (recur))))))) | |
| 127 | + (finally (socket/c-close server))))) | |
| 128 | + | |
| 129 | +;; ------------------------------------------------------------------ session | |
| 130 | + | |
| 131 | +(defn- broker-host [broker] | |
| 132 | + (-> (or broker default-broker) | |
| 133 | + (str/replace #"^https?://" "") | |
| 134 | + (str/split #"/") | |
| 135 | + first)) | |
| 136 | + | |
| 137 | +(defn refresh-session | |
| 138 | + "Mint a fresh single-use web-token from the durable broker token. This is | |
| 139 | + what a reconnect uses; the token from the browser handoff is spent." | |
| 140 | + [broker broker-token] | |
| 141 | + (let [body (atproto/request (broker-host broker) "/session" | |
| 142 | + (atproto/json-object {"broker_token" broker-token})) | |
| 143 | + token (atproto/json-str body "token")] | |
| 144 | + (when-not token | |
| 145 | + (throw (ex-info (or (atproto/json-str body "message") | |
| 146 | + "Broker session refresh failed — sign in again") | |
| 147 | + {:body body}))) | |
| 148 | + {:token token | |
| 149 | + :broker-token broker-token | |
| 150 | + :nick (atproto/json-str body "nick") | |
| 151 | + :did (atproto/json-str body "did") | |
| 152 | + :handle (or (atproto/json-str body "handle") "")})) | |
| new file mode 100644 | |||
| @@ -0,0 +1,152 @@ | |||
| 1 | +(ns frq.oauth | ||
| 2 | + "Bluesky OAuth through freeq's auth broker, the way sleek does it. | ||
| 3 | + | ||
| 4 | + The broker owns the OAuth dance with the user's PDS; this client only has to | ||
| 5 | + get the handoff back. On desktop that is a loopback capture: bind a port, put | ||
| 6 | + it in `return_to`, open the browser, and serve a page whose only job is to | ||
| 7 | + POST the `#oauth=` fragment back — the fragment never leaves the browser as a | ||
| 8 | + query string, so it stays out of logs and history. | ||
| 9 | + | ||
| 10 | + What comes back is a short-lived SASL `web-token` and a durable | ||
| 11 | + `broker_token`. The web-token is single-use: `/session` mints a fresh one | ||
| 12 | + from the broker token on every later connection." | ||
| 13 | + (:require [clojure.string :as str] | ||
| 14 | + [frq.atproto :as atproto] | ||
| 15 | + [jolt.ffi :as ffi] | ||
| 16 | + [jolt.host :as host] | ||
| 17 | + [jolt.socket :as socket])) | ||
| 18 | + | ||
| 19 | +(def default-broker "https://auth.freeq.at") | ||
| 20 | + | ||
| 21 | +;; ------------------------------------------------------------------ urls | ||
| 22 | + | ||
| 23 | +(defn url-encode | ||
| 24 | + "Percent-encode everything a handle could hold that a query string cannot." | ||
| 25 | + [s] | ||
| 26 | + (apply str | ||
| 27 | + (for [b (.getBytes (or s "")) | ||
| 28 | + :let [c (char (bit-and (int b) 0xff))]] | ||
| 29 | + (if (or (Character/isLetterOrDigit c) (#{\- \_ \. \~} c)) | ||
| 30 | + c | ||
| 31 | + (format "%%%02X" (bit-and (int b) 0xff)))))) | ||
| 32 | + | ||
| 33 | +(defn login-url [broker handle return-to] | ||
| 34 | + (let [base (str/replace (or broker default-broker) #"/+$" "") | ||
| 35 | + handle (-> (or handle "") str/trim (str/replace #"^@" ""))] | ||
| 36 | + (str base "/auth/login?handle=" (url-encode handle) | ||
| 37 | + "&return_to=" (url-encode return-to)))) | ||
| 38 | + | ||
| 39 | +(defn open-browser! | ||
| 40 | + "Hand the URL to the desktop. A failure here is not fatal — the caller shows | ||
| 41 | + the URL so it can be opened by hand." | ||
| 42 | + [url] | ||
| 43 | + (try | ||
| 44 | + (zero? (host/sh (str "xdg-open '" (str/replace url "'" "%27") "' >/dev/null 2>&1 &"))) | ||
| 45 | + (catch Exception _ false))) | ||
| 46 | + | ||
| 47 | +;; ------------------------------------------------------------------ capture | ||
| 48 | + | ||
| 49 | +(def ^:private capture-html | ||
| 50 | + (str "<!doctype html><meta charset=utf-8><title>frq</title>" | ||
| 51 | + "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">" | ||
| 52 | + "<p id=m>Finishing sign-in…</p><script>" | ||
| 53 | + "var h=location.hash.replace(/^#/,'');" | ||
| 54 | + "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');" | ||
| 55 | + "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}" | ||
| 56 | + "else{fetch('/capture',{method:'POST',body:p})" | ||
| 57 | + ".then(function(){document.getElementById('m').textContent='Signed in — you can close this tab.';})" | ||
| 58 | + ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}" | ||
| 59 | + "</script></body>")) | ||
| 60 | + | ||
| 61 | +(defn- respond! [fd body content-type] | ||
| 62 | + (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type | ||
| 63 | + "\r\nConnection: close\r\nContent-Length: " | ||
| 64 | + (count (.getBytes body)) "\r\n\r\n") | ||
| 65 | + text (str head body)] | ||
| 66 | + (ffi/with-c-string [p text] | ||
| 67 | + (socket/c-send fd p (count (.getBytes text)) 0)))) | ||
| 68 | + | ||
| 69 | +(defn- read-request [fd] | ||
| 70 | + (let [buf (ffi/alloc 16384) | ||
| 71 | + n (socket/c-recv fd buf 16384 0)] | ||
| 72 | + (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) ""))) | ||
| 73 | + | ||
| 74 | +(defn- bind-loopback! | ||
| 75 | + "A listening socket on some free loopback port. Returns [fd port]." | ||
| 76 | + [] | ||
| 77 | + (loop [port 7390] | ||
| 78 | + (when (> port 7420) | ||
| 79 | + (throw (ex-info "No free loopback port for the OAuth handoff" {}))) | ||
| 80 | + (let [fd (socket/c-socket 2 1 0)] | ||
| 81 | + (if (and (>= fd 0) | ||
| 82 | + (zero? (socket/c-bind fd (#'socket/make-sockaddr-in "127.0.0.1" port) 16)) | ||
| 83 | + (zero? (socket/c-listen fd 4))) | ||
| 84 | + [fd port] | ||
| 85 | + (do (socket/c-close fd) (recur (inc port))))))) | ||
| 86 | + | ||
| 87 | +(defn tokens-of | ||
| 88 | + "The broker's base64url JSON payload as {:token :broker-token :nick :did | ||
| 89 | + :handle}." | ||
| 90 | + [payload] | ||
| 91 | + (let [json (atproto/b64-decode (str/trim payload)) | ||
| 92 | + token (atproto/json-str json "token") | ||
| 93 | + broker (atproto/json-str json "broker_token")] | ||
| 94 | + (when-not (and token broker) | ||
| 95 | + (throw (ex-info (or (atproto/json-str json "error") "Malformed sign-in payload") | ||
| 96 | + {:body json}))) | ||
| 97 | + {:token token | ||
| 98 | + :broker-token broker | ||
| 99 | + :nick (atproto/json-str json "nick") | ||
| 100 | + :did (atproto/json-str json "did") | ||
| 101 | + :handle (or (atproto/json-str json "handle") "")})) | ||
| 102 | + | ||
| 103 | +(defn await-callback! | ||
| 104 | + "Serve the loopback capture until the browser posts the handoff back. | ||
| 105 | + | ||
| 106 | + Blocking, so run it off the UI thread. `on-url` is called with the login URL | ||
| 107 | + once the port is known — that is what the caller shows and opens." | ||
| 108 | + [broker handle on-url] | ||
| 109 | + (let [[server port] (bind-loopback!) | ||
| 110 | + url (login-url broker handle (str "http://127.0.0.1:" port))] | ||
| 111 | + (try | ||
| 112 | + (on-url url) | ||
| 113 | + (loop [] | ||
| 114 | + (let [fd (socket/c-accept server ffi/null ffi/null)] | ||
| 115 | + (if (neg? fd) | ||
| 116 | + (throw (ex-info "Loopback accept failed" {:port port})) | ||
| 117 | + (let [req (read-request fd) | ||
| 118 | + line (first (str/split-lines req))] | ||
| 119 | + (if (str/starts-with? (or line "") "POST /capture") | ||
| 120 | + (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))] | ||
| 121 | + (respond! fd "ok" "text/plain") | ||
| 122 | + (socket/c-close fd) | ||
| 123 | + (tokens-of body)) | ||
| 124 | + (do (respond! fd capture-html "text/html; charset=utf-8") | ||
| 125 | + (socket/c-close fd) | ||
| 126 | + (recur))))))) | ||
| 127 | + (finally (socket/c-close server))))) | ||
| 128 | + | ||
| 129 | +;; ------------------------------------------------------------------ session | ||
| 130 | + | ||
| 131 | +(defn- broker-host [broker] | ||
| 132 | + (-> (or broker default-broker) | ||
| 133 | + (str/replace #"^https?://" "") | ||
| 134 | + (str/split #"/") | ||
| 135 | + first)) | ||
| 136 | + | ||
| 137 | +(defn refresh-session | ||
| 138 | + "Mint a fresh single-use web-token from the durable broker token. This is | ||
| 139 | + what a reconnect uses; the token from the browser handoff is spent." | ||
| 140 | + [broker broker-token] | ||
| 141 | + (let [body (atproto/request (broker-host broker) "/session" | ||
| 142 | + (atproto/json-object {"broker_token" broker-token})) | ||
| 143 | + token (atproto/json-str body "token")] | ||
| 144 | + (when-not token | ||
| 145 | + (throw (ex-info (or (atproto/json-str body "message") | ||
| 146 | + "Broker session refresh failed — sign in again") | ||
| 147 | + {:body body}))) | ||
| 148 | + {:token token | ||
| 149 | + :broker-token broker-token | ||
| 150 | + :nick (atproto/json-str body "nick") | ||
| 151 | + :did (atproto/json-str body "did") | ||
| 152 | + :handle (or (atproto/json-str body "handle") "")})) | ||
modified
src/frq/state.jolt +69 -18 | @@ -7,7 +7,8 @@ | ||
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [frq.atproto :as atproto] |
| 10 | - [frq.irc :as irc])) | |
| 10 | + [frq.irc :as irc] | |
| 11 | + [frq.oauth :as oauth])) | |
| 11 | 12 | |
| 12 | 13 | (def default-host "irc.freeq.at") |
| 13 | 14 | (def default-port "6697") |
| @@ -36,10 +37,15 @@ | ||
| 36 | 37 | ;; Bluesky sign-in. The app password reaches the user's own PDS and nothing |
| 37 | 38 | ;; else: freeq is handed the session token it mints, and verifies that token by |
| 38 | 39 | ;; asking the same PDS. It is never written to disk. |
| 39 | -(defonce auth-mode (atom :guest)) ; :guest | :bluesky | |
| 40 | +(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password | |
| 40 | 41 | (defonce form-handle (atom "")) |
| 41 | 42 | (defonce form-app-password (atom "")) |
| 42 | -(defonce session (atom nil)) ; {:did :handle :access-jwt :pds} | |
| 43 | +(defonce session (atom nil)) ; a pds-session or a web-token one | |
| 44 | + | |
| 45 | +;; The durable half of an OAuth sign-in. The web-token beside it is single-use, | |
| 46 | +;; so a reconnect mints a fresh one from this rather than replaying the old. | |
| 47 | +(defonce broker-token (atom nil)) | |
| 48 | +(defonce login-url (atom nil)) ; shown while the browser is open | |
| 43 | 49 | |
| 44 | 50 | ;; joined as soon as the server sends 001 |
| 45 | 51 | (def auto-join "#test") |
| @@ -125,6 +131,10 @@ | ||
| 125 | 131 | (reset! error (str "Cannot join: " (last params))) |
| 126 | 132 | "903" (reset! status (str "Signed in as " (:handle @session))) |
| 127 | 133 | ("904" "905" "906") (do (reset! session nil) |
| 134 | + ;; The broker token may still be good — but a | |
| 135 | + ;; refusal is as likely to mean it is not, and a | |
| 136 | + ;; stale one would fail the same way every time. | |
| 137 | + (reset! broker-token nil) | |
| 128 | 138 | (reset! error (str "Bluesky sign-in refused: " |
| 129 | 139 | (or (last params) "no reason given")))) |
| 130 | 140 | "*DISCONNECTED*" (do (reset! conn nil) |
| @@ -153,31 +163,57 @@ | ||
| 153 | 163 | (println "frq: dialing" host port (if tls? "tls" "plain"))) |
| 154 | 164 | (reset! conn (irc/connect! host port nick apply-msg! tls? sess))) |
| 155 | 165 | |
| 156 | -(defn connect! | |
| 157 | - "Dial, and fall back to the plain listener when TLS cannot be had. Android is | |
| 158 | - the case that needs this: jolt's TLS is OpenSSL through the dynamic loader, | |
| 159 | - and there is no public libssl there to load." | |
| 166 | +(defn- connect-blocking! | |
| 167 | + "Sign in if asked to, then dial. Blocking throughout — a browser handoff can | |
| 168 | + take a minute, and the TLS handshake is not instant either." | |
| 160 | 169 | [] |
| 161 | 170 | (reset! error nil) |
| 162 | 171 | (reset! connecting? true) |
| 163 | 172 | (reset! status (str "Connecting to " @form-host ":" @form-port "…")) |
| 164 | 173 | (let [host @form-host |
| 165 | 174 | port (parse-long (str/trim @form-port)) |
| 166 | - bluesky? (= :bluesky @auth-mode) | |
| 167 | - sess (when bluesky? | |
| 168 | - (reset! status (str "Signing in as " (str/trim @form-handle) "…")) | |
| 169 | - (let [s (atproto/create-session (str/trim @form-handle) | |
| 170 | - @form-app-password)] | |
| 175 | + mode @auth-mode | |
| 176 | + sess (case mode | |
| 177 | + ;; OAuth: the browser does the talking, we wait on loopback. A | |
| 178 | + ;; broker token in hand skips the browser entirely. | |
| 179 | + :bluesky | |
| 180 | + (let [handle (str/trim @form-handle) | |
| 181 | + tokens (if-let [bt @broker-token] | |
| 182 | + (do (reset! status "Refreshing your session…") | |
| 183 | + (oauth/refresh-session oauth/default-broker bt)) | |
| 184 | + (do (reset! status "Opening your browser to sign in…") | |
| 185 | + (oauth/await-callback! | |
| 186 | + oauth/default-broker handle | |
| 187 | + (fn [url] | |
| 188 | + (reset! login-url url) | |
| 189 | + (oauth/open-browser! url) | |
| 190 | + (reset! status "Waiting for the browser…"))))) | |
| 191 | + s (assoc tokens :kind :web-token)] | |
| 192 | + (reset! login-url nil) | |
| 193 | + (reset! broker-token (:broker-token tokens)) | |
| 171 | 194 | (reset! session s) |
| 172 | - ;; The password did its work at the PDS; do not keep it. | |
| 173 | - (reset! form-app-password "") | |
| 174 | - s)) | |
| 195 | + (when (seq (:handle tokens)) (reset! form-handle (:handle tokens))) | |
| 196 | + s) | |
| 197 | + | |
| 198 | + :app-password | |
| 199 | + (do (reset! status (str "Signing in as " (str/trim @form-handle) "…")) | |
| 200 | + (let [s (assoc (atproto/create-session (str/trim @form-handle) | |
| 201 | + @form-app-password) | |
| 202 | + :kind :pds-session)] | |
| 203 | + (reset! session s) | |
| 204 | + ;; The password did its work at the PDS; do not keep it. | |
| 205 | + (reset! form-app-password "") | |
| 206 | + s)) | |
| 207 | + | |
| 208 | + nil) | |
| 175 | 209 | ;; An authenticated connection still needs a nick — the DID is the |
| 176 | 210 | ;; identity, the nick is only what the channel calls you. |
| 177 | - nick (if bluesky? | |
| 178 | - (-> (:handle sess) (str/split #"\.") first) | |
| 211 | + nick (if sess | |
| 212 | + (or (:nick sess) | |
| 213 | + (-> (or (:handle sess) "") (str/split #"\\.") first) | |
| 214 | + (str/trim @form-nick)) | |
| 179 | 215 | (str/trim @form-nick))] |
| 180 | - (when bluesky? (reset! form-nick nick)) | |
| 216 | + (when sess (reset! form-nick nick)) | |
| 181 | 217 | (try |
| 182 | 218 | (dial! host port nick @form-tls? sess) |
| 183 | 219 | (catch Exception e |
| @@ -199,6 +235,21 @@ | ||
| 199 | 235 | (reset! status "Not connected") |
| 200 | 236 | (reset! error (str "Could not connect: " (describe e))))))))) |
| 201 | 237 | |
| 238 | +(defn connect! | |
| 239 | + "Start connecting. The work happens on another thread: the OAuth wait sits on | |
| 240 | + a loopback accept until the browser comes back, and the UI has frames to | |
| 241 | + paint in the meantime." | |
| 242 | + [] | |
| 243 | + (reset! error nil) | |
| 244 | + (reset! connecting? true) | |
| 245 | + (future | |
| 246 | + (try (connect-blocking!) | |
| 247 | + (catch Exception e | |
| 248 | + (reset! connecting? false) | |
| 249 | + (reset! conn nil) | |
| 250 | + (reset! status "Not connected") | |
| 251 | + (reset! error (str "Could not connect: " (describe e))))))) | |
| 252 | + | |
| 202 | 253 | (defn disconnect! [] |
| 203 | 254 | (when-let [c @conn] (irc/close! c)) |
| 204 | 255 | (reset! conn nil) |
| @@ -7,7 +7,8 @@ | |||
| 7 | (:require [clojure.string :as str] | 7 | (:require [clojure.string :as str] |
| 8 | [glimmer.ratom :as r :refer [atom]] | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | [frq.atproto :as atproto] | 9 | [frq.atproto :as atproto] |
| 10 | - [frq.irc :as irc])) | 10 | + [frq.irc :as irc] |
| 11 | + [frq.oauth :as oauth])) | ||
| 11 | 12 | ||
| 12 | (def default-host "irc.freeq.at") | 13 | (def default-host "irc.freeq.at") |
| 13 | (def default-port "6697") | 14 | (def default-port "6697") |
| @@ -36,10 +37,15 @@ | |||
| 36 | ;; Bluesky sign-in. The app password reaches the user's own PDS and nothing | 37 | ;; Bluesky sign-in. The app password reaches the user's own PDS and nothing |
| 37 | ;; else: freeq is handed the session token it mints, and verifies that token by | 38 | ;; else: freeq is handed the session token it mints, and verifies that token by |
| 38 | ;; asking the same PDS. It is never written to disk. | 39 | ;; asking the same PDS. It is never written to disk. |
| 39 | -(defonce auth-mode (atom :guest)) ; :guest | :bluesky | 40 | +(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password |
| 40 | (defonce form-handle (atom "")) | 41 | (defonce form-handle (atom "")) |
| 41 | (defonce form-app-password (atom "")) | 42 | (defonce form-app-password (atom "")) |
| 42 | -(defonce session (atom nil)) ; {:did :handle :access-jwt :pds} | 43 | +(defonce session (atom nil)) ; a pds-session or a web-token one |
| 44 | + | ||
| 45 | +;; The durable half of an OAuth sign-in. The web-token beside it is single-use, | ||
| 46 | +;; so a reconnect mints a fresh one from this rather than replaying the old. | ||
| 47 | +(defonce broker-token (atom nil)) | ||
| 48 | +(defonce login-url (atom nil)) ; shown while the browser is open | ||
| 43 | 49 | ||
| 44 | ;; joined as soon as the server sends 001 | 50 | ;; joined as soon as the server sends 001 |
| 45 | (def auto-join "#test") | 51 | (def auto-join "#test") |
| @@ -125,6 +131,10 @@ | |||
| 125 | (reset! error (str "Cannot join: " (last params))) | 131 | (reset! error (str "Cannot join: " (last params))) |
| 126 | "903" (reset! status (str "Signed in as " (:handle @session))) | 132 | "903" (reset! status (str "Signed in as " (:handle @session))) |
| 127 | ("904" "905" "906") (do (reset! session nil) | 133 | ("904" "905" "906") (do (reset! session nil) |
| 134 | + ;; The broker token may still be good — but a | ||
| 135 | + ;; refusal is as likely to mean it is not, and a | ||
| 136 | + ;; stale one would fail the same way every time. | ||
| 137 | + (reset! broker-token nil) | ||
| 128 | (reset! error (str "Bluesky sign-in refused: " | 138 | (reset! error (str "Bluesky sign-in refused: " |
| 129 | (or (last params) "no reason given")))) | 139 | (or (last params) "no reason given")))) |
| 130 | "*DISCONNECTED*" (do (reset! conn nil) | 140 | "*DISCONNECTED*" (do (reset! conn nil) |
| @@ -153,31 +163,57 @@ | |||
| 153 | (println "frq: dialing" host port (if tls? "tls" "plain"))) | 163 | (println "frq: dialing" host port (if tls? "tls" "plain"))) |
| 154 | (reset! conn (irc/connect! host port nick apply-msg! tls? sess))) | 164 | (reset! conn (irc/connect! host port nick apply-msg! tls? sess))) |
| 155 | 165 | ||
| 156 | -(defn connect! | 166 | +(defn- connect-blocking! |
| 157 | - "Dial, and fall back to the plain listener when TLS cannot be had. Android is | 167 | + "Sign in if asked to, then dial. Blocking throughout — a browser handoff can |
| 158 | - the case that needs this: jolt's TLS is OpenSSL through the dynamic loader, | 168 | + take a minute, and the TLS handshake is not instant either." |
| 159 | - and there is no public libssl there to load." | ||
| 160 | [] | 169 | [] |
| 161 | (reset! error nil) | 170 | (reset! error nil) |
| 162 | (reset! connecting? true) | 171 | (reset! connecting? true) |
| 163 | (reset! status (str "Connecting to " @form-host ":" @form-port "…")) | 172 | (reset! status (str "Connecting to " @form-host ":" @form-port "…")) |
| 164 | (let [host @form-host | 173 | (let [host @form-host |
| 165 | port (parse-long (str/trim @form-port)) | 174 | port (parse-long (str/trim @form-port)) |
| 166 | - bluesky? (= :bluesky @auth-mode) | 175 | + mode @auth-mode |
| 167 | - sess (when bluesky? | 176 | + sess (case mode |
| 168 | - (reset! status (str "Signing in as " (str/trim @form-handle) "…")) | 177 | + ;; OAuth: the browser does the talking, we wait on loopback. A |
| 169 | - (let [s (atproto/create-session (str/trim @form-handle) | 178 | + ;; broker token in hand skips the browser entirely. |
| 170 | - @form-app-password)] | 179 | + :bluesky |
| 180 | + (let [handle (str/trim @form-handle) | ||
| 181 | + tokens (if-let [bt @broker-token] | ||
| 182 | + (do (reset! status "Refreshing your session…") | ||
| 183 | + (oauth/refresh-session oauth/default-broker bt)) | ||
| 184 | + (do (reset! status "Opening your browser to sign in…") | ||
| 185 | + (oauth/await-callback! | ||
| 186 | + oauth/default-broker handle | ||
| 187 | + (fn [url] | ||
| 188 | + (reset! login-url url) | ||
| 189 | + (oauth/open-browser! url) | ||
| 190 | + (reset! status "Waiting for the browser…"))))) | ||
| 191 | + s (assoc tokens :kind :web-token)] | ||
| 192 | + (reset! login-url nil) | ||
| 193 | + (reset! broker-token (:broker-token tokens)) | ||
| 171 | (reset! session s) | 194 | (reset! session s) |
| 172 | - ;; The password did its work at the PDS; do not keep it. | 195 | + (when (seq (:handle tokens)) (reset! form-handle (:handle tokens))) |
| 173 | - (reset! form-app-password "") | 196 | + s) |
| 174 | - s)) | 197 | + |
| 198 | + :app-password | ||
| 199 | + (do (reset! status (str "Signing in as " (str/trim @form-handle) "…")) | ||
| 200 | + (let [s (assoc (atproto/create-session (str/trim @form-handle) | ||
| 201 | + @form-app-password) | ||
| 202 | + :kind :pds-session)] | ||
| 203 | + (reset! session s) | ||
| 204 | + ;; The password did its work at the PDS; do not keep it. | ||
| 205 | + (reset! form-app-password "") | ||
| 206 | + s)) | ||
| 207 | + | ||
| 208 | + nil) | ||
| 175 | ;; An authenticated connection still needs a nick — the DID is the | 209 | ;; An authenticated connection still needs a nick — the DID is the |
| 176 | ;; identity, the nick is only what the channel calls you. | 210 | ;; identity, the nick is only what the channel calls you. |
| 177 | - nick (if bluesky? | 211 | + nick (if sess |
| 178 | - (-> (:handle sess) (str/split #"\.") first) | 212 | + (or (:nick sess) |
| 213 | + (-> (or (:handle sess) "") (str/split #"\\.") first) | ||
| 214 | + (str/trim @form-nick)) | ||
| 179 | (str/trim @form-nick))] | 215 | (str/trim @form-nick))] |
| 180 | - (when bluesky? (reset! form-nick nick)) | 216 | + (when sess (reset! form-nick nick)) |
| 181 | (try | 217 | (try |
| 182 | (dial! host port nick @form-tls? sess) | 218 | (dial! host port nick @form-tls? sess) |
| 183 | (catch Exception e | 219 | (catch Exception e |
| @@ -199,6 +235,21 @@ | |||
| 199 | (reset! status "Not connected") | 235 | (reset! status "Not connected") |
| 200 | (reset! error (str "Could not connect: " (describe e))))))))) | 236 | (reset! error (str "Could not connect: " (describe e))))))))) |
| 201 | 237 | ||
| 238 | +(defn connect! | ||
| 239 | + "Start connecting. The work happens on another thread: the OAuth wait sits on | ||
| 240 | + a loopback accept until the browser comes back, and the UI has frames to | ||
| 241 | + paint in the meantime." | ||
| 242 | + [] | ||
| 243 | + (reset! error nil) | ||
| 244 | + (reset! connecting? true) | ||
| 245 | + (future | ||
| 246 | + (try (connect-blocking!) | ||
| 247 | + (catch Exception e | ||
| 248 | + (reset! connecting? false) | ||
| 249 | + (reset! conn nil) | ||
| 250 | + (reset! status "Not connected") | ||
| 251 | + (reset! error (str "Could not connect: " (describe e))))))) | ||
| 252 | + | ||
| 202 | (defn disconnect! [] | 253 | (defn disconnect! [] |
| 203 | (when-let [c @conn] (irc/close! c)) | 254 | (when-let [c @conn] (irc/close! c)) |
| 204 | (reset! conn nil) | 255 | (reset! conn nil) |