this repo has no description

Compare changes

Choose any two refs to compare.

+133
CODE_OF_CONDUCT.md
··· 1 + 2 + # Contributor Covenant Code of Conduct 3 + 4 + ## Our Pledge 5 + 6 + We as members, contributors, and leaders pledge to make participation in our 7 + community a harassment-free experience for everyone, regardless of age, body 8 + size, visible or invisible disability, ethnicity, sex characteristics, gender 9 + identity and expression, level of experience, education, socio-economic status, 10 + nationality, personal appearance, race, caste, color, religion, or sexual 11 + identity and orientation. 12 + 13 + We pledge to act and interact in ways that contribute to an open, welcoming, 14 + diverse, inclusive, and healthy community. 15 + 16 + ## Our Standards 17 + 18 + Examples of behavior that contributes to a positive environment for our 19 + community include: 20 + 21 + * Demonstrating empathy and kindness toward other people 22 + * Being respectful of differing opinions, viewpoints, and experiences 23 + * Giving and gracefully accepting constructive feedback 24 + * Accepting responsibility and apologizing to those affected by our mistakes, 25 + and learning from the experience 26 + * Focusing on what is best not just for us as individuals, but for the overall 27 + community 28 + 29 + Examples of unacceptable behavior include: 30 + 31 + * The use of sexualized language or imagery, and sexual attention or advances of 32 + any kind 33 + * Trolling, insulting or derogatory comments, and personal or political attacks 34 + * Public or private harassment 35 + * Publishing others' private information, such as a physical or email address, 36 + without their explicit permission 37 + * Other conduct which could reasonably be considered inappropriate in a 38 + professional setting 39 + 40 + ## Enforcement Responsibilities 41 + 42 + Community leaders are responsible for clarifying and enforcing our standards of 43 + acceptable behavior and will take appropriate and fair corrective action in 44 + response to any behavior that they deem inappropriate, threatening, offensive, 45 + or harmful. 46 + 47 + Community leaders have the right and responsibility to remove, edit, or reject 48 + comments, commits, code, wiki edits, issues, and other contributions that are 49 + not aligned to this Code of Conduct, and will communicate reasons for moderation 50 + decisions when appropriate. 51 + 52 + ## Scope 53 + 54 + This Code of Conduct applies within all community spaces, and also applies when 55 + an individual is officially representing the community in public spaces. 56 + Examples of representing our community include using an official e-mail address, 57 + posting via an official social media account, or acting as an appointed 58 + representative at an online or offline event. 59 + 60 + ## Enforcement 61 + 62 + Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 + reported to the community leaders responsible for enforcement at 64 + contact@sparrowtek.com. 65 + All complaints will be reviewed and investigated promptly and fairly. 66 + 67 + All community leaders are obligated to respect the privacy and security of the 68 + reporter of any incident. 69 + 70 + ## Enforcement Guidelines 71 + 72 + Community leaders will follow these Community Impact Guidelines in determining 73 + the consequences for any action they deem in violation of this Code of Conduct: 74 + 75 + ### 1. Correction 76 + 77 + **Community Impact**: Use of inappropriate language or other behavior deemed 78 + unprofessional or unwelcome in the community. 79 + 80 + **Consequence**: A private, written warning from community leaders, providing 81 + clarity around the nature of the violation and an explanation of why the 82 + behavior was inappropriate. A public apology may be requested. 83 + 84 + ### 2. Warning 85 + 86 + **Community Impact**: A violation through a single incident or series of 87 + actions. 88 + 89 + **Consequence**: A warning with consequences for continued behavior. No 90 + interaction with the people involved, including unsolicited interaction with 91 + those enforcing the Code of Conduct, for a specified period of time. This 92 + includes avoiding interactions in community spaces as well as external channels 93 + like social media. Violating these terms may lead to a temporary or permanent 94 + ban. 95 + 96 + ### 3. Temporary Ban 97 + 98 + **Community Impact**: A serious violation of community standards, including 99 + sustained inappropriate behavior. 100 + 101 + **Consequence**: A temporary ban from any sort of interaction or public 102 + communication with the community for a specified period of time. No public or 103 + private interaction with the people involved, including unsolicited interaction 104 + with those enforcing the Code of Conduct, is allowed during this period. 105 + Violating these terms may lead to a permanent ban. 106 + 107 + ### 4. Permanent Ban 108 + 109 + **Community Impact**: Demonstrating a pattern of violation of community 110 + standards, including sustained inappropriate behavior, harassment of an 111 + individual, or aggression toward or disparagement of classes of individuals. 112 + 113 + **Consequence**: A permanent ban from any sort of public interaction within the 114 + community. 115 + 116 + ## Attribution 117 + 118 + This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 + version 2.1, available at 120 + [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 + 122 + Community Impact Guidelines were inspired by 123 + [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 + 125 + For answers to common questions about this code of conduct, see the FAQ at 126 + [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 + [https://www.contributor-covenant.org/translations][translations]. 128 + 129 + [homepage]: https://www.contributor-covenant.org 130 + [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 + [Mozilla CoC]: https://github.com/mozilla/diversity 132 + [FAQ]: https://www.contributor-covenant.org/faq 133 + [translations]: https://www.contributor-covenant.org/translations
-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. |
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 SparrowTek 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+60
Package.resolved
··· 1 + { 2 + "originHash" : "46681c90ffb61eca5269d3e2ab8743c6f802287641f8bccf7c47227aa7a6a97a", 3 + "pins" : [ 4 + { 5 + "identity" : "jwt-kit", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/vapor/jwt-kit.git", 8 + "state" : { 9 + "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", 10 + "version" : "5.3.0" 11 + } 12 + }, 13 + { 14 + "identity" : "oauthenticator", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/radmakr/OAuthenticator.git", 17 + "state" : { 18 + "branch" : "CoreAtProtocol", 19 + "revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70" 20 + } 21 + }, 22 + { 23 + "identity" : "swift-asn1", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/apple/swift-asn1.git", 26 + "state" : { 27 + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", 28 + "version" : "1.5.1" 29 + } 30 + }, 31 + { 32 + "identity" : "swift-certificates", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/apple/swift-certificates.git", 35 + "state" : { 36 + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", 37 + "version" : "1.17.0" 38 + } 39 + }, 40 + { 41 + "identity" : "swift-crypto", 42 + "kind" : "remoteSourceControl", 43 + "location" : "https://github.com/apple/swift-crypto.git", 44 + "state" : { 45 + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", 46 + "version" : "4.2.0" 47 + } 48 + }, 49 + { 50 + "identity" : "swift-log", 51 + "kind" : "remoteSourceControl", 52 + "location" : "https://github.com/apple/swift-log.git", 53 + "state" : { 54 + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", 55 + "version" : "1.8.0" 56 + } 57 + } 58 + ], 59 + "version" : 3 60 + }
+21 -6
Package.swift
··· 5 5 let package = Package( 6 6 name: "CoreATProtocol", 7 7 platforms: [ 8 - .iOS(.v26), 9 - .watchOS(.v26), 10 - .tvOS(.v26), 11 - .macOS(.v26), 12 - .macCatalyst(.v26), 8 + .iOS(.v17), 9 + .watchOS(.v11), 10 + .tvOS(.v17), 11 + .macOS(.v14), 12 + .macCatalyst(.v17), 13 13 ], 14 14 products: [ 15 15 .library( ··· 17 17 targets: ["CoreATProtocol"] 18 18 ), 19 19 ], 20 + dependencies: [ 21 + // Using fork with fix for WebAuthenticationSession platform guards 22 + // PR pending at https://github.com/ChimeHQ/OAuthenticator 23 + // .package(url: "https://github.com/ChimeHQ/OAuthenticator.git", branch: "main"), 24 + .package(url: "https://github.com/radmakr/OAuthenticator.git", branch: "CoreAtProtocol"), 25 + // .package(path: "../OAuthenticator"), 26 + .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), 27 + ], 20 28 targets: [ 21 29 .target( 22 - name: "CoreATProtocol" 30 + name: "CoreATProtocol", 31 + dependencies: [ 32 + "OAuthenticator", 33 + .product(name: "JWTKit", package: "jwt-kit"), 34 + ], 35 + swiftSettings: [ 36 + .enableExperimentalFeature("StrictConcurrency") 37 + ] 23 38 ), 24 39 .testTarget( 25 40 name: "CoreATProtocolTests",
+230
README.md
··· 1 + # CoreATProtocol 2 + 3 + A Swift package providing the foundational networking layer for interacting with the [AT Protocol](https://atproto.com) (Authenticated Transfer Protocol). This library handles the core HTTP communication, authentication token management, and request/response encoding required to build AT Protocol clients. 4 + 5 + ## Overview 6 + 7 + CoreATProtocol is designed to be protocol-agnostic within the AT Protocol ecosystem. It provides the networking infrastructure that higher-level packages (like [bskyKit](https://tangled.org/@sparrowtek.com/bskyKit) for Bluesky) can build upon to implement specific lexicons. 8 + 9 + ### Key Features 10 + 11 + - **Modern Swift Concurrency** - Built with Swift 6.2 using async/await and actors for thread-safe operations 12 + - **Global Actor Isolation** - Uses `@APActor` for consistent thread safety across all AT Protocol operations 13 + - **Flexible Network Routing** - Generic `NetworkRouter` that works with any endpoint conforming to `EndpointType` 14 + - **Automatic Token Management** - Built-in support for JWT access/refresh token handling with automatic retry on expiration 15 + - **Multiple Parameter Encodings** - URL, JSON, and combined encoding strategies for request parameters 16 + - **AT Protocol Error Handling** - Typed error responses matching AT Protocol error specifications 17 + - **Testable Architecture** - Protocol-based design allows easy mocking for unit tests 18 + 19 + ## Requirements 20 + 21 + - Swift 6.2+ 22 + - iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+ 23 + 24 + ## Installation 25 + 26 + ### Swift Package Manager 27 + 28 + Add CoreATProtocol to your `Package.swift` dependencies: 29 + 30 + ```swift 31 + dependencies: [ 32 + .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), 33 + ] 34 + ``` 35 + 36 + Then add it to your target dependencies: 37 + 38 + ```swift 39 + .target( 40 + name: "YourTarget", 41 + dependencies: ["CoreATProtocol"] 42 + ), 43 + ``` 44 + 45 + Or in Xcode: File > Add Package Dependencies and enter: 46 + ``` 47 + https://tangled.org/@sparrowtek.com/CoreATProtocol 48 + ``` 49 + 50 + ## Usage 51 + 52 + ### Initial Setup 53 + 54 + Configure the environment with your host URL and authentication tokens: 55 + 56 + ```swift 57 + import CoreATProtocol 58 + 59 + // Setup with host and tokens 60 + await setup( 61 + hostURL: "https://bsky.social", 62 + accessJWT: "your-access-token", 63 + refreshJWT: "your-refresh-token" 64 + ) 65 + 66 + // Or update tokens later 67 + await updateTokens(access: newAccessToken, refresh: newRefreshToken) 68 + 69 + // Change host 70 + await update(hostURL: "https://different-pds.example") 71 + ``` 72 + 73 + ### Defining Endpoints 74 + 75 + Create endpoints by conforming to `EndpointType`: 76 + 77 + ```swift 78 + import CoreATProtocol 79 + 80 + enum MyEndpoint: EndpointType { 81 + case getProfile(actor: String) 82 + case createPost(text: String) 83 + 84 + var baseURL: URL { 85 + get async { 86 + URL(string: APEnvironment.current.host ?? "https://bsky.social")! 87 + } 88 + } 89 + 90 + var path: String { 91 + switch self { 92 + case .getProfile: 93 + return "/xrpc/app.bsky.actor.getProfile" 94 + case .createPost: 95 + return "/xrpc/com.atproto.repo.createRecord" 96 + } 97 + } 98 + 99 + var httpMethod: HTTPMethod { 100 + switch self { 101 + case .getProfile: return .get 102 + case .createPost: return .post 103 + } 104 + } 105 + 106 + var task: HTTPTask { 107 + get async { 108 + switch self { 109 + case .getProfile(let actor): 110 + return .requestParameters(encoding: .urlEncoding(parameters: ["actor": actor])) 111 + case .createPost(let text): 112 + let body: [String: Any] = ["text": text] 113 + return .requestParameters(encoding: .jsonEncoding(parameters: body)) 114 + } 115 + } 116 + } 117 + 118 + var headers: HTTPHeaders? { 119 + get async { nil } 120 + } 121 + } 122 + ``` 123 + 124 + ### Making Requests 125 + 126 + Use `NetworkRouter` to execute requests: 127 + 128 + ```swift 129 + @APActor 130 + class MyATClient { 131 + private let router = NetworkRouter<MyEndpoint>() 132 + 133 + init() { 134 + router.delegate = APEnvironment.current.routerDelegate 135 + } 136 + 137 + func getProfile(actor: String) async throws -> ProfileResponse { 138 + try await router.execute(.getProfile(actor: actor)) 139 + } 140 + } 141 + ``` 142 + 143 + ### Custom JSON Decoding 144 + 145 + Use the pre-configured AT Protocol decoder for proper date handling: 146 + 147 + ```swift 148 + let router = NetworkRouter<MyEndpoint>(decoder: .atDecoder) 149 + ``` 150 + 151 + ### Error Handling 152 + 153 + Handle AT Protocol specific errors: 154 + 155 + ```swift 156 + do { 157 + let profile: Profile = try await router.execute(.getProfile(actor: "did:plc:example")) 158 + } catch let error as AtError { 159 + switch error { 160 + case .message(let errorMessage): 161 + print("AT Error: \(errorMessage.error) - \(errorMessage.message ?? "")") 162 + case .network(let networkError): 163 + switch networkError { 164 + case .statusCode(let code, let data): 165 + print("HTTP \(code?.rawValue ?? 0)") 166 + case .encodingFailed: 167 + print("Failed to encode request") 168 + default: 169 + print("Network error: \(networkError)") 170 + } 171 + } 172 + } 173 + ``` 174 + 175 + ## Architecture 176 + 177 + ### Core Components 178 + 179 + | Component | Description | 180 + |-----------|-------------| 181 + | `APActor` | Global actor ensuring thread-safe access to AT Protocol state | 182 + | `APEnvironment` | Singleton holding host URL, tokens, and delegates | 183 + | `NetworkRouter` | Generic router executing typed endpoint requests | 184 + | `EndpointType` | Protocol defining API endpoint requirements | 185 + | `ParameterEncoding` | Enum supporting URL, JSON, and hybrid encoding | 186 + | `AtError` | AT Protocol error types with message parsing | 187 + 188 + ### Thread Safety 189 + 190 + All AT Protocol operations are isolated to `@APActor` ensuring thread-safe access: 191 + 192 + ```swift 193 + @APActor 194 + public func myFunction() async { 195 + // Safe access to APEnvironment.current 196 + } 197 + ``` 198 + 199 + ## Parameter Encoding Options 200 + 201 + ```swift 202 + // URL query parameters 203 + .urlEncoding(parameters: ["key": "value"]) 204 + 205 + // JSON body 206 + .jsonEncoding(parameters: ["key": "value"]) 207 + 208 + // Pre-encoded JSON data 209 + .jsonDataEncoding(data: jsonData) 210 + 211 + // Encodable objects 212 + .jsonEncodableEncoding(encodable: myStruct) 213 + 214 + // Combined URL + JSON body 215 + .urlAndJsonEncoding(urlParameters: ["q": "search"], bodyParameters: ["data": "value"]) 216 + ``` 217 + 218 + ## Related Packages 219 + 220 + - **[bskyKit](https://tangled.org/@sparrowtek.com/bskyKit)** - Bluesky-specific lexicon implementations built on CoreATProtocol 221 + 222 + ## License 223 + 224 + This project is licensed under an [MIT license](https://tangled.org/sparrowtek.com/CoreATProtocol/blob/main/LICENSE). 225 + 226 + ## Contributing 227 + 228 + It is always a good idea to discuss before taking on a significant task. That said, I have a strong bias towards enthusiasm. If you are excited about doing something, I'll do my best to get out of your way. 229 + 230 + By participating in this project you agree to abide by the [Contributor Code of Conduct](https://tangled.org/sparrowtek.com/CoreATProtocol/blob/main/CODE_OF_CONDUCT.md).
+5 -5
Sources/CoreATProtocol/APEnvironment.swift
··· 5 5 // Created by Thomas Rademaker on 10/10/25. 6 6 // 7 7 8 + import JWTKit 9 + 8 10 @APActor 9 11 public class APEnvironment { 10 12 public static var current: APEnvironment = APEnvironment() ··· 13 15 public var accessToken: String? 14 16 public var refreshToken: String? 15 17 public var atProtocoldelegate: CoreATProtocolDelegate? 18 + public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 19 + public var dpopPrivateKey: ES256PrivateKey? 20 + public var dpopKeys: JWTKeyCollection? 16 21 public let routerDelegate = APRouterDelegate() 17 - public var oauthManager: OAuthManager? { 18 - didSet { 19 - routerDelegate.oauthManager = oauthManager 20 - } 21 - } 22 22 23 23 private init() {} 24 24
+50 -37
Sources/CoreATProtocol/CoreATProtocol.swift
··· 1 1 // The Swift Programming Language 2 2 // https://docs.swift.org/swift-book 3 3 4 - public protocol CoreATProtocolDelegate: AnyObject {} 4 + import JWTKit 5 + 6 + // MARK: - Session 7 + 8 + /// Represents an authenticated AT Protocol session 9 + public struct Session: Sendable, Codable, Hashable { 10 + public let did: String 11 + public let handle: String 12 + public let email: String? 13 + public let accessJwt: String? 14 + public let refreshJwt: String? 15 + 16 + public init(did: String, handle: String, email: String? = nil, accessJwt: String? = nil, refreshJwt: String? = nil) { 17 + self.did = did 18 + self.handle = handle 19 + self.email = email 20 + self.accessJwt = accessJwt 21 + self.refreshJwt = refreshJwt 22 + } 23 + } 24 + 25 + // MARK: - Delegate 26 + 27 + public protocol CoreATProtocolDelegate: AnyObject, Sendable { 28 + /// Called when the session is updated (e.g., tokens refreshed) 29 + func sessionUpdated(_ session: Session) async 30 + } 31 + 32 + // Default implementation for optional method 33 + public extension CoreATProtocolDelegate { 34 + func sessionUpdated(_ session: Session) async {} 35 + } 5 36 6 37 @APActor 7 38 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { ··· 17 48 } 18 49 19 50 @APActor 20 - public func updateTokens(access: String?, refresh: String?) { 21 - APEnvironment.current.accessToken = access 22 - APEnvironment.current.refreshToken = refresh 51 + public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) { 52 + APEnvironment.current.tokenRefreshHandler = handler 23 53 } 24 54 25 55 @APActor 26 - public func update(hostURL: String?) { 27 - APEnvironment.current.host = hostURL 28 - } 56 + public func setDPoPPrivateKey(pem: String?) async throws { 57 + guard let pem, !pem.isEmpty else { 58 + APEnvironment.current.dpopPrivateKey = nil 59 + APEnvironment.current.dpopKeys = nil 60 + return 61 + } 29 62 30 - @APActor 31 - public func configureOAuth( 32 - configuration: OAuthConfiguration, 33 - credentialStore: OAuthCredentialStore? = nil 34 - ) async throws { 35 - let store = credentialStore ?? InMemoryOAuthCredentialStore() 36 - let manager = try await OAuthManager(configuration: configuration, credentialStore: store) 37 - APEnvironment.current.oauthManager = manager 38 - } 63 + let privateKey = try ES256PrivateKey(pem: pem) 64 + let keys = JWTKeyCollection() 65 + await keys.add(ecdsa: privateKey) 39 66 40 - @APActor 41 - public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { 42 - guard let manager = APEnvironment.current.oauthManager else { 43 - throw OAuthManagerError.invalidAuthorizationState 44 - } 45 - let session = try await manager.authenticate(handle: handle, using: uiProvider) 46 - APEnvironment.current.host = session.pdsURL.absoluteString 47 - return session 67 + APEnvironment.current.dpopPrivateKey = privateKey 68 + APEnvironment.current.dpopKeys = keys 48 69 } 49 70 50 71 @APActor 51 - public func currentOAuthSession() -> OAuthSession? { 52 - APEnvironment.current.oauthManager?.currentSession 72 + public func updateTokens(access: String?, refresh: String?) { 73 + APEnvironment.current.accessToken = access 74 + APEnvironment.current.refreshToken = refresh 53 75 } 54 76 55 77 @APActor 56 - public func refreshOAuthSession() async throws -> OAuthSession { 57 - guard let manager = APEnvironment.current.oauthManager else { 58 - throw OAuthManagerError.invalidAuthorizationState 59 - } 60 - return try await manager.refreshSession() 61 - } 62 - 63 - @APActor 64 - public func signOutOAuth() async throws { 65 - guard let manager = APEnvironment.current.oauthManager else { return } 66 - try await manager.signOut() 78 + public func update(hostURL: String?) { 79 + APEnvironment.current.host = hostURL 67 80 }
+2 -1
Sources/CoreATProtocol/Models/ATError.swift
··· 11 11 } 12 12 13 13 public struct ErrorMessage: Codable, Sendable { 14 - #warning("Should error be type string or AtErrorType?") 14 + /// The error type as a string. Kept as String rather than AtErrorType 15 + /// to handle unknown error types that the server may return. 15 16 public let error: String 16 17 public let message: String? 17 18
+159 -7
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 1 1 import Foundation 2 + import JWTKit 3 + import OAuthenticator 4 + #if canImport(CryptoKit) 5 + import CryptoKit 6 + #else 7 + import Crypto 8 + #endif 2 9 3 10 @APActor 4 11 public protocol NetworkRouterDelegate: AnyObject { 5 12 func intercept(_ request: inout URLRequest) async 6 13 func shouldRetry(error: Error, attempts: Int) async throws -> Bool 7 - func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async 8 - } 9 - 10 - extension NetworkRouterDelegate { 11 - public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {} 12 14 } 13 15 14 16 /// Describes the implementation details of a NetworkRouter ··· 41 43 let networking: Networking 42 44 let urlSessionTaskDelegate: URLSessionTaskDelegate? 43 45 var decoder: JSONDecoder 46 + private let dpopActor = DPoPRequestActor() 44 47 45 48 public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) { 46 49 if let networking = networking { ··· 66 69 guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed } 67 70 await delegate?.intercept(&request) 68 71 69 - let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 72 + let (data, response) = try await executeRequest(request) 70 73 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 71 - await delegate?.didReceive(response: httpResponse, data: data, for: request) 72 74 switch httpResponse.statusCode { 73 75 case 200...299: 74 76 return try decoder.decode(T.self, from: data) ··· 91 93 return try await execute(route, attempts: attempts + 1) 92 94 } 93 95 } 96 + 97 + private func executeRequest(_ request: URLRequest) async throws -> (Data, URLResponse) { 98 + if let accessToken = APEnvironment.current.accessToken, 99 + let privateKey = APEnvironment.current.dpopPrivateKey, 100 + let keys = APEnvironment.current.dpopKeys { 101 + return try await dpopResponse( 102 + for: request, 103 + accessToken: accessToken, 104 + privateKey: privateKey, 105 + keys: keys 106 + ) 107 + } 108 + 109 + return try await networking.data(for: request, delegate: urlSessionTaskDelegate) 110 + } 111 + 112 + private func dpopResponse( 113 + for request: URLRequest, 114 + accessToken: String, 115 + privateKey: ES256PrivateKey, 116 + keys: JWTKeyCollection 117 + ) async throws -> (Data, URLResponse) { 118 + let tokenHash = hashToken(accessToken) 119 + let jwtGenerator: DPoPSigner.JWTGenerator = { params in 120 + try await self.generateDPoPJWT( 121 + params: params, 122 + tokenHash: tokenHash, 123 + privateKey: privateKey, 124 + keys: keys 125 + ) 126 + } 127 + 128 + let responseProvider: URLResponseProvider = { request in 129 + try await self.networking.data(for: request, delegate: nil) 130 + } 131 + 132 + return try await dpopActor.response( 133 + request: request, 134 + jwtGenerator: jwtGenerator, 135 + token: accessToken, 136 + tokenHash: tokenHash, 137 + provider: responseProvider 138 + ) 139 + } 140 + 141 + private func generateDPoPJWT( 142 + params: DPoPSigner.JWTParameters, 143 + tokenHash: String, 144 + privateKey: ES256PrivateKey, 145 + keys: JWTKeyCollection 146 + ) async throws -> String { 147 + let htu = stripQueryAndFragment(from: params.requestEndpoint) 148 + let payload = DPoPRequestPayload( 149 + htm: params.httpMethod, 150 + htu: htu, 151 + iat: .init(value: .now), 152 + jti: .init(value: UUID().uuidString), 153 + nonce: params.nonce, 154 + ath: tokenHash 155 + ) 156 + 157 + var header = JWTHeader() 158 + header.typ = "dpop+jwt" 159 + header.alg = "ES256" 160 + 161 + if let keyParams = privateKey.parameters { 162 + let xBase64URL = keyParams.x 163 + .replacingOccurrences(of: "+", with: "-") 164 + .replacingOccurrences(of: "/", with: "_") 165 + .replacingOccurrences(of: "=", with: "") 166 + let yBase64URL = keyParams.y 167 + .replacingOccurrences(of: "+", with: "-") 168 + .replacingOccurrences(of: "/", with: "_") 169 + .replacingOccurrences(of: "=", with: "") 170 + 171 + header.jwk = [ 172 + "kty": .string("EC"), 173 + "crv": .string("P-256"), 174 + "x": .string(xBase64URL), 175 + "y": .string(yBase64URL) 176 + ] 177 + } 178 + 179 + return try await keys.sign(payload, header: header) 180 + } 181 + 182 + private func stripQueryAndFragment(from url: String) -> String { 183 + let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1 184 + let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1 185 + 186 + let end: Int 187 + if fragmentIndex == -1 { 188 + end = queryIndex 189 + } else if queryIndex == -1 { 190 + end = fragmentIndex 191 + } else { 192 + end = min(fragmentIndex, queryIndex) 193 + } 194 + 195 + return end == -1 ? url : String(url.prefix(end)) 196 + } 197 + 198 + private func hashToken(_ token: String) -> String { 199 + let digest = SHA256.hash(data: Data(token.utf8)) 200 + return Data(digest).base64URLEncodedString() 201 + } 94 202 95 203 func buildRequest(from route: Endpoint) async throws -> URLRequest { 96 204 ··· 125 233 } 126 234 } 127 235 } 236 + 237 + private struct DPoPRequestPayload: JWTPayload { 238 + let htm: String 239 + let htu: String 240 + let iat: IssuedAtClaim 241 + let jti: IDClaim 242 + let nonce: String? 243 + let ath: String? 244 + 245 + func verify(using key: some JWTAlgorithm) throws { 246 + // No additional verification needed for DPoP 247 + } 248 + } 249 + 250 + private actor DPoPRequestActor { 251 + private let signer = DPoPSigner() 252 + 253 + func response( 254 + request: URLRequest, 255 + jwtGenerator: DPoPSigner.JWTGenerator, 256 + token: String, 257 + tokenHash: String, 258 + provider: URLResponseProvider 259 + ) async throws -> (Data, URLResponse) { 260 + try await signer.response( 261 + isolation: self, 262 + for: request, 263 + using: jwtGenerator, 264 + token: token, 265 + tokenHash: tokenHash, 266 + issuingServer: nil, 267 + provider: provider 268 + ) 269 + } 270 + } 271 + 272 + private extension Data { 273 + func base64URLEncodedString() -> String { 274 + base64EncodedString() 275 + .replacingOccurrences(of: "+", with: "-") 276 + .replacingOccurrences(of: "/", with: "_") 277 + .replacingOccurrences(of: "=", with: "") 278 + } 279 + }
+1 -1
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
··· 1 1 @preconcurrency import Foundation 2 2 3 3 @APActor 4 - public protocol Networking { 4 + public protocol Networking: Sendable { 5 5 func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) 6 6 } 7 7
+31 -86
Sources/CoreATProtocol/Networking.swift
··· 31 31 } 32 32 33 33 @APActor 34 - public final class APRouterDelegate: NetworkRouterDelegate { 35 - public var oauthManager: OAuthManager? { 36 - didSet { pendingRetryAction = .none } 37 - } 38 - 39 - private enum RetryAction { 40 - case none 41 - case refreshToken 42 - case regenerateDPoP 43 - } 44 - 45 - private var pendingRetryAction: RetryAction = .none 46 - 34 + public class APRouterDelegate: NetworkRouterDelegate { 35 + private var shouldRefreshToken = false 36 + private var refreshTask: Task<Bool, Error>? 37 + 47 38 public func intercept(_ request: inout URLRequest) async { 48 - if let manager = oauthManager { 49 - do { 50 - try await manager.authenticateResourceRequest(&request) 51 - return 52 - } catch { 53 - // Fall back to legacy bearer injection if OAuth authentication fails. 54 - } 39 + if APEnvironment.current.dpopPrivateKey != nil { 40 + return 55 41 } 56 42 57 - if let accessToken = APEnvironment.current.accessToken { 43 + if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { 44 + shouldRefreshToken = false 45 + request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") 46 + } else if let accessToken = APEnvironment.current.accessToken { 58 47 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 59 48 } 60 49 } 61 - 50 + 62 51 public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 63 - if let manager = oauthManager { 64 - switch pendingRetryAction { 65 - case .regenerateDPoP where attempts < 3: 66 - pendingRetryAction = .none 67 - return true 68 - case .refreshToken: 69 - pendingRetryAction = .none 70 - do { 71 - _ = try await manager.refreshSession(force: true) 72 - return true 73 - } catch { 74 - return false 75 - } 76 - default: 77 - pendingRetryAction = .none 52 + func refreshViaOAuth() async throws -> Bool { 53 + guard let handler = APEnvironment.current.tokenRefreshHandler else { 54 + return false 78 55 } 79 - } 80 56 81 - if case .message(let message) = error as? AtError, 82 - message.error == AtErrorType.expiredToken.rawValue { 83 - return false 84 - } 57 + if let refreshTask { 58 + return try await refreshTask.value 59 + } 85 60 86 - return false 87 - } 61 + let task = Task { try await handler() } 62 + refreshTask = task 88 63 89 - public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async { 90 - guard let manager = oauthManager else { return } 64 + defer { refreshTask = nil } 91 65 92 - if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 93 - await manager.updateResourceServerNonce(nonce) 66 + return try await task.value 94 67 } 95 68 96 - guard (400..<500).contains(response.statusCode) else { 97 - pendingRetryAction = .none 98 - return 99 - } 100 - 101 - if containsUseDPoPNonce(response: response, data: data) { 102 - pendingRetryAction = .regenerateDPoP 103 - return 104 - } 105 - 106 - if containsInvalidToken(response: response, data: data) { 107 - pendingRetryAction = .refreshToken 108 - return 69 + if attempts == 1, 70 + case .network(let networkError) = error as? AtError, 71 + case .statusCode(let statusCode, _) = networkError, 72 + let statusCode = statusCode?.rawValue, 73 + statusCode == 401 || statusCode == 403 { 74 + return try await refreshViaOAuth() 109 75 } 110 76 111 - pendingRetryAction = .none 112 - } 113 - 114 - private func containsUseDPoPNonce(response: HTTPURLResponse, data: Data) -> Bool { 115 - if header(response, containsError: "use_dpop_nonce") { 116 - return true 77 + if case .message(let message) = error as? AtError, 78 + message.error == AtErrorType.expiredToken.rawValue, 79 + attempts == 1 { 80 + return try await refreshViaOAuth() 117 81 } 118 - if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), 119 - errorResponse.error == "use_dpop_nonce" { 120 - return true 121 - } 122 - return false 123 - } 124 82 125 - private func containsInvalidToken(response: HTTPURLResponse, data: Data) -> Bool { 126 - if header(response, containsError: "invalid_token") { 127 - return true 128 - } 129 - if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), 130 - errorResponse.error == "invalid_token" { 131 - return true 132 - } 133 83 return false 134 - } 135 - 136 - private func header(_ response: HTTPURLResponse, containsError token: String) -> Bool { 137 - guard let header = response.value(forHTTPHeaderField: "WWW-Authenticate") else { return false } 138 - return header.range(of: "error=\"\(token)\"", options: .caseInsensitive) != nil || header.range(of: "error=\(token)", options: .caseInsensitive) != nil 139 84 } 140 85 }
+431
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 1 + import Foundation 2 + import OAuthenticator 3 + import JWTKit 4 + 5 + // MARK: - Re-export OAuthenticator types for convenience 6 + public typealias Login = OAuthenticator.Login 7 + public typealias Token = OAuthenticator.Token 8 + public typealias LoginStorage = OAuthenticator.LoginStorage 9 + typealias ATProto = Bluesky 10 + 11 + // MARK: - Public Types 12 + 13 + /// Result of successful authentication 14 + public struct ATProtoAuthResult: Sendable { 15 + public let did: String 16 + public let handle: String 17 + public let accessToken: String 18 + public let refreshToken: String? 19 + public let expiresIn: Int 20 + public let pdsEndpoint: String 21 + } 22 + 23 + /// Configuration for AT Protocol OAuth 24 + public struct ATProtoOAuthConfig: Sendable { 25 + public let clientMetadataURL: String 26 + public let redirectURI: String 27 + public let scopes: [String] 28 + 29 + public init( 30 + clientMetadataURL: String, 31 + redirectURI: String, 32 + scopes: [String] = ["atproto", "transition:generic"] 33 + ) { 34 + self.clientMetadataURL = clientMetadataURL 35 + self.redirectURI = redirectURI 36 + self.scopes = scopes 37 + } 38 + } 39 + 40 + /// Storage callbacks for persisting login state 41 + public struct ATProtoAuthStorage: Sendable { 42 + public let retrieveLogin: @Sendable () async throws -> Login? 43 + public let storeLogin: @Sendable (Login) async throws -> Void 44 + public let retrievePrivateKey: @Sendable () async throws -> Data? 45 + public let storePrivateKey: @Sendable (Data) async throws -> Void 46 + 47 + public init( 48 + retrieveLogin: @escaping @Sendable () async throws -> Login?, 49 + storeLogin: @escaping @Sendable (Login) async throws -> Void, 50 + retrievePrivateKey: @escaping @Sendable () async throws -> Data?, 51 + storePrivateKey: @escaping @Sendable (Data) async throws -> Void 52 + ) { 53 + self.retrieveLogin = retrieveLogin 54 + self.storeLogin = storeLogin 55 + self.retrievePrivateKey = retrievePrivateKey 56 + self.storePrivateKey = storePrivateKey 57 + } 58 + } 59 + 60 + public enum ATProtoOAuthError: Error, Sendable { 61 + case invalidConfiguration 62 + case authenticationFailed(String) 63 + case identityResolutionFailed 64 + case privateKeyExportFailed 65 + } 66 + 67 + /// Type alias for the user authenticator callback 68 + /// Takes authorization URL and callback scheme, returns the callback URL with auth code 69 + public typealias UserAuthenticator = @Sendable (URL, String) async throws -> URL 70 + 71 + // MARK: - OAuth Client 72 + 73 + /// Main AT Protocol OAuth client - adapted from AtProtocol/Services/AtProto.swift 74 + @APActor 75 + public final class ATProtoOAuth: Sendable { 76 + private let config: ATProtoOAuthConfig 77 + private let storage: ATProtoAuthStorage 78 + private let identityResolver: IdentityResolver 79 + private let dpopRequestActor = DPoPRequestActor() 80 + private var hasPersistedKey: Bool 81 + 82 + // JWT signing keys (pattern from AtProtocol) 83 + private var keys: JWTKeyCollection 84 + private var privateKey: ES256PrivateKey 85 + 86 + public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage) async { 87 + self.config = config 88 + self.storage = storage 89 + self.identityResolver = IdentityResolver() 90 + 91 + // Initialize JWT keys (from AtProto.swift lines 19-23) 92 + if let storedKeyData = try? await storage.retrievePrivateKey(), 93 + let pem = String(data: storedKeyData, encoding: .utf8), 94 + let restoredKey = try? ES256PrivateKey(pem: pem) { 95 + self.privateKey = restoredKey 96 + self.hasPersistedKey = true 97 + } else { 98 + self.privateKey = ES256PrivateKey() 99 + self.hasPersistedKey = false 100 + } 101 + self.keys = JWTKeyCollection() 102 + await self.keys.add(ecdsa: privateKey) 103 + } 104 + 105 + /// Initialize with existing private key (for session restoration) 106 + public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage, privateKeyPEM: String) async throws { 107 + self.config = config 108 + self.storage = storage 109 + self.identityResolver = IdentityResolver() 110 + 111 + // Restore existing key 112 + self.privateKey = try ES256PrivateKey(pem: privateKeyPEM) 113 + self.hasPersistedKey = true 114 + self.keys = JWTKeyCollection() 115 + await self.keys.add(ecdsa: privateKey) 116 + } 117 + 118 + /// Authenticate user by handle 119 + /// - Parameters: 120 + /// - handle: The user's AT Protocol handle (e.g., "alice.bsky.social") 121 + /// - userAuthenticator: Callback to present the authorization URL and return the callback URL 122 + /// - Returns: Authentication result with tokens and user info 123 + public func authenticate( 124 + handle: String, 125 + userAuthenticator: @escaping UserAuthenticator 126 + ) async throws -> ATProtoAuthResult { 127 + // Step 1: Resolve identity 128 + let identity: IdentityResolver.ResolvedIdentity 129 + do { 130 + identity = try await identityResolver.resolve(handle: handle) 131 + } catch { 132 + throw ATProtoOAuthError.authenticationFailed("Identity resolution failed: \(error.localizedDescription)") 133 + } 134 + 135 + // Step 2: Store private key for future sessions 136 + try await persistPrivateKey() 137 + 138 + // Step 3: Load client metadata 139 + let provider = URLSession.defaultProvider 140 + let clientConfig: ClientMetadata 141 + do { 142 + clientConfig = try await ClientMetadata.load( 143 + for: config.clientMetadataURL, 144 + provider: provider 145 + ) 146 + } catch { 147 + throw ATProtoOAuthError.authenticationFailed("Failed to load client metadata from \(config.clientMetadataURL): \(error.localizedDescription)") 148 + } 149 + 150 + // Step 4: Load server metadata 151 + let serverConfig: ServerMetadata 152 + do { 153 + serverConfig = try await ServerMetadata.load( 154 + for: identity.authServerHost, 155 + provider: provider 156 + ) 157 + } catch { 158 + throw ATProtoOAuthError.authenticationFailed("Failed to load server metadata from \(identity.authServerHost): \(error.localizedDescription)") 159 + } 160 + 161 + // Step 5: Create login storage 162 + let loginStorage = LoginStorage( 163 + retrieveLogin: storage.retrieveLogin, 164 + storeLogin: storage.storeLogin 165 + ) 166 + 167 + // Step 6: Create JWT generator 168 + let jwtGenerator: DPoPSigner.JWTGenerator = { [self] params in 169 + try await self.generateJWT(params: params) 170 + } 171 + 172 + // Step 7: Create authenticator 173 + let tokenHandling = ATProto.tokenHandling( 174 + account: handle, 175 + server: serverConfig, 176 + jwtGenerator: jwtGenerator 177 + ) 178 + 179 + let authenticatorConfig = Authenticator.Configuration( 180 + appCredentials: clientConfig.credentials, 181 + loginStorage: loginStorage, 182 + tokenHandling: tokenHandling, 183 + mode: .manualOnly, 184 + userAuthenticator: userAuthenticator 185 + ) 186 + 187 + let authenticator = Authenticator(config: authenticatorConfig) 188 + 189 + // Step 8: Trigger authentication with user interaction 190 + let login: Login 191 + do { 192 + login = try await authenticator.authenticate() 193 + } catch { 194 + if shouldRecoverFromRefreshFailure(error) { 195 + try await storage.storeLogin(Login(token: "invalid", validUntilDate: .distantPast)) 196 + try await resetDPoPKey() 197 + do { 198 + login = try await authenticator.authenticate() 199 + } catch { 200 + throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 201 + } 202 + } else { 203 + throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 204 + } 205 + } 206 + 207 + // Step 9: Setup CoreATProtocol environment 208 + setup( 209 + hostURL: identity.pdsEndpoint, 210 + accessJWT: login.accessToken.value, 211 + refreshJWT: login.refreshToken?.value, 212 + delegate: nil 213 + ) 214 + 215 + return ATProtoAuthResult( 216 + did: identity.did, 217 + handle: identity.handle, 218 + accessToken: login.accessToken.value, 219 + refreshToken: login.refreshToken?.value, 220 + expiresIn: Int(login.accessToken.expiry?.timeIntervalSinceNow ?? 3600), 221 + pdsEndpoint: identity.pdsEndpoint 222 + ) 223 + } 224 + 225 + /// Refresh tokens if the stored access token is expired (or if forced). 226 + public func refreshLoginIfNeeded(handle: String? = nil, force: Bool = false) async throws -> Login? { 227 + guard let login = try await storage.retrieveLogin() else { 228 + return nil 229 + } 230 + 231 + if !force, login.accessToken.valid { 232 + return nil 233 + } 234 + 235 + guard hasPersistedKey else { 236 + return nil 237 + } 238 + 239 + guard login.refreshToken?.valid == true else { 240 + return nil 241 + } 242 + 243 + let issuer: String 244 + if let issuingServer = login.issuingServer { 245 + issuer = issuingServer 246 + } else if let handle { 247 + let identity = try await identityResolver.resolve(handle: handle) 248 + issuer = identity.authorizationServer 249 + } else { 250 + return nil 251 + } 252 + 253 + let provider = URLSession.defaultProvider 254 + let serverHost = stripScheme(from: issuer) 255 + let serverConfig = try await ServerMetadata.load(for: serverHost, provider: provider) 256 + let clientConfig = try await ClientMetadata.load(for: config.clientMetadataURL, provider: provider) 257 + let jwtGenerator: DPoPSigner.JWTGenerator = { [self] params in 258 + try await self.generateJWT(params: params) 259 + } 260 + let tokenHandling = ATProto.tokenHandling( 261 + account: handle, 262 + server: serverConfig, 263 + jwtGenerator: jwtGenerator 264 + ) 265 + 266 + guard let refreshProvider = tokenHandling.refreshProvider else { 267 + return nil 268 + } 269 + 270 + let responseProvider: URLResponseProvider = { request in 271 + try await self.dpopRequestActor.response( 272 + request: request, 273 + jwtGenerator: jwtGenerator, 274 + provider: provider, 275 + issuingServer: issuer 276 + ) 277 + } 278 + 279 + let refreshedLogin = try await refreshProvider(login, clientConfig.credentials, responseProvider) 280 + try await storage.storeLogin(refreshedLogin) 281 + return refreshedLogin 282 + } 283 + 284 + /// Export private key PEM for persistence 285 + public var privateKeyPEM: String { 286 + privateKey.pemRepresentation 287 + } 288 + 289 + // MARK: - Private (from AtProto.swift lines 60-72) 290 + 291 + private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String { 292 + // Strip query params and fragments from htu per DPoP spec 293 + let htu = stripQueryAndFragment(from: params.requestEndpoint) 294 + 295 + let payload = DPoPPayload( 296 + htm: params.httpMethod, 297 + htu: htu, 298 + iat: .init(value: .now), 299 + jti: .init(value: UUID().uuidString), 300 + nonce: params.nonce 301 + ) 302 + 303 + // DPoP requires typ="dpop+jwt", alg="ES256", and the public key in jwk header 304 + var header = JWTHeader() 305 + header.typ = "dpop+jwt" 306 + header.alg = "ES256" 307 + 308 + // Get public key parameters and convert to base64url for JWK 309 + if let keyParams = privateKey.parameters { 310 + // Convert from base64 to base64url (replace + with -, / with _, remove =) 311 + let xBase64URL = keyParams.x 312 + .replacingOccurrences(of: "+", with: "-") 313 + .replacingOccurrences(of: "/", with: "_") 314 + .replacingOccurrences(of: "=", with: "") 315 + let yBase64URL = keyParams.y 316 + .replacingOccurrences(of: "+", with: "-") 317 + .replacingOccurrences(of: "/", with: "_") 318 + .replacingOccurrences(of: "=", with: "") 319 + 320 + header.jwk = [ 321 + "kty": .string("EC"), 322 + "crv": .string("P-256"), 323 + "x": .string(xBase64URL), 324 + "y": .string(yBase64URL) 325 + ] 326 + } 327 + 328 + return try await self.keys.sign(payload, header: header) 329 + } 330 + 331 + /// Strip query string and fragment from URL per DPoP spec 332 + private func stripQueryAndFragment(from url: String) -> String { 333 + let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1 334 + let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1 335 + 336 + let end: Int 337 + if fragmentIndex == -1 { 338 + end = queryIndex 339 + } else if queryIndex == -1 { 340 + end = fragmentIndex 341 + } else { 342 + end = min(fragmentIndex, queryIndex) 343 + } 344 + 345 + return end == -1 ? url : String(url.prefix(end)) 346 + } 347 + 348 + private func stripScheme(from url: String) -> String { 349 + if url.hasPrefix("https://") { 350 + return String(url.dropFirst(8)) 351 + } else if url.hasPrefix("http://") { 352 + return String(url.dropFirst(7)) 353 + } 354 + return url 355 + } 356 + 357 + private func persistPrivateKey() async throws { 358 + let keyPEM = privateKey.pemRepresentation 359 + guard let keyData = keyPEM.data(using: .utf8) else { 360 + throw ATProtoOAuthError.privateKeyExportFailed 361 + } 362 + try await storage.storePrivateKey(keyData) 363 + hasPersistedKey = true 364 + } 365 + 366 + private func resetDPoPKey() async throws { 367 + privateKey = ES256PrivateKey() 368 + keys = JWTKeyCollection() 369 + await keys.add(ecdsa: privateKey) 370 + hasPersistedKey = false 371 + try await persistPrivateKey() 372 + } 373 + 374 + private func shouldRecoverFromRefreshFailure(_ error: Error) -> Bool { 375 + guard let authError = error as? AuthenticatorError else { 376 + return false 377 + } 378 + 379 + switch authError { 380 + case .refreshNotPossible, .unauthorizedRefreshFailed, .dpopTokenExpected, .httpResponseExpected: 381 + return true 382 + default: 383 + return false 384 + } 385 + } 386 + } 387 + 388 + // MARK: - DPoP Payload (from AtProto.swift lines 88-98) 389 + 390 + private struct DPoPPayload: JWTPayload { 391 + let htm: String 392 + let htu: String 393 + let iat: IssuedAtClaim 394 + let jti: IDClaim 395 + let nonce: String? 396 + 397 + func verify(using key: some JWTAlgorithm) throws { 398 + // No additional verification needed for DPoP 399 + } 400 + } 401 + 402 + private actor DPoPRequestActor { 403 + private let signer = DPoPSigner() 404 + 405 + func response( 406 + request: URLRequest, 407 + jwtGenerator: DPoPSigner.JWTGenerator, 408 + provider: URLResponseProvider, 409 + issuingServer: String? 410 + ) async throws -> (Data, URLResponse) { 411 + try await signer.response( 412 + isolation: self, 413 + for: request, 414 + using: jwtGenerator, 415 + token: nil, 416 + tokenHash: nil, 417 + issuingServer: issuingServer, 418 + provider: provider 419 + ) 420 + } 421 + } 422 + 423 + // MARK: - URLSession Extension 424 + 425 + extension URLSession { 426 + static var defaultProvider: URLResponseProvider { 427 + { request in 428 + try await URLSession.shared.data(for: request) 429 + } 430 + } 431 + }
-58
Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift
··· 1 - import Foundation 2 - 3 - enum DNSResolverError: Error, Sendable { 4 - case invalidResponse 5 - } 6 - 7 - protocol DNSResolving: Sendable { 8 - func txtRecords(for host: String) async throws -> [String] 9 - } 10 - 11 - @APActor 12 - final class DoHDNSResolver: DNSResolving { 13 - private let baseURL: URL 14 - private let httpClient: OAuthHTTPClient 15 - 16 - init(baseURL: URL = URL(string: "https://cloudflare-dns.com/dns-query")!, httpClient: OAuthHTTPClient = OAuthHTTPClient()) { 17 - self.baseURL = baseURL 18 - self.httpClient = httpClient 19 - } 20 - 21 - func txtRecords(for host: String) async throws -> [String] { 22 - guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { 23 - throw DNSResolverError.invalidResponse 24 - } 25 - var queryItems = components.queryItems ?? [] 26 - queryItems.append(URLQueryItem(name: "name", value: host)) 27 - queryItems.append(URLQueryItem(name: "type", value: "TXT")) 28 - components.queryItems = queryItems 29 - guard let url = components.url else { throw DNSResolverError.invalidResponse } 30 - var request = URLRequest(url: url) 31 - request.setValue("application/dns-json", forHTTPHeaderField: "Accept") 32 - let (data, _) = try await httpClient.send(request) 33 - let response = try httpClient.decodeJSON(DNSResponse.self, from: data) 34 - return response.answers?.compactMap { $0.txtValue } ?? [] 35 - } 36 - 37 - private struct DNSResponse: Decodable { 38 - struct Answer: Decodable { 39 - let data: String 40 - 41 - var txtValue: String? { 42 - guard data.count >= 2 else { return nil } 43 - var trimmed = data 44 - if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") { 45 - trimmed.removeFirst() 46 - trimmed.removeLast() 47 - } 48 - return trimmed 49 - } 50 - } 51 - 52 - private enum CodingKeys: String, CodingKey { 53 - case answers = "Answer" 54 - } 55 - 56 - let answers: [Answer]? 57 - } 58 - }
-134
Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift
··· 1 - import Foundation 2 - 3 - enum IdentityResolverError: Error, Sendable { 4 - case unableToResolveHandle 5 - case invalidDID 6 - case unsupportedDIDMethod 7 - case missingPDSService 8 - } 9 - 10 - @APActor 11 - final class IdentityResolver: Sendable { 12 - private let httpClient: OAuthHTTPClient 13 - private let dnsResolver: DNSResolving 14 - 15 - init(httpClient: OAuthHTTPClient = OAuthHTTPClient(), dnsResolver: DNSResolving = DoHDNSResolver()) { 16 - self.httpClient = httpClient 17 - self.dnsResolver = dnsResolver 18 - } 19 - 20 - func resolveHandle(_ handle: String) async throws -> String { 21 - if handle.lowercased().hasPrefix("did:") { 22 - return handle 23 - } 24 - 25 - if let did = try? await resolveViaHTTPS(handle: handle) { 26 - return did 27 - } 28 - 29 - if let did = try? await resolveViaDNS(handle: handle) { 30 - return did 31 - } 32 - 33 - throw IdentityResolverError.unableToResolveHandle 34 - } 35 - 36 - func fetchDIDDocument(for did: String) async throws -> DIDDocument { 37 - if did.hasPrefix("did:plc:") { 38 - guard let encodedDID = did.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), 39 - let url = URL(string: "https://plc.directory/\(encodedDID)") else { 40 - throw IdentityResolverError.invalidDID 41 - } 42 - return try await fetchJSON(url: url, type: DIDDocument.self) 43 - } else if did.hasPrefix("did:web:") { 44 - let components = try webDIDComponents(did: did) 45 - return try await fetchJSON(url: components.url, type: DIDDocument.self) 46 - } else { 47 - throw IdentityResolverError.unsupportedDIDMethod 48 - } 49 - } 50 - 51 - func discoverProtectedResource(for pdsURL: URL) async throws -> OAuthProtectedResourceMetadata { 52 - let endpoint = pdsURL.appendingPathComponent(".well-known/oauth-protected-resource") 53 - return try await fetchJSON(url: endpoint, type: OAuthProtectedResourceMetadata.self) 54 - } 55 - 56 - func fetchAuthorizationServerMetadata(from url: URL) async throws -> OAuthAuthorizationServerMetadata { 57 - let endpoint = url.appendingPathComponent(".well-known/oauth-authorization-server") 58 - return try await fetchJSON(url: endpoint, type: OAuthAuthorizationServerMetadata.self) 59 - } 60 - 61 - func extractPDSEndpoint(from document: DIDDocument) throws -> URL { 62 - guard let service = document.service(ofType: "AtprotoPersonalDataServer"), let url = URL(string: service.serviceEndpoint) else { 63 - throw IdentityResolverError.missingPDSService 64 - } 65 - return url 66 - } 67 - 68 - // MARK: - Private 69 - 70 - private func resolveViaHTTPS(handle: String) async throws -> String? { 71 - var components = URLComponents() 72 - components.scheme = "https" 73 - components.host = handle 74 - components.path = "/.well-known/atproto-did" 75 - guard let url = components.url else { return nil } 76 - var request = URLRequest(url: url) 77 - request.timeoutInterval = 5 78 - let (data, response) = try await httpClient.send(request) 79 - guard (200..<300).contains(response.statusCode) else { return nil } 80 - let did = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) 81 - guard did.isEmpty == false, did.lowercased().hasPrefix("did:") else { return nil } 82 - return did 83 - } 84 - 85 - private func resolveViaDNS(handle: String) async throws -> String? { 86 - let hostname = "_atproto.\(handle)" 87 - let records = try await dnsResolver.txtRecords(for: hostname) 88 - for record in records { 89 - let parts = record.split(separator: "=", maxSplits: 1).map(String.init) 90 - if parts.count == 2, parts[0] == "did" { 91 - return parts[1] 92 - } 93 - } 94 - return nil 95 - } 96 - 97 - private func fetchJSON<T: Decodable>(url: URL, type: T.Type) async throws -> T { 98 - var request = URLRequest(url: url) 99 - request.setValue("application/json", forHTTPHeaderField: "Accept") 100 - let (data, response) = try await httpClient.send(request) 101 - guard (200..<300).contains(response.statusCode) else { 102 - throw IdentityResolverError.invalidDID 103 - } 104 - return try httpClient.decodeJSON(T.self, from: data) 105 - } 106 - 107 - private func webDIDComponents(did: String) throws -> (host: String, pathSegments: [String], url: URL) { 108 - let prefix = "did:web:" 109 - guard did.hasPrefix(prefix) else { throw IdentityResolverError.invalidDID } 110 - let suffix = String(did.dropFirst(prefix.count)) 111 - let segments = suffix.split(separator: ":").map { segment in 112 - segment.removingPercentEncoding ?? String(segment) 113 - } 114 - guard let host = segments.first else { 115 - throw IdentityResolverError.invalidDID 116 - } 117 - let pathSegments = Array(segments.dropFirst()) 118 - var components = URLComponents() 119 - components.scheme = "https" 120 - components.host = host 121 - let path: String 122 - if pathSegments.isEmpty { 123 - path = "/.well-known/did.json" 124 - } else { 125 - let joined = pathSegments.joined(separator: "/") 126 - path = "/\(joined)/did.json" 127 - } 128 - components.path = path 129 - guard let url = components.url else { 130 - throw IdentityResolverError.invalidDID 131 - } 132 - return (host, pathSegments, url) 133 - } 134 - }
+190
Sources/CoreATProtocol/OAuth/IdentityResolver.swift
··· 1 + import Foundation 2 + 3 + public enum IdentityError: Error, Sendable { 4 + case invalidHandle 5 + case invalidDID 6 + case resolutionFailed 7 + case noPDSFound 8 + case noAuthServerFound 9 + } 10 + 11 + /// Resolves AT Protocol identities: handle -> DID -> PDS -> Auth Server 12 + @APActor 13 + public struct IdentityResolver: Sendable { 14 + 15 + public struct ResolvedIdentity: Sendable { 16 + public let handle: String 17 + public let did: String 18 + public let pdsEndpoint: String 19 + public let authorizationServer: String 20 + 21 + /// Server hostname for OAuthenticator's ServerMetadata.load() 22 + public var authServerHost: String { 23 + if authorizationServer.hasPrefix("https://") { 24 + return String(authorizationServer.dropFirst(8)) 25 + } else if authorizationServer.hasPrefix("http://") { 26 + return String(authorizationServer.dropFirst(7)) 27 + } 28 + return authorizationServer 29 + } 30 + } 31 + 32 + public init() {} 33 + 34 + /// Full resolution: handle -> all identity info needed for OAuth 35 + public func resolve(handle: String) async throws -> ResolvedIdentity { 36 + let cleanHandle = handle.replacingOccurrences(of: "@", with: "") 37 + 38 + // Step 1: Handle -> DID 39 + let did = try await resolveHandle(cleanHandle) 40 + 41 + // Step 2: DID -> PDS 42 + let pds = try await resolvePDS(did: did) 43 + 44 + // Step 3: PDS -> Auth Server 45 + let authServer = try await discoverAuthServer(pdsURL: pds) 46 + 47 + return ResolvedIdentity( 48 + handle: cleanHandle, 49 + did: did, 50 + pdsEndpoint: pds, 51 + authorizationServer: authServer 52 + ) 53 + } 54 + 55 + // MARK: - Handle -> DID 56 + 57 + private func resolveHandle(_ handle: String) async throws -> String { 58 + // Try HTTPS first, fall back to DNS 59 + do { 60 + return try await resolveViaHTTPS(handle: handle) 61 + } catch { 62 + return try await resolveViaDNS(handle: handle) 63 + } 64 + } 65 + 66 + private func resolveViaHTTPS(handle: String) async throws -> String { 67 + guard let url = URL(string: "https://\(handle)/.well-known/atproto-did") else { 68 + throw IdentityError.invalidHandle 69 + } 70 + 71 + let (data, response) = try await URLSession.shared.data(from: url) 72 + 73 + guard let httpResponse = response as? HTTPURLResponse, 74 + httpResponse.statusCode == 200 else { 75 + throw IdentityError.resolutionFailed 76 + } 77 + 78 + guard let did = String(data: data, encoding: .utf8)? 79 + .trimmingCharacters(in: .whitespacesAndNewlines), 80 + did.hasPrefix("did:") else { 81 + throw IdentityError.invalidDID 82 + } 83 + 84 + return did 85 + } 86 + 87 + private func resolveViaDNS(handle: String) async throws -> String { 88 + // Use Cloudflare DNS-over-HTTPS for TXT record lookup 89 + let hostname = "_atproto.\(handle)" 90 + guard let dohURL = URL(string: "https://1.1.1.1/dns-query?name=\(hostname)&type=TXT") else { 91 + throw IdentityError.resolutionFailed 92 + } 93 + 94 + var request = URLRequest(url: dohURL) 95 + request.setValue("application/dns-json", forHTTPHeaderField: "Accept") 96 + 97 + let (data, _) = try await URLSession.shared.data(for: request) 98 + 99 + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 100 + let answers = json["Answer"] as? [[String: Any]] else { 101 + throw IdentityError.resolutionFailed 102 + } 103 + 104 + for answer in answers { 105 + if let txtData = answer["data"] as? String { 106 + let cleanData = txtData.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) 107 + if cleanData.hasPrefix("did=") { 108 + let did = String(cleanData.dropFirst(4)) 109 + if did.hasPrefix("did:") { 110 + return did 111 + } 112 + } 113 + } 114 + } 115 + 116 + throw IdentityError.resolutionFailed 117 + } 118 + 119 + // MARK: - DID -> PDS 120 + 121 + private func resolvePDS(did: String) async throws -> String { 122 + let url: URL 123 + if did.hasPrefix("did:plc:") { 124 + guard let plcURL = URL(string: "https://plc.directory/\(did)") else { 125 + throw IdentityError.invalidDID 126 + } 127 + url = plcURL 128 + } else if did.hasPrefix("did:web:") { 129 + let domain = did.replacingOccurrences(of: "did:web:", with: "") 130 + guard let webURL = URL(string: "https://\(domain)/.well-known/did.json") else { 131 + throw IdentityError.invalidDID 132 + } 133 + url = webURL 134 + } else { 135 + throw IdentityError.invalidDID 136 + } 137 + 138 + let (data, _) = try await URLSession.shared.data(from: url) 139 + let document = try JSONDecoder().decode(DIDDocument.self, from: data) 140 + 141 + guard let pds = document.pdsEndpoint else { 142 + throw IdentityError.noPDSFound 143 + } 144 + 145 + return pds 146 + } 147 + 148 + // MARK: - PDS -> Auth Server 149 + 150 + private func discoverAuthServer(pdsURL: String) async throws -> String { 151 + guard let metadataURL = URL(string: "\(pdsURL)/.well-known/oauth-protected-resource") else { 152 + throw IdentityError.noAuthServerFound 153 + } 154 + 155 + let (data, _) = try await URLSession.shared.data(from: metadataURL) 156 + let metadata = try JSONDecoder().decode(ResourceServerMetadata.self, from: data) 157 + 158 + guard let authServer = metadata.authorizationServers.first else { 159 + throw IdentityError.noAuthServerFound 160 + } 161 + 162 + return authServer 163 + } 164 + } 165 + 166 + // MARK: - Supporting Types 167 + 168 + struct DIDDocument: Codable, Sendable { 169 + let id: String 170 + let alsoKnownAs: [String]? 171 + let service: [DIDService]? 172 + 173 + var pdsEndpoint: String? { 174 + service?.first { $0.id.hasSuffix("#atproto_pds") }?.serviceEndpoint 175 + } 176 + } 177 + 178 + struct DIDService: Codable, Sendable { 179 + let id: String 180 + let type: String 181 + let serviceEndpoint: String 182 + } 183 + 184 + struct ResourceServerMetadata: Codable, Sendable { 185 + let authorizationServers: [String] 186 + 187 + enum CodingKeys: String, CodingKey { 188 + case authorizationServers = "authorization_servers" 189 + } 190 + }
-47
Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift
··· 1 - import Foundation 2 - 3 - struct DIDDocument: Decodable, Sendable { 4 - struct Service: Decodable, Sendable { 5 - let id: String 6 - let type: String 7 - let serviceEndpoint: String 8 - 9 - private enum CodingKeys: String, CodingKey { 10 - case id 11 - case type 12 - case serviceEndpoint 13 - } 14 - 15 - init(from decoder: Decoder) throws { 16 - let container = try decoder.container(keyedBy: CodingKeys.self) 17 - self.id = try container.decode(String.self, forKey: .id) 18 - self.type = try container.decode(String.self, forKey: .type) 19 - if let endpoint = try? container.decode(String.self, forKey: .serviceEndpoint) { 20 - self.serviceEndpoint = endpoint 21 - } else if let endpointObject = try? container.decode(ServiceEndpoint.self, forKey: .serviceEndpoint) { 22 - guard let uri = endpointObject.uri else { 23 - throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Missing uri field in service endpoint object") 24 - } 25 - self.serviceEndpoint = uri 26 - } else { 27 - throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Unsupported service endpoint type") 28 - } 29 - } 30 - 31 - private struct ServiceEndpoint: Decodable { 32 - let uri: String? 33 - } 34 - } 35 - 36 - let id: String 37 - let services: [Service] 38 - 39 - private enum CodingKeys: String, CodingKey { 40 - case id 41 - case services = "service" 42 - } 43 - 44 - func service(ofType type: String) -> Service? { 45 - services.first { $0.type.localizedCaseInsensitiveCompare(type) == .orderedSame } 46 - } 47 - }
-30
Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift
··· 1 - import Foundation 2 - 3 - public struct OAuthConfiguration: Sendable { 4 - public let clientMetadataURL: URL 5 - public let redirectURI: URL 6 - public let requestedScopes: [String] 7 - public let additionalAuthorizationParameters: [String: String] 8 - 9 - public init( 10 - clientMetadataURL: URL, 11 - redirectURI: URL, 12 - requestedScopes: [String] = ["atproto"], 13 - additionalAuthorizationParameters: [String: String] = [:] 14 - ) { 15 - self.clientMetadataURL = clientMetadataURL 16 - self.redirectURI = redirectURI 17 - var scopes = requestedScopes 18 - if scopes.isEmpty { 19 - scopes = ["atproto"] 20 - } else if scopes.contains("atproto") == false { 21 - scopes.append("atproto") 22 - } 23 - var uniqueScopes: [String] = [] 24 - for scope in scopes where uniqueScopes.contains(scope) == false { 25 - uniqueScopes.append(scope) 26 - } 27 - self.requestedScopes = uniqueScopes 28 - self.additionalAuthorizationParameters = additionalAuthorizationParameters 29 - } 30 - }
-160
Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift
··· 1 - import Foundation 2 - 3 - struct OAuthProtectedResourceMetadata: Decodable, Sendable { 4 - let authorizationServers: [URL] 5 - 6 - private enum CodingKeys: String, CodingKey { 7 - case authorizationServers 8 - } 9 - 10 - init(from decoder: Decoder) throws { 11 - let container = try decoder.container(keyedBy: CodingKeys.self) 12 - let values = try container.decodeIfPresent([String].self, forKey: .authorizationServers) ?? [] 13 - self.authorizationServers = try values.map { value in 14 - guard let url = URL(string: value) else { 15 - throw DecodingError.dataCorruptedError(forKey: .authorizationServers, in: container, debugDescription: "Invalid authorization server URL") 16 - } 17 - return url 18 - } 19 - } 20 - } 21 - 22 - struct OAuthAuthorizationServerMetadata: Decodable, Sendable { 23 - let issuer: URL 24 - let authorizationEndpoint: URL 25 - let tokenEndpoint: URL 26 - let pushedAuthorizationRequestEndpoint: URL 27 - let codeChallengeMethodsSupported: [String] 28 - let dPoPSigningAlgValuesSupported: [String] 29 - let scopesSupported: [String] 30 - 31 - private enum CodingKeys: String, CodingKey { 32 - case issuer 33 - case authorizationEndpoint 34 - case tokenEndpoint 35 - case pushedAuthorizationRequestEndpoint 36 - case codeChallengeMethodsSupported 37 - case dPoPSigningAlgValuesSupported = "dpopSigningAlgValuesSupported" 38 - case scopesSupported 39 - } 40 - 41 - init(from decoder: Decoder) throws { 42 - let container = try decoder.container(keyedBy: CodingKeys.self) 43 - guard let issuer = URL(string: try container.decode(String.self, forKey: .issuer)) else { 44 - throw DecodingError.dataCorruptedError(forKey: .issuer, in: container, debugDescription: "Invalid issuer URL") 45 - } 46 - guard let authorizationEndpoint = URL(string: try container.decode(String.self, forKey: .authorizationEndpoint)) else { 47 - throw DecodingError.dataCorruptedError(forKey: .authorizationEndpoint, in: container, debugDescription: "Invalid authorization endpoint") 48 - } 49 - guard let tokenEndpoint = URL(string: try container.decode(String.self, forKey: .tokenEndpoint)) else { 50 - throw DecodingError.dataCorruptedError(forKey: .tokenEndpoint, in: container, debugDescription: "Invalid token endpoint") 51 - } 52 - guard let parEndpoint = URL(string: try container.decode(String.self, forKey: .pushedAuthorizationRequestEndpoint)) else { 53 - throw DecodingError.dataCorruptedError(forKey: .pushedAuthorizationRequestEndpoint, in: container, debugDescription: "Invalid PAR endpoint") 54 - } 55 - 56 - self.issuer = issuer 57 - self.authorizationEndpoint = authorizationEndpoint 58 - self.tokenEndpoint = tokenEndpoint 59 - self.pushedAuthorizationRequestEndpoint = parEndpoint 60 - self.codeChallengeMethodsSupported = try container.decodeIfPresent([String].self, forKey: .codeChallengeMethodsSupported) ?? [] 61 - self.dPoPSigningAlgValuesSupported = try container.decodeIfPresent([String].self, forKey: .dPoPSigningAlgValuesSupported) ?? [] 62 - self.scopesSupported = try container.decodeIfPresent([String].self, forKey: .scopesSupported) ?? [] 63 - } 64 - } 65 - 66 - struct OAuthClientMetadata: Decodable, Sendable { 67 - let clientID: URL 68 - let scope: String 69 - let redirectURIs: [URL] 70 - let grantTypes: [String] 71 - let responseTypes: [String] 72 - let tokenEndpointAuthMethod: String 73 - let tokenEndpointAuthSigningAlg: String? 74 - let dPoPBoundAccessTokens: Bool 75 - 76 - private enum CodingKeys: String, CodingKey { 77 - case clientID = "clientId" 78 - case scope 79 - case redirectURIs = "redirectUris" 80 - case grantTypes 81 - case responseTypes 82 - case tokenEndpointAuthMethod 83 - case tokenEndpointAuthSigningAlg 84 - case dPoPBoundAccessTokens = "dpopBoundAccessTokens" 85 - } 86 - 87 - init(from decoder: Decoder) throws { 88 - let container = try decoder.container(keyedBy: CodingKeys.self) 89 - guard let clientID = URL(string: try container.decode(String.self, forKey: .clientID)) else { 90 - throw DecodingError.dataCorruptedError(forKey: .clientID, in: container, debugDescription: "Invalid client metadata URL") 91 - } 92 - self.clientID = clientID 93 - self.scope = try container.decode(String.self, forKey: .scope) 94 - let redirectStrings = try container.decode([String].self, forKey: .redirectURIs) 95 - self.redirectURIs = try redirectStrings.map { value in 96 - guard let url = URL(string: value) else { 97 - throw DecodingError.dataCorruptedError(forKey: .redirectURIs, in: container, debugDescription: "Invalid redirect URI") 98 - } 99 - return url 100 - } 101 - self.grantTypes = try container.decode([String].self, forKey: .grantTypes) 102 - self.responseTypes = try container.decode([String].self, forKey: .responseTypes) 103 - self.tokenEndpointAuthMethod = try container.decode(String.self, forKey: .tokenEndpointAuthMethod) 104 - self.tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg) 105 - self.dPoPBoundAccessTokens = try container.decode(Bool.self, forKey: .dPoPBoundAccessTokens) 106 - } 107 - } 108 - 109 - struct OAuthTokenResponse: Decodable, Sendable { 110 - let accessToken: String 111 - let refreshToken: String? 112 - let tokenType: String 113 - let expiresIn: TimeInterval? 114 - let scope: String? 115 - let issuedTokenType: String? 116 - let subject: String? 117 - 118 - private enum CodingKeys: String, CodingKey { 119 - case accessToken = "access_token" 120 - case refreshToken = "refresh_token" 121 - case tokenType = "token_type" 122 - case expiresIn = "expires_in" 123 - case scope 124 - case issuedTokenType = "issued_token_type" 125 - case subject = "sub" 126 - } 127 - } 128 - 129 - struct PushedAuthorizationRequestResponse: Decodable, Sendable { 130 - let requestURI: String 131 - let expiresIn: Int 132 - 133 - private enum CodingKeys: String, CodingKey { 134 - case requestURI = "request_uri" 135 - case expiresIn = "expires_in" 136 - } 137 - } 138 - 139 - struct OAuthErrorResponse: Decodable, Error, Sendable { 140 - let error: String 141 - let errorDescription: String? 142 - let errorURI: URL? 143 - 144 - private enum CodingKeys: String, CodingKey { 145 - case error 146 - case errorDescription = "error_description" 147 - case errorURI = "error_uri" 148 - } 149 - 150 - init(from decoder: Decoder) throws { 151 - let container = try decoder.container(keyedBy: CodingKeys.self) 152 - self.error = try container.decode(String.self, forKey: .error) 153 - self.errorDescription = try container.decodeIfPresent(String.self, forKey: .errorDescription) 154 - if let raw = try container.decodeIfPresent(String.self, forKey: .errorURI) { 155 - self.errorURI = URL(string: raw) 156 - } else { 157 - self.errorURI = nil 158 - } 159 - } 160 - }
-53
Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift
··· 1 - import Foundation 2 - 3 - public struct OAuthSession: Codable, Sendable { 4 - public let did: String 5 - public let pdsURL: URL 6 - public let authorizationServer: URL 7 - public let tokenEndpoint: URL 8 - public let accessToken: String 9 - public let refreshToken: String 10 - public let tokenType: String 11 - public let scope: String? 12 - public let expiresIn: TimeInterval? 13 - public let issuedAt: Date 14 - 15 - public init( 16 - did: String, 17 - pdsURL: URL, 18 - authorizationServer: URL, 19 - tokenEndpoint: URL, 20 - accessToken: String, 21 - refreshToken: String, 22 - tokenType: String, 23 - scope: String?, 24 - expiresIn: TimeInterval?, 25 - issuedAt: Date 26 - ) { 27 - self.did = did 28 - self.pdsURL = pdsURL 29 - self.authorizationServer = authorizationServer 30 - self.tokenEndpoint = tokenEndpoint 31 - self.accessToken = accessToken 32 - self.refreshToken = refreshToken 33 - self.tokenType = tokenType 34 - self.scope = scope 35 - self.expiresIn = expiresIn 36 - self.issuedAt = issuedAt 37 - } 38 - 39 - public func isExpired(relativeTo date: Date = Date(), tolerance: TimeInterval = 0) -> Bool { 40 - guard let expiresAt else { return false } 41 - return expiresAt <= date.addingTimeInterval(tolerance * -1) 42 - } 43 - 44 - public func needsRefresh(relativeTo date: Date = Date(), threshold: TimeInterval = 300) -> Bool { 45 - guard let expiresAt else { return false } 46 - return expiresAt <= date.addingTimeInterval(threshold) 47 - } 48 - 49 - public var expiresAt: Date? { 50 - guard let expiresIn else { return nil } 51 - return issuedAt.addingTimeInterval(expiresIn) 52 - } 53 - }
-34
Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift
··· 1 - import Foundation 2 - 3 - enum OAuthNetworkingError: Error, Sendable { 4 - case invalidResponse 5 - } 6 - 7 - @APActor 8 - final class OAuthHTTPClient: Sendable { 9 - private let networking: Networking 10 - private let jsonDecoder: JSONDecoder 11 - 12 - init(networking: Networking = URLSession.shared, decoder: JSONDecoder? = nil) { 13 - self.networking = networking 14 - if let decoder { 15 - self.jsonDecoder = decoder 16 - } else { 17 - let decoder = JSONDecoder() 18 - decoder.keyDecodingStrategy = .convertFromSnakeCase 19 - self.jsonDecoder = decoder 20 - } 21 - } 22 - 23 - func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { 24 - let (data, response) = try await networking.data(for: request, delegate: nil) 25 - guard let httpResponse = response as? HTTPURLResponse else { 26 - throw OAuthNetworkingError.invalidResponse 27 - } 28 - return (data, httpResponse) 29 - } 30 - 31 - func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { 32 - try jsonDecoder.decode(T.self, from: data) 33 - } 34 - }
-507
Sources/CoreATProtocol/OAuth/OAuthManager.swift
··· 1 - import Foundation 2 - 3 - public enum OAuthManagerError: Error, Sendable { 4 - case missingAuthorizationServer 5 - case invalidAuthorizationState 6 - case authorizationInProgress 7 - case callbackStateMismatch 8 - case authorizationCancelled 9 - case tokenExchangeFailed 10 - case refreshFailed 11 - case invalidRedirectURL 12 - case unsupportedAuthorizationServer 13 - case clientMetadataValidationFailed 14 - case identityResolutionFailed 15 - case missingSession 16 - case invalidRequest 17 - } 18 - 19 - public struct AuthorizationRequest: Sendable { 20 - public let authorizationURL: URL 21 - public let redirectURI: URL 22 - } 23 - 24 - @APActor 25 - public final class OAuthManager: Sendable { 26 - private let configuration: OAuthConfiguration 27 - private let credentialStore: OAuthCredentialStore 28 - private let identityResolver: IdentityResolver 29 - private let httpClient: OAuthHTTPClient 30 - private let randomGenerator: RandomDataGenerating 31 - private var dpopGenerator: DPoPGenerator 32 - 33 - private var cachedClientMetadata: OAuthClientMetadata? 34 - private var pendingAuthorization: PendingAuthorization? 35 - private var cachedSession: OAuthSession? 36 - private var authorizationServerNonce: String? 37 - private var resourceServerNonce: String? 38 - 39 - init( 40 - configuration: OAuthConfiguration, 41 - credentialStore: OAuthCredentialStore, 42 - identityResolver: IdentityResolver = IdentityResolver(), 43 - httpClient: OAuthHTTPClient = OAuthHTTPClient(), 44 - randomGenerator: RandomDataGenerating = SecureRandomDataGenerator() 45 - ) async throws { 46 - self.configuration = configuration 47 - self.credentialStore = credentialStore 48 - self.identityResolver = identityResolver 49 - self.httpClient = httpClient 50 - self.randomGenerator = randomGenerator 51 - 52 - if let keyData = try await credentialStore.loadDPoPKey(), 53 - (try? DPoPKeyPair(rawRepresentation: keyData)) != nil { 54 - self.dpopGenerator = DPoPGenerator(keyPair: try DPoPKeyPair(rawRepresentation: keyData)) 55 - } else { 56 - let keyPair = DPoPKeyPair() 57 - self.dpopGenerator = DPoPGenerator(keyPair: keyPair) 58 - try await credentialStore.saveDPoPKey(keyPair.export()) 59 - } 60 - 61 - self.cachedSession = try await credentialStore.loadSession() 62 - } 63 - 64 - public convenience init( 65 - configuration: OAuthConfiguration, 66 - credentialStore: OAuthCredentialStore 67 - ) async throws { 68 - try await self.init( 69 - configuration: configuration, 70 - credentialStore: credentialStore, 71 - identityResolver: IdentityResolver(), 72 - httpClient: OAuthHTTPClient(), 73 - randomGenerator: SecureRandomDataGenerator() 74 - ) 75 - } 76 - 77 - public var currentSession: OAuthSession? { 78 - cachedSession 79 - } 80 - 81 - public func authenticateResourceRequest(_ request: inout URLRequest) async throws { 82 - guard let url = request.url else { throw OAuthManagerError.invalidRequest } 83 - guard var session = cachedSession else { throw OAuthManagerError.missingSession } 84 - 85 - if session.needsRefresh() { 86 - session = try await refreshSession(force: true) 87 - } 88 - 89 - let proof = try dpopGenerator.generateProof( 90 - method: request.httpMethod ?? "GET", 91 - url: url, 92 - nonce: resourceServerNonce, 93 - accessToken: session.accessToken 94 - ) 95 - 96 - request.setValue("DPoP \(session.accessToken)", forHTTPHeaderField: "Authorization") 97 - request.setValue(proof, forHTTPHeaderField: "DPoP") 98 - } 99 - 100 - public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { 101 - let request = try await beginAuthorization(for: handle) 102 - guard let callbackScheme = configuration.redirectURI.scheme else { 103 - throw OAuthManagerError.invalidRedirectURL 104 - } 105 - let callbackURL = try await uiProvider.presentAuthorization(at: request.authorizationURL, callbackScheme: callbackScheme) 106 - return try await resumeAuthorization(from: callbackURL) 107 - } 108 - 109 - public func beginAuthorization(for handle: String) async throws -> AuthorizationRequest { 110 - guard pendingAuthorization == nil else { throw OAuthManagerError.authorizationInProgress } 111 - 112 - let did = try await identityResolver.resolveHandle(handle) 113 - let didDocument = try await identityResolver.fetchDIDDocument(for: did) 114 - let pdsEndpoint = try identityResolver.extractPDSEndpoint(from: didDocument) 115 - 116 - let protectedMetadata = try await identityResolver.discoverProtectedResource(for: pdsEndpoint) 117 - guard let authorizationServerURL = protectedMetadata.authorizationServers.first else { 118 - throw OAuthManagerError.missingAuthorizationServer 119 - } 120 - let authMetadata = try await identityResolver.fetchAuthorizationServerMetadata(from: authorizationServerURL) 121 - try validateAuthorizationServerMetadata(authMetadata) 122 - 123 - let clientMetadata = try await loadClientMetadata() 124 - 125 - let pkce = try PKCEGenerator(randomGenerator: randomGenerator).makeValues() 126 - let state = try generateState() 127 - 128 - let parResult = try await performPushedAuthorizationRequest( 129 - metadata: authMetadata, 130 - clientMetadata: clientMetadata, 131 - handle: handle, 132 - did: did, 133 - pkce: pkce, 134 - state: state 135 - ) 136 - 137 - authorizationServerNonce = parResult.nonce ?? authorizationServerNonce 138 - 139 - let authorizationURL = makeAuthorizationURL( 140 - endpoint: authMetadata.authorizationEndpoint, 141 - clientID: clientMetadata.clientID, 142 - requestURI: parResult.requestURI 143 - ) 144 - 145 - pendingAuthorization = PendingAuthorization( 146 - handle: handle, 147 - did: did, 148 - pdsURL: pdsEndpoint, 149 - authorizationServerMetadata: authMetadata, 150 - clientMetadata: clientMetadata, 151 - state: state, 152 - pkce: pkce, 153 - requestURI: parResult.requestURI, 154 - issuedAt: Date() 155 - ) 156 - 157 - return AuthorizationRequest(authorizationURL: authorizationURL, redirectURI: configuration.redirectURI) 158 - } 159 - 160 - public func resumeAuthorization(from callbackURL: URL) async throws -> OAuthSession { 161 - guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { 162 - throw OAuthManagerError.invalidRedirectURL 163 - } 164 - guard let pending = pendingAuthorization else { throw OAuthManagerError.invalidAuthorizationState } 165 - defer { pendingAuthorization = nil } 166 - 167 - if components.scheme != configuration.redirectURI.scheme { 168 - throw OAuthManagerError.invalidRedirectURL 169 - } 170 - 171 - let queryItems = components.queryItems ?? [] 172 - if queryItems.contains(where: { $0.name == "error" }) { 173 - throw OAuthManagerError.tokenExchangeFailed 174 - } 175 - 176 - guard let state = queryItems.first(where: { $0.name == "state" })?.value, state == pending.state else { 177 - throw OAuthManagerError.callbackStateMismatch 178 - } 179 - 180 - guard let code = queryItems.first(where: { $0.name == "code" })?.value else { 181 - throw OAuthManagerError.tokenExchangeFailed 182 - } 183 - 184 - let tokenResponse = try await exchangeAuthorizationCode( 185 - code: code, 186 - pending: pending 187 - ) 188 - 189 - guard let subject = tokenResponse.subject, subject == pending.did else { 190 - throw OAuthManagerError.identityResolutionFailed 191 - } 192 - 193 - guard let refreshToken = tokenResponse.refreshToken, refreshToken.isEmpty == false else { 194 - throw OAuthManagerError.tokenExchangeFailed 195 - } 196 - 197 - let session = OAuthSession( 198 - did: pending.did, 199 - pdsURL: pending.pdsURL, 200 - authorizationServer: pending.authorizationServerMetadata.issuer, 201 - tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint, 202 - accessToken: tokenResponse.accessToken, 203 - refreshToken: refreshToken, 204 - tokenType: tokenResponse.tokenType, 205 - scope: tokenResponse.scope, 206 - expiresIn: tokenResponse.expiresIn, 207 - issuedAt: Date() 208 - ) 209 - try await store(session: session) 210 - resourceServerNonce = nil 211 - return session 212 - } 213 - 214 - public func refreshSession(force: Bool = false) async throws -> OAuthSession { 215 - guard let session = cachedSession else { throw OAuthManagerError.refreshFailed } 216 - if !force, session.needsRefresh() == false { 217 - return session 218 - } 219 - 220 - let refreshed = try await performRefresh(session: session) 221 - try await store(session: refreshed) 222 - return refreshed 223 - } 224 - 225 - public func signOut() async throws { 226 - cachedSession = nil 227 - pendingAuthorization = nil 228 - authorizationServerNonce = nil 229 - resourceServerNonce = nil 230 - cachedClientMetadata = nil 231 - try await credentialStore.deleteSession() 232 - APEnvironment.current.accessToken = nil 233 - APEnvironment.current.refreshToken = nil 234 - APEnvironment.current.host = nil 235 - } 236 - 237 - // MARK: - Nonce Management 238 - 239 - public func updateAuthorizationServerNonce(_ nonce: String?) async { 240 - authorizationServerNonce = nonce 241 - } 242 - 243 - public func updateResourceServerNonce(_ nonce: String?) async { 244 - resourceServerNonce = nonce 245 - } 246 - 247 - public func currentResourceServerNonce() -> String? { 248 - resourceServerNonce 249 - } 250 - 251 - public func currentAuthorizationServerNonce() -> String? { 252 - authorizationServerNonce 253 - } 254 - 255 - // MARK: - Private helpers 256 - 257 - private func loadClientMetadata() async throws -> OAuthClientMetadata { 258 - if let metadata = cachedClientMetadata { 259 - return metadata 260 - } 261 - 262 - var request = URLRequest(url: configuration.clientMetadataURL) 263 - request.setValue("application/json", forHTTPHeaderField: "Accept") 264 - let (data, response) = try await httpClient.send(request) 265 - guard (200..<300).contains(response.statusCode) else { 266 - throw OAuthManagerError.clientMetadataValidationFailed 267 - } 268 - let metadata = try httpClient.decodeJSON(OAuthClientMetadata.self, from: data) 269 - try validateClientMetadata(metadata) 270 - cachedClientMetadata = metadata 271 - return metadata 272 - } 273 - 274 - private func validateClientMetadata(_ metadata: OAuthClientMetadata) throws { 275 - guard metadata.clientID == configuration.clientMetadataURL else { 276 - throw OAuthManagerError.clientMetadataValidationFailed 277 - } 278 - guard metadata.redirectURIs.contains(configuration.redirectURI) else { 279 - throw OAuthManagerError.clientMetadataValidationFailed 280 - } 281 - guard metadata.grantTypes.contains("authorization_code") else { 282 - throw OAuthManagerError.clientMetadataValidationFailed 283 - } 284 - guard metadata.responseTypes.contains("code") else { 285 - throw OAuthManagerError.clientMetadataValidationFailed 286 - } 287 - guard metadata.dPoPBoundAccessTokens else { 288 - throw OAuthManagerError.clientMetadataValidationFailed 289 - } 290 - } 291 - 292 - private func validateAuthorizationServerMetadata(_ metadata: OAuthAuthorizationServerMetadata) throws { 293 - guard metadata.codeChallengeMethodsSupported.contains(where: { $0.caseInsensitiveCompare("S256") == .orderedSame }) else { 294 - throw OAuthManagerError.unsupportedAuthorizationServer 295 - } 296 - guard metadata.dPoPSigningAlgValuesSupported.contains(where: { $0.caseInsensitiveCompare("ES256") == .orderedSame }) else { 297 - throw OAuthManagerError.unsupportedAuthorizationServer 298 - } 299 - guard metadata.scopesSupported.isEmpty || metadata.scopesSupported.contains("atproto") else { 300 - throw OAuthManagerError.unsupportedAuthorizationServer 301 - } 302 - } 303 - 304 - private func generateState() throws -> String { 305 - let data = try randomGenerator.data(count: 32) 306 - return Base64URL.encode(data) 307 - } 308 - 309 - private func performPushedAuthorizationRequest( 310 - metadata: OAuthAuthorizationServerMetadata, 311 - clientMetadata: OAuthClientMetadata, 312 - handle: String, 313 - did: String, 314 - pkce: PKCEValues, 315 - state: String 316 - ) async throws -> (requestURI: String, nonce: String?) { 317 - let parameters: [String: String] = { 318 - var base: [String: String] = [ 319 - "client_id": configuration.clientMetadataURL.absoluteString, 320 - "redirect_uri": configuration.redirectURI.absoluteString, 321 - "response_type": "code", 322 - "scope": configuration.requestedScopes.joined(separator: " "), 323 - "code_challenge": pkce.challenge, 324 - "code_challenge_method": "S256", 325 - "state": state, 326 - "login_hint": handle, 327 - "resource": did 328 - ] 329 - configuration.additionalAuthorizationParameters.forEach { base[$0.key] = $0.value } 330 - return base 331 - }() 332 - 333 - var request = URLRequest(url: metadata.pushedAuthorizationRequestEndpoint) 334 - request.httpMethod = "POST" 335 - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 336 - request.httpBody = try formEncodedBody(from: parameters) 337 - 338 - var currentNonce = authorizationServerNonce 339 - for _ in 0..<2 { 340 - let proof = try dpopGenerator.generateProof( 341 - method: "POST", 342 - url: metadata.pushedAuthorizationRequestEndpoint, 343 - nonce: currentNonce, 344 - accessToken: nil 345 - ) 346 - request.setValue(proof, forHTTPHeaderField: "DPoP") 347 - let (data, response) = try await httpClient.send(request) 348 - if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 349 - currentNonce = nonce 350 - } 351 - 352 - switch response.statusCode { 353 - case 201: 354 - authorizationServerNonce = currentNonce 355 - let parResponse = try httpClient.decodeJSON(PushedAuthorizationRequestResponse.self, from: data) 356 - return (parResponse.requestURI, currentNonce) 357 - case 400, 401: 358 - if currentNonce != nil { 359 - continue 360 - } 361 - if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), 362 - errorResponse.error == "use_dpop_nonce" { 363 - continue 364 - } 365 - throw OAuthManagerError.tokenExchangeFailed 366 - default: 367 - throw OAuthManagerError.tokenExchangeFailed 368 - } 369 - } 370 - 371 - throw OAuthManagerError.tokenExchangeFailed 372 - } 373 - 374 - private func makeAuthorizationURL(endpoint: URL, clientID: URL, requestURI: String) -> URL { 375 - var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents() 376 - var items = components.queryItems ?? [] 377 - items.append(URLQueryItem(name: "client_id", value: clientID.absoluteString)) 378 - items.append(URLQueryItem(name: "request_uri", value: requestURI)) 379 - components.queryItems = items 380 - return components.url ?? endpoint 381 - } 382 - 383 - private func exchangeAuthorizationCode(code: String, pending: PendingAuthorization) async throws -> OAuthTokenResponse { 384 - let parameters: [String: String] = [ 385 - "grant_type": "authorization_code", 386 - "code": code, 387 - "redirect_uri": configuration.redirectURI.absoluteString, 388 - "client_id": configuration.clientMetadataURL.absoluteString, 389 - "code_verifier": pending.pkce.verifier 390 - ] 391 - 392 - var request = URLRequest(url: pending.authorizationServerMetadata.tokenEndpoint) 393 - request.httpMethod = "POST" 394 - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 395 - request.httpBody = try formEncodedBody(from: parameters) 396 - 397 - let response = try await sendTokenRequest(request: request, tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint) 398 - return response 399 - } 400 - 401 - private func performRefresh(session: OAuthSession) async throws -> OAuthSession { 402 - let parameters: [String: String] = [ 403 - "grant_type": "refresh_token", 404 - "refresh_token": session.refreshToken, 405 - "client_id": configuration.clientMetadataURL.absoluteString, 406 - "redirect_uri": configuration.redirectURI.absoluteString 407 - ] 408 - 409 - var request = URLRequest(url: session.tokenEndpoint) 410 - request.httpMethod = "POST" 411 - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 412 - request.httpBody = try formEncodedBody(from: parameters) 413 - 414 - let tokenResponse = try await sendTokenRequest(request: request, tokenEndpoint: session.tokenEndpoint) 415 - guard let subject = tokenResponse.subject, subject == session.did else { 416 - throw OAuthManagerError.refreshFailed 417 - } 418 - 419 - let refreshToken: String 420 - if let newToken = tokenResponse.refreshToken, newToken.isEmpty == false { 421 - refreshToken = newToken 422 - } else { 423 - refreshToken = session.refreshToken 424 - } 425 - 426 - return OAuthSession( 427 - did: session.did, 428 - pdsURL: session.pdsURL, 429 - authorizationServer: session.authorizationServer, 430 - tokenEndpoint: session.tokenEndpoint, 431 - accessToken: tokenResponse.accessToken, 432 - refreshToken: refreshToken, 433 - tokenType: tokenResponse.tokenType, 434 - scope: tokenResponse.scope, 435 - expiresIn: tokenResponse.expiresIn, 436 - issuedAt: Date() 437 - ) 438 - } 439 - 440 - private func sendTokenRequest(request: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { 441 - var request = request 442 - let nonce = authorizationServerNonce 443 - let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: nonce, accessToken: nil) 444 - request.setValue(proof, forHTTPHeaderField: "DPoP") 445 - 446 - let (data, response) = try await httpClient.send(request) 447 - if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { 448 - authorizationServerNonce = newNonce 449 - } 450 - 451 - switch response.statusCode { 452 - case 200: 453 - return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) 454 - case 400, 401: 455 - if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), 456 - errorResponse.error == "use_dpop_nonce", 457 - let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 458 - authorizationServerNonce = nonce 459 - return try await retryTokenRequest(originalRequest: request, tokenEndpoint: tokenEndpoint) 460 - } 461 - fallthrough 462 - default: 463 - throw OAuthManagerError.tokenExchangeFailed 464 - } 465 - } 466 - 467 - private func retryTokenRequest(originalRequest: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { 468 - var request = originalRequest 469 - let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: authorizationServerNonce, accessToken: nil) 470 - request.setValue(proof, forHTTPHeaderField: "DPoP") 471 - let (data, response) = try await httpClient.send(request) 472 - if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { 473 - authorizationServerNonce = newNonce 474 - } 475 - guard response.statusCode == 200 else { throw OAuthManagerError.tokenExchangeFailed } 476 - return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) 477 - } 478 - 479 - private func formEncodedBody(from parameters: [String: String]) throws -> Data { 480 - var components = URLComponents() 481 - components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } 482 - guard let query = components.percentEncodedQuery, let data = query.data(using: .utf8) else { 483 - throw OAuthManagerError.tokenExchangeFailed 484 - } 485 - return data 486 - } 487 - 488 - private func store(session: OAuthSession) async throws { 489 - cachedSession = session 490 - try await credentialStore.save(session: session) 491 - APEnvironment.current.accessToken = session.accessToken 492 - APEnvironment.current.refreshToken = session.refreshToken 493 - APEnvironment.current.host = session.pdsURL.absoluteString 494 - } 495 - } 496 - 497 - private struct PendingAuthorization: Sendable { 498 - let handle: String 499 - let did: String 500 - let pdsURL: URL 501 - let authorizationServerMetadata: OAuthAuthorizationServerMetadata 502 - let clientMetadata: OAuthClientMetadata 503 - let state: String 504 - let pkce: PKCEValues 505 - let requestURI: String 506 - let issuedAt: Date 507 - }
-5
Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift
··· 1 - import Foundation 2 - 3 - public protocol OAuthUIProvider: Sendable { 4 - func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL 5 - }
-101
Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift
··· 1 - import CryptoKit 2 - import Foundation 3 - 4 - enum DPoPGeneratorError: Error, Sendable { 5 - case invalidURL 6 - case keyUnavailable 7 - } 8 - 9 - @APActor 10 - public final class DPoPGenerator: Sendable { 11 - private var keyPair: DPoPKeyPair 12 - private let clock: () -> Date 13 - 14 - init(keyPair: DPoPKeyPair, clock: @escaping () -> Date = Date.init) { 15 - self.keyPair = keyPair 16 - self.clock = clock 17 - } 18 - 19 - public convenience init(clock: @escaping () -> Date = Date.init) { 20 - self.init(keyPair: DPoPKeyPair(), clock: clock) 21 - } 22 - 23 - public func updateKey(using rawRepresentation: Data) throws { 24 - self.keyPair = try DPoPKeyPair(rawRepresentation: rawRepresentation) 25 - } 26 - 27 - public func exportKey() -> Data { 28 - keyPair.export() 29 - } 30 - 31 - public func generateProof( 32 - method: String, 33 - url: URL, 34 - nonce: String?, 35 - accessToken: String? 36 - ) throws -> String { 37 - let normalizedHTU = try normalize(url: url) 38 - let issuedAt = Int(clock().timeIntervalSince1970) 39 - let header = Header(jwk: keyPair.publicKeyJWK) 40 - let payload = Payload( 41 - htm: method.uppercased(), 42 - htu: normalizedHTU, 43 - iat: issuedAt, 44 - exp: issuedAt + 120, 45 - jti: UUID().uuidString, 46 - nonce: nonce, 47 - ath: accessToken.flatMap { accessTokenHash(for: $0) } 48 - ) 49 - 50 - let encoder = JSONEncoder() 51 - encoder.outputFormatting = [.withoutEscapingSlashes] 52 - let headerEncoded = Base64URL.encode(try encoder.encode(header)) 53 - let payloadEncoded = Base64URL.encode(try encoder.encode(payload)) 54 - let signingInput = Data("\(headerEncoded).\(payloadEncoded)".utf8) 55 - let signature = try keyPair.privateKey.signature(for: signingInput) 56 - let signatureEncoded = Base64URL.encode(signature.derRepresentation) 57 - return "\(headerEncoded).\(payloadEncoded).\(signatureEncoded)" 58 - } 59 - 60 - private func normalize(url: URL) throws -> String { 61 - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 62 - throw DPoPGeneratorError.invalidURL 63 - } 64 - components.fragment = nil 65 - guard let normalized = components.url?.absoluteString else { 66 - throw DPoPGeneratorError.invalidURL 67 - } 68 - return normalized 69 - } 70 - 71 - private func accessTokenHash(for token: String) -> String { 72 - let digest = SHA256.hash(data: Data(token.utf8)) 73 - return Base64URL.encode(Data(digest)) 74 - } 75 - 76 - private struct Header: Encodable { 77 - let typ = "dpop+jwt" 78 - let alg = "ES256" 79 - let jwk: [String: String] 80 - } 81 - 82 - private struct Payload: Encodable { 83 - let htm: String 84 - let htu: String 85 - let iat: Int 86 - let exp: Int 87 - let jti: String 88 - let nonce: String? 89 - let ath: String? 90 - 91 - private enum CodingKeys: String, CodingKey { 92 - case htm 93 - case htu 94 - case iat 95 - case exp 96 - case jti 97 - case nonce 98 - case ath 99 - } 100 - } 101 - }
-38
Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift
··· 1 - import CryptoKit 2 - import Foundation 3 - 4 - struct DPoPKeyPair: Sendable { 5 - let privateKey: P256.Signing.PrivateKey 6 - 7 - init() { 8 - self.privateKey = P256.Signing.PrivateKey() 9 - } 10 - 11 - init(privateKey: P256.Signing.PrivateKey) { 12 - self.privateKey = privateKey 13 - } 14 - 15 - init(rawRepresentation: Data) throws { 16 - self.privateKey = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation) 17 - } 18 - 19 - var publicKeyJWK: [String: String] { 20 - let publicKeyData = privateKey.publicKey.x963Representation 21 - // Strip leading 0x04 per SEC1 encoding to expose affine coordinates 22 - let xData = Data(publicKeyData[1..<33]) 23 - let yData = Data(publicKeyData[33..<65]) 24 - 25 - return [ 26 - "kty": "EC", 27 - "crv": "P-256", 28 - "alg": "ES256", 29 - "use": "sig", 30 - "x": Base64URL.encode(xData), 31 - "y": Base64URL.encode(yData) 32 - ] 33 - } 34 - 35 - func export() -> Data { 36 - privateKey.rawRepresentation 37 - } 38 - }
-38
Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift
··· 1 - import CryptoKit 2 - import Foundation 3 - 4 - struct PKCEValues: Sendable { 5 - let verifier: String 6 - let challenge: String 7 - } 8 - 9 - struct PKCEGenerator: Sendable { 10 - private let randomGenerator: RandomDataGenerating 11 - 12 - init(randomGenerator: RandomDataGenerating = SecureRandomDataGenerator()) { 13 - self.randomGenerator = randomGenerator 14 - } 15 - 16 - func makeValues() throws -> PKCEValues { 17 - let verifier = try makeVerifier() 18 - let challenge = makeChallenge(from: verifier) 19 - return PKCEValues(verifier: verifier, challenge: challenge) 20 - } 21 - 22 - func makeVerifier() throws -> String { 23 - let candidateLengths = [32, 48, 64] 24 - for length in candidateLengths { 25 - let data = try randomGenerator.data(count: length) 26 - let candidate = Base64URL.encode(data) 27 - if (43...128).contains(candidate.count) { 28 - return candidate 29 - } 30 - } 31 - throw RandomDataGeneratorError.allocationFailed 32 - } 33 - 34 - func makeChallenge(from verifier: String) -> String { 35 - let digest = SHA256.hash(data: Data(verifier.utf8)) 36 - return Base64URL.encode(Data(digest)) 37 - } 38 - }
-23
Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift
··· 1 - import Foundation 2 - import Security 3 - 4 - enum RandomDataGeneratorError: Error, Sendable { 5 - case allocationFailed 6 - case generationFailed(status: OSStatus) 7 - } 8 - 9 - protocol RandomDataGenerating: Sendable { 10 - func data(count: Int) throws -> Data 11 - } 12 - 13 - struct SecureRandomDataGenerator: RandomDataGenerating { 14 - func data(count: Int) throws -> Data { 15 - guard count > 0 else { return Data() } 16 - var buffer = Data(count: count) 17 - let status = buffer.withUnsafeMutableBytes { pointer in 18 - SecRandomCopyBytes(kSecRandomDefault, count, pointer.baseAddress!) 19 - } 20 - guard status == errSecSuccess else { throw RandomDataGeneratorError.generationFailed(status: status) } 21 - return buffer 22 - } 23 - }
-41
Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift
··· 1 - import Foundation 2 - 3 - public protocol OAuthCredentialStore: Sendable { 4 - func loadSession() async throws -> OAuthSession? 5 - func save(session: OAuthSession) async throws 6 - func deleteSession() async throws 7 - func loadDPoPKey() async throws -> Data? 8 - func saveDPoPKey(_ data: Data) async throws 9 - func deleteDPoPKey() async throws 10 - } 11 - 12 - public actor InMemoryOAuthCredentialStore: OAuthCredentialStore { 13 - private var session: OAuthSession? 14 - private var dpopKey: Data? 15 - 16 - public init() {} 17 - 18 - public func loadSession() async throws -> OAuthSession? { 19 - session 20 - } 21 - 22 - public func save(session: OAuthSession) async throws { 23 - self.session = session 24 - } 25 - 26 - public func deleteSession() async throws { 27 - session = nil 28 - } 29 - 30 - public func loadDPoPKey() async throws -> Data? { 31 - dpopKey 32 - } 33 - 34 - public func saveDPoPKey(_ data: Data) async throws { 35 - dpopKey = data 36 - } 37 - 38 - public func deleteDPoPKey() async throws { 39 - dpopKey = nil 40 - } 41 - }
-26
Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift
··· 1 - import Foundation 2 - 3 - enum Base64URLError: Error, Sendable { 4 - case invalidLength 5 - case invalidCharacters 6 - } 7 - 8 - struct Base64URL: Sendable { 9 - static func encode(_ data: Data) -> String { 10 - data.base64EncodedString() 11 - .replacingOccurrences(of: "+", with: "-") 12 - .replacingOccurrences(of: "/", with: "_") 13 - .replacingOccurrences(of: "=", with: "") 14 - } 15 - 16 - static func decode(_ string: String) throws -> Data { 17 - let remainder = string.count % 4 18 - guard remainder != 1 else { throw Base64URLError.invalidLength } 19 - let paddingLength = remainder == 0 ? 0 : 4 - remainder 20 - let padded = string + String(repeating: "=", count: paddingLength) 21 - guard let data = Data(base64Encoded: padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")) else { 22 - throw Base64URLError.invalidCharacters 23 - } 24 - return data 25 - } 26 - }
-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 - }
-97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
··· 1 - import CryptoKit 2 - import Foundation 3 - import Testing 4 - @testable import CoreATProtocol 5 - 6 - private struct DeterministicRandomGenerator: RandomDataGenerating { 7 - func data(count: Int) throws -> Data { 8 - Data(repeating: 0x42, count: count) 9 - } 10 - } 11 - 12 - @Test("Base64URL encodes without padding and decodes back") 13 - func base64URLRoundTrip() throws { 14 - let data = Data([0xde, 0xad, 0xbe, 0xef]) 15 - let encoded = Base64URL.encode(data) 16 - #expect(encoded.contains("=") == false) 17 - let decoded = try Base64URL.decode(encoded) 18 - #expect(decoded == data) 19 - } 20 - 21 - @Test("PKCE generator creates verifier within bounds and matching challenge") 22 - func pkceGeneratorProducesExpectedValues() throws { 23 - let generator = PKCEGenerator(randomGenerator: DeterministicRandomGenerator()) 24 - let values = try generator.makeValues() 25 - #expect(values.verifier.count >= 43) 26 - #expect(values.verifier.count <= 128) 27 - 28 - let expectedDigest = SHA256.hash(data: Data(values.verifier.utf8)) 29 - let expectedChallenge = Base64URL.encode(Data(expectedDigest)) 30 - #expect(values.challenge == expectedChallenge) 31 - } 32 - 33 - @Test("DPoP generator signs payload with expected claims") 34 - func dpopGeneratorProducesValidProof() async throws { 35 - let keyPair = DPoPKeyPair() 36 - let generator = await DPoPGenerator(clock: { Date(timeIntervalSince1970: 1_700_000_000) }) 37 - try await generator.updateKey(using: keyPair.export()) 38 - let url = URL(string: "https://example.com/resource")! 39 - let proof = try await generator.generateProof( 40 - method: "GET", 41 - url: url, 42 - nonce: "nonce-value", 43 - accessToken: "access-token" 44 - ) 45 - 46 - let components = proof.split(separator: ".") 47 - #expect(components.count == 3) 48 - 49 - let headerData = try Base64URL.decode(String(components[0])) 50 - let payloadData = try Base64URL.decode(String(components[1])) 51 - let signatureData = try Base64URL.decode(String(components[2])) 52 - 53 - let header = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] 54 - let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] 55 - 56 - #expect(header?["typ"] as? String == "dpop+jwt") 57 - #expect(header?["alg"] as? String == "ES256") 58 - let jwk = header?["jwk"] as? [String: String] 59 - #expect(jwk?["kty"] == "EC") 60 - #expect(jwk?["crv"] == "P-256") 61 - 62 - #expect(payload?["htm"] as? String == "GET") 63 - #expect(payload?["htu"] as? String == "https://example.com/resource") 64 - #expect(payload?["nonce"] as? String == "nonce-value") 65 - #expect(payload?["ath"] as? String == Base64URL.encode(Data(SHA256.hash(data: Data("access-token".utf8))))) 66 - 67 - if let iat = payload?["iat"] as? Int { 68 - #expect(iat == 1_700_000_000) 69 - } else { 70 - Issue.record("DPoP payload missing iat") 71 - } 72 - 73 - let signingInput = Data((components[0] + "." + components[1]).utf8) 74 - let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData) 75 - #expect(keyPair.privateKey.publicKey.isValidSignature(signature, for: signingInput)) 76 - } 77 - 78 - @Test("OAuth session refresh heuristics") 79 - func oauthSessionRefreshLogic() { 80 - let issuedAt = Date() 81 - let session = OAuthSession( 82 - did: "did:plc:example", 83 - pdsURL: URL(string: "https://pds.example.com")!, 84 - authorizationServer: URL(string: "https://auth.example.com")!, 85 - tokenEndpoint: URL(string: "https://auth.example.com/token")!, 86 - accessToken: "token", 87 - refreshToken: "refresh", 88 - tokenType: "DPoP", 89 - scope: "atproto", 90 - expiresIn: 3600, 91 - issuedAt: issuedAt 92 - ) 93 - 94 - #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3500)) == false) 95 - #expect(session.needsRefresh(relativeTo: issuedAt.addingTimeInterval(3300), threshold: 400)) 96 - #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3600))) 97 - }
+134
Tests/CoreATProtocolTests/OAuthTests.swift
··· 1 + import Foundation 2 + import Testing 3 + import OAuthenticator 4 + @testable import CoreATProtocol 5 + 6 + @Suite("Identity Resolution") 7 + struct IdentityResolverTests { 8 + 9 + @Test("Resolve well-known handle via HTTPS") 10 + func testResolveHandle() async throws { 11 + let resolver = await IdentityResolver() 12 + 13 + // atproto.com is a stable test handle 14 + let identity = try await resolver.resolve(handle: "atproto.com") 15 + 16 + print("DID: \(identity.did)") 17 + print("PDS: \(identity.pdsEndpoint)") 18 + print("Auth Server: \(identity.authorizationServer)") 19 + print("Auth Server Host: \(identity.authServerHost)") 20 + 21 + #expect(identity.did.hasPrefix("did:")) 22 + #expect(identity.pdsEndpoint.hasPrefix("https://")) 23 + #expect(identity.authorizationServer.hasPrefix("https://")) 24 + #expect(!identity.authServerHost.hasPrefix("https://")) 25 + } 26 + 27 + @Test("Handle with @ prefix is cleaned") 28 + func testHandleCleaning() async throws { 29 + let resolver = await IdentityResolver() 30 + 31 + let identity = try await resolver.resolve(handle: "@atproto.com") 32 + 33 + #expect(identity.handle == "atproto.com") 34 + } 35 + 36 + @Test("ServerMetadata loads from auth server host") 37 + func testServerMetadataLoad() async throws { 38 + let resolver = await IdentityResolver() 39 + let identity = try await resolver.resolve(handle: "atproto.com") 40 + 41 + let provider: URLResponseProvider = { request in 42 + try await URLSession.shared.data(for: request) 43 + } 44 + 45 + // This should not throw - tests that authServerHost works with ServerMetadata.load 46 + let serverConfig = try await ServerMetadata.load( 47 + for: identity.authServerHost, 48 + provider: provider 49 + ) 50 + 51 + print("Authorization endpoint: \(serverConfig.authorizationEndpoint)") 52 + print("Token endpoint: \(serverConfig.tokenEndpoint)") 53 + 54 + #expect(serverConfig.authorizationEndpoint.hasPrefix("https://")) 55 + #expect(serverConfig.tokenEndpoint.hasPrefix("https://")) 56 + } 57 + 58 + @Test("ClientMetadata loads from URL") 59 + func testClientMetadataLoad() async throws { 60 + let provider: URLResponseProvider = { request in 61 + try await URLSession.shared.data(for: request) 62 + } 63 + 64 + // Use the real Plume client metadata 65 + let clientConfig = try await ClientMetadata.load( 66 + for: "https://sparrowtek.com/plume.json", 67 + provider: provider 68 + ) 69 + 70 + print("Client ID: \(clientConfig.clientId)") 71 + print("Redirect URIs: \(clientConfig.redirectURIs)") 72 + 73 + #expect(clientConfig.clientId == "https://sparrowtek.com/plume.json") 74 + } 75 + } 76 + 77 + @Suite("DPoP JWT") 78 + struct DPoPTests { 79 + 80 + @Test("JWT generation with JWTKit") 81 + func testJWTGeneration() async throws { 82 + // This tests that jwt-kit is properly integrated 83 + // The actual JWT signing is tested via OAuthenticator integration 84 + 85 + let storage = ATProtoAuthStorage( 86 + retrieveLogin: { nil }, 87 + storeLogin: { _ in }, 88 + retrievePrivateKey: { nil }, 89 + storePrivateKey: { _ in } 90 + ) 91 + 92 + let config = ATProtoOAuthConfig( 93 + clientMetadataURL: "https://example.com/client-metadata.json", 94 + redirectURI: "example://callback" 95 + ) 96 + 97 + let client = await ATProtoOAuth(config: config, storage: storage) 98 + 99 + // Verify key was generated 100 + let keyPEM = await client.privateKeyPEM 101 + #expect(!keyPEM.isEmpty) 102 + #expect(keyPEM.contains("BEGIN PRIVATE KEY")) 103 + } 104 + 105 + @Test("Private key can be exported and restored") 106 + func testKeyPersistence() async throws { 107 + let storage = ATProtoAuthStorage( 108 + retrieveLogin: { nil }, 109 + storeLogin: { _ in }, 110 + retrievePrivateKey: { nil }, 111 + storePrivateKey: { _ in } 112 + ) 113 + 114 + let config = ATProtoOAuthConfig( 115 + clientMetadataURL: "https://example.com/client-metadata.json", 116 + redirectURI: "example://callback" 117 + ) 118 + 119 + // Create client and get its key 120 + let client = await ATProtoOAuth(config: config, storage: storage) 121 + let keyPEM = await client.privateKeyPEM 122 + 123 + // Create another client with the same key 124 + let restoredClient = try await ATProtoOAuth( 125 + config: config, 126 + storage: storage, 127 + privateKeyPEM: keyPEM 128 + ) 129 + let restoredKeyPEM = await restoredClient.privateKeyPEM 130 + 131 + // Keys should match 132 + #expect(keyPEM == restoredKeyPEM) 133 + } 134 + }