this repo has no description

Compare changes

Choose any two refs to compare.

-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 -
···
+12
Documentation.docc/Info.plist
···
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleDevelopmentRegion</key> 6 + <string>en</string> 7 + <key>CFBundleIdentifier</key> 8 + <string>com.sparrowtek.coreatprotocol.documentation</string> 9 + <key>CFBundleName</key> 10 + <string>CoreATProtocol Documentation</string> 11 + </dict> 12 + </plist>
+204
Documentation.docc/OAuthIntegrationGuide.md
···
··· 1 + # OAuth Integration on iOS 2 + 3 + @Metadata { 4 + @Abstract( 5 + "Step-by-step instructions for adopting CoreATProtocol's bespoke OAuth implementation inside an iOS app." 6 + ) 7 + } 8 + 9 + ## Overview 10 + 11 + CoreATProtocol ships with an actor-isolated `OAuthManager` that performs the AT Protocol OAuth 2.1 flow, including PAR, PKCE, and DPoP. On iOS you combine the manager with a Keychain-backed credential store and an `ASWebAuthenticationSession`-based UI provider. After configuration, API calls issued through CoreATProtocol automatically receive DPoP-bound authorization headers via `APRouterDelegate`. 12 + 13 + The sections below walk through the recommended wiring for production iOS apps. 14 + 15 + ## Prerequisites 16 + 17 + - Xcode 16 or later with Swift 6. 18 + - A Bluesky/AT Protocol OAuth client metadata document hosted at a stable HTTPS URL. 19 + - A custom URL scheme registered in your app to receive the OAuth redirect. 20 + - Familiarity with Keychain Services and Swift concurrency. 21 + 22 + ## Step 1: Configure the Package 23 + 24 + Add CoreATProtocol as a Swift Package dependency in Xcode. Ensure your iOS target links against CoreATProtocol and imports it inside the app module. 25 + 26 + ```swift 27 + import CoreATProtocol 28 + ``` 29 + 30 + ## Step 2: Provide a Credential Store 31 + 32 + CoreATProtocol exposes `OAuthCredentialStore`. On iOS you typically persist credentials in the Keychain. Implement a store that conforms to the protocol and registers it with strong protections (biometric prompts are optional but encouraged). 33 + 34 + ```swift 35 + import CoreATProtocol 36 + import Security 37 + 38 + actor KeychainCredentialStore: OAuthCredentialStore { 39 + private enum Item { 40 + static let account = "com.sparrowtek.coreatprotocol.oauth" 41 + static let sessionKey = "session" 42 + static let dpopKey = "dpop-key" 43 + } 44 + 45 + func loadSession() async throws -> OAuthSession? { 46 + guard let data = try read(key: Item.sessionKey) else { return nil } 47 + return try JSONDecoder().decode(OAuthSession.self, from: data) 48 + } 49 + 50 + func save(session: OAuthSession) async throws { 51 + let data = try JSONEncoder().encode(session) 52 + try write(data, key: Item.sessionKey) 53 + } 54 + 55 + func deleteSession() async throws { 56 + try delete(key: Item.sessionKey) 57 + } 58 + 59 + func loadDPoPKey() async throws -> Data? { 60 + try read(key: Item.dpopKey) 61 + } 62 + 63 + func saveDPoPKey(_ data: Data) async throws { 64 + try write(data, key: Item.dpopKey) 65 + } 66 + 67 + func deleteDPoPKey() async throws { 68 + try delete(key: Item.dpopKey) 69 + } 70 + 71 + // MARK: - Helpers 72 + 73 + private func read(key: String) throws -> Data? { /* Keychain lookup */ } 74 + private func write(_ data: Data, key: String) throws { /* Keychain add/update */ } 75 + private func delete(key: String) throws { /* Keychain delete */ } 76 + } 77 + ``` 78 + 79 + Persist both the serialized `OAuthSession` and the raw DPoP private key so refreshes survive app restarts. 80 + 81 + ## Step 3: Create an OAuth UI Provider 82 + 83 + Provide an `OAuthUIProvider` that wraps `ASWebAuthenticationSession` and routes callbacks back into the manager. 84 + 85 + ```swift 86 + import AuthenticationServices 87 + import CoreATProtocol 88 + 89 + final class WebAuthenticationProvider: NSObject, OAuthUIProvider { 90 + private let presentationAnchor: ASPresentationAnchor 91 + 92 + init(anchor: ASPresentationAnchor) { 93 + self.presentationAnchor = anchor 94 + } 95 + 96 + func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL { 97 + try await withCheckedThrowingContinuation { continuation in 98 + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in 99 + if let error { continuation.resume(throwing: error) } 100 + else if let callbackURL { continuation.resume(returning: callbackURL) } 101 + else { continuation.resume(throwing: OAuthManagerError.authorizationCancelled) } 102 + } 103 + session.presentationContextProvider = self 104 + session.prefersEphemeralWebBrowserSession = true 105 + session.start() 106 + } 107 + } 108 + } 109 + 110 + extension WebAuthenticationProvider: ASWebAuthenticationPresentationContextProviding { 111 + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 112 + presentationAnchor 113 + } 114 + } 115 + ``` 116 + 117 + ## Step 4: Configure CoreATProtocol at Launch 118 + 119 + Set up the OAuth manager once the app knows its client metadata URL and redirect URI. The helper below is typically invoked from an `@MainActor` app coordinator. 120 + 121 + ```swift 122 + @MainActor 123 + func configureCoreATProtocol() async { 124 + let metadataURL = URL(string: "https://example.com/oauth/client-metadata.json")! 125 + let redirectURI = URL(string: "myapp://oauth/callback")! 126 + let configuration = OAuthConfiguration( 127 + clientMetadataURL: metadataURL, 128 + redirectURI: redirectURI 129 + ) 130 + 131 + let credentialStore = KeychainCredentialStore() 132 + try await CoreATProtocol.configureOAuth(configuration: configuration, credentialStore: credentialStore) 133 + } 134 + ``` 135 + 136 + Once configured, all `NetworkRouter` instances created by CoreATProtocol use the shared `APRouterDelegate`, which injects DPoP headers and handles nonce/token refreshes automatically. 137 + 138 + ## Step 5: Initiate Authentication 139 + 140 + Trigger the OAuth flow when the user picks a Bluesky handle. The manager resolves the handle to a DID, performs PAR, presents the auth session, exchanges codes, and caches the resulting `OAuthSession`. 141 + 142 + ```swift 143 + @MainActor 144 + func signIn(handle: String, anchor: ASPresentationAnchor) async { 145 + do { 146 + let provider = WebAuthenticationProvider(anchor: anchor) 147 + let session = try await CoreATProtocol.authenticate(handle: handle, using: provider) 148 + // Persist any additional app state and transition UI 149 + print("Authenticated DID: \(session.did)") 150 + } catch { 151 + // Present user-friendly errors or retry guidance 152 + print("OAuth failed: \(error)") 153 + } 154 + } 155 + ``` 156 + 157 + For returning users, call `CoreATProtocol.currentOAuthSession()` to check if a session already exists, and `CoreATProtocol.refreshOAuthSession()` to proactively refresh tokens. 158 + 159 + ## Step 6: Make Authenticated Requests 160 + 161 + After authentication, issue XRPC calls through CoreATProtocol normally. The router delegate supplies `Authorization: DPoP <token>` and a matching `DPoP` proof. Nonce challenges (`use_dpop_nonce`) and 401 responses automatically trigger a retry or refresh. 162 + 163 + ```swift 164 + @MainActor 165 + func loadProfile() async throws -> ActorProfile { 166 + // Example: using a CoreATProtocol service client 167 + let service = try await SomeServiceClient() 168 + return try await service.fetchProfile() 169 + } 170 + ``` 171 + 172 + If you maintain your own URL sessions, route them through CoreATProtocol or call `OAuthManager.authenticateResourceRequest(_:)` manually to attach the DPoP header before sending. 173 + 174 + ## Step 7: Handle Sign-Out 175 + 176 + When the user signs out, remove the stored session and DPoP key to enforce a clean re-authentication. 177 + 178 + ```swift 179 + @MainActor 180 + func signOut() async { 181 + do { 182 + try await CoreATProtocol.signOutOAuth() 183 + } catch { 184 + assertionFailure("Failed to sign out cleanly: \(error)") 185 + } 186 + } 187 + ``` 188 + 189 + You may also want to revoke tokens via the OAuth revocation endpoint once the server exposes it. 190 + 191 + ## Step 8: Testing and Diagnostics 192 + 193 + - Use dependency injection to swap `IdentityResolver`, `OAuthHTTPClient`, and `OAuthCredentialStore` with mocks for unit tests. 194 + - Exercise the new Swift Testing cases in `Tests/CoreATProtocolTests` to verify PKCE, DPoP, and session expiry logic after future changes. 195 + - Capture and log `WWW-Authenticate` headers during development to monitor nonce churn. 196 + 197 + ## Troubleshooting 198 + 199 + | Symptom | Suggested Fix | 200 + | --- | --- | 201 + | `authorization_in_progress` errors | Ensure `beginAuthorization` is not called twice in parallel. Await `resumeAuthorization` before retrying. | 202 + | `invalid_redirect_uri` | Confirm the redirect URI in the client metadata exactly matches the one passed to `OAuthConfiguration`. | 203 + | `use_dpop_nonce` loops | Inspect your networking stack for caching; DPoP proof URLs must not contain query fragments. | 204 + | Token refresh failing after app relaunch | Verify the Keychain store persists both the session JSON and the raw DPoP key. |
-60
Package.resolved
··· 1 - { 2 - "originHash" : "2237e2c10a8d530dcbd1f9770efc8fcf2a9fc2ca2c63a19882551fea7ab9fe25", 3 - "pins" : [ 4 - { 5 - "identity" : "jwt-kit", 6 - "kind" : "remoteSourceControl", 7 - "location" : "https://github.com/vapor/jwt-kit.git", 8 - "state" : { 9 - "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", 10 - "version" : "5.2.0" 11 - } 12 - }, 13 - { 14 - "identity" : "oauthenticator", 15 - "kind" : "remoteSourceControl", 16 - "location" : "https://github.com/ChimeHQ/OAuthenticator", 17 - "state" : { 18 - "branch" : "main", 19 - "revision" : "618971d4d341650db664925fd0479032294064ad" 20 - } 21 - }, 22 - { 23 - "identity" : "swift-asn1", 24 - "kind" : "remoteSourceControl", 25 - "location" : "https://github.com/apple/swift-asn1.git", 26 - "state" : { 27 - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", 28 - "version" : "1.5.0" 29 - } 30 - }, 31 - { 32 - "identity" : "swift-certificates", 33 - "kind" : "remoteSourceControl", 34 - "location" : "https://github.com/apple/swift-certificates.git", 35 - "state" : { 36 - "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", 37 - "version" : "1.15.0" 38 - } 39 - }, 40 - { 41 - "identity" : "swift-crypto", 42 - "kind" : "remoteSourceControl", 43 - "location" : "https://github.com/apple/swift-crypto.git", 44 - "state" : { 45 - "revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18", 46 - "version" : "4.0.0" 47 - } 48 - }, 49 - { 50 - "identity" : "swift-log", 51 - "kind" : "remoteSourceControl", 52 - "location" : "https://github.com/apple/swift-log.git", 53 - "state" : { 54 - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", 55 - "version" : "1.6.4" 56 - } 57 - } 58 - ], 59 - "version" : 3 60 - }
···
+1 -9
Package.swift
··· 17 targets: ["CoreATProtocol"] 18 ), 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 - ], 24 targets: [ 25 .target( 26 - name: "CoreATProtocol", 27 - dependencies: [ 28 - "OAuthenticator", 29 - .product(name: "JWTKit", package: "jwt-kit"), 30 - ], 31 ), 32 .testTarget( 33 name: "CoreATProtocolTests",
··· 17 targets: ["CoreATProtocol"] 18 ), 19 ], 20 targets: [ 21 .target( 22 + name: "CoreATProtocol" 23 ), 24 .testTarget( 25 name: "CoreATProtocolTests",
+12 -108
Sources/CoreATProtocol/APEnvironment.swift
··· 5 // Created by Thomas Rademaker on 10/10/25. 6 // 7 8 - import Foundation 9 - import OAuthenticator 10 - 11 @APActor 12 public class APEnvironment { 13 public static var current: APEnvironment = APEnvironment() 14 - 15 - // MARK: - Connection Configuration 16 public var host: String? 17 - 18 - // MARK: - Authentication Tokens 19 public var accessToken: String? 20 public var refreshToken: String? 21 - public var login: Login? 22 - 23 - // MARK: - DPoP Support 24 - public var dpopProofGenerator: DPoPSigner.JWTGenerator? 25 - public var resourceServerNonce: String? 26 - public let resourceDPoPSigner = DPoPSigner() 27 - 28 - // MARK: - OAuth Configuration (for token refresh) 29 - public var serverMetadata: ServerMetadata? 30 - public var clientId: String? 31 - public var authState: AuthenticationState? 32 - public var tokenStorage: TokenStorageProtocol? 33 - 34 - // MARK: - Identity 35 - public var resolvedIdentity: IdentityResolver.ResolvedIdentity? 36 - public let identityResolver = IdentityResolver() 37 - 38 - // MARK: - Delegates and Callbacks 39 public var atProtocoldelegate: CoreATProtocolDelegate? 40 public let routerDelegate = APRouterDelegate() 41 - 42 - // MARK: - State Flags 43 - private var isRefreshing = false 44 - 45 - private init() {} 46 - 47 - // MARK: - Token Refresh 48 - 49 - /// Checks if the current access token needs refresh. 50 - public var needsTokenRefresh: Bool { 51 - if let state = authState { 52 - return state.isAccessTokenExpired 53 - } 54 - // If no auth state, check login object 55 - if let login = login { 56 - return !login.accessToken.valid 57 - } 58 - return false 59 - } 60 - 61 - /// Attempts to refresh the access token if needed. 62 - /// Returns true if refresh succeeded or wasn't needed, false if refresh failed. 63 - public func refreshTokenIfNeeded() async -> Bool { 64 - guard needsTokenRefresh else { return true } 65 - 66 - // Prevent concurrent refresh attempts 67 - guard !isRefreshing else { return false } 68 - isRefreshing = true 69 - defer { isRefreshing = false } 70 - 71 - return await performTokenRefresh() 72 - } 73 - 74 - // MARK: - Configuration 75 - 76 - /// Configures the environment for OAuth with token refresh support. 77 - public func configureOAuth( 78 - serverMetadata: ServerMetadata, 79 - clientId: String, 80 - tokenStorage: TokenStorageProtocol? = nil 81 - ) { 82 - self.serverMetadata = serverMetadata 83 - self.clientId = clientId 84 - self.tokenStorage = tokenStorage 85 - } 86 - 87 - /// Stores the complete authentication state after successful login. 88 - public func setAuthenticationState(_ state: AuthenticationState) async { 89 - self.authState = state 90 - self.accessToken = state.accessToken 91 - self.refreshToken = state.refreshToken 92 - 93 - // Update host from PDS URL 94 - if let url = URL(string: state.pdsURL) { 95 - self.host = url.absoluteString 96 - } 97 - 98 - // Persist if storage is configured 99 - if let storage = tokenStorage { 100 - try? await storage.store(state) 101 - } 102 - } 103 - 104 - /// Restores authentication state from storage. 105 - public func restoreAuthenticationState() async -> Bool { 106 - guard let storage = tokenStorage else { return false } 107 - 108 - do { 109 - guard let state = try await storage.retrieve() else { 110 - return false 111 - } 112 - 113 - self.authState = state 114 - self.accessToken = state.accessToken 115 - self.refreshToken = state.refreshToken 116 - 117 - if let url = URL(string: state.pdsURL) { 118 - self.host = url.absoluteString 119 - } 120 - 121 - return true 122 - } catch { 123 - return false 124 } 125 } 126 }
··· 5 // Created by Thomas Rademaker on 10/10/25. 6 // 7 8 @APActor 9 public class APEnvironment { 10 public static var current: APEnvironment = APEnvironment() 11 + 12 public var host: String? 13 public var accessToken: String? 14 public var refreshToken: String? 15 public var atProtocoldelegate: CoreATProtocolDelegate? 16 public let routerDelegate = APRouterDelegate() 17 + public var oauthManager: OAuthManager? { 18 + didSet { 19 + routerDelegate.oauthManager = oauthManager 20 } 21 } 22 + 23 + private init() {} 24 + 25 + // func setup(apiKey: String, apiSecret: String, userAgent: String) { 26 + // self.apiKey = apiKey 27 + // self.apiSecret = apiSecret 28 + // self.userAgent = userAgent 29 + // } 30 }
+24 -142
Sources/CoreATProtocol/CoreATProtocol.swift
··· 1 // The Swift Programming Language 2 // https://docs.swift.org/swift-book 3 4 - @_exported import OAuthenticator 5 - 6 - /// Delegate protocol for receiving authentication and session lifecycle events. 7 - @MainActor 8 - public protocol CoreATProtocolDelegate: AnyObject, Sendable { 9 - /// Called when tokens have been refreshed. 10 - func tokensUpdated(accessToken: String, refreshToken: String?) async 11 - 12 - /// Called when a session has expired and re-authentication is required. 13 - func sessionExpired() async 14 - 15 - /// Called when authentication fails. 16 - func authenticationFailed(error: Error) async 17 - 18 - /// Called when DPoP nonce is updated from a server response. 19 - func dpopNonceUpdated(nonce: String) async 20 - } 21 - 22 - /// Default implementations for optional delegate methods. 23 - public extension CoreATProtocolDelegate { 24 - func tokensUpdated(accessToken: String, refreshToken: String?) async {} 25 - func sessionExpired() async {} 26 - func authenticationFailed(error: Error) async {} 27 - func dpopNonceUpdated(nonce: String) async {} 28 - } 29 30 - // MARK: - Setup Functions 31 - 32 - /// Configures the AT Protocol environment with basic authentication. 33 - /// - Parameters: 34 - /// - hostURL: The PDS host URL 35 - /// - accessJWT: Access token 36 - /// - refreshJWT: Refresh token 37 - /// - delegate: Optional delegate for receiving events 38 @APActor 39 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { 40 APEnvironment.current.host = hostURL ··· 43 APEnvironment.current.atProtocoldelegate = delegate 44 } 45 46 - /// Configures the AT Protocol environment with OAuth support. 47 - /// - Parameters: 48 - /// - serverMetadata: OAuth authorization server metadata 49 - /// - clientId: The client ID for this application 50 - /// - tokenStorage: Optional persistent storage for tokens 51 - /// - delegate: Optional delegate for receiving events 52 - @APActor 53 - public func setupOAuth( 54 - serverMetadata: ServerMetadata, 55 - clientId: String, 56 - tokenStorage: TokenStorageProtocol? = nil, 57 - delegate: CoreATProtocolDelegate? = nil 58 - ) { 59 - APEnvironment.current.configureOAuth( 60 - serverMetadata: serverMetadata, 61 - clientId: clientId, 62 - tokenStorage: tokenStorage 63 - ) 64 - APEnvironment.current.atProtocoldelegate = delegate 65 - } 66 - 67 - /// Sets the delegate for receiving authentication events. 68 @APActor 69 public func setDelegate(_ delegate: CoreATProtocolDelegate) { 70 APEnvironment.current.atProtocoldelegate = delegate 71 } 72 73 - /// Updates the stored tokens. 74 @APActor 75 public func updateTokens(access: String?, refresh: String?) { 76 APEnvironment.current.accessToken = access 77 APEnvironment.current.refreshToken = refresh 78 } 79 80 - /// Updates the host URL. 81 @APActor 82 public func update(hostURL: String?) { 83 APEnvironment.current.host = hostURL 84 } 85 86 - /// Applies a complete authentication context from a successful OAuth login. 87 - /// - Parameters: 88 - /// - login: The Login object from OAuthenticator 89 - /// - generator: DPoP JWT generator for signing requests 90 - /// - resourceNonce: Initial DPoP nonce from the resource server 91 - /// - serverMetadata: OAuth server metadata for token refresh 92 - /// - clientId: Client ID for token refresh 93 @APActor 94 - public func applyAuthenticationContext( 95 - login: Login, 96 - generator: @escaping DPoPSigner.JWTGenerator, 97 - resourceNonce: String? = nil, 98 - serverMetadata: ServerMetadata? = nil, 99 - clientId: String? = nil 100 - ) { 101 - APEnvironment.current.login = login 102 - APEnvironment.current.accessToken = login.accessToken.value 103 - APEnvironment.current.refreshToken = login.refreshToken?.value 104 - APEnvironment.current.dpopProofGenerator = generator 105 - APEnvironment.current.resourceServerNonce = resourceNonce 106 - APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce 107 - 108 - // Store OAuth configuration if provided (needed for token refresh) 109 - if let metadata = serverMetadata { 110 - APEnvironment.current.serverMetadata = metadata 111 - } 112 - if let id = clientId { 113 - APEnvironment.current.clientId = id 114 - } 115 } 116 117 - /// Clears all authentication context and tokens. 118 @APActor 119 - public func clearAuthenticationContext() async { 120 - APEnvironment.current.login = nil 121 - APEnvironment.current.dpopProofGenerator = nil 122 - APEnvironment.current.resourceServerNonce = nil 123 - APEnvironment.current.accessToken = nil 124 - APEnvironment.current.refreshToken = nil 125 - APEnvironment.current.resourceDPoPSigner.nonce = nil 126 - APEnvironment.current.authState = nil 127 - APEnvironment.current.resolvedIdentity = nil 128 - 129 - // Clear persistent storage if configured 130 - if let storage = APEnvironment.current.tokenStorage { 131 - try? await storage.clear() 132 } 133 } 134 135 - /// Updates the resource server DPoP nonce. 136 @APActor 137 - public func updateResourceDPoPNonce(_ nonce: String?) { 138 - APEnvironment.current.resourceServerNonce = nonce 139 - APEnvironment.current.resourceDPoPSigner.nonce = nonce 140 } 141 142 - // MARK: - Identity Resolution 143 - 144 - /// Resolves a handle to a complete identity with PDS and authorization server URLs. 145 - /// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social") 146 - /// - Returns: Complete resolved identity information 147 @APActor 148 - public func resolveIdentity(handle: String) async throws -> IdentityResolver.ResolvedIdentity { 149 - let identity = try await APEnvironment.current.identityResolver.resolveIdentity(handle: handle) 150 - APEnvironment.current.resolvedIdentity = identity 151 - APEnvironment.current.host = identity.pdsURL 152 - return identity 153 } 154 155 - /// Resolves a DID to a complete identity with PDS and authorization server URLs. 156 - /// - Parameter did: The DID to resolve (e.g., "did:plc:abc123") 157 - /// - Returns: Complete resolved identity information 158 @APActor 159 - public func resolveIdentity(did: String) async throws -> IdentityResolver.ResolvedIdentity { 160 - let identity = try await APEnvironment.current.identityResolver.resolveIdentity(did: did) 161 - APEnvironment.current.resolvedIdentity = identity 162 - APEnvironment.current.host = identity.pdsURL 163 - return identity 164 - } 165 - 166 - // MARK: - Session Management 167 - 168 - /// Attempts to restore a previous session from persistent storage. 169 - /// - Returns: true if a session was restored, false otherwise 170 - @APActor 171 - public func restoreSession() async -> Bool { 172 - return await APEnvironment.current.restoreAuthenticationState() 173 - } 174 - 175 - /// Checks if the current session is valid and has non-expired tokens. 176 - @APActor 177 - public var hasValidSession: Bool { 178 - if let state = APEnvironment.current.authState { 179 - return !state.isAccessTokenExpired || state.canRefresh 180 - } 181 - if let login = APEnvironment.current.login { 182 - return login.accessToken.valid || (login.refreshToken?.valid ?? false) 183 - } 184 - return APEnvironment.current.accessToken != nil 185 }
··· 1 // The Swift Programming Language 2 // https://docs.swift.org/swift-book 3 4 + public protocol CoreATProtocolDelegate: AnyObject {} 5 6 @APActor 7 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { 8 APEnvironment.current.host = hostURL ··· 11 APEnvironment.current.atProtocoldelegate = delegate 12 } 13 14 @APActor 15 public func setDelegate(_ delegate: CoreATProtocolDelegate) { 16 APEnvironment.current.atProtocoldelegate = delegate 17 } 18 19 @APActor 20 public func updateTokens(access: String?, refresh: String?) { 21 APEnvironment.current.accessToken = access 22 APEnvironment.current.refreshToken = refresh 23 } 24 25 @APActor 26 public func update(hostURL: String?) { 27 APEnvironment.current.host = hostURL 28 } 29 30 @APActor 31 + public func configureOAuth( 32 + configuration: OAuthConfiguration, 33 + credentialStore: OAuthCredentialStore? = nil 34 + ) async throws { 35 + let store = credentialStore ?? InMemoryOAuthCredentialStore() 36 + let manager = try await OAuthManager(configuration: configuration, credentialStore: store) 37 + APEnvironment.current.oauthManager = manager 38 } 39 40 @APActor 41 + public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { 42 + guard let manager = APEnvironment.current.oauthManager else { 43 + throw OAuthManagerError.invalidAuthorizationState 44 } 45 + let session = try await manager.authenticate(handle: handle, using: uiProvider) 46 + APEnvironment.current.host = session.pdsURL.absoluteString 47 + return session 48 } 49 50 @APActor 51 + public func currentOAuthSession() -> OAuthSession? { 52 + APEnvironment.current.oauthManager?.currentSession 53 } 54 55 @APActor 56 + public func refreshOAuthSession() async throws -> OAuthSession { 57 + guard let manager = APEnvironment.current.oauthManager else { 58 + throw OAuthManagerError.invalidAuthorizationState 59 + } 60 + return try await manager.refreshSession() 61 } 62 63 @APActor 64 + public func signOutOAuth() async throws { 65 + guard let manager = APEnvironment.current.oauthManager else { return } 66 + try await manager.signOut() 67 }
-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 - }
···
-123
Sources/CoreATProtocol/Identity/DIDDocument.swift
··· 1 - // 2 - // DIDDocument.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - 10 - /// Represents a DID Document as specified by the AT Protocol. 11 - /// DID Documents contain the public key and service endpoints for an identity. 12 - public struct DIDDocument: Codable, Sendable, Hashable { 13 - public let context: [String] 14 - public let id: String 15 - public let alsoKnownAs: [String]? 16 - public let verificationMethod: [VerificationMethod]? 17 - public let service: [Service]? 18 - 19 - enum CodingKeys: String, CodingKey { 20 - case context = "@context" 21 - case id 22 - case alsoKnownAs 23 - case verificationMethod 24 - case service 25 - } 26 - 27 - public init( 28 - context: [String] = ["https://www.w3.org/ns/did/v1"], 29 - id: String, 30 - alsoKnownAs: [String]? = nil, 31 - verificationMethod: [VerificationMethod]? = nil, 32 - service: [Service]? = nil 33 - ) { 34 - self.context = context 35 - self.id = id 36 - self.alsoKnownAs = alsoKnownAs 37 - self.verificationMethod = verificationMethod 38 - self.service = service 39 - } 40 - 41 - /// Extracts the handle from the alsoKnownAs field. 42 - /// Handles are stored as `at://handle` URIs. 43 - public var handle: String? { 44 - alsoKnownAs?.compactMap { uri -> String? in 45 - guard uri.hasPrefix("at://") else { return nil } 46 - return String(uri.dropFirst(5)) 47 - }.first 48 - } 49 - 50 - /// Extracts the PDS (Personal Data Server) endpoint from the service array. 51 - public var pdsEndpoint: String? { 52 - service?.first { $0.id == "#atproto_pds" || $0.type == "AtprotoPersonalDataServer" }?.serviceEndpoint 53 - } 54 - } 55 - 56 - /// Represents a verification method in a DID Document. 57 - public struct VerificationMethod: Codable, Sendable, Hashable { 58 - public let id: String 59 - public let type: String 60 - public let controller: String 61 - public let publicKeyMultibase: String? 62 - 63 - public init(id: String, type: String, controller: String, publicKeyMultibase: String? = nil) { 64 - self.id = id 65 - self.type = type 66 - self.controller = controller 67 - self.publicKeyMultibase = publicKeyMultibase 68 - } 69 - } 70 - 71 - /// Represents a service endpoint in a DID Document. 72 - public struct Service: Codable, Sendable, Hashable { 73 - public let id: String 74 - public let type: String 75 - public let serviceEndpoint: String 76 - 77 - public init(id: String, type: String, serviceEndpoint: String) { 78 - self.id = id 79 - self.type = type 80 - self.serviceEndpoint = serviceEndpoint 81 - } 82 - } 83 - 84 - /// Represents the response from a PLC directory lookup. 85 - public struct PLCDirectoryResponse: Codable, Sendable { 86 - public let did: String 87 - public let verificationMethods: [String: String]? 88 - public let rotationKeys: [String]? 89 - public let alsoKnownAs: [String]? 90 - public let services: [String: PLCService]? 91 - 92 - public struct PLCService: Codable, Sendable { 93 - public let type: String 94 - public let endpoint: String 95 - } 96 - 97 - /// Converts PLC response to standard DID Document format. 98 - public func toDIDDocument() -> DIDDocument { 99 - let verificationMethods = self.verificationMethods?.map { (id, key) in 100 - VerificationMethod( 101 - id: "\(did)\(id)", 102 - type: "Multikey", 103 - controller: did, 104 - publicKeyMultibase: key 105 - ) 106 - } 107 - 108 - let services = self.services?.map { (id, service) in 109 - Service( 110 - id: id, 111 - type: service.type, 112 - serviceEndpoint: service.endpoint 113 - ) 114 - } 115 - 116 - return DIDDocument( 117 - id: did, 118 - alsoKnownAs: alsoKnownAs, 119 - verificationMethod: verificationMethods, 120 - service: services 121 - ) 122 - } 123 - }
···
-334
Sources/CoreATProtocol/Identity/IdentityResolver.swift
··· 1 - // 2 - // IdentityResolver.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - @preconcurrency import OAuthenticator 10 - 11 - /// Errors that can occur during identity resolution. 12 - public enum IdentityError: Error, Sendable { 13 - case invalidHandle(String) 14 - case invalidDID(String) 15 - case handleResolutionFailed(String) 16 - case didResolutionFailed(String) 17 - case pdsNotFound 18 - case authorizationServerNotFound 19 - case networkError(Error) 20 - case invalidURL(String) 21 - case bidirectionalVerificationFailed(handle: String, did: String) 22 - } 23 - 24 - /// Resolves AT Protocol identities (handles and DIDs) to their associated metadata. 25 - /// 26 - /// This resolver handles: 27 - /// - Handle to DID resolution via `.well-known/atproto-did` 28 - /// - DID document fetching for both `did:plc` and `did:web` methods 29 - /// - PDS (Personal Data Server) endpoint discovery 30 - /// - Authorization server metadata fetching 31 - /// - Bidirectional handle verification 32 - @APActor 33 - public final class IdentityResolver { 34 - 35 - /// Cache entry for resolved identities. 36 - private struct CacheEntry { 37 - let document: DIDDocument 38 - let timestamp: Date 39 - } 40 - 41 - private let urlSession: URLSession 42 - private var cache: [String: CacheEntry] = [:] 43 - 44 - /// Cache TTL in seconds. Default is 10 minutes as recommended by AT Protocol spec. 45 - public var cacheTTL: TimeInterval = 600 46 - 47 - /// The PLC directory URL for resolving did:plc identifiers. 48 - public var plcDirectoryURL: String = "https://plc.directory" 49 - 50 - public init(urlSession: URLSession = .shared) { 51 - self.urlSession = urlSession 52 - } 53 - 54 - // MARK: - Handle Resolution 55 - 56 - /// Resolves a handle to a DID using the `.well-known/atproto-did` endpoint. 57 - /// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social") 58 - /// - Returns: The DID string (e.g., "did:plc:abc123") 59 - public func resolveHandle(_ handle: String) async throws -> String { 60 - let normalizedHandle = handle.lowercased().trimmingCharacters(in: .whitespaces) 61 - 62 - guard isValidHandle(normalizedHandle) else { 63 - throw IdentityError.invalidHandle(handle) 64 - } 65 - 66 - let urlString = "https://\(normalizedHandle)/.well-known/atproto-did" 67 - guard let url = URL(string: urlString) else { 68 - throw IdentityError.invalidURL(urlString) 69 - } 70 - 71 - do { 72 - let (data, response) = try await urlSession.data(from: url) 73 - 74 - guard let httpResponse = response as? HTTPURLResponse, 75 - (200...299).contains(httpResponse.statusCode) else { 76 - throw IdentityError.handleResolutionFailed(handle) 77 - } 78 - 79 - guard let did = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), 80 - did.hasPrefix("did:") else { 81 - throw IdentityError.handleResolutionFailed(handle) 82 - } 83 - 84 - return did 85 - } catch let error as IdentityError { 86 - throw error 87 - } catch { 88 - throw IdentityError.networkError(error) 89 - } 90 - } 91 - 92 - // MARK: - DID Resolution 93 - 94 - /// Resolves a DID to its DID Document. 95 - /// - Parameter did: The DID to resolve (e.g., "did:plc:abc123" or "did:web:example.com") 96 - /// - Returns: The DID Document containing verification methods and service endpoints 97 - public func resolveDID(_ did: String) async throws -> DIDDocument { 98 - // Check cache first 99 - if let cached = cache[did], Date().timeIntervalSince(cached.timestamp) < cacheTTL { 100 - return cached.document 101 - } 102 - 103 - let document: DIDDocument 104 - 105 - if did.hasPrefix("did:plc:") { 106 - document = try await resolvePLCDID(did) 107 - } else if did.hasPrefix("did:web:") { 108 - document = try await resolveWebDID(did) 109 - } else { 110 - throw IdentityError.invalidDID(did) 111 - } 112 - 113 - // Cache the result 114 - cache[did] = CacheEntry(document: document, timestamp: Date()) 115 - 116 - return document 117 - } 118 - 119 - /// Resolves a did:plc identifier using the PLC directory. 120 - private func resolvePLCDID(_ did: String) async throws -> DIDDocument { 121 - let urlString = "\(plcDirectoryURL)/\(did)" 122 - guard let url = URL(string: urlString) else { 123 - throw IdentityError.invalidURL(urlString) 124 - } 125 - 126 - do { 127 - let (data, response) = try await urlSession.data(from: url) 128 - 129 - guard let httpResponse = response as? HTTPURLResponse, 130 - (200...299).contains(httpResponse.statusCode) else { 131 - throw IdentityError.didResolutionFailed(did) 132 - } 133 - 134 - // Try to decode as PLC directory response first 135 - if let plcResponse = try? JSONDecoder().decode(PLCDirectoryResponse.self, from: data) { 136 - return plcResponse.toDIDDocument() 137 - } 138 - 139 - // Fall back to standard DID document format 140 - return try JSONDecoder().decode(DIDDocument.self, from: data) 141 - } catch let error as IdentityError { 142 - throw error 143 - } catch { 144 - throw IdentityError.networkError(error) 145 - } 146 - } 147 - 148 - /// Resolves a did:web identifier. 149 - private func resolveWebDID(_ did: String) async throws -> DIDDocument { 150 - // did:web:example.com -> https://example.com/.well-known/did.json 151 - // did:web:example.com:path:to:resource -> https://example.com/path/to/resource/did.json 152 - let identifier = String(did.dropFirst("did:web:".count)) 153 - let parts = identifier.split(separator: ":").map(String.init) 154 - 155 - let urlString: String 156 - if parts.count == 1 { 157 - urlString = "https://\(parts[0])/.well-known/did.json" 158 - } else { 159 - let host = parts[0] 160 - let path = parts.dropFirst().joined(separator: "/") 161 - urlString = "https://\(host)/\(path)/did.json" 162 - } 163 - 164 - guard let url = URL(string: urlString) else { 165 - throw IdentityError.invalidURL(urlString) 166 - } 167 - 168 - do { 169 - let (data, response) = try await urlSession.data(from: url) 170 - 171 - guard let httpResponse = response as? HTTPURLResponse, 172 - (200...299).contains(httpResponse.statusCode) else { 173 - throw IdentityError.didResolutionFailed(did) 174 - } 175 - 176 - return try JSONDecoder().decode(DIDDocument.self, from: data) 177 - } catch let error as IdentityError { 178 - throw error 179 - } catch { 180 - throw IdentityError.networkError(error) 181 - } 182 - } 183 - 184 - // MARK: - PDS Discovery 185 - 186 - /// Gets the PDS endpoint for a given DID. 187 - /// - Parameter did: The DID to look up 188 - /// - Returns: The PDS service endpoint URL 189 - public func getPDSEndpoint(for did: String) async throws -> String { 190 - let document = try await resolveDID(did) 191 - 192 - guard let pds = document.pdsEndpoint else { 193 - throw IdentityError.pdsNotFound 194 - } 195 - 196 - return pds 197 - } 198 - 199 - // MARK: - Authorization Server Discovery 200 - 201 - /// Represents the OAuth Protected Resource metadata from a PDS. 202 - public struct ProtectedResourceMetadata: Codable, Sendable { 203 - public let resource: String 204 - public let authorizationServers: [String] 205 - 206 - enum CodingKeys: String, CodingKey { 207 - case resource 208 - case authorizationServers = "authorization_servers" 209 - } 210 - } 211 - 212 - /// Fetches the authorization server URL from a PDS. 213 - /// - Parameter pdsURL: The PDS base URL 214 - /// - Returns: The authorization server URL 215 - public func getAuthorizationServer(from pdsURL: String) async throws -> String { 216 - let normalizedPDS = pdsURL.hasSuffix("/") ? String(pdsURL.dropLast()) : pdsURL 217 - let urlString = "\(normalizedPDS)/.well-known/oauth-protected-resource" 218 - 219 - guard let url = URL(string: urlString) else { 220 - throw IdentityError.invalidURL(urlString) 221 - } 222 - 223 - do { 224 - let (data, response) = try await urlSession.data(from: url) 225 - 226 - guard let httpResponse = response as? HTTPURLResponse, 227 - (200...299).contains(httpResponse.statusCode) else { 228 - throw IdentityError.authorizationServerNotFound 229 - } 230 - 231 - let metadata = try JSONDecoder().decode(ProtectedResourceMetadata.self, from: data) 232 - 233 - guard let authServer = metadata.authorizationServers.first else { 234 - throw IdentityError.authorizationServerNotFound 235 - } 236 - 237 - return authServer 238 - } catch let error as IdentityError { 239 - throw error 240 - } catch { 241 - throw IdentityError.networkError(error) 242 - } 243 - } 244 - 245 - // MARK: - Full Resolution 246 - 247 - /// Result of resolving an identity including all metadata. 248 - public struct ResolvedIdentity: Sendable { 249 - public let handle: String 250 - public let did: String 251 - public let didDocument: DIDDocument 252 - public let pdsURL: String 253 - public let authorizationServerURL: String 254 - 255 - public init(handle: String, did: String, didDocument: DIDDocument, pdsURL: String, authorizationServerURL: String) { 256 - self.handle = handle 257 - self.did = did 258 - self.didDocument = didDocument 259 - self.pdsURL = pdsURL 260 - self.authorizationServerURL = authorizationServerURL 261 - } 262 - } 263 - 264 - /// Fully resolves an identity from a handle, including bidirectional verification. 265 - /// - Parameter handle: The handle to resolve 266 - /// - Returns: Complete identity information including PDS and auth server 267 - public func resolveIdentity(handle: String) async throws -> ResolvedIdentity { 268 - // Step 1: Resolve handle to DID 269 - let did = try await resolveHandle(handle) 270 - 271 - // Step 2: Resolve DID to document 272 - let document = try await resolveDID(did) 273 - 274 - // Step 3: Verify bidirectional handle claim 275 - let normalizedHandle = handle.lowercased() 276 - if let documentHandle = document.handle?.lowercased(), documentHandle != normalizedHandle { 277 - throw IdentityError.bidirectionalVerificationFailed(handle: handle, did: did) 278 - } 279 - 280 - // Step 4: Get PDS endpoint 281 - guard let pdsURL = document.pdsEndpoint else { 282 - throw IdentityError.pdsNotFound 283 - } 284 - 285 - // Step 5: Get authorization server 286 - let authServerURL = try await getAuthorizationServer(from: pdsURL) 287 - 288 - return ResolvedIdentity( 289 - handle: handle, 290 - did: did, 291 - didDocument: document, 292 - pdsURL: pdsURL, 293 - authorizationServerURL: authServerURL 294 - ) 295 - } 296 - 297 - /// Resolves identity starting from a DID. 298 - /// - Parameter did: The DID to resolve 299 - /// - Returns: Complete identity information 300 - public func resolveIdentity(did: String) async throws -> ResolvedIdentity { 301 - let document = try await resolveDID(did) 302 - 303 - guard let pdsURL = document.pdsEndpoint else { 304 - throw IdentityError.pdsNotFound 305 - } 306 - 307 - let authServerURL = try await getAuthorizationServer(from: pdsURL) 308 - 309 - return ResolvedIdentity( 310 - handle: document.handle ?? "", 311 - did: did, 312 - didDocument: document, 313 - pdsURL: pdsURL, 314 - authorizationServerURL: authServerURL 315 - ) 316 - } 317 - 318 - // MARK: - Validation 319 - 320 - /// Validates if a string is a valid handle format. 321 - private func isValidHandle(_ handle: String) -> Bool { 322 - // Basic validation: must have at least one dot, no spaces, reasonable length 323 - let parts = handle.split(separator: ".") 324 - guard parts.count >= 2 else { return false } 325 - guard handle.count >= 3 && handle.count <= 253 else { return false } 326 - guard !handle.contains(" ") else { return false } 327 - return true 328 - } 329 - 330 - /// Clears the identity cache. 331 - public func clearCache() { 332 - cache.removeAll() 333 - } 334 - }
···
-245
Sources/CoreATProtocol/Logging/ATLogger.swift
··· 1 - // 2 - // ATLogger.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - import os.log 10 - 11 - /// Log levels for AT Protocol operations. 12 - public enum ATLogLevel: Int, Comparable, Sendable { 13 - case debug = 0 14 - case info = 1 15 - case warning = 2 16 - case error = 3 17 - case none = 100 18 - 19 - public static func < (lhs: ATLogLevel, rhs: ATLogLevel) -> Bool { 20 - lhs.rawValue < rhs.rawValue 21 - } 22 - } 23 - 24 - /// Protocol for custom log handlers. 25 - public protocol ATLogHandler: Sendable { 26 - func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) 27 - } 28 - 29 - /// Logger for AT Protocol operations. 30 - /// Provides structured logging with support for custom handlers. 31 - public final class ATLogger: @unchecked Sendable { 32 - 33 - /// Shared logger instance. 34 - public static let shared = ATLogger() 35 - 36 - /// Current log level. Messages below this level are not logged. 37 - public var logLevel: ATLogLevel = .info 38 - 39 - /// Custom log handler. If nil, uses OSLog on Apple platforms. 40 - public var handler: ATLogHandler? 41 - 42 - /// Whether to include request/response bodies in logs (may contain sensitive data). 43 - public var logBodies: Bool = false 44 - 45 - /// Whether to redact authorization headers and tokens. 46 - public var redactTokens: Bool = true 47 - 48 - private let osLog: OSLog 49 - 50 - private init() { 51 - self.osLog = OSLog(subsystem: "com.atprotocol.core", category: "network") 52 - } 53 - 54 - // MARK: - Logging Methods 55 - 56 - /// Logs a debug message. 57 - public func debug( 58 - _ message: @autoclosure () -> String, 59 - metadata: [String: String]? = nil, 60 - file: String = #file, 61 - function: String = #function, 62 - line: Int = #line 63 - ) { 64 - log(level: .debug, message: message(), metadata: metadata, file: file, function: function, line: line) 65 - } 66 - 67 - /// Logs an info message. 68 - public func info( 69 - _ message: @autoclosure () -> String, 70 - metadata: [String: String]? = nil, 71 - file: String = #file, 72 - function: String = #function, 73 - line: Int = #line 74 - ) { 75 - log(level: .info, message: message(), metadata: metadata, file: file, function: function, line: line) 76 - } 77 - 78 - /// Logs a warning message. 79 - public func warning( 80 - _ message: @autoclosure () -> String, 81 - metadata: [String: String]? = nil, 82 - file: String = #file, 83 - function: String = #function, 84 - line: Int = #line 85 - ) { 86 - log(level: .warning, message: message(), metadata: metadata, file: file, function: function, line: line) 87 - } 88 - 89 - /// Logs an error message. 90 - public func error( 91 - _ message: @autoclosure () -> String, 92 - metadata: [String: String]? = nil, 93 - file: String = #file, 94 - function: String = #function, 95 - line: Int = #line 96 - ) { 97 - log(level: .error, message: message(), metadata: metadata, file: file, function: function, line: line) 98 - } 99 - 100 - // MARK: - Network Logging 101 - 102 - /// Logs an outgoing request. 103 - public func logRequest(_ request: URLRequest, id: String = UUID().uuidString) { 104 - guard logLevel <= .debug else { return } 105 - 106 - var metadata: [String: String] = [ 107 - "request_id": id, 108 - "method": request.httpMethod ?? "UNKNOWN", 109 - "url": request.url?.absoluteString ?? "unknown" 110 - ] 111 - 112 - // Add headers (redacting sensitive ones) 113 - if let headers = request.allHTTPHeaderFields { 114 - for (key, value) in headers { 115 - let redactedValue = shouldRedact(header: key) ? "[REDACTED]" : value 116 - metadata["header_\(key)"] = redactedValue 117 - } 118 - } 119 - 120 - // Optionally log body 121 - if logBodies, let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { 122 - let truncated = bodyString.count > 1000 ? String(bodyString.prefix(1000)) + "..." : bodyString 123 - metadata["body"] = truncated 124 - } 125 - 126 - debug("Request: \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")", metadata: metadata) 127 - } 128 - 129 - /// Logs an incoming response. 130 - public func logResponse(_ response: URLResponse, data: Data?, error: Error?, id: String = UUID().uuidString, duration: TimeInterval? = nil) { 131 - guard logLevel <= .debug else { return } 132 - 133 - var metadata: [String: String] = ["request_id": id] 134 - 135 - if let httpResponse = response as? HTTPURLResponse { 136 - metadata["status_code"] = String(httpResponse.statusCode) 137 - metadata["url"] = httpResponse.url?.absoluteString ?? "unknown" 138 - } 139 - 140 - if let duration = duration { 141 - metadata["duration_ms"] = String(format: "%.2f", duration * 1000) 142 - } 143 - 144 - if let data = data { 145 - metadata["response_size"] = String(data.count) 146 - 147 - if logBodies, let bodyString = String(data: data, encoding: .utf8) { 148 - let truncated = bodyString.count > 1000 ? String(bodyString.prefix(1000)) + "..." : bodyString 149 - metadata["body"] = truncated 150 - } 151 - } 152 - 153 - if let error = error { 154 - metadata["error"] = error.localizedDescription 155 - self.error("Response error: \(error.localizedDescription)", metadata: metadata) 156 - } else if let httpResponse = response as? HTTPURLResponse { 157 - let message = "Response: \(httpResponse.statusCode)" 158 - if httpResponse.statusCode >= 400 { 159 - warning(message, metadata: metadata) 160 - } else { 161 - debug(message, metadata: metadata) 162 - } 163 - } 164 - } 165 - 166 - /// Logs a token refresh attempt. 167 - public func logTokenRefresh(success: Bool, error: Error? = nil) { 168 - if success { 169 - info("Token refresh successful") 170 - } else if let error = error { 171 - self.error("Token refresh failed: \(error.localizedDescription)") 172 - } else { 173 - warning("Token refresh failed") 174 - } 175 - } 176 - 177 - /// Logs identity resolution. 178 - public func logIdentityResolution(handle: String? = nil, did: String? = nil, success: Bool, error: Error? = nil) { 179 - var metadata: [String: String] = [:] 180 - if let handle = handle { metadata["handle"] = handle } 181 - if let did = did { metadata["did"] = did } 182 - 183 - if success { 184 - debug("Identity resolved", metadata: metadata) 185 - } else if let error = error { 186 - self.error("Identity resolution failed: \(error.localizedDescription)", metadata: metadata) 187 - } 188 - } 189 - 190 - // MARK: - Private 191 - 192 - private func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) { 193 - guard level >= logLevel else { return } 194 - 195 - if let handler = handler { 196 - handler.log(level: level, message: message, metadata: metadata, file: file, function: function, line: line) 197 - } else { 198 - let fileName = (file as NSString).lastPathComponent 199 - let logMessage = "[\(fileName):\(line)] \(function) - \(message)" 200 - 201 - switch level { 202 - case .debug: 203 - os_log(.debug, log: osLog, "%{public}@", logMessage) 204 - case .info: 205 - os_log(.info, log: osLog, "%{public}@", logMessage) 206 - case .warning: 207 - os_log(.default, log: osLog, "โš ๏ธ %{public}@", logMessage) 208 - case .error: 209 - os_log(.error, log: osLog, "%{public}@", logMessage) 210 - case .none: 211 - break 212 - } 213 - } 214 - } 215 - 216 - private func shouldRedact(header: String) -> Bool { 217 - guard redactTokens else { return false } 218 - let sensitiveHeaders = ["authorization", "dpop", "cookie", "set-cookie"] 219 - return sensitiveHeaders.contains(header.lowercased()) 220 - } 221 - } 222 - 223 - /// Console log handler for development. 224 - public struct ConsoleLogHandler: ATLogHandler { 225 - public init() {} 226 - 227 - public func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) { 228 - let fileName = (file as NSString).lastPathComponent 229 - let prefix: String 230 - switch level { 231 - case .debug: prefix = "๐Ÿ” DEBUG" 232 - case .info: prefix = "โ„น๏ธ INFO" 233 - case .warning: prefix = "โš ๏ธ WARNING" 234 - case .error: prefix = "โŒ ERROR" 235 - case .none: return 236 - } 237 - 238 - var output = "\(prefix) [\(fileName):\(line)] \(message)" 239 - if let metadata = metadata, !metadata.isEmpty { 240 - let metaString = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") 241 - output += " {\(metaString)}" 242 - } 243 - print(output) 244 - } 245 - }
···
-227
Sources/CoreATProtocol/LoginService.swift
··· 1 - // 2 - // LoginService.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Thomas Rademaker on 10/17/25. 6 - // 7 - 8 - import Foundation 9 - import OAuthenticator 10 - 11 - /// Service for handling AT Protocol OAuth authentication. 12 - @APActor 13 - public final class LoginService { 14 - 15 - /// Errors that can occur during login. 16 - public enum Error: Swift.Error, Sendable { 17 - case missingStoredLogin 18 - case identityResolutionFailed(IdentityError) 19 - case serverMetadataFailed 20 - case clientMetadataFailed 21 - case authenticationFailed(Swift.Error) 22 - case subjectMismatch(expected: String, actual: String) 23 - } 24 - 25 - private let loginStorage: LoginStorage 26 - private let jwtGenerator: DPoPSigner.JWTGenerator 27 - private let identityResolver: IdentityResolver 28 - private var authenticator: Authenticator? 29 - 30 - /// Creates a new login service. 31 - /// - Parameters: 32 - /// - jwtGenerator: DPoP JWT generator for signing proofs 33 - /// - loginStorage: Storage for persisting login tokens 34 - public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) { 35 - self.jwtGenerator = jwtGenerator 36 - self.loginStorage = loginStorage 37 - self.identityResolver = IdentityResolver() 38 - } 39 - 40 - /// Performs OAuth login for an AT Protocol account. 41 - /// 42 - /// This method: 43 - /// 1. Resolves the account handle/DID to find the PDS 44 - /// 2. Discovers OAuth server metadata 45 - /// 3. Fetches client metadata 46 - /// 4. Performs PKCE + PAR + DPoP OAuth flow 47 - /// 5. Verifies the returned identity matches the expected account 48 - /// 6. Stores the tokens and updates the environment 49 - /// 50 - /// - Parameters: 51 - /// - account: Handle or DID of the account to authenticate 52 - /// - clientMetadataEndpoint: URL where the client metadata document is published 53 - /// - Returns: The Login object with access and refresh tokens 54 - public func login(account: String, clientMetadataEndpoint: String) async throws -> Login { 55 - let provider = URLSession.defaultProvider 56 - 57 - // Step 1: Resolve identity to find PDS and auth server 58 - let resolvedIdentity: IdentityResolver.ResolvedIdentity 59 - do { 60 - if account.hasPrefix("did:") { 61 - resolvedIdentity = try await identityResolver.resolveIdentity(did: account) 62 - } else { 63 - resolvedIdentity = try await identityResolver.resolveIdentity(handle: account) 64 - } 65 - } catch let error as IdentityError { 66 - ATLogger.shared.error("Identity resolution failed for \(account): \(error)") 67 - throw Error.identityResolutionFailed(error) 68 - } 69 - 70 - ATLogger.shared.info("Resolved identity: DID=\(resolvedIdentity.did), PDS=\(resolvedIdentity.pdsURL)") 71 - 72 - // Update environment with PDS 73 - APEnvironment.current.host = resolvedIdentity.pdsURL 74 - APEnvironment.current.resolvedIdentity = resolvedIdentity 75 - 76 - // Step 2: Extract server host for metadata fetch 77 - guard let serverURL = URL(string: resolvedIdentity.authorizationServerURL), 78 - let serverHost = serverURL.host else { 79 - throw Error.serverMetadataFailed 80 - } 81 - 82 - // Step 3: Fetch server metadata 83 - let serverConfig: ServerMetadata 84 - do { 85 - serverConfig = try await ServerMetadata.load(for: serverHost, provider: provider) 86 - APEnvironment.current.serverMetadata = serverConfig 87 - } catch { 88 - ATLogger.shared.error("Failed to load server metadata: \(error)") 89 - throw Error.serverMetadataFailed 90 - } 91 - 92 - // Step 4: Fetch client metadata 93 - let clientConfig: ClientMetadata 94 - do { 95 - clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider) 96 - APEnvironment.current.clientId = clientConfig.clientId 97 - } catch { 98 - ATLogger.shared.error("Failed to load client metadata: \(error)") 99 - throw Error.clientMetadataFailed 100 - } 101 - 102 - // Step 5: Configure and perform OAuth 103 - let tokenHandling = Bluesky.tokenHandling( 104 - account: account, 105 - server: serverConfig, 106 - jwtGenerator: jwtGenerator 107 - ) 108 - 109 - let config = Authenticator.Configuration( 110 - appCredentials: clientConfig.credentials, 111 - loginStorage: loginStorage, 112 - tokenHandling: tokenHandling, 113 - mode: .automatic 114 - ) 115 - 116 - authenticator = Authenticator(config: config) 117 - 118 - do { 119 - try await authenticator?.authenticate() 120 - } catch { 121 - ATLogger.shared.error("Authentication failed: \(error)") 122 - throw Error.authenticationFailed(error) 123 - } 124 - 125 - // Step 6: Retrieve and verify login 126 - guard let storedLogin = try await loginStorage.retrieveLogin() else { 127 - throw Error.missingStoredLogin 128 - } 129 - 130 - // Verify the subject matches expected DID 131 - if let issuer = storedLogin.issuingServer, issuer != resolvedIdentity.did { 132 - ATLogger.shared.warning("Subject mismatch: expected \(resolvedIdentity.did), got \(issuer)") 133 - // This is a security check - the token should be for the expected user 134 - throw Error.subjectMismatch(expected: resolvedIdentity.did, actual: issuer) 135 - } 136 - 137 - // Step 7: Update environment with complete authentication context 138 - applyAuthenticationContext( 139 - login: storedLogin, 140 - generator: jwtGenerator, 141 - serverMetadata: serverConfig, 142 - clientId: clientConfig.clientId 143 - ) 144 - 145 - // Store complete auth state if token storage is configured 146 - if let tokenStorage = APEnvironment.current.tokenStorage { 147 - let authState = AuthenticationState( 148 - did: resolvedIdentity.did, 149 - handle: resolvedIdentity.handle, 150 - pdsURL: resolvedIdentity.pdsURL, 151 - authServerURL: resolvedIdentity.authorizationServerURL, 152 - accessToken: storedLogin.accessToken.value, 153 - accessTokenExpiry: storedLogin.accessToken.expiry, 154 - refreshToken: storedLogin.refreshToken?.value, 155 - scope: storedLogin.scopes, 156 - dpopPrivateKeyData: nil // Key management is caller's responsibility 157 - ) 158 - try? await tokenStorage.store(authState) 159 - APEnvironment.current.authState = authState 160 - } 161 - 162 - ATLogger.shared.info("Login successful for \(resolvedIdentity.handle)") 163 - 164 - return storedLogin 165 - } 166 - 167 - /// Performs OAuth login using pre-resolved identity and server metadata. 168 - /// Use this when you've already resolved the identity and fetched metadata. 169 - /// 170 - /// - Parameters: 171 - /// - identity: Pre-resolved identity information 172 - /// - serverMetadata: Pre-fetched OAuth server metadata 173 - /// - clientMetadata: Pre-fetched client metadata 174 - /// - Returns: The Login object with access and refresh tokens 175 - public func login( 176 - identity: IdentityResolver.ResolvedIdentity, 177 - serverMetadata: ServerMetadata, 178 - clientMetadata: ClientMetadata 179 - ) async throws -> Login { 180 - // Update environment 181 - APEnvironment.current.host = identity.pdsURL 182 - APEnvironment.current.resolvedIdentity = identity 183 - APEnvironment.current.serverMetadata = serverMetadata 184 - APEnvironment.current.clientId = clientMetadata.clientId 185 - 186 - let tokenHandling = Bluesky.tokenHandling( 187 - account: identity.handle, 188 - server: serverMetadata, 189 - jwtGenerator: jwtGenerator 190 - ) 191 - 192 - let config = Authenticator.Configuration( 193 - appCredentials: clientMetadata.credentials, 194 - loginStorage: loginStorage, 195 - tokenHandling: tokenHandling, 196 - mode: .automatic 197 - ) 198 - 199 - authenticator = Authenticator(config: config) 200 - 201 - do { 202 - try await authenticator?.authenticate() 203 - } catch { 204 - throw Error.authenticationFailed(error) 205 - } 206 - 207 - guard let storedLogin = try await loginStorage.retrieveLogin() else { 208 - throw Error.missingStoredLogin 209 - } 210 - 211 - applyAuthenticationContext( 212 - login: storedLogin, 213 - generator: jwtGenerator, 214 - serverMetadata: serverMetadata, 215 - clientId: clientMetadata.clientId 216 - ) 217 - 218 - return storedLogin 219 - } 220 - 221 - /// Logs out by clearing all stored tokens and authentication state. 222 - public func logout() async { 223 - await clearAuthenticationContext() 224 - authenticator = nil 225 - ATLogger.shared.info("Logged out") 226 - } 227 - }
···
+5 -207
Sources/CoreATProtocol/Models/ATError.swift
··· 5 // Created by Thomas Rademaker on 10/8/25. 6 // 7 8 - import Foundation 9 - 10 - /// Top-level error type for AT Protocol operations. 11 - public enum AtError: Error, Sendable { 12 - /// An error message returned by the server. 13 case message(ErrorMessage) 14 - 15 - /// A network-level error. 16 case network(NetworkError) 17 - 18 - /// An OAuth/authentication error. 19 - case oauth(OAuthError) 20 - 21 - /// An identity resolution error. 22 - case identity(IdentityError) 23 - 24 - /// A decoding error. 25 - case decoding(DecodingError) 26 - 27 - /// An unknown error. 28 - case unknown(Error) 29 } 30 31 - extension AtError: LocalizedError { 32 - public var errorDescription: String? { 33 - switch self { 34 - case .message(let msg): 35 - return msg.message ?? msg.error 36 - case .network(let err): 37 - return err.localizedDescription 38 - case .oauth(let err): 39 - return err.localizedDescription 40 - case .identity(let err): 41 - return String(describing: err) 42 - case .decoding(let err): 43 - return err.localizedDescription 44 - case .unknown(let err): 45 - return err.localizedDescription 46 - } 47 - } 48 - 49 - /// Returns true if this error indicates the user needs to re-authenticate. 50 - public var requiresReauthentication: Bool { 51 - switch self { 52 - case .message(let msg): 53 - return msg.errorType == .authenticationRequired || 54 - msg.errorType == .expiredToken || 55 - msg.errorType == .authMissing 56 - case .network(let err): 57 - if case .statusCode(let code, _) = err, code?.rawValue == 401 { 58 - return true 59 - } 60 - return false 61 - case .oauth(let err): 62 - switch err { 63 - case .accessTokenExpired, .refreshTokenExpired, .refreshTokenMissing: 64 - return true 65 - default: 66 - return false 67 - } 68 - default: 69 - return false 70 - } 71 - } 72 - 73 - /// Returns true if this error might succeed if retried. 74 - public var isRetryable: Bool { 75 - switch self { 76 - case .message(let msg): 77 - return msg.errorType == .rateLimitExceeded 78 - case .network(let err): 79 - switch err { 80 - case .statusCode(let code, _): 81 - // 5xx errors and 429 are retryable 82 - guard let status = code?.rawValue else { return false } 83 - return status >= 500 || status == 429 84 - case .tokenRefresh: 85 - return true 86 - default: 87 - return false 88 - } 89 - default: 90 - return false 91 - } 92 - } 93 - } 94 - 95 - /// Error message returned by AT Protocol servers. 96 public struct ErrorMessage: Codable, Sendable { 97 - /// The error code/type string. 98 public let error: String 99 - 100 - /// Optional human-readable error message. 101 public let message: String? 102 - 103 public init(error: String, message: String?) { 104 self.error = error 105 self.message = message 106 } 107 - 108 - /// Attempts to parse the error string as a known error type. 109 - public var errorType: AtErrorType? { 110 - AtErrorType(rawValue: error) 111 - } 112 } 113 114 - /// Known AT Protocol error types. 115 - public enum AtErrorType: String, Codable, Sendable, CaseIterable { 116 - // Authentication errors 117 case authenticationRequired = "AuthenticationRequired" 118 case expiredToken = "ExpiredToken" 119 - case authMissing = "AuthMissing" 120 - case invalidToken = "InvalidToken" 121 - 122 - // Request errors 123 case invalidRequest = "InvalidRequest" 124 - case invalidSwap = "InvalidSwap" 125 case methodNotImplemented = "MethodNotImplemented" 126 - 127 - // Rate limiting 128 case rateLimitExceeded = "RateLimitExceeded" 129 - 130 - // Account errors 131 - case accountTakedown = "AccountTakedown" 132 - case accountSuspended = "AccountSuspended" 133 - case accountDeactivated = "AccountDeactivated" 134 - case accountNotFound = "AccountNotFound" 135 - 136 - // Record errors 137 - case recordNotFound = "RecordNotFound" 138 - case repoNotFound = "RepoNotFound" 139 - case blobNotFound = "BlobNotFound" 140 - case blockNotFound = "BlockNotFound" 141 - 142 - // Validation errors 143 - case invalidHandle = "InvalidHandle" 144 - case handleNotAvailable = "HandleNotAvailable" 145 - case unsupportedDomain = "UnsupportedDomain" 146 - case unresolvableDid = "UnresolvableDid" 147 - 148 - // Blob errors 149 - case blobTooLarge = "BlobTooLarge" 150 - case invalidBlob = "InvalidBlob" 151 - 152 - // Content errors 153 - case duplicateCreate = "DuplicateCreate" 154 - case unknownFeed = "UnknownFeed" 155 - case unknownList = "UnknownList" 156 - case notFound = "NotFound" 157 - 158 - // Server errors 159 - case upstreamFailure = "UpstreamFailure" 160 - case upstreamTimeout = "UpstreamTimeout" 161 - case internalServerError = "InternalServerError" 162 - 163 - /// Human-readable description of the error type. 164 - public var description: String { 165 - switch self { 166 - case .authenticationRequired: return "Authentication is required" 167 - case .expiredToken: return "The access token has expired" 168 - case .authMissing: return "Authentication credentials are missing" 169 - case .invalidToken: return "The provided token is invalid" 170 - case .invalidRequest: return "The request is invalid" 171 - case .invalidSwap: return "The swap operation is invalid" 172 - case .methodNotImplemented: return "This method is not implemented" 173 - case .rateLimitExceeded: return "Rate limit exceeded" 174 - case .accountTakedown: return "Account has been taken down" 175 - case .accountSuspended: return "Account has been suspended" 176 - case .accountDeactivated: return "Account has been deactivated" 177 - case .accountNotFound: return "Account not found" 178 - case .recordNotFound: return "Record not found" 179 - case .repoNotFound: return "Repository not found" 180 - case .blobNotFound: return "Blob not found" 181 - case .blockNotFound: return "Block not found" 182 - case .invalidHandle: return "The handle is invalid" 183 - case .handleNotAvailable: return "The handle is not available" 184 - case .unsupportedDomain: return "The domain is not supported" 185 - case .unresolvableDid: return "The DID cannot be resolved" 186 - case .blobTooLarge: return "The blob is too large" 187 - case .invalidBlob: return "The blob is invalid" 188 - case .duplicateCreate: return "A record with this key already exists" 189 - case .unknownFeed: return "The feed is not known" 190 - case .unknownList: return "The list is not known" 191 - case .notFound: return "The resource was not found" 192 - case .upstreamFailure: return "An upstream service failed" 193 - case .upstreamTimeout: return "An upstream service timed out" 194 - case .internalServerError: return "Internal server error" 195 - } 196 - } 197 - } 198 - 199 - /// Rate limit information from response headers. 200 - public struct RateLimitInfo: Sendable { 201 - /// Maximum number of requests allowed in the window. 202 - public let limit: Int 203 - 204 - /// Number of requests remaining in the current window. 205 - public let remaining: Int 206 - 207 - /// Unix timestamp when the rate limit resets. 208 - public let resetTimestamp: TimeInterval 209 - 210 - /// Date when the rate limit resets. 211 - public var resetDate: Date { 212 - Date(timeIntervalSince1970: resetTimestamp) 213 - } 214 - 215 - /// Time interval until the rate limit resets. 216 - public var timeUntilReset: TimeInterval { 217 - resetTimestamp - Date().timeIntervalSince1970 218 - } 219 - 220 - /// Parses rate limit information from HTTP response headers. 221 - public static func from(response: HTTPURLResponse) -> RateLimitInfo? { 222 - guard let limitStr = response.value(forHTTPHeaderField: "RateLimit-Limit"), 223 - let remainingStr = response.value(forHTTPHeaderField: "RateLimit-Remaining"), 224 - let resetStr = response.value(forHTTPHeaderField: "RateLimit-Reset"), 225 - let limit = Int(limitStr), 226 - let remaining = Int(remainingStr), 227 - let reset = TimeInterval(resetStr) else { 228 - return nil 229 - } 230 - 231 - return RateLimitInfo(limit: limit, remaining: remaining, resetTimestamp: reset) 232 - } 233 }
··· 5 // Created by Thomas Rademaker on 10/8/25. 6 // 7 8 + public enum AtError: Error { 9 case message(ErrorMessage) 10 case network(NetworkError) 11 } 12 13 public struct ErrorMessage: Codable, Sendable { 14 + #warning("Should error be type string or AtErrorType?") 15 public let error: String 16 public let message: String? 17 + 18 public init(error: String, message: String?) { 19 self.error = error 20 self.message = message 21 } 22 } 23 24 + public enum AtErrorType: String, Codable, Sendable { 25 case authenticationRequired = "AuthenticationRequired" 26 case expiredToken = "ExpiredToken" 27 case invalidRequest = "InvalidRequest" 28 case methodNotImplemented = "MethodNotImplemented" 29 case rateLimitExceeded = "RateLimitExceeded" 30 + case authMissing = "AuthMissing" 31 }
+3 -81
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
··· 1 - import Foundation 2 - 3 - /// Describes the type of HTTP task to perform. 4 public enum HTTPTask: Sendable { 5 - /// A simple request with no body. 6 case request 7 - 8 - /// A request with encoded parameters (URL query or JSON body). 9 case requestParameters(encoding: ParameterEncoding) 10 - 11 - /// A blob upload request with raw data and content type. 12 - case uploadBlob(data: Data, mimeType: String) 13 - 14 - /// A multipart form data upload. 15 - case uploadMultipart(parts: [MultipartFormData]) 16 - } 17 - 18 - /// Represents a single part in a multipart form data request. 19 - public struct MultipartFormData: Sendable { 20 - /// The field name for this part. 21 - public let name: String 22 - 23 - /// The filename for file uploads (nil for regular fields). 24 - public let filename: String? 25 - 26 - /// The content type of this part. 27 - public let mimeType: String? 28 - 29 - /// The data for this part. 30 - public let data: Data 31 - 32 - /// Creates a text field part. 33 - public static func field(name: String, value: String) -> MultipartFormData { 34 - MultipartFormData( 35 - name: name, 36 - filename: nil, 37 - mimeType: nil, 38 - data: Data(value.utf8) 39 - ) 40 - } 41 - 42 - /// Creates a file upload part. 43 - public static func file(name: String, filename: String, mimeType: String, data: Data) -> MultipartFormData { 44 - MultipartFormData( 45 - name: name, 46 - filename: filename, 47 - mimeType: mimeType, 48 - data: data 49 - ) 50 - } 51 - 52 - public init(name: String, filename: String?, mimeType: String?, data: Data) { 53 - self.name = name 54 - self.filename = filename 55 - self.mimeType = mimeType 56 - self.data = data 57 - } 58 - } 59 - 60 - /// Response from a blob upload operation. 61 - public struct BlobUploadResponse: Codable, Sendable { 62 - public let blob: BlobRef 63 - 64 - public struct BlobRef: Codable, Sendable { 65 - public let type: String 66 - public let ref: BlobLink 67 - public let mimeType: String 68 - public let size: Int 69 - 70 - enum CodingKeys: String, CodingKey { 71 - case type = "$type" 72 - case ref 73 - case mimeType 74 - case size 75 - } 76 - 77 - public struct BlobLink: Codable, Sendable { 78 - public let link: String 79 - 80 - enum CodingKeys: String, CodingKey { 81 - case link = "$link" 82 - } 83 - } 84 - } 85 }
··· 1 public enum HTTPTask: Sendable { 2 case request 3 + 4 case requestParameters(encoding: ParameterEncoding) 5 + 6 + // case download, upload...etc 7 }
+11 -47
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 1 import Foundation 2 3 - /// Protocol for intercepting and handling network requests. 4 - /// Implementations can be isolated to any actor since methods are async. 5 - public protocol NetworkRouterDelegate: AnyObject, Sendable { 6 func intercept(_ request: inout URLRequest) async 7 func shouldRetry(error: Error, attempts: Int) async throws -> Bool 8 } 9 10 /// Describes the implementation details of a NetworkRouter ··· 64 65 let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 66 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 67 switch httpResponse.statusCode { 68 case 200...299: 69 return try decoder.decode(T.self, from: data) ··· 88 } 89 90 func buildRequest(from route: Endpoint) async throws -> URLRequest { 91 - 92 var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path), 93 cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 94 - timeoutInterval: 30.0) 95 - 96 request.httpMethod = route.httpMethod.rawValue 97 do { 98 switch await route.task { 99 case .request: 100 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 101 await addAdditionalHeaders(route.headers, request: &request) 102 - 103 case .requestParameters(let parameterEncoding): 104 await addAdditionalHeaders(route.headers, request: &request) 105 try configureParameters(parameterEncoding: parameterEncoding, request: &request) 106 - 107 - case .uploadBlob(let data, let mimeType): 108 - request.setValue(mimeType, forHTTPHeaderField: "Content-Type") 109 - request.setValue(String(data.count), forHTTPHeaderField: "Content-Length") 110 - request.httpBody = data 111 - await addAdditionalHeaders(route.headers, request: &request) 112 - 113 - case .uploadMultipart(let parts): 114 - let boundary = "Boundary-\(UUID().uuidString)" 115 - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 116 - request.httpBody = buildMultipartBody(parts: parts, boundary: boundary) 117 - await addAdditionalHeaders(route.headers, request: &request) 118 } 119 return request 120 } catch { 121 throw error 122 } 123 - } 124 - 125 - /// Builds a multipart form data body from parts. 126 - private func buildMultipartBody(parts: [MultipartFormData], boundary: String) -> Data { 127 - var body = Data() 128 - let lineBreak = "\r\n" 129 - 130 - for part in parts { 131 - body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) 132 - 133 - if let filename = part.filename { 134 - body.append("Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8)!) 135 - } else { 136 - body.append("Content-Disposition: form-data; name=\"\(part.name)\"\(lineBreak)".data(using: .utf8)!) 137 - } 138 - 139 - if let mimeType = part.mimeType { 140 - body.append("Content-Type: \(mimeType)\(lineBreak)".data(using: .utf8)!) 141 - } 142 - 143 - body.append(lineBreak.data(using: .utf8)!) 144 - body.append(part.data) 145 - body.append(lineBreak.data(using: .utf8)!) 146 - } 147 - 148 - body.append("--\(boundary)--\(lineBreak)".data(using: .utf8)!) 149 - 150 - return body 151 } 152 153 private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws {
··· 1 import Foundation 2 3 + @APActor 4 + public protocol NetworkRouterDelegate: AnyObject { 5 func intercept(_ request: inout URLRequest) async 6 func shouldRetry(error: Error, attempts: Int) async throws -> Bool 7 + func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async 8 + } 9 + 10 + extension NetworkRouterDelegate { 11 + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {} 12 } 13 14 /// Describes the implementation details of a NetworkRouter ··· 68 69 let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 70 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 71 + await delegate?.didReceive(response: httpResponse, data: data, for: request) 72 switch httpResponse.statusCode { 73 case 200...299: 74 return try decoder.decode(T.self, from: data) ··· 93 } 94 95 func buildRequest(from route: Endpoint) async throws -> URLRequest { 96 + 97 var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path), 98 cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 99 + timeoutInterval: 10.0) 100 + 101 request.httpMethod = route.httpMethod.rawValue 102 do { 103 switch await route.task { 104 case .request: 105 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 106 await addAdditionalHeaders(route.headers, request: &request) 107 case .requestParameters(let parameterEncoding): 108 await addAdditionalHeaders(route.headers, request: &request) 109 try configureParameters(parameterEncoding: parameterEncoding, request: &request) 110 } 111 return request 112 } catch { 113 throw error 114 } 115 } 116 117 private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws {
+77 -185
Sources/CoreATProtocol/Networking.swift
··· 6 // 7 8 import Foundation 9 - import CryptoKit 10 - @preconcurrency import OAuthenticator 11 12 extension JSONDecoder { 13 - /// A JSON decoder configured for AT Protocol date formats. 14 - /// Supports ISO 8601 dates with fractional seconds and timezone. 15 public static var atDecoder: JSONDecoder { 16 let decoder = JSONDecoder() 17 decoder.keyDecodingStrategy = .convertFromSnakeCase 18 - decoder.dateDecodingStrategy = .custom { decoder in 19 - let container = try decoder.singleValueContainer() 20 - let dateString = try container.decode(String.self) 21 - 22 - // Try multiple date formats that AT Protocol APIs may return 23 - let formatters = Self.atDateFormatters 24 - 25 - for formatter in formatters { 26 - if let date = formatter.date(from: dateString) { 27 - return date 28 - } 29 - } 30 - 31 - // Try ISO8601 with fractional seconds 32 - let iso8601 = ISO8601DateFormatter() 33 - iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 34 - if let date = iso8601.date(from: dateString) { 35 - return date 36 - } 37 - 38 - // Try without fractional seconds 39 - iso8601.formatOptions = [.withInternetDateTime] 40 - if let date = iso8601.date(from: dateString) { 41 - return date 42 - } 43 - 44 - throw DecodingError.dataCorruptedError( 45 - in: container, 46 - debugDescription: "Cannot decode date string: \(dateString)" 47 - ) 48 - } 49 - 50 return decoder 51 } 52 - 53 - /// Date formatters for various AT Protocol date formats. 54 - private static var atDateFormatters: [DateFormatter] { 55 - let formats = [ 56 - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // With microseconds and timezone 57 - "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // With milliseconds and timezone 58 - "yyyy-MM-dd'T'HH:mm:ss.SSSX", // With milliseconds and short timezone 59 - "yyyy-MM-dd'T'HH:mm:ssXXXXX", // Without fractional seconds 60 - "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", // With Z timezone 61 - "yyyy-MM-dd'T'HH:mm:ss'Z'" // Without fractional, with Z 62 - ] 63 - 64 - return formats.map { format in 65 - let formatter = DateFormatter() 66 - formatter.dateFormat = format 67 - formatter.timeZone = TimeZone(secondsFromGMT: 0) 68 - formatter.locale = Locale(identifier: "en_US_POSIX") 69 - return formatter 70 - } 71 - } 72 } 73 74 - /// Checks if enough time has passed since last fetch to allow a new request. 75 - /// - Parameters: 76 - /// - lastFetched: Unix timestamp of last fetch (0 means never fetched) 77 - /// - timeLimit: Minimum seconds between fetches (default 1 hour) 78 - /// - Returns: true if a new request should be performed 79 func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { 80 guard lastFetched != 0 else { return true } 81 let currentTime = Date.now 82 let lastFetchTime = Date(timeIntervalSince1970: lastFetched) 83 - guard let differenceInSeconds = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false } 84 - return differenceInSeconds >= timeLimit 85 } 86 87 @APActor 88 - public class APRouterDelegate: NetworkRouterDelegate { 89 - /// Maximum retry attempts for token refresh. 90 - private let maxRefreshAttempts = 2 91 92 - public init() {} 93 94 - nonisolated public func intercept(_ request: inout URLRequest) async { 95 - // Try DPoP-authenticated request first (preferred for AT Protocol) 96 - if let generator = await APEnvironment.current.dpopProofGenerator, 97 - let login = await APEnvironment.current.login { 98 - let token = login.accessToken.value 99 - let tokenHash = await tokenHash(for: token) 100 - let signer = await APEnvironment.current.resourceDPoPSigner 101 - await MainActor.run { 102 - signer.nonce = nil 103 - } 104 - let nonce = await APEnvironment.current.resourceServerNonce 105 - await MainActor.run { 106 - signer.nonce = nonce 107 - } 108 109 do { 110 - try await signer.authenticateRequest( 111 - &request, 112 - isolation: MainActor.shared, 113 - using: generator, 114 - token: token, 115 - tokenHash: tokenHash, 116 - issuer: login.issuingServer 117 - ) 118 } catch { 119 - // If DPoP signing fails, fall back to providing the token directly. 120 - request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") 121 } 122 - 123 - return 124 } 125 126 - // Fall back to simple Bearer token authentication 127 - if let accessToken = await APEnvironment.current.accessToken { 128 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 129 } 130 } 131 132 - nonisolated public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 133 - // Don't retry more than maxRefreshAttempts times 134 - guard attempts <= maxRefreshAttempts else { return false } 135 - 136 - // Check if the error indicates we need to refresh the token 137 - let shouldAttemptRefresh = isTokenExpiredError(error) 138 - 139 - guard shouldAttemptRefresh else { return false } 140 - 141 - // Attempt token refresh 142 - let refreshed = await performTokenRefresh() 143 - 144 - return refreshed 145 - } 146 - 147 - /// Determines if an error indicates the token has expired and needs refresh. 148 - nonisolated private func isTokenExpiredError(_ error: Error) -> Bool { 149 - // Check for 401 Unauthorized status code 150 - if case .network(let networkError) = error as? AtError, 151 - case .statusCode(let statusCode, _) = networkError, 152 - statusCode?.rawValue == 401 { 153 - return true 154 } 155 156 - // Check for explicit expired token error message 157 if case .message(let message) = error as? AtError, 158 message.error == AtErrorType.expiredToken.rawValue { 159 - return true 160 - } 161 - 162 - // Check for authentication required error 163 - if case .message(let message) = error as? AtError, 164 - message.error == AtErrorType.authenticationRequired.rawValue { 165 - return true 166 } 167 168 return false 169 } 170 171 - /// Performs token refresh using the configured OAuth settings. 172 - nonisolated private func performTokenRefresh() async -> Bool { 173 - let env = await APEnvironment.current 174 175 - // Try using the authState-based refresh first 176 - if await env.authState != nil { 177 - return await env.performTokenRefresh() 178 } 179 180 - // Fall back to OAuthenticator's refresh if we have a login with refresh token 181 - guard let login = await env.login, 182 - let refreshToken = login.refreshToken, 183 - refreshToken.valid else { 184 - return false 185 } 186 187 - guard let serverMetadata = await env.serverMetadata, 188 - let clientId = await env.clientId else { 189 - return false 190 } 191 192 - // Use RefreshService for the actual refresh 193 - let refreshService = await RefreshService() 194 195 - // Create an AuthenticationState from the current login if we don't have one 196 - let state = AuthenticationState( 197 - did: login.issuingServer ?? "", 198 - handle: nil, 199 - pdsURL: await env.host ?? "", 200 - authServerURL: serverMetadata.issuer, 201 - accessToken: login.accessToken.value, 202 - accessTokenExpiry: login.accessToken.expiry, 203 - refreshToken: refreshToken.value, 204 - refreshTokenExpiry: refreshToken.expiry, 205 - scope: login.scopes, 206 - dpopPrivateKeyData: nil 207 - ) 208 209 - do { 210 - let newState = try await refreshService.refresh( 211 - state: state, 212 - serverMetadata: serverMetadata, 213 - clientId: clientId, 214 - dpopGenerator: await env.dpopProofGenerator 215 - ) 216 - 217 - // Update the environment 218 - await updateEnvironmentWithNewTokens(newState) 219 - 220 return true 221 - } catch { 222 - print("Token refresh failed: \(error)") 223 - return false 224 } 225 } 226 227 - /// Updates the environment with refreshed tokens. 228 - private func updateEnvironmentWithNewTokens(_ state: AuthenticationState) async { 229 - APEnvironment.current.accessToken = state.accessToken 230 - APEnvironment.current.refreshToken = state.refreshToken 231 - APEnvironment.current.authState = state 232 - 233 - // Update login object if present 234 - if var login = APEnvironment.current.login { 235 - login.accessToken = Token(value: state.accessToken, expiry: state.accessTokenExpiry) 236 - if let newRefresh = state.refreshToken { 237 - login.refreshToken = Token(value: newRefresh, expiry: state.refreshTokenExpiry) 238 - } 239 - APEnvironment.current.login = login 240 } 241 } 242 243 - /// Computes SHA-256 hash of the access token for DPoP `ath` claim. 244 - nonisolated private func tokenHash(for token: String) -> String { 245 - let digest = SHA256.hash(data: Data(token.utf8)) 246 - return Data(digest).base64URLEncodedString() 247 } 248 }
··· 6 // 7 8 import Foundation 9 10 extension JSONDecoder { 11 public static var atDecoder: JSONDecoder { 12 + let dateFormatter = DateFormatter() 13 + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX" 14 + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 15 + dateFormatter.locale = Locale(identifier: "en_US") 16 + 17 let decoder = JSONDecoder() 18 decoder.keyDecodingStrategy = .convertFromSnakeCase 19 + decoder.dateDecodingStrategy = .formatted(dateFormatter) 20 + 21 return decoder 22 } 23 } 24 25 func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { 26 guard lastFetched != 0 else { return true } 27 let currentTime = Date.now 28 let lastFetchTime = Date(timeIntervalSince1970: lastFetched) 29 + guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false } 30 + return differenceInMinutes >= timeLimit 31 } 32 33 @APActor 34 + public final class APRouterDelegate: NetworkRouterDelegate { 35 + public var oauthManager: OAuthManager? { 36 + didSet { pendingRetryAction = .none } 37 + } 38 39 + private enum RetryAction { 40 + case none 41 + case refreshToken 42 + case regenerateDPoP 43 + } 44 45 + private var pendingRetryAction: RetryAction = .none 46 47 + public func intercept(_ request: inout URLRequest) async { 48 + if let manager = oauthManager { 49 do { 50 + try await manager.authenticateResourceRequest(&request) 51 + return 52 } catch { 53 + // Fall back to legacy bearer injection if OAuth authentication fails. 54 } 55 } 56 57 + if let accessToken = APEnvironment.current.accessToken { 58 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 59 } 60 } 61 62 + public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 63 + if let manager = oauthManager { 64 + switch pendingRetryAction { 65 + case .regenerateDPoP where attempts < 3: 66 + pendingRetryAction = .none 67 + return true 68 + case .refreshToken: 69 + pendingRetryAction = .none 70 + do { 71 + _ = try await manager.refreshSession(force: true) 72 + return true 73 + } catch { 74 + return false 75 + } 76 + default: 77 + pendingRetryAction = .none 78 + } 79 } 80 81 if case .message(let message) = error as? AtError, 82 message.error == AtErrorType.expiredToken.rawValue { 83 + return false 84 } 85 86 return false 87 } 88 89 + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async { 90 + guard let manager = oauthManager else { return } 91 92 + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 93 + await manager.updateResourceServerNonce(nonce) 94 } 95 96 + guard (400..<500).contains(response.statusCode) else { 97 + pendingRetryAction = .none 98 + return 99 } 100 101 + if containsUseDPoPNonce(response: response, data: data) { 102 + pendingRetryAction = .regenerateDPoP 103 + return 104 } 105 106 + if containsInvalidToken(response: response, data: data) { 107 + pendingRetryAction = .refreshToken 108 + return 109 + } 110 111 + pendingRetryAction = .none 112 + } 113 114 + private func containsUseDPoPNonce(response: HTTPURLResponse, data: Data) -> Bool { 115 + if header(response, containsError: "use_dpop_nonce") { 116 + return true 117 + } 118 + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), 119 + errorResponse.error == "use_dpop_nonce" { 120 return true 121 } 122 + return false 123 } 124 125 + private func containsInvalidToken(response: HTTPURLResponse, data: Data) -> Bool { 126 + if header(response, containsError: "invalid_token") { 127 + return true 128 + } 129 + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), 130 + errorResponse.error == "invalid_token" { 131 + return true 132 } 133 + return false 134 } 135 136 + private func header(_ response: HTTPURLResponse, containsError token: String) -> Bool { 137 + guard let header = response.value(forHTTPHeaderField: "WWW-Authenticate") else { return false } 138 + return header.range(of: "error=\"\(token)\"", options: .caseInsensitive) != nil || header.range(of: "error=\(token)", options: .caseInsensitive) != nil 139 } 140 }
-263
Sources/CoreATProtocol/OAuth/ATClientMetadata.swift
··· 1 - // 2 - // ATClientMetadata.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - 10 - /// AT Protocol OAuth client metadata document. 11 - /// This document must be published at the `client_id` URL for OAuth registration. 12 - /// 13 - /// See: https://atproto.com/specs/oauth 14 - public struct ATClientMetadata: Codable, Sendable, Hashable { 15 - 16 - /// The client identifier. Must be a fully-qualified HTTPS URL pointing to this metadata. 17 - public let clientId: String 18 - 19 - /// Application type: "web" or "native". 20 - public let applicationType: ApplicationType 21 - 22 - /// Supported grant types. Must include "authorization_code" and "refresh_token". 23 - public let grantTypes: [String] 24 - 25 - /// Requested scopes. Must include "atproto". 26 - public let scope: String 27 - 28 - /// Supported response types. Must include "code". 29 - public let responseTypes: [String] 30 - 31 - /// Redirect URIs for OAuth callbacks. 32 - public let redirectUris: [String] 33 - 34 - /// Whether access tokens are DPoP-bound. Must be true for AT Protocol. 35 - public let dpopBoundAccessTokens: Bool 36 - 37 - /// Token endpoint authentication method. 38 - /// "none" for public clients, "private_key_jwt" for confidential clients. 39 - public let tokenEndpointAuthMethod: String 40 - 41 - /// Human-readable application name. 42 - public let clientName: String? 43 - 44 - /// URL to the application's logo. 45 - public let logoUri: String? 46 - 47 - /// URL to the application's homepage. 48 - public let clientUri: String? 49 - 50 - /// URL to the application's terms of service. 51 - public let tosUri: String? 52 - 53 - /// URL to the application's privacy policy. 54 - public let policyUri: String? 55 - 56 - /// JWK Set for confidential clients (inline). 57 - public let jwks: JWKSet? 58 - 59 - /// URL to JWK Set for confidential clients. 60 - public let jwksUri: String? 61 - 62 - enum CodingKeys: String, CodingKey { 63 - case clientId = "client_id" 64 - case applicationType = "application_type" 65 - case grantTypes = "grant_types" 66 - case scope 67 - case responseTypes = "response_types" 68 - case redirectUris = "redirect_uris" 69 - case dpopBoundAccessTokens = "dpop_bound_access_tokens" 70 - case tokenEndpointAuthMethod = "token_endpoint_auth_method" 71 - case clientName = "client_name" 72 - case logoUri = "logo_uri" 73 - case clientUri = "client_uri" 74 - case tosUri = "tos_uri" 75 - case policyUri = "policy_uri" 76 - case jwks 77 - case jwksUri = "jwks_uri" 78 - } 79 - 80 - /// Application type for OAuth clients. 81 - public enum ApplicationType: String, Codable, Sendable, Hashable { 82 - case web 83 - case native 84 - } 85 - 86 - /// Creates a new client metadata document for a public (native) client. 87 - /// - Parameters: 88 - /// - clientId: The client_id URL where this metadata will be published 89 - /// - redirectUri: The callback URI for OAuth redirects 90 - /// - clientName: Human-readable application name 91 - /// - scope: OAuth scopes (default includes "atproto" and "transition:generic") 92 - /// - logoUri: Optional logo URL 93 - /// - clientUri: Optional homepage URL 94 - /// - tosUri: Optional terms of service URL 95 - /// - policyUri: Optional privacy policy URL 96 - public init( 97 - clientId: String, 98 - redirectUri: String, 99 - clientName: String, 100 - scope: String = "atproto transition:generic", 101 - logoUri: String? = nil, 102 - clientUri: String? = nil, 103 - tosUri: String? = nil, 104 - policyUri: String? = nil 105 - ) { 106 - self.clientId = clientId 107 - self.applicationType = .native 108 - self.grantTypes = ["authorization_code", "refresh_token"] 109 - self.scope = scope 110 - self.responseTypes = ["code"] 111 - self.redirectUris = [redirectUri] 112 - self.dpopBoundAccessTokens = true 113 - self.tokenEndpointAuthMethod = "none" 114 - self.clientName = clientName 115 - self.logoUri = logoUri 116 - self.clientUri = clientUri 117 - self.tosUri = tosUri 118 - self.policyUri = policyUri 119 - self.jwks = nil 120 - self.jwksUri = nil 121 - } 122 - 123 - /// Creates a new client metadata document for a confidential (web) client. 124 - /// - Parameters: 125 - /// - clientId: The client_id URL where this metadata will be published 126 - /// - redirectUri: The callback URI for OAuth redirects 127 - /// - clientName: Human-readable application name 128 - /// - jwksUri: URL to the JWK Set containing the client's public keys 129 - /// - scope: OAuth scopes (default includes "atproto" and "transition:generic") 130 - /// - logoUri: Optional logo URL 131 - /// - clientUri: Optional homepage URL 132 - /// - tosUri: Optional terms of service URL 133 - /// - policyUri: Optional privacy policy URL 134 - public init( 135 - clientId: String, 136 - redirectUri: String, 137 - clientName: String, 138 - jwksUri: String, 139 - scope: String = "atproto transition:generic", 140 - logoUri: String? = nil, 141 - clientUri: String? = nil, 142 - tosUri: String? = nil, 143 - policyUri: String? = nil 144 - ) { 145 - self.clientId = clientId 146 - self.applicationType = .web 147 - self.grantTypes = ["authorization_code", "refresh_token"] 148 - self.scope = scope 149 - self.responseTypes = ["code"] 150 - self.redirectUris = [redirectUri] 151 - self.dpopBoundAccessTokens = true 152 - self.tokenEndpointAuthMethod = "private_key_jwt" 153 - self.clientName = clientName 154 - self.logoUri = logoUri 155 - self.clientUri = clientUri 156 - self.tosUri = tosUri 157 - self.policyUri = policyUri 158 - self.jwks = nil 159 - self.jwksUri = jwksUri 160 - } 161 - 162 - /// Encodes this metadata as JSON suitable for publishing. 163 - public func toJSON() throws -> Data { 164 - let encoder = JSONEncoder() 165 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 166 - return try encoder.encode(self) 167 - } 168 - 169 - /// Encodes this metadata as a JSON string suitable for publishing. 170 - public func toJSONString() throws -> String { 171 - let data = try toJSON() 172 - guard let string = String(data: data, encoding: .utf8) else { 173 - throw OAuthError.invalidConfiguration(reason: "Failed to encode metadata as UTF-8") 174 - } 175 - return string 176 - } 177 - 178 - /// Validates this metadata against AT Protocol OAuth requirements. 179 - public func validate() throws { 180 - // Validate client_id is HTTPS 181 - guard clientId.hasPrefix("https://") || clientId.hasPrefix("http://localhost") else { 182 - throw OAuthError.invalidConfiguration(reason: "client_id must be HTTPS URL (except localhost)") 183 - } 184 - 185 - // Validate required grant types 186 - guard grantTypes.contains("authorization_code") else { 187 - throw OAuthError.invalidConfiguration(reason: "grant_types must include 'authorization_code'") 188 - } 189 - guard grantTypes.contains("refresh_token") else { 190 - throw OAuthError.invalidConfiguration(reason: "grant_types must include 'refresh_token'") 191 - } 192 - 193 - // Validate scope includes atproto 194 - guard scope.contains("atproto") else { 195 - throw OAuthError.invalidConfiguration(reason: "scope must include 'atproto'") 196 - } 197 - 198 - // Validate response types 199 - guard responseTypes.contains("code") else { 200 - throw OAuthError.invalidConfiguration(reason: "response_types must include 'code'") 201 - } 202 - 203 - // Validate redirect URIs 204 - guard !redirectUris.isEmpty else { 205 - throw OAuthError.invalidConfiguration(reason: "At least one redirect_uri is required") 206 - } 207 - 208 - // Validate DPoP requirement 209 - guard dpopBoundAccessTokens else { 210 - throw OAuthError.invalidConfiguration(reason: "dpop_bound_access_tokens must be true") 211 - } 212 - 213 - // Validate confidential client has keys 214 - if tokenEndpointAuthMethod == "private_key_jwt" { 215 - guard jwks != nil || jwksUri != nil else { 216 - throw OAuthError.invalidConfiguration(reason: "Confidential clients must provide jwks or jwks_uri") 217 - } 218 - } 219 - } 220 - } 221 - 222 - /// JWK Set structure for confidential clients. 223 - public struct JWKSet: Codable, Sendable, Hashable { 224 - public let keys: [JWK] 225 - 226 - public init(keys: [JWK]) { 227 - self.keys = keys 228 - } 229 - } 230 - 231 - /// JSON Web Key structure. 232 - public struct JWK: Codable, Sendable, Hashable { 233 - public let kty: String 234 - public let crv: String? 235 - public let x: String? 236 - public let y: String? 237 - public let kid: String? 238 - public let use: String? 239 - public let alg: String? 240 - 241 - public init( 242 - kty: String, 243 - crv: String? = nil, 244 - x: String? = nil, 245 - y: String? = nil, 246 - kid: String? = nil, 247 - use: String? = nil, 248 - alg: String? = nil 249 - ) { 250 - self.kty = kty 251 - self.crv = crv 252 - self.x = x 253 - self.y = y 254 - self.kid = kid 255 - self.use = use 256 - self.alg = alg 257 - } 258 - 259 - /// Creates an ES256 public key JWK from coordinates. 260 - public static func es256PublicKey(x: String, y: String, kid: String? = nil) -> JWK { 261 - JWK(kty: "EC", crv: "P-256", x: x, y: y, kid: kid, use: "sig", alg: "ES256") 262 - } 263 - }
···
+58
Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift
···
··· 1 + import Foundation 2 + 3 + enum DNSResolverError: Error, Sendable { 4 + case invalidResponse 5 + } 6 + 7 + protocol DNSResolving: Sendable { 8 + func txtRecords(for host: String) async throws -> [String] 9 + } 10 + 11 + @APActor 12 + final class DoHDNSResolver: DNSResolving { 13 + private let baseURL: URL 14 + private let httpClient: OAuthHTTPClient 15 + 16 + init(baseURL: URL = URL(string: "https://cloudflare-dns.com/dns-query")!, httpClient: OAuthHTTPClient = OAuthHTTPClient()) { 17 + self.baseURL = baseURL 18 + self.httpClient = httpClient 19 + } 20 + 21 + func txtRecords(for host: String) async throws -> [String] { 22 + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { 23 + throw DNSResolverError.invalidResponse 24 + } 25 + var queryItems = components.queryItems ?? [] 26 + queryItems.append(URLQueryItem(name: "name", value: host)) 27 + queryItems.append(URLQueryItem(name: "type", value: "TXT")) 28 + components.queryItems = queryItems 29 + guard let url = components.url else { throw DNSResolverError.invalidResponse } 30 + var request = URLRequest(url: url) 31 + request.setValue("application/dns-json", forHTTPHeaderField: "Accept") 32 + let (data, _) = try await httpClient.send(request) 33 + let response = try httpClient.decodeJSON(DNSResponse.self, from: data) 34 + return response.answers?.compactMap { $0.txtValue } ?? [] 35 + } 36 + 37 + private struct DNSResponse: Decodable { 38 + struct Answer: Decodable { 39 + let data: String 40 + 41 + var txtValue: String? { 42 + guard data.count >= 2 else { return nil } 43 + var trimmed = data 44 + if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") { 45 + trimmed.removeFirst() 46 + trimmed.removeLast() 47 + } 48 + return trimmed 49 + } 50 + } 51 + 52 + private enum CodingKeys: String, CodingKey { 53 + case answers = "Answer" 54 + } 55 + 56 + let answers: [Answer]? 57 + } 58 + }
+134
Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift
···
··· 1 + import Foundation 2 + 3 + enum IdentityResolverError: Error, Sendable { 4 + case unableToResolveHandle 5 + case invalidDID 6 + case unsupportedDIDMethod 7 + case missingPDSService 8 + } 9 + 10 + @APActor 11 + final class IdentityResolver: Sendable { 12 + private let httpClient: OAuthHTTPClient 13 + private let dnsResolver: DNSResolving 14 + 15 + init(httpClient: OAuthHTTPClient = OAuthHTTPClient(), dnsResolver: DNSResolving = DoHDNSResolver()) { 16 + self.httpClient = httpClient 17 + self.dnsResolver = dnsResolver 18 + } 19 + 20 + func resolveHandle(_ handle: String) async throws -> String { 21 + if handle.lowercased().hasPrefix("did:") { 22 + return handle 23 + } 24 + 25 + if let did = try? await resolveViaHTTPS(handle: handle) { 26 + return did 27 + } 28 + 29 + if let did = try? await resolveViaDNS(handle: handle) { 30 + return did 31 + } 32 + 33 + throw IdentityResolverError.unableToResolveHandle 34 + } 35 + 36 + func fetchDIDDocument(for did: String) async throws -> DIDDocument { 37 + if did.hasPrefix("did:plc:") { 38 + guard let encodedDID = did.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), 39 + let url = URL(string: "https://plc.directory/\(encodedDID)") else { 40 + throw IdentityResolverError.invalidDID 41 + } 42 + return try await fetchJSON(url: url, type: DIDDocument.self) 43 + } else if did.hasPrefix("did:web:") { 44 + let components = try webDIDComponents(did: did) 45 + return try await fetchJSON(url: components.url, type: DIDDocument.self) 46 + } else { 47 + throw IdentityResolverError.unsupportedDIDMethod 48 + } 49 + } 50 + 51 + func discoverProtectedResource(for pdsURL: URL) async throws -> OAuthProtectedResourceMetadata { 52 + let endpoint = pdsURL.appendingPathComponent(".well-known/oauth-protected-resource") 53 + return try await fetchJSON(url: endpoint, type: OAuthProtectedResourceMetadata.self) 54 + } 55 + 56 + func fetchAuthorizationServerMetadata(from url: URL) async throws -> OAuthAuthorizationServerMetadata { 57 + let endpoint = url.appendingPathComponent(".well-known/oauth-authorization-server") 58 + return try await fetchJSON(url: endpoint, type: OAuthAuthorizationServerMetadata.self) 59 + } 60 + 61 + func extractPDSEndpoint(from document: DIDDocument) throws -> URL { 62 + guard let service = document.service(ofType: "AtprotoPersonalDataServer"), let url = URL(string: service.serviceEndpoint) else { 63 + throw IdentityResolverError.missingPDSService 64 + } 65 + return url 66 + } 67 + 68 + // MARK: - Private 69 + 70 + private func resolveViaHTTPS(handle: String) async throws -> String? { 71 + var components = URLComponents() 72 + components.scheme = "https" 73 + components.host = handle 74 + components.path = "/.well-known/atproto-did" 75 + guard let url = components.url else { return nil } 76 + var request = URLRequest(url: url) 77 + request.timeoutInterval = 5 78 + let (data, response) = try await httpClient.send(request) 79 + guard (200..<300).contains(response.statusCode) else { return nil } 80 + let did = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) 81 + guard did.isEmpty == false, did.lowercased().hasPrefix("did:") else { return nil } 82 + return did 83 + } 84 + 85 + private func resolveViaDNS(handle: String) async throws -> String? { 86 + let hostname = "_atproto.\(handle)" 87 + let records = try await dnsResolver.txtRecords(for: hostname) 88 + for record in records { 89 + let parts = record.split(separator: "=", maxSplits: 1).map(String.init) 90 + if parts.count == 2, parts[0] == "did" { 91 + return parts[1] 92 + } 93 + } 94 + return nil 95 + } 96 + 97 + private func fetchJSON<T: Decodable>(url: URL, type: T.Type) async throws -> T { 98 + var request = URLRequest(url: url) 99 + request.setValue("application/json", forHTTPHeaderField: "Accept") 100 + let (data, response) = try await httpClient.send(request) 101 + guard (200..<300).contains(response.statusCode) else { 102 + throw IdentityResolverError.invalidDID 103 + } 104 + return try httpClient.decodeJSON(T.self, from: data) 105 + } 106 + 107 + private func webDIDComponents(did: String) throws -> (host: String, pathSegments: [String], url: URL) { 108 + let prefix = "did:web:" 109 + guard did.hasPrefix(prefix) else { throw IdentityResolverError.invalidDID } 110 + let suffix = String(did.dropFirst(prefix.count)) 111 + let segments = suffix.split(separator: ":").map { segment in 112 + segment.removingPercentEncoding ?? String(segment) 113 + } 114 + guard let host = segments.first else { 115 + throw IdentityResolverError.invalidDID 116 + } 117 + let pathSegments = Array(segments.dropFirst()) 118 + var components = URLComponents() 119 + components.scheme = "https" 120 + components.host = host 121 + let path: String 122 + if pathSegments.isEmpty { 123 + path = "/.well-known/did.json" 124 + } else { 125 + let joined = pathSegments.joined(separator: "/") 126 + path = "/\(joined)/did.json" 127 + } 128 + components.path = path 129 + guard let url = components.url else { 130 + throw IdentityResolverError.invalidDID 131 + } 132 + return (host, pathSegments, url) 133 + } 134 + }
+47
Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift
···
··· 1 + import Foundation 2 + 3 + struct DIDDocument: Decodable, Sendable { 4 + struct Service: Decodable, Sendable { 5 + let id: String 6 + let type: String 7 + let serviceEndpoint: String 8 + 9 + private enum CodingKeys: String, CodingKey { 10 + case id 11 + case type 12 + case serviceEndpoint 13 + } 14 + 15 + init(from decoder: Decoder) throws { 16 + let container = try decoder.container(keyedBy: CodingKeys.self) 17 + self.id = try container.decode(String.self, forKey: .id) 18 + self.type = try container.decode(String.self, forKey: .type) 19 + if let endpoint = try? container.decode(String.self, forKey: .serviceEndpoint) { 20 + self.serviceEndpoint = endpoint 21 + } else if let endpointObject = try? container.decode(ServiceEndpoint.self, forKey: .serviceEndpoint) { 22 + guard let uri = endpointObject.uri else { 23 + throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Missing uri field in service endpoint object") 24 + } 25 + self.serviceEndpoint = uri 26 + } else { 27 + throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Unsupported service endpoint type") 28 + } 29 + } 30 + 31 + private struct ServiceEndpoint: Decodable { 32 + let uri: String? 33 + } 34 + } 35 + 36 + let id: String 37 + let services: [Service] 38 + 39 + private enum CodingKeys: String, CodingKey { 40 + case id 41 + case services = "service" 42 + } 43 + 44 + func service(ofType type: String) -> Service? { 45 + services.first { $0.type.localizedCaseInsensitiveCompare(type) == .orderedSame } 46 + } 47 + }
+30
Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift
···
··· 1 + import Foundation 2 + 3 + public struct OAuthConfiguration: Sendable { 4 + public let clientMetadataURL: URL 5 + public let redirectURI: URL 6 + public let requestedScopes: [String] 7 + public let additionalAuthorizationParameters: [String: String] 8 + 9 + public init( 10 + clientMetadataURL: URL, 11 + redirectURI: URL, 12 + requestedScopes: [String] = ["atproto"], 13 + additionalAuthorizationParameters: [String: String] = [:] 14 + ) { 15 + self.clientMetadataURL = clientMetadataURL 16 + self.redirectURI = redirectURI 17 + var scopes = requestedScopes 18 + if scopes.isEmpty { 19 + scopes = ["atproto"] 20 + } else if scopes.contains("atproto") == false { 21 + scopes.append("atproto") 22 + } 23 + var uniqueScopes: [String] = [] 24 + for scope in scopes where uniqueScopes.contains(scope) == false { 25 + uniqueScopes.append(scope) 26 + } 27 + self.requestedScopes = uniqueScopes 28 + self.additionalAuthorizationParameters = additionalAuthorizationParameters 29 + } 30 + }
+160
Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift
···
··· 1 + import Foundation 2 + 3 + struct OAuthProtectedResourceMetadata: Decodable, Sendable { 4 + let authorizationServers: [URL] 5 + 6 + private enum CodingKeys: String, CodingKey { 7 + case authorizationServers 8 + } 9 + 10 + init(from decoder: Decoder) throws { 11 + let container = try decoder.container(keyedBy: CodingKeys.self) 12 + let values = try container.decodeIfPresent([String].self, forKey: .authorizationServers) ?? [] 13 + self.authorizationServers = try values.map { value in 14 + guard let url = URL(string: value) else { 15 + throw DecodingError.dataCorruptedError(forKey: .authorizationServers, in: container, debugDescription: "Invalid authorization server URL") 16 + } 17 + return url 18 + } 19 + } 20 + } 21 + 22 + struct OAuthAuthorizationServerMetadata: Decodable, Sendable { 23 + let issuer: URL 24 + let authorizationEndpoint: URL 25 + let tokenEndpoint: URL 26 + let pushedAuthorizationRequestEndpoint: URL 27 + let codeChallengeMethodsSupported: [String] 28 + let dPoPSigningAlgValuesSupported: [String] 29 + let scopesSupported: [String] 30 + 31 + private enum CodingKeys: String, CodingKey { 32 + case issuer 33 + case authorizationEndpoint 34 + case tokenEndpoint 35 + case pushedAuthorizationRequestEndpoint 36 + case codeChallengeMethodsSupported 37 + case dPoPSigningAlgValuesSupported = "dpopSigningAlgValuesSupported" 38 + case scopesSupported 39 + } 40 + 41 + init(from decoder: Decoder) throws { 42 + let container = try decoder.container(keyedBy: CodingKeys.self) 43 + guard let issuer = URL(string: try container.decode(String.self, forKey: .issuer)) else { 44 + throw DecodingError.dataCorruptedError(forKey: .issuer, in: container, debugDescription: "Invalid issuer URL") 45 + } 46 + guard let authorizationEndpoint = URL(string: try container.decode(String.self, forKey: .authorizationEndpoint)) else { 47 + throw DecodingError.dataCorruptedError(forKey: .authorizationEndpoint, in: container, debugDescription: "Invalid authorization endpoint") 48 + } 49 + guard let tokenEndpoint = URL(string: try container.decode(String.self, forKey: .tokenEndpoint)) else { 50 + throw DecodingError.dataCorruptedError(forKey: .tokenEndpoint, in: container, debugDescription: "Invalid token endpoint") 51 + } 52 + guard let parEndpoint = URL(string: try container.decode(String.self, forKey: .pushedAuthorizationRequestEndpoint)) else { 53 + throw DecodingError.dataCorruptedError(forKey: .pushedAuthorizationRequestEndpoint, in: container, debugDescription: "Invalid PAR endpoint") 54 + } 55 + 56 + self.issuer = issuer 57 + self.authorizationEndpoint = authorizationEndpoint 58 + self.tokenEndpoint = tokenEndpoint 59 + self.pushedAuthorizationRequestEndpoint = parEndpoint 60 + self.codeChallengeMethodsSupported = try container.decodeIfPresent([String].self, forKey: .codeChallengeMethodsSupported) ?? [] 61 + self.dPoPSigningAlgValuesSupported = try container.decodeIfPresent([String].self, forKey: .dPoPSigningAlgValuesSupported) ?? [] 62 + self.scopesSupported = try container.decodeIfPresent([String].self, forKey: .scopesSupported) ?? [] 63 + } 64 + } 65 + 66 + struct OAuthClientMetadata: Decodable, Sendable { 67 + let clientID: URL 68 + let scope: String 69 + let redirectURIs: [URL] 70 + let grantTypes: [String] 71 + let responseTypes: [String] 72 + let tokenEndpointAuthMethod: String 73 + let tokenEndpointAuthSigningAlg: String? 74 + let dPoPBoundAccessTokens: Bool 75 + 76 + private enum CodingKeys: String, CodingKey { 77 + case clientID = "clientId" 78 + case scope 79 + case redirectURIs = "redirectUris" 80 + case grantTypes 81 + case responseTypes 82 + case tokenEndpointAuthMethod 83 + case tokenEndpointAuthSigningAlg 84 + case dPoPBoundAccessTokens = "dpopBoundAccessTokens" 85 + } 86 + 87 + init(from decoder: Decoder) throws { 88 + let container = try decoder.container(keyedBy: CodingKeys.self) 89 + guard let clientID = URL(string: try container.decode(String.self, forKey: .clientID)) else { 90 + throw DecodingError.dataCorruptedError(forKey: .clientID, in: container, debugDescription: "Invalid client metadata URL") 91 + } 92 + self.clientID = clientID 93 + self.scope = try container.decode(String.self, forKey: .scope) 94 + let redirectStrings = try container.decode([String].self, forKey: .redirectURIs) 95 + self.redirectURIs = try redirectStrings.map { value in 96 + guard let url = URL(string: value) else { 97 + throw DecodingError.dataCorruptedError(forKey: .redirectURIs, in: container, debugDescription: "Invalid redirect URI") 98 + } 99 + return url 100 + } 101 + self.grantTypes = try container.decode([String].self, forKey: .grantTypes) 102 + self.responseTypes = try container.decode([String].self, forKey: .responseTypes) 103 + self.tokenEndpointAuthMethod = try container.decode(String.self, forKey: .tokenEndpointAuthMethod) 104 + self.tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg) 105 + self.dPoPBoundAccessTokens = try container.decode(Bool.self, forKey: .dPoPBoundAccessTokens) 106 + } 107 + } 108 + 109 + struct OAuthTokenResponse: Decodable, Sendable { 110 + let accessToken: String 111 + let refreshToken: String? 112 + let tokenType: String 113 + let expiresIn: TimeInterval? 114 + let scope: String? 115 + let issuedTokenType: String? 116 + let subject: String? 117 + 118 + private enum CodingKeys: String, CodingKey { 119 + case accessToken = "access_token" 120 + case refreshToken = "refresh_token" 121 + case tokenType = "token_type" 122 + case expiresIn = "expires_in" 123 + case scope 124 + case issuedTokenType = "issued_token_type" 125 + case subject = "sub" 126 + } 127 + } 128 + 129 + struct PushedAuthorizationRequestResponse: Decodable, Sendable { 130 + let requestURI: String 131 + let expiresIn: Int 132 + 133 + private enum CodingKeys: String, CodingKey { 134 + case requestURI = "request_uri" 135 + case expiresIn = "expires_in" 136 + } 137 + } 138 + 139 + struct OAuthErrorResponse: Decodable, Error, Sendable { 140 + let error: String 141 + let errorDescription: String? 142 + let errorURI: URL? 143 + 144 + private enum CodingKeys: String, CodingKey { 145 + case error 146 + case errorDescription = "error_description" 147 + case errorURI = "error_uri" 148 + } 149 + 150 + init(from decoder: Decoder) throws { 151 + let container = try decoder.container(keyedBy: CodingKeys.self) 152 + self.error = try container.decode(String.self, forKey: .error) 153 + self.errorDescription = try container.decodeIfPresent(String.self, forKey: .errorDescription) 154 + if let raw = try container.decodeIfPresent(String.self, forKey: .errorURI) { 155 + self.errorURI = URL(string: raw) 156 + } else { 157 + self.errorURI = nil 158 + } 159 + } 160 + }
+53
Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift
···
··· 1 + import Foundation 2 + 3 + public struct OAuthSession: Codable, Sendable { 4 + public let did: String 5 + public let pdsURL: URL 6 + public let authorizationServer: URL 7 + public let tokenEndpoint: URL 8 + public let accessToken: String 9 + public let refreshToken: String 10 + public let tokenType: String 11 + public let scope: String? 12 + public let expiresIn: TimeInterval? 13 + public let issuedAt: Date 14 + 15 + public init( 16 + did: String, 17 + pdsURL: URL, 18 + authorizationServer: URL, 19 + tokenEndpoint: URL, 20 + accessToken: String, 21 + refreshToken: String, 22 + tokenType: String, 23 + scope: String?, 24 + expiresIn: TimeInterval?, 25 + issuedAt: Date 26 + ) { 27 + self.did = did 28 + self.pdsURL = pdsURL 29 + self.authorizationServer = authorizationServer 30 + self.tokenEndpoint = tokenEndpoint 31 + self.accessToken = accessToken 32 + self.refreshToken = refreshToken 33 + self.tokenType = tokenType 34 + self.scope = scope 35 + self.expiresIn = expiresIn 36 + self.issuedAt = issuedAt 37 + } 38 + 39 + public func isExpired(relativeTo date: Date = Date(), tolerance: TimeInterval = 0) -> Bool { 40 + guard let expiresAt else { return false } 41 + return expiresAt <= date.addingTimeInterval(tolerance * -1) 42 + } 43 + 44 + public func needsRefresh(relativeTo date: Date = Date(), threshold: TimeInterval = 300) -> Bool { 45 + guard let expiresAt else { return false } 46 + return expiresAt <= date.addingTimeInterval(threshold) 47 + } 48 + 49 + public var expiresAt: Date? { 50 + guard let expiresIn else { return nil } 51 + return issuedAt.addingTimeInterval(expiresIn) 52 + } 53 + }
+34
Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift
···
··· 1 + import Foundation 2 + 3 + enum OAuthNetworkingError: Error, Sendable { 4 + case invalidResponse 5 + } 6 + 7 + @APActor 8 + final class OAuthHTTPClient: Sendable { 9 + private let networking: Networking 10 + private let jsonDecoder: JSONDecoder 11 + 12 + init(networking: Networking = URLSession.shared, decoder: JSONDecoder? = nil) { 13 + self.networking = networking 14 + if let decoder { 15 + self.jsonDecoder = decoder 16 + } else { 17 + let decoder = JSONDecoder() 18 + decoder.keyDecodingStrategy = .convertFromSnakeCase 19 + self.jsonDecoder = decoder 20 + } 21 + } 22 + 23 + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { 24 + let (data, response) = try await networking.data(for: request, delegate: nil) 25 + guard let httpResponse = response as? HTTPURLResponse else { 26 + throw OAuthNetworkingError.invalidResponse 27 + } 28 + return (data, httpResponse) 29 + } 30 + 31 + func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { 32 + try jsonDecoder.decode(T.self, from: data) 33 + } 34 + }
-139
Sources/CoreATProtocol/OAuth/OAuthError.swift
··· 1 - // 2 - // OAuthError.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - 10 - /// Errors specific to OAuth operations in AT Protocol. 11 - public enum OAuthError: Error, Sendable, Hashable { 12 - // MARK: - Token Errors 13 - case accessTokenExpired 14 - case refreshTokenExpired 15 - case refreshTokenMissing 16 - case refreshFailed(reason: String) 17 - case tokenExchangeFailed(reason: String) 18 - 19 - // MARK: - Configuration Errors 20 - case missingServerMetadata 21 - case missingClientMetadata 22 - case missingCredentials 23 - case invalidConfiguration(reason: String) 24 - 25 - // MARK: - Authorization Errors 26 - case authorizationDenied 27 - case invalidState 28 - case invalidScope 29 - case parRequestFailed(reason: String) 30 - 31 - // MARK: - DPoP Errors 32 - case dpopRequired 33 - case dpopNonceMissing 34 - case dpopSigningFailed(reason: String) 35 - case dpopKeyMissing 36 - 37 - // MARK: - Identity Errors 38 - case subjectMismatch(expected: String, received: String) 39 - case issuerMismatch(expected: String, received: String) 40 - 41 - // MARK: - Storage Errors 42 - case storageFailed(reason: String) 43 - case loginNotFound 44 - } 45 - 46 - extension OAuthError: LocalizedError { 47 - public var errorDescription: String? { 48 - switch self { 49 - case .accessTokenExpired: 50 - return "Access token has expired" 51 - case .refreshTokenExpired: 52 - return "Refresh token has expired" 53 - case .refreshTokenMissing: 54 - return "No refresh token available" 55 - case .refreshFailed(let reason): 56 - return "Token refresh failed: \(reason)" 57 - case .tokenExchangeFailed(let reason): 58 - return "Token exchange failed: \(reason)" 59 - case .missingServerMetadata: 60 - return "Server metadata is not available" 61 - case .missingClientMetadata: 62 - return "Client metadata is not available" 63 - case .missingCredentials: 64 - return "App credentials are not configured" 65 - case .invalidConfiguration(let reason): 66 - return "Invalid OAuth configuration: \(reason)" 67 - case .authorizationDenied: 68 - return "Authorization was denied by the user" 69 - case .invalidState: 70 - return "State token mismatch - possible CSRF attack" 71 - case .invalidScope: 72 - return "Requested scope was not granted" 73 - case .parRequestFailed(let reason): 74 - return "Pushed Authorization Request failed: \(reason)" 75 - case .dpopRequired: 76 - return "DPoP is required but not configured" 77 - case .dpopNonceMissing: 78 - return "DPoP nonce was not provided by server" 79 - case .dpopSigningFailed(let reason): 80 - return "DPoP JWT signing failed: \(reason)" 81 - case .dpopKeyMissing: 82 - return "DPoP private key is not available" 83 - case .subjectMismatch(let expected, let received): 84 - return "Subject mismatch: expected \(expected), received \(received)" 85 - case .issuerMismatch(let expected, let received): 86 - return "Issuer mismatch: expected \(expected), received \(received)" 87 - case .storageFailed(let reason): 88 - return "Token storage failed: \(reason)" 89 - case .loginNotFound: 90 - return "No stored login found" 91 - } 92 - } 93 - } 94 - 95 - /// Response from a token refresh request. 96 - public struct TokenRefreshResponse: Codable, Sendable { 97 - public let accessToken: String 98 - public let refreshToken: String? 99 - public let tokenType: String 100 - public let expiresIn: Int 101 - public let scope: String? 102 - public let sub: String 103 - 104 - enum CodingKeys: String, CodingKey { 105 - case accessToken = "access_token" 106 - case refreshToken = "refresh_token" 107 - case tokenType = "token_type" 108 - case expiresIn = "expires_in" 109 - case scope 110 - case sub 111 - } 112 - 113 - public init( 114 - accessToken: String, 115 - refreshToken: String?, 116 - tokenType: String, 117 - expiresIn: Int, 118 - scope: String?, 119 - sub: String 120 - ) { 121 - self.accessToken = accessToken 122 - self.refreshToken = refreshToken 123 - self.tokenType = tokenType 124 - self.expiresIn = expiresIn 125 - self.scope = scope 126 - self.sub = sub 127 - } 128 - } 129 - 130 - /// Error response from OAuth endpoints. 131 - public struct OAuthErrorResponse: Codable, Sendable { 132 - public let error: String 133 - public let errorDescription: String? 134 - 135 - enum CodingKeys: String, CodingKey { 136 - case error 137 - case errorDescription = "error_description" 138 - } 139 - }
···
+507
Sources/CoreATProtocol/OAuth/OAuthManager.swift
···
··· 1 + import Foundation 2 + 3 + public enum OAuthManagerError: Error, Sendable { 4 + case missingAuthorizationServer 5 + case invalidAuthorizationState 6 + case authorizationInProgress 7 + case callbackStateMismatch 8 + case authorizationCancelled 9 + case tokenExchangeFailed 10 + case refreshFailed 11 + case invalidRedirectURL 12 + case unsupportedAuthorizationServer 13 + case clientMetadataValidationFailed 14 + case identityResolutionFailed 15 + case missingSession 16 + case invalidRequest 17 + } 18 + 19 + public struct AuthorizationRequest: Sendable { 20 + public let authorizationURL: URL 21 + public let redirectURI: URL 22 + } 23 + 24 + @APActor 25 + public final class OAuthManager: Sendable { 26 + private let configuration: OAuthConfiguration 27 + private let credentialStore: OAuthCredentialStore 28 + private let identityResolver: IdentityResolver 29 + private let httpClient: OAuthHTTPClient 30 + private let randomGenerator: RandomDataGenerating 31 + private var dpopGenerator: DPoPGenerator 32 + 33 + private var cachedClientMetadata: OAuthClientMetadata? 34 + private var pendingAuthorization: PendingAuthorization? 35 + private var cachedSession: OAuthSession? 36 + private var authorizationServerNonce: String? 37 + private var resourceServerNonce: String? 38 + 39 + init( 40 + configuration: OAuthConfiguration, 41 + credentialStore: OAuthCredentialStore, 42 + identityResolver: IdentityResolver = IdentityResolver(), 43 + httpClient: OAuthHTTPClient = OAuthHTTPClient(), 44 + randomGenerator: RandomDataGenerating = SecureRandomDataGenerator() 45 + ) async throws { 46 + self.configuration = configuration 47 + self.credentialStore = credentialStore 48 + self.identityResolver = identityResolver 49 + self.httpClient = httpClient 50 + self.randomGenerator = randomGenerator 51 + 52 + if let keyData = try await credentialStore.loadDPoPKey(), 53 + (try? DPoPKeyPair(rawRepresentation: keyData)) != nil { 54 + self.dpopGenerator = DPoPGenerator(keyPair: try DPoPKeyPair(rawRepresentation: keyData)) 55 + } else { 56 + let keyPair = DPoPKeyPair() 57 + self.dpopGenerator = DPoPGenerator(keyPair: keyPair) 58 + try await credentialStore.saveDPoPKey(keyPair.export()) 59 + } 60 + 61 + self.cachedSession = try await credentialStore.loadSession() 62 + } 63 + 64 + public convenience init( 65 + configuration: OAuthConfiguration, 66 + credentialStore: OAuthCredentialStore 67 + ) async throws { 68 + try await self.init( 69 + configuration: configuration, 70 + credentialStore: credentialStore, 71 + identityResolver: IdentityResolver(), 72 + httpClient: OAuthHTTPClient(), 73 + randomGenerator: SecureRandomDataGenerator() 74 + ) 75 + } 76 + 77 + public var currentSession: OAuthSession? { 78 + cachedSession 79 + } 80 + 81 + public func authenticateResourceRequest(_ request: inout URLRequest) async throws { 82 + guard let url = request.url else { throw OAuthManagerError.invalidRequest } 83 + guard var session = cachedSession else { throw OAuthManagerError.missingSession } 84 + 85 + if session.needsRefresh() { 86 + session = try await refreshSession(force: true) 87 + } 88 + 89 + let proof = try dpopGenerator.generateProof( 90 + method: request.httpMethod ?? "GET", 91 + url: url, 92 + nonce: resourceServerNonce, 93 + accessToken: session.accessToken 94 + ) 95 + 96 + request.setValue("DPoP \(session.accessToken)", forHTTPHeaderField: "Authorization") 97 + request.setValue(proof, forHTTPHeaderField: "DPoP") 98 + } 99 + 100 + public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { 101 + let request = try await beginAuthorization(for: handle) 102 + guard let callbackScheme = configuration.redirectURI.scheme else { 103 + throw OAuthManagerError.invalidRedirectURL 104 + } 105 + let callbackURL = try await uiProvider.presentAuthorization(at: request.authorizationURL, callbackScheme: callbackScheme) 106 + return try await resumeAuthorization(from: callbackURL) 107 + } 108 + 109 + public func beginAuthorization(for handle: String) async throws -> AuthorizationRequest { 110 + guard pendingAuthorization == nil else { throw OAuthManagerError.authorizationInProgress } 111 + 112 + let did = try await identityResolver.resolveHandle(handle) 113 + let didDocument = try await identityResolver.fetchDIDDocument(for: did) 114 + let pdsEndpoint = try identityResolver.extractPDSEndpoint(from: didDocument) 115 + 116 + let protectedMetadata = try await identityResolver.discoverProtectedResource(for: pdsEndpoint) 117 + guard let authorizationServerURL = protectedMetadata.authorizationServers.first else { 118 + throw OAuthManagerError.missingAuthorizationServer 119 + } 120 + let authMetadata = try await identityResolver.fetchAuthorizationServerMetadata(from: authorizationServerURL) 121 + try validateAuthorizationServerMetadata(authMetadata) 122 + 123 + let clientMetadata = try await loadClientMetadata() 124 + 125 + let pkce = try PKCEGenerator(randomGenerator: randomGenerator).makeValues() 126 + let state = try generateState() 127 + 128 + let parResult = try await performPushedAuthorizationRequest( 129 + metadata: authMetadata, 130 + clientMetadata: clientMetadata, 131 + handle: handle, 132 + did: did, 133 + pkce: pkce, 134 + state: state 135 + ) 136 + 137 + authorizationServerNonce = parResult.nonce ?? authorizationServerNonce 138 + 139 + let authorizationURL = makeAuthorizationURL( 140 + endpoint: authMetadata.authorizationEndpoint, 141 + clientID: clientMetadata.clientID, 142 + requestURI: parResult.requestURI 143 + ) 144 + 145 + pendingAuthorization = PendingAuthorization( 146 + handle: handle, 147 + did: did, 148 + pdsURL: pdsEndpoint, 149 + authorizationServerMetadata: authMetadata, 150 + clientMetadata: clientMetadata, 151 + state: state, 152 + pkce: pkce, 153 + requestURI: parResult.requestURI, 154 + issuedAt: Date() 155 + ) 156 + 157 + return AuthorizationRequest(authorizationURL: authorizationURL, redirectURI: configuration.redirectURI) 158 + } 159 + 160 + public func resumeAuthorization(from callbackURL: URL) async throws -> OAuthSession { 161 + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { 162 + throw OAuthManagerError.invalidRedirectURL 163 + } 164 + guard let pending = pendingAuthorization else { throw OAuthManagerError.invalidAuthorizationState } 165 + defer { pendingAuthorization = nil } 166 + 167 + if components.scheme != configuration.redirectURI.scheme { 168 + throw OAuthManagerError.invalidRedirectURL 169 + } 170 + 171 + let queryItems = components.queryItems ?? [] 172 + if queryItems.contains(where: { $0.name == "error" }) { 173 + throw OAuthManagerError.tokenExchangeFailed 174 + } 175 + 176 + guard let state = queryItems.first(where: { $0.name == "state" })?.value, state == pending.state else { 177 + throw OAuthManagerError.callbackStateMismatch 178 + } 179 + 180 + guard let code = queryItems.first(where: { $0.name == "code" })?.value else { 181 + throw OAuthManagerError.tokenExchangeFailed 182 + } 183 + 184 + let tokenResponse = try await exchangeAuthorizationCode( 185 + code: code, 186 + pending: pending 187 + ) 188 + 189 + guard let subject = tokenResponse.subject, subject == pending.did else { 190 + throw OAuthManagerError.identityResolutionFailed 191 + } 192 + 193 + guard let refreshToken = tokenResponse.refreshToken, refreshToken.isEmpty == false else { 194 + throw OAuthManagerError.tokenExchangeFailed 195 + } 196 + 197 + let session = OAuthSession( 198 + did: pending.did, 199 + pdsURL: pending.pdsURL, 200 + authorizationServer: pending.authorizationServerMetadata.issuer, 201 + tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint, 202 + accessToken: tokenResponse.accessToken, 203 + refreshToken: refreshToken, 204 + tokenType: tokenResponse.tokenType, 205 + scope: tokenResponse.scope, 206 + expiresIn: tokenResponse.expiresIn, 207 + issuedAt: Date() 208 + ) 209 + try await store(session: session) 210 + resourceServerNonce = nil 211 + return session 212 + } 213 + 214 + public func refreshSession(force: Bool = false) async throws -> OAuthSession { 215 + guard let session = cachedSession else { throw OAuthManagerError.refreshFailed } 216 + if !force, session.needsRefresh() == false { 217 + return session 218 + } 219 + 220 + let refreshed = try await performRefresh(session: session) 221 + try await store(session: refreshed) 222 + return refreshed 223 + } 224 + 225 + public func signOut() async throws { 226 + cachedSession = nil 227 + pendingAuthorization = nil 228 + authorizationServerNonce = nil 229 + resourceServerNonce = nil 230 + cachedClientMetadata = nil 231 + try await credentialStore.deleteSession() 232 + APEnvironment.current.accessToken = nil 233 + APEnvironment.current.refreshToken = nil 234 + APEnvironment.current.host = nil 235 + } 236 + 237 + // MARK: - Nonce Management 238 + 239 + public func updateAuthorizationServerNonce(_ nonce: String?) async { 240 + authorizationServerNonce = nonce 241 + } 242 + 243 + public func updateResourceServerNonce(_ nonce: String?) async { 244 + resourceServerNonce = nonce 245 + } 246 + 247 + public func currentResourceServerNonce() -> String? { 248 + resourceServerNonce 249 + } 250 + 251 + public func currentAuthorizationServerNonce() -> String? { 252 + authorizationServerNonce 253 + } 254 + 255 + // MARK: - Private helpers 256 + 257 + private func loadClientMetadata() async throws -> OAuthClientMetadata { 258 + if let metadata = cachedClientMetadata { 259 + return metadata 260 + } 261 + 262 + var request = URLRequest(url: configuration.clientMetadataURL) 263 + request.setValue("application/json", forHTTPHeaderField: "Accept") 264 + let (data, response) = try await httpClient.send(request) 265 + guard (200..<300).contains(response.statusCode) else { 266 + throw OAuthManagerError.clientMetadataValidationFailed 267 + } 268 + let metadata = try httpClient.decodeJSON(OAuthClientMetadata.self, from: data) 269 + try validateClientMetadata(metadata) 270 + cachedClientMetadata = metadata 271 + return metadata 272 + } 273 + 274 + private func validateClientMetadata(_ metadata: OAuthClientMetadata) throws { 275 + guard metadata.clientID == configuration.clientMetadataURL else { 276 + throw OAuthManagerError.clientMetadataValidationFailed 277 + } 278 + guard metadata.redirectURIs.contains(configuration.redirectURI) else { 279 + throw OAuthManagerError.clientMetadataValidationFailed 280 + } 281 + guard metadata.grantTypes.contains("authorization_code") else { 282 + throw OAuthManagerError.clientMetadataValidationFailed 283 + } 284 + guard metadata.responseTypes.contains("code") else { 285 + throw OAuthManagerError.clientMetadataValidationFailed 286 + } 287 + guard metadata.dPoPBoundAccessTokens else { 288 + throw OAuthManagerError.clientMetadataValidationFailed 289 + } 290 + } 291 + 292 + private func validateAuthorizationServerMetadata(_ metadata: OAuthAuthorizationServerMetadata) throws { 293 + guard metadata.codeChallengeMethodsSupported.contains(where: { $0.caseInsensitiveCompare("S256") == .orderedSame }) else { 294 + throw OAuthManagerError.unsupportedAuthorizationServer 295 + } 296 + guard metadata.dPoPSigningAlgValuesSupported.contains(where: { $0.caseInsensitiveCompare("ES256") == .orderedSame }) else { 297 + throw OAuthManagerError.unsupportedAuthorizationServer 298 + } 299 + guard metadata.scopesSupported.isEmpty || metadata.scopesSupported.contains("atproto") else { 300 + throw OAuthManagerError.unsupportedAuthorizationServer 301 + } 302 + } 303 + 304 + private func generateState() throws -> String { 305 + let data = try randomGenerator.data(count: 32) 306 + return Base64URL.encode(data) 307 + } 308 + 309 + private func performPushedAuthorizationRequest( 310 + metadata: OAuthAuthorizationServerMetadata, 311 + clientMetadata: OAuthClientMetadata, 312 + handle: String, 313 + did: String, 314 + pkce: PKCEValues, 315 + state: String 316 + ) async throws -> (requestURI: String, nonce: String?) { 317 + let parameters: [String: String] = { 318 + var base: [String: String] = [ 319 + "client_id": configuration.clientMetadataURL.absoluteString, 320 + "redirect_uri": configuration.redirectURI.absoluteString, 321 + "response_type": "code", 322 + "scope": configuration.requestedScopes.joined(separator: " "), 323 + "code_challenge": pkce.challenge, 324 + "code_challenge_method": "S256", 325 + "state": state, 326 + "login_hint": handle, 327 + "resource": did 328 + ] 329 + configuration.additionalAuthorizationParameters.forEach { base[$0.key] = $0.value } 330 + return base 331 + }() 332 + 333 + var request = URLRequest(url: metadata.pushedAuthorizationRequestEndpoint) 334 + request.httpMethod = "POST" 335 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 336 + request.httpBody = try formEncodedBody(from: parameters) 337 + 338 + var currentNonce = authorizationServerNonce 339 + for _ in 0..<2 { 340 + let proof = try dpopGenerator.generateProof( 341 + method: "POST", 342 + url: metadata.pushedAuthorizationRequestEndpoint, 343 + nonce: currentNonce, 344 + accessToken: nil 345 + ) 346 + request.setValue(proof, forHTTPHeaderField: "DPoP") 347 + let (data, response) = try await httpClient.send(request) 348 + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 349 + currentNonce = nonce 350 + } 351 + 352 + switch response.statusCode { 353 + case 201: 354 + authorizationServerNonce = currentNonce 355 + let parResponse = try httpClient.decodeJSON(PushedAuthorizationRequestResponse.self, from: data) 356 + return (parResponse.requestURI, currentNonce) 357 + case 400, 401: 358 + if currentNonce != nil { 359 + continue 360 + } 361 + if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), 362 + errorResponse.error == "use_dpop_nonce" { 363 + continue 364 + } 365 + throw OAuthManagerError.tokenExchangeFailed 366 + default: 367 + throw OAuthManagerError.tokenExchangeFailed 368 + } 369 + } 370 + 371 + throw OAuthManagerError.tokenExchangeFailed 372 + } 373 + 374 + private func makeAuthorizationURL(endpoint: URL, clientID: URL, requestURI: String) -> URL { 375 + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents() 376 + var items = components.queryItems ?? [] 377 + items.append(URLQueryItem(name: "client_id", value: clientID.absoluteString)) 378 + items.append(URLQueryItem(name: "request_uri", value: requestURI)) 379 + components.queryItems = items 380 + return components.url ?? endpoint 381 + } 382 + 383 + private func exchangeAuthorizationCode(code: String, pending: PendingAuthorization) async throws -> OAuthTokenResponse { 384 + let parameters: [String: String] = [ 385 + "grant_type": "authorization_code", 386 + "code": code, 387 + "redirect_uri": configuration.redirectURI.absoluteString, 388 + "client_id": configuration.clientMetadataURL.absoluteString, 389 + "code_verifier": pending.pkce.verifier 390 + ] 391 + 392 + var request = URLRequest(url: pending.authorizationServerMetadata.tokenEndpoint) 393 + request.httpMethod = "POST" 394 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 395 + request.httpBody = try formEncodedBody(from: parameters) 396 + 397 + let response = try await sendTokenRequest(request: request, tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint) 398 + return response 399 + } 400 + 401 + private func performRefresh(session: OAuthSession) async throws -> OAuthSession { 402 + let parameters: [String: String] = [ 403 + "grant_type": "refresh_token", 404 + "refresh_token": session.refreshToken, 405 + "client_id": configuration.clientMetadataURL.absoluteString, 406 + "redirect_uri": configuration.redirectURI.absoluteString 407 + ] 408 + 409 + var request = URLRequest(url: session.tokenEndpoint) 410 + request.httpMethod = "POST" 411 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 412 + request.httpBody = try formEncodedBody(from: parameters) 413 + 414 + let tokenResponse = try await sendTokenRequest(request: request, tokenEndpoint: session.tokenEndpoint) 415 + guard let subject = tokenResponse.subject, subject == session.did else { 416 + throw OAuthManagerError.refreshFailed 417 + } 418 + 419 + let refreshToken: String 420 + if let newToken = tokenResponse.refreshToken, newToken.isEmpty == false { 421 + refreshToken = newToken 422 + } else { 423 + refreshToken = session.refreshToken 424 + } 425 + 426 + return OAuthSession( 427 + did: session.did, 428 + pdsURL: session.pdsURL, 429 + authorizationServer: session.authorizationServer, 430 + tokenEndpoint: session.tokenEndpoint, 431 + accessToken: tokenResponse.accessToken, 432 + refreshToken: refreshToken, 433 + tokenType: tokenResponse.tokenType, 434 + scope: tokenResponse.scope, 435 + expiresIn: tokenResponse.expiresIn, 436 + issuedAt: Date() 437 + ) 438 + } 439 + 440 + private func sendTokenRequest(request: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { 441 + var request = request 442 + let nonce = authorizationServerNonce 443 + let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: nonce, accessToken: nil) 444 + request.setValue(proof, forHTTPHeaderField: "DPoP") 445 + 446 + let (data, response) = try await httpClient.send(request) 447 + if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { 448 + authorizationServerNonce = newNonce 449 + } 450 + 451 + switch response.statusCode { 452 + case 200: 453 + return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) 454 + case 400, 401: 455 + if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), 456 + errorResponse.error == "use_dpop_nonce", 457 + let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 458 + authorizationServerNonce = nonce 459 + return try await retryTokenRequest(originalRequest: request, tokenEndpoint: tokenEndpoint) 460 + } 461 + fallthrough 462 + default: 463 + throw OAuthManagerError.tokenExchangeFailed 464 + } 465 + } 466 + 467 + private func retryTokenRequest(originalRequest: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { 468 + var request = originalRequest 469 + let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: authorizationServerNonce, accessToken: nil) 470 + request.setValue(proof, forHTTPHeaderField: "DPoP") 471 + let (data, response) = try await httpClient.send(request) 472 + if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { 473 + authorizationServerNonce = newNonce 474 + } 475 + guard response.statusCode == 200 else { throw OAuthManagerError.tokenExchangeFailed } 476 + return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) 477 + } 478 + 479 + private func formEncodedBody(from parameters: [String: String]) throws -> Data { 480 + var components = URLComponents() 481 + components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } 482 + guard let query = components.percentEncodedQuery, let data = query.data(using: .utf8) else { 483 + throw OAuthManagerError.tokenExchangeFailed 484 + } 485 + return data 486 + } 487 + 488 + private func store(session: OAuthSession) async throws { 489 + cachedSession = session 490 + try await credentialStore.save(session: session) 491 + APEnvironment.current.accessToken = session.accessToken 492 + APEnvironment.current.refreshToken = session.refreshToken 493 + APEnvironment.current.host = session.pdsURL.absoluteString 494 + } 495 + } 496 + 497 + private struct PendingAuthorization: Sendable { 498 + let handle: String 499 + let did: String 500 + let pdsURL: URL 501 + let authorizationServerMetadata: OAuthAuthorizationServerMetadata 502 + let clientMetadata: OAuthClientMetadata 503 + let state: String 504 + let pkce: PKCEValues 505 + let requestURI: String 506 + let issuedAt: Date 507 + }
+5
Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift
···
··· 1 + import Foundation 2 + 3 + public protocol OAuthUIProvider: Sendable { 4 + func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL 5 + }
-204
Sources/CoreATProtocol/OAuth/RefreshService.swift
··· 1 - // 2 - // RefreshService.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - import CryptoKit 10 - @preconcurrency import OAuthenticator 11 - 12 - /// Handles token refresh operations for AT Protocol OAuth. 13 - @APActor 14 - public final class RefreshService { 15 - 16 - /// Request body for token refresh. 17 - struct RefreshTokenRequest: Codable, Sendable { 18 - let refreshToken: String 19 - let grantType: String 20 - let clientId: String 21 - 22 - enum CodingKeys: String, CodingKey { 23 - case refreshToken = "refresh_token" 24 - case grantType = "grant_type" 25 - case clientId = "client_id" 26 - } 27 - 28 - init(refreshToken: String, clientId: String) { 29 - self.refreshToken = refreshToken 30 - self.grantType = "refresh_token" 31 - self.clientId = clientId 32 - } 33 - } 34 - 35 - private let urlSession: URLSession 36 - 37 - public init(urlSession: URLSession = .shared) { 38 - self.urlSession = urlSession 39 - } 40 - 41 - /// Refreshes tokens using the stored authentication state. 42 - /// - Parameters: 43 - /// - state: Current authentication state with refresh token 44 - /// - serverMetadata: OAuth server metadata with token endpoint 45 - /// - clientId: The client ID for the application 46 - /// - dpopGenerator: DPoP JWT generator for signing requests 47 - /// - Returns: Updated authentication state with new tokens 48 - public func refresh( 49 - state: AuthenticationState, 50 - serverMetadata: ServerMetadata, 51 - clientId: String, 52 - dpopGenerator: DPoPSigner.JWTGenerator? 53 - ) async throws -> AuthenticationState { 54 - guard let refreshToken = state.refreshToken else { 55 - throw OAuthError.refreshTokenMissing 56 - } 57 - 58 - guard !state.isRefreshTokenExpired else { 59 - throw OAuthError.refreshTokenExpired 60 - } 61 - 62 - guard let tokenURL = URL(string: serverMetadata.tokenEndpoint) else { 63 - throw OAuthError.invalidConfiguration(reason: "Invalid token endpoint URL") 64 - } 65 - 66 - let requestBody = RefreshTokenRequest(refreshToken: refreshToken, clientId: clientId) 67 - 68 - var request = URLRequest(url: tokenURL) 69 - request.httpMethod = "POST" 70 - request.setValue("application/json", forHTTPHeaderField: "Content-Type") 71 - request.setValue("application/json", forHTTPHeaderField: "Accept") 72 - request.httpBody = try JSONEncoder().encode(requestBody) 73 - 74 - // Add DPoP header if generator is available 75 - if let generator = dpopGenerator { 76 - let dpopSigner = DPoPSigner() 77 - dpopSigner.nonce = await APEnvironment.current.resourceServerNonce 78 - 79 - try await dpopSigner.authenticateRequest( 80 - &request, 81 - isolation: APActor.shared, 82 - using: generator, 83 - token: nil, 84 - tokenHash: nil, 85 - issuer: serverMetadata.issuer 86 - ) 87 - } 88 - 89 - let (data, response) = try await urlSession.data(for: request) 90 - 91 - guard let httpResponse = response as? HTTPURLResponse else { 92 - throw OAuthError.refreshFailed(reason: "Invalid response type") 93 - } 94 - 95 - // Update DPoP nonce from response 96 - if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 97 - await APEnvironment.current.setResourceServerNonce(newNonce) 98 - } 99 - 100 - guard (200...299).contains(httpResponse.statusCode) else { 101 - if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { 102 - throw OAuthError.refreshFailed(reason: errorResponse.errorDescription ?? errorResponse.error) 103 - } 104 - throw OAuthError.refreshFailed(reason: "HTTP \(httpResponse.statusCode)") 105 - } 106 - 107 - let tokenResponse = try JSONDecoder().decode(TokenRefreshResponse.self, from: data) 108 - 109 - // Verify token type is DPoP 110 - guard tokenResponse.tokenType.lowercased() == "dpop" else { 111 - throw OAuthError.dpopRequired 112 - } 113 - 114 - // Verify subject matches 115 - guard tokenResponse.sub == state.did else { 116 - throw OAuthError.subjectMismatch(expected: state.did, received: tokenResponse.sub) 117 - } 118 - 119 - return state.withUpdatedTokens( 120 - access: tokenResponse.accessToken, 121 - refresh: tokenResponse.refreshToken, 122 - expiresIn: tokenResponse.expiresIn 123 - ) 124 - } 125 - } 126 - 127 - // MARK: - APEnvironment Extension for Refresh 128 - 129 - extension APEnvironment { 130 - /// Performs token refresh and updates the environment. 131 - /// - Returns: true if refresh succeeded, false otherwise 132 - public func performTokenRefresh() async -> Bool { 133 - guard let state = authState else { 134 - return false 135 - } 136 - 137 - guard state.canRefresh else { 138 - return false 139 - } 140 - 141 - guard let serverMetadata = serverMetadata else { 142 - return false 143 - } 144 - 145 - guard let clientId = clientId else { 146 - return false 147 - } 148 - 149 - let refreshService = RefreshService() 150 - 151 - do { 152 - let newState = try await refreshService.refresh( 153 - state: state, 154 - serverMetadata: serverMetadata, 155 - clientId: clientId, 156 - dpopGenerator: dpopProofGenerator 157 - ) 158 - 159 - // Update environment with new tokens 160 - self.authState = newState 161 - self.accessToken = newState.accessToken 162 - self.refreshToken = newState.refreshToken 163 - 164 - // Update the Login object if present 165 - if var currentLogin = login { 166 - currentLogin.accessToken = Token( 167 - value: newState.accessToken, 168 - expiry: newState.accessTokenExpiry 169 - ) 170 - if let newRefresh = newState.refreshToken { 171 - currentLogin.refreshToken = Token(value: newRefresh) 172 - } 173 - self.login = currentLogin 174 - } 175 - 176 - // Notify delegate of token update 177 - await atProtocoldelegate?.tokensUpdated( 178 - accessToken: newState.accessToken, 179 - refreshToken: newState.refreshToken 180 - ) 181 - 182 - // Persist if storage is configured 183 - if let storage = tokenStorage { 184 - try? await storage.updateTokens( 185 - access: newState.accessToken, 186 - refresh: newState.refreshToken, 187 - expiresIn: Int(newState.accessTokenExpiry?.timeIntervalSinceNow ?? 3600) 188 - ) 189 - } 190 - 191 - return true 192 - } catch { 193 - // Log the error but don't throw - let caller handle retry logic 194 - print("Token refresh failed: \(error)") 195 - return false 196 - } 197 - } 198 - 199 - /// Sets the resource server DPoP nonce. 200 - public func setResourceServerNonce(_ nonce: String?) { 201 - resourceServerNonce = nonce 202 - resourceDPoPSigner.nonce = nonce 203 - } 204 - }
···
+101
Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift
···
··· 1 + import CryptoKit 2 + import Foundation 3 + 4 + enum DPoPGeneratorError: Error, Sendable { 5 + case invalidURL 6 + case keyUnavailable 7 + } 8 + 9 + @APActor 10 + public final class DPoPGenerator: Sendable { 11 + private var keyPair: DPoPKeyPair 12 + private let clock: () -> Date 13 + 14 + init(keyPair: DPoPKeyPair, clock: @escaping () -> Date = Date.init) { 15 + self.keyPair = keyPair 16 + self.clock = clock 17 + } 18 + 19 + public convenience init(clock: @escaping () -> Date = Date.init) { 20 + self.init(keyPair: DPoPKeyPair(), clock: clock) 21 + } 22 + 23 + public func updateKey(using rawRepresentation: Data) throws { 24 + self.keyPair = try DPoPKeyPair(rawRepresentation: rawRepresentation) 25 + } 26 + 27 + public func exportKey() -> Data { 28 + keyPair.export() 29 + } 30 + 31 + public func generateProof( 32 + method: String, 33 + url: URL, 34 + nonce: String?, 35 + accessToken: String? 36 + ) throws -> String { 37 + let normalizedHTU = try normalize(url: url) 38 + let issuedAt = Int(clock().timeIntervalSince1970) 39 + let header = Header(jwk: keyPair.publicKeyJWK) 40 + let payload = Payload( 41 + htm: method.uppercased(), 42 + htu: normalizedHTU, 43 + iat: issuedAt, 44 + exp: issuedAt + 120, 45 + jti: UUID().uuidString, 46 + nonce: nonce, 47 + ath: accessToken.flatMap { accessTokenHash(for: $0) } 48 + ) 49 + 50 + let encoder = JSONEncoder() 51 + encoder.outputFormatting = [.withoutEscapingSlashes] 52 + let headerEncoded = Base64URL.encode(try encoder.encode(header)) 53 + let payloadEncoded = Base64URL.encode(try encoder.encode(payload)) 54 + let signingInput = Data("\(headerEncoded).\(payloadEncoded)".utf8) 55 + let signature = try keyPair.privateKey.signature(for: signingInput) 56 + let signatureEncoded = Base64URL.encode(signature.derRepresentation) 57 + return "\(headerEncoded).\(payloadEncoded).\(signatureEncoded)" 58 + } 59 + 60 + private func normalize(url: URL) throws -> String { 61 + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 62 + throw DPoPGeneratorError.invalidURL 63 + } 64 + components.fragment = nil 65 + guard let normalized = components.url?.absoluteString else { 66 + throw DPoPGeneratorError.invalidURL 67 + } 68 + return normalized 69 + } 70 + 71 + private func accessTokenHash(for token: String) -> String { 72 + let digest = SHA256.hash(data: Data(token.utf8)) 73 + return Base64URL.encode(Data(digest)) 74 + } 75 + 76 + private struct Header: Encodable { 77 + let typ = "dpop+jwt" 78 + let alg = "ES256" 79 + let jwk: [String: String] 80 + } 81 + 82 + private struct Payload: Encodable { 83 + let htm: String 84 + let htu: String 85 + let iat: Int 86 + let exp: Int 87 + let jti: String 88 + let nonce: String? 89 + let ath: String? 90 + 91 + private enum CodingKeys: String, CodingKey { 92 + case htm 93 + case htu 94 + case iat 95 + case exp 96 + case jti 97 + case nonce 98 + case ath 99 + } 100 + } 101 + }
+38
Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift
···
··· 1 + import CryptoKit 2 + import Foundation 3 + 4 + struct DPoPKeyPair: Sendable { 5 + let privateKey: P256.Signing.PrivateKey 6 + 7 + init() { 8 + self.privateKey = P256.Signing.PrivateKey() 9 + } 10 + 11 + init(privateKey: P256.Signing.PrivateKey) { 12 + self.privateKey = privateKey 13 + } 14 + 15 + init(rawRepresentation: Data) throws { 16 + self.privateKey = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation) 17 + } 18 + 19 + var publicKeyJWK: [String: String] { 20 + let publicKeyData = privateKey.publicKey.x963Representation 21 + // Strip leading 0x04 per SEC1 encoding to expose affine coordinates 22 + let xData = Data(publicKeyData[1..<33]) 23 + let yData = Data(publicKeyData[33..<65]) 24 + 25 + return [ 26 + "kty": "EC", 27 + "crv": "P-256", 28 + "alg": "ES256", 29 + "use": "sig", 30 + "x": Base64URL.encode(xData), 31 + "y": Base64URL.encode(yData) 32 + ] 33 + } 34 + 35 + func export() -> Data { 36 + privateKey.rawRepresentation 37 + } 38 + }
+38
Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift
···
··· 1 + import CryptoKit 2 + import Foundation 3 + 4 + struct PKCEValues: Sendable { 5 + let verifier: String 6 + let challenge: String 7 + } 8 + 9 + struct PKCEGenerator: Sendable { 10 + private let randomGenerator: RandomDataGenerating 11 + 12 + init(randomGenerator: RandomDataGenerating = SecureRandomDataGenerator()) { 13 + self.randomGenerator = randomGenerator 14 + } 15 + 16 + func makeValues() throws -> PKCEValues { 17 + let verifier = try makeVerifier() 18 + let challenge = makeChallenge(from: verifier) 19 + return PKCEValues(verifier: verifier, challenge: challenge) 20 + } 21 + 22 + func makeVerifier() throws -> String { 23 + let candidateLengths = [32, 48, 64] 24 + for length in candidateLengths { 25 + let data = try randomGenerator.data(count: length) 26 + let candidate = Base64URL.encode(data) 27 + if (43...128).contains(candidate.count) { 28 + return candidate 29 + } 30 + } 31 + throw RandomDataGeneratorError.allocationFailed 32 + } 33 + 34 + func makeChallenge(from verifier: String) -> String { 35 + let digest = SHA256.hash(data: Data(verifier.utf8)) 36 + return Base64URL.encode(Data(digest)) 37 + } 38 + }
+23
Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift
···
··· 1 + import Foundation 2 + import Security 3 + 4 + enum RandomDataGeneratorError: Error, Sendable { 5 + case allocationFailed 6 + case generationFailed(status: OSStatus) 7 + } 8 + 9 + protocol RandomDataGenerating: Sendable { 10 + func data(count: Int) throws -> Data 11 + } 12 + 13 + struct SecureRandomDataGenerator: RandomDataGenerating { 14 + func data(count: Int) throws -> Data { 15 + guard count > 0 else { return Data() } 16 + var buffer = Data(count: count) 17 + let status = buffer.withUnsafeMutableBytes { pointer in 18 + SecRandomCopyBytes(kSecRandomDefault, count, pointer.baseAddress!) 19 + } 20 + guard status == errSecSuccess else { throw RandomDataGeneratorError.generationFailed(status: status) } 21 + return buffer 22 + } 23 + }
+41
Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift
···
··· 1 + import Foundation 2 + 3 + public protocol OAuthCredentialStore: Sendable { 4 + func loadSession() async throws -> OAuthSession? 5 + func save(session: OAuthSession) async throws 6 + func deleteSession() async throws 7 + func loadDPoPKey() async throws -> Data? 8 + func saveDPoPKey(_ data: Data) async throws 9 + func deleteDPoPKey() async throws 10 + } 11 + 12 + public actor InMemoryOAuthCredentialStore: OAuthCredentialStore { 13 + private var session: OAuthSession? 14 + private var dpopKey: Data? 15 + 16 + public init() {} 17 + 18 + public func loadSession() async throws -> OAuthSession? { 19 + session 20 + } 21 + 22 + public func save(session: OAuthSession) async throws { 23 + self.session = session 24 + } 25 + 26 + public func deleteSession() async throws { 27 + session = nil 28 + } 29 + 30 + public func loadDPoPKey() async throws -> Data? { 31 + dpopKey 32 + } 33 + 34 + public func saveDPoPKey(_ data: Data) async throws { 35 + dpopKey = data 36 + } 37 + 38 + public func deleteDPoPKey() async throws { 39 + dpopKey = nil 40 + } 41 + }
-239
Sources/CoreATProtocol/OAuth/TokenStorage.swift
··· 1 - // 2 - // TokenStorage.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Foundation 9 - @preconcurrency import OAuthenticator 10 - 11 - /// Protocol for persisting authentication tokens. 12 - /// Implementations should use secure storage such as Keychain on Apple platforms. 13 - public protocol TokenStorageProtocol: Sendable { 14 - /// Stores the complete authentication state. 15 - func store(_ authState: AuthenticationState) async throws 16 - 17 - /// Retrieves the stored authentication state. 18 - func retrieve() async throws -> AuthenticationState? 19 - 20 - /// Clears all stored authentication data. 21 - func clear() async throws 22 - 23 - /// Updates only the tokens without changing other state. 24 - func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws 25 - } 26 - 27 - /// Complete authentication state to be persisted. 28 - public struct AuthenticationState: Codable, Sendable { 29 - public let did: String 30 - public let handle: String? 31 - public let pdsURL: String 32 - public let authServerURL: String 33 - public let accessToken: String 34 - public let accessTokenExpiry: Date? 35 - public let refreshToken: String? 36 - public let refreshTokenExpiry: Date? 37 - public let scope: String? 38 - public let dpopPrivateKeyData: Data? 39 - public let createdAt: Date 40 - public let updatedAt: Date 41 - 42 - public init( 43 - did: String, 44 - handle: String?, 45 - pdsURL: String, 46 - authServerURL: String, 47 - accessToken: String, 48 - accessTokenExpiry: Date?, 49 - refreshToken: String?, 50 - refreshTokenExpiry: Date? = nil, 51 - scope: String?, 52 - dpopPrivateKeyData: Data?, 53 - createdAt: Date = Date(), 54 - updatedAt: Date = Date() 55 - ) { 56 - self.did = did 57 - self.handle = handle 58 - self.pdsURL = pdsURL 59 - self.authServerURL = authServerURL 60 - self.accessToken = accessToken 61 - self.accessTokenExpiry = accessTokenExpiry 62 - self.refreshToken = refreshToken 63 - self.refreshTokenExpiry = refreshTokenExpiry 64 - self.scope = scope 65 - self.dpopPrivateKeyData = dpopPrivateKeyData 66 - self.createdAt = createdAt 67 - self.updatedAt = updatedAt 68 - } 69 - 70 - /// Creates an updated state with new tokens. 71 - public func withUpdatedTokens( 72 - access: String, 73 - refresh: String?, 74 - expiresIn: Int 75 - ) -> AuthenticationState { 76 - AuthenticationState( 77 - did: did, 78 - handle: handle, 79 - pdsURL: pdsURL, 80 - authServerURL: authServerURL, 81 - accessToken: access, 82 - accessTokenExpiry: Date().addingTimeInterval(TimeInterval(expiresIn)), 83 - refreshToken: refresh ?? refreshToken, 84 - refreshTokenExpiry: refreshTokenExpiry, 85 - scope: scope, 86 - dpopPrivateKeyData: dpopPrivateKeyData, 87 - createdAt: createdAt, 88 - updatedAt: Date() 89 - ) 90 - } 91 - 92 - /// Checks if the access token is expired or about to expire. 93 - public var isAccessTokenExpired: Bool { 94 - guard let expiry = accessTokenExpiry else { return false } 95 - // Consider expired if less than 60 seconds remaining 96 - return expiry.timeIntervalSinceNow < 60 97 - } 98 - 99 - /// Checks if the refresh token is expired. 100 - public var isRefreshTokenExpired: Bool { 101 - guard let expiry = refreshTokenExpiry else { return false } 102 - return expiry.timeIntervalSinceNow < 0 103 - } 104 - 105 - /// Checks if we can attempt a token refresh. 106 - public var canRefresh: Bool { 107 - refreshToken != nil && !isRefreshTokenExpired 108 - } 109 - } 110 - 111 - /// In-memory token storage for testing or temporary use. 112 - /// Not recommended for production - use Keychain-based storage instead. 113 - @APActor 114 - public final class InMemoryTokenStorage: TokenStorageProtocol { 115 - private var state: AuthenticationState? 116 - 117 - public init() {} 118 - 119 - public func store(_ authState: AuthenticationState) async throws { 120 - self.state = authState 121 - } 122 - 123 - public func retrieve() async throws -> AuthenticationState? { 124 - return state 125 - } 126 - 127 - public func clear() async throws { 128 - state = nil 129 - } 130 - 131 - public func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws { 132 - guard let current = state else { 133 - throw OAuthError.loginNotFound 134 - } 135 - state = current.withUpdatedTokens(access: access, refresh: refresh, expiresIn: expiresIn) 136 - } 137 - } 138 - 139 - #if canImport(Security) 140 - import Security 141 - 142 - /// Keychain-based token storage for secure persistence on Apple platforms. 143 - @APActor 144 - public final class KeychainTokenStorage: TokenStorageProtocol { 145 - private let service: String 146 - private let account: String 147 - private let accessGroup: String? 148 - 149 - /// Creates a new Keychain storage instance. 150 - /// - Parameters: 151 - /// - service: The service identifier (typically your app's bundle ID) 152 - /// - account: The account identifier (can be a constant or user-specific) 153 - /// - accessGroup: Optional access group for sharing between apps 154 - public init(service: String, account: String = "atproto_auth", accessGroup: String? = nil) { 155 - self.service = service 156 - self.account = account 157 - self.accessGroup = accessGroup 158 - } 159 - 160 - public func store(_ authState: AuthenticationState) async throws { 161 - let data = try JSONEncoder().encode(authState) 162 - 163 - var query: [String: Any] = [ 164 - kSecClass as String: kSecClassGenericPassword, 165 - kSecAttrService as String: service, 166 - kSecAttrAccount as String: account, 167 - kSecValueData as String: data, 168 - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly 169 - ] 170 - 171 - if let group = accessGroup { 172 - query[kSecAttrAccessGroup as String] = group 173 - } 174 - 175 - // Delete existing item first 176 - let deleteQuery: [String: Any] = [ 177 - kSecClass as String: kSecClassGenericPassword, 178 - kSecAttrService as String: service, 179 - kSecAttrAccount as String: account 180 - ] 181 - SecItemDelete(deleteQuery as CFDictionary) 182 - 183 - let status = SecItemAdd(query as CFDictionary, nil) 184 - 185 - guard status == errSecSuccess else { 186 - throw OAuthError.storageFailed(reason: "Keychain write failed with status: \(status)") 187 - } 188 - } 189 - 190 - public func retrieve() async throws -> AuthenticationState? { 191 - var query: [String: Any] = [ 192 - kSecClass as String: kSecClassGenericPassword, 193 - kSecAttrService as String: service, 194 - kSecAttrAccount as String: account, 195 - kSecReturnData as String: true, 196 - kSecMatchLimit as String: kSecMatchLimitOne 197 - ] 198 - 199 - if let group = accessGroup { 200 - query[kSecAttrAccessGroup as String] = group 201 - } 202 - 203 - var result: AnyObject? 204 - let status = SecItemCopyMatching(query as CFDictionary, &result) 205 - 206 - guard status == errSecSuccess, let data = result as? Data else { 207 - if status == errSecItemNotFound { 208 - return nil 209 - } 210 - throw OAuthError.storageFailed(reason: "Keychain read failed with status: \(status)") 211 - } 212 - 213 - return try JSONDecoder().decode(AuthenticationState.self, from: data) 214 - } 215 - 216 - public func clear() async throws { 217 - let query: [String: Any] = [ 218 - kSecClass as String: kSecClassGenericPassword, 219 - kSecAttrService as String: service, 220 - kSecAttrAccount as String: account 221 - ] 222 - 223 - let status = SecItemDelete(query as CFDictionary) 224 - 225 - guard status == errSecSuccess || status == errSecItemNotFound else { 226 - throw OAuthError.storageFailed(reason: "Keychain delete failed with status: \(status)") 227 - } 228 - } 229 - 230 - public func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws { 231 - guard let current = try await retrieve() else { 232 - throw OAuthError.loginNotFound 233 - } 234 - 235 - let updated = current.withUpdatedTokens(access: access, refresh: refresh, expiresIn: expiresIn) 236 - try await store(updated) 237 - } 238 - } 239 - #endif
···
+26
Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift
···
··· 1 + import Foundation 2 + 3 + enum Base64URLError: Error, Sendable { 4 + case invalidLength 5 + case invalidCharacters 6 + } 7 + 8 + struct Base64URL: Sendable { 9 + static func encode(_ data: Data) -> String { 10 + data.base64EncodedString() 11 + .replacingOccurrences(of: "+", with: "-") 12 + .replacingOccurrences(of: "/", with: "_") 13 + .replacingOccurrences(of: "=", with: "") 14 + } 15 + 16 + static func decode(_ string: String) throws -> Data { 17 + let remainder = string.count % 4 18 + guard remainder != 1 else { throw Base64URLError.invalidLength } 19 + let paddingLength = remainder == 0 ? 0 : 4 - remainder 20 + let padded = string + String(repeating: "=", count: paddingLength) 21 + guard let data = Data(base64Encoded: padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")) else { 22 + throw Base64URLError.invalidCharacters 23 + } 24 + return data 25 + } 26 + }
-190
Tests/CoreATProtocolTests/ATErrorTests.swift
··· 1 - // 2 - // ATErrorTests.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Testing 9 - import Foundation 10 - @testable import CoreATProtocol 11 - 12 - @Suite("AT Error Tests") 13 - struct ATErrorTests { 14 - 15 - // MARK: - ErrorMessage Tests 16 - 17 - @Test("ErrorMessage parses from JSON") 18 - func testErrorMessageParsing() throws { 19 - let json = """ 20 - { 21 - "error": "ExpiredToken", 22 - "message": "The access token has expired" 23 - } 24 - """.data(using: .utf8)! 25 - 26 - let message = try JSONDecoder().decode(ErrorMessage.self, from: json) 27 - 28 - #expect(message.error == "ExpiredToken") 29 - #expect(message.message == "The access token has expired") 30 - #expect(message.errorType == .expiredToken) 31 - } 32 - 33 - @Test("ErrorMessage handles unknown error types") 34 - func testUnknownErrorType() throws { 35 - let json = """ 36 - { 37 - "error": "SomeNewError", 38 - "message": "An unknown error occurred" 39 - } 40 - """.data(using: .utf8)! 41 - 42 - let message = try JSONDecoder().decode(ErrorMessage.self, from: json) 43 - 44 - #expect(message.error == "SomeNewError") 45 - #expect(message.errorType == nil) 46 - } 47 - 48 - @Test("ErrorMessage handles missing message field") 49 - func testMissingMessage() throws { 50 - let json = """ 51 - { 52 - "error": "InvalidRequest" 53 - } 54 - """.data(using: .utf8)! 55 - 56 - let message = try JSONDecoder().decode(ErrorMessage.self, from: json) 57 - 58 - #expect(message.error == "InvalidRequest") 59 - #expect(message.message == nil) 60 - } 61 - 62 - // MARK: - AtErrorType Tests 63 - 64 - @Test("All error types have descriptions") 65 - func testErrorTypeDescriptions() { 66 - for errorType in AtErrorType.allCases { 67 - #expect(!errorType.description.isEmpty, "\(errorType) should have a description") 68 - } 69 - } 70 - 71 - @Test("Error types decode correctly") 72 - func testErrorTypeDecoding() throws { 73 - let testCases: [(String, AtErrorType)] = [ 74 - ("\"AuthenticationRequired\"", .authenticationRequired), 75 - ("\"ExpiredToken\"", .expiredToken), 76 - ("\"RateLimitExceeded\"", .rateLimitExceeded), 77 - ("\"RecordNotFound\"", .recordNotFound), 78 - ("\"BlobTooLarge\"", .blobTooLarge) 79 - ] 80 - 81 - for (json, expected) in testCases { 82 - let data = json.data(using: .utf8)! 83 - let decoded = try JSONDecoder().decode(AtErrorType.self, from: data) 84 - #expect(decoded == expected) 85 - } 86 - } 87 - 88 - // MARK: - AtError Tests 89 - 90 - @Test("AtError.requiresReauthentication identifies auth errors") 91 - func testRequiresReauthentication() { 92 - let expiredTokenError = AtError.message(ErrorMessage(error: "ExpiredToken", message: nil)) 93 - #expect(expiredTokenError.requiresReauthentication == true) 94 - 95 - let authRequiredError = AtError.message(ErrorMessage(error: "AuthenticationRequired", message: nil)) 96 - #expect(authRequiredError.requiresReauthentication == true) 97 - 98 - let notFoundError = AtError.message(ErrorMessage(error: "NotFound", message: nil)) 99 - #expect(notFoundError.requiresReauthentication == false) 100 - 101 - let unauthorized = AtError.network(NetworkError.statusCode(.unauthorized, data: Data())) 102 - #expect(unauthorized.requiresReauthentication == true) 103 - 104 - let serverError = AtError.network(NetworkError.statusCode(.internalServerError, data: Data())) 105 - #expect(serverError.requiresReauthentication == false) 106 - } 107 - 108 - @Test("AtError.isRetryable identifies retryable errors") 109 - func testIsRetryable() { 110 - let rateLimitError = AtError.message(ErrorMessage(error: "RateLimitExceeded", message: nil)) 111 - #expect(rateLimitError.isRetryable == true) 112 - 113 - let serverError = AtError.network(NetworkError.statusCode(.internalServerError, data: Data())) 114 - #expect(serverError.isRetryable == true) 115 - 116 - let badRequestError = AtError.network(NetworkError.statusCode(.badRequest, data: Data())) 117 - #expect(badRequestError.isRetryable == false) 118 - 119 - let notFoundError = AtError.message(ErrorMessage(error: "NotFound", message: nil)) 120 - #expect(notFoundError.isRetryable == false) 121 - } 122 - 123 - // MARK: - RateLimitInfo Tests 124 - 125 - @Test("RateLimitInfo parses from headers") 126 - func testRateLimitParsing() { 127 - // Create a mock response with rate limit headers 128 - let url = URL(string: "https://example.com")! 129 - let headers = [ 130 - "RateLimit-Limit": "100", 131 - "RateLimit-Remaining": "50", 132 - "RateLimit-Reset": "1704067200" 133 - ] 134 - 135 - let response = HTTPURLResponse( 136 - url: url, 137 - statusCode: 200, 138 - httpVersion: nil, 139 - headerFields: headers 140 - )! 141 - 142 - let rateLimitInfo = RateLimitInfo.from(response: response) 143 - 144 - #expect(rateLimitInfo != nil) 145 - #expect(rateLimitInfo?.limit == 100) 146 - #expect(rateLimitInfo?.remaining == 50) 147 - #expect(rateLimitInfo?.resetTimestamp == 1704067200) 148 - } 149 - 150 - @Test("RateLimitInfo returns nil for missing headers") 151 - func testRateLimitMissingHeaders() { 152 - let url = URL(string: "https://example.com")! 153 - let response = HTTPURLResponse( 154 - url: url, 155 - statusCode: 200, 156 - httpVersion: nil, 157 - headerFields: [:] 158 - )! 159 - 160 - let rateLimitInfo = RateLimitInfo.from(response: response) 161 - #expect(rateLimitInfo == nil) 162 - } 163 - 164 - @Test("RateLimitInfo calculates time until reset") 165 - func testTimeUntilReset() { 166 - let futureReset = Date().timeIntervalSince1970 + 300 // 5 minutes from now 167 - let info = RateLimitInfo(limit: 100, remaining: 0, resetTimestamp: futureReset) 168 - 169 - #expect(info.timeUntilReset > 0) 170 - #expect(info.timeUntilReset <= 300) 171 - } 172 - 173 - // MARK: - OAuthError Tests 174 - 175 - @Test("OAuthError has localized descriptions") 176 - func testOAuthErrorDescriptions() { 177 - let errors: [OAuthError] = [ 178 - .accessTokenExpired, 179 - .refreshTokenMissing, 180 - .dpopRequired, 181 - .storageFailed(reason: "Test reason"), 182 - .subjectMismatch(expected: "did:plc:a", received: "did:plc:b") 183 - ] 184 - 185 - for error in errors { 186 - #expect(error.errorDescription != nil, "\(error) should have a description") 187 - #expect(!error.errorDescription!.isEmpty) 188 - } 189 - } 190 - }
···
-178
Tests/CoreATProtocolTests/ClientMetadataTests.swift
··· 1 - // 2 - // ClientMetadataTests.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Testing 9 - import Foundation 10 - @testable import CoreATProtocol 11 - 12 - @Suite("Client Metadata Tests") 13 - struct ClientMetadataTests { 14 - 15 - @Test("Creates valid public client metadata") 16 - func testPublicClientMetadata() throws { 17 - let metadata = ATClientMetadata( 18 - clientId: "https://example.com/client-metadata.json", 19 - redirectUri: "com.example.app://oauth/callback", 20 - clientName: "My AT Proto App" 21 - ) 22 - 23 - #expect(metadata.clientId == "https://example.com/client-metadata.json") 24 - #expect(metadata.applicationType == .native) 25 - #expect(metadata.tokenEndpointAuthMethod == "none") 26 - #expect(metadata.dpopBoundAccessTokens == true) 27 - #expect(metadata.grantTypes.contains("authorization_code")) 28 - #expect(metadata.grantTypes.contains("refresh_token")) 29 - #expect(metadata.responseTypes.contains("code")) 30 - #expect(metadata.scope.contains("atproto")) 31 - } 32 - 33 - @Test("Creates valid confidential client metadata") 34 - func testConfidentialClientMetadata() throws { 35 - let metadata = ATClientMetadata( 36 - clientId: "https://webapp.example.com/client-metadata.json", 37 - redirectUri: "https://webapp.example.com/oauth/callback", 38 - clientName: "My Web App", 39 - jwksUri: "https://webapp.example.com/.well-known/jwks.json" 40 - ) 41 - 42 - #expect(metadata.applicationType == .web) 43 - #expect(metadata.tokenEndpointAuthMethod == "private_key_jwt") 44 - #expect(metadata.jwksUri == "https://webapp.example.com/.well-known/jwks.json") 45 - } 46 - 47 - @Test("Metadata validates HTTPS requirement") 48 - func testHTTPSValidation() { 49 - let invalidMetadata = ATClientMetadata( 50 - clientId: "http://insecure.example.com/metadata.json", 51 - redirectUri: "com.example.app://callback", 52 - clientName: "Insecure App" 53 - ) 54 - 55 - #expect(throws: OAuthError.self) { 56 - try invalidMetadata.validate() 57 - } 58 - } 59 - 60 - @Test("Metadata allows localhost for development") 61 - func testLocalhostAllowed() throws { 62 - let metadata = ATClientMetadata( 63 - clientId: "http://localhost/client-metadata.json", 64 - redirectUri: "http://127.0.0.1/callback", 65 - clientName: "Dev App" 66 - ) 67 - 68 - // Should not throw 69 - try metadata.validate() 70 - } 71 - 72 - @Test("Metadata validates atproto scope requirement") 73 - func testScopeValidation() { 74 - // Create metadata with custom scope missing atproto 75 - let json = """ 76 - { 77 - "client_id": "https://example.com/metadata.json", 78 - "application_type": "native", 79 - "grant_types": ["authorization_code", "refresh_token"], 80 - "scope": "openid profile", 81 - "response_types": ["code"], 82 - "redirect_uris": ["com.example://callback"], 83 - "dpop_bound_access_tokens": true, 84 - "token_endpoint_auth_method": "none" 85 - } 86 - """.data(using: .utf8)! 87 - 88 - do { 89 - let metadata = try JSONDecoder().decode(ATClientMetadata.self, from: json) 90 - 91 - #expect(throws: OAuthError.self) { 92 - try metadata.validate() 93 - } 94 - } catch { 95 - Issue.record("Failed to decode metadata: \(error)") 96 - } 97 - } 98 - 99 - @Test("Metadata validates DPoP requirement") 100 - func testDPoPValidation() { 101 - let json = """ 102 - { 103 - "client_id": "https://example.com/metadata.json", 104 - "application_type": "native", 105 - "grant_types": ["authorization_code", "refresh_token"], 106 - "scope": "atproto", 107 - "response_types": ["code"], 108 - "redirect_uris": ["com.example://callback"], 109 - "dpop_bound_access_tokens": false, 110 - "token_endpoint_auth_method": "none" 111 - } 112 - """.data(using: .utf8)! 113 - 114 - do { 115 - let metadata = try JSONDecoder().decode(ATClientMetadata.self, from: json) 116 - 117 - #expect(throws: OAuthError.self) { 118 - try metadata.validate() 119 - } 120 - } catch { 121 - Issue.record("Failed to decode metadata: \(error)") 122 - } 123 - } 124 - 125 - @Test("Metadata encodes to valid JSON") 126 - func testJSONEncoding() throws { 127 - let metadata = ATClientMetadata( 128 - clientId: "https://myapp.example.com/client-metadata.json", 129 - redirectUri: "com.myapp://oauth", 130 - clientName: "My App", 131 - scope: "atproto transition:generic", 132 - logoUri: "https://myapp.example.com/logo.png", 133 - clientUri: "https://myapp.example.com", 134 - tosUri: "https://myapp.example.com/tos", 135 - policyUri: "https://myapp.example.com/privacy" 136 - ) 137 - 138 - let jsonString = try metadata.toJSONString() 139 - 140 - // Verify it's valid JSON by parsing it 141 - let data = jsonString.data(using: .utf8)! 142 - let parsed = try JSONDecoder().decode(ATClientMetadata.self, from: data) 143 - 144 - #expect(parsed.clientId == metadata.clientId) 145 - #expect(parsed.clientName == metadata.clientName) 146 - #expect(parsed.logoUri == metadata.logoUri) 147 - } 148 - 149 - @Test("JWK creates ES256 public key correctly") 150 - func testJWKCreation() { 151 - let jwk = JWK.es256PublicKey( 152 - x: "base64url-x-coordinate", 153 - y: "base64url-y-coordinate", 154 - kid: "key-1" 155 - ) 156 - 157 - #expect(jwk.kty == "EC") 158 - #expect(jwk.crv == "P-256") 159 - #expect(jwk.alg == "ES256") 160 - #expect(jwk.use == "sig") 161 - #expect(jwk.kid == "key-1") 162 - } 163 - 164 - @Test("JWKSet encodes correctly") 165 - func testJWKSetEncoding() throws { 166 - let jwkSet = JWKSet(keys: [ 167 - JWK.es256PublicKey(x: "x1", y: "y1", kid: "key-1"), 168 - JWK.es256PublicKey(x: "x2", y: "y2", kid: "key-2") 169 - ]) 170 - 171 - let encoded = try JSONEncoder().encode(jwkSet) 172 - let decoded = try JSONDecoder().decode(JWKSet.self, from: encoded) 173 - 174 - #expect(decoded.keys.count == 2) 175 - #expect(decoded.keys[0].kid == "key-1") 176 - #expect(decoded.keys[1].kid == "key-2") 177 - } 178 - }
···
+2 -87
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
··· 1 import Testing 2 - import Foundation 3 @testable import CoreATProtocol 4 5 - @Suite("CoreATProtocol Environment Tests", .serialized) 6 - struct CoreATProtocolTests { 7 - 8 - @Test("Environment singleton is accessible") 9 - func testEnvironmentSingleton() async { 10 - // Clear state first 11 - await clearAuthenticationContext() 12 - 13 - // Just verify we can access the singleton 14 - let host = await APEnvironment.current.host 15 - #expect(host == nil) // Default state should have nil host 16 - } 17 - 18 - @Test("Setup configures environment correctly") 19 - func testSetup() async { 20 - // Clear previous state 21 - await clearAuthenticationContext() 22 - 23 - await setup( 24 - hostURL: "https://bsky.social", 25 - accessJWT: "test-access", 26 - refreshJWT: "test-refresh" 27 - ) 28 - 29 - let host = await APEnvironment.current.host 30 - let access = await APEnvironment.current.accessToken 31 - let refresh = await APEnvironment.current.refreshToken 32 - 33 - #expect(host == "https://bsky.social") 34 - #expect(access == "test-access") 35 - #expect(refresh == "test-refresh") 36 - 37 - // Clean up 38 - await clearAuthenticationContext() 39 - } 40 - 41 - @Test("Clear authentication context removes all tokens") 42 - func testClearContext() async { 43 - await setup( 44 - hostURL: "https://test.social", 45 - accessJWT: "access", 46 - refreshJWT: "refresh" 47 - ) 48 - 49 - await clearAuthenticationContext() 50 - 51 - let access = await APEnvironment.current.accessToken 52 - let refresh = await APEnvironment.current.refreshToken 53 - let login = await APEnvironment.current.login 54 - 55 - #expect(access == nil) 56 - #expect(refresh == nil) 57 - #expect(login == nil) 58 - } 59 - 60 - @Test("Update tokens modifies existing tokens") 61 - func testUpdateTokens() async { 62 - await setup(hostURL: nil, accessJWT: "old-access", refreshJWT: "old-refresh") 63 - await updateTokens(access: "new-access", refresh: "new-refresh") 64 - 65 - let access = await APEnvironment.current.accessToken 66 - let refresh = await APEnvironment.current.refreshToken 67 - 68 - #expect(access == "new-access") 69 - #expect(refresh == "new-refresh") 70 - 71 - await clearAuthenticationContext() 72 - } 73 - 74 - @Test("DPoP nonce update works correctly") 75 - func testDPoPNonceUpdate() async { 76 - await updateResourceDPoPNonce("test-nonce-123") 77 - 78 - let nonce = await APEnvironment.current.resourceServerNonce 79 - 80 - #expect(nonce == "test-nonce-123") 81 - 82 - await updateResourceDPoPNonce(nil) 83 - } 84 - 85 - @Test("hasValidSession returns false when no session") 86 - func testNoValidSession() async { 87 - await clearAuthenticationContext() 88 - let valid = await hasValidSession 89 - #expect(valid == false) 90 - } 91 }
··· 1 import Testing 2 @testable import CoreATProtocol 3 4 + @Test func example() async throws { 5 + // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 }
-51
Tests/CoreATProtocolTests/DPoPJWTGeneratorTests.swift
··· 1 - // 2 - // DPoPJWTGeneratorTests.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Testing 9 - import Foundation 10 - import JWTKit 11 - @testable import CoreATProtocol 12 - 13 - @Suite("DPoP JWT Generator Tests", .serialized) 14 - struct DPoPJWTGeneratorTests { 15 - 16 - @Test("DPoP JWT Generator can be created with ES256 key") 17 - func testGeneratorCreation() async throws { 18 - let privateKey = ES256PrivateKey() 19 - let generator = try await DPoPJWTGenerator(privateKey: privateKey) 20 - 21 - // Verify we can get a JWT generator function 22 - _ = await generator.jwtGenerator() 23 - // If we get here without throwing, the test passes 24 - } 25 - 26 - @Test("DPoPKeyMaterialError cases exist") 27 - func testKeyMaterialErrors() { 28 - // Test error cases exist and are equatable 29 - let error1 = DPoPKeyMaterialError.publicKeyUnavailable 30 - let error2 = DPoPKeyMaterialError.invalidCoordinate 31 - 32 - #expect(error1 != error2) 33 - #expect(error1 == DPoPKeyMaterialError.publicKeyUnavailable) 34 - } 35 - 36 - @Test("Resource server nonce can be updated") 37 - func testResourceServerNonce() async { 38 - // Clear state first 39 - await updateResourceDPoPNonce(nil) 40 - 41 - // Set nonce using the public function 42 - await updateResourceDPoPNonce("test-nonce-value") 43 - let nonce = await APEnvironment.current.resourceServerNonce 44 - #expect(nonce == "test-nonce-value") 45 - 46 - // Clear it 47 - await updateResourceDPoPNonce(nil) 48 - let clearedNonce = await APEnvironment.current.resourceServerNonce 49 - #expect(clearedNonce == nil) 50 - } 51 - }
···
-119
Tests/CoreATProtocolTests/DateDecodingTests.swift
··· 1 - // 2 - // DateDecodingTests.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Testing 9 - import Foundation 10 - @testable import CoreATProtocol 11 - 12 - @Suite("Date Decoding Tests") 13 - struct DateDecodingTests { 14 - 15 - struct DateContainer: Decodable { 16 - let date: Date 17 - } 18 - 19 - @Test("Decodes ISO 8601 with milliseconds and Z timezone") 20 - func testMillisecondsWithZ() throws { 21 - let json = """ 22 - {"date": "2024-01-15T10:30:00.123Z"} 23 - """.data(using: .utf8)! 24 - 25 - let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 26 - 27 - let calendar = Calendar(identifier: .gregorian) 28 - let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date) 29 - 30 - #expect(components.year == 2024) 31 - #expect(components.month == 1) 32 - #expect(components.day == 15) 33 - #expect(components.hour == 10) 34 - #expect(components.minute == 30) 35 - } 36 - 37 - @Test("Decodes ISO 8601 with offset timezone") 38 - func testWithOffset() throws { 39 - let json = """ 40 - {"date": "2024-06-20T15:45:30.000+00:00"} 41 - """.data(using: .utf8)! 42 - 43 - let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 44 - 45 - let calendar = Calendar(identifier: .gregorian) 46 - let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date) 47 - 48 - #expect(components.year == 2024) 49 - #expect(components.month == 6) 50 - #expect(components.day == 20) 51 - #expect(components.hour == 15) 52 - #expect(components.minute == 45) 53 - } 54 - 55 - @Test("Decodes ISO 8601 without fractional seconds") 56 - func testWithoutFractional() throws { 57 - let json = """ 58 - {"date": "2024-03-10T08:00:00Z"} 59 - """.data(using: .utf8)! 60 - 61 - let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 62 - 63 - let calendar = Calendar(identifier: .gregorian) 64 - let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date) 65 - 66 - #expect(components.year == 2024) 67 - #expect(components.month == 3) 68 - #expect(components.day == 10) 69 - } 70 - 71 - @Test("Decodes microseconds precision") 72 - func testMicroseconds() throws { 73 - let json = """ 74 - {"date": "2024-12-25T12:00:00.123456+00:00"} 75 - """.data(using: .utf8)! 76 - 77 - let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 78 - 79 - // Just verify it parses without error 80 - #expect(container.date != Date.distantPast) 81 - } 82 - 83 - @Test("Multiple date formats in same response") 84 - func testMultipleFormats() throws { 85 - struct MultipleDates: Decodable { 86 - let createdAt: Date 87 - let indexedAt: Date 88 - let updatedAt: Date 89 - } 90 - 91 - let json = """ 92 - { 93 - "createdAt": "2024-01-01T00:00:00.000Z", 94 - "indexedAt": "2024-01-01T00:00:00Z", 95 - "updatedAt": "2024-01-01T00:00:00.000+00:00" 96 - } 97 - """.data(using: .utf8)! 98 - 99 - let dates = try JSONDecoder.atDecoder.decode(MultipleDates.self, from: json) 100 - 101 - // All should parse to the same time (within a small margin) 102 - let interval1 = abs(dates.createdAt.timeIntervalSince(dates.indexedAt)) 103 - let interval2 = abs(dates.createdAt.timeIntervalSince(dates.updatedAt)) 104 - 105 - #expect(interval1 < 1, "Dates should be within 1 second of each other") 106 - #expect(interval2 < 1, "Dates should be within 1 second of each other") 107 - } 108 - 109 - @Test("Throws on invalid date format") 110 - func testInvalidFormat() { 111 - let json = """ 112 - {"date": "not-a-date"} 113 - """.data(using: .utf8)! 114 - 115 - #expect(throws: DecodingError.self) { 116 - _ = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 117 - } 118 - } 119 - }
···
+37 -150
Tests/CoreATProtocolTests/IdentityResolverTests.swift
··· 1 - // 2 - // IdentityResolverTests.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 import Testing 9 - import Foundation 10 @testable import CoreATProtocol 11 12 - @Suite("Identity Resolver Tests") 13 - struct IdentityResolverTests { 14 - 15 - // MARK: - DID Document Tests 16 17 - @Test("DID Document parses correctly") 18 - func testDIDDocumentParsing() throws { 19 - let json = """ 20 - { 21 - "@context": ["https://www.w3.org/ns/did/v1"], 22 - "id": "did:plc:abc123", 23 - "alsoKnownAs": ["at://alice.bsky.social"], 24 - "verificationMethod": [{ 25 - "id": "#atproto", 26 - "type": "Multikey", 27 - "controller": "did:plc:abc123", 28 - "publicKeyMultibase": "zDnae..." 29 - }], 30 - "service": [{ 31 - "id": "#atproto_pds", 32 - "type": "AtprotoPersonalDataServer", 33 - "serviceEndpoint": "https://bsky.social" 34 - }] 35 - } 36 - """.data(using: .utf8)! 37 - 38 - let document = try JSONDecoder().decode(DIDDocument.self, from: json) 39 - 40 - #expect(document.id == "did:plc:abc123") 41 - #expect(document.handle == "alice.bsky.social") 42 - #expect(document.pdsEndpoint == "https://bsky.social") 43 - #expect(document.verificationMethod?.count == 1) 44 - #expect(document.service?.count == 1) 45 } 46 47 - @Test("DID Document extracts handle from alsoKnownAs") 48 - func testHandleExtraction() throws { 49 - let document = DIDDocument( 50 - id: "did:plc:test", 51 - alsoKnownAs: ["at://user.example.com", "https://other.url"], 52 - verificationMethod: nil, 53 - service: nil 54 - ) 55 - 56 - #expect(document.handle == "user.example.com") 57 } 58 59 - @Test("DID Document returns nil handle when missing") 60 - func testMissingHandle() throws { 61 - let document = DIDDocument( 62 - id: "did:plc:test", 63 - alsoKnownAs: nil, 64 - verificationMethod: nil, 65 - service: nil 66 - ) 67 68 - #expect(document.handle == nil) 69 - } 70 - 71 - @Test("PLC Directory response converts to DID Document") 72 - func testPLCResponseConversion() throws { 73 - let json = """ 74 { 75 - "did": "did:plc:xyz789", 76 - "alsoKnownAs": ["at://bob.test.com"], 77 - "verificationMethods": { 78 - "#atproto": "did:key:zDnae..." 79 - }, 80 - "services": { 81 - "#atproto_pds": { 82 - "type": "AtprotoPersonalDataServer", 83 - "endpoint": "https://pds.example.com" 84 - } 85 - } 86 } 87 - """.data(using: .utf8)! 88 - 89 - let plcResponse = try JSONDecoder().decode(PLCDirectoryResponse.self, from: json) 90 - let document = plcResponse.toDIDDocument() 91 - 92 - #expect(document.id == "did:plc:xyz789") 93 - #expect(document.alsoKnownAs?.contains("at://bob.test.com") == true) 94 } 95 96 - // MARK: - Identity Error Tests 97 98 - @Test("Identity errors are properly typed") 99 - func testIdentityErrors() { 100 - let handleError = IdentityError.invalidHandle("bad handle") 101 - let pdsError = IdentityError.pdsNotFound 102 103 - // Test error descriptions 104 - #expect(String(describing: handleError).contains("invalidHandle")) 105 - #expect(String(describing: pdsError).contains("pdsNotFound")) 106 - } 107 - 108 - // MARK: - Handle Validation Tests 109 - 110 - @Test("Valid handles are accepted") 111 - func testValidHandles() async throws { 112 - // These should be valid handle formats 113 - let validHandles = [ 114 - "alice.bsky.social", 115 - "user.example.com", 116 - "test.subdomain.domain.tld" 117 - ] 118 - 119 - for handle in validHandles { 120 - // Just testing the format is accepted 121 - let normalized = handle.lowercased() 122 - #expect(normalized.contains("."), "\(handle) should contain a dot") 123 - } 124 - } 125 - 126 - // MARK: - Cache Tests 127 - 128 - @Test("Cache is cleared correctly") 129 - func testCacheClear() async { 130 - let resolver = await IdentityResolver() 131 - await resolver.clearCache() 132 - // Should not throw 133 - } 134 - 135 - @Test("Cache TTL is configurable") 136 - func testCacheTTL() async { 137 - let resolver = await IdentityResolver() 138 - let defaultTTL = await resolver.cacheTTL 139 - #expect(defaultTTL == 600, "Default cache TTL should be 600 seconds") 140 - 141 - await MainActor.run { 142 - // Note: Need to access through proper isolation 143 - } 144 - } 145 - 146 - // MARK: - Protected Resource Metadata Tests 147 - 148 - @Test("Protected resource metadata parses correctly") 149 - func testProtectedResourceMetadata() throws { 150 - let json = """ 151 - { 152 - "resource": "https://bsky.social", 153 - "authorization_servers": ["https://bsky.social"] 154 - } 155 - """.data(using: .utf8)! 156 - 157 - let metadata = try JSONDecoder().decode( 158 - IdentityResolver.ProtectedResourceMetadata.self, 159 - from: json 160 - ) 161 - 162 - #expect(metadata.resource == "https://bsky.social") 163 - #expect(metadata.authorizationServers.count == 1) 164 - #expect(metadata.authorizationServers.first == "https://bsky.social") 165 - } 166 }
··· 1 + import Foundation 2 import Testing 3 @testable import CoreATProtocol 4 5 + @APActor 6 + final class MockNetworking: Networking { 7 + var requestedURLs: [URL] = [] 8 + var responseData: Data 9 + var statusCode: Int 10 11 + init(responseData: Data, statusCode: Int = 200) { 12 + self.responseData = responseData 13 + self.statusCode = statusCode 14 } 15 16 + func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { 17 + if let url = request.url { 18 + requestedURLs.append(url) 19 + } 20 + let response = HTTPURLResponse(url: request.url ?? URL(string: "https://example.com")!, statusCode: statusCode, httpVersion: nil, headerFields: [:])! 21 + return (responseData, response) 22 } 23 + } 24 25 + struct MockDNSResolver: DNSResolving { 26 + func txtRecords(for host: String) async throws -> [String] { [] } 27 + } 28 29 + @Test("Identity resolver fetches PLC DID documents using full identifier path") 30 + func identityResolverUsesFullPLCPath() async throws { 31 + let documentJSON = """ 32 + { 33 + "id": "did:plc:identifier", 34 + "service": [ 35 { 36 + "id": "#atproto_pds", 37 + "type": "AtprotoPersonalDataServer", 38 + "serviceEndpoint": "https://example.com" 39 } 40 + ] 41 } 42 + """.data(using: .utf8)! 43 44 + let networking = await MockNetworking(responseData: documentJSON) 45 + let httpClient = await OAuthHTTPClient(networking: networking) 46 + let resolver = await IdentityResolver(httpClient: httpClient, dnsResolver: MockDNSResolver()) 47 48 + let document = try await resolver.fetchDIDDocument(for: "did:plc:identifier") 49 + #expect(document.id == "did:plc:identifier") 50 51 + let requestedPath = await networking.requestedURLs.first?.path 52 + #expect(requestedPath == "/did:plc:identifier") 53 }
+37
Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift
···
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Test("Client metadata decodes from sample JSON") 6 + func decodeClientMetadata() throws { 7 + let json = """ 8 + { 9 + "client_id": "https://sparrowtek.com/plume.json", 10 + "client_name": "Plume iOS", 11 + "application_type": "native", 12 + "grant_types": [ 13 + "authorization_code", 14 + "refresh_token" 15 + ], 16 + "scope": "atproto", 17 + "response_types": [ 18 + "code" 19 + ], 20 + "redirect_uris": [ 21 + "com.sparrowtek.plume:/oauth/callback" 22 + ], 23 + "token_endpoint_auth_method": "none", 24 + "dpop_bound_access_tokens": true, 25 + "client_uri": "https://sparrowtek.com", 26 + "policy_uri": "https://sparrowtek.com/privacy", 27 + "tos_uri": "https://sparrowtek.com/terms" 28 + } 29 + """.data(using: .utf8)! 30 + 31 + let decoder = JSONDecoder() 32 + decoder.keyDecodingStrategy = .convertFromSnakeCase 33 + let metadata = try decoder.decode(OAuthClientMetadata.self, from: json) 34 + #expect(metadata.clientID.absoluteString == "https://sparrowtek.com/plume.json") 35 + #expect(metadata.redirectURIs.count == 1) 36 + #expect(metadata.dPoPBoundAccessTokens) 37 + }
+15
Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift
···
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Test("Authorization server metadata decodes from sample JSON") 6 + func decodeAuthorizationServerMetadata() throws { 7 + let json = """ 8 + {"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","introspection_endpoint":"https://bsky.social/oauth/introspect","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true} 9 + """.data(using: .utf8)! 10 + 11 + let decoder = JSONDecoder() 12 + decoder.keyDecodingStrategy = .convertFromSnakeCase 13 + let metadata = try decoder.decode(OAuthAuthorizationServerMetadata.self, from: json) 14 + #expect(metadata.authorizationEndpoint.absoluteString == "https://bsky.social/oauth/authorize") 15 + }
+97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
···
··· 1 + import CryptoKit 2 + import Foundation 3 + import Testing 4 + @testable import CoreATProtocol 5 + 6 + private struct DeterministicRandomGenerator: RandomDataGenerating { 7 + func data(count: Int) throws -> Data { 8 + Data(repeating: 0x42, count: count) 9 + } 10 + } 11 + 12 + @Test("Base64URL encodes without padding and decodes back") 13 + func base64URLRoundTrip() throws { 14 + let data = Data([0xde, 0xad, 0xbe, 0xef]) 15 + let encoded = Base64URL.encode(data) 16 + #expect(encoded.contains("=") == false) 17 + let decoded = try Base64URL.decode(encoded) 18 + #expect(decoded == data) 19 + } 20 + 21 + @Test("PKCE generator creates verifier within bounds and matching challenge") 22 + func pkceGeneratorProducesExpectedValues() throws { 23 + let generator = PKCEGenerator(randomGenerator: DeterministicRandomGenerator()) 24 + let values = try generator.makeValues() 25 + #expect(values.verifier.count >= 43) 26 + #expect(values.verifier.count <= 128) 27 + 28 + let expectedDigest = SHA256.hash(data: Data(values.verifier.utf8)) 29 + let expectedChallenge = Base64URL.encode(Data(expectedDigest)) 30 + #expect(values.challenge == expectedChallenge) 31 + } 32 + 33 + @Test("DPoP generator signs payload with expected claims") 34 + func dpopGeneratorProducesValidProof() async throws { 35 + let keyPair = DPoPKeyPair() 36 + let generator = await DPoPGenerator(clock: { Date(timeIntervalSince1970: 1_700_000_000) }) 37 + try await generator.updateKey(using: keyPair.export()) 38 + let url = URL(string: "https://example.com/resource")! 39 + let proof = try await generator.generateProof( 40 + method: "GET", 41 + url: url, 42 + nonce: "nonce-value", 43 + accessToken: "access-token" 44 + ) 45 + 46 + let components = proof.split(separator: ".") 47 + #expect(components.count == 3) 48 + 49 + let headerData = try Base64URL.decode(String(components[0])) 50 + let payloadData = try Base64URL.decode(String(components[1])) 51 + let signatureData = try Base64URL.decode(String(components[2])) 52 + 53 + let header = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] 54 + let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] 55 + 56 + #expect(header?["typ"] as? String == "dpop+jwt") 57 + #expect(header?["alg"] as? String == "ES256") 58 + let jwk = header?["jwk"] as? [String: String] 59 + #expect(jwk?["kty"] == "EC") 60 + #expect(jwk?["crv"] == "P-256") 61 + 62 + #expect(payload?["htm"] as? String == "GET") 63 + #expect(payload?["htu"] as? String == "https://example.com/resource") 64 + #expect(payload?["nonce"] as? String == "nonce-value") 65 + #expect(payload?["ath"] as? String == Base64URL.encode(Data(SHA256.hash(data: Data("access-token".utf8))))) 66 + 67 + if let iat = payload?["iat"] as? Int { 68 + #expect(iat == 1_700_000_000) 69 + } else { 70 + Issue.record("DPoP payload missing iat") 71 + } 72 + 73 + let signingInput = Data((components[0] + "." + components[1]).utf8) 74 + let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData) 75 + #expect(keyPair.privateKey.publicKey.isValidSignature(signature, for: signingInput)) 76 + } 77 + 78 + @Test("OAuth session refresh heuristics") 79 + func oauthSessionRefreshLogic() { 80 + let issuedAt = Date() 81 + let session = OAuthSession( 82 + did: "did:plc:example", 83 + pdsURL: URL(string: "https://pds.example.com")!, 84 + authorizationServer: URL(string: "https://auth.example.com")!, 85 + tokenEndpoint: URL(string: "https://auth.example.com/token")!, 86 + accessToken: "token", 87 + refreshToken: "refresh", 88 + tokenType: "DPoP", 89 + scope: "atproto", 90 + expiresIn: 3600, 91 + issuedAt: issuedAt 92 + ) 93 + 94 + #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3500)) == false) 95 + #expect(session.needsRefresh(relativeTo: issuedAt.addingTimeInterval(3300), threshold: 400)) 96 + #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3600))) 97 + }
-236
Tests/CoreATProtocolTests/TokenStorageTests.swift
··· 1 - // 2 - // TokenStorageTests.swift 3 - // CoreATProtocol 4 - // 5 - // Created by Claude on 2026-01-02. 6 - // 7 - 8 - import Testing 9 - import Foundation 10 - @testable import CoreATProtocol 11 - 12 - @Suite("Token Storage Tests") 13 - struct TokenStorageTests { 14 - 15 - // MARK: - AuthenticationState Tests 16 - 17 - @Test("AuthenticationState initializes correctly") 18 - func testAuthStateInit() { 19 - let state = AuthenticationState( 20 - did: "did:plc:test123", 21 - handle: "test.bsky.social", 22 - pdsURL: "https://bsky.social", 23 - authServerURL: "https://bsky.social", 24 - accessToken: "access-token-value", 25 - accessTokenExpiry: Date().addingTimeInterval(3600), 26 - refreshToken: "refresh-token-value", 27 - scope: "atproto transition:generic", 28 - dpopPrivateKeyData: nil 29 - ) 30 - 31 - #expect(state.did == "did:plc:test123") 32 - #expect(state.handle == "test.bsky.social") 33 - #expect(state.accessToken == "access-token-value") 34 - #expect(state.refreshToken == "refresh-token-value") 35 - } 36 - 37 - @Test("AuthenticationState detects expired access token") 38 - func testAccessTokenExpiry() { 39 - let expiredState = AuthenticationState( 40 - did: "did:plc:test", 41 - handle: nil, 42 - pdsURL: "https://pds.example.com", 43 - authServerURL: "https://auth.example.com", 44 - accessToken: "expired", 45 - accessTokenExpiry: Date().addingTimeInterval(-100), // Already expired 46 - refreshToken: nil, 47 - scope: nil, 48 - dpopPrivateKeyData: nil 49 - ) 50 - 51 - #expect(expiredState.isAccessTokenExpired == true) 52 - 53 - let validState = AuthenticationState( 54 - did: "did:plc:test", 55 - handle: nil, 56 - pdsURL: "https://pds.example.com", 57 - authServerURL: "https://auth.example.com", 58 - accessToken: "valid", 59 - accessTokenExpiry: Date().addingTimeInterval(3600), // Valid for 1 hour 60 - refreshToken: nil, 61 - scope: nil, 62 - dpopPrivateKeyData: nil 63 - ) 64 - 65 - #expect(validState.isAccessTokenExpired == false) 66 - } 67 - 68 - @Test("AuthenticationState.canRefresh checks refresh token") 69 - func testCanRefresh() { 70 - let withRefresh = AuthenticationState( 71 - did: "did:plc:test", 72 - handle: nil, 73 - pdsURL: "https://pds.example.com", 74 - authServerURL: "https://auth.example.com", 75 - accessToken: "access", 76 - accessTokenExpiry: nil, 77 - refreshToken: "refresh-token", 78 - refreshTokenExpiry: Date().addingTimeInterval(86400), // Valid for 1 day 79 - scope: nil, 80 - dpopPrivateKeyData: nil 81 - ) 82 - 83 - #expect(withRefresh.canRefresh == true) 84 - 85 - let withoutRefresh = AuthenticationState( 86 - did: "did:plc:test", 87 - handle: nil, 88 - pdsURL: "https://pds.example.com", 89 - authServerURL: "https://auth.example.com", 90 - accessToken: "access", 91 - accessTokenExpiry: nil, 92 - refreshToken: nil, 93 - scope: nil, 94 - dpopPrivateKeyData: nil 95 - ) 96 - 97 - #expect(withoutRefresh.canRefresh == false) 98 - } 99 - 100 - @Test("AuthenticationState updates tokens correctly") 101 - func testUpdateTokens() { 102 - let original = AuthenticationState( 103 - did: "did:plc:test", 104 - handle: "test.user", 105 - pdsURL: "https://pds.example.com", 106 - authServerURL: "https://auth.example.com", 107 - accessToken: "old-access", 108 - accessTokenExpiry: Date(), 109 - refreshToken: "old-refresh", 110 - scope: "atproto", 111 - dpopPrivateKeyData: nil 112 - ) 113 - 114 - let updated = original.withUpdatedTokens( 115 - access: "new-access", 116 - refresh: "new-refresh", 117 - expiresIn: 1800 118 - ) 119 - 120 - #expect(updated.accessToken == "new-access") 121 - #expect(updated.refreshToken == "new-refresh") 122 - #expect(updated.did == original.did) // DID should not change 123 - #expect(updated.handle == original.handle) // Handle should not change 124 - #expect(updated.updatedAt > original.updatedAt) 125 - } 126 - 127 - // MARK: - InMemoryTokenStorage Tests 128 - 129 - @Test("InMemoryTokenStorage stores and retrieves") 130 - func testInMemoryStorage() async throws { 131 - let storage = await InMemoryTokenStorage() 132 - 133 - let state = AuthenticationState( 134 - did: "did:plc:memory", 135 - handle: "memory.test", 136 - pdsURL: "https://pds.example.com", 137 - authServerURL: "https://auth.example.com", 138 - accessToken: "memory-token", 139 - accessTokenExpiry: Date().addingTimeInterval(3600), 140 - refreshToken: "memory-refresh", 141 - scope: "atproto", 142 - dpopPrivateKeyData: nil 143 - ) 144 - 145 - try await storage.store(state) 146 - let retrieved = try await storage.retrieve() 147 - 148 - #expect(retrieved != nil) 149 - #expect(retrieved?.did == "did:plc:memory") 150 - #expect(retrieved?.accessToken == "memory-token") 151 - } 152 - 153 - @Test("InMemoryTokenStorage clears correctly") 154 - func testInMemoryClear() async throws { 155 - let storage = await InMemoryTokenStorage() 156 - 157 - let state = AuthenticationState( 158 - did: "did:plc:clear", 159 - handle: nil, 160 - pdsURL: "https://pds.example.com", 161 - authServerURL: "https://auth.example.com", 162 - accessToken: "token", 163 - accessTokenExpiry: nil, 164 - refreshToken: nil, 165 - scope: nil, 166 - dpopPrivateKeyData: nil 167 - ) 168 - 169 - try await storage.store(state) 170 - try await storage.clear() 171 - let retrieved = try await storage.retrieve() 172 - 173 - #expect(retrieved == nil) 174 - } 175 - 176 - @Test("InMemoryTokenStorage updates tokens") 177 - func testInMemoryUpdate() async throws { 178 - let storage = await InMemoryTokenStorage() 179 - 180 - let state = AuthenticationState( 181 - did: "did:plc:update", 182 - handle: nil, 183 - pdsURL: "https://pds.example.com", 184 - authServerURL: "https://auth.example.com", 185 - accessToken: "original", 186 - accessTokenExpiry: Date(), 187 - refreshToken: "original-refresh", 188 - scope: nil, 189 - dpopPrivateKeyData: nil 190 - ) 191 - 192 - try await storage.store(state) 193 - try await storage.updateTokens(access: "updated", refresh: "updated-refresh", expiresIn: 3600) 194 - 195 - let retrieved = try await storage.retrieve() 196 - #expect(retrieved?.accessToken == "updated") 197 - #expect(retrieved?.refreshToken == "updated-refresh") 198 - } 199 - 200 - @Test("InMemoryTokenStorage throws when updating without stored state") 201 - func testInMemoryUpdateWithoutState() async { 202 - let storage = await InMemoryTokenStorage() 203 - 204 - await #expect(throws: OAuthError.self) { 205 - try await storage.updateTokens(access: "new", refresh: nil, expiresIn: 3600) 206 - } 207 - } 208 - 209 - // MARK: - AuthenticationState Codable Tests 210 - 211 - @Test("AuthenticationState encodes and decodes") 212 - func testAuthStateCodable() throws { 213 - let original = AuthenticationState( 214 - did: "did:plc:codable", 215 - handle: "codable.test", 216 - pdsURL: "https://pds.example.com", 217 - authServerURL: "https://auth.example.com", 218 - accessToken: "codable-access", 219 - accessTokenExpiry: Date().addingTimeInterval(3600), 220 - refreshToken: "codable-refresh", 221 - refreshTokenExpiry: Date().addingTimeInterval(86400), 222 - scope: "atproto transition:generic", 223 - dpopPrivateKeyData: Data([1, 2, 3, 4]) 224 - ) 225 - 226 - let encoded = try JSONEncoder().encode(original) 227 - let decoded = try JSONDecoder().decode(AuthenticationState.self, from: encoded) 228 - 229 - #expect(decoded.did == original.did) 230 - #expect(decoded.handle == original.handle) 231 - #expect(decoded.accessToken == original.accessToken) 232 - #expect(decoded.refreshToken == original.refreshToken) 233 - #expect(decoded.scope == original.scope) 234 - #expect(decoded.dpopPrivateKeyData == original.dpopPrivateKeyData) 235 - } 236 - }
···