From 7ad89282a7cb7e24c46ced6d56710ae0b93680b3 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Fri, 17 Oct 2025 16:08:33 -0400 Subject: [PATCH] first pass at using libraries to implement oauth --- Package.swift | 10 ++- Sources/CoreATProtocol/LoginService.swift | 85 +++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 Sources/CoreATProtocol/LoginService.swift diff --git a/Package.swift b/Package.swift index a482d08..3153195 100644 --- a/Package.swift +++ b/Package.swift @@ -17,9 +17,17 @@ let package = Package( targets: ["CoreATProtocol"] ), ], + dependencies: [ + .package(url: "https://github.com/ChimeHQ/OAuthenticator", branch: "main"), + .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), + ], targets: [ .target( - name: "CoreATProtocol" + name: "CoreATProtocol", + dependencies: [ + "OAuthenticator", + .product(name: "JWTKit", package: "jwt-kit"), + ], ), .testTarget( name: "CoreATProtocolTests", diff --git a/Sources/CoreATProtocol/LoginService.swift b/Sources/CoreATProtocol/LoginService.swift new file mode 100644 index 0000000..5876b80 --- /dev/null +++ b/Sources/CoreATProtocol/LoginService.swift @@ -0,0 +1,85 @@ +// +// LoginService.swift +// CoreATProtocol +// +// Created by Thomas Rademaker on 10/17/25. +// + +import Foundation +import OAuthenticator +import JWTKit +import CryptoKit + +@APActor +class LoginService { + private var keys: JWTKeyCollection + private var privateKey: ES256PrivateKey + + public init() async { + // Create keys once during initialization + self.privateKey = ES256PrivateKey() + self.keys = JWTKeyCollection() + // Add the key to the collection + await self.keys.add(ecdsa: privateKey) + } + + public func login(account: String, clientMetadataEndpoint: String) async throws { + let provider = URLSession.defaultProvider + let host = APEnvironment.current.host ?? "" + let server = if host.hasPrefix("https://") { + String(host.dropFirst(8)) + } else if host.hasPrefix("http://") { + String(host.dropFirst(7)) + } else { host } + + let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider) + let serverConfig = try await ServerMetadata.load(for: server, provider: provider) + + // Create storage for persisting login state + let loginStorage = LoginStorage { + // Implement retrieving stored login + // Return stored Login if it exists, or nil + return nil + } storeLogin: { login in + // Implement storing the login + // Store the login securely + + print("LOGIN: \(login)") + } + + let jwtGenerator: DPoPSigner.JWTGenerator = { params in + try await self.generateJWT(params: params) + } + + let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator) + let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic) + let authenticator = Authenticator(config: config) + try await authenticator.authenticate() + } + + private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String { + // Create DPoP payload using existing keys + let payload = DPoPPayload( + htm: params.httpMethod, + htu: params.requestEndpoint, + iat: .init(value: .now), + jti: .init(value: UUID().uuidString), + nonce: params.nonce + ) + + // Sign with existing keys + return try await self.keys.sign(payload) + } +} + +private struct DPoPPayload: JWTPayload { + let htm: String + let htu: String + let iat: IssuedAtClaim + let jti: IDClaim + let nonce: String? + + func verify(using key: some JWTAlgorithm) throws { + // No additional verification needed + } +} -- 2.43.0 From dcd096b479591892c87b6dac88ea951c1c7aba02 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Fri, 17 Oct 2025 16:08:48 -0400 Subject: [PATCH] resolved added --- Package.resolved | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..991916d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "1139e0e1075c4de720978803490da5da789019e0504be736dd4f216da7eadad4", + "pins" : [ + { + "identity" : "jwt-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt-kit.git", + "state" : { + "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", + "version" : "5.2.0" + } + }, + { + "identity" : "oauthenticator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/OAuthenticator", + "state" : { + "branch" : "main", + "revision" : "618971d4d341650db664925fd0479032294064ad" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", + "version" : "1.15.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18", + "version" : "4.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + } + ], + "version" : 3 +} -- 2.43.0 From d8bc67e69317264ef25a50c2a8c2668510fdf219 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Fri, 17 Oct 2025 20:32:03 -0400 Subject: [PATCH] further oauth support --- Package.resolved | 2 +- Sources/CoreATProtocol/APEnvironment.swift | 7 +- Sources/CoreATProtocol/CoreATProtocol.swift | 28 +++++++ Sources/CoreATProtocol/DPoPJWTGenerator.swift | 83 +++++++++++++++++++ .../Extensions/Data+Base64URL.swift | 11 +++ Sources/CoreATProtocol/LoginService.swift | 73 ++++------------ Sources/CoreATProtocol/Networking.swift | 39 ++++++++- .../Networking/Services/NetworkRouter.swift | 2 +- 8 files changed, 183 insertions(+), 62 deletions(-) create mode 100644 Sources/CoreATProtocol/DPoPJWTGenerator.swift create mode 100644 Sources/CoreATProtocol/Extensions/Data+Base64URL.swift diff --git a/Package.resolved b/Package.resolved index 991916d..995f309 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1139e0e1075c4de720978803490da5da789019e0504be736dd4f216da7eadad4", + "originHash" : "2237e2c10a8d530dcbd1f9770efc8fcf2a9fc2ca2c63a19882551fea7ab9fe25", "pins" : [ { "identity" : "jwt-kit", diff --git a/Sources/CoreATProtocol/APEnvironment.swift b/Sources/CoreATProtocol/APEnvironment.swift index d5acbed..5d7855b 100644 --- a/Sources/CoreATProtocol/APEnvironment.swift +++ b/Sources/CoreATProtocol/APEnvironment.swift @@ -5,6 +5,8 @@ // Created by Thomas Rademaker on 10/10/25. // +import OAuthenticator + @APActor public class APEnvironment { public static var current: APEnvironment = APEnvironment() @@ -12,8 +14,12 @@ public class APEnvironment { public var host: String? public var accessToken: String? public var refreshToken: String? + public var login: Login? + public var dpopProofGenerator: DPoPSigner.JWTGenerator? + public var resourceServerNonce: String? public var atProtocoldelegate: CoreATProtocolDelegate? public let routerDelegate = APRouterDelegate() + public let resourceDPoPSigner = DPoPSigner() private init() {} @@ -23,4 +29,3 @@ public class APEnvironment { // self.userAgent = userAgent // } } - diff --git a/Sources/CoreATProtocol/CoreATProtocol.swift b/Sources/CoreATProtocol/CoreATProtocol.swift index 9f21348..34f3b6f 100644 --- a/Sources/CoreATProtocol/CoreATProtocol.swift +++ b/Sources/CoreATProtocol/CoreATProtocol.swift @@ -1,6 +1,8 @@ // The Swift Programming Language // https://docs.swift.org/swift-book +@_exported import OAuthenticator + public protocol CoreATProtocolDelegate: AnyObject {} @APActor @@ -26,3 +28,29 @@ public func updateTokens(access: String?, refresh: String?) { public func update(hostURL: String?) { APEnvironment.current.host = hostURL } + +@APActor +public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) { + APEnvironment.current.login = login + APEnvironment.current.accessToken = login.accessToken.value + APEnvironment.current.refreshToken = login.refreshToken?.value + APEnvironment.current.dpopProofGenerator = generator + APEnvironment.current.resourceServerNonce = resourceNonce + APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce +} + +@APActor +public func clearAuthenticationContext() { + APEnvironment.current.login = nil + APEnvironment.current.dpopProofGenerator = nil + APEnvironment.current.resourceServerNonce = nil + APEnvironment.current.accessToken = nil + APEnvironment.current.refreshToken = nil + APEnvironment.current.resourceDPoPSigner.nonce = nil +} + +@APActor +public func updateResourceDPoPNonce(_ nonce: String?) { + APEnvironment.current.resourceServerNonce = nonce + APEnvironment.current.resourceDPoPSigner.nonce = nonce +} diff --git a/Sources/CoreATProtocol/DPoPJWTGenerator.swift b/Sources/CoreATProtocol/DPoPJWTGenerator.swift new file mode 100644 index 0000000..fdde428 --- /dev/null +++ b/Sources/CoreATProtocol/DPoPJWTGenerator.swift @@ -0,0 +1,83 @@ +import Foundation +import JWTKit +import OAuthenticator + +public enum DPoPKeyMaterialError: Error, Equatable { + case publicKeyUnavailable + case invalidCoordinate +} + +public actor DPoPJWTGenerator { + private let privateKey: ES256PrivateKey + private let keys: JWTKeyCollection + private let jwkHeader: [String: JWTHeaderField] + + public init(privateKey: ES256PrivateKey) async throws { + self.privateKey = privateKey + self.keys = JWTKeyCollection() + self.jwkHeader = try Self.makeJWKHeader(from: privateKey) + await self.keys.add(ecdsa: privateKey) + } + + public func jwtGenerator() -> DPoPSigner.JWTGenerator { + { params in + try await self.makeJWT(for: params) + } + } + + public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String { + var header = JWTHeader() + header.typ = params.keyType + header.alg = header.alg ?? "ES256" + header.jwk = jwkHeader + + let issuedAt = Date() + let payload = DPoPPayload( + htm: params.httpMethod, + htu: params.requestEndpoint, + iat: IssuedAtClaim(value: issuedAt), + exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)), + jti: IDClaim(value: UUID().uuidString), + nonce: params.nonce, + iss: params.issuingServer.map { IssuerClaim(value: $0) }, + ath: params.tokenHash + ) + + return try await keys.sign(payload, header: header) + } + + private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] { + guard let parameters = key.publicKey.parameters else { + throw DPoPKeyMaterialError.publicKeyUnavailable + } + + guard + let xData = Data(base64Encoded: parameters.x), + let yData = Data(base64Encoded: parameters.y) + else { + throw DPoPKeyMaterialError.invalidCoordinate + } + + return [ + "kty": .string("EC"), + "crv": .string("P-256"), + "x": .string(xData.base64URLEncodedString()), + "y": .string(yData.base64URLEncodedString()) + ] + } +} + +struct DPoPPayload: JWTPayload { + let htm: String + let htu: String + let iat: IssuedAtClaim + let exp: ExpirationClaim + let jti: IDClaim + let nonce: String? + let iss: IssuerClaim? + let ath: String? + + func verify(using key: some JWTAlgorithm) throws { + try exp.verifyNotExpired(currentDate: Date()) + } +} diff --git a/Sources/CoreATProtocol/Extensions/Data+Base64URL.swift b/Sources/CoreATProtocol/Extensions/Data+Base64URL.swift new file mode 100644 index 0000000..d968d86 --- /dev/null +++ b/Sources/CoreATProtocol/Extensions/Data+Base64URL.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Data { + /// Returns a URL-safe Base64 representation without padding. + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/CoreATProtocol/LoginService.swift b/Sources/CoreATProtocol/LoginService.swift index 5876b80..8c47bd6 100644 --- a/Sources/CoreATProtocol/LoginService.swift +++ b/Sources/CoreATProtocol/LoginService.swift @@ -7,23 +7,22 @@ import Foundation import OAuthenticator -import JWTKit -import CryptoKit @APActor -class LoginService { - private var keys: JWTKeyCollection - private var privateKey: ES256PrivateKey - - public init() async { - // Create keys once during initialization - self.privateKey = ES256PrivateKey() - self.keys = JWTKeyCollection() - // Add the key to the collection - await self.keys.add(ecdsa: privateKey) +public final class LoginService { + public enum Error: Swift.Error { + case missingStoredLogin } - - public func login(account: String, clientMetadataEndpoint: String) async throws { + + private let loginStorage: LoginStorage + private let jwtGenerator: DPoPSigner.JWTGenerator + + public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) { + self.jwtGenerator = jwtGenerator + self.loginStorage = loginStorage + } + + public func login(account: String, clientMetadataEndpoint: String) async throws -> Login { let provider = URLSession.defaultProvider let host = APEnvironment.current.host ?? "" let server = if host.hasPrefix("https://") { @@ -34,52 +33,16 @@ class LoginService { let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider) let serverConfig = try await ServerMetadata.load(for: server, provider: provider) - - // Create storage for persisting login state - let loginStorage = LoginStorage { - // Implement retrieving stored login - // Return stored Login if it exists, or nil - return nil - } storeLogin: { login in - // Implement storing the login - // Store the login securely - - print("LOGIN: \(login)") - } - - let jwtGenerator: DPoPSigner.JWTGenerator = { params in - try await self.generateJWT(params: params) - } let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator) let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic) let authenticator = Authenticator(config: config) try await authenticator.authenticate() - } - - private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String { - // Create DPoP payload using existing keys - let payload = DPoPPayload( - htm: params.httpMethod, - htu: params.requestEndpoint, - iat: .init(value: .now), - jti: .init(value: UUID().uuidString), - nonce: params.nonce - ) - - // Sign with existing keys - return try await self.keys.sign(payload) - } -} -private struct DPoPPayload: JWTPayload { - let htm: String - let htu: String - let iat: IssuedAtClaim - let jti: IDClaim - let nonce: String? - - func verify(using key: some JWTAlgorithm) throws { - // No additional verification needed + guard let storedLogin = try await loginStorage.retrieveLogin() else { + throw Error.missingStoredLogin + } + + return storedLogin } } diff --git a/Sources/CoreATProtocol/Networking.swift b/Sources/CoreATProtocol/Networking.swift index a9c0fb4..c31c58e 100644 --- a/Sources/CoreATProtocol/Networking.swift +++ b/Sources/CoreATProtocol/Networking.swift @@ -6,6 +6,8 @@ // import Foundation +import CryptoKit +@preconcurrency import OAuthenticator extension JSONDecoder { public static var atDecoder: JSONDecoder { @@ -30,15 +32,39 @@ func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { return differenceInMinutes >= timeLimit } -@APActor +@MainActor public class APRouterDelegate: NetworkRouterDelegate { private var shouldRefreshToken = false public func intercept(_ request: inout URLRequest) async { - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { + if let generator = await APEnvironment.current.dpopProofGenerator, + let login = await APEnvironment.current.login { + let token = login.accessToken.value + let tokenHash = tokenHash(for: token) + let signer = await APEnvironment.current.resourceDPoPSigner + signer.nonce = await APEnvironment.current.resourceServerNonce + + do { + try await signer.authenticateRequest( + &request, + isolation: MainActor.shared, + using: generator, + token: token, + tokenHash: tokenHash, + issuer: login.issuingServer + ) + } catch { + // If DPoP signing fails, fall back to providing the token directly. + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") + } + + return + } + + if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken { shouldRefreshToken = false request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") - } else if let accessToken = APEnvironment.current.accessToken { + } else if let accessToken = await APEnvironment.current.accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } } @@ -65,7 +91,12 @@ public class APRouterDelegate: NetworkRouterDelegate { message.error == AtErrorType.expiredToken.rawValue { return try await getNewToken() } - + return false } + + private func tokenHash(for token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return Data(digest).base64URLEncodedString() + } } diff --git a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift index 366ea0f..8043d90 100644 --- a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift +++ b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift @@ -1,6 +1,6 @@ import Foundation -@APActor +@MainActor public protocol NetworkRouterDelegate: AnyObject { func intercept(_ request: inout URLRequest) async func shouldRetry(error: Error, attempts: Int) async throws -> Bool -- 2.43.0 From be71fba38fec509679ced77f6444d134bd2de50d Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Sat, 18 Oct 2025 14:48:43 -0400 Subject: [PATCH] asked codex to make some documentation lol --- .../CoreATProtocol.docc/BuildBlueskyLogin.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md diff --git a/Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md b/Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md new file mode 100644 index 0000000..a9903e6 --- /dev/null +++ b/Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md @@ -0,0 +1,157 @@ +# Build a Bluesky Login Flow + +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. + +## Add the package to your app + +1. In your app target's `Package.swift`, add the CoreATProtocol dependency: + + ```swift + .package(url: "https://github.com/your-org/CoreATProtocol.git", from: "1.0.0") + ``` + +2. List ``CoreATProtocol`` in the target's dependencies: + + ```swift + .target( + name: "App", + dependencies: [ + .product(name: "CoreATProtocol", package: "CoreATProtocol") + ] + ) + ``` + +3. Import the module where you coordinate authentication: + + ```swift + import CoreATProtocol + ``` + +## Persist a DPoP key + +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. + +```swift +import CryptoKit +import JWTKit + +final class DPoPKeyStore { + private let keyTag = "com.example.app.dpop" + + func loadOrCreateKey() throws -> ES256PrivateKey { + if let raw = try loadKeyData() { + return try ES256PrivateKey(pem: raw) + } + + let key = ES256PrivateKey() + try persist(key.pemRepresentation) + return key + } + + private func loadKeyData() throws -> String? { + // Read from the Keychain and return the PEM string if it exists. + nil + } + + private func persist(_ pem: String) throws { + // Write the PEM string to the Keychain. + } +} +``` + +## Expose a DPoP JWT generator + +Wrap the signing key with ``DPoPJWTGenerator`` so the library can mint proofs on demand. + +```swift +let keyStore = DPoPKeyStore() +let privateKey = try await keyStore.loadOrCreateKey() +let dpopGenerator = try await DPoPJWTGenerator(privateKey: privateKey) +let jwtGenerator = dpopGenerator.jwtGenerator() +``` + +Pass ``DPoPJWTGenerator.jwtGenerator()`` to ``LoginService`` and later to ``applyAuthenticationContext(login:generator:resourceNonce:)`` so API calls share the same key material. + +## Configure login storage + +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. + +```swift +import OAuthenticator + +struct BlueskyLoginStore { + func makeStorage() -> LoginStorage { + LoginStorage { + try await loadLogin() + } storeLogin: { login in + try await persist(login) + } + } + + private func loadLogin() async throws -> Login? { + // Decode and return the previously stored login if one exists. + nil + } + + private func persist(_ login: Login) async throws { + // Save the login (for example, in the Keychain or the file system). + } +} +``` + +## Perform the OAuth flow + +1. Configure shared environment state early in your app lifecycle: + + ```swift + await setup( + hostURL: "https://bsky.social", + accessJWT: nil, + refreshJWT: nil, + delegate: self + ) + ``` + +2. Create the services needed for authentication: + + ```swift + let loginStorage = BlueskyLoginStore().makeStorage() + let loginService = LoginService(jwtGenerator: jwtGenerator, loginStorage: loginStorage) + ``` + +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). + + ```swift + let login = try await loginService.login( + account: "did:plc:your-user", + clientMetadataEndpoint: "https://example.com/.well-known/coreatprotocol-client.json" + ) + ``` + +4. Share the authentication context with CoreATProtocol so the networking layer can add DPoP proofs automatically: + + ```swift + await applyAuthenticationContext(login: login, generator: jwtGenerator) + ``` + +5. When Bluesky returns a new DPoP nonce (`DPoP-Nonce` header), call ``updateResourceDPoPNonce(_:)`` with the latest value before the next request. + +6. To sign the user out, call ``clearAuthenticationContext()`` and erase any stored login and keychain items. + +## Make API requests + +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. + +```swift +var router = NetworkRouter(decoder: .atDecoder) +router.delegate = await APEnvironment.current.routerDelegate +``` + +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. + +## Troubleshooting + +- Ensure the DPoP key persists across app launches. If the key changes, all tokens issued by Bluesky become invalid and the user must reauthenticate. +- Always call ``applyAuthenticationContext(login:generator:resourceNonce:)`` after refreshing tokens via ``updateTokens(access:refresh:)`` or custom flows so the delegate has current credentials. +- If Bluesky rejects requests with `use_dpop_nonce`, update the cached value via ``updateResourceDPoPNonce(_:)`` and retry. + -- 2.43.0