this repo has no description

more oauth

+341 -16
+12
Documentation.docc/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleDevelopmentRegion</key> 6 + <string>en</string> 7 + <key>CFBundleIdentifier</key> 8 + <string>com.sparrowtek.coreatprotocol.documentation</string> 9 + <key>CFBundleName</key> 10 + <string>CoreATProtocol Documentation</string> 11 + </dict> 12 + </plist>
+204
Documentation.docc/OAuthIntegrationGuide.md
··· 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 + 11 + 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`. 12 + 13 + The 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 + 24 + Add 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 27 + import CoreATProtocol 28 + ``` 29 + 30 + ## Step 2: Provide a Credential Store 31 + 32 + 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). 33 + 34 + ```swift 35 + import CoreATProtocol 36 + import Security 37 + 38 + actor 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 + 79 + Persist 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 + 83 + Provide an `OAuthUIProvider` that wraps `ASWebAuthenticationSession` and routes callbacks back into the manager. 84 + 85 + ```swift 86 + import AuthenticationServices 87 + import CoreATProtocol 88 + 89 + final 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 + 110 + extension WebAuthenticationProvider: ASWebAuthenticationPresentationContextProviding { 111 + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 112 + presentationAnchor 113 + } 114 + } 115 + ``` 116 + 117 + ## Step 4: Configure CoreATProtocol at Launch 118 + 119 + 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. 120 + 121 + ```swift 122 + @MainActor 123 + func 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 + 136 + Once 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 + 140 + 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`. 141 + 142 + ```swift 143 + @MainActor 144 + func 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 + 157 + For 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 + 161 + 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. 162 + 163 + ```swift 164 + @MainActor 165 + func 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 + 172 + If 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 + 176 + When the user signs out, remove the stored session and DPoP key to enforce a clean re-authentication. 177 + 178 + ```swift 179 + @MainActor 180 + func signOut() async { 181 + do { 182 + try await CoreATProtocol.signOutOAuth() 183 + } catch { 184 + assertionFailure("Failed to sign out cleanly: \(error)") 185 + } 186 + } 187 + ``` 188 + 189 + You 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. |
+4
Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift
··· 49 49 } 50 50 } 51 51 52 + private enum CodingKeys: String, CodingKey { 53 + case answers = "Answer" 54 + } 55 + 52 56 let answers: [Answer]? 53 57 } 54 58 }
+2 -2
Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift
··· 35 35 36 36 func fetchDIDDocument(for did: String) async throws -> DIDDocument { 37 37 if did.hasPrefix("did:plc:") { 38 - let identifier = String(did.dropFirst("did:plc:".count)) 39 - guard let url = URL(string: "https://plc.directory/\(identifier)") else { 38 + guard let encodedDID = did.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), 39 + let url = URL(string: "https://plc.directory/\(encodedDID)") else { 40 40 throw IdentityResolverError.invalidDID 41 41 } 42 42 return try await fetchJSON(url: url, type: DIDDocument.self)
+14 -14
Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift
··· 4 4 let authorizationServers: [URL] 5 5 6 6 private enum CodingKeys: String, CodingKey { 7 - case authorizationServers = "authorization_servers" 7 + case authorizationServers 8 8 } 9 9 10 10 init(from decoder: Decoder) throws { ··· 30 30 31 31 private enum CodingKeys: String, CodingKey { 32 32 case issuer 33 - case authorizationEndpoint = "authorization_endpoint" 34 - case tokenEndpoint = "token_endpoint" 35 - case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" 36 - case codeChallengeMethodsSupported = "code_challenge_methods_supported" 37 - case dPoPSigningAlgValuesSupported = "dpop_signing_alg_values_supported" 38 - case scopesSupported = "scopes_supported" 33 + case authorizationEndpoint 34 + case tokenEndpoint 35 + case pushedAuthorizationRequestEndpoint 36 + case codeChallengeMethodsSupported 37 + case dPoPSigningAlgValuesSupported = "dpopSigningAlgValuesSupported" 38 + case scopesSupported 39 39 } 40 40 41 41 init(from decoder: Decoder) throws { ··· 74 74 let dPoPBoundAccessTokens: Bool 75 75 76 76 private enum CodingKeys: String, CodingKey { 77 - case clientID = "client_id" 77 + case clientID = "clientId" 78 78 case scope 79 - case redirectURIs = "redirect_uris" 80 - case grantTypes = "grant_types" 81 - case responseTypes = "response_types" 82 - case tokenEndpointAuthMethod = "token_endpoint_auth_method" 83 - case tokenEndpointAuthSigningAlg = "token_endpoint_auth_signing_alg" 84 - case dPoPBoundAccessTokens = "dpop_bound_access_tokens" 79 + case redirectURIs = "redirectUris" 80 + case grantTypes 81 + case responseTypes 82 + case tokenEndpointAuthMethod 83 + case tokenEndpointAuthSigningAlg 84 + case dPoPBoundAccessTokens = "dpopBoundAccessTokens" 85 85 } 86 86 87 87 init(from decoder: Decoder) throws {
+53
Tests/CoreATProtocolTests/IdentityResolverTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @APActor 6 + final class MockNetworking: Networking { 7 + var requestedURLs: [URL] = [] 8 + var responseData: Data 9 + var statusCode: Int 10 + 11 + init(responseData: Data, statusCode: Int = 200) { 12 + self.responseData = responseData 13 + self.statusCode = statusCode 14 + } 15 + 16 + func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { 17 + if let url = request.url { 18 + requestedURLs.append(url) 19 + } 20 + let response = HTTPURLResponse(url: request.url ?? URL(string: "https://example.com")!, statusCode: statusCode, httpVersion: nil, headerFields: [:])! 21 + return (responseData, response) 22 + } 23 + } 24 + 25 + struct MockDNSResolver: DNSResolving { 26 + func txtRecords(for host: String) async throws -> [String] { [] } 27 + } 28 + 29 + @Test("Identity resolver fetches PLC DID documents using full identifier path") 30 + func identityResolverUsesFullPLCPath() async throws { 31 + let documentJSON = """ 32 + { 33 + "id": "did:plc:identifier", 34 + "service": [ 35 + { 36 + "id": "#atproto_pds", 37 + "type": "AtprotoPersonalDataServer", 38 + "serviceEndpoint": "https://example.com" 39 + } 40 + ] 41 + } 42 + """.data(using: .utf8)! 43 + 44 + let networking = await MockNetworking(responseData: documentJSON) 45 + let httpClient = await OAuthHTTPClient(networking: networking) 46 + let resolver = await IdentityResolver(httpClient: httpClient, dnsResolver: MockDNSResolver()) 47 + 48 + let document = try await resolver.fetchDIDDocument(for: "did:plc:identifier") 49 + #expect(document.id == "did:plc:identifier") 50 + 51 + let requestedPath = await networking.requestedURLs.first?.path 52 + #expect(requestedPath == "/did:plc:identifier") 53 + }
+37
Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Test("Client metadata decodes from sample JSON") 6 + func decodeClientMetadata() throws { 7 + let json = """ 8 + { 9 + "client_id": "https://sparrowtek.com/plume.json", 10 + "client_name": "Plume iOS", 11 + "application_type": "native", 12 + "grant_types": [ 13 + "authorization_code", 14 + "refresh_token" 15 + ], 16 + "scope": "atproto", 17 + "response_types": [ 18 + "code" 19 + ], 20 + "redirect_uris": [ 21 + "com.sparrowtek.plume:/oauth/callback" 22 + ], 23 + "token_endpoint_auth_method": "none", 24 + "dpop_bound_access_tokens": true, 25 + "client_uri": "https://sparrowtek.com", 26 + "policy_uri": "https://sparrowtek.com/privacy", 27 + "tos_uri": "https://sparrowtek.com/terms" 28 + } 29 + """.data(using: .utf8)! 30 + 31 + let decoder = JSONDecoder() 32 + decoder.keyDecodingStrategy = .convertFromSnakeCase 33 + let metadata = try decoder.decode(OAuthClientMetadata.self, from: json) 34 + #expect(metadata.clientID.absoluteString == "https://sparrowtek.com/plume.json") 35 + #expect(metadata.redirectURIs.count == 1) 36 + #expect(metadata.dPoPBoundAccessTokens) 37 + }
+15
Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Test("Authorization server metadata decodes from sample JSON") 6 + func decodeAuthorizationServerMetadata() throws { 7 + let json = """ 8 + {"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} 9 + """.data(using: .utf8)! 10 + 11 + let decoder = JSONDecoder() 12 + decoder.keyDecodingStrategy = .convertFromSnakeCase 13 + let metadata = try decoder.decode(OAuthAuthorizationServerMetadata.self, from: json) 14 + #expect(metadata.authorizationEndpoint.absoluteString == "https://bsky.social/oauth/authorize") 15 + }