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>
c960984 parent: f205f09 modified
spike/nim/.gitignore +1 -0 | @@ -2,3 +2,4 @@ | ||
| 2 | 2 | nimcache/ |
| 3 | 3 | sqlite_probe |
| 4 | 4 | dpop_probe |
| 5 | +oauth_probe | |
| @@ -2,3 +2,4 @@ | |||
| 2 | nimcache/ | 2 | nimcache/ |
| 3 | sqlite_probe | 3 | sqlite_probe |
| 4 | dpop_probe | 4 | 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. | |
| 2 | 2 | ## |
| 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 .) | |
| 107 | 4 | |
| 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 | |
| 110 | 8 | |
| 111 | 9 | when isMainModule: |
| 112 | 10 | 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 from | 3 | +## 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: | ||
| 9 | 9 | |
| 10 | 10 | ``` |
| 11 | 11 | 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 | |
| 14 | 15 | ``` |
| 15 | 16 | |
| 17 | +Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. | |
| 18 | + | |
| 16 | 19 | ## 1. sqlite-vec + FTS5 — works |
| 17 | 20 | |
| 18 | 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 | 59 | keypairs — worth confirming, because ECDSA values with leading zeros are a |
| 57 | 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 | 93 | ## What this does and does not prove |
| 60 | 94 | |
| 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. | |
| 63 | 97 | |
| 64 | 98 | Not proven, and still ahead: |
| 65 | 99 | |
| 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. | |
| 71 | 105 | - **CBOR/CAR** parsing for repository records. |
| 72 | 106 | |
| 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/nim | 11 | cd spike/nim |
| 12 | -nim c -r sqlite_probe.nim # storage | 12 | +nim c -r sqlite_probe.nim # storage |
| 13 | -nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # auth | 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 | ||
| 14 | ``` | 15 | ``` |
| 15 | 16 | ||
| 17 | +Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. | ||
| 18 | + | ||
| 16 | ## 1. sqlite-vec + FTS5 — works | 19 | ## 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 a | 59 | 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 prove | 93 | ## What this does and does not prove |
| 60 | 94 | ||
| 61 | -Proven: the cryptography and the storage layer are available to Nim, in the | 95 | +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 nonce | 100 | +- **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 have | 107 | +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. | ||