this repo has no description
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. |