nandi/gleanpublic Fork 0
c960984
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

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

Build the ATProto OAuth flow in Nim

Ports identity resolution and the OAuth client from the shape of indigo's
atproto/auth/oauth: handle -> DID -> PDS -> auth server -> metadata -> PAR
-> authorize URL, with the DPoP layer from the earlier spike underneath.

oauth_probe.nim drives it against real servers. bsky.social accepts a real
PAR and returns a request_uri, which is the check worth having: it
validates the DPoP signature, the nonce retry and PKCE at once, server-side,
in a way local testing cannot.

The nonce retry needed proving rather than assuming. A PDS rejects the first
DPoP-signed request of a session with 400 use_dpop_nonce and supplies a
nonce to retry with; since sendAuthRequest retries transparently, a server
that never demanded a nonce would look exactly like a working retry. The
probe fires an unnonced request separately and asserts the rejection.

That check was wrong at first: a stub body returned invalid_request, not
use_dpop_nonce, because bsky.social validates parameters before the nonce.
The body has to be fully valid to observe the nonce requirement at all.

The flow stops at the authorization URL, which is where it should stop --
the next step is a human granting consent. exchangeCode and refresh are
written against that callback and are therefore still untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-20T21:05:40-07:00 Browse files
c960984 parent: f205f09
modified spike/nim/.gitignore +1 -0
@@ -2,3 +2,4 @@
22 nimcache/
33 sqlite_probe
44 dpop_probe
5+oauth_probe
@@ -2,3 +2,4 @@
2 nimcache/2 nimcache/
3 sqlite_probe3 sqlite_probe
4 dpop_probe4 dpop_probe
5+oauth_probe
added spike/nim/atproto/dpop.nim +102 -0
new file mode 100644
@@ -0,0 +1,102 @@
1+## ES256 keys and DPoP proofs (RFC 9449) for ATProto OAuth.
2+##
3+## What has to be exactly right:
4+## * ES256 signatures in JOSE form -- raw R||S, 64 bytes, NOT the DER that
5+## most crypto libraries hand you.
6+## * An RFC 7638 JWK thumbprint, which is a SHA-256 over canonical JSON:
7+## exactly the members crv/kty/x/y, lexicographically ordered, no spaces.
8+## * base64url without padding, everywhere.
9+##
10+## bearssl is used because br_ecdsa_sign_raw already emits R||S, so there is no
11+## DER unwrapping step to get wrong. dpop_probe.nim cross-checks the output
12+## against a Go verifier.
13+
14+import std/[base64, json, strutils, times, strformat]
15+import bearssl/[ec, rand]
16+import bearssl/abi/[bearssl_ec, bearssl_rand, bearssl_hash]
17+import nimcrypto/[sha2, hash]
18+
19+type P256Key* = object
20+ priv*: array[32, byte]
21+ pubX*: array[32, byte]
22+ pubY*: array[32, byte]
23+
24+proc toSeq(a: openArray[byte]): seq[byte] =
25+ result = newSeq[byte](a.len)
26+ for i, b in a: result[i] = b
27+
28+proc b64u*(data: openArray[byte]): string =
29+ ## base64url, unpadded (RFC 7515 §2).
30+ base64.encode(data, safe = true).strip(chars = {'='})
31+
32+proc b64u*(s: string): string =
33+ b64u(s.toOpenArrayByte(0, s.high).toSeq)
34+
35+proc generateKey*(rng: var HmacDrbgContext): P256Key =
36+ ## P-256 keypair. bearssl returns the public point uncompressed:
37+ ## 0x04 || X(32) || Y(32).
38+ var
39+ skBuf: array[EC_KBUF_PRIV_MAX_SIZE, byte]
40+ pkBuf: array[EC_KBUF_PUB_MAX_SIZE, byte]
41+ sk: EcPrivateKey
42+ pk: EcPublicKey
43+
44+ let n = ecKeygen(PrngClassPointerConst(addr rng.vtable), addr ecPrimeI31, addr sk,
45+ addr skBuf[0], EC_secp256r1.cint)
46+ doAssert n > 0, "ecKeygen failed"
47+ let m = ecComputePub(addr ecPrimeI31, addr pk, addr pkBuf[0], addr sk)
48+ doAssert m > 0, "ecComputePub failed"
49+
50+ doAssert sk.xlen == 32, &"unexpected private key length {sk.xlen}"
51+ doAssert pk.qlen == 65 and cast[ptr UncheckedArray[byte]](pk.q)[0] == 0x04'u8,
52+ "expected an uncompressed public point"
53+
54+ let
55+ skBytes = cast[ptr UncheckedArray[byte]](sk.x)
56+ pkBytes = cast[ptr UncheckedArray[byte]](pk.q)
57+ for i in 0 ..< 32:
58+ result.priv[i] = skBytes[i]
59+ result.pubX[i] = pkBytes[1 + i]
60+ result.pubY[i] = pkBytes[33 + i]
61+
62+proc publicJwk*(k: P256Key): JsonNode =
63+ %*{"crv": "P-256", "kty": "EC", "x": b64u(k.pubX), "y": b64u(k.pubY)}
64+
65+proc thumbprint*(k: P256Key): string =
66+ ## RFC 7638: SHA-256 over the canonical JWK. The member set and ordering are
67+ ## fixed by the spec, so this is built by hand rather than serialised from a
68+ ## JsonNode whose key order is incidental.
69+ let canonical = &"""{{"crv":"P-256","kty":"EC","x":"{b64u(k.pubX)}","y":"{b64u(k.pubY)}"}}"""
70+ b64u(sha256.digest(canonical).data)
71+
72+proc signEs256*(k: P256Key, rng: var HmacDrbgContext, signingInput: string): seq[byte] =
73+ var
74+ sk: EcPrivateKey
75+ priv = k.priv
76+ sk.curve = EC_secp256r1.cint
77+ sk.x = cast[ptr byte](addr priv[0])
78+ sk.xlen = 32
79+
80+ let digest = sha256.digest(signingInput).data
81+ var sig: array[64, byte]
82+ let n = ecdsaSignRawGetDefault()(
83+ addr ecPrimeI31, addr sha256Vtable, unsafeAddr digest[0], addr sk, addr sig[0])
84+ doAssert n == 64, &"expected a 64-byte raw signature, got {n}"
85+ result = sig.toSeq
86+
87+proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string,
88+ nonce = ""): string =
89+ ## A DPoP proof JWT (RFC 9449) as an ATProto PDS expects it.
90+ let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk}
91+ var payload = %*{
92+ "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16],
93+ "htm": htm,
94+ "htu": htu,
95+ "iat": now().utc.toTime.toUnix,
96+ }
97+ if nonce.len > 0:
98+ payload["nonce"] = %nonce
99+
100+ let signingInput = b64u($header) & "." & b64u($payload)
101+ signingInput & "." & b64u(k.signEs256(rng, signingInput))
102+
new file mode 100644
@@ -0,0 +1,102 @@
1+## ES256 keys and DPoP proofs (RFC 9449) for ATProto OAuth.
2+##
3+## What has to be exactly right:
4+## * ES256 signatures in JOSE form -- raw R||S, 64 bytes, NOT the DER that
5+## most crypto libraries hand you.
6+## * An RFC 7638 JWK thumbprint, which is a SHA-256 over canonical JSON:
7+## exactly the members crv/kty/x/y, lexicographically ordered, no spaces.
8+## * base64url without padding, everywhere.
9+##
10+## bearssl is used because br_ecdsa_sign_raw already emits R||S, so there is no
11+## DER unwrapping step to get wrong. dpop_probe.nim cross-checks the output
12+## against a Go verifier.
13+
14+import std/[base64, json, strutils, times, strformat]
15+import bearssl/[ec, rand]
16+import bearssl/abi/[bearssl_ec, bearssl_rand, bearssl_hash]
17+import nimcrypto/[sha2, hash]
18+
19+type P256Key* = object
20+ priv*: array[32, byte]
21+ pubX*: array[32, byte]
22+ pubY*: array[32, byte]
23+
24+proc toSeq(a: openArray[byte]): seq[byte] =
25+ result = newSeq[byte](a.len)
26+ for i, b in a: result[i] = b
27+
28+proc b64u*(data: openArray[byte]): string =
29+ ## base64url, unpadded (RFC 7515 §2).
30+ base64.encode(data, safe = true).strip(chars = {'='})
31+
32+proc b64u*(s: string): string =
33+ b64u(s.toOpenArrayByte(0, s.high).toSeq)
34+
35+proc generateKey*(rng: var HmacDrbgContext): P256Key =
36+ ## P-256 keypair. bearssl returns the public point uncompressed:
37+ ## 0x04 || X(32) || Y(32).
38+ var
39+ skBuf: array[EC_KBUF_PRIV_MAX_SIZE, byte]
40+ pkBuf: array[EC_KBUF_PUB_MAX_SIZE, byte]
41+ sk: EcPrivateKey
42+ pk: EcPublicKey
43+
44+ let n = ecKeygen(PrngClassPointerConst(addr rng.vtable), addr ecPrimeI31, addr sk,
45+ addr skBuf[0], EC_secp256r1.cint)
46+ doAssert n > 0, "ecKeygen failed"
47+ let m = ecComputePub(addr ecPrimeI31, addr pk, addr pkBuf[0], addr sk)
48+ doAssert m > 0, "ecComputePub failed"
49+
50+ doAssert sk.xlen == 32, &"unexpected private key length {sk.xlen}"
51+ doAssert pk.qlen == 65 and cast[ptr UncheckedArray[byte]](pk.q)[0] == 0x04'u8,
52+ "expected an uncompressed public point"
53+
54+ let
55+ skBytes = cast[ptr UncheckedArray[byte]](sk.x)
56+ pkBytes = cast[ptr UncheckedArray[byte]](pk.q)
57+ for i in 0 ..< 32:
58+ result.priv[i] = skBytes[i]
59+ result.pubX[i] = pkBytes[1 + i]
60+ result.pubY[i] = pkBytes[33 + i]
61+
62+proc publicJwk*(k: P256Key): JsonNode =
63+ %*{"crv": "P-256", "kty": "EC", "x": b64u(k.pubX), "y": b64u(k.pubY)}
64+
65+proc thumbprint*(k: P256Key): string =
66+ ## RFC 7638: SHA-256 over the canonical JWK. The member set and ordering are
67+ ## fixed by the spec, so this is built by hand rather than serialised from a
68+ ## JsonNode whose key order is incidental.
69+ let canonical = &"""{{"crv":"P-256","kty":"EC","x":"{b64u(k.pubX)}","y":"{b64u(k.pubY)}"}}"""
70+ b64u(sha256.digest(canonical).data)
71+
72+proc signEs256*(k: P256Key, rng: var HmacDrbgContext, signingInput: string): seq[byte] =
73+ var
74+ sk: EcPrivateKey
75+ priv = k.priv
76+ sk.curve = EC_secp256r1.cint
77+ sk.x = cast[ptr byte](addr priv[0])
78+ sk.xlen = 32
79+
80+ let digest = sha256.digest(signingInput).data
81+ var sig: array[64, byte]
82+ let n = ecdsaSignRawGetDefault()(
83+ addr ecPrimeI31, addr sha256Vtable, unsafeAddr digest[0], addr sk, addr sig[0])
84+ doAssert n == 64, &"expected a 64-byte raw signature, got {n}"
85+ result = sig.toSeq
86+
87+proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string,
88+ nonce = ""): string =
89+ ## A DPoP proof JWT (RFC 9449) as an ATProto PDS expects it.
90+ let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk}
91+ var payload = %*{
92+ "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16],
93+ "htm": htm,
94+ "htu": htu,
95+ "iat": now().utc.toTime.toUnix,
96+ }
97+ if nonce.len > 0:
98+ payload["nonce"] = %nonce
99+
100+ let signingInput = b64u($header) & "." & b64u($payload)
101+ signingInput & "." & b64u(k.signEs256(rng, signingInput))
102+
added spike/nim/atproto/identity.nim +142 -0
new file mode 100644
@@ -0,0 +1,142 @@
1+## Handle and DID resolution: the chain that turns "alice.bsky.social" into
2+## the PDS host an OAuth flow has to talk to.
3+##
4+## The steps, matching what indigo's identity directory does:
5+## handle -> DID via DNS TXT _atproto.<handle>, else
6+## https://<handle>/.well-known/atproto-did
7+## DID -> doc via the PLC directory for did:plc, or
8+## https://<host>/.well-known/did.json for did:web
9+## doc -> PDS the service entry with id "#atproto_pds"
10+
11+import std/[httpclient, json, options, os, osproc, strutils, uri]
12+
13+const
14+ DefaultPlcUrl* = "https://plc.directory"
15+ UserAgent* = "glean-nim/0.1"
16+ HttpTimeoutMs = 10_000
17+
18+type
19+ IdentityError* = object of CatchableError
20+
21+ Identity* = object
22+ did*: string
23+ handle*: string
24+ pdsEndpoint*: string
25+
26+proc newClient(): HttpClient =
27+ newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs)
28+
29+proc isValidHandle*(handle: string): bool =
30+ ## Deliberately loose: a dotted, non-empty ASCII domain. The authoritative
31+ ## check is whether it resolves.
32+ if handle.len == 0 or handle.len > 253 or '.' notin handle:
33+ return false
34+ for label in handle.split('.'):
35+ if label.len == 0: return false
36+ for ch in label:
37+ if ch notin {'a'..'z', 'A'..'Z', '0'..'9', '-'}: return false
38+ true
39+
40+proc isDid*(s: string): bool =
41+ s.startsWith("did:plc:") or s.startsWith("did:web:")
42+
43+proc resolveHandleDns(handle: string): Option[string] =
44+ ## _atproto.<handle> TXT record containing "did=did:plc:...".
45+ ##
46+ ## Nim has no resolver in the stdlib, so this shells out to `dig`. A real
47+ ## port should bind a resolver rather than depend on a binary being present;
48+ ## a missing `dig` is treated as "no DNS answer" so the HTTP fallback runs.
49+ let dig = findExe("dig")
50+ if dig.len == 0:
51+ return none(string)
52+ let (output, code) = execCmdEx(dig & " +short +time=3 +tries=1 TXT _atproto." &
53+ quoteShell(handle))
54+ if code != 0:
55+ return none(string)
56+ for rawLine in output.splitLines:
57+ let line = rawLine.strip(chars = {'"', ' ', '\t'})
58+ if line.startsWith("did="):
59+ let did = line[4 .. ^1].strip(chars = {'"'})
60+ if did.isDid:
61+ return some(did)
62+ none(string)
63+
64+proc resolveHandleHttp(handle: string): Option[string] =
65+ let client = newClient()
66+ defer: client.close()
67+ try:
68+ let res = client.get("https://" & handle & "/.well-known/atproto-did")
69+ if res.code.int != 200:
70+ return none(string)
71+ let did = res.body.strip()
72+ if did.isDid: some(did) else: none(string)
73+ except CatchableError:
74+ none(string)
75+
76+proc resolveHandle*(handle: string): string =
77+ ## DNS first, then the well-known endpoint, as the spec prefers.
78+ if not handle.isValidHandle:
79+ raise newException(IdentityError, "not a valid handle: " & handle)
80+ let viaDns = resolveHandleDns(handle)
81+ if viaDns.isSome:
82+ return viaDns.get
83+ let viaHttp = resolveHandleHttp(handle)
84+ if viaHttp.isSome:
85+ return viaHttp.get
86+ raise newException(IdentityError, "could not resolve handle: " & handle)
87+
88+proc resolveDidDoc*(did: string, plcUrl = DefaultPlcUrl): JsonNode =
89+ if not did.isDid:
90+ raise newException(IdentityError, "unsupported DID method: " & did)
91+
92+ let url =
93+ if did.startsWith("did:plc:"):
94+ plcUrl & "/" & did
95+ else:
96+ # did:web:example.com -> https://example.com/.well-known/did.json
97+ let host = did["did:web:".len .. ^1].replace("%3A", ":")
98+ "https://" & host & "/.well-known/did.json"
99+
100+ let client = newClient()
101+ defer: client.close()
102+ let res = client.get(url)
103+ if res.code.int != 200:
104+ raise newException(IdentityError,
105+ "DID document lookup failed (HTTP " & $res.code.int & "): " & did)
106+ try:
107+ result = parseJson(res.body)
108+ except JsonParsingError:
109+ raise newException(IdentityError, "DID document is not valid JSON: " & did)
110+
111+proc pdsEndpoint*(doc: JsonNode): string =
112+ ## The service entry whose id ends in "#atproto_pds".
113+ if doc.kind != JObject or "service" notin doc:
114+ return ""
115+ for svc in doc["service"]:
116+ let id = svc{"id"}.getStr
117+ if id.endsWith("#atproto_pds"):
118+ return svc{"serviceEndpoint"}.getStr.strip(chars = {'/'})
119+ ""
120+
121+proc handleFromDoc(doc: JsonNode): string =
122+ for aka in doc{"alsoKnownAs"}:
123+ let v = aka.getStr
124+ if v.startsWith("at://"):
125+ return v["at://".len .. ^1]
126+ ""
127+
128+proc lookup*(identifier: string, plcUrl = DefaultPlcUrl): Identity =
129+ ## Resolve a handle or DID all the way to a PDS endpoint.
130+ let did = if identifier.isDid: identifier else: resolveHandle(identifier)
131+ let doc = resolveDidDoc(did, plcUrl)
132+ result = Identity(
133+ did: did,
134+ handle: if identifier.isDid: handleFromDoc(doc) else: identifier,
135+ pdsEndpoint: pdsEndpoint(doc),
136+ )
137+ if result.pdsEndpoint.len == 0:
138+ raise newException(IdentityError, "identity has no atproto PDS: " & did)
139+
140+proc hostOf*(url: string): string =
141+ let u = parseUri(url)
142+ if u.port.len > 0: u.hostname & ":" & u.port else: u.hostname
new file mode 100644
@@ -0,0 +1,142 @@
1+## Handle and DID resolution: the chain that turns "alice.bsky.social" into
2+## the PDS host an OAuth flow has to talk to.
3+##
4+## The steps, matching what indigo's identity directory does:
5+## handle -> DID via DNS TXT _atproto.<handle>, else
6+## https://<handle>/.well-known/atproto-did
7+## DID -> doc via the PLC directory for did:plc, or
8+## https://<host>/.well-known/did.json for did:web
9+## doc -> PDS the service entry with id "#atproto_pds"
10+
11+import std/[httpclient, json, options, os, osproc, strutils, uri]
12+
13+const
14+ DefaultPlcUrl* = "https://plc.directory"
15+ UserAgent* = "glean-nim/0.1"
16+ HttpTimeoutMs = 10_000
17+
18+type
19+ IdentityError* = object of CatchableError
20+
21+ Identity* = object
22+ did*: string
23+ handle*: string
24+ pdsEndpoint*: string
25+
26+proc newClient(): HttpClient =
27+ newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs)
28+
29+proc isValidHandle*(handle: string): bool =
30+ ## Deliberately loose: a dotted, non-empty ASCII domain. The authoritative
31+ ## check is whether it resolves.
32+ if handle.len == 0 or handle.len > 253 or '.' notin handle:
33+ return false
34+ for label in handle.split('.'):
35+ if label.len == 0: return false
36+ for ch in label:
37+ if ch notin {'a'..'z', 'A'..'Z', '0'..'9', '-'}: return false
38+ true
39+
40+proc isDid*(s: string): bool =
41+ s.startsWith("did:plc:") or s.startsWith("did:web:")
42+
43+proc resolveHandleDns(handle: string): Option[string] =
44+ ## _atproto.<handle> TXT record containing "did=did:plc:...".
45+ ##
46+ ## Nim has no resolver in the stdlib, so this shells out to `dig`. A real
47+ ## port should bind a resolver rather than depend on a binary being present;
48+ ## a missing `dig` is treated as "no DNS answer" so the HTTP fallback runs.
49+ let dig = findExe("dig")
50+ if dig.len == 0:
51+ return none(string)
52+ let (output, code) = execCmdEx(dig & " +short +time=3 +tries=1 TXT _atproto." &
53+ quoteShell(handle))
54+ if code != 0:
55+ return none(string)
56+ for rawLine in output.splitLines:
57+ let line = rawLine.strip(chars = {'"', ' ', '\t'})
58+ if line.startsWith("did="):
59+ let did = line[4 .. ^1].strip(chars = {'"'})
60+ if did.isDid:
61+ return some(did)
62+ none(string)
63+
64+proc resolveHandleHttp(handle: string): Option[string] =
65+ let client = newClient()
66+ defer: client.close()
67+ try:
68+ let res = client.get("https://" & handle & "/.well-known/atproto-did")
69+ if res.code.int != 200:
70+ return none(string)
71+ let did = res.body.strip()
72+ if did.isDid: some(did) else: none(string)
73+ except CatchableError:
74+ none(string)
75+
76+proc resolveHandle*(handle: string): string =
77+ ## DNS first, then the well-known endpoint, as the spec prefers.
78+ if not handle.isValidHandle:
79+ raise newException(IdentityError, "not a valid handle: " & handle)
80+ let viaDns = resolveHandleDns(handle)
81+ if viaDns.isSome:
82+ return viaDns.get
83+ let viaHttp = resolveHandleHttp(handle)
84+ if viaHttp.isSome:
85+ return viaHttp.get
86+ raise newException(IdentityError, "could not resolve handle: " & handle)
87+
88+proc resolveDidDoc*(did: string, plcUrl = DefaultPlcUrl): JsonNode =
89+ if not did.isDid:
90+ raise newException(IdentityError, "unsupported DID method: " & did)
91+
92+ let url =
93+ if did.startsWith("did:plc:"):
94+ plcUrl & "/" & did
95+ else:
96+ # did:web:example.com -> https://example.com/.well-known/did.json
97+ let host = did["did:web:".len .. ^1].replace("%3A", ":")
98+ "https://" & host & "/.well-known/did.json"
99+
100+ let client = newClient()
101+ defer: client.close()
102+ let res = client.get(url)
103+ if res.code.int != 200:
104+ raise newException(IdentityError,
105+ "DID document lookup failed (HTTP " & $res.code.int & "): " & did)
106+ try:
107+ result = parseJson(res.body)
108+ except JsonParsingError:
109+ raise newException(IdentityError, "DID document is not valid JSON: " & did)
110+
111+proc pdsEndpoint*(doc: JsonNode): string =
112+ ## The service entry whose id ends in "#atproto_pds".
113+ if doc.kind != JObject or "service" notin doc:
114+ return ""
115+ for svc in doc["service"]:
116+ let id = svc{"id"}.getStr
117+ if id.endsWith("#atproto_pds"):
118+ return svc{"serviceEndpoint"}.getStr.strip(chars = {'/'})
119+ ""
120+
121+proc handleFromDoc(doc: JsonNode): string =
122+ for aka in doc{"alsoKnownAs"}:
123+ let v = aka.getStr
124+ if v.startsWith("at://"):
125+ return v["at://".len .. ^1]
126+ ""
127+
128+proc lookup*(identifier: string, plcUrl = DefaultPlcUrl): Identity =
129+ ## Resolve a handle or DID all the way to a PDS endpoint.
130+ let did = if identifier.isDid: identifier else: resolveHandle(identifier)
131+ let doc = resolveDidDoc(did, plcUrl)
132+ result = Identity(
133+ did: did,
134+ handle: if identifier.isDid: handleFromDoc(doc) else: identifier,
135+ pdsEndpoint: pdsEndpoint(doc),
136+ )
137+ if result.pdsEndpoint.len == 0:
138+ raise newException(IdentityError, "identity has no atproto PDS: " & did)
139+
140+proc hostOf*(url: string): string =
141+ let u = parseUri(url)
142+ if u.port.len > 0: u.hostname & ":" & u.port else: u.hostname
added spike/nim/atproto/oauth.nim +329 -0
new file mode 100644
@@ -0,0 +1,329 @@
1+## ATProto OAuth client: metadata discovery, PAR, authorization URL, and the
2+## token exchange, ported from the shape of bluesky-social/indigo's
3+## atproto/auth/oauth package.
4+##
5+## Two details drive most of the design:
6+##
7+## 1. Every request to the auth server carries a DPoP proof bound to a
8+## per-session P-256 key, and the server will reject the first one with
9+## `use_dpop_nonce` plus a DPoP-Nonce header. That is normal, not an error:
10+## the request must be retried once with the supplied nonce. Note the
11+## status is 400 on the auth server (not the 401 a resource server uses).
12+##
13+## 2. PKCE is mandatory. The verifier is kept alongside the request so the
14+## callback can present it.
15+
16+import std/[httpclient, json, strutils, strformat, sysrand, uri]
17+import nimcrypto/[sha2, hash]
18+import bearssl/rand
19+import ./dpop
20+import ./identity
21+
22+type
23+ OAuthError* = object of CatchableError
24+
25+ ClientConfig* = object
26+ clientId*: string
27+ callbackUrl*: string
28+ scopes*: seq[string]
29+
30+ AuthServerMetadata* = object
31+ issuer*: string
32+ authorizationEndpoint*: string
33+ tokenEndpoint*: string
34+ parEndpoint*: string
35+ revocationEndpoint*: string
36+
37+ AuthRequestData* = object ## Persisted between the redirect and the callback.
38+ state*: string
39+ authServerUrl*: string
40+ scopes*: seq[string]
41+ pkceVerifier*: string
42+ requestUri*: string
43+ tokenEndpoint*: string
44+ revocationEndpoint*: string
45+ dpopNonce*: string
46+ dpopKey*: P256Key
47+ accountDid*: string
48+
49+ TokenResponse* = object
50+ accessToken*: string
51+ refreshToken*: string
52+ tokenType*: string
53+ expiresIn*: int
54+ scope*: string
55+ sub*: string
56+
57+const HttpTimeoutMs = 15_000
58+
59+proc newClient(): HttpClient =
60+ newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs)
61+
62+proc scopeStr*(scopes: seq[string]): string = scopes.join(" ")
63+
64+proc newPublicConfig*(clientId, callbackUrl: string,
65+ scopes = @["atproto", "transition:generic"]): ClientConfig =
66+ ClientConfig(clientId: clientId, callbackUrl: callbackUrl, scopes: scopes)
67+
68+proc newLocalhostConfig*(callbackUrl: string,
69+ scopes = @["atproto", "transition:generic"]): ClientConfig =
70+ ## Localhost development clients have no hosted metadata document; the
71+ ## client_id encodes the redirect and scopes instead.
72+ var params = @[("redirect_uri", callbackUrl), ("scope", scopeStr(scopes))]
73+ ClientConfig(
74+ clientId: "http://localhost" & "?" & params.encodeQuery,
75+ callbackUrl: callbackUrl,
76+ scopes: scopes,
77+ )
78+
79+proc clientMetadata*(cfg: ClientConfig): JsonNode =
80+ ## Served at the client_id URL for a public (non-confidential) client.
81+ %*{
82+ "client_id": cfg.clientId,
83+ "application_type": "web",
84+ "client_name": "glean",
85+ "grant_types": ["authorization_code", "refresh_token"],
86+ "scope": scopeStr(cfg.scopes),
87+ "response_types": ["code"],
88+ "redirect_uris": [cfg.callbackUrl],
89+ "token_endpoint_auth_method": "none",
90+ "dpop_bound_access_tokens": true,
91+ }
92+
93+proc secureRandomB64*(n: int): string =
94+ var buf = newSeq[byte](n)
95+ doAssert urandom(buf), "system randomness unavailable"
96+ b64u(buf)
97+
98+proc s256Challenge*(verifier: string): string =
99+ b64u(sha256.digest(verifier).data)
100+
101+# --- discovery -------------------------------------------------------------
102+
103+proc resolveAuthServerUrl*(pdsUrl: string): string =
104+ ## The PDS advertises which authorization server governs it.
105+ let url = &"https://{hostOf(pdsUrl)}/.well-known/oauth-protected-resource"
106+ let client = newClient()
107+ defer: client.close()
108+ let res = client.get(url)
109+ if res.code.int != 200:
110+ raise newException(OAuthError,
111+ &"protected resource metadata failed (HTTP {res.code.int}) at {url}")
112+ let body = parseJson(res.body)
113+ let servers = body{"authorization_servers"}
114+ if servers == nil or servers.kind != JArray or servers.len == 0:
115+ raise newException(OAuthError, "PDS lists no authorization servers: " & pdsUrl)
116+ servers[0].getStr.strip(chars = {'/'})
117+
118+proc resolveAuthServerMetadata*(authServerUrl: string): AuthServerMetadata =
119+ let url = &"https://{hostOf(authServerUrl)}/.well-known/oauth-authorization-server"
120+ let client = newClient()
121+ defer: client.close()
122+ let res = client.get(url)
123+ if res.code.int != 200:
124+ raise newException(OAuthError,
125+ &"auth server metadata failed (HTTP {res.code.int}) at {url}")
126+ let b = parseJson(res.body)
127+
128+ result = AuthServerMetadata(
129+ issuer: b{"issuer"}.getStr,
130+ authorizationEndpoint: b{"authorization_endpoint"}.getStr,
131+ tokenEndpoint: b{"token_endpoint"}.getStr,
132+ parEndpoint: b{"pushed_authorization_request_endpoint"}.getStr,
133+ revocationEndpoint: b{"revocation_endpoint"}.getStr,
134+ )
135+
136+ # An auth server missing any of these cannot complete an ATProto flow, and
137+ # failing here beats a confusing error three requests later.
138+ for (name, value) in {
139+ "issuer": result.issuer,
140+ "authorization_endpoint": result.authorizationEndpoint,
141+ "token_endpoint": result.tokenEndpoint,
142+ "pushed_authorization_request_endpoint": result.parEndpoint,
143+ }:
144+ if value.len == 0:
145+ raise newException(OAuthError, "auth server metadata is missing " & name)
146+
147+ if result.issuer.strip(chars = {'/'}) != authServerUrl.strip(chars = {'/'}):
148+ raise newException(OAuthError,
149+ &"auth server issuer mismatch: metadata says {result.issuer}, fetched from {authServerUrl}")
150+
151+# --- requests --------------------------------------------------------------
152+
153+proc errorReason(body: string): string =
154+ try:
155+ let j = parseJson(body)
156+ result = j{"error"}.getStr
157+ let desc = j{"error_description"}.getStr
158+ if desc.len > 0:
159+ result &= ": " & desc
160+ except CatchableError:
161+ result = "unknown"
162+ if result.len == 0:
163+ result = "unknown"
164+
165+proc errorCode(body: string): string =
166+ try: parseJson(body){"error"}.getStr
167+ except CatchableError: ""
168+
169+proc postWithDpop(url, body: string, key: P256Key, rng: var HmacDrbgContext,
170+ nonce: var string): Response =
171+ ## POST with a DPoP proof, retrying once when the server demands a nonce.
172+ ##
173+ ## The first request of a session always lacks a nonce, so the retry is the
174+ ## expected path rather than an error case. The auth server signals this
175+ ## with 400 + error=use_dpop_nonce and supplies DPoP-Nonce.
176+ let client = newClient()
177+ defer: client.close()
178+
179+ for attempt in 0 .. 1:
180+ let proof = dpopProof(key, rng, "POST", url, nonce = nonce)
181+ client.headers = newHttpHeaders({
182+ "Content-Type": "application/x-www-form-urlencoded",
183+ "DPoP": proof,
184+ })
185+ result = client.post(url, body = body)
186+
187+ let supplied = result.headers.getOrDefault("DPoP-Nonce").string
188+ if supplied.len > 0:
189+ nonce = supplied
190+
191+ if attempt == 0 and result.code.int == 400 and supplied.len > 0:
192+ if errorCode(result.body) == "use_dpop_nonce":
193+ continue
194+ return result
195+
196+proc sendAuthRequest*(cfg: ClientConfig, meta: AuthServerMetadata,
197+ rng: var HmacDrbgContext, loginHint = ""): AuthRequestData =
198+ ## Pushed Authorization Request: hand the parameters to the auth server up
199+ ## front and get back a short-lived request_uri to redirect the user with.
200+ let
201+ state = secureRandomB64(16)
202+ verifier = secureRandomB64(48)
203+ key = generateKey(rng)
204+
205+ var params = @[
206+ ("client_id", cfg.clientId),
207+ ("state", state),
208+ ("redirect_uri", cfg.callbackUrl),
209+ ("scope", scopeStr(cfg.scopes)),
210+ ("response_type", "code"),
211+ ("code_challenge", s256Challenge(verifier)),
212+ ("code_challenge_method", "S256"),
213+ ]
214+ if loginHint.len > 0:
215+ params.add ("login_hint", loginHint)
216+
217+ var nonce = ""
218+ let res = postWithDpop(meta.parEndpoint, params.encodeQuery, key, rng, nonce)
219+ if res.code.int notin [200, 201]:
220+ raise newException(OAuthError,
221+ &"PAR failed (HTTP {res.code.int}): {errorReason(res.body)}")
222+
223+ let requestUri = parseJson(res.body){"request_uri"}.getStr
224+ if requestUri.len == 0:
225+ raise newException(OAuthError, "PAR response has no request_uri")
226+
227+ AuthRequestData(
228+ state: state,
229+ authServerUrl: meta.issuer,
230+ scopes: cfg.scopes,
231+ pkceVerifier: verifier,
232+ requestUri: requestUri,
233+ tokenEndpoint: meta.tokenEndpoint,
234+ revocationEndpoint: meta.revocationEndpoint,
235+ dpopNonce: nonce,
236+ dpopKey: key,
237+ )
238+
239+proc authorizeUrl*(cfg: ClientConfig, meta: AuthServerMetadata,
240+ info: AuthRequestData): string =
241+ let params = @[("client_id", cfg.clientId), ("request_uri", info.requestUri)]
242+ meta.authorizationEndpoint & "?" & params.encodeQuery
243+
244+proc startAuthFlow*(cfg: ClientConfig, identifier: string,
245+ rng: var HmacDrbgContext,
246+ plcUrl = DefaultPlcUrl): (string, AuthRequestData) =
247+ ## Resolve an account (or take an auth server URL directly), push the
248+ ## request, and return the URL to send the user to.
249+ var
250+ authServerUrl: string
251+ accountDid: string
252+ loginHint: string
253+
254+ if identifier.startsWith("https://"):
255+ authServerUrl = identifier.strip(chars = {'/'})
256+ else:
257+ let ident = lookup(identifier, plcUrl)
258+ accountDid = ident.did
259+ loginHint = identifier
260+ authServerUrl = resolveAuthServerUrl(ident.pdsEndpoint)
261+
262+ let meta = resolveAuthServerMetadata(authServerUrl)
263+ var info = sendAuthRequest(cfg, meta, rng, loginHint)
264+ info.accountDid = accountDid
265+ (authorizeUrl(cfg, meta, info), info)
266+
267+proc exchangeCode*(cfg: ClientConfig, info: var AuthRequestData,
268+ code: string, rng: var HmacDrbgContext): TokenResponse =
269+ ## Trade the authorization code for tokens, proving possession of both the
270+ ## PKCE verifier and the session's DPoP key.
271+ let params = @[
272+ ("client_id", cfg.clientId),
273+ ("redirect_uri", cfg.callbackUrl),
274+ ("grant_type", "authorization_code"),
275+ ("code", code),
276+ ("code_verifier", info.pkceVerifier),
277+ ]
278+ var nonce = info.dpopNonce
279+ let res = postWithDpop(info.tokenEndpoint, params.encodeQuery,
280+ info.dpopKey, rng, nonce)
281+ info.dpopNonce = nonce
282+ if res.code.int != 200:
283+ raise newException(OAuthError,
284+ &"token request failed (HTTP {res.code.int}): {errorReason(res.body)}")
285+
286+ let b = parseJson(res.body)
287+ result = TokenResponse(
288+ accessToken: b{"access_token"}.getStr,
289+ refreshToken: b{"refresh_token"}.getStr,
290+ tokenType: b{"token_type"}.getStr,
291+ expiresIn: b{"expires_in"}.getInt,
292+ scope: b{"scope"}.getStr,
293+ sub: b{"sub"}.getStr,
294+ )
295+
296+ # DPoP-bound tokens are useless as bearer tokens; a server answering
297+ # token_type=Bearer means the binding silently did not happen.
298+ if not result.tokenType.toLowerAscii.startsWith("dpop"):
299+ raise newException(OAuthError,
300+ "expected a DPoP-bound token, got token_type=" & result.tokenType)
301+ if result.sub.len == 0:
302+ raise newException(OAuthError, "token response has no subject DID")
303+ if info.accountDid.len > 0 and result.sub != info.accountDid:
304+ raise newException(OAuthError,
305+ &"token subject {result.sub} does not match requested account {info.accountDid}")
306+
307+proc refresh*(cfg: ClientConfig, info: var AuthRequestData,
308+ refreshToken: string, rng: var HmacDrbgContext): TokenResponse =
309+ let params = @[
310+ ("client_id", cfg.clientId),
311+ ("grant_type", "refresh_token"),
312+ ("refresh_token", refreshToken),
313+ ]
314+ var nonce = info.dpopNonce
315+ let res = postWithDpop(info.tokenEndpoint, params.encodeQuery,
316+ info.dpopKey, rng, nonce)
317+ info.dpopNonce = nonce
318+ if res.code.int != 200:
319+ raise newException(OAuthError,
320+ &"refresh failed (HTTP {res.code.int}): {errorReason(res.body)}")
321+ let b = parseJson(res.body)
322+ TokenResponse(
323+ accessToken: b{"access_token"}.getStr,
324+ refreshToken: b{"refresh_token"}.getStr,
325+ tokenType: b{"token_type"}.getStr,
326+ expiresIn: b{"expires_in"}.getInt,
327+ scope: b{"scope"}.getStr,
328+ sub: b{"sub"}.getStr,
329+ )
new file mode 100644
@@ -0,0 +1,329 @@
1+## ATProto OAuth client: metadata discovery, PAR, authorization URL, and the
2+## token exchange, ported from the shape of bluesky-social/indigo's
3+## atproto/auth/oauth package.
4+##
5+## Two details drive most of the design:
6+##
7+## 1. Every request to the auth server carries a DPoP proof bound to a
8+## per-session P-256 key, and the server will reject the first one with
9+## `use_dpop_nonce` plus a DPoP-Nonce header. That is normal, not an error:
10+## the request must be retried once with the supplied nonce. Note the
11+## status is 400 on the auth server (not the 401 a resource server uses).
12+##
13+## 2. PKCE is mandatory. The verifier is kept alongside the request so the
14+## callback can present it.
15+
16+import std/[httpclient, json, strutils, strformat, sysrand, uri]
17+import nimcrypto/[sha2, hash]
18+import bearssl/rand
19+import ./dpop
20+import ./identity
21+
22+type
23+ OAuthError* = object of CatchableError
24+
25+ ClientConfig* = object
26+ clientId*: string
27+ callbackUrl*: string
28+ scopes*: seq[string]
29+
30+ AuthServerMetadata* = object
31+ issuer*: string
32+ authorizationEndpoint*: string
33+ tokenEndpoint*: string
34+ parEndpoint*: string
35+ revocationEndpoint*: string
36+
37+ AuthRequestData* = object ## Persisted between the redirect and the callback.
38+ state*: string
39+ authServerUrl*: string
40+ scopes*: seq[string]
41+ pkceVerifier*: string
42+ requestUri*: string
43+ tokenEndpoint*: string
44+ revocationEndpoint*: string
45+ dpopNonce*: string
46+ dpopKey*: P256Key
47+ accountDid*: string
48+
49+ TokenResponse* = object
50+ accessToken*: string
51+ refreshToken*: string
52+ tokenType*: string
53+ expiresIn*: int
54+ scope*: string
55+ sub*: string
56+
57+const HttpTimeoutMs = 15_000
58+
59+proc newClient(): HttpClient =
60+ newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs)
61+
62+proc scopeStr*(scopes: seq[string]): string = scopes.join(" ")
63+
64+proc newPublicConfig*(clientId, callbackUrl: string,
65+ scopes = @["atproto", "transition:generic"]): ClientConfig =
66+ ClientConfig(clientId: clientId, callbackUrl: callbackUrl, scopes: scopes)
67+
68+proc newLocalhostConfig*(callbackUrl: string,
69+ scopes = @["atproto", "transition:generic"]): ClientConfig =
70+ ## Localhost development clients have no hosted metadata document; the
71+ ## client_id encodes the redirect and scopes instead.
72+ var params = @[("redirect_uri", callbackUrl), ("scope", scopeStr(scopes))]
73+ ClientConfig(
74+ clientId: "http://localhost" & "?" & params.encodeQuery,
75+ callbackUrl: callbackUrl,
76+ scopes: scopes,
77+ )
78+
79+proc clientMetadata*(cfg: ClientConfig): JsonNode =
80+ ## Served at the client_id URL for a public (non-confidential) client.
81+ %*{
82+ "client_id": cfg.clientId,
83+ "application_type": "web",
84+ "client_name": "glean",
85+ "grant_types": ["authorization_code", "refresh_token"],
86+ "scope": scopeStr(cfg.scopes),
87+ "response_types": ["code"],
88+ "redirect_uris": [cfg.callbackUrl],
89+ "token_endpoint_auth_method": "none",
90+ "dpop_bound_access_tokens": true,
91+ }
92+
93+proc secureRandomB64*(n: int): string =
94+ var buf = newSeq[byte](n)
95+ doAssert urandom(buf), "system randomness unavailable"
96+ b64u(buf)
97+
98+proc s256Challenge*(verifier: string): string =
99+ b64u(sha256.digest(verifier).data)
100+
101+# --- discovery -------------------------------------------------------------
102+
103+proc resolveAuthServerUrl*(pdsUrl: string): string =
104+ ## The PDS advertises which authorization server governs it.
105+ let url = &"https://{hostOf(pdsUrl)}/.well-known/oauth-protected-resource"
106+ let client = newClient()
107+ defer: client.close()
108+ let res = client.get(url)
109+ if res.code.int != 200:
110+ raise newException(OAuthError,
111+ &"protected resource metadata failed (HTTP {res.code.int}) at {url}")
112+ let body = parseJson(res.body)
113+ let servers = body{"authorization_servers"}
114+ if servers == nil or servers.kind != JArray or servers.len == 0:
115+ raise newException(OAuthError, "PDS lists no authorization servers: " & pdsUrl)
116+ servers[0].getStr.strip(chars = {'/'})
117+
118+proc resolveAuthServerMetadata*(authServerUrl: string): AuthServerMetadata =
119+ let url = &"https://{hostOf(authServerUrl)}/.well-known/oauth-authorization-server"
120+ let client = newClient()
121+ defer: client.close()
122+ let res = client.get(url)
123+ if res.code.int != 200:
124+ raise newException(OAuthError,
125+ &"auth server metadata failed (HTTP {res.code.int}) at {url}")
126+ let b = parseJson(res.body)
127+
128+ result = AuthServerMetadata(
129+ issuer: b{"issuer"}.getStr,
130+ authorizationEndpoint: b{"authorization_endpoint"}.getStr,
131+ tokenEndpoint: b{"token_endpoint"}.getStr,
132+ parEndpoint: b{"pushed_authorization_request_endpoint"}.getStr,
133+ revocationEndpoint: b{"revocation_endpoint"}.getStr,
134+ )
135+
136+ # An auth server missing any of these cannot complete an ATProto flow, and
137+ # failing here beats a confusing error three requests later.
138+ for (name, value) in {
139+ "issuer": result.issuer,
140+ "authorization_endpoint": result.authorizationEndpoint,
141+ "token_endpoint": result.tokenEndpoint,
142+ "pushed_authorization_request_endpoint": result.parEndpoint,
143+ }:
144+ if value.len == 0:
145+ raise newException(OAuthError, "auth server metadata is missing " & name)
146+
147+ if result.issuer.strip(chars = {'/'}) != authServerUrl.strip(chars = {'/'}):
148+ raise newException(OAuthError,
149+ &"auth server issuer mismatch: metadata says {result.issuer}, fetched from {authServerUrl}")
150+
151+# --- requests --------------------------------------------------------------
152+
153+proc errorReason(body: string): string =
154+ try:
155+ let j = parseJson(body)
156+ result = j{"error"}.getStr
157+ let desc = j{"error_description"}.getStr
158+ if desc.len > 0:
159+ result &= ": " & desc
160+ except CatchableError:
161+ result = "unknown"
162+ if result.len == 0:
163+ result = "unknown"
164+
165+proc errorCode(body: string): string =
166+ try: parseJson(body){"error"}.getStr
167+ except CatchableError: ""
168+
169+proc postWithDpop(url, body: string, key: P256Key, rng: var HmacDrbgContext,
170+ nonce: var string): Response =
171+ ## POST with a DPoP proof, retrying once when the server demands a nonce.
172+ ##
173+ ## The first request of a session always lacks a nonce, so the retry is the
174+ ## expected path rather than an error case. The auth server signals this
175+ ## with 400 + error=use_dpop_nonce and supplies DPoP-Nonce.
176+ let client = newClient()
177+ defer: client.close()
178+
179+ for attempt in 0 .. 1:
180+ let proof = dpopProof(key, rng, "POST", url, nonce = nonce)
181+ client.headers = newHttpHeaders({
182+ "Content-Type": "application/x-www-form-urlencoded",
183+ "DPoP": proof,
184+ })
185+ result = client.post(url, body = body)
186+
187+ let supplied = result.headers.getOrDefault("DPoP-Nonce").string
188+ if supplied.len > 0:
189+ nonce = supplied
190+
191+ if attempt == 0 and result.code.int == 400 and supplied.len > 0:
192+ if errorCode(result.body) == "use_dpop_nonce":
193+ continue
194+ return result
195+
196+proc sendAuthRequest*(cfg: ClientConfig, meta: AuthServerMetadata,
197+ rng: var HmacDrbgContext, loginHint = ""): AuthRequestData =
198+ ## Pushed Authorization Request: hand the parameters to the auth server up
199+ ## front and get back a short-lived request_uri to redirect the user with.
200+ let
201+ state = secureRandomB64(16)
202+ verifier = secureRandomB64(48)
203+ key = generateKey(rng)
204+
205+ var params = @[
206+ ("client_id", cfg.clientId),
207+ ("state", state),
208+ ("redirect_uri", cfg.callbackUrl),
209+ ("scope", scopeStr(cfg.scopes)),
210+ ("response_type", "code"),
211+ ("code_challenge", s256Challenge(verifier)),
212+ ("code_challenge_method", "S256"),
213+ ]
214+ if loginHint.len > 0:
215+ params.add ("login_hint", loginHint)
216+
217+ var nonce = ""
218+ let res = postWithDpop(meta.parEndpoint, params.encodeQuery, key, rng, nonce)
219+ if res.code.int notin [200, 201]:
220+ raise newException(OAuthError,
221+ &"PAR failed (HTTP {res.code.int}): {errorReason(res.body)}")
222+
223+ let requestUri = parseJson(res.body){"request_uri"}.getStr
224+ if requestUri.len == 0:
225+ raise newException(OAuthError, "PAR response has no request_uri")
226+
227+ AuthRequestData(
228+ state: state,
229+ authServerUrl: meta.issuer,
230+ scopes: cfg.scopes,
231+ pkceVerifier: verifier,
232+ requestUri: requestUri,
233+ tokenEndpoint: meta.tokenEndpoint,
234+ revocationEndpoint: meta.revocationEndpoint,
235+ dpopNonce: nonce,
236+ dpopKey: key,
237+ )
238+
239+proc authorizeUrl*(cfg: ClientConfig, meta: AuthServerMetadata,
240+ info: AuthRequestData): string =
241+ let params = @[("client_id", cfg.clientId), ("request_uri", info.requestUri)]
242+ meta.authorizationEndpoint & "?" & params.encodeQuery
243+
244+proc startAuthFlow*(cfg: ClientConfig, identifier: string,
245+ rng: var HmacDrbgContext,
246+ plcUrl = DefaultPlcUrl): (string, AuthRequestData) =
247+ ## Resolve an account (or take an auth server URL directly), push the
248+ ## request, and return the URL to send the user to.
249+ var
250+ authServerUrl: string
251+ accountDid: string
252+ loginHint: string
253+
254+ if identifier.startsWith("https://"):
255+ authServerUrl = identifier.strip(chars = {'/'})
256+ else:
257+ let ident = lookup(identifier, plcUrl)
258+ accountDid = ident.did
259+ loginHint = identifier
260+ authServerUrl = resolveAuthServerUrl(ident.pdsEndpoint)
261+
262+ let meta = resolveAuthServerMetadata(authServerUrl)
263+ var info = sendAuthRequest(cfg, meta, rng, loginHint)
264+ info.accountDid = accountDid
265+ (authorizeUrl(cfg, meta, info), info)
266+
267+proc exchangeCode*(cfg: ClientConfig, info: var AuthRequestData,
268+ code: string, rng: var HmacDrbgContext): TokenResponse =
269+ ## Trade the authorization code for tokens, proving possession of both the
270+ ## PKCE verifier and the session's DPoP key.
271+ let params = @[
272+ ("client_id", cfg.clientId),
273+ ("redirect_uri", cfg.callbackUrl),
274+ ("grant_type", "authorization_code"),
275+ ("code", code),
276+ ("code_verifier", info.pkceVerifier),
277+ ]
278+ var nonce = info.dpopNonce
279+ let res = postWithDpop(info.tokenEndpoint, params.encodeQuery,
280+ info.dpopKey, rng, nonce)
281+ info.dpopNonce = nonce
282+ if res.code.int != 200:
283+ raise newException(OAuthError,
284+ &"token request failed (HTTP {res.code.int}): {errorReason(res.body)}")
285+
286+ let b = parseJson(res.body)
287+ result = TokenResponse(
288+ accessToken: b{"access_token"}.getStr,
289+ refreshToken: b{"refresh_token"}.getStr,
290+ tokenType: b{"token_type"}.getStr,
291+ expiresIn: b{"expires_in"}.getInt,
292+ scope: b{"scope"}.getStr,
293+ sub: b{"sub"}.getStr,
294+ )
295+
296+ # DPoP-bound tokens are useless as bearer tokens; a server answering
297+ # token_type=Bearer means the binding silently did not happen.
298+ if not result.tokenType.toLowerAscii.startsWith("dpop"):
299+ raise newException(OAuthError,
300+ "expected a DPoP-bound token, got token_type=" & result.tokenType)
301+ if result.sub.len == 0:
302+ raise newException(OAuthError, "token response has no subject DID")
303+ if info.accountDid.len > 0 and result.sub != info.accountDid:
304+ raise newException(OAuthError,
305+ &"token subject {result.sub} does not match requested account {info.accountDid}")
306+
307+proc refresh*(cfg: ClientConfig, info: var AuthRequestData,
308+ refreshToken: string, rng: var HmacDrbgContext): TokenResponse =
309+ let params = @[
310+ ("client_id", cfg.clientId),
311+ ("grant_type", "refresh_token"),
312+ ("refresh_token", refreshToken),
313+ ]
314+ var nonce = info.dpopNonce
315+ let res = postWithDpop(info.tokenEndpoint, params.encodeQuery,
316+ info.dpopKey, rng, nonce)
317+ info.dpopNonce = nonce
318+ if res.code.int != 200:
319+ raise newException(OAuthError,
320+ &"refresh failed (HTTP {res.code.int}): {errorReason(res.body)}")
321+ let b = parseJson(res.body)
322+ TokenResponse(
323+ accessToken: b{"access_token"}.getStr,
324+ refreshToken: b{"refresh_token"}.getStr,
325+ tokenType: b{"token_type"}.getStr,
326+ expiresIn: b{"expires_in"}.getInt,
327+ scope: b{"scope"}.getStr,
328+ sub: b{"sub"}.getStr,
329+ )
modified spike/nim/dpop_probe.nim +5 -107
@@ -1,112 +1,10 @@
1-## Spike: can Nim produce ATProto-compatible ES256 / DPoP proofs?
1+## Cross-checks atproto/dpop.nim against an independent Go verifier.
22 ##
3-## This is the riskiest part of a Nim port. Glean's Go backend gets OAuth from
4-## bluesky-social/indigo; Nim has no ATProto library, so the port would have to
5-## build the DPoP layer itself. Everything downstream of sign-in depends on it,
6-## so it is worth proving before committing to the rest.
7-##
8-## What has to be exactly right:
9-## * ES256 signatures in JOSE form -- raw R||S, 64 bytes, NOT the DER that
10-## most crypto libraries hand you.
11-## * An RFC 7638 JWK thumbprint, which is a SHA-256 over canonical JSON:
12-## exactly the members crv/kty/x/y, lexicographically ordered, no spaces.
13-## * base64url without padding, everywhere.
14-##
15-## bearssl is used because br_ecdsa_sign_raw already emits R||S, so there is no
16-## DER unwrapping step to get wrong.
17-##
18-## Self-verification would only prove internal consistency, so the signed token
19-## is handed to a Go verifier (verify/verify.go) built on the same stdlib the
20-## real server uses.
21-
22-import std/[base64, json, strutils, times, strformat]
23-import bearssl/[ec, rand]
24-import bearssl/abi/[bearssl_ec, bearssl_rand, bearssl_hash]
25-import nimcrypto/[sha2, hash]
26-
27-type P256Key* = object
28- priv*: array[32, byte]
29- pubX*: array[32, byte]
30- pubY*: array[32, byte]
31-
32-proc toSeq(a: openArray[byte]): seq[byte] =
33- result = newSeq[byte](a.len)
34- for i, b in a: result[i] = b
35-
36-proc b64u*(data: openArray[byte]): string =
37- ## base64url, unpadded (RFC 7515 §2).
38- base64.encode(data, safe = true).strip(chars = {'='})
39-
40-proc b64u*(s: string): string =
41- b64u(s.toOpenArrayByte(0, s.high).toSeq)
42-
43-proc generateKey*(rng: var HmacDrbgContext): P256Key =
44- ## P-256 keypair. bearssl returns the public point uncompressed:
45- ## 0x04 || X(32) || Y(32).
46- var
47- skBuf: array[EC_KBUF_PRIV_MAX_SIZE, byte]
48- pkBuf: array[EC_KBUF_PUB_MAX_SIZE, byte]
49- sk: EcPrivateKey
50- pk: EcPublicKey
51-
52- let n = ecKeygen(PrngClassPointerConst(addr rng.vtable), addr ecPrimeI31, addr sk,
53- addr skBuf[0], EC_secp256r1.cint)
54- doAssert n > 0, "ecKeygen failed"
55- let m = ecComputePub(addr ecPrimeI31, addr pk, addr pkBuf[0], addr sk)
56- doAssert m > 0, "ecComputePub failed"
57-
58- doAssert sk.xlen == 32, &"unexpected private key length {sk.xlen}"
59- doAssert pk.qlen == 65 and cast[ptr UncheckedArray[byte]](pk.q)[0] == 0x04'u8,
60- "expected an uncompressed public point"
61-
62- let
63- skBytes = cast[ptr UncheckedArray[byte]](sk.x)
64- pkBytes = cast[ptr UncheckedArray[byte]](pk.q)
65- for i in 0 ..< 32:
66- result.priv[i] = skBytes[i]
67- result.pubX[i] = pkBytes[1 + i]
68- result.pubY[i] = pkBytes[33 + i]
69-
70-proc publicJwk*(k: P256Key): JsonNode =
71- %*{"crv": "P-256", "kty": "EC", "x": b64u(k.pubX), "y": b64u(k.pubY)}
72-
73-proc thumbprint*(k: P256Key): string =
74- ## RFC 7638: SHA-256 over the canonical JWK. The member set and ordering are
75- ## fixed by the spec, so this is built by hand rather than serialised from a
76- ## JsonNode whose key order is incidental.
77- let canonical = &"""{{"crv":"P-256","kty":"EC","x":"{b64u(k.pubX)}","y":"{b64u(k.pubY)}"}}"""
78- b64u(sha256.digest(canonical).data)
79-
80-proc signEs256*(k: P256Key, rng: var HmacDrbgContext, signingInput: string): seq[byte] =
81- var
82- sk: EcPrivateKey
83- priv = k.priv
84- sk.curve = EC_secp256r1.cint
85- sk.x = cast[ptr byte](addr priv[0])
86- sk.xlen = 32
87-
88- let digest = sha256.digest(signingInput).data
89- var sig: array[64, byte]
90- let n = ecdsaSignRawGetDefault()(
91- addr ecPrimeI31, addr sha256Vtable, unsafeAddr digest[0], addr sk, addr sig[0])
92- doAssert n == 64, &"expected a 64-byte raw signature, got {n}"
93- result = sig.toSeq
94-
95-proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string,
96- nonce = ""): string =
97- ## A DPoP proof JWT (RFC 9449) as an ATProto PDS expects it.
98- let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk}
99- var payload = %*{
100- "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16],
101- "htm": htm,
102- "htu": htu,
103- "iat": now().utc.toTime.toUnix,
104- }
105- if nonce.len > 0:
106- payload["nonce"] = %nonce
3+## nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .)
1074
108- let signingInput = b64u($header) & "." & b64u($payload)
109- signingInput & "." & b64u(k.signEs256(rng, signingInput))
5+import std/json
6+import bearssl/rand
7+import atproto/dpop
1108
1119 when isMainModule:
11210 let rngRef = HmacDrbgContext.new()
@@ -1,112 +1,10 @@
1-## Spike: can Nim produce ATProto-compatible ES256 / DPoP proofs?1+## Cross-checks atproto/dpop.nim against an independent Go verifier.
2 ##2 ##
3-## This is the riskiest part of a Nim port. Glean's Go backend gets OAuth from3+## nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .)
4-## bluesky-social/indigo; Nim has no ATProto library, so the port would have to
5-## build the DPoP layer itself. Everything downstream of sign-in depends on it,
6-## so it is worth proving before committing to the rest.
7-##
8-## What has to be exactly right:
9-## * ES256 signatures in JOSE form -- raw R||S, 64 bytes, NOT the DER that
10-## most crypto libraries hand you.
11-## * An RFC 7638 JWK thumbprint, which is a SHA-256 over canonical JSON:
12-## exactly the members crv/kty/x/y, lexicographically ordered, no spaces.
13-## * base64url without padding, everywhere.
14-##
15-## bearssl is used because br_ecdsa_sign_raw already emits R||S, so there is no
16-## DER unwrapping step to get wrong.
17-##
18-## Self-verification would only prove internal consistency, so the signed token
19-## is handed to a Go verifier (verify/verify.go) built on the same stdlib the
20-## real server uses.
21-
22-import std/[base64, json, strutils, times, strformat]
23-import bearssl/[ec, rand]
24-import bearssl/abi/[bearssl_ec, bearssl_rand, bearssl_hash]
25-import nimcrypto/[sha2, hash]
26-
27-type P256Key* = object
28- priv*: array[32, byte]
29- pubX*: array[32, byte]
30- pubY*: array[32, byte]
31-
32-proc toSeq(a: openArray[byte]): seq[byte] =
33- result = newSeq[byte](a.len)
34- for i, b in a: result[i] = b
35-
36-proc b64u*(data: openArray[byte]): string =
37- ## base64url, unpadded (RFC 7515 §2).
38- base64.encode(data, safe = true).strip(chars = {'='})
39-
40-proc b64u*(s: string): string =
41- b64u(s.toOpenArrayByte(0, s.high).toSeq)
42-
43-proc generateKey*(rng: var HmacDrbgContext): P256Key =
44- ## P-256 keypair. bearssl returns the public point uncompressed:
45- ## 0x04 || X(32) || Y(32).
46- var
47- skBuf: array[EC_KBUF_PRIV_MAX_SIZE, byte]
48- pkBuf: array[EC_KBUF_PUB_MAX_SIZE, byte]
49- sk: EcPrivateKey
50- pk: EcPublicKey
51-
52- let n = ecKeygen(PrngClassPointerConst(addr rng.vtable), addr ecPrimeI31, addr sk,
53- addr skBuf[0], EC_secp256r1.cint)
54- doAssert n > 0, "ecKeygen failed"
55- let m = ecComputePub(addr ecPrimeI31, addr pk, addr pkBuf[0], addr sk)
56- doAssert m > 0, "ecComputePub failed"
57-
58- doAssert sk.xlen == 32, &"unexpected private key length {sk.xlen}"
59- doAssert pk.qlen == 65 and cast[ptr UncheckedArray[byte]](pk.q)[0] == 0x04'u8,
60- "expected an uncompressed public point"
61-
62- let
63- skBytes = cast[ptr UncheckedArray[byte]](sk.x)
64- pkBytes = cast[ptr UncheckedArray[byte]](pk.q)
65- for i in 0 ..< 32:
66- result.priv[i] = skBytes[i]
67- result.pubX[i] = pkBytes[1 + i]
68- result.pubY[i] = pkBytes[33 + i]
69-
70-proc publicJwk*(k: P256Key): JsonNode =
71- %*{"crv": "P-256", "kty": "EC", "x": b64u(k.pubX), "y": b64u(k.pubY)}
72-
73-proc thumbprint*(k: P256Key): string =
74- ## RFC 7638: SHA-256 over the canonical JWK. The member set and ordering are
75- ## fixed by the spec, so this is built by hand rather than serialised from a
76- ## JsonNode whose key order is incidental.
77- let canonical = &"""{{"crv":"P-256","kty":"EC","x":"{b64u(k.pubX)}","y":"{b64u(k.pubY)}"}}"""
78- b64u(sha256.digest(canonical).data)
79-
80-proc signEs256*(k: P256Key, rng: var HmacDrbgContext, signingInput: string): seq[byte] =
81- var
82- sk: EcPrivateKey
83- priv = k.priv
84- sk.curve = EC_secp256r1.cint
85- sk.x = cast[ptr byte](addr priv[0])
86- sk.xlen = 32
87-
88- let digest = sha256.digest(signingInput).data
89- var sig: array[64, byte]
90- let n = ecdsaSignRawGetDefault()(
91- addr ecPrimeI31, addr sha256Vtable, unsafeAddr digest[0], addr sk, addr sig[0])
92- doAssert n == 64, &"expected a 64-byte raw signature, got {n}"
93- result = sig.toSeq
94-
95-proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string,
96- nonce = ""): string =
97- ## A DPoP proof JWT (RFC 9449) as an ATProto PDS expects it.
98- let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk}
99- var payload = %*{
100- "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16],
101- "htm": htm,
102- "htu": htu,
103- "iat": now().utc.toTime.toUnix,
104- }
105- if nonce.len > 0:
106- payload["nonce"] = %nonce
107 4
108- let signingInput = b64u($header) & "." & b64u($payload)5+import std/json
109- signingInput & "." & b64u(k.signEs256(rng, signingInput))6+import bearssl/rand
7+import atproto/dpop
110 8
111 when isMainModule:9 when isMainModule:
112 let rngRef = HmacDrbgContext.new()10 let rngRef = HmacDrbgContext.new()
added spike/nim/oauth_probe.nim +140 -0
new file mode 100644
@@ -0,0 +1,140 @@
1+## Drives the OAuth flow against a real PDS, as far as it can go without a
2+## human at a browser.
3+##
4+## nim c -r -d:ssl oauth_probe.nim [handle]
5+##
6+## Everything here is unauthenticated and read-only except the PAR, which
7+## creates a short-lived pending authorization the server discards on its own.
8+## Getting a request_uri back is the meaningful result: it means the DPoP
9+## proof was accepted, the nonce retry worked, and PKCE was well-formed.
10+
11+import std/[httpclient, json, os, strformat, strutils, uri]
12+import bearssl/rand
13+import atproto/[dpop, identity, oauth]
14+
15+var failures = 0
16+
17+proc report(name: string, ok: bool, detail = "") =
18+ if not ok: inc failures
19+ let label = if ok: "PASS" else: "FAIL"
20+ if detail.len > 0:
21+ echo &" {label} {name} -- {detail}"
22+ else:
23+ echo &" {label} {name}"
24+
25+when isMainModule:
26+ let handle = if paramCount() >= 1: paramStr(1) else: "bsky.app"
27+ echo "ATProto OAuth flow probe"
28+ echo &" identifier: {handle}"
29+ echo ""
30+
31+ let rngRef = HmacDrbgContext.new()
32+ doAssert rngRef != nil, "no system randomness"
33+ var rng = rngRef[]
34+
35+ # --- resolution ---------------------------------------------------------
36+ var ident: Identity
37+ try:
38+ ident = lookup(handle)
39+ report("handle resolves to a DID", ident.did.isDid, ident.did)
40+ report("identity links to a PDS", ident.pdsEndpoint.len > 0, ident.pdsEndpoint)
41+ except CatchableError as e:
42+ report("handle resolves", false, e.msg)
43+ quit 1
44+
45+ # --- discovery ----------------------------------------------------------
46+ var authServerUrl: string
47+ try:
48+ authServerUrl = resolveAuthServerUrl(ident.pdsEndpoint)
49+ report("PDS advertises an auth server", authServerUrl.len > 0, authServerUrl)
50+ except CatchableError as e:
51+ report("PDS advertises an auth server", false, e.msg)
52+ quit 1
53+
54+ var meta: AuthServerMetadata
55+ try:
56+ meta = resolveAuthServerMetadata(authServerUrl)
57+ report("auth server metadata fetched", true, meta.issuer)
58+ report("has a PAR endpoint", meta.parEndpoint.len > 0, meta.parEndpoint)
59+ report("has a token endpoint", meta.tokenEndpoint.len > 0, meta.tokenEndpoint)
60+ except CatchableError as e:
61+ report("auth server metadata fetched", false, e.msg)
62+ quit 1
63+
64+ # --- PKCE ---------------------------------------------------------------
65+ block:
66+ # RFC 7636 test vector: this verifier must produce this challenge.
67+ const
68+ verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
69+ want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
70+ report("PKCE S256 matches RFC 7636 vector",
71+ s256Challenge(verifier) == want, s256Challenge(verifier))
72+
73+ # --- the nonce dance ----------------------------------------------------
74+ # sendAuthRequest retries transparently, which would hide a server that
75+ # never demanded a nonce. Prove the first unnonced attempt really is
76+ # rejected, so the retry path is load-bearing rather than decorative.
77+ let cfg0 = newLocalhostConfig("http://127.0.0.1:8080/callback")
78+ block:
79+ let
80+ key = generateKey(rng)
81+ proof = dpopProof(key, rng, "POST", meta.parEndpoint)
82+ client = newHttpClient(userAgent = UserAgent, timeout = 15_000)
83+ defer: client.close()
84+ client.headers = newHttpHeaders({
85+ "Content-Type": "application/x-www-form-urlencoded",
86+ "DPoP": proof,
87+ })
88+ # The body must be fully valid: bsky.social validates parameters before it
89+ # checks the nonce, so a stub body returns invalid_request and tells us
90+ # nothing about the nonce requirement.
91+ let verifier = secureRandomB64(48)
92+ let body = @{
93+ "client_id": cfg0.clientId,
94+ "state": secureRandomB64(16),
95+ "redirect_uri": cfg0.callbackUrl,
96+ "scope": scopeStr(cfg0.scopes),
97+ "response_type": "code",
98+ "code_challenge": s256Challenge(verifier),
99+ "code_challenge_method": "S256",
100+ }.encodeQuery
101+ let res = client.post(meta.parEndpoint, body = body)
102+ let code = try: parseJson(res.body){"error"}.getStr except CatchableError: ""
103+ let nonce = res.headers.getOrDefault("DPoP-Nonce").string
104+ report("first PAR without a nonce is rejected",
105+ res.code.int == 400 and code == "use_dpop_nonce", &"HTTP {res.code.int} {code}")
106+ report("rejection supplies a DPoP-Nonce to retry with", nonce.len > 0)
107+
108+ # --- PAR ----------------------------------------------------------------
109+ # A localhost client needs no hosted metadata document, so the flow can be
110+ # exercised without deploying anything.
111+ let cfg = cfg0
112+ report("localhost client_id is well-formed",
113+ cfg.clientId.startsWith("http://localhost?"), cfg.clientId)
114+
115+ try:
116+ let info = sendAuthRequest(cfg, meta, rng, loginHint = handle)
117+ report("PAR accepted (DPoP proof + nonce retry + PKCE)",
118+ info.requestUri.startsWith("urn:ietf:params:oauth:request_uri:"),
119+ info.requestUri)
120+ report("auth server issued a DPoP nonce", info.dpopNonce.len > 0,
121+ info.dpopNonce[0 ..< min(12, info.dpopNonce.len)] & "...")
122+ report("PKCE verifier retained for callback", info.pkceVerifier.len >= 43)
123+ report("state is unguessable", info.state.len >= 21, &"{info.state.len} chars")
124+
125+ let url = authorizeUrl(cfg, meta, info)
126+ let q = parseUri(url).query
127+ report("authorize URL carries request_uri", "request_uri=" in q)
128+ report("authorize URL carries client_id", "client_id=" in q)
129+ echo ""
130+ echo " authorize URL:"
131+ echo " ", url
132+ except CatchableError as e:
133+ report("PAR accepted", false, e.msg)
134+
135+ echo ""
136+ if failures == 0:
137+ echo "RESULT: the OAuth flow works against a live PDS up to user consent."
138+ else:
139+ echo &"RESULT: {failures} check(s) failed."
140+ quit 1
new file mode 100644
@@ -0,0 +1,140 @@
1+## Drives the OAuth flow against a real PDS, as far as it can go without a
2+## human at a browser.
3+##
4+## nim c -r -d:ssl oauth_probe.nim [handle]
5+##
6+## Everything here is unauthenticated and read-only except the PAR, which
7+## creates a short-lived pending authorization the server discards on its own.
8+## Getting a request_uri back is the meaningful result: it means the DPoP
9+## proof was accepted, the nonce retry worked, and PKCE was well-formed.
10+
11+import std/[httpclient, json, os, strformat, strutils, uri]
12+import bearssl/rand
13+import atproto/[dpop, identity, oauth]
14+
15+var failures = 0
16+
17+proc report(name: string, ok: bool, detail = "") =
18+ if not ok: inc failures
19+ let label = if ok: "PASS" else: "FAIL"
20+ if detail.len > 0:
21+ echo &" {label} {name} -- {detail}"
22+ else:
23+ echo &" {label} {name}"
24+
25+when isMainModule:
26+ let handle = if paramCount() >= 1: paramStr(1) else: "bsky.app"
27+ echo "ATProto OAuth flow probe"
28+ echo &" identifier: {handle}"
29+ echo ""
30+
31+ let rngRef = HmacDrbgContext.new()
32+ doAssert rngRef != nil, "no system randomness"
33+ var rng = rngRef[]
34+
35+ # --- resolution ---------------------------------------------------------
36+ var ident: Identity
37+ try:
38+ ident = lookup(handle)
39+ report("handle resolves to a DID", ident.did.isDid, ident.did)
40+ report("identity links to a PDS", ident.pdsEndpoint.len > 0, ident.pdsEndpoint)
41+ except CatchableError as e:
42+ report("handle resolves", false, e.msg)
43+ quit 1
44+
45+ # --- discovery ----------------------------------------------------------
46+ var authServerUrl: string
47+ try:
48+ authServerUrl = resolveAuthServerUrl(ident.pdsEndpoint)
49+ report("PDS advertises an auth server", authServerUrl.len > 0, authServerUrl)
50+ except CatchableError as e:
51+ report("PDS advertises an auth server", false, e.msg)
52+ quit 1
53+
54+ var meta: AuthServerMetadata
55+ try:
56+ meta = resolveAuthServerMetadata(authServerUrl)
57+ report("auth server metadata fetched", true, meta.issuer)
58+ report("has a PAR endpoint", meta.parEndpoint.len > 0, meta.parEndpoint)
59+ report("has a token endpoint", meta.tokenEndpoint.len > 0, meta.tokenEndpoint)
60+ except CatchableError as e:
61+ report("auth server metadata fetched", false, e.msg)
62+ quit 1
63+
64+ # --- PKCE ---------------------------------------------------------------
65+ block:
66+ # RFC 7636 test vector: this verifier must produce this challenge.
67+ const
68+ verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
69+ want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
70+ report("PKCE S256 matches RFC 7636 vector",
71+ s256Challenge(verifier) == want, s256Challenge(verifier))
72+
73+ # --- the nonce dance ----------------------------------------------------
74+ # sendAuthRequest retries transparently, which would hide a server that
75+ # never demanded a nonce. Prove the first unnonced attempt really is
76+ # rejected, so the retry path is load-bearing rather than decorative.
77+ let cfg0 = newLocalhostConfig("http://127.0.0.1:8080/callback")
78+ block:
79+ let
80+ key = generateKey(rng)
81+ proof = dpopProof(key, rng, "POST", meta.parEndpoint)
82+ client = newHttpClient(userAgent = UserAgent, timeout = 15_000)
83+ defer: client.close()
84+ client.headers = newHttpHeaders({
85+ "Content-Type": "application/x-www-form-urlencoded",
86+ "DPoP": proof,
87+ })
88+ # The body must be fully valid: bsky.social validates parameters before it
89+ # checks the nonce, so a stub body returns invalid_request and tells us
90+ # nothing about the nonce requirement.
91+ let verifier = secureRandomB64(48)
92+ let body = @{
93+ "client_id": cfg0.clientId,
94+ "state": secureRandomB64(16),
95+ "redirect_uri": cfg0.callbackUrl,
96+ "scope": scopeStr(cfg0.scopes),
97+ "response_type": "code",
98+ "code_challenge": s256Challenge(verifier),
99+ "code_challenge_method": "S256",
100+ }.encodeQuery
101+ let res = client.post(meta.parEndpoint, body = body)
102+ let code = try: parseJson(res.body){"error"}.getStr except CatchableError: ""
103+ let nonce = res.headers.getOrDefault("DPoP-Nonce").string
104+ report("first PAR without a nonce is rejected",
105+ res.code.int == 400 and code == "use_dpop_nonce", &"HTTP {res.code.int} {code}")
106+ report("rejection supplies a DPoP-Nonce to retry with", nonce.len > 0)
107+
108+ # --- PAR ----------------------------------------------------------------
109+ # A localhost client needs no hosted metadata document, so the flow can be
110+ # exercised without deploying anything.
111+ let cfg = cfg0
112+ report("localhost client_id is well-formed",
113+ cfg.clientId.startsWith("http://localhost?"), cfg.clientId)
114+
115+ try:
116+ let info = sendAuthRequest(cfg, meta, rng, loginHint = handle)
117+ report("PAR accepted (DPoP proof + nonce retry + PKCE)",
118+ info.requestUri.startsWith("urn:ietf:params:oauth:request_uri:"),
119+ info.requestUri)
120+ report("auth server issued a DPoP nonce", info.dpopNonce.len > 0,
121+ info.dpopNonce[0 ..< min(12, info.dpopNonce.len)] & "...")
122+ report("PKCE verifier retained for callback", info.pkceVerifier.len >= 43)
123+ report("state is unguessable", info.state.len >= 21, &"{info.state.len} chars")
124+
125+ let url = authorizeUrl(cfg, meta, info)
126+ let q = parseUri(url).query
127+ report("authorize URL carries request_uri", "request_uri=" in q)
128+ report("authorize URL carries client_id", "client_id=" in q)
129+ echo ""
130+ echo " authorize URL:"
131+ echo " ", url
132+ except CatchableError as e:
133+ report("PAR accepted", false, e.msg)
134+
135+ echo ""
136+ if failures == 0:
137+ echo "RESULT: the OAuth flow works against a live PDS up to user consent."
138+ else:
139+ echo &"RESULT: {failures} check(s) failed."
140+ quit 1
modified spike/nim/readme.md +56 -11
@@ -9,10 +9,13 @@ Run both:
99
1010 ```
1111 cd spike/nim
12-nim c -r sqlite_probe.nim # storage
13-nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # auth
12+nim c -r sqlite_probe.nim # storage
13+nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto
14+nim c -r -d:ssl oauth_probe.nim [handle] # the flow
1415 ```
1516
17+Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks.
18+
1619 ## 1. sqlite-vec + FTS5 — works
1720
1821 Glean needs FTS5 for article search and sqlite-vec for embedding similarity.
@@ -56,19 +59,61 @@ that the thumbprint matches when recomputed independently.
5659 keypairs — worth confirming, because ECDSA values with leading zeros are a
5760 classic source of intermittent length bugs in `R||S` encoding.
5861
62+## 3. The OAuth flow — works against a live PDS
63+
64+`atproto/identity.nim` and `atproto/oauth.nim` implement resolution and the
65+client flow, ported from the shape of indigo's `atproto/auth/oauth`.
66+`oauth_probe.nim` drives it end to end against real servers:
67+
68+ handle -> DID -> PDS -> auth server -> metadata -> PAR -> authorize URL
69+
70+Against `bsky.social` all 15 checks pass, including a **real PAR that the
71+production auth server accepts** and returns a `request_uri` for. That single
72+result exercises the DPoP signature, the nonce retry and PKCE at once — a
73+server-side validation no amount of local testing substitutes for.
74+
75+Two things worth recording:
76+
77+**The nonce retry is load-bearing, and the probe proves it.** A PDS rejects the
78+first DPoP-signed request of a session with `400 use_dpop_nonce` and supplies a
79+`DPoP-Nonce` header to retry with. Since `sendAuthRequest` retries
80+transparently, the probe separately fires an unnonced request and asserts the
81+rejection — otherwise a server that never demanded a nonce would look identical
82+to a working retry.
83+
84+**Order of validation caught me out.** That check first sent a stub body and got
85+`invalid_request`, not `use_dpop_nonce`: bsky.social validates request
86+parameters *before* the nonce. The body has to be fully valid to observe the
87+nonce requirement at all.
88+
89+The flow stops at the authorization URL, which is correct — the next step is a
90+human granting consent in a browser. `exchangeCode` and `refresh` are
91+implemented against that callback but are consequently untested.
92+
5993 ## What this does and does not prove
6094
61-Proven: the cryptography and the storage layer are available to Nim, in the
62-exact formats ATProto and Glean need.
95+Proven: the cryptography, the storage layer, and the OAuth flow up to user
96+consent all work from Nim, in the formats ATProto and real servers accept.
6397
6498 Not proven, and still ahead:
6599
66-- The **OAuth protocol flow** — PAR, the authorization request, the DPoP nonce
67- retry dance (a PDS rejects the first request and returns a nonce to use).
68- Mechanical, but a lot of it.
69-- **DID/handle resolution** and the PLC directory.
70-- **XRPC** and the Jetstream websocket consumer (`indigo` covers both today).
100+- **Token exchange and refresh** — written, but needs a browser consent to
101+ exercise. This is the one place a real end-to-end test still has to happen.
102+- **Session persistence** and the token-refresh lifecycle.
103+- **XRPC** request signing with the DPoP-bound access token.
104+- The **Jetstream** websocket consumer.
71105 - **CBOR/CAR** parsing for repository records.
72106
73-The honest read: nothing here is blocking, and the two things that could have
74-been blocking are not. The remaining work is large but ordinary.
107+The honest read: none of the three things that could have sunk the port did.
108+What is left is protocol plumbing against well-specified formats — large, but
109+ordinary.
110+
111+## Caveats in this code
112+
113+- `identity.nim` shells out to `dig` for the DNS leg of handle resolution,
114+ because Nim's stdlib has no resolver. A missing `dig` degrades to the
115+ HTTP `.well-known` fallback rather than failing, but a real port should bind
116+ a resolver instead of depending on a binary.
117+- Only public and localhost clients are supported. Confidential clients
118+ (`private_key_jwt` client assertions) are not implemented; Glean uses a
119+ public client today.
@@ -9,10 +9,13 @@ Run both:
9 9
10 ```10 ```
11 cd spike/nim11 cd spike/nim
12-nim c -r sqlite_probe.nim # storage12+nim c -r sqlite_probe.nim # storage
13-nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # auth13+nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto
14+nim c -r -d:ssl oauth_probe.nim [handle] # the flow
14 ```15 ```
15 16
17+Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks.
18+
16 ## 1. sqlite-vec + FTS5 — works19 ## 1. sqlite-vec + FTS5 — works
17 20
18 Glean needs FTS5 for article search and sqlite-vec for embedding similarity.21 Glean needs FTS5 for article search and sqlite-vec for embedding similarity.
@@ -56,19 +59,61 @@ that the thumbprint matches when recomputed independently.
56 keypairs — worth confirming, because ECDSA values with leading zeros are a59 keypairs — worth confirming, because ECDSA values with leading zeros are a
57 classic source of intermittent length bugs in `R||S` encoding.60 classic source of intermittent length bugs in `R||S` encoding.
58 61
62+## 3. The OAuth flow — works against a live PDS
63+
64+`atproto/identity.nim` and `atproto/oauth.nim` implement resolution and the
65+client flow, ported from the shape of indigo's `atproto/auth/oauth`.
66+`oauth_probe.nim` drives it end to end against real servers:
67+
68+ handle -> DID -> PDS -> auth server -> metadata -> PAR -> authorize URL
69+
70+Against `bsky.social` all 15 checks pass, including a **real PAR that the
71+production auth server accepts** and returns a `request_uri` for. That single
72+result exercises the DPoP signature, the nonce retry and PKCE at once — a
73+server-side validation no amount of local testing substitutes for.
74+
75+Two things worth recording:
76+
77+**The nonce retry is load-bearing, and the probe proves it.** A PDS rejects the
78+first DPoP-signed request of a session with `400 use_dpop_nonce` and supplies a
79+`DPoP-Nonce` header to retry with. Since `sendAuthRequest` retries
80+transparently, the probe separately fires an unnonced request and asserts the
81+rejection — otherwise a server that never demanded a nonce would look identical
82+to a working retry.
83+
84+**Order of validation caught me out.** That check first sent a stub body and got
85+`invalid_request`, not `use_dpop_nonce`: bsky.social validates request
86+parameters *before* the nonce. The body has to be fully valid to observe the
87+nonce requirement at all.
88+
89+The flow stops at the authorization URL, which is correct — the next step is a
90+human granting consent in a browser. `exchangeCode` and `refresh` are
91+implemented against that callback but are consequently untested.
92+
59 ## What this does and does not prove93 ## What this does and does not prove
60 94
61-Proven: the cryptography and the storage layer are available to Nim, in the95+Proven: the cryptography, the storage layer, and the OAuth flow up to user
62-exact formats ATProto and Glean need.96+consent all work from Nim, in the formats ATProto and real servers accept.
63 97
64 Not proven, and still ahead:98 Not proven, and still ahead:
65 99
66-- The **OAuth protocol flow** — PAR, the authorization request, the DPoP nonce100+- **Token exchange and refresh** — written, but needs a browser consent to
67- retry dance (a PDS rejects the first request and returns a nonce to use).101+ exercise. This is the one place a real end-to-end test still has to happen.
68- Mechanical, but a lot of it.102+- **Session persistence** and the token-refresh lifecycle.
69-- **DID/handle resolution** and the PLC directory.103+- **XRPC** request signing with the DPoP-bound access token.
70-- **XRPC** and the Jetstream websocket consumer (`indigo` covers both today).104+- The **Jetstream** websocket consumer.
71 - **CBOR/CAR** parsing for repository records.105 - **CBOR/CAR** parsing for repository records.
72 106
73-The honest read: nothing here is blocking, and the two things that could have107+The honest read: none of the three things that could have sunk the port did.
74-been blocking are not. The remaining work is large but ordinary.108+What is left is protocol plumbing against well-specified formats — large, but
109+ordinary.
110+
111+## Caveats in this code
112+
113+- `identity.nim` shells out to `dig` for the DNS leg of handle resolution,
114+ because Nim's stdlib has no resolver. A missing `dig` degrades to the
115+ HTTP `.well-known` fallback rather than failing, but a real port should bind
116+ a resolver instead of depending on a binary.
117+- Only public and localhost clients are supported. Confidential clients
118+ (`private_key_jwt` client assertions) are not implemented; Glean uses a
119+ public client today.