oauth #1

closed
opened by radmakr.com targeting main from oauth
Changed files
+330 -7
Documentation
CoreATProtocol.docc
Sources
+9 -1
Package.swift
··· 17 17 targets: ["CoreATProtocol"] 18 18 ), 19 19 ], 20 + dependencies: [ 21 + .package(url: "https://github.com/ChimeHQ/OAuthenticator", branch: "main"), 22 + .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), 23 + ], 20 24 targets: [ 21 25 .target( 22 - name: "CoreATProtocol" 26 + name: "CoreATProtocol", 27 + dependencies: [ 28 + "OAuthenticator", 29 + .product(name: "JWTKit", package: "jwt-kit"), 30 + ], 23 31 ), 24 32 .testTarget( 25 33 name: "CoreATProtocolTests",
+6 -1
Sources/CoreATProtocol/APEnvironment.swift
··· 5 5 // Created by Thomas Rademaker on 10/10/25. 6 6 // 7 7 8 + import OAuthenticator 9 + 8 10 @APActor 9 11 public class APEnvironment { 10 12 public static var current: APEnvironment = APEnvironment() ··· 12 14 public var host: String? 13 15 public var accessToken: String? 14 16 public var refreshToken: String? 17 + public var login: Login? 18 + public var dpopProofGenerator: DPoPSigner.JWTGenerator? 19 + public var resourceServerNonce: String? 15 20 public var atProtocoldelegate: CoreATProtocolDelegate? 16 21 public let routerDelegate = APRouterDelegate() 22 + public let resourceDPoPSigner = DPoPSigner() 17 23 18 24 private init() {} 19 25 ··· 23 29 // self.userAgent = userAgent 24 30 // } 25 31 } 26 -
+28
Sources/CoreATProtocol/CoreATProtocol.swift
··· 1 1 // The Swift Programming Language 2 2 // https://docs.swift.org/swift-book 3 3 4 + @_exported import OAuthenticator 5 + 4 6 public protocol CoreATProtocolDelegate: AnyObject {} 5 7 6 8 @APActor ··· 26 28 public func update(hostURL: String?) { 27 29 APEnvironment.current.host = hostURL 28 30 } 31 + 32 + @APActor 33 + public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) { 34 + APEnvironment.current.login = login 35 + APEnvironment.current.accessToken = login.accessToken.value 36 + APEnvironment.current.refreshToken = login.refreshToken?.value 37 + APEnvironment.current.dpopProofGenerator = generator 38 + APEnvironment.current.resourceServerNonce = resourceNonce 39 + APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce 40 + } 41 + 42 + @APActor 43 + public func clearAuthenticationContext() { 44 + APEnvironment.current.login = nil 45 + APEnvironment.current.dpopProofGenerator = nil 46 + APEnvironment.current.resourceServerNonce = nil 47 + APEnvironment.current.accessToken = nil 48 + APEnvironment.current.refreshToken = nil 49 + APEnvironment.current.resourceDPoPSigner.nonce = nil 50 + } 51 + 52 + @APActor 53 + public func updateResourceDPoPNonce(_ nonce: String?) { 54 + APEnvironment.current.resourceServerNonce = nonce 55 + APEnvironment.current.resourceDPoPSigner.nonce = nonce 56 + }
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
··· 1 + import Foundation 2 + import JWTKit 3 + import OAuthenticator 4 + 5 + public enum DPoPKeyMaterialError: Error, Equatable { 6 + case publicKeyUnavailable 7 + case invalidCoordinate 8 + } 9 + 10 + public actor DPoPJWTGenerator { 11 + private let privateKey: ES256PrivateKey 12 + private let keys: JWTKeyCollection 13 + private let jwkHeader: [String: JWTHeaderField] 14 + 15 + public init(privateKey: ES256PrivateKey) async throws { 16 + self.privateKey = privateKey 17 + self.keys = JWTKeyCollection() 18 + self.jwkHeader = try Self.makeJWKHeader(from: privateKey) 19 + await self.keys.add(ecdsa: privateKey) 20 + } 21 + 22 + public func jwtGenerator() -> DPoPSigner.JWTGenerator { 23 + { params in 24 + try await self.makeJWT(for: params) 25 + } 26 + } 27 + 28 + public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String { 29 + var header = JWTHeader() 30 + header.typ = params.keyType 31 + header.alg = header.alg ?? "ES256" 32 + header.jwk = jwkHeader 33 + 34 + let issuedAt = Date() 35 + let payload = DPoPPayload( 36 + htm: params.httpMethod, 37 + htu: params.requestEndpoint, 38 + iat: IssuedAtClaim(value: issuedAt), 39 + exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)), 40 + jti: IDClaim(value: UUID().uuidString), 41 + nonce: params.nonce, 42 + iss: params.issuingServer.map { IssuerClaim(value: $0) }, 43 + ath: params.tokenHash 44 + ) 45 + 46 + return try await keys.sign(payload, header: header) 47 + } 48 + 49 + private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] { 50 + guard let parameters = key.publicKey.parameters else { 51 + throw DPoPKeyMaterialError.publicKeyUnavailable 52 + } 53 + 54 + guard 55 + let xData = Data(base64Encoded: parameters.x), 56 + let yData = Data(base64Encoded: parameters.y) 57 + else { 58 + throw DPoPKeyMaterialError.invalidCoordinate 59 + } 60 + 61 + return [ 62 + "kty": .string("EC"), 63 + "crv": .string("P-256"), 64 + "x": .string(xData.base64URLEncodedString()), 65 + "y": .string(yData.base64URLEncodedString()) 66 + ] 67 + } 68 + } 69 + 70 + struct DPoPPayload: JWTPayload { 71 + let htm: String 72 + let htu: String 73 + let iat: IssuedAtClaim 74 + let exp: ExpirationClaim 75 + let jti: IDClaim 76 + let nonce: String? 77 + let iss: IssuerClaim? 78 + let ath: String? 79 + 80 + func verify(using key: some JWTAlgorithm) throws { 81 + try exp.verifyNotExpired(currentDate: Date()) 82 + } 83 + }
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
··· 1 + import Foundation 2 + 3 + extension Data { 4 + /// Returns a URL-safe Base64 representation without padding. 5 + func base64URLEncodedString() -> String { 6 + base64EncodedString() 7 + .replacingOccurrences(of: "+", with: "-") 8 + .replacingOccurrences(of: "/", with: "_") 9 + .replacingOccurrences(of: "=", with: "") 10 + } 11 + }
+35 -4
Sources/CoreATProtocol/Networking.swift
··· 6 6 // 7 7 8 8 import Foundation 9 + import CryptoKit 10 + @preconcurrency import OAuthenticator 9 11 10 12 extension JSONDecoder { 11 13 public static var atDecoder: JSONDecoder { ··· 30 32 return differenceInMinutes >= timeLimit 31 33 } 32 34 33 - @APActor 35 + @MainActor 34 36 public class APRouterDelegate: NetworkRouterDelegate { 35 37 private var shouldRefreshToken = false 36 38 37 39 public func intercept(_ request: inout URLRequest) async { 38 - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { 40 + if let generator = await APEnvironment.current.dpopProofGenerator, 41 + let login = await APEnvironment.current.login { 42 + let token = login.accessToken.value 43 + let tokenHash = tokenHash(for: token) 44 + let signer = await APEnvironment.current.resourceDPoPSigner 45 + signer.nonce = await APEnvironment.current.resourceServerNonce 46 + 47 + do { 48 + try await signer.authenticateRequest( 49 + &request, 50 + isolation: MainActor.shared, 51 + using: generator, 52 + token: token, 53 + tokenHash: tokenHash, 54 + issuer: login.issuingServer 55 + ) 56 + } catch { 57 + // If DPoP signing fails, fall back to providing the token directly. 58 + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") 59 + } 60 + 61 + return 62 + } 63 + 64 + if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken { 39 65 shouldRefreshToken = false 40 66 request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") 41 - } else if let accessToken = APEnvironment.current.accessToken { 67 + } else if let accessToken = await APEnvironment.current.accessToken { 42 68 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 43 69 } 44 70 } ··· 65 91 message.error == AtErrorType.expiredToken.rawValue { 66 92 return try await getNewToken() 67 93 } 68 - 94 + 69 95 return false 70 96 } 97 + 98 + private func tokenHash(for token: String) -> String { 99 + let digest = SHA256.hash(data: Data(token.utf8)) 100 + return Data(digest).base64URLEncodedString() 101 + } 71 102 }
+1 -1
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 1 1 import Foundation 2 2 3 - @APActor 3 + @MainActor 4 4 public protocol NetworkRouterDelegate: AnyObject { 5 5 func intercept(_ request: inout URLRequest) async 6 6 func shouldRetry(error: Error, attempts: Int) async throws -> Bool
+157
Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md
··· 1 + # Build a Bluesky Login Flow 2 + 3 + Learn how an iOS app can depend on ``CoreATProtocol`` and guide a user through the AT Protocol OAuth flow using Bluesky as the authorization server. 4 + 5 + ## Add the package to your app 6 + 7 + 1. In your app target's `Package.swift`, add the CoreATProtocol dependency: 8 + 9 + ```swift 10 + .package(url: "https://github.com/your-org/CoreATProtocol.git", from: "1.0.0") 11 + ``` 12 + 13 + 2. List ``CoreATProtocol`` in the target's dependencies: 14 + 15 + ```swift 16 + .target( 17 + name: "App", 18 + dependencies: [ 19 + .product(name: "CoreATProtocol", package: "CoreATProtocol") 20 + ] 21 + ) 22 + ``` 23 + 24 + 3. Import the module where you coordinate authentication: 25 + 26 + ```swift 27 + import CoreATProtocol 28 + ``` 29 + 30 + ## Persist a DPoP key 31 + 32 + Bluesky issues DPoP-bound access tokens, so the app must generate and persist a single ES256 key pair. The example below stores the private key in the Keychain and recreates it when needed. 33 + 34 + ```swift 35 + import CryptoKit 36 + import JWTKit 37 + 38 + final class DPoPKeyStore { 39 + private let keyTag = "com.example.app.dpop" 40 + 41 + func loadOrCreateKey() throws -> ES256PrivateKey { 42 + if let raw = try loadKeyData() { 43 + return try ES256PrivateKey(pem: raw) 44 + } 45 + 46 + let key = ES256PrivateKey() 47 + try persist(key.pemRepresentation) 48 + return key 49 + } 50 + 51 + private func loadKeyData() throws -> String? { 52 + // Read from the Keychain and return the PEM string if it exists. 53 + nil 54 + } 55 + 56 + private func persist(_ pem: String) throws { 57 + // Write the PEM string to the Keychain. 58 + } 59 + } 60 + ``` 61 + 62 + ## Expose a DPoP JWT generator 63 + 64 + Wrap the signing key with ``DPoPJWTGenerator`` so the library can mint proofs on demand. 65 + 66 + ```swift 67 + let keyStore = DPoPKeyStore() 68 + let privateKey = try await keyStore.loadOrCreateKey() 69 + let dpopGenerator = try await DPoPJWTGenerator(privateKey: privateKey) 70 + let jwtGenerator = dpopGenerator.jwtGenerator() 71 + ``` 72 + 73 + Pass ``DPoPJWTGenerator.jwtGenerator()`` to ``LoginService`` and later to ``applyAuthenticationContext(login:generator:resourceNonce:)`` so API calls share the same key material. 74 + 75 + ## Configure login storage 76 + 77 + Provide a ``LoginStorage`` implementation that reads and writes the user’s Bluesky session securely. The storage runs on the calling actor, so use async APIs. 78 + 79 + ```swift 80 + import OAuthenticator 81 + 82 + struct BlueskyLoginStore { 83 + func makeStorage() -> LoginStorage { 84 + LoginStorage { 85 + try await loadLogin() 86 + } storeLogin: { login in 87 + try await persist(login) 88 + } 89 + } 90 + 91 + private func loadLogin() async throws -> Login? { 92 + // Decode and return the previously stored login if one exists. 93 + nil 94 + } 95 + 96 + private func persist(_ login: Login) async throws { 97 + // Save the login (for example, in the Keychain or the file system). 98 + } 99 + } 100 + ``` 101 + 102 + ## Perform the OAuth flow 103 + 104 + 1. Configure shared environment state early in your app lifecycle: 105 + 106 + ```swift 107 + await setup( 108 + hostURL: "https://bsky.social", 109 + accessJWT: nil, 110 + refreshJWT: nil, 111 + delegate: self 112 + ) 113 + ``` 114 + 115 + 2. Create the services needed for authentication: 116 + 117 + ```swift 118 + let loginStorage = BlueskyLoginStore().makeStorage() 119 + let loginService = LoginService(jwtGenerator: jwtGenerator, loginStorage: loginStorage) 120 + ``` 121 + 122 + 3. Start the Bluesky OAuth flow. Use the client metadata URL registered with the Authorization Server (for example, the one served from your app’s hosted metadata file). 123 + 124 + ```swift 125 + let login = try await loginService.login( 126 + account: "did:plc:your-user", 127 + clientMetadataEndpoint: "https://example.com/.well-known/coreatprotocol-client.json" 128 + ) 129 + ``` 130 + 131 + 4. Share the authentication context with CoreATProtocol so the networking layer can add DPoP proofs automatically: 132 + 133 + ```swift 134 + await applyAuthenticationContext(login: login, generator: jwtGenerator) 135 + ``` 136 + 137 + 5. When Bluesky returns a new DPoP nonce (`DPoP-Nonce` header), call ``updateResourceDPoPNonce(_:)`` with the latest value before the next request. 138 + 139 + 6. To sign the user out, call ``clearAuthenticationContext()`` and erase any stored login and keychain items. 140 + 141 + ## Make API requests 142 + 143 + Attach the package’s router delegate to your networking stack (for example, the client that wraps ``URLSession``) so that access tokens and DPoP proofs are injected into outgoing requests. 144 + 145 + ```swift 146 + var router = NetworkRouter<SomeEndpoint>(decoder: .atDecoder) 147 + router.delegate = await APEnvironment.current.routerDelegate 148 + ``` 149 + 150 + With the context applied, subsequent calls through ``APRouterDelegate`` will refresh DPoP proofs, hash access tokens into the `ath` claim, and keep the nonce in sync with the server. 151 + 152 + ## Troubleshooting 153 + 154 + - Ensure the DPoP key persists across app launches. If the key changes, all tokens issued by Bluesky become invalid and the user must reauthenticate. 155 + - Always call ``applyAuthenticationContext(login:generator:resourceNonce:)`` after refreshing tokens via ``updateTokens(access:refresh:)`` or custom flows so the delegate has current credentials. 156 + - If Bluesky rejects requests with `use_dpop_nonce`, update the cached value via ``updateResourceDPoPNonce(_:)`` and retry. 157 +