# OAuth Integration on iOS @Metadata { @Abstract( "Step-by-step instructions for adopting CoreATProtocol's bespoke OAuth implementation inside an iOS app." ) } ## Overview 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`. The sections below walk through the recommended wiring for production iOS apps. ## Prerequisites - Xcode 16 or later with Swift 6. - A Bluesky/AT Protocol OAuth client metadata document hosted at a stable HTTPS URL. - A custom URL scheme registered in your app to receive the OAuth redirect. - Familiarity with Keychain Services and Swift concurrency. ## Step 1: Configure the Package Add CoreATProtocol as a Swift Package dependency in Xcode. Ensure your iOS target links against CoreATProtocol and imports it inside the app module. ```swift import CoreATProtocol ``` ## Step 2: Provide a Credential Store 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). ```swift import CoreATProtocol import Security actor KeychainCredentialStore: OAuthCredentialStore { private enum Item { static let account = "com.sparrowtek.coreatprotocol.oauth" static let sessionKey = "session" static let dpopKey = "dpop-key" } func loadSession() async throws -> OAuthSession? { guard let data = try read(key: Item.sessionKey) else { return nil } return try JSONDecoder().decode(OAuthSession.self, from: data) } func save(session: OAuthSession) async throws { let data = try JSONEncoder().encode(session) try write(data, key: Item.sessionKey) } func deleteSession() async throws { try delete(key: Item.sessionKey) } func loadDPoPKey() async throws -> Data? { try read(key: Item.dpopKey) } func saveDPoPKey(_ data: Data) async throws { try write(data, key: Item.dpopKey) } func deleteDPoPKey() async throws { try delete(key: Item.dpopKey) } // MARK: - Helpers private func read(key: String) throws -> Data? { /* Keychain lookup */ } private func write(_ data: Data, key: String) throws { /* Keychain add/update */ } private func delete(key: String) throws { /* Keychain delete */ } } ``` Persist both the serialized `OAuthSession` and the raw DPoP private key so refreshes survive app restarts. ## Step 3: Create an OAuth UI Provider Provide an `OAuthUIProvider` that wraps `ASWebAuthenticationSession` and routes callbacks back into the manager. ```swift import AuthenticationServices import CoreATProtocol final class WebAuthenticationProvider: NSObject, OAuthUIProvider { private let presentationAnchor: ASPresentationAnchor init(anchor: ASPresentationAnchor) { self.presentationAnchor = anchor } func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL { try await withCheckedThrowingContinuation { continuation in let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in if let error { continuation.resume(throwing: error) } else if let callbackURL { continuation.resume(returning: callbackURL) } else { continuation.resume(throwing: OAuthManagerError.authorizationCancelled) } } session.presentationContextProvider = self session.prefersEphemeralWebBrowserSession = true session.start() } } } extension WebAuthenticationProvider: ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { presentationAnchor } } ``` ## Step 4: Configure CoreATProtocol at Launch 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. ```swift @MainActor func configureCoreATProtocol() async { let metadataURL = URL(string: "https://example.com/oauth/client-metadata.json")! let redirectURI = URL(string: "myapp://oauth/callback")! let configuration = OAuthConfiguration( clientMetadataURL: metadataURL, redirectURI: redirectURI ) let credentialStore = KeychainCredentialStore() try await CoreATProtocol.configureOAuth(configuration: configuration, credentialStore: credentialStore) } ``` Once configured, all `NetworkRouter` instances created by CoreATProtocol use the shared `APRouterDelegate`, which injects DPoP headers and handles nonce/token refreshes automatically. ## Step 5: Initiate Authentication 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`. ```swift @MainActor func signIn(handle: String, anchor: ASPresentationAnchor) async { do { let provider = WebAuthenticationProvider(anchor: anchor) let session = try await CoreATProtocol.authenticate(handle: handle, using: provider) // Persist any additional app state and transition UI print("Authenticated DID: \(session.did)") } catch { // Present user-friendly errors or retry guidance print("OAuth failed: \(error)") } } ``` For returning users, call `CoreATProtocol.currentOAuthSession()` to check if a session already exists, and `CoreATProtocol.refreshOAuthSession()` to proactively refresh tokens. ## Step 6: Make Authenticated Requests After authentication, issue XRPC calls through CoreATProtocol normally. The router delegate supplies `Authorization: DPoP ` and a matching `DPoP` proof. Nonce challenges (`use_dpop_nonce`) and 401 responses automatically trigger a retry or refresh. ```swift @MainActor func loadProfile() async throws -> ActorProfile { // Example: using a CoreATProtocol service client let service = try await SomeServiceClient() return try await service.fetchProfile() } ``` If you maintain your own URL sessions, route them through CoreATProtocol or call `OAuthManager.authenticateResourceRequest(_:)` manually to attach the DPoP header before sending. ## Step 7: Handle Sign-Out When the user signs out, remove the stored session and DPoP key to enforce a clean re-authentication. ```swift @MainActor func signOut() async { do { try await CoreATProtocol.signOutOAuth() } catch { assertionFailure("Failed to sign out cleanly: \(error)") } } ``` You may also want to revoke tokens via the OAuth revocation endpoint once the server exposes it. ## Step 8: Testing and Diagnostics - Use dependency injection to swap `IdentityResolver`, `OAuthHTTPClient`, and `OAuthCredentialStore` with mocks for unit tests. - Exercise the new Swift Testing cases in `Tests/CoreATProtocolTests` to verify PKCE, DPoP, and session expiry logic after future changes. - Capture and log `WWW-Authenticate` headers during development to monitor nonce churn. ## Troubleshooting | Symptom | Suggested Fix | | --- | --- | | `authorization_in_progress` errors | Ensure `beginAuthorization` is not called twice in parallel. Await `resumeAuthorization` before retrying. | | `invalid_redirect_uri` | Confirm the redirect URI in the client metadata exactly matches the one passed to `OAuthConfiguration`. | | `use_dpop_nonce` loops | Inspect your networking stack for caching; DPoP proof URLs must not contain query fragments. | | Token refresh failing after app relaunch | Verify the Keychain store persists both the session JSON and the raw DPoP key. |