From d52a041e284b645c40867d27fa672d25645389e9 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Fri, 17 Oct 2025 15:13:20 -0400 Subject: [PATCH] more oauth --- Documentation.docc/Info.plist | 12 ++ Documentation.docc/OAuthIntegrationGuide.md | 204 ++++++++++++++++++ .../OAuth/Identity/DNSResolver.swift | 4 + .../OAuth/Identity/IdentityResolver.swift | 4 +- .../OAuth/Models/OAuthMetadata.swift | 28 +-- .../IdentityResolverTests.swift | 53 +++++ .../OAuthClientMetadataParsingTests.swift | 37 ++++ .../OAuthMetadataParsingTests.swift | 15 ++ 8 files changed, 341 insertions(+), 16 deletions(-) create mode 100644 Documentation.docc/Info.plist create mode 100644 Documentation.docc/OAuthIntegrationGuide.md create mode 100644 Tests/CoreATProtocolTests/IdentityResolverTests.swift create mode 100644 Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift create mode 100644 Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift diff --git a/Documentation.docc/Info.plist b/Documentation.docc/Info.plist new file mode 100644 index 0000000..b90e107 --- /dev/null +++ b/Documentation.docc/Info.plist @@ -0,0 +1,12 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleIdentifier + com.sparrowtek.coreatprotocol.documentation + CFBundleName + CoreATProtocol Documentation + + diff --git a/Documentation.docc/OAuthIntegrationGuide.md b/Documentation.docc/OAuthIntegrationGuide.md new file mode 100644 index 0000000..16bdeb6 --- /dev/null +++ b/Documentation.docc/OAuthIntegrationGuide.md @@ -0,0 +1,204 @@ +# 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. | diff --git a/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift b/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift index 5ace196..bedb9a4 100644 --- a/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift +++ b/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift @@ -49,6 +49,10 @@ final class DoHDNSResolver: DNSResolving { } } + private enum CodingKeys: String, CodingKey { + case answers = "Answer" + } + let answers: [Answer]? } } diff --git a/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift b/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift index 272b81c..39b71d8 100644 --- a/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift +++ b/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift @@ -35,8 +35,8 @@ final class IdentityResolver: Sendable { func fetchDIDDocument(for did: String) async throws -> DIDDocument { if did.hasPrefix("did:plc:") { - let identifier = String(did.dropFirst("did:plc:".count)) - guard let url = URL(string: "https://plc.directory/\(identifier)") else { + guard let encodedDID = did.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "https://plc.directory/\(encodedDID)") else { throw IdentityResolverError.invalidDID } return try await fetchJSON(url: url, type: DIDDocument.self) diff --git a/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift b/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift index db9e59a..695cefe 100644 --- a/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift +++ b/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift @@ -4,7 +4,7 @@ struct OAuthProtectedResourceMetadata: Decodable, Sendable { let authorizationServers: [URL] private enum CodingKeys: String, CodingKey { - case authorizationServers = "authorization_servers" + case authorizationServers } init(from decoder: Decoder) throws { @@ -30,12 +30,12 @@ struct OAuthAuthorizationServerMetadata: Decodable, Sendable { private enum CodingKeys: String, CodingKey { case issuer - case authorizationEndpoint = "authorization_endpoint" - case tokenEndpoint = "token_endpoint" - case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" - case codeChallengeMethodsSupported = "code_challenge_methods_supported" - case dPoPSigningAlgValuesSupported = "dpop_signing_alg_values_supported" - case scopesSupported = "scopes_supported" + case authorizationEndpoint + case tokenEndpoint + case pushedAuthorizationRequestEndpoint + case codeChallengeMethodsSupported + case dPoPSigningAlgValuesSupported = "dpopSigningAlgValuesSupported" + case scopesSupported } init(from decoder: Decoder) throws { @@ -74,14 +74,14 @@ struct OAuthClientMetadata: Decodable, Sendable { let dPoPBoundAccessTokens: Bool private enum CodingKeys: String, CodingKey { - case clientID = "client_id" + case clientID = "clientId" case scope - case redirectURIs = "redirect_uris" - case grantTypes = "grant_types" - case responseTypes = "response_types" - case tokenEndpointAuthMethod = "token_endpoint_auth_method" - case tokenEndpointAuthSigningAlg = "token_endpoint_auth_signing_alg" - case dPoPBoundAccessTokens = "dpop_bound_access_tokens" + case redirectURIs = "redirectUris" + case grantTypes + case responseTypes + case tokenEndpointAuthMethod + case tokenEndpointAuthSigningAlg + case dPoPBoundAccessTokens = "dpopBoundAccessTokens" } init(from decoder: Decoder) throws { diff --git a/Tests/CoreATProtocolTests/IdentityResolverTests.swift b/Tests/CoreATProtocolTests/IdentityResolverTests.swift new file mode 100644 index 0000000..30efb0b --- /dev/null +++ b/Tests/CoreATProtocolTests/IdentityResolverTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import CoreATProtocol + +@APActor +final class MockNetworking: Networking { + var requestedURLs: [URL] = [] + var responseData: Data + var statusCode: Int + + init(responseData: Data, statusCode: Int = 200) { + self.responseData = responseData + self.statusCode = statusCode + } + + func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { + if let url = request.url { + requestedURLs.append(url) + } + let response = HTTPURLResponse(url: request.url ?? URL(string: "https://example.com")!, statusCode: statusCode, httpVersion: nil, headerFields: [:])! + return (responseData, response) + } +} + +struct MockDNSResolver: DNSResolving { + func txtRecords(for host: String) async throws -> [String] { [] } +} + +@Test("Identity resolver fetches PLC DID documents using full identifier path") +func identityResolverUsesFullPLCPath() async throws { + let documentJSON = """ + { + "id": "did:plc:identifier", + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://example.com" + } + ] + } + """.data(using: .utf8)! + + let networking = await MockNetworking(responseData: documentJSON) + let httpClient = await OAuthHTTPClient(networking: networking) + let resolver = await IdentityResolver(httpClient: httpClient, dnsResolver: MockDNSResolver()) + + let document = try await resolver.fetchDIDDocument(for: "did:plc:identifier") + #expect(document.id == "did:plc:identifier") + + let requestedPath = await networking.requestedURLs.first?.path + #expect(requestedPath == "/did:plc:identifier") +} diff --git a/Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift b/Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift new file mode 100644 index 0000000..b223922 --- /dev/null +++ b/Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import CoreATProtocol + +@Test("Client metadata decodes from sample JSON") +func decodeClientMetadata() throws { + let json = """ + { + "client_id": "https://sparrowtek.com/plume.json", + "client_name": "Plume iOS", + "application_type": "native", + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "scope": "atproto", + "response_types": [ + "code" + ], + "redirect_uris": [ + "com.sparrowtek.plume:/oauth/callback" + ], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true, + "client_uri": "https://sparrowtek.com", + "policy_uri": "https://sparrowtek.com/privacy", + "tos_uri": "https://sparrowtek.com/terms" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let metadata = try decoder.decode(OAuthClientMetadata.self, from: json) + #expect(metadata.clientID.absoluteString == "https://sparrowtek.com/plume.json") + #expect(metadata.redirectURIs.count == 1) + #expect(metadata.dPoPBoundAccessTokens) +} diff --git a/Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift b/Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift new file mode 100644 index 0000000..d02348b --- /dev/null +++ b/Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift @@ -0,0 +1,15 @@ +import Foundation +import Testing +@testable import CoreATProtocol + +@Test("Authorization server metadata decodes from sample JSON") +func decodeAuthorizationServerMetadata() throws { + let json = """ + {"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","introspection_endpoint":"https://bsky.social/oauth/introspect","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true} + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let metadata = try decoder.decode(OAuthAuthorizationServerMetadata.self, from: json) + #expect(metadata.authorizationEndpoint.absoluteString == "https://bsky.social/oauth/authorize") +} -- 2.54.0