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

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

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>
nandi committed 2026-08-30T00:34:53-07:00 Browse files
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
1212 of an `AppState` struct.
1313
1414 ```
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
1617 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
1718 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
1819 src/frq/app.jolt the screens
@@ -38,22 +39,28 @@ cargo run --release --bin freeq-server # in the freeq checkout
3839
3940 ## Signing in
4041
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.
4443
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.
5152
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.
5557
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.
5764
5865 ## Android
5966
@@ -88,7 +95,10 @@ surface — that surface does not work on Android either, while the syscalls do.
8895 ## Limits
8996
9097 * **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.
93103 * **No scrollback trimming, avatars, reactions, threads, or calls.**
94104 * 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 payload15+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, PRIVMSG17 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 screens19 src/frq/app.jolt the screens
@@ -38,22 +39,28 @@ cargo run --release --bin freeq-server # in the freeq checkout
38 39
39 ## Signing in40 ## Signing in
40 41
41-Guest is the default. The **Bluesky** tab on the connect screen takes a handle42+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 DID44+**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 PDS45+loopback port, puts it in `return_to`, and opens
47-3. `com.atproto.server.createSession` mints a session token there46+`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 be48+it never reaches a server as a query string. The page frq serves there has one
50- replayed elsewhere49+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 handed53+**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 to54+`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 ## Android65 ## Android
59 66
@@ -88,7 +95,10 @@ surface — that surface does not work on Android either, while the syscalls do.
88 ## Limits95 ## 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, no98+* **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 @@
2929
3030 (defn- mode-tabs []
3131 [:hbox {:spacing 8}
32- (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"]]]
32+ (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]]
3333 [:button {:key k
3434 :label label
3535 :kind (if (= k @s/auth-mode) :primary :default)
@@ -69,10 +69,27 @@
6969 [error-note]
7070 [:card {}
7171 [mode-tabs]
72- (if (= :bluesky @s/auth-mode)
72+ (case @s/auth-mode
73+ :bluesky
7374 [:vbox {:spacing 6}
7475 [: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."}]
7693 [:label {:label "Handle"}]
7794 [:entry {:text @s/form-handle
7895 :width-request 320
@@ -84,6 +101,7 @@
84101 :placeholder "xxxx-xxxx-xxxx-xxxx"
85102 :on-change #(reset! s/form-app-password %)}]
86103 [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]]
104+
87105 [:vbox {:spacing 6}
88106 [:title-2 {:label "Connect as guest"}]
89107 [:label {:label "Nick"}]
@@ -204,7 +222,11 @@
204222 (if-let [sess @s/session]
205223 [:vbox {:spacing 2}
206224 [: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))}])]
208230 [:dim-label {:label "Guest — not signed in."}])
209231 [:separator {}]
210232 [: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 k33 [:button {:key k
34 :label label34 :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-handle94 [:entry {:text @s/form-handle
78 :width-request 32095 :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 @@
161161 (String. (byte-array (map unchecked-byte bytes)))))
162162
163163 (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."
167171 [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-response163 (defn sasl-response
164- "The base64url SASL payload for a PDS session: the token, the DID it belongs164+ "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 be165+
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 @@
77 (:require [clojure.string :as str]
88 [glimmer.ratom :as r :refer [atom]]
99 [frq.atproto :as atproto]
10- [frq.irc :as irc]))
10+ [frq.irc :as irc]
11+ [frq.oauth :as oauth]))
1112
1213 (def default-host "irc.freeq.at")
1314 (def default-port "6697")
@@ -36,10 +37,15 @@
3637 ;; Bluesky sign-in. The app password reaches the user's own PDS and nothing
3738 ;; else: freeq is handed the session token it mints, and verifies that token by
3839 ;; 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
4041 (defonce form-handle (atom ""))
4142 (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
4349
4450 ;; joined as soon as the server sends 001
4551 (def auto-join "#test")
@@ -125,6 +131,10 @@
125131 (reset! error (str "Cannot join: " (last params)))
126132 "903" (reset! status (str "Signed in as " (:handle @session)))
127133 ("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)
128138 (reset! error (str "Bluesky sign-in refused: "
129139 (or (last params) "no reason given"))))
130140 "*DISCONNECTED*" (do (reset! conn nil)
@@ -153,31 +163,57 @@
153163 (println "frq: dialing" host port (if tls? "tls" "plain")))
154164 (reset! conn (irc/connect! host port nick apply-msg! tls? sess)))
155165
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."
160169 []
161170 (reset! error nil)
162171 (reset! connecting? true)
163172 (reset! status (str "Connecting to " @form-host ":" @form-port ""))
164173 (let [host @form-host
165174 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))
171194 (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)
175209 ;; An authenticated connection still needs a nick the DID is the
176210 ;; 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))
179215 (str/trim @form-nick))]
180- (when bluesky? (reset! form-nick nick))
216+ (when sess (reset! form-nick nick))
181217 (try
182218 (dial! host port nick @form-tls? sess)
183219 (catch Exception e
@@ -199,6 +235,21 @@
199235 (reset! status "Not connected")
200236 (reset! error (str "Could not connect: " (describe e)))))))))
201237
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+
202253 (defn disconnect! []
203254 (when-let [c @conn] (irc/close! c))
204255 (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 nothing37 ;; 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 by38 ;; 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 | :bluesky40+(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 00150 ;; 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 is167+ "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-host173 (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 the209 ;; 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 (try217 (try
182 (dial! host port nick @form-tls? sess)218 (dial! host port nick @form-tls? sess)
183 (catch Exception e219 (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)