What a thing is, apart from how it is fetched
`oauthcore` and `atprotocore` hold the parts that are the same wherever this runs: the login URL and the handoff payload; what a session is, the SASL response built from it, and base64url. `oauth` and `atproto` keep the halves that talk — a loopback listener, a browser to open, an HTTPS round trip. This is the shape `common/frq/oauth/core.cljc` had, against two platform halves, and it is back for the reason it existed: a browser catches the broker's answer by *being* the page that was redirected, and resolves an identity with `fetch`, which is asynchronous where this code is not. The handshake now takes `atprotocore` rather than `atproto`, so the thing that builds a SASL payload no longer imports an HTTP client to do it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0fee54c parent: 3836fee modified
nim/src/frq/atproto.nim +8 -69 | @@ -1,44 +1,14 @@ | ||
| 1 | -## handle → DID → PDS → session, and the SASL payload freeq takes. | |
| 1 | +## Resolving an identity, over HTTPS. | |
| 2 | 2 | ## |
| 3 | -## From `common/frq/atproto/core.cljc`. That file is split into `-req` and | |
| 4 | -## `-parse` pairs because ClojureDart had no portable HTTP client and the host | |
| 5 | -## had to make the call in between; Nim has `std/httpclient`, so the round | |
| 6 | -## trips are here whole and the seam is gone. | |
| 7 | -## | |
| 8 | -## The JSON is `std/json` rather than the hand-rolled string scanner the | |
| 9 | -## Clojure uses. That scanner exists because two compilers disagreed about | |
| 10 | -## JSON and neither could be depended on; one language has one JSON. | |
| 3 | +## The half that talks: a handle to a DID, a DID to its PDS, an app-password | |
| 4 | +## sign-in, and a profile. `atprotocore` holds what a session *is* and the | |
| 5 | +## SASL payload built from it, which is the same everywhere and is what the | |
| 6 | +## handshake needs. | |
| 11 | 7 | |
| 12 | -import std/[base64, httpclient, json, net, strutils] | |
| 8 | +import std/[httpclient, json, net, strutils] | |
| 13 | 9 | import frq/[trace, eintr] |
| 14 | - | |
| 15 | -const | |
| 16 | - directoryHost* = "public.api.bsky.app" | |
| 17 | - plcHost* = "plc.directory" | |
| 18 | - | |
| 19 | -type | |
| 20 | - SessionKind* = enum | |
| 21 | - skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token" | |
| 22 | - | |
| 23 | - Session* = object | |
| 24 | - kind*: SessionKind | |
| 25 | - did*: string | |
| 26 | - handle*: string | |
| 27 | - accessJwt*: string | |
| 28 | - pds*: string | |
| 29 | - token*: string ## a web-token from the broker, where that is the kind | |
| 30 | - | |
| 31 | - AtprotoError* = object of CatchableError | |
| 32 | - | |
| 33 | -proc b64url*(s: string): string = | |
| 34 | - ## base64url, unpadded — what AUTHENTICATE carries. | |
| 35 | - s.encode().replace("+", "-").replace("/", "_").replace("=", "") | |
| 36 | - | |
| 37 | -proc b64urlDecode*(s: string): string = | |
| 38 | - var t = s.replace("-", "+").replace("_", "/") | |
| 39 | - # `decode` wants the padding the wire form drops. | |
| 40 | - while t.len mod 4 != 0: t.add '=' | |
| 41 | - try: decode(t) except CatchableError: "" | |
| 10 | +import frq/atprotocore | |
| 11 | +export atprotocore | |
| 42 | 12 | |
| 43 | 13 | proc newClient(): HttpClient = |
| 44 | 14 | ## No socket timeout, and that is not an oversight: Linux never restarts a |
| @@ -75,13 +45,6 @@ proc getJson(url, whatFor: string): JsonNode = | ||
| 75 | 45 | if c != nil: |
| 76 | 46 | try: c.close() except CatchableError: discard |
| 77 | 47 | |
| 78 | -func hostOf*(url: string): string = | |
| 79 | - var u = url | |
| 80 | - for scheme in ["https://", "http://"]: | |
| 81 | - if u.startsWith(scheme): u = u[scheme.len .. ^1] | |
| 82 | - let i = u.find('/') | |
| 83 | - if i < 0: u else: u[0 ..< i] | |
| 84 | - | |
| 85 | 48 | proc resolveHandle*(handle: string): string = |
| 86 | 49 | ## A handle to a DID. One that is already a DID needs no call and passes |
| 87 | 50 | ## through, which is why this is not simply a request builder. |
| @@ -177,27 +140,3 @@ proc createSession*(handle, password: string): Session = | ||
| 177 | 140 | if c != nil: |
| 178 | 141 | try: c.close() except CatchableError: discard |
| 179 | 142 | |
| 180 | -proc saslResponse*(s: Session, nonce: string): string = | |
| 181 | - ## The base64url SASL payload, for either kind freeq takes. | |
| 182 | - ## | |
| 183 | - ## A pds-session carries the PDS token, the DID it belongs to, its PDS, and | |
| 184 | - ## the server's own nonce echoed back so the token cannot be replayed at | |
| 185 | - ## another server. A web-token from the broker carries only the token — the | |
| 186 | - ## server looks the DID up in its own store, which is why the field is sent | |
| 187 | - ## empty rather than guessed at. | |
| 188 | - case s.kind | |
| 189 | - of skWebToken: | |
| 190 | - b64url($(%*{"did": "", "method": "web-token", "signature": s.token})) | |
| 191 | - else: | |
| 192 | - b64url($(%*{"did": s.did, | |
| 193 | - "signature": s.accessJwt, | |
| 194 | - "method": "pds-session", | |
| 195 | - "pds_url": s.pds, | |
| 196 | - "challenge_nonce": nonce})) | |
| 197 | - | |
| 198 | -proc nonceOf*(challenge: string): string = | |
| 199 | - ## The nonce out of the server's AUTHENTICATE challenge, which is a | |
| 200 | - ## base64url JSON object. | |
| 201 | - let raw = b64urlDecode(challenge) | |
| 202 | - if raw.len == 0: return "" | |
| 203 | - try: parseJson(raw){"nonce"}.getStr() except CatchableError: "" | |
| @@ -1,44 +1,14 @@ | |||
| 1 | -## handle → DID → PDS → session, and the SASL payload freeq takes. | 1 | +## Resolving an identity, over HTTPS. |
| 2 | ## | 2 | ## |
| 3 | -## From `common/frq/atproto/core.cljc`. That file is split into `-req` and | 3 | +## The half that talks: a handle to a DID, a DID to its PDS, an app-password |
| 4 | -## `-parse` pairs because ClojureDart had no portable HTTP client and the host | 4 | +## sign-in, and a profile. `atprotocore` holds what a session *is* and the |
| 5 | -## had to make the call in between; Nim has `std/httpclient`, so the round | 5 | +## SASL payload built from it, which is the same everywhere and is what the |
| 6 | -## trips are here whole and the seam is gone. | 6 | +## handshake needs. |
| 7 | -## | ||
| 8 | -## The JSON is `std/json` rather than the hand-rolled string scanner the | ||
| 9 | -## Clojure uses. That scanner exists because two compilers disagreed about | ||
| 10 | -## JSON and neither could be depended on; one language has one JSON. | ||
| 11 | 7 | ||
| 12 | -import std/[base64, httpclient, json, net, strutils] | 8 | +import std/[httpclient, json, net, strutils] |
| 13 | import frq/[trace, eintr] | 9 | import frq/[trace, eintr] |
| 14 | - | 10 | +import frq/atprotocore |
| 15 | -const | 11 | +export atprotocore |
| 16 | - directoryHost* = "public.api.bsky.app" | ||
| 17 | - plcHost* = "plc.directory" | ||
| 18 | - | ||
| 19 | -type | ||
| 20 | - SessionKind* = enum | ||
| 21 | - skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token" | ||
| 22 | - | ||
| 23 | - Session* = object | ||
| 24 | - kind*: SessionKind | ||
| 25 | - did*: string | ||
| 26 | - handle*: string | ||
| 27 | - accessJwt*: string | ||
| 28 | - pds*: string | ||
| 29 | - token*: string ## a web-token from the broker, where that is the kind | ||
| 30 | - | ||
| 31 | - AtprotoError* = object of CatchableError | ||
| 32 | - | ||
| 33 | -proc b64url*(s: string): string = | ||
| 34 | - ## base64url, unpadded — what AUTHENTICATE carries. | ||
| 35 | - s.encode().replace("+", "-").replace("/", "_").replace("=", "") | ||
| 36 | - | ||
| 37 | -proc b64urlDecode*(s: string): string = | ||
| 38 | - var t = s.replace("-", "+").replace("_", "/") | ||
| 39 | - # `decode` wants the padding the wire form drops. | ||
| 40 | - while t.len mod 4 != 0: t.add '=' | ||
| 41 | - try: decode(t) except CatchableError: "" | ||
| 42 | 12 | ||
| 43 | proc newClient(): HttpClient = | 13 | proc newClient(): HttpClient = |
| 44 | ## No socket timeout, and that is not an oversight: Linux never restarts a | 14 | ## No socket timeout, and that is not an oversight: Linux never restarts a |
| @@ -75,13 +45,6 @@ proc getJson(url, whatFor: string): JsonNode = | |||
| 75 | if c != nil: | 45 | if c != nil: |
| 76 | try: c.close() except CatchableError: discard | 46 | try: c.close() except CatchableError: discard |
| 77 | 47 | ||
| 78 | -func hostOf*(url: string): string = | ||
| 79 | - var u = url | ||
| 80 | - for scheme in ["https://", "http://"]: | ||
| 81 | - if u.startsWith(scheme): u = u[scheme.len .. ^1] | ||
| 82 | - let i = u.find('/') | ||
| 83 | - if i < 0: u else: u[0 ..< i] | ||
| 84 | - | ||
| 85 | proc resolveHandle*(handle: string): string = | 48 | proc resolveHandle*(handle: string): string = |
| 86 | ## A handle to a DID. One that is already a DID needs no call and passes | 49 | ## A handle to a DID. One that is already a DID needs no call and passes |
| 87 | ## through, which is why this is not simply a request builder. | 50 | ## through, which is why this is not simply a request builder. |
| @@ -177,27 +140,3 @@ proc createSession*(handle, password: string): Session = | |||
| 177 | if c != nil: | 140 | if c != nil: |
| 178 | try: c.close() except CatchableError: discard | 141 | try: c.close() except CatchableError: discard |
| 179 | 142 | ||
| 180 | -proc saslResponse*(s: Session, nonce: string): string = | ||
| 181 | - ## The base64url SASL payload, for either kind freeq takes. | ||
| 182 | - ## | ||
| 183 | - ## A pds-session carries the PDS token, the DID it belongs to, its PDS, and | ||
| 184 | - ## the server's own nonce echoed back so the token cannot be replayed at | ||
| 185 | - ## another server. A web-token from the broker carries only the token — the | ||
| 186 | - ## server looks the DID up in its own store, which is why the field is sent | ||
| 187 | - ## empty rather than guessed at. | ||
| 188 | - case s.kind | ||
| 189 | - of skWebToken: | ||
| 190 | - b64url($(%*{"did": "", "method": "web-token", "signature": s.token})) | ||
| 191 | - else: | ||
| 192 | - b64url($(%*{"did": s.did, | ||
| 193 | - "signature": s.accessJwt, | ||
| 194 | - "method": "pds-session", | ||
| 195 | - "pds_url": s.pds, | ||
| 196 | - "challenge_nonce": nonce})) | ||
| 197 | - | ||
| 198 | -proc nonceOf*(challenge: string): string = | ||
| 199 | - ## The nonce out of the server's AUTHENTICATE challenge, which is a | ||
| 200 | - ## base64url JSON object. | ||
| 201 | - let raw = b64urlDecode(challenge) | ||
| 202 | - if raw.len == 0: return "" | ||
| 203 | - try: parseJson(raw){"nonce"}.getStr() except CatchableError: "" | ||
added
nim/src/frq/atprotocore.nim +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +## What an AT Protocol identity is, and what freeq is told about it. | |
| 2 | +## | |
| 3 | +## The pure half: the session an identity becomes, the SASL payload built | |
| 4 | +## from it, and the base64url both ends agree on. `atproto` itself does the | |
| 5 | +## HTTP, and is the half a browser replaces — `fetch` is asynchronous where | |
| 6 | +## this is not, so on the web the host resolves an identity and hands the | |
| 7 | +## answer in rather than being called into. | |
| 8 | + | |
| 9 | +import std/[base64, json, strutils] | |
| 10 | + | |
| 11 | +const | |
| 12 | + directoryHost* = "public.api.bsky.app" | |
| 13 | + plcHost* = "plc.directory" | |
| 14 | + | |
| 15 | +type | |
| 16 | + SessionKind* = enum | |
| 17 | + skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token" | |
| 18 | + | |
| 19 | + Session* = object | |
| 20 | + kind*: SessionKind | |
| 21 | + did*: string | |
| 22 | + handle*: string | |
| 23 | + accessJwt*: string | |
| 24 | + pds*: string | |
| 25 | + token*: string ## a web-token from the broker, where that is the kind | |
| 26 | + | |
| 27 | + AtprotoError* = object of CatchableError | |
| 28 | + | |
| 29 | +proc b64url*(s: string): string = | |
| 30 | + ## base64url, unpadded — what AUTHENTICATE carries. | |
| 31 | + s.encode().replace("+", "-").replace("/", "_").replace("=", "") | |
| 32 | + | |
| 33 | +proc b64urlDecode*(s: string): string = | |
| 34 | + var t = s.replace("-", "+").replace("_", "/") | |
| 35 | + # `decode` wants the padding the wire form drops. | |
| 36 | + while t.len mod 4 != 0: t.add '=' | |
| 37 | + try: decode(t) except CatchableError: "" | |
| 38 | + | |
| 39 | +func hostOf*(url: string): string = | |
| 40 | + var u = url | |
| 41 | + for scheme in ["https://", "http://"]: | |
| 42 | + if u.startsWith(scheme): u = u[scheme.len .. ^1] | |
| 43 | + let i = u.find('/') | |
| 44 | + if i < 0: u else: u[0 ..< i] | |
| 45 | + | |
| 46 | +proc saslResponse*(s: Session, nonce: string): string = | |
| 47 | + ## The base64url SASL payload, for either kind freeq takes. | |
| 48 | + ## | |
| 49 | + ## A pds-session carries the PDS token, the DID it belongs to, its PDS, and | |
| 50 | + ## the server's own nonce echoed back so the token cannot be replayed at | |
| 51 | + ## another server. A web-token from the broker carries only the token — the | |
| 52 | + ## server looks the DID up in its own store, which is why the field is sent | |
| 53 | + ## empty rather than guessed at. | |
| 54 | + case s.kind | |
| 55 | + of skWebToken: | |
| 56 | + b64url($(%*{"did": "", "method": "web-token", "signature": s.token})) | |
| 57 | + else: | |
| 58 | + b64url($(%*{"did": s.did, | |
| 59 | + "signature": s.accessJwt, | |
| 60 | + "method": "pds-session", | |
| 61 | + "pds_url": s.pds, | |
| 62 | + "challenge_nonce": nonce})) | |
| 63 | + | |
| 64 | +proc nonceOf*(challenge: string): string = | |
| 65 | + ## The nonce out of the server's AUTHENTICATE challenge, which is a | |
| 66 | + ## base64url JSON object. | |
| 67 | + let raw = b64urlDecode(challenge) | |
| 68 | + if raw.len == 0: return "" | |
| 69 | + try: parseJson(raw){"nonce"}.getStr() except CatchableError: "" | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +## What an AT Protocol identity is, and what freeq is told about it. | ||
| 2 | +## | ||
| 3 | +## The pure half: the session an identity becomes, the SASL payload built | ||
| 4 | +## from it, and the base64url both ends agree on. `atproto` itself does the | ||
| 5 | +## HTTP, and is the half a browser replaces — `fetch` is asynchronous where | ||
| 6 | +## this is not, so on the web the host resolves an identity and hands the | ||
| 7 | +## answer in rather than being called into. | ||
| 8 | + | ||
| 9 | +import std/[base64, json, strutils] | ||
| 10 | + | ||
| 11 | +const | ||
| 12 | + directoryHost* = "public.api.bsky.app" | ||
| 13 | + plcHost* = "plc.directory" | ||
| 14 | + | ||
| 15 | +type | ||
| 16 | + SessionKind* = enum | ||
| 17 | + skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token" | ||
| 18 | + | ||
| 19 | + Session* = object | ||
| 20 | + kind*: SessionKind | ||
| 21 | + did*: string | ||
| 22 | + handle*: string | ||
| 23 | + accessJwt*: string | ||
| 24 | + pds*: string | ||
| 25 | + token*: string ## a web-token from the broker, where that is the kind | ||
| 26 | + | ||
| 27 | + AtprotoError* = object of CatchableError | ||
| 28 | + | ||
| 29 | +proc b64url*(s: string): string = | ||
| 30 | + ## base64url, unpadded — what AUTHENTICATE carries. | ||
| 31 | + s.encode().replace("+", "-").replace("/", "_").replace("=", "") | ||
| 32 | + | ||
| 33 | +proc b64urlDecode*(s: string): string = | ||
| 34 | + var t = s.replace("-", "+").replace("_", "/") | ||
| 35 | + # `decode` wants the padding the wire form drops. | ||
| 36 | + while t.len mod 4 != 0: t.add '=' | ||
| 37 | + try: decode(t) except CatchableError: "" | ||
| 38 | + | ||
| 39 | +func hostOf*(url: string): string = | ||
| 40 | + var u = url | ||
| 41 | + for scheme in ["https://", "http://"]: | ||
| 42 | + if u.startsWith(scheme): u = u[scheme.len .. ^1] | ||
| 43 | + let i = u.find('/') | ||
| 44 | + if i < 0: u else: u[0 ..< i] | ||
| 45 | + | ||
| 46 | +proc saslResponse*(s: Session, nonce: string): string = | ||
| 47 | + ## The base64url SASL payload, for either kind freeq takes. | ||
| 48 | + ## | ||
| 49 | + ## A pds-session carries the PDS token, the DID it belongs to, its PDS, and | ||
| 50 | + ## the server's own nonce echoed back so the token cannot be replayed at | ||
| 51 | + ## another server. A web-token from the broker carries only the token — the | ||
| 52 | + ## server looks the DID up in its own store, which is why the field is sent | ||
| 53 | + ## empty rather than guessed at. | ||
| 54 | + case s.kind | ||
| 55 | + of skWebToken: | ||
| 56 | + b64url($(%*{"did": "", "method": "web-token", "signature": s.token})) | ||
| 57 | + else: | ||
| 58 | + b64url($(%*{"did": s.did, | ||
| 59 | + "signature": s.accessJwt, | ||
| 60 | + "method": "pds-session", | ||
| 61 | + "pds_url": s.pds, | ||
| 62 | + "challenge_nonce": nonce})) | ||
| 63 | + | ||
| 64 | +proc nonceOf*(challenge: string): string = | ||
| 65 | + ## The nonce out of the server's AUTHENTICATE challenge, which is a | ||
| 66 | + ## base64url JSON object. | ||
| 67 | + let raw = b64urlDecode(challenge) | ||
| 68 | + if raw.len == 0: return "" | ||
| 69 | + try: parseJson(raw){"nonce"}.getStr() except CatchableError: "" | ||
modified
nim/src/frq/handshake.nim +1 -1 | @@ -5,7 +5,7 @@ | ||
| 5 | 5 | ## say back — so it answers with lines and the caller writes them. |
| 6 | 6 | |
| 7 | 7 | import std/[sets, strutils] |
| 8 | -import frq/[ircparse, atproto, msgsig] | |
| 8 | +import frq/[ircparse, atprotocore, msgsig] | |
| 9 | 9 | |
| 10 | 10 | const |
| 11 | 11 | saslChunk* = 100_000 |
| @@ -5,7 +5,7 @@ | |||
| 5 | ## say back — so it answers with lines and the caller writes them. | 5 | ## say back — so it answers with lines and the caller writes them. |
| 6 | 6 | ||
| 7 | import std/[sets, strutils] | 7 | import std/[sets, strutils] |
| 8 | -import frq/[ircparse, atproto, msgsig] | 8 | +import frq/[ircparse, atprotocore, msgsig] |
| 9 | 9 | ||
| 10 | const | 10 | const |
| 11 | saslChunk* = 100_000 | 11 | saslChunk* = 100_000 |
modified
nim/src/frq/oauth.nim +14 -103 | @@ -1,113 +1,24 @@ | ||
| 1 | -## Signing in with Bluesky, through freeq's auth broker. | |
| 1 | +## The browser handoff, on a desktop. | |
| 2 | 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. | |
| 3 | +## The pure half — the login URL, the payload, the capture page — is | |
| 4 | +## `oauthcore`, shared with the web build, which catches the answer by being | |
| 5 | +## the page that was redirected rather than by listening for it. | |
| 7 | 6 | ## |
| 8 | 7 | ## 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: | |
| 8 | +## own PDS, so this holds no password and no DPoP key. It binds a loopback | |
| 9 | +## port, opens a browser at `/auth/login?…&return_to=http://127.0.0.1:<port>`, | |
| 10 | +## and serves a page whose one job is to POST the URL fragment back — a | |
| 11 | +## fragment never reaches a server. | |
| 11 | 12 | ## |
| 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. | |
| 13 | +## The wait is a thread for the reason `conn`'s is: a reader takes as long as | |
| 14 | +## they take over a login page, and `dispatch` is called on the frame. | |
| 25 | 15 | |
| 26 | -import std/[base64, httpclient, json, nativesockets, net, osproc, | |
| 27 | - strutils, times] | |
| 16 | +import std/[httpclient, json, nativesockets, net, osproc, strutils, times] | |
| 28 | 17 | import frq/[trace, eintr] |
| 18 | +import frq/oauthcore | |
| 19 | +export oauthcore | |
| 29 | 20 | |
| 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") | |
| 21 | +const loginTimeout = 5 * 60 ## seconds; a login page nobody finishes | |
| 111 | 22 | |
| 112 | 23 | proc refreshSession*(broker, brokerToken: string): Tokens = |
| 113 | 24 | ## Mint a fresh web-token from the durable broker token. |
| @@ -1,113 +1,24 @@ | |||
| 1 | -## Signing in with Bluesky, through freeq's auth broker. | 1 | +## The browser handoff, on a desktop. |
| 2 | ## | 2 | ## |
| 3 | -## From `common/frq/oauth/core.cljc` and `flutter/src/frq/oauth/dart.cljd`, | 3 | +## The pure half — the login URL, the payload, the capture page — is |
| 4 | -## which were two files because ClojureDart compiled for a browser as well: | 4 | +## `oauthcore`, shared with the web build, which catches the answer by being |
| 5 | -## building the URL and reading the payload were portable, catching the | 5 | +## the page that was redirected rather than by listening for it. |
| 6 | -## redirect was not. Nim only has the desktop, so it is one file. | ||
| 7 | ## | 6 | ## |
| 8 | ## The broker at `auth.freeq.at` does the AT Protocol OAuth with the reader's | 7 | ## 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 | 8 | +## own PDS, so this holds no password and no DPoP key. It binds a loopback |
| 10 | -## client does is send them there and read what comes back: | 9 | +## port, opens a browser at `/auth/login?…&return_to=http://127.0.0.1:<port>`, |
| 10 | +## and serves a page whose one job is to POST the URL fragment back — a | ||
| 11 | +## fragment never reaches a server. | ||
| 11 | ## | 12 | ## |
| 12 | -## 1. bind a loopback listener on a port the kernel picks | 13 | +## The wait is a thread for the reason `conn`'s is: a reader takes as long as |
| 13 | -## 2. open `<broker>/auth/login?handle=…&return_to=http://127.0.0.1:<port>` | 14 | +## they take over a login page, and `dispatch` is called on the frame. |
| 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 | 15 | ||
| 26 | -import std/[base64, httpclient, json, nativesockets, net, osproc, | 16 | +import std/[httpclient, json, nativesockets, net, osproc, strutils, times] |
| 27 | - strutils, times] | ||
| 28 | import frq/[trace, eintr] | 17 | import frq/[trace, eintr] |
| 18 | +import frq/oauthcore | ||
| 19 | +export oauthcore | ||
| 29 | 20 | ||
| 30 | -const | 21 | +const loginTimeout = 5 * 60 ## seconds; a login page nobody finishes |
| 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 | 22 | ||
| 112 | proc refreshSession*(broker, brokerToken: string): Tokens = | 23 | proc refreshSession*(broker, brokerToken: string): Tokens = |
| 113 | ## Mint a fresh web-token from the durable broker token. | 24 | ## Mint a fresh web-token from the durable broker token. |
added
nim/src/frq/oauthcore.nim +91 -0 | new file mode 100644 | ||
| @@ -0,0 +1,91 @@ | ||
| 1 | +## The broker flow, minus the waiting: a login URL out, a handoff payload in. | |
| 2 | +## | |
| 3 | +## Split from `oauth` for the reason `common/frq/oauth/core.cljc` was split | |
| 4 | +## from its two platform halves, which is the same reason it is back: building | |
| 5 | +## the URL and reading the payload are the same everywhere, and catching the | |
| 6 | +## answer is not. A desktop listens on a loopback port; a browser is *already* | |
| 7 | +## the thing being redirected, and reads the fragment it came back with. | |
| 8 | + | |
| 9 | + | |
| 10 | +import std/[base64, json, strutils] | |
| 11 | + | |
| 12 | +const defaultBroker* = "https://auth.freeq.at" | |
| 13 | + | |
| 14 | +type | |
| 15 | + Tokens* = object | |
| 16 | + ## What the broker hands back. `token` is single-use. | |
| 17 | + token*, brokerToken*, nick*, did*, handle*: string | |
| 18 | + | |
| 19 | + OauthError* = object of CatchableError | |
| 20 | + | |
| 21 | +# ----------------------------------------------------------------- the url | |
| 22 | + | |
| 23 | +const hexDigits = "0123456789ABCDEF" | |
| 24 | + | |
| 25 | +func unreserved(b: byte): bool = | |
| 26 | + ## RFC 3986's unreserved set, by byte value: A-Z a-z 0-9 - _ . ~ | |
| 27 | + ## | |
| 28 | + ## By number rather than by `isAlphaNumeric`, which would also say yes to | |
| 29 | + ## é — and a percent-encoder that passes é through has not encoded | |
| 30 | + ## anything. | |
| 31 | + (b >= 48'u8 and b <= 57'u8) or (b >= 65'u8 and b <= 90'u8) or | |
| 32 | + (b >= 97'u8 and b <= 122'u8) or b in [45'u8, 95'u8, 46'u8, 126'u8] | |
| 33 | + | |
| 34 | +func urlEncode*(s: string): string = | |
| 35 | + ## Percent-encode everything a handle could hold that a query string cannot. | |
| 36 | + ## | |
| 37 | + ## Over UTF-8 bytes rather than characters: a non-ASCII handle is several | |
| 38 | + ## bytes and each is encoded on its own, which is what the spec says and | |
| 39 | + ## what the broker expects. | |
| 40 | + for c in s: | |
| 41 | + let b = byte(c) | |
| 42 | + if unreserved(b): result.add c | |
| 43 | + else: | |
| 44 | + result.add '%' | |
| 45 | + result.add hexDigits[int(b shr 4)] | |
| 46 | + result.add hexDigits[int(b and 0x0f)] | |
| 47 | + | |
| 48 | +func trimmedBroker(broker: string): string = | |
| 49 | + result = if broker.len > 0: broker else: defaultBroker | |
| 50 | + while result.len > 0 and result[^1] == '/': result.setLen(result.len - 1) | |
| 51 | + | |
| 52 | +func loginUrl*(broker, handle, returnTo: string): string = | |
| 53 | + ## Where the browser goes. A leading `@` on the handle is the reader typing | |
| 54 | + ## it the way it appears beside a message, not part of it. | |
| 55 | + var h = handle.strip() | |
| 56 | + if h.startsWith("@"): h = h[1 .. ^1] | |
| 57 | + trimmedBroker(broker) & "/auth/login?handle=" & urlEncode(h) & | |
| 58 | + "&return_to=" & urlEncode(returnTo) | |
| 59 | + | |
| 60 | +func brokerHost*(broker: string): string = | |
| 61 | + var b = trimmedBroker(broker) | |
| 62 | + if b.startsWith("https://"): b = b[8 .. ^1] | |
| 63 | + elif b.startsWith("http://"): b = b[7 .. ^1] | |
| 64 | + b.split('/')[0] | |
| 65 | + | |
| 66 | +# ------------------------------------------------------------- the handoff | |
| 67 | + | |
| 68 | +proc b64urlDecode(s: string): string = | |
| 69 | + var t = s.replace("-", "+").replace("_", "/") | |
| 70 | + while t.len mod 4 != 0: t.add '=' | |
| 71 | + try: decode(t) except CatchableError: "" | |
| 72 | + | |
| 73 | +proc tokensOf*(payload: string): Tokens = | |
| 74 | + ## The broker's base64url JSON payload, as fields. | |
| 75 | + ## | |
| 76 | + ## Both tokens or none: a payload missing either is the broker reporting a | |
| 77 | + ## failure, and it puts the reason in `error`. | |
| 78 | + let raw = b64urlDecode(payload.strip()) | |
| 79 | + let j = try: parseJson(raw) | |
| 80 | + except CatchableError: | |
| 81 | + raise newException(OauthError, "Malformed sign-in payload") | |
| 82 | + result = Tokens(token: j{"token"}.getStr(), | |
| 83 | + brokerToken: j{"broker_token"}.getStr(), | |
| 84 | + nick: j{"nick"}.getStr(), | |
| 85 | + did: j{"did"}.getStr(), | |
| 86 | + handle: j{"handle"}.getStr()) | |
| 87 | + if result.token.len == 0 or result.brokerToken.len == 0: | |
| 88 | + let e = j{"error"}.getStr() | |
| 89 | + raise newException(OauthError, | |
| 90 | + if e.len > 0: e else: "Malformed sign-in payload") | |
| 91 | + | |
| new file mode 100644 | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | +## The broker flow, minus the waiting: a login URL out, a handoff payload in. | ||
| 2 | +## | ||
| 3 | +## Split from `oauth` for the reason `common/frq/oauth/core.cljc` was split | ||
| 4 | +## from its two platform halves, which is the same reason it is back: building | ||
| 5 | +## the URL and reading the payload are the same everywhere, and catching the | ||
| 6 | +## answer is not. A desktop listens on a loopback port; a browser is *already* | ||
| 7 | +## the thing being redirected, and reads the fragment it came back with. | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +import std/[base64, json, strutils] | ||
| 11 | + | ||
| 12 | +const defaultBroker* = "https://auth.freeq.at" | ||
| 13 | + | ||
| 14 | +type | ||
| 15 | + Tokens* = object | ||
| 16 | + ## What the broker hands back. `token` is single-use. | ||
| 17 | + token*, brokerToken*, nick*, did*, handle*: string | ||
| 18 | + | ||
| 19 | + OauthError* = object of CatchableError | ||
| 20 | + | ||
| 21 | +# ----------------------------------------------------------------- the url | ||
| 22 | + | ||
| 23 | +const hexDigits = "0123456789ABCDEF" | ||
| 24 | + | ||
| 25 | +func unreserved(b: byte): bool = | ||
| 26 | + ## RFC 3986's unreserved set, by byte value: A-Z a-z 0-9 - _ . ~ | ||
| 27 | + ## | ||
| 28 | + ## By number rather than by `isAlphaNumeric`, which would also say yes to | ||
| 29 | + ## é — and a percent-encoder that passes é through has not encoded | ||
| 30 | + ## anything. | ||
| 31 | + (b >= 48'u8 and b <= 57'u8) or (b >= 65'u8 and b <= 90'u8) or | ||
| 32 | + (b >= 97'u8 and b <= 122'u8) or b in [45'u8, 95'u8, 46'u8, 126'u8] | ||
| 33 | + | ||
| 34 | +func urlEncode*(s: string): string = | ||
| 35 | + ## Percent-encode everything a handle could hold that a query string cannot. | ||
| 36 | + ## | ||
| 37 | + ## Over UTF-8 bytes rather than characters: a non-ASCII handle is several | ||
| 38 | + ## bytes and each is encoded on its own, which is what the spec says and | ||
| 39 | + ## what the broker expects. | ||
| 40 | + for c in s: | ||
| 41 | + let b = byte(c) | ||
| 42 | + if unreserved(b): result.add c | ||
| 43 | + else: | ||
| 44 | + result.add '%' | ||
| 45 | + result.add hexDigits[int(b shr 4)] | ||
| 46 | + result.add hexDigits[int(b and 0x0f)] | ||
| 47 | + | ||
| 48 | +func trimmedBroker(broker: string): string = | ||
| 49 | + result = if broker.len > 0: broker else: defaultBroker | ||
| 50 | + while result.len > 0 and result[^1] == '/': result.setLen(result.len - 1) | ||
| 51 | + | ||
| 52 | +func loginUrl*(broker, handle, returnTo: string): string = | ||
| 53 | + ## Where the browser goes. A leading `@` on the handle is the reader typing | ||
| 54 | + ## it the way it appears beside a message, not part of it. | ||
| 55 | + var h = handle.strip() | ||
| 56 | + if h.startsWith("@"): h = h[1 .. ^1] | ||
| 57 | + trimmedBroker(broker) & "/auth/login?handle=" & urlEncode(h) & | ||
| 58 | + "&return_to=" & urlEncode(returnTo) | ||
| 59 | + | ||
| 60 | +func brokerHost*(broker: string): string = | ||
| 61 | + var b = trimmedBroker(broker) | ||
| 62 | + if b.startsWith("https://"): b = b[8 .. ^1] | ||
| 63 | + elif b.startsWith("http://"): b = b[7 .. ^1] | ||
| 64 | + b.split('/')[0] | ||
| 65 | + | ||
| 66 | +# ------------------------------------------------------------- the handoff | ||
| 67 | + | ||
| 68 | +proc b64urlDecode(s: string): string = | ||
| 69 | + var t = s.replace("-", "+").replace("_", "/") | ||
| 70 | + while t.len mod 4 != 0: t.add '=' | ||
| 71 | + try: decode(t) except CatchableError: "" | ||
| 72 | + | ||
| 73 | +proc tokensOf*(payload: string): Tokens = | ||
| 74 | + ## The broker's base64url JSON payload, as fields. | ||
| 75 | + ## | ||
| 76 | + ## Both tokens or none: a payload missing either is the broker reporting a | ||
| 77 | + ## failure, and it puts the reason in `error`. | ||
| 78 | + let raw = b64urlDecode(payload.strip()) | ||
| 79 | + let j = try: parseJson(raw) | ||
| 80 | + except CatchableError: | ||
| 81 | + raise newException(OauthError, "Malformed sign-in payload") | ||
| 82 | + result = Tokens(token: j{"token"}.getStr(), | ||
| 83 | + brokerToken: j{"broker_token"}.getStr(), | ||
| 84 | + nick: j{"nick"}.getStr(), | ||
| 85 | + did: j{"did"}.getStr(), | ||
| 86 | + handle: j{"handle"}.getStr()) | ||
| 87 | + if result.token.len == 0 or result.brokerToken.len == 0: | ||
| 88 | + let e = j{"error"}.getStr() | ||
| 89 | + raise newException(OauthError, | ||
| 90 | + if e.len > 0: e else: "Malformed sign-in payload") | ||
| 91 | + | ||