nandi/frqpublic Fork 0
bdd2c3c
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, through the broker

The connect screen has had a Bluesky tab since the port, with copy
apologising that it was not wired up: the broker flow needed a browser
and a loopback listener, and the ClojureDart that had both went with the
rest of the tree. Everything downstream of it survived — `skWebToken`
SASL, the `brokerToken` and `loginUrl` fields, the store — so what was
missing was the middle.

`frq/oauth.nim` is that middle, and is `common/frq/oauth/core.cljc` and
`flutter/src/frq/oauth/dart.cljd` in one file: two files there because
ClojureDart also compiled for a browser, where catching a redirect is
somebody else's problem. Nim has only the desktop.

The broker does the OAuth with the reader's own PDS, so this holds no
password and no DPoP key. It binds a loopback port, opens a browser at
`/auth/login?handle=…&return_to=http://127.0.0.1:<port>`, and serves a
page whose one job is to POST the URL fragment back — a fragment never
reaches a server. Out of that comes a single-use web-token, which goes
straight into SASL, and a durable broker token, which is the only half
written to disk. A second run trades it at `/session` and never opens a
browser at all; `frq_init` reads it, which is the first thing that
function has ever had to do.

The wait is a thread for the reason `conn`'s is: a reader takes as long
as they take over a login page, and `dispatch` is called on the frame.
A junk POST does not end it, since the real handoff may still be coming.
Cancel ends it, so a tab finished ten minutes later cannot sign in
behind the reader; a broker token the broker no longer honours is
dropped rather than replayed on every Connect.

`connectNow` splits: the handoff has signed in already, on a token that
is single-use, and running `signIn` again would spend a round trip
replacing the session it is holding.

