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>
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 @@ | ||
| 14 | 14 | import std/[json, options, strutils, tables] |
| 15 | 15 | import std/sets |
| 16 | 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 | 18 | import frq/conn as tr |
| 19 | +import frq/oauth as oa | |
| 19 | 20 | |
| 20 | 21 | proc split2(id: string): (string, string) = |
| 21 | 22 | ## `"room.open:#test"` → `("room.open", "#test")`. The argument may itself |
| @@ -49,6 +50,42 @@ proc openRoom(name: string) = | ||
| 49 | 50 | # Opening a room is reading it: the marker moves to the newest line here. |
| 50 | 51 | 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 | 89 | proc signIn(): bool = |
| 53 | 90 | ## Whatever identity was asked for, settled before the socket opens. |
| 54 | 91 | ## |
| @@ -83,19 +120,36 @@ proc signIn(): bool = | ||
| 83 | 120 | app.connecting = false |
| 84 | 121 | false |
| 85 | 122 | 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 | |
| 91 | 146 | |
| 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. | |
| 99 | 153 | app.connecting = true |
| 100 | 154 | app.status = "Connecting to " & app.formHost & ":" & app.formPort & |
| 101 | 155 | (if app.formTls: " over TLS" else: "") & "…" |
| @@ -104,6 +158,15 @@ proc connectNow() = | ||
| 104 | 158 | tr.open(tr.ConnConfig(host: app.formHost.strip(), port: port, |
| 105 | 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 | 170 | proc sendDraft() = |
| 108 | 171 | let text = app.draft.strip() |
| 109 | 172 | if text.len == 0 or app.current.len == 0: return |
| @@ -160,12 +223,19 @@ proc dispatch*(event: JsonNode) = | ||
| 160 | 223 | |
| 161 | 224 | of "session.forget": |
| 162 | 225 | app.brokerToken = "" |
| 226 | + clearSession() | |
| 227 | + app.loginUrl = "" | |
| 228 | + oa.cancel() | |
| 163 | 229 | app.status = "Saved session forgotten." |
| 164 | 230 | |
| 165 | 231 | # --------------------------------------------------------- the connection |
| 166 | 232 | of "connect": connectNow() |
| 167 | 233 | |
| 168 | 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 | 239 | # The key goes with the connection, so a reconnect signs with one the |
| 170 | 240 | # server has actually been told about. |
| 171 | 241 | msgsig.forget() |
| @@ -365,6 +435,33 @@ proc note(room: string, m: Message) = | ||
| 365 | 435 | app.rooms[room] = r.recount(app.formNick) |
| 366 | 436 | |
| 367 | 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 | 465 | while true: |
| 369 | 466 | let (ok, e) = tr.tryEvent() |
| 370 | 467 | 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/sets | 15 | 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 tr | 18 | 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 itself | 22 | ## `"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].markRead | 51 | 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 = false | 120 | app.connecting = false |
| 84 | false | 121 | false |
| 85 | of amBluesky: | 122 | of amBluesky: |
| 86 | - # The broker flow needs a browser and a loopback listener to catch the | 123 | + # 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 as | 124 | + # 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 | - false | 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 | ||
| 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."); return | 149 | + ## |
| 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."); return | 151 | + ## already, on a token that is single-use, and running `signIn` again would |
| 97 | - app.hasError = false | 152 | + ## spend a broker round trip replacing a session it is holding. |
| 98 | - if not signIn(): return | ||
| 99 | app.connecting = true | 153 | 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: return | 172 | 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 connection | 231 | # --------------------------------------------------------- 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 the | 239 | # 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: break | 467 | if not ok: break |
modified
nim/src/frq/screens/connect.nim +8 -7 | @@ -54,12 +54,13 @@ func authFields(s: State): Node = | ||
| 54 | 54 | of amBluesky: |
| 55 | 55 | result = vbox(%*{"spacing": 6}, |
| 56 | 56 | 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."), | |
| 63 | 64 | label("Handle"), |
| 64 | 65 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", |
| 65 | 66 | width = 320)) |
| @@ -74,7 +75,7 @@ func authFields(s: State): Node = | ||
| 74 | 75 | var login = vbox(%*{"key": "login-url", "spacing": 4}) |
| 75 | 76 | if s.loginUrl.len > 0: |
| 76 | 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 | 79 | label(s.loginUrl)) |
| 79 | 80 | result.children.add login |
| 80 | 81 | |
| @@ -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 and | 57 | + # What actually happens, in the order it happens: a browser opens, the |
| 58 | - # a loopback listener to catch the redirect, and neither is ported — a | 58 | + # 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 login | 80 | result.children.add login |
| 80 | 81 | ||
modified
nim/src/frq/store.nim +1 -1 | @@ -10,7 +10,7 @@ | ||
| 10 | 10 | ## count its whole history unread — which is worse than a room that starts |
| 11 | 11 | ## over. |
| 12 | 12 | |
| 13 | -import std/[json, os, strutils, tables] | |
| 13 | +import std/[json, os, tables] | |
| 14 | 14 | import frq/[model, trace] |
| 15 | 15 | |
| 16 | 16 | type |
| @@ -10,7 +10,7 @@ | |||
| 10 | ## count its whole history unread — which is worse than a room that starts | 10 | ## 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 | type | 16 | type |
modified
nim/src/frq_core.nim +11 -4 | @@ -34,7 +34,7 @@ import frq/screens/chat as scChatScreen | ||
| 34 | 34 | import frq/screens/settings as scSettingsScreen |
| 35 | 35 | |
| 36 | 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 | 39 | ## It used to call `NimMain()`. On Linux `--app:lib` already emits a library |
| 40 | 40 | ## constructor that runs Nim's module initialisers at dlopen, so calling it |
| @@ -43,9 +43,16 @@ proc frq_init*() {.exportc, dynlib.} = | ||
| 43 | 43 | ## quietly resetting them. The reader thread then drained a different queue |
| 44 | 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 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() | |
| 49 | 56 | |
| 50 | 57 | proc dup(s: string): cstring = |
| 51 | 58 | ## 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 scSettingsScreen | 34 | 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 library | 39 | ## 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 it | 40 | ## 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 queue | 43 | ## 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 it | 46 | + ## 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 | - discard | 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() | ||
| 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": | ||
| 46 | 46 | check "handle" in t.keys("entry") |
| 47 | 47 | check "nick" notin t.keys("entry") |
| 48 | 48 | |
| 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. | |
| 51 | 53 | 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 | 58 | test "app-password asks for both, and says where to make one": |
| 55 | 59 | 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 = amBluesky | 53 | 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 = amAppPassword | 59 | s.authMode = amAppPassword |