this repo has no description

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, 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.