The listener is tested against itself — a real GET for the page and a
real POST of a payload, over a real loopback socket. `begin` takes an
`openBrowser` parameter so that test does not open a tab on whoever runs
the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T08:49:04-07:00 Browse files
bdd2c3c parent: 1cb40af
added nim/src/frq/oauth.nim +315 -0
new file mode 100644
@@ -0,0 +1,315 @@
1+## Signing in with Bluesky, through freeq's auth broker.
2+##
3+## From `common/frq/oauth/core.cljc` and `flutter/src/frq/oauth/dart.cljd`,
4+## which were two files because ClojureDart compiled for a browser as well:
5+## building the URL and reading the payload were portable, catching the
6+## redirect was not. Nim only has the desktop, so it is one file.
7+##
8+## The broker at `auth.freeq.at` does the AT Protocol OAuth with the reader's
9+## own PDS — the app never sees a password and never holds a DPoP key. All a
10+## client does is send them there and read what comes back:
11+##
12+## 1. bind a loopback listener on a port the kernel picks
13+## 2. open `<broker>/auth/login?handle=…&return_to=http://127.0.0.1:<port>`
14+## 3. the browser lands back on us with the payload in the URL *fragment*
15+## 4. a fragment never reaches a server, so the page we serve posts it back
16+## 5. that payload carries a single-use web-token and a durable broker token
17+##
18+## The web-token goes straight into SASL and is spent; the broker token is
19+## what `refreshSession` trades for a new one on the next run, and the only
20+## thing worth writing to disk.
21+##
22+## The wait is a thread, like `conn`'s, and for the same reason: a reader
23+## takes as long as they take over a login page, and `dispatch` is called on
24+## the frame.
25+
26+import std/[base64, httpclient, json, nativesockets, net, osproc,
27+ strutils, times]
28+import frq/trace
29+
30+const
31+ defaultBroker* = "https://auth.freeq.at"
32+ loginTimeout = 5 * 60 ## seconds; a login page nobody finishes
33+
34+type
35+ Tokens* = object
36+ ## What the broker hands back. `token` is single-use.
37+ token*, brokerToken*, nick*, did*, handle*: string
38+
39+ OauthError* = object of CatchableError
40+
41+# ----------------------------------------------------------------- the url
42+
43+const hexDigits = "0123456789ABCDEF"
44+
45+func unreserved(b: byte): bool =
46+ ## RFC 3986's unreserved set, by byte value: A-Z a-z 0-9 - _ . ~
47+ ##
48+ ## By number rather than by `isAlphaNumeric`, which would also say yes to
49+ ## é — and a percent-encoder that passes é through has not encoded
50+ ## anything.
51+ (b >= 48'u8 and b <= 57'u8) or (b >= 65'u8 and b <= 90'u8) or
52+ (b >= 97'u8 and b <= 122'u8) or b in [45'u8, 95'u8, 46'u8, 126'u8]
53+
54+func urlEncode*(s: string): string =
55+ ## Percent-encode everything a handle could hold that a query string cannot.
56+ ##
57+ ## Over UTF-8 bytes rather than characters: a non-ASCII handle is several
58+ ## bytes and each is encoded on its own, which is what the spec says and
59+ ## what the broker expects.
60+ for c in s:
61+ let b = byte(c)
62+ if unreserved(b): result.add c
63+ else:
64+ result.add '%'
65+ result.add hexDigits[int(b shr 4)]
66+ result.add hexDigits[int(b and 0x0f)]
67+
68+func trimmedBroker(broker: string): string =
69+ result = if broker.len > 0: broker else: defaultBroker
70+ while result.len > 0 and result[^1] == '/': result.setLen(result.len - 1)
71+
72+func loginUrl*(broker, handle, returnTo: string): string =
73+ ## Where the browser goes. A leading `@` on the handle is the reader typing
74+ ## it the way it appears beside a message, not part of it.
75+ var h = handle.strip()
76+ if h.startsWith("@"): h = h[1 .. ^1]
77+ trimmedBroker(broker) & "/auth/login?handle=" & urlEncode(h) &
78+ "&return_to=" & urlEncode(returnTo)
79+
80+func brokerHost*(broker: string): string =
81+ var b = trimmedBroker(broker)
82+ if b.startsWith("https://"): b = b[8 .. ^1]
83+ elif b.startsWith("http://"): b = b[7 .. ^1]
84+ b.split('/')[0]
85+
86+# ------------------------------------------------------------- the handoff
87+
88+proc b64urlDecode(s: string): string =
89+ var t = s.replace("-", "+").replace("_", "/")
90+ while t.len mod 4 != 0: t.add '='
91+ try: decode(t) except CatchableError: ""
92+
93+proc tokensOf*(payload: string): Tokens =
94+ ## The broker's base64url JSON payload, as fields.
95+ ##
96+ ## Both tokens or none: a payload missing either is the broker reporting a
97+ ## failure, and it puts the reason in `error`.
98+ let raw = b64urlDecode(payload.strip())
99+ let j = try: parseJson(raw)
100+ except CatchableError:
101+ raise newException(OauthError, "Malformed sign-in payload")
102+ result = Tokens(token: j{"token"}.getStr(),
103+ brokerToken: j{"broker_token"}.getStr(),
104+ nick: j{"nick"}.getStr(),
105+ did: j{"did"}.getStr(),
106+ handle: j{"handle"}.getStr())
107+ if result.token.len == 0 or result.brokerToken.len == 0:
108+ let e = j{"error"}.getStr()
109+ raise newException(OauthError,
110+ if e.len > 0: e else: "Malformed sign-in payload")
111+
112+proc refreshSession*(broker, brokerToken: string): Tokens =
113+ ## Mint a fresh web-token from the durable broker token.
114+ ##
115+ ## This is what a second run uses: the token that came back through the
116+ ## browser was spent on the first connection, and the reader should not see
117+ ## a login page again for it.
118+ let c = newHttpClient(timeout = 15_000,
119+ sslContext = newContext(verifyMode = CVerifyPeer))
120+ try:
121+ c.headers = newHttpHeaders({"Content-Type": "application/json"})
122+ let res = c.request("https://" & brokerHost(broker) & "/session",
123+ httpMethod = HttpPost,
124+ body = $(%*{"broker_token": brokerToken}))
125+ # The body whatever the status: an expired token is a 401 whose message
126+ # is the part worth showing.
127+ let j = try: parseJson(res.body)
128+ except CatchableError:
129+ raise newException(OauthError,
130+ "The broker's answer was not JSON (" & res.status & ").")
131+ let token = j{"token"}.getStr()
132+ if token.len == 0:
133+ let m = j{"message"}.getStr()
134+ raise newException(OauthError,
135+ if m.len > 0: m else: "Broker session refresh failed — sign in again")
136+ Tokens(token: token, brokerToken: brokerToken,
137+ nick: j{"nick"}.getStr(), did: j{"did"}.getStr(),
138+ handle: j{"handle"}.getStr())
139+ finally:
140+ c.close()
141+
142+# --------------------------------------------------------- the capture page
143+
144+func captureHtml*(): string =
145+ ## The page the browser lands on, whose one job is to post the fragment
146+ ## back — a fragment never reaches a server, so nothing here can read it
147+ ## without a line of script.
148+ ##
149+ ## The Clojure took a `return-url` for Android, where the browser is in
150+ ## front of the app and a `frq://` link has to raise it. On a desktop the
151+ ## window is already beside the browser, so the page says the reader can
152+ ## close the tab and that is the whole of it.
153+ "<!doctype html><meta charset=utf-8><title>frq</title>" &
154+ "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">" &
155+ "<p id=m>Finishing sign-in…</p>" &
156+ "<script>" &
157+ "var h=location.hash.replace(/^#/,'');" &
158+ "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');" &
159+ "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}" &
160+ "else{fetch('/capture',{method:'POST',body:p})" &
161+ ".then(function(){document.getElementById('m').textContent=" &
162+ "'Signed in — you can close this tab.';})" &
163+ ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}" &
164+ "</script></body>"
165+
166+func httpResponse*(status, contentType, body: string): string =
167+ ## One response, headers and all. Hand-written rather than
168+ ## `asynchttpserver`, because this server answers two shapes of request on
169+ ## a socket nothing outside this machine can reach, and async in a thread
170+ ## buys nothing for it.
171+ "HTTP/1.1 " & status & "\r\n" &
172+ "Content-Type: " & contentType & "\r\n" &
173+ "Content-Length: " & $body.len & "\r\n" &
174+ "Connection: close\r\n" &
175+ "Cache-Control: no-store\r\n\r\n" & body
176+
177+func contentLengthOf*(head: string): int =
178+ ## The body length out of a request's headers, or 0 where it says none.
179+ ##
180+ ## Case-insensitively: the header is whatever the browser felt like
181+ ## capitalising, and `fetch` sends `content-length` in lower case where
182+ ## curl sends it capitalised.
183+ for line in head.splitLines():
184+ let colon = line.find(':')
185+ if colon > 0 and line[0 ..< colon].strip().toLowerAscii() == "content-length":
186+ return try: parseInt(line[colon + 1 .. ^1].strip()) except ValueError: 0
187+ 0
188+
189+# ------------------------------------------------------------- the listener
190+
191+type LoginReq = object
192+ broker, handle: string
193+ openBrowser: bool
194+
195+var
196+ events: Channel[string] ## "url: <login url>" | "ok: <payload>" | "error: …"
197+ worker: Thread[LoginReq]
198+ running: bool
199+ cancelled: bool
200+
201+proc openInBrowser(url: string) =
202+ ## Best effort. The URL is on the screen either way — `loginUrl` is shown
203+ ## under "If the browser did not open, visit:" for exactly the machine
204+ ## where this does nothing.
205+ try:
206+ let p = startProcess("xdg-open", args = [url], options = {poUsePath})
207+ p.close()
208+ except CatchableError as e:
209+ trace("oauth", "could not open a browser: " & e.msg)
210+
211+proc readRequest(client: Socket): (string, string) =
212+ ## A request as (head, body). Read by hand: the head ends at a blank line,
213+ ## and the body is exactly what Content-Length says — a fragment posted
214+ ## back is one short line and never chunked.
215+ var head: string
216+ while not head.endsWith("\r\n\r\n"):
217+ var c: string
218+ if client.recv(c, 1, timeout = 10_000) <= 0: return ("", "")
219+ head.add c
220+ if head.len > 16_384: return ("", "") # nothing legitimate is this big
221+ let want = contentLengthOf(head)
222+ var body: string
223+ if want > 0 and want <= 16_384:
224+ discard client.recv(body, want, timeout = 10_000)
225+ (head, body)
226+
227+proc workerBody(req: LoginReq) {.thread.} =
228+ var server = newSocket()
229+ try:
230+ server.setSockOpt(OptReuseAddr, true)
231+ server.bindAddr(Port(0), "127.0.0.1")
232+ server.listen()
233+ let port = server.getLocalAddr()[1]
234+ let url = loginUrl(req.broker, req.handle,
235+ "http://127.0.0.1:" & $uint16(port))
236+ trace("oauth", "listening on " & $uint16(port))
237+ events.send("url: " & url)
238+ if req.openBrowser: openInBrowser(url)
239+
240+ let deadline = epochTime() + loginTimeout.float
241+ while not cancelled and epochTime() < deadline:
242+ # A second at a time rather than a blocking accept, so cancelling and
243+ # the deadline are both answered without another thread closing a
244+ # socket this one is sitting inside.
245+ var readable = @[server.getFd()]
246+ if selectRead(readable, 1000) <= 0: continue
247+
248+ var client: Socket
249+ try:
250+ server.accept(client)
251+ let (head, body) = readRequest(client)
252+ if head.startsWith("POST"):
253+ # A POST carrying nothing usable is not the end of the wait — the
254+ # real handoff may still be on its way — so only a good payload
255+ # stops the listener.
256+ var good = false
257+ try:
258+ discard tokensOf(body)
259+ good = true
260+ except CatchableError as e:
261+ trace("oauth", "ignoring a POST: " & e.msg)
262+ client.send(httpResponse(if good: "200 OK" else: "400 Bad Request",
263+ "text/plain; charset=utf-8",
264+ if good: "ok" else: "bad payload"))
265+ if good:
266+ client.close()
267+ events.send("ok: " & body.strip())
268+ return
269+ else:
270+ client.send(httpResponse("200 OK", "text/html; charset=utf-8",
271+ captureHtml()))
272+ finally:
273+ try: client.close() except CatchableError: discard
274+
275+ events.send(if cancelled: "error: Sign-in cancelled."
276+ else: "error: The browser did not come back — sign in again.")
277+ except CatchableError as e:
278+ trace("oauth", "!! " & e.msg)
279+ events.send("error: " & e.msg)
280+ finally:
281+ try: server.close() except CatchableError: discard
282+
283+proc begin*(broker, handle: string, openBrowser = true) =
284+ ## Open the browser and start waiting. Returns at once; what happens next
285+ ## arrives through `tryEvent`.
286+ ##
287+ ## `openBrowser` is off in the test that drives the loopback listener for
288+ ## real, which otherwise opens a tab on whoever runs the suite. The login
289+ ## URL goes out on the channel either way — it is on the screen for the
290+ ## machine that has no `xdg-open`, and that is the same string a test
291+ ## posts back to.
292+ if running:
293+ trace("oauth", "a sign-in is already waiting")
294+ return
295+ while events.tryRecv()[0]: discard
296+ cancelled = false
297+ running = true
298+ createThread(worker, workerBody,
299+ LoginReq(broker: broker, handle: handle,
300+ openBrowser: openBrowser))
301+
302+proc cancel*() =
303+ ## Stop waiting. The thread notices within the second it is sleeping in.
304+ if running: cancelled = true
305+
306+proc tryEvent*(): (bool, string) = events.tryRecv()
307+
308+proc finished*() =
309+ ## Called by the drain once an event has settled the wait.
310+ running = false
311+ cancelled = false
312+
313+proc waiting*(): bool = running
314+
315+events.open()
new file mode 100644
@@ -0,0 +1,315 @@
1+## Signing in with Bluesky, through freeq's auth broker.
2+##
3+## From `common/frq/oauth/core.cljc` and `flutter/src/frq/oauth/dart.cljd`,
4+## which were two files because ClojureDart compiled for a browser as well:
5+## building the URL and reading the payload were portable, catching the
6+## redirect was not. Nim only has the desktop, so it is one file.
7+##
8+## The broker at `auth.freeq.at` does the AT Protocol OAuth with the reader's
9+## own PDS — the app never sees a password and never holds a DPoP key. All a
10+## client does is send them there and read what comes back:
11+##
12+## 1. bind a loopback listener on a port the kernel picks
13+## 2. open `<broker>/auth/login?handle=…&return_to=http://127.0.0.1:<port>`
14+## 3. the browser lands back on us with the payload in the URL *fragment*
15+## 4. a fragment never reaches a server, so the page we serve posts it back
16+## 5. that payload carries a single-use web-token and a durable broker token
17+##
18+## The web-token goes straight into SASL and is spent; the broker token is
19+## what `refreshSession` trades for a new one on the next run, and the only
20+## thing worth writing to disk.
21+##
22+## The wait is a thread, like `conn`'s, and for the same reason: a reader
23+## takes as long as they take over a login page, and `dispatch` is called on
24+## the frame.
25+
26+import std/[base64, httpclient, json, nativesockets, net, osproc,
27+ strutils, times]
28+import frq/trace
29+
30+const
31+ defaultBroker* = "https://auth.freeq.at"
32+ loginTimeout = 5 * 60 ## seconds; a login page nobody finishes
33+
34+type
35+ Tokens* = object
36+ ## What the broker hands back. `token` is single-use.
37+ token*, brokerToken*, nick*, did*, handle*: string
38+
39+ OauthError* = object of CatchableError
40+
41+# ----------------------------------------------------------------- the url
42+
43+const hexDigits = "0123456789ABCDEF"
44+
45+func unreserved(b: byte): bool =
46+ ## RFC 3986's unreserved set, by byte value: A-Z a-z 0-9 - _ . ~
47+ ##
48+ ## By number rather than by `isAlphaNumeric`, which would also say yes to
49+ ## é — and a percent-encoder that passes é through has not encoded
50+ ## anything.
51+ (b >= 48'u8 and b <= 57'u8) or (b >= 65'u8 and b <= 90'u8) or
52+ (b >= 97'u8 and b <= 122'u8) or b in [45'u8, 95'u8, 46'u8, 126'u8]
53+
54+func urlEncode*(s: string): string =
55+ ## Percent-encode everything a handle could hold that a query string cannot.
56+ ##
57+ ## Over UTF-8 bytes rather than characters: a non-ASCII handle is several
58+ ## bytes and each is encoded on its own, which is what the spec says and
59+ ## what the broker expects.
60+ for c in s:
61+ let b = byte(c)
62+ if unreserved(b): result.add c
63+ else:
64+ result.add '%'
65+ result.add hexDigits[int(b shr 4)]
66+ result.add hexDigits[int(b and 0x0f)]
67+
68+func trimmedBroker(broker: string): string =
69+ result = if broker.len > 0: broker else: defaultBroker
70+ while result.len > 0 and result[^1] == '/': result.setLen(result.len - 1)
71+
72+func loginUrl*(broker, handle, returnTo: string): string =
73+ ## Where the browser goes. A leading `@` on the handle is the reader typing
74+ ## it the way it appears beside a message, not part of it.
75+ var h = handle.strip()
76+ if h.startsWith("@"): h = h[1 .. ^1]
77+ trimmedBroker(broker) & "/auth/login?handle=" & urlEncode(h) &
78+ "&return_to=" & urlEncode(returnTo)
79+
80+func brokerHost*(broker: string): string =
81+ var b = trimmedBroker(broker)
82+ if b.startsWith("https://"): b = b[8 .. ^1]
83+ elif b.startsWith("http://"): b = b[7 .. ^1]
84+ b.split('/')[0]
85+
86+# ------------------------------------------------------------- the handoff
87+
88+proc b64urlDecode(s: string): string =
89+ var t = s.replace("-", "+").replace("_", "/")
90+ while t.len mod 4 != 0: t.add '='
91+ try: decode(t) except CatchableError: ""
92+
93+proc tokensOf*(payload: string): Tokens =
94+ ## The broker's base64url JSON payload, as fields.
95+ ##
96+ ## Both tokens or none: a payload missing either is the broker reporting a
97+ ## failure, and it puts the reason in `error`.
98+ let raw = b64urlDecode(payload.strip())
99+ let j = try: parseJson(raw)
100+ except CatchableError:
101+ raise newException(OauthError, "Malformed sign-in payload")
102+ result = Tokens(token: j{"token"}.getStr(),
103+ brokerToken: j{"broker_token"}.getStr(),
104+ nick: j{"nick"}.getStr(),
105+ did: j{"did"}.getStr(),
106+ handle: j{"handle"}.getStr())
107+ if result.token.len == 0 or result.brokerToken.len == 0:
108+ let e = j{"error"}.getStr()
109+ raise newException(OauthError,
110+ if e.len > 0: e else: "Malformed sign-in payload")
111+
112+proc refreshSession*(broker, brokerToken: string): Tokens =
113+ ## Mint a fresh web-token from the durable broker token.
114+ ##
115+ ## This is what a second run uses: the token that came back through the
116+ ## browser was spent on the first connection, and the reader should not see
117+ ## a login page again for it.
118+ let c = newHttpClient(timeout = 15_000,
119+ sslContext = newContext(verifyMode = CVerifyPeer))
120+ try:
121+ c.headers = newHttpHeaders({"Content-Type": "application/json"})
122+ let res = c.request("https://" & brokerHost(broker) & "/session",
123+ httpMethod = HttpPost,
124+ body = $(%*{"broker_token": brokerToken}))
125+ # The body whatever the status: an expired token is a 401 whose message
126+ # is the part worth showing.
127+ let j = try: parseJson(res.body)
128+ except CatchableError:
129+ raise newException(OauthError,
130+ "The broker's answer was not JSON (" & res.status & ").")
131+ let token = j{"token"}.getStr()
132+ if token.len == 0:
133+ let m = j{"message"}.getStr()
134+ raise newException(OauthError,
135+ if m.len > 0: m else: "Broker session refresh failed — sign in again")
136+ Tokens(token: token, brokerToken: brokerToken,
137+ nick: j{"nick"}.getStr(), did: j{"did"}.getStr(),
138+ handle: j{"handle"}.getStr())
139+ finally:
140+ c.close()
141+
142+# --------------------------------------------------------- the capture page
143+
144+func captureHtml*(): string =
145+ ## The page the browser lands on, whose one job is to post the fragment
146+ ## back — a fragment never reaches a server, so nothing here can read it
147+ ## without a line of script.
148+ ##
149+ ## The Clojure took a `return-url` for Android, where the browser is in
150+ ## front of the app and a `frq://` link has to raise it. On a desktop the
151+ ## window is already beside the browser, so the page says the reader can
152+ ## close the tab and that is the whole of it.
153+ "<!doctype html><meta charset=utf-8><title>frq</title>" &
154+ "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">" &
155+ "<p id=m>Finishing sign-in…</p>" &
156+ "<script>" &
157+ "var h=location.hash.replace(/^#/,'');" &
158+ "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');" &
159+ "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}" &
160+ "else{fetch('/capture',{method:'POST',body:p})" &
161+ ".then(function(){document.getElementById('m').textContent=" &
162+ "'Signed in — you can close this tab.';})" &
163+ ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}" &
164+ "</script></body>"
165+
166+func httpResponse*(status, contentType, body: string): string =
167+ ## One response, headers and all. Hand-written rather than
168+ ## `asynchttpserver`, because this server answers two shapes of request on
169+ ## a socket nothing outside this machine can reach, and async in a thread
170+ ## buys nothing for it.
171+ "HTTP/1.1 " & status & "\r\n" &
172+ "Content-Type: " & contentType & "\r\n" &
173+ "Content-Length: " & $body.len & "\r\n" &
174+ "Connection: close\r\n" &
175+ "Cache-Control: no-store\r\n\r\n" & body
176+
177+func contentLengthOf*(head: string): int =
178+ ## The body length out of a request's headers, or 0 where it says none.
179+ ##
180+ ## Case-insensitively: the header is whatever the browser felt like
181+ ## capitalising, and `fetch` sends `content-length` in lower case where
182+ ## curl sends it capitalised.
183+ for line in head.splitLines():
184+ let colon = line.find(':')
185+ if colon > 0 and line[0 ..< colon].strip().toLowerAscii() == "content-length":
186+ return try: parseInt(line[colon + 1 .. ^1].strip()) except ValueError: 0
187+ 0
188+
189+# ------------------------------------------------------------- the listener
190+
191+type LoginReq = object
192+ broker, handle: string
193+ openBrowser: bool
194+
195+var
196+ events: Channel[string] ## "url: <login url>" | "ok: <payload>" | "error: …"
197+ worker: Thread[LoginReq]
198+ running: bool
199+ cancelled: bool
200+
201+proc openInBrowser(url: string) =
202+ ## Best effort. The URL is on the screen either way — `loginUrl` is shown
203+ ## under "If the browser did not open, visit:" for exactly the machine
204+ ## where this does nothing.
205+ try:
206+ let p = startProcess("xdg-open", args = [url], options = {poUsePath})
207+ p.close()
208+ except CatchableError as e:
209+ trace("oauth", "could not open a browser: " & e.msg)
210+
211+proc readRequest(client: Socket): (string, string) =
212+ ## A request as (head, body). Read by hand: the head ends at a blank line,
213+ ## and the body is exactly what Content-Length says — a fragment posted
214+ ## back is one short line and never chunked.
215+ var head: string
216+ while not head.endsWith("\r\n\r\n"):
217+ var c: string
218+ if client.recv(c, 1, timeout = 10_000) <= 0: return ("", "")
219+ head.add c
220+ if head.len > 16_384: return ("", "") # nothing legitimate is this big
221+ let want = contentLengthOf(head)
222+ var body: string
223+ if want > 0 and want <= 16_384:
224+ discard client.recv(body, want, timeout = 10_000)
225+ (head, body)
226+
227+proc workerBody(req: LoginReq) {.thread.} =
228+ var server = newSocket()
229+ try:
230+ server.setSockOpt(OptReuseAddr, true)
231+ server.bindAddr(Port(0), "127.0.0.1")
232+ server.listen()
233+ let port = server.getLocalAddr()[1]
234+ let url = loginUrl(req.broker, req.handle,
235+ "http://127.0.0.1:" & $uint16(port))
236+ trace("oauth", "listening on " & $uint16(port))
237+ events.send("url: " & url)
238+ if req.openBrowser: openInBrowser(url)
239+
240+ let deadline = epochTime() + loginTimeout.float
241+ while not cancelled and epochTime() < deadline:
242+ # A second at a time rather than a blocking accept, so cancelling and
243+ # the deadline are both answered without another thread closing a
244+ # socket this one is sitting inside.
245+ var readable = @[server.getFd()]
246+ if selectRead(readable, 1000) <= 0: continue
247+
248+ var client: Socket
249+ try:
250+ server.accept(client)
251+ let (head, body) = readRequest(client)
252+ if head.startsWith("POST"):
253+ # A POST carrying nothing usable is not the end of the wait — the
254+ # real handoff may still be on its way — so only a good payload
255+ # stops the listener.
256+ var good = false
257+ try:
258+ discard tokensOf(body)
259+ good = true
260+ except CatchableError as e:
261+ trace("oauth", "ignoring a POST: " & e.msg)
262+ client.send(httpResponse(if good: "200 OK" else: "400 Bad Request",
263+ "text/plain; charset=utf-8",
264+ if good: "ok" else: "bad payload"))
265+ if good:
266+ client.close()
267+ events.send("ok: " & body.strip())
268+ return
269+ else:
270+ client.send(httpResponse("200 OK", "text/html; charset=utf-8",
271+ captureHtml()))
272+ finally:
273+ try: client.close() except CatchableError: discard
274+
275+ events.send(if cancelled: "error: Sign-in cancelled."
276+ else: "error: The browser did not come back — sign in again.")
277+ except CatchableError as e:
278+ trace("oauth", "!! " & e.msg)
279+ events.send("error: " & e.msg)
280+ finally:
281+ try: server.close() except CatchableError: discard
282+
283+proc begin*(broker, handle: string, openBrowser = true) =
284+ ## Open the browser and start waiting. Returns at once; what happens next
285+ ## arrives through `tryEvent`.
286+ ##
287+ ## `openBrowser` is off in the test that drives the loopback listener for
288+ ## real, which otherwise opens a tab on whoever runs the suite. The login
289+ ## URL goes out on the channel either way — it is on the screen for the
290+ ## machine that has no `xdg-open`, and that is the same string a test
291+ ## posts back to.
292+ if running:
293+ trace("oauth", "a sign-in is already waiting")
294+ return
295+ while events.tryRecv()[0]: discard
296+ cancelled = false
297+ running = true
298+ createThread(worker, workerBody,
299+ LoginReq(broker: broker, handle: handle,
300+ openBrowser: openBrowser))
301+
302+proc cancel*() =
303+ ## Stop waiting. The thread notices within the second it is sleeping in.
304+ if running: cancelled = true
305+
306+proc tryEvent*(): (bool, string) = events.tryRecv()
307+
308+proc finished*() =
309+ ## Called by the drain once an event has settled the wait.
310+ running = false
311+ cancelled = false
312+
313+proc waiting*(): bool = running
314+
315+events.open()
modified nim/src/frq/reducer.nim +110 -13
@@ -14,8 +14,9 @@
1414 import std/[json, options, strutils, tables]
1515 import std/sets
1616 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17- atproto, handshake, textruns, members, msgsig, profile]
17+ atproto, handshake, textruns, members, msgsig, profile, store]
1818 import frq/conn as tr
19+import frq/oauth as oa
1920
2021 proc split2(id: string): (string, string) =
2122 ## `"room.open:#test"` → `("room.open", "#test")`. The argument may itself
@@ -49,6 +50,42 @@ proc openRoom(name: string) =
4950 # Opening a room is reading it: the marker moves to the newest line here.
5051 app.rooms[name] = app.rooms[name].markRead
5152
53+proc restore*() =
54+ ## What a previous run left on disk, back in the state.
55+ ##
56+ ## Only the sign-in: a broker token is what saves the reader a login page,
57+ ## and the mode goes with it because a remembered session is not much use
58+ ## sitting behind the Guest tab. The nick and handle come along so the
59+ ## screen says who it is about before the broker is asked.
60+ let (saved, had) = loadSession()
61+ if not had: return
62+ app.brokerToken = saved.brokerToken
63+ app.authMode = amBluesky
64+ if saved.handle.len > 0: app.formHandle = saved.handle
65+ if saved.nick.len > 0: app.formNick = saved.nick
66+ trace("oauth", "a saved session for " & saved.handle)
67+
68+proc adoptTokens(t: oa.Tokens) =
69+ ## A broker handoff, become an identity.
70+ ##
71+ ## The nick and the handle are set together for the reason the app-password
72+ ## path sets them together: the channel calls us one thing and the client
73+ ## believes another otherwise, and every "is this me?" test comes back
74+ ## false. The broker's `nick` wins where it sent one — it is what the server
75+ ## has already decided to call this DID.
76+ session = Session(kind: skWebToken, token: t.token,
77+ did: t.did, handle: t.handle)
78+ if t.handle.len > 0: app.formHandle = t.handle
79+ let nick = if t.nick.len > 0: t.nick else: t.handle
80+ if nick.len > 0: app.formNick = nick
81+ # Only the durable half is written: the web-token beside it is single-use
82+ # and would be a stale secret on disk by the time anything read it.
83+ app.brokerToken = t.brokerToken
84+ discard saveSession(SavedSession(brokerToken: t.brokerToken,
85+ handle: app.formHandle, did: t.did,
86+ nick: app.formNick))
87+ trace("oauth", "signed in as " & t.did)
88+
5289 proc signIn(): bool =
5390 ## Whatever identity was asked for, settled before the socket opens.
5491 ##
@@ -83,19 +120,36 @@ proc signIn(): bool =
83120 app.connecting = false
84121 false
85122 of amBluesky:
86- # The broker flow needs a browser and a loopback listener to catch the
87- # redirect, and neither is ported. Said plainly rather than connecting as
88- # a guest and looking like it worked.
89- setError("Bluesky OAuth is not wired up yet — use an app password, or connect as a guest.")
90- false
123+ # A remembered broker token is the whole reason to keep one: it buys a
124+ # fresh web-token without a browser, so a second run connects with no
125+ # login page at all. Only when there is none does the browser open, and
126+ # that path does not finish here — `connectNow` is called again from the
127+ # drain once the handoff lands.
128+ if app.brokerToken.len == 0:
129+ app.status = "Waiting for the browser…"
130+ oa.begin(oa.defaultBroker, app.formHandle)
131+ return false
132+ try:
133+ app.status = "Refreshing your sign-in…"
134+ adoptTokens(oa.refreshSession(oa.defaultBroker, app.brokerToken))
135+ true
136+ except CatchableError as e:
137+ # A token the broker no longer honours is worse than none: every
138+ # Connect would spend a round trip failing the same way. Dropped, and
139+ # the next press opens the browser.
140+ trace("oauth", "refresh failed: " & e.msg)
141+ app.brokerToken = ""
142+ clearSession()
143+ setError(e.msg)
144+ app.connecting = false
145+ false
91146
92-proc connectNow() =
93- if app.formHost.strip().len == 0:
94- setError("A server is required."); return
95- if app.formNick.strip().len == 0 and app.authMode == amGuest:
96- setError("A nickname is required."); return
97- app.hasError = false
98- if not signIn(): return
147+proc openSocket() =
148+ ## The connection itself, with whoever we are already settled.
149+ ##
150+ ## Split from `connectNow` for the browser handoff: that path has signed in
151+ ## already, on a token that is single-use, and running `signIn` again would
152+ ## spend a broker round trip replacing a session it is holding.
99153 app.connecting = true
100154 app.status = "Connecting to " & app.formHost & ":" & app.formPort &
101155 (if app.formTls: " over TLS" else: "") & ""
@@ -104,6 +158,15 @@ proc connectNow() =
104158 tr.open(tr.ConnConfig(host: app.formHost.strip(), port: port,
105159 tls: app.formTls))
106160
161+proc connectNow() =
162+ if app.formHost.strip().len == 0:
163+ setError("A server is required."); return
164+ if app.formNick.strip().len == 0 and app.authMode == amGuest:
165+ setError("A nickname is required."); return
166+ app.hasError = false
167+ if not signIn(): return
168+ openSocket()
169+
107170 proc sendDraft() =
108171 let text = app.draft.strip()
109172 if text.len == 0 or app.current.len == 0: return
@@ -160,12 +223,19 @@ proc dispatch*(event: JsonNode) =
160223
161224 of "session.forget":
162225 app.brokerToken = ""
226+ clearSession()
227+ app.loginUrl = ""
228+ oa.cancel()
163229 app.status = "Saved session forgotten."
164230
165231 # --------------------------------------------------------- the connection
166232 of "connect": connectNow()
167233
168234 of "cancel", "disconnect":
235+ # A browser wait is part of connecting, so Cancel ends it too — otherwise
236+ # a tab finished ten minutes later would sign in behind the reader.
237+ oa.cancel()
238+ app.loginUrl = ""
169239 # The key goes with the connection, so a reconnect signs with one the
170240 # server has actually been told about.
171241 msgsig.forget()
@@ -365,6 +435,33 @@ proc note(room: string, m: Message) =
365435 app.rooms[room] = r.recount(app.formNick)
366436
367437 proc drain*() =
438+ # The browser handoff, before the socket: a sign-in that just landed should
439+ # open the connection in the same frame it arrived, rather than leaving the
440+ # screen saying "Waiting for the browser…" until the next one.
441+ while true:
442+ let (ok, e) = oa.tryEvent()
443+ if not ok: break
444+ if e.startsWith("url: "):
445+ # Shown under "If the browser did not open, visit:" — on a machine with
446+ # no xdg-open this is the whole of the flow the reader can see.
447+ app.loginUrl = e[5 .. ^1]
448+ elif e.startsWith("ok: "):
449+ oa.finished()
450+ app.loginUrl = ""
451+ try:
452+ adoptTokens(oa.tokensOf(e[4 .. ^1]))
453+ openSocket()
454+ except CatchableError as ex:
455+ setError(ex.msg)
456+ app.connecting = false
457+ app.status = "Not connected"
458+ elif e.startsWith("error: "):
459+ oa.finished()
460+ app.loginUrl = ""
461+ setError(e[7 .. ^1])
462+ app.connecting = false
463+ app.status = "Not connected"
464+
368465 while true:
369466 let (ok, e) = tr.tryEvent()
370467 if not ok: break
@@ -14,8 +14,9 @@
14 import std/[json, options, strutils, tables]14 import std/[json, options, strutils, tables]
15 import std/sets15 import std/sets
16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17- atproto, handshake, textruns, members, msgsig, profile]17+ atproto, handshake, textruns, members, msgsig, profile, store]
18 import frq/conn as tr18 import frq/conn as tr
19+import frq/oauth as oa
19 20
20 proc split2(id: string): (string, string) =21 proc split2(id: string): (string, string) =
21 ## `"room.open:#test"` → `("room.open", "#test")`. The argument may itself22 ## `"room.open:#test"` → `("room.open", "#test")`. The argument may itself
@@ -49,6 +50,42 @@ proc openRoom(name: string) =
49 # Opening a room is reading it: the marker moves to the newest line here.50 # Opening a room is reading it: the marker moves to the newest line here.
50 app.rooms[name] = app.rooms[name].markRead51 app.rooms[name] = app.rooms[name].markRead
51 52
53+proc restore*() =
54+ ## What a previous run left on disk, back in the state.
55+ ##
56+ ## Only the sign-in: a broker token is what saves the reader a login page,
57+ ## and the mode goes with it because a remembered session is not much use
58+ ## sitting behind the Guest tab. The nick and handle come along so the
59+ ## screen says who it is about before the broker is asked.
60+ let (saved, had) = loadSession()
61+ if not had: return
62+ app.brokerToken = saved.brokerToken
63+ app.authMode = amBluesky
64+ if saved.handle.len > 0: app.formHandle = saved.handle
65+ if saved.nick.len > 0: app.formNick = saved.nick
66+ trace("oauth", "a saved session for " & saved.handle)
67+
68+proc adoptTokens(t: oa.Tokens) =
69+ ## A broker handoff, become an identity.
70+ ##
71+ ## The nick and the handle are set together for the reason the app-password
72+ ## path sets them together: the channel calls us one thing and the client
73+ ## believes another otherwise, and every "is this me?" test comes back
74+ ## false. The broker's `nick` wins where it sent one — it is what the server
75+ ## has already decided to call this DID.
76+ session = Session(kind: skWebToken, token: t.token,
77+ did: t.did, handle: t.handle)
78+ if t.handle.len > 0: app.formHandle = t.handle
79+ let nick = if t.nick.len > 0: t.nick else: t.handle
80+ if nick.len > 0: app.formNick = nick
81+ # Only the durable half is written: the web-token beside it is single-use
82+ # and would be a stale secret on disk by the time anything read it.
83+ app.brokerToken = t.brokerToken
84+ discard saveSession(SavedSession(brokerToken: t.brokerToken,
85+ handle: app.formHandle, did: t.did,
86+ nick: app.formNick))
87+ trace("oauth", "signed in as " & t.did)
88+
52 proc signIn(): bool =89 proc signIn(): bool =
53 ## Whatever identity was asked for, settled before the socket opens.90 ## Whatever identity was asked for, settled before the socket opens.
54 ##91 ##
@@ -83,19 +120,36 @@ proc signIn(): bool =
83 app.connecting = false120 app.connecting = false
84 false121 false
85 of amBluesky:122 of amBluesky:
86- # The broker flow needs a browser and a loopback listener to catch the123+ # A remembered broker token is the whole reason to keep one: it buys a
87- # redirect, and neither is ported. Said plainly rather than connecting as124+ # fresh web-token without a browser, so a second run connects with no
88- # a guest and looking like it worked.125+ # login page at all. Only when there is none does the browser open, and
89- setError("Bluesky OAuth is not wired up yet — use an app password, or connect as a guest.")126+ # that path does not finish here — `connectNow` is called again from the
90- false127+ # drain once the handoff lands.
128+ if app.brokerToken.len == 0:
129+ app.status = "Waiting for the browser…"
130+ oa.begin(oa.defaultBroker, app.formHandle)
131+ return false
132+ try:
133+ app.status = "Refreshing your sign-in…"
134+ adoptTokens(oa.refreshSession(oa.defaultBroker, app.brokerToken))
135+ true
136+ except CatchableError as e:
137+ # A token the broker no longer honours is worse than none: every
138+ # Connect would spend a round trip failing the same way. Dropped, and
139+ # the next press opens the browser.
140+ trace("oauth", "refresh failed: " & e.msg)
141+ app.brokerToken = ""
142+ clearSession()
143+ setError(e.msg)
144+ app.connecting = false
145+ false
91 146
92-proc connectNow() =147+proc openSocket() =
93- if app.formHost.strip().len == 0:148+ ## The connection itself, with whoever we are already settled.
94- setError("A server is required."); return149+ ##
95- if app.formNick.strip().len == 0 and app.authMode == amGuest:150+ ## Split from `connectNow` for the browser handoff: that path has signed in
96- setError("A nickname is required."); return151+ ## already, on a token that is single-use, and running `signIn` again would
97- app.hasError = false152+ ## spend a broker round trip replacing a session it is holding.
98- if not signIn(): return
99 app.connecting = true153 app.connecting = true
100 app.status = "Connecting to " & app.formHost & ":" & app.formPort &154 app.status = "Connecting to " & app.formHost & ":" & app.formPort &
101 (if app.formTls: " over TLS" else: "") & ""155 (if app.formTls: " over TLS" else: "") & ""
@@ -104,6 +158,15 @@ proc connectNow() =
104 tr.open(tr.ConnConfig(host: app.formHost.strip(), port: port,158 tr.open(tr.ConnConfig(host: app.formHost.strip(), port: port,
105 tls: app.formTls))159 tls: app.formTls))
106 160
161+proc connectNow() =
162+ if app.formHost.strip().len == 0:
163+ setError("A server is required."); return
164+ if app.formNick.strip().len == 0 and app.authMode == amGuest:
165+ setError("A nickname is required."); return
166+ app.hasError = false
167+ if not signIn(): return
168+ openSocket()
169+
107 proc sendDraft() =170 proc sendDraft() =
108 let text = app.draft.strip()171 let text = app.draft.strip()
109 if text.len == 0 or app.current.len == 0: return172 if text.len == 0 or app.current.len == 0: return
@@ -160,12 +223,19 @@ proc dispatch*(event: JsonNode) =
160 223
161 of "session.forget":224 of "session.forget":
162 app.brokerToken = ""225 app.brokerToken = ""
226+ clearSession()
227+ app.loginUrl = ""
228+ oa.cancel()
163 app.status = "Saved session forgotten."229 app.status = "Saved session forgotten."
164 230
165 # --------------------------------------------------------- the connection231 # --------------------------------------------------------- the connection
166 of "connect": connectNow()232 of "connect": connectNow()
167 233
168 of "cancel", "disconnect":234 of "cancel", "disconnect":
235+ # A browser wait is part of connecting, so Cancel ends it too — otherwise
236+ # a tab finished ten minutes later would sign in behind the reader.
237+ oa.cancel()
238+ app.loginUrl = ""
169 # The key goes with the connection, so a reconnect signs with one the239 # The key goes with the connection, so a reconnect signs with one the
170 # server has actually been told about.240 # server has actually been told about.
171 msgsig.forget()241 msgsig.forget()
@@ -365,6 +435,33 @@ proc note(room: string, m: Message) =
365 app.rooms[room] = r.recount(app.formNick)435 app.rooms[room] = r.recount(app.formNick)
366 436
367 proc drain*() =437 proc drain*() =
438+ # The browser handoff, before the socket: a sign-in that just landed should
439+ # open the connection in the same frame it arrived, rather than leaving the
440+ # screen saying "Waiting for the browser…" until the next one.
441+ while true:
442+ let (ok, e) = oa.tryEvent()
443+ if not ok: break
444+ if e.startsWith("url: "):
445+ # Shown under "If the browser did not open, visit:" — on a machine with
446+ # no xdg-open this is the whole of the flow the reader can see.
447+ app.loginUrl = e[5 .. ^1]
448+ elif e.startsWith("ok: "):
449+ oa.finished()
450+ app.loginUrl = ""
451+ try:
452+ adoptTokens(oa.tokensOf(e[4 .. ^1]))
453+ openSocket()
454+ except CatchableError as ex:
455+ setError(ex.msg)
456+ app.connecting = false
457+ app.status = "Not connected"
458+ elif e.startsWith("error: "):
459+ oa.finished()
460+ app.loginUrl = ""
461+ setError(e[7 .. ^1])
462+ app.connecting = false
463+ app.status = "Not connected"
464+
368 while true:465 while true:
369 let (ok, e) = tr.tryEvent()466 let (ok, e) = tr.tryEvent()
370 if not ok: break467 if not ok: break
modified nim/src/frq/screens/connect.nim +8 -7
@@ -54,12 +54,13 @@ func authFields(s: State): Node =
5454 of amBluesky:
5555 result = vbox(%*{"spacing": 6},
5656 title2("Sign in with Bluesky"),
57- # Says what it actually does today. The broker flow needs a browser and
58- # a loopback listener to catch the redirect, and neither is ported — a
59- # screen that describes the finished thing is a screen that lies.
60- dimLabel("Not wired up yet: the broker flow needs a browser and a " &
61- "loopback listener, and neither is ported. Use an app " &
62- "password, or connect as a guest."),
57+ # What actually happens, in the order it happens: a browser opens, the
58+ # login page is your PDS's own, and the app waits on a loopback port
59+ # for the answer. Worth saying because a window opening on its own is
60+ # otherwise alarming.
61+ dimLabel("Connect opens your browser at auth.freeq.at, which signs you " &
62+ "in with your own PDS and hands the result back. Your password " &
63+ "never reaches this app."),
6364 label("Handle"),
6465 entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
6566 width = 320))
@@ -74,7 +75,7 @@ func authFields(s: State): Node =
7475 var login = vbox(%*{"key": "login-url", "spacing": 4})
7576 if s.loginUrl.len > 0:
7677 login.children.add vbox(%*{"spacing": 4},
77- dimLabel("If the browser did not open, visit:"),
78+ dimLabel("Waiting for the browser. If it did not open, visit:"),
7879 label(s.loginUrl))
7980 result.children.add login
8081
@@ -54,12 +54,13 @@ func authFields(s: State): Node =
54 of amBluesky:54 of amBluesky:
55 result = vbox(%*{"spacing": 6},55 result = vbox(%*{"spacing": 6},
56 title2("Sign in with Bluesky"),56 title2("Sign in with Bluesky"),
57- # Says what it actually does today. The broker flow needs a browser and57+ # What actually happens, in the order it happens: a browser opens, the
58- # a loopback listener to catch the redirect, and neither is ported — a58+ # login page is your PDS's own, and the app waits on a loopback port
59- # screen that describes the finished thing is a screen that lies.59+ # for the answer. Worth saying because a window opening on its own is
60- dimLabel("Not wired up yet: the broker flow needs a browser and a " &60+ # otherwise alarming.
61- "loopback listener, and neither is ported. Use an app " &61+ dimLabel("Connect opens your browser at auth.freeq.at, which signs you " &
62- "password, or connect as a guest."),62+ "in with your own PDS and hands the result back. Your password " &
63+ "never reaches this app."),
63 label("Handle"),64 label("Handle"),
64 entry("handle", s.formHandle, "alice.bsky.social", "handle.change",65 entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
65 width = 320))66 width = 320))
@@ -74,7 +75,7 @@ func authFields(s: State): Node =
74 var login = vbox(%*{"key": "login-url", "spacing": 4})75 var login = vbox(%*{"key": "login-url", "spacing": 4})
75 if s.loginUrl.len > 0:76 if s.loginUrl.len > 0:
76 login.children.add vbox(%*{"spacing": 4},77 login.children.add vbox(%*{"spacing": 4},
77- dimLabel("If the browser did not open, visit:"),78+ dimLabel("Waiting for the browser. If it did not open, visit:"),
78 label(s.loginUrl))79 label(s.loginUrl))
79 result.children.add login80 result.children.add login
80 81
modified nim/src/frq/store.nim +1 -1
@@ -10,7 +10,7 @@
1010 ## count its whole history unread — which is worse than a room that starts
1111 ## over.
1212
13-import std/[json, os, strutils, tables]
13+import std/[json, os, tables]
1414 import frq/[model, trace]
1515
1616 type
@@ -10,7 +10,7 @@
10 ## count its whole history unread — which is worse than a room that starts10 ## count its whole history unread — which is worse than a room that starts
11 ## over.11 ## over.
12 12
13-import std/[json, os, strutils, tables]13+import std/[json, os, tables]
14 import frq/[model, trace]14 import frq/[model, trace]
15 15
16 type16 type
modified nim/src/frq_core.nim +11 -4
@@ -34,7 +34,7 @@ import frq/screens/chat as scChatScreen
3434 import frq/screens/settings as scSettingsScreen
3535
3636 proc frq_init*() {.exportc, dynlib.} =
37- ## Kept for the ABI, and deliberately empty.
37+ ## Reads the saved sign-in, and otherwise stays out of the way.
3838 ##
3939 ## It used to call `NimMain()`. On Linux `--app:lib` already emits a library
4040 ## constructor that runs Nim's module initialisers at dlopen, so calling it
@@ -43,9 +43,16 @@ proc frq_init*() {.exportc, dynlib.} =
4343 ## quietly resetting them. The reader thread then drained a different queue
4444 ## from the one the writer filled, and nothing this client sent ever left.
4545 ##
46- ## Nothing to do here, then, but the symbol stays: a binding that calls it
47- ## should keep working, and one that does not should not have to care.
48- discard
46+ ## So it stayed empty for a long time. What it does now is the one thing
47+ ## that genuinely belongs before the first frame and cannot go in
48+ ## `initState`, which is a `func` and touches no disk: restoring the broker
49+ ## token, so the connect screen opens saying the session is remembered
50+ ## rather than offering a login page the reader does not need.
51+ ##
52+ ## `frq_ui_reset` deliberately does not do this. It is the tests' entry
53+ ## point, and a suite that picked up whoever is signed in on the machine
54+ ## running it would pass or fail by accident.
55+ reducer.restore()
4956
5057 proc dup(s: string): cstring =
5158 ## A copy of `s` that outlives this call, for the caller to `frq_free`.
@@ -34,7 +34,7 @@ import frq/screens/chat as scChatScreen
34 import frq/screens/settings as scSettingsScreen34 import frq/screens/settings as scSettingsScreen
35 35
36 proc frq_init*() {.exportc, dynlib.} =36 proc frq_init*() {.exportc, dynlib.} =
37- ## Kept for the ABI, and deliberately empty.37+ ## Reads the saved sign-in, and otherwise stays out of the way.
38 ##38 ##
39 ## It used to call `NimMain()`. On Linux `--app:lib` already emits a library39 ## It used to call `NimMain()`. On Linux `--app:lib` already emits a library
40 ## constructor that runs Nim's module initialisers at dlopen, so calling it40 ## constructor that runs Nim's module initialisers at dlopen, so calling it
@@ -43,9 +43,16 @@ proc frq_init*() {.exportc, dynlib.} =
43 ## quietly resetting them. The reader thread then drained a different queue43 ## quietly resetting them. The reader thread then drained a different queue
44 ## from the one the writer filled, and nothing this client sent ever left.44 ## from the one the writer filled, and nothing this client sent ever left.
45 ##45 ##
46- ## Nothing to do here, then, but the symbol stays: a binding that calls it46+ ## So it stayed empty for a long time. What it does now is the one thing
47- ## should keep working, and one that does not should not have to care.47+ ## that genuinely belongs before the first frame and cannot go in
48- discard48+ ## `initState`, which is a `func` and touches no disk: restoring the broker
49+ ## token, so the connect screen opens saying the session is remembered
50+ ## rather than offering a login page the reader does not need.
51+ ##
52+ ## `frq_ui_reset` deliberately does not do this. It is the tests' entry
53+ ## point, and a suite that picked up whoever is signed in on the machine
54+ ## running it would pass or fail by accident.
55+ reducer.restore()
49 56
50 proc dup(s: string): cstring =57 proc dup(s: string): cstring =
51 ## A copy of `s` that outlives this call, for the caller to `frq_free`.58 ## A copy of `s` that outlives this call, for the caller to `frq_free`.
added nim/tests/toauth.nim +146 -0
new file mode 100644
@@ -0,0 +1,146 @@
1+## The broker flow, minus the browser: a URL in, a payload out.
2+##
3+## No network and no socket here. What is worth testing about this module is
4+## the encoding either end has to agree on — a handle in a query string, a
5+## base64url payload, a Content-Length header — and all of it is a string in
6+## and a string out.
7+
8+import std/[base64, httpclient, json, os, strutils, unittest]
9+import frq/oauth
10+
11+suite "urlEncode":
12+ test "the unreserved set goes through untouched":
13+ check urlEncode("alice.bsky.social") == "alice.bsky.social"
14+ check urlEncode("a-z_0.9~") == "a-z_0.9~"
15+ test "everything else is percent-encoded, in upper-case hex":
16+ check urlEncode("a b") == "a%20b"
17+ check urlEncode("a/b?c=d&e") == "a%2Fb%3Fc%3Dd%26e"
18+ check urlEncode("@alice") == "%40alice"
19+ test "a non-ASCII handle is encoded per byte, not per character":
20+ # é is two bytes in UTF-8, and a percent-encoder that passes it through
21+ # has not encoded anything.
22+ check urlEncode("café") == "caf%C3%A9"
23+
24+suite "loginUrl":
25+ test "handle and return_to are both encoded":
26+ check loginUrl("https://auth.freeq.at", "alice.bsky.social",
27+ "http://127.0.0.1:7391") ==
28+ "https://auth.freeq.at/auth/login?handle=alice.bsky.social" &
29+ "&return_to=http%3A%2F%2F127.0.0.1%3A7391"
30+ test "a trailing slash on the broker is not doubled":
31+ check loginUrl("https://auth.freeq.at/", "a.uk", "x").startsWith(
32+ "https://auth.freeq.at/auth/login?")
33+ test "an empty broker is the default one":
34+ check loginUrl("", "a.uk", "x").startsWith(defaultBroker & "/auth/login?")
35+ test "a leading @ is how it is written beside a message, not part of it":
36+ check "handle=alice.uk&" in loginUrl("", "@alice.uk", "x")
37+ test "and so is the whitespace around a pasted handle":
38+ check "handle=alice.uk&" in loginUrl("", " alice.uk ", "x")
39+
40+suite "brokerHost":
41+ test "the host alone, whatever the scheme":
42+ check brokerHost("https://auth.freeq.at") == "auth.freeq.at"
43+ check brokerHost("http://localhost:8080/x") == "localhost:8080"
44+ check brokerHost("") == "auth.freeq.at"
45+
46+proc payload(j: JsonNode): string =
47+ ## What the broker puts in the fragment: base64url, unpadded.
48+ encode($j).replace("+", "-").replace("/", "_").replace("=", "")
49+
50+suite "tokensOf":
51+ test "a full payload becomes fields":
52+ let t = tokensOf(payload(%*{"token": "web", "broker_token": "durable",
53+ "nick": "alice", "did": "did:plc:a",
54+ "handle": "alice.uk"}))
55+ check t.token == "web"
56+ check t.brokerToken == "durable"
57+ check t.nick == "alice"
58+ check t.did == "did:plc:a"
59+ check t.handle == "alice.uk"
60+ test "surrounding whitespace is the browser's, not the payload's":
61+ check tokensOf(" " & payload(%*{"token": "a", "broker_token": "b"}) &
62+ "\n").token == "a"
63+ test "either token missing is a failure, not a half sign-in":
64+ expect OauthError: discard tokensOf(payload(%*{"token": "web"}))
65+ expect OauthError: discard tokensOf(payload(%*{"broker_token": "d"}))
66+ test "and the broker's own reason is what gets raised":
67+ try:
68+ discard tokensOf(payload(%*{"error": "that handle has no account"}))
69+ check false
70+ except OauthError as e:
71+ check e.msg == "that handle has no account"
72+ test "something that is not base64url JSON at all":
73+ expect OauthError: discard tokensOf("not-a-payload")
74+ expect OauthError: discard tokensOf("")
75+
76+suite "contentLengthOf":
77+ test "the header, however the client capitalised it":
78+ check contentLengthOf("POST /capture\r\nContent-Length: 42\r\n\r\n") == 42
79+ check contentLengthOf("POST /capture\r\ncontent-length: 7\r\n\r\n") == 7
80+ test "no header is no body":
81+ check contentLengthOf("GET / HTTP/1.1\r\nHost: x\r\n\r\n") == 0
82+ test "and a header that is not a number does not throw":
83+ check contentLengthOf("POST /\r\nContent-Length: banana\r\n\r\n") == 0
84+
85+suite "httpResponse":
86+ test "the length is the body's, in bytes":
87+ let r = httpResponse("200 OK", "text/plain", "héllo")
88+ check "Content-Length: 6" in r # é is two bytes
89+ check r.startsWith("HTTP/1.1 200 OK\r\n")
90+ check r.endsWith("\r\n\r\nhéllo")
91+
92+suite "captureHtml":
93+ test "posts the fragment back, because a fragment never reaches a server":
94+ let h = captureHtml()
95+ check "location.hash" in h
96+ check "'/capture'" in h
97+ check "method:'POST'" in h
98+
99+suite "the loopback listener":
100+ # The one part of this that is not a string in and a string out. It binds a
101+ # port, serves the capture page, and waits — so the test is a real browser's
102+ # side of the handoff: fetch the page, post the fragment back, and see the
103+ # tokens come out of the channel.
104+ #
105+ # No browser is opened. `begin` takes that as a parameter for this test
106+ # alone; the URL it would have opened comes out on the channel regardless,
107+ # and is what these requests are aimed at.
108+ test "serves the page, ignores junk, and completes on a real payload":
109+ let good = payload(%*{"token": "web", "broker_token": "durable",
110+ "nick": "alice", "did": "did:plc:a",
111+ "handle": "alice.uk"})
112+ begin(defaultBroker, "alice.uk", openBrowser = false)
113+ defer: finished()
114+
115+ var url: string
116+ for _ in 0 .. 200:
117+ let (ok, e) = tryEvent()
118+ if ok and e.startsWith("url: "): url = e[5 .. ^1]; break
119+ sleep(25)
120+ require url.len > 0
121+
122+ # The `return_to` we handed the broker is the address to talk to.
123+ let here = url.split("return_to=")[1]
124+ .replace("%3A", ":").replace("%2F", "/")
125+ let c = newHttpClient(timeout = 5000)
126+ defer: c.close()
127+
128+ # A GET is the browser landing on us: it gets the page whose script posts
129+ # the fragment back.
130+ check "location.hash" in c.getContent(here)
131+
132+ # A POST carrying nothing usable is not the end of the wait — the real
133+ # handoff may still be on its way.
134+ check c.request(here & "/capture", httpMethod = HttpPost,
135+ body = "garbage").status.startsWith("400")
136+ check not tryEvent()[0]
137+
138+ check c.request(here & "/capture", httpMethod = HttpPost,
139+ body = good).body == "ok"
140+ var got: string
141+ for _ in 0 .. 200:
142+ let (ok, e) = tryEvent()
143+ if ok: got = e; break
144+ sleep(25)
145+ require got.startsWith("ok: ")
146+ check tokensOf(got[4 .. ^1]).brokerToken == "durable"
new file mode 100644
@@ -0,0 +1,146 @@
1+## The broker flow, minus the browser: a URL in, a payload out.
2+##
3+## No network and no socket here. What is worth testing about this module is
4+## the encoding either end has to agree on — a handle in a query string, a
5+## base64url payload, a Content-Length header — and all of it is a string in
6+## and a string out.
7+
8+import std/[base64, httpclient, json, os, strutils, unittest]
9+import frq/oauth
10+
11+suite "urlEncode":
12+ test "the unreserved set goes through untouched":
13+ check urlEncode("alice.bsky.social") == "alice.bsky.social"
14+ check urlEncode("a-z_0.9~") == "a-z_0.9~"
15+ test "everything else is percent-encoded, in upper-case hex":
16+ check urlEncode("a b") == "a%20b"
17+ check urlEncode("a/b?c=d&e") == "a%2Fb%3Fc%3Dd%26e"
18+ check urlEncode("@alice") == "%40alice"
19+ test "a non-ASCII handle is encoded per byte, not per character":
20+ # é is two bytes in UTF-8, and a percent-encoder that passes it through
21+ # has not encoded anything.
22+ check urlEncode("café") == "caf%C3%A9"
23+
24+suite "loginUrl":
25+ test "handle and return_to are both encoded":
26+ check loginUrl("https://auth.freeq.at", "alice.bsky.social",
27+ "http://127.0.0.1:7391") ==
28+ "https://auth.freeq.at/auth/login?handle=alice.bsky.social" &
29+ "&return_to=http%3A%2F%2F127.0.0.1%3A7391"
30+ test "a trailing slash on the broker is not doubled":
31+ check loginUrl("https://auth.freeq.at/", "a.uk", "x").startsWith(
32+ "https://auth.freeq.at/auth/login?")
33+ test "an empty broker is the default one":
34+ check loginUrl("", "a.uk", "x").startsWith(defaultBroker & "/auth/login?")
35+ test "a leading @ is how it is written beside a message, not part of it":
36+ check "handle=alice.uk&" in loginUrl("", "@alice.uk", "x")
37+ test "and so is the whitespace around a pasted handle":
38+ check "handle=alice.uk&" in loginUrl("", " alice.uk ", "x")
39+
40+suite "brokerHost":
41+ test "the host alone, whatever the scheme":
42+ check brokerHost("https://auth.freeq.at") == "auth.freeq.at"
43+ check brokerHost("http://localhost:8080/x") == "localhost:8080"
44+ check brokerHost("") == "auth.freeq.at"
45+
46+proc payload(j: JsonNode): string =
47+ ## What the broker puts in the fragment: base64url, unpadded.
48+ encode($j).replace("+", "-").replace("/", "_").replace("=", "")
49+
50+suite "tokensOf":
51+ test "a full payload becomes fields":
52+ let t = tokensOf(payload(%*{"token": "web", "broker_token": "durable",
53+ "nick": "alice", "did": "did:plc:a",
54+ "handle": "alice.uk"}))
55+ check t.token == "web"
56+ check t.brokerToken == "durable"
57+ check t.nick == "alice"
58+ check t.did == "did:plc:a"
59+ check t.handle == "alice.uk"
60+ test "surrounding whitespace is the browser's, not the payload's":
61+ check tokensOf(" " & payload(%*{"token": "a", "broker_token": "b"}) &
62+ "\n").token == "a"
63+ test "either token missing is a failure, not a half sign-in":
64+ expect OauthError: discard tokensOf(payload(%*{"token": "web"}))
65+ expect OauthError: discard tokensOf(payload(%*{"broker_token": "d"}))
66+ test "and the broker's own reason is what gets raised":
67+ try:
68+ discard tokensOf(payload(%*{"error": "that handle has no account"}))
69+ check false
70+ except OauthError as e:
71+ check e.msg == "that handle has no account"
72+ test "something that is not base64url JSON at all":
73+ expect OauthError: discard tokensOf("not-a-payload")
74+ expect OauthError: discard tokensOf("")
75+
76+suite "contentLengthOf":
77+ test "the header, however the client capitalised it":
78+ check contentLengthOf("POST /capture\r\nContent-Length: 42\r\n\r\n") == 42
79+ check contentLengthOf("POST /capture\r\ncontent-length: 7\r\n\r\n") == 7
80+ test "no header is no body":
81+ check contentLengthOf("GET / HTTP/1.1\r\nHost: x\r\n\r\n") == 0
82+ test "and a header that is not a number does not throw":
83+ check contentLengthOf("POST /\r\nContent-Length: banana\r\n\r\n") == 0
84+
85+suite "httpResponse":
86+ test "the length is the body's, in bytes":
87+ let r = httpResponse("200 OK", "text/plain", "héllo")
88+ check "Content-Length: 6" in r # é is two bytes
89+ check r.startsWith("HTTP/1.1 200 OK\r\n")
90+ check r.endsWith("\r\n\r\nhéllo")
91+
92+suite "captureHtml":
93+ test "posts the fragment back, because a fragment never reaches a server":
94+ let h = captureHtml()
95+ check "location.hash" in h
96+ check "'/capture'" in h
97+ check "method:'POST'" in h
98+
99+suite "the loopback listener":
100+ # The one part of this that is not a string in and a string out. It binds a
101+ # port, serves the capture page, and waits — so the test is a real browser's
102+ # side of the handoff: fetch the page, post the fragment back, and see the
103+ # tokens come out of the channel.
104+ #
105+ # No browser is opened. `begin` takes that as a parameter for this test
106+ # alone; the URL it would have opened comes out on the channel regardless,
107+ # and is what these requests are aimed at.
108+ test "serves the page, ignores junk, and completes on a real payload":
109+ let good = payload(%*{"token": "web", "broker_token": "durable",
110+ "nick": "alice", "did": "did:plc:a",
111+ "handle": "alice.uk"})
112+ begin(defaultBroker, "alice.uk", openBrowser = false)
113+ defer: finished()
114+
115+ var url: string
116+ for _ in 0 .. 200:
117+ let (ok, e) = tryEvent()
118+ if ok and e.startsWith("url: "): url = e[5 .. ^1]; break
119+ sleep(25)
120+ require url.len > 0
121+
122+ # The `return_to` we handed the broker is the address to talk to.
123+ let here = url.split("return_to=")[1]
124+ .replace("%3A", ":").replace("%2F", "/")
125+ let c = newHttpClient(timeout = 5000)
126+ defer: c.close()
127+
128+ # A GET is the browser landing on us: it gets the page whose script posts
129+ # the fragment back.
130+ check "location.hash" in c.getContent(here)
131+
132+ # A POST carrying nothing usable is not the end of the wait — the real
133+ # handoff may still be on its way.
134+ check c.request(here & "/capture", httpMethod = HttpPost,
135+ body = "garbage").status.startsWith("400")
136+ check not tryEvent()[0]
137+
138+ check c.request(here & "/capture", httpMethod = HttpPost,
139+ body = good).body == "ok"
140+ var got: string
141+ for _ in 0 .. 200:
142+ let (ok, e) = tryEvent()
143+ if ok: got = e; break
144+ sleep(25)
145+ require got.startsWith("ok: ")
146+ check tokensOf(got[4 .. ^1]).brokerToken == "durable"
modified nim/tests/tscreens.nim +7 -3
@@ -46,10 +46,14 @@ suite "the connect screen":
4646 check "handle" in t.keys("entry")
4747 check "nick" notin t.keys("entry")
4848
49- test "and admits the broker flow is not wired up":
50- # A screen that describes the finished thing is a screen that lies.
49+ test "and says what Connect will actually do":
50+ # This used to assert the opposite — that the screen admitted the broker
51+ # flow was not wired up — which was the honest copy while it was not.
52+ # A window opening on its own is alarming without a line saying it will.
5153 s.authMode = amBluesky
52- check cs.connectScreen(s).labels("dim-label").anyIt("Not wired up yet" in it)
54+ let dim = cs.connectScreen(s).labels("dim-label")
55+ check dim.anyIt("opens your browser" in it)
56+ check dim.anyIt("never reaches this app" in it)
5357
5458 test "app-password asks for both, and says where to make one":
5559 s.authMode = amAppPassword
@@ -46,10 +46,14 @@ suite "the connect screen":
46 check "handle" in t.keys("entry")46 check "handle" in t.keys("entry")
47 check "nick" notin t.keys("entry")47 check "nick" notin t.keys("entry")
48 48
49- test "and admits the broker flow is not wired up":49+ test "and says what Connect will actually do":
50- # A screen that describes the finished thing is a screen that lies.50+ # This used to assert the opposite — that the screen admitted the broker
51+ # flow was not wired up — which was the honest copy while it was not.
52+ # A window opening on its own is alarming without a line saying it will.
51 s.authMode = amBluesky53 s.authMode = amBluesky
52- check cs.connectScreen(s).labels("dim-label").anyIt("Not wired up yet" in it)54+ let dim = cs.connectScreen(s).labels("dim-label")
55+ check dim.anyIt("opens your browser" in it)
56+ check dim.anyIt("never reaches this app" in it)
53 57
54 test "app-password asks for both, and says where to make one":58 test "app-password asks for both, and says where to make one":
55 s.authMode = amAppPassword59 s.authMode = amAppPassword