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.
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).
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.
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.
@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.
@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 <token> and a matching DPoP proof. Nonce challenges (use_dpop_nonce) and 401 responses automatically trigger a retry or refresh.
@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.
@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, andOAuthCredentialStorewith mocks for unit tests. - Exercise the new Swift Testing cases in
Tests/CoreATProtocolTeststo verify PKCE, DPoP, and session expiry logic after future changes. - Capture and log
WWW-Authenticateheaders 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. |