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

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

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>
nandi committed 2026-09-19T21:02:25-07:00 Browse files
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.
22 ##
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.
117
12-import std/[base64, httpclient, json, net, strutils]
8+import std/[httpclient, json, net, strutils]
139 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
4212
4313 proc newClient(): HttpClient =
4414 ## No socket timeout, and that is not an oversight: Linux never restarts a
@@ -75,13 +45,6 @@ proc getJson(url, whatFor: string): JsonNode =
7545 if c != nil:
7646 try: c.close() except CatchableError: discard
7747
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-
8548 proc resolveHandle*(handle: string): string =
8649 ## A handle to a DID. One that is already a DID needs no call and passes
8750 ## through, which is why this is not simply a request builder.
@@ -177,27 +140,3 @@ proc createSession*(handle, password: string): Session =
177140 if c != nil:
178141 try: c.close() except CatchableError: discard
179142
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` and3+## 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 host4+## 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 round5+## 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-const11+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 a14 ## 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: discard46 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 passes49 ## 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: discard141 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 @@
55 ## say back — so it answers with lines and the caller writes them.
66
77 import std/[sets, strutils]
8-import frq/[ircparse, atproto, msgsig]
8+import frq/[ircparse, atprotocore, msgsig]
99
1010 const
1111 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 const10 const
11 saslChunk* = 100_00011 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.
22 ##
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.
76 ##
87 ## 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.
1112 ##
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.
2515
26-import std/[base64, httpclient, json, nativesockets, net, osproc,
27- strutils, times]
16+import std/[httpclient, json, nativesockets, net, osproc, strutils, times]
2817 import frq/[trace, eintr]
18+import frq/oauthcore
19+export oauthcore
2920
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
11122
11223 proc refreshSession*(broker, brokerToken: string): Tokens =
11324 ## 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 the5+## 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's7 ## 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 a8+## 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 picks13+## 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-const21+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+