mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto/oauth): add DPoP proof JWT signing + per-origin nonce cache

+211 -1
+29
Packages/ATProto/Sources/ATProto/OAuth/DPoP/DPoPNonceStore.swift
··· 1 + import Foundation 2 + 3 + /// Caches the latest `DPoP-Nonce` header value per origin (scheme+host+port). 4 + /// Providers consult this store before signing a proof and update it when 5 + /// responses carry a new nonce. 6 + public actor DPoPNonceStore { 7 + private var nonces: [String: String] = [:] 8 + 9 + public init() {} 10 + 11 + public func nonce(for url: URL) -> String? { 12 + nonces[Self.originKey(for: url)] 13 + } 14 + 15 + public func setNonce(_ nonce: String, for url: URL) { 16 + nonces[Self.originKey(for: url)] = nonce 17 + } 18 + 19 + public func clear() { 20 + nonces.removeAll() 21 + } 22 + 23 + static func originKey(for url: URL) -> String { 24 + let scheme = url.scheme ?? "https" 25 + let host = url.host ?? "" 26 + let port = url.port.map { ":\($0)" } ?? "" 27 + return "\(scheme)://\(host)\(port)" 28 + } 29 + }
+59
Packages/ATProto/Sources/ATProto/OAuth/DPoP/DPoPProof.swift
··· 1 + import Foundation 2 + import CryptoKit 3 + 4 + public enum DPoPProof { 5 + /// Builds and signs a DPoP proof JWT for the given HTTP request. 6 + /// 7 + /// - `htu`: the request URL without query or fragment, per RFC 9449 §4.2. 8 + /// - `ath`: if `accessToken` is non-nil, included as the base64url-no-pad 9 + /// SHA-256 hash of the access token (RFC 9449 §4.2). 10 + /// - `nonce`: if the server previously returned a `DPoP-Nonce`, pass it here. 11 + public static func create( 12 + method: String, 13 + url: URL, 14 + key: any DPoPKey, 15 + nonce: String?, 16 + accessToken: String? 17 + ) async throws -> String { 18 + let jwk = try await key.publicJWK() 19 + let header: [String: Any] = [ 20 + "typ": "dpop+jwt", 21 + "alg": "ES256", 22 + "jwk": jwk, 23 + ] 24 + 25 + var payload: [String: Any] = [ 26 + "htm": method.uppercased(), 27 + "htu": canonicalHTU(url), 28 + "iat": Int(Date().timeIntervalSince1970), 29 + "jti": randomJTI(), 30 + ] 31 + if let nonce { payload["nonce"] = nonce } 32 + if let accessToken { 33 + let digest = SHA256.hash(data: Data(accessToken.utf8)) 34 + payload["ath"] = PKCE.base64URLEncode(Data(digest)) 35 + } 36 + 37 + let headerData = try JSONSerialization.data(withJSONObject: header, options: [.sortedKeys]) 38 + let payloadData = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) 39 + let headerB64 = PKCE.base64URLEncode(headerData) 40 + let payloadB64 = PKCE.base64URLEncode(payloadData) 41 + let signingInput = "\(headerB64).\(payloadB64)" 42 + let signature = try await key.sign(Data(signingInput.utf8)) 43 + let sigB64 = PKCE.base64URLEncode(signature) 44 + return "\(signingInput).\(sigB64)" 45 + } 46 + 47 + /// Strips query + fragment from the URL per RFC 9449 §4.2. 48 + static func canonicalHTU(_ url: URL) -> String { 49 + var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents() 50 + comps.query = nil 51 + comps.fragment = nil 52 + return comps.url?.absoluteString ?? url.absoluteString 53 + } 54 + 55 + private static func randomJTI() -> String { 56 + let bytes = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) 57 + return PKCE.base64URLEncode(bytes) 58 + } 59 + }
+1 -1
Packages/ATProto/Sources/ATProto/OAuth/PKCE.swift
··· 19 19 return base64URLEncode(Data(digest)) 20 20 } 21 21 22 - static func base64URLEncode(_ data: Data) -> String { 22 + public static func base64URLEncode(_ data: Data) -> String { 23 23 data.base64EncodedString() 24 24 .replacingOccurrences(of: "+", with: "-") 25 25 .replacingOccurrences(of: "/", with: "_")
+122
Packages/ATProto/Tests/ATProtoTests/OAuth/DPoPProofTests.swift
··· 1 + import Foundation 2 + import CryptoKit 3 + import Testing 4 + @testable import ATProto 5 + 6 + @Suite("DPoPProof") 7 + struct DPoPProofTests { 8 + @Test("proof JWT has dpop+jwt header type and ES256 alg") 9 + func headerFields() async throws { 10 + let key = try InMemoryES256DPoPKey.generate() 11 + let proof = try await DPoPProof.create( 12 + method: "POST", 13 + url: URL(string: "https://bsky.social/xrpc/com.atproto.repo.createRecord")!, 14 + key: key, 15 + nonce: nil, 16 + accessToken: nil 17 + ) 18 + let header = try decodeJWTHeader(proof) 19 + #expect(header["typ"] as? String == "dpop+jwt") 20 + #expect(header["alg"] as? String == "ES256") 21 + #expect(header["jwk"] is [String: Any]) 22 + } 23 + 24 + @Test("proof payload includes htm, htu, iat, jti") 25 + func payloadRequiredClaims() async throws { 26 + let key = try InMemoryES256DPoPKey.generate() 27 + let proof = try await DPoPProof.create( 28 + method: "GET", 29 + url: URL(string: "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=did:plc:abc")!, 30 + key: key, 31 + nonce: nil, 32 + accessToken: nil 33 + ) 34 + let payload = try decodeJWTPayload(proof) 35 + #expect(payload["htm"] as? String == "GET") 36 + // htu must strip query + fragment per RFC 9449 §4.2. 37 + #expect(payload["htu"] as? String == "https://bsky.social/xrpc/com.atproto.repo.describeRepo") 38 + #expect(payload["iat"] is Int) 39 + let jti = payload["jti"] as? String ?? "" 40 + #expect(jti.count >= 16) 41 + } 42 + 43 + @Test("proof includes nonce when supplied") 44 + func withNonce() async throws { 45 + let key = try InMemoryES256DPoPKey.generate() 46 + let proof = try await DPoPProof.create( 47 + method: "POST", 48 + url: URL(string: "https://bsky.social/xrpc/anything")!, 49 + key: key, 50 + nonce: "abc123", 51 + accessToken: nil 52 + ) 53 + let payload = try decodeJWTPayload(proof) 54 + #expect(payload["nonce"] as? String == "abc123") 55 + } 56 + 57 + @Test("proof includes ath when access token supplied") 58 + func withAccessToken() async throws { 59 + let key = try InMemoryES256DPoPKey.generate() 60 + let accessToken = "ExampleToken" 61 + let proof = try await DPoPProof.create( 62 + method: "GET", 63 + url: URL(string: "https://bsky.social/xrpc/anything")!, 64 + key: key, 65 + nonce: nil, 66 + accessToken: accessToken 67 + ) 68 + let payload = try decodeJWTPayload(proof) 69 + let expectedAth = PKCE.base64URLEncode(Data(SHA256.hash(data: Data(accessToken.utf8)))) 70 + #expect(payload["ath"] as? String == expectedAth) 71 + } 72 + 73 + @Test("proof signature verifies against the key's public JWK") 74 + func signatureVerifies() async throws { 75 + let key = try InMemoryES256DPoPKey.generate() 76 + let proof = try await DPoPProof.create( 77 + method: "POST", 78 + url: URL(string: "https://example.com/token")!, 79 + key: key, 80 + nonce: nil, 81 + accessToken: nil 82 + ) 83 + // Split header.payload.signature, verify signature over "header.payload" bytes. 84 + let parts = proof.split(separator: ".").map(String.init) 85 + #expect(parts.count == 3) 86 + let signingInput = Data("\(parts[0]).\(parts[1])".utf8) 87 + let sigBytes = try base64URLDecode(parts[2]) 88 + let sig = try CryptoKit.P256.Signing.ECDSASignature(rawRepresentation: sigBytes) 89 + let pub = try await key.publicKey() 90 + #expect(pub.isValidSignature(sig, for: signingInput)) 91 + } 92 + } 93 + 94 + // MARK: - JWT test helpers 95 + 96 + private func decodeJWTHeader(_ jwt: String) throws -> [String: Any] { 97 + let parts = jwt.split(separator: ".").map(String.init) 98 + guard parts.count == 3 else { throw TestError.invalid } 99 + return try decodeJSONObject(try base64URLDecode(parts[0])) 100 + } 101 + 102 + private func decodeJWTPayload(_ jwt: String) throws -> [String: Any] { 103 + let parts = jwt.split(separator: ".").map(String.init) 104 + guard parts.count == 3 else { throw TestError.invalid } 105 + return try decodeJSONObject(try base64URLDecode(parts[1])) 106 + } 107 + 108 + private func decodeJSONObject(_ data: Data) throws -> [String: Any] { 109 + guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { 110 + throw TestError.invalid 111 + } 112 + return obj 113 + } 114 + 115 + func base64URLDecode(_ s: String) throws -> Data { 116 + var padded = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") 117 + while padded.count % 4 != 0 { padded += "=" } 118 + guard let d = Data(base64Encoded: padded) else { throw TestError.invalid } 119 + return d 120 + } 121 + 122 + enum TestError: Error { case invalid }