this repo has no description
at ai_oauth 204 lines 8.1 kB view raw view rendered
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 11CoreATProtocol 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 13The 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 24Add 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 27import CoreATProtocol 28``` 29 30## Step 2: Provide a Credential Store 31 32CoreATProtocol 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 35import CoreATProtocol 36import Security 37 38actor 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 79Persist 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 83Provide an `OAuthUIProvider` that wraps `ASWebAuthenticationSession` and routes callbacks back into the manager. 84 85```swift 86import AuthenticationServices 87import CoreATProtocol 88 89final 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 110extension WebAuthenticationProvider: ASWebAuthenticationPresentationContextProviding { 111 func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 112 presentationAnchor 113 } 114} 115``` 116 117## Step 4: Configure CoreATProtocol at Launch 118 119Set 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 123func 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 136Once 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 140Trigger 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 144func 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 157For 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 161After 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 165func 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 172If 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 176When the user signs out, remove the stored session and DPoP key to enforce a clean re-authentication. 177 178```swift 179@MainActor 180func signOut() async { 181 do { 182 try await CoreATProtocol.signOutOAuth() 183 } catch { 184 assertionFailure("Failed to sign out cleanly: \(error)") 185 } 186} 187``` 188 189You 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. |