···11+# OAuth Integration on iOS
22+33+@Metadata {
44+ @Abstract(
55+ "Step-by-step instructions for adopting CoreATProtocol's bespoke OAuth implementation inside an iOS app."
66+ )
77+}
88+99+## Overview
1010+1111+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`.
1212+1313+The sections below walk through the recommended wiring for production iOS apps.
1414+1515+## Prerequisites
1616+1717+- Xcode 16 or later with Swift 6.
1818+- A Bluesky/AT Protocol OAuth client metadata document hosted at a stable HTTPS URL.
1919+- A custom URL scheme registered in your app to receive the OAuth redirect.
2020+- Familiarity with Keychain Services and Swift concurrency.
2121+2222+## Step 1: Configure the Package
2323+2424+Add CoreATProtocol as a Swift Package dependency in Xcode. Ensure your iOS target links against CoreATProtocol and imports it inside the app module.
2525+2626+```swift
2727+import CoreATProtocol
2828+```
2929+3030+## Step 2: Provide a Credential Store
3131+3232+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).
3333+3434+```swift
3535+import CoreATProtocol
3636+import Security
3737+3838+actor KeychainCredentialStore: OAuthCredentialStore {
3939+ private enum Item {
4040+ static let account = "com.sparrowtek.coreatprotocol.oauth"
4141+ static let sessionKey = "session"
4242+ static let dpopKey = "dpop-key"
4343+ }
4444+4545+ func loadSession() async throws -> OAuthSession? {
4646+ guard let data = try read(key: Item.sessionKey) else { return nil }
4747+ return try JSONDecoder().decode(OAuthSession.self, from: data)
4848+ }
4949+5050+ func save(session: OAuthSession) async throws {
5151+ let data = try JSONEncoder().encode(session)
5252+ try write(data, key: Item.sessionKey)
5353+ }
5454+5555+ func deleteSession() async throws {
5656+ try delete(key: Item.sessionKey)
5757+ }
5858+5959+ func loadDPoPKey() async throws -> Data? {
6060+ try read(key: Item.dpopKey)
6161+ }
6262+6363+ func saveDPoPKey(_ data: Data) async throws {
6464+ try write(data, key: Item.dpopKey)
6565+ }
6666+6767+ func deleteDPoPKey() async throws {
6868+ try delete(key: Item.dpopKey)
6969+ }
7070+7171+ // MARK: - Helpers
7272+7373+ private func read(key: String) throws -> Data? { /* Keychain lookup */ }
7474+ private func write(_ data: Data, key: String) throws { /* Keychain add/update */ }
7575+ private func delete(key: String) throws { /* Keychain delete */ }
7676+}
7777+```
7878+7979+Persist both the serialized `OAuthSession` and the raw DPoP private key so refreshes survive app restarts.
8080+8181+## Step 3: Create an OAuth UI Provider
8282+8383+Provide an `OAuthUIProvider` that wraps `ASWebAuthenticationSession` and routes callbacks back into the manager.
8484+8585+```swift
8686+import AuthenticationServices
8787+import CoreATProtocol
8888+8989+final class WebAuthenticationProvider: NSObject, OAuthUIProvider {
9090+ private let presentationAnchor: ASPresentationAnchor
9191+9292+ init(anchor: ASPresentationAnchor) {
9393+ self.presentationAnchor = anchor
9494+ }
9595+9696+ func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL {
9797+ try await withCheckedThrowingContinuation { continuation in
9898+ let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in
9999+ if let error { continuation.resume(throwing: error) }
100100+ else if let callbackURL { continuation.resume(returning: callbackURL) }
101101+ else { continuation.resume(throwing: OAuthManagerError.authorizationCancelled) }
102102+ }
103103+ session.presentationContextProvider = self
104104+ session.prefersEphemeralWebBrowserSession = true
105105+ session.start()
106106+ }
107107+ }
108108+}
109109+110110+extension WebAuthenticationProvider: ASWebAuthenticationPresentationContextProviding {
111111+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
112112+ presentationAnchor
113113+ }
114114+}
115115+```
116116+117117+## Step 4: Configure CoreATProtocol at Launch
118118+119119+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.
120120+121121+```swift
122122+@MainActor
123123+func configureCoreATProtocol() async {
124124+ let metadataURL = URL(string: "https://example.com/oauth/client-metadata.json")!
125125+ let redirectURI = URL(string: "myapp://oauth/callback")!
126126+ let configuration = OAuthConfiguration(
127127+ clientMetadataURL: metadataURL,
128128+ redirectURI: redirectURI
129129+ )
130130+131131+ let credentialStore = KeychainCredentialStore()
132132+ try await CoreATProtocol.configureOAuth(configuration: configuration, credentialStore: credentialStore)
133133+}
134134+```
135135+136136+Once configured, all `NetworkRouter` instances created by CoreATProtocol use the shared `APRouterDelegate`, which injects DPoP headers and handles nonce/token refreshes automatically.
137137+138138+## Step 5: Initiate Authentication
139139+140140+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`.
141141+142142+```swift
143143+@MainActor
144144+func signIn(handle: String, anchor: ASPresentationAnchor) async {
145145+ do {
146146+ let provider = WebAuthenticationProvider(anchor: anchor)
147147+ let session = try await CoreATProtocol.authenticate(handle: handle, using: provider)
148148+ // Persist any additional app state and transition UI
149149+ print("Authenticated DID: \(session.did)")
150150+ } catch {
151151+ // Present user-friendly errors or retry guidance
152152+ print("OAuth failed: \(error)")
153153+ }
154154+}
155155+```
156156+157157+For returning users, call `CoreATProtocol.currentOAuthSession()` to check if a session already exists, and `CoreATProtocol.refreshOAuthSession()` to proactively refresh tokens.
158158+159159+## Step 6: Make Authenticated Requests
160160+161161+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.
162162+163163+```swift
164164+@MainActor
165165+func loadProfile() async throws -> ActorProfile {
166166+ // Example: using a CoreATProtocol service client
167167+ let service = try await SomeServiceClient()
168168+ return try await service.fetchProfile()
169169+}
170170+```
171171+172172+If you maintain your own URL sessions, route them through CoreATProtocol or call `OAuthManager.authenticateResourceRequest(_:)` manually to attach the DPoP header before sending.
173173+174174+## Step 7: Handle Sign-Out
175175+176176+When the user signs out, remove the stored session and DPoP key to enforce a clean re-authentication.
177177+178178+```swift
179179+@MainActor
180180+func signOut() async {
181181+ do {
182182+ try await CoreATProtocol.signOutOAuth()
183183+ } catch {
184184+ assertionFailure("Failed to sign out cleanly: \(error)")
185185+ }
186186+}
187187+```
188188+189189+You may also want to revoke tokens via the OAuth revocation endpoint once the server exposes it.
190190+191191+## Step 8: Testing and Diagnostics
192192+193193+- Use dependency injection to swap `IdentityResolver`, `OAuthHTTPClient`, and `OAuthCredentialStore` with mocks for unit tests.
194194+- Exercise the new Swift Testing cases in `Tests/CoreATProtocolTests` to verify PKCE, DPoP, and session expiry logic after future changes.
195195+- Capture and log `WWW-Authenticate` headers during development to monitor nonce churn.
196196+197197+## Troubleshooting
198198+199199+| Symptom | Suggested Fix |
200200+| --- | --- |
201201+| `authorization_in_progress` errors | Ensure `beginAuthorization` is not called twice in parallel. Await `resumeAuthorization` before retrying. |
202202+| `invalid_redirect_uri` | Confirm the redirect URI in the client metadata exactly matches the one passed to `OAuthConfiguration`. |
203203+| `use_dpop_nonce` loops | Inspect your networking stack for caching; DPoP proof URLs must not contain query fragments. |
204204+| Token refresh failing after app relaunch | Verify the Keychain store persists both the session JSON and the raw DPoP key. |