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 - }
···
+6 -21
Package.swift
··· 5 let package = Package( 6 name: "CoreATProtocol", 7 platforms: [ 8 - .iOS(.v17), 9 - .watchOS(.v11), 10 - .tvOS(.v17), 11 - .macOS(.v14), 12 - .macCatalyst(.v17), 13 ], 14 products: [ 15 .library( ··· 17 targets: ["CoreATProtocol"] 18 ), 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 - ], 28 targets: [ 29 .target( 30 - name: "CoreATProtocol", 31 - dependencies: [ 32 - "OAuthenticator", 33 - .product(name: "JWTKit", package: "jwt-kit"), 34 - ], 35 - swiftSettings: [ 36 - .enableExperimentalFeature("StrictConcurrency") 37 - ] 38 ), 39 .testTarget( 40 name: "CoreATProtocolTests",
··· 5 let package = Package( 6 name: "CoreATProtocol", 7 platforms: [ 8 + .iOS(.v26), 9 + .watchOS(.v26), 10 + .tvOS(.v26), 11 + .macOS(.v26), 12 + .macCatalyst(.v26), 13 ], 14 products: [ 15 .library( ··· 17 targets: ["CoreATProtocol"] 18 ), 19 ], 20 targets: [ 21 .target( 22 + name: "CoreATProtocol" 23 ), 24 .testTarget( 25 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 // Created by Thomas Rademaker on 10/10/25. 6 // 7 8 - import JWTKit 9 - 10 @APActor 11 public class APEnvironment { 12 public static var current: APEnvironment = APEnvironment() ··· 15 public var accessToken: String? 16 public var refreshToken: String? 17 public var atProtocoldelegate: CoreATProtocolDelegate? 18 - public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 19 - public var dpopPrivateKey: ES256PrivateKey? 20 - public var dpopKeys: JWTKeyCollection? 21 public let routerDelegate = APRouterDelegate() 22 23 private init() {} 24
··· 5 // Created by Thomas Rademaker on 10/10/25. 6 // 7 8 @APActor 9 public class APEnvironment { 10 public static var current: APEnvironment = APEnvironment() ··· 13 public var accessToken: String? 14 public var refreshToken: String? 15 public var atProtocoldelegate: CoreATProtocolDelegate? 16 public let routerDelegate = APRouterDelegate() 17 + public var oauthManager: OAuthManager? { 18 + didSet { 19 + routerDelegate.oauthManager = oauthManager 20 + } 21 + } 22 23 private init() {} 24
+37 -50
Sources/CoreATProtocol/CoreATProtocol.swift
··· 1 // The Swift Programming Language 2 // https://docs.swift.org/swift-book 3 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 - } 36 37 @APActor 38 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { ··· 48 } 49 50 @APActor 51 - public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) { 52 - APEnvironment.current.tokenRefreshHandler = handler 53 } 54 55 @APActor 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 } 62 63 - let privateKey = try ES256PrivateKey(pem: pem) 64 - let keys = JWTKeyCollection() 65 - await keys.add(ecdsa: privateKey) 66 - 67 - APEnvironment.current.dpopPrivateKey = privateKey 68 - APEnvironment.current.dpopKeys = keys 69 } 70 71 @APActor 72 - public func updateTokens(access: String?, refresh: String?) { 73 - APEnvironment.current.accessToken = access 74 - APEnvironment.current.refreshToken = refresh 75 } 76 77 @APActor 78 - public func update(hostURL: String?) { 79 - APEnvironment.current.host = hostURL 80 }
··· 1 // The Swift Programming Language 2 // https://docs.swift.org/swift-book 3 4 + public protocol CoreATProtocolDelegate: AnyObject {} 5 6 @APActor 7 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { ··· 17 } 18 19 @APActor 20 + public func updateTokens(access: String?, refresh: String?) { 21 + APEnvironment.current.accessToken = access 22 + APEnvironment.current.refreshToken = refresh 23 + } 24 + 25 + @APActor 26 + public func update(hostURL: String?) { 27 + APEnvironment.current.host = hostURL 28 + } 29 + 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 } 39 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 48 + } 49 50 + @APActor 51 + public func currentOAuthSession() -> OAuthSession? { 52 + APEnvironment.current.oauthManager?.currentSession 53 } 54 55 @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() 67 }
+1 -2
Sources/CoreATProtocol/Models/ATError.swift
··· 11 } 12 13 public struct ErrorMessage: Codable, Sendable { 14 - /// The error type as a string. Kept as String rather than AtErrorType 15 - /// to handle unknown error types that the server may return. 16 public let error: String 17 public let message: String? 18
··· 11 } 12 13 public struct ErrorMessage: Codable, Sendable { 14 + #warning("Should error be type string or AtErrorType?") 15 public let error: String 16 public let message: String? 17
+7 -159
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 1 import Foundation 2 - import JWTKit 3 - import OAuthenticator 4 - #if canImport(CryptoKit) 5 - import CryptoKit 6 - #else 7 - import Crypto 8 - #endif 9 10 @APActor 11 public protocol NetworkRouterDelegate: AnyObject { 12 func intercept(_ request: inout URLRequest) async 13 func shouldRetry(error: Error, attempts: Int) async throws -> Bool 14 } 15 16 /// Describes the implementation details of a NetworkRouter ··· 43 let networking: Networking 44 let urlSessionTaskDelegate: URLSessionTaskDelegate? 45 var decoder: JSONDecoder 46 - private let dpopActor = DPoPRequestActor() 47 48 public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) { 49 if let networking = networking { ··· 69 guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed } 70 await delegate?.intercept(&request) 71 72 - let (data, response) = try await executeRequest(request) 73 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 74 switch httpResponse.statusCode { 75 case 200...299: 76 return try decoder.decode(T.self, from: data) ··· 93 return try await execute(route, attempts: attempts + 1) 94 } 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 - } 202 203 func buildRequest(from route: Endpoint) async throws -> URLRequest { 204 ··· 233 } 234 } 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 import Foundation 2 3 @APActor 4 public protocol NetworkRouterDelegate: AnyObject { 5 func intercept(_ request: inout URLRequest) async 6 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 } 13 14 /// Describes the implementation details of a NetworkRouter ··· 41 let networking: Networking 42 let urlSessionTaskDelegate: URLSessionTaskDelegate? 43 var decoder: JSONDecoder 44 45 public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) { 46 if let networking = networking { ··· 66 guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed } 67 await delegate?.intercept(&request) 68 69 + let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 70 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 71 + await delegate?.didReceive(response: httpResponse, data: data, for: request) 72 switch httpResponse.statusCode { 73 case 200...299: 74 return try decoder.decode(T.self, from: data) ··· 91 return try await execute(route, attempts: attempts + 1) 92 } 93 } 94 95 func buildRequest(from route: Endpoint) async throws -> URLRequest { 96 ··· 125 } 126 } 127 }
+1 -1
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
··· 1 @preconcurrency import Foundation 2 3 @APActor 4 - public protocol Networking: Sendable { 5 func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) 6 } 7
··· 1 @preconcurrency import Foundation 2 3 @APActor 4 + public protocol Networking { 5 func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) 6 } 7
+86 -31
Sources/CoreATProtocol/Networking.swift
··· 31 } 32 33 @APActor 34 - public class APRouterDelegate: NetworkRouterDelegate { 35 - private var shouldRefreshToken = false 36 - private var refreshTask: Task<Bool, Error>? 37 - 38 public func intercept(_ request: inout URLRequest) async { 39 - if APEnvironment.current.dpopPrivateKey != nil { 40 - return 41 } 42 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 { 47 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 48 } 49 } 50 - 51 public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 52 - func refreshViaOAuth() async throws -> Bool { 53 - guard let handler = APEnvironment.current.tokenRefreshHandler else { 54 - return false 55 } 56 57 - if let refreshTask { 58 - return try await refreshTask.value 59 - } 60 61 - let task = Task { try await handler() } 62 - refreshTask = task 63 64 - defer { refreshTask = nil } 65 66 - return try await task.value 67 } 68 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() 75 } 76 77 - if case .message(let message) = error as? AtError, 78 - message.error == AtErrorType.expiredToken.rawValue, 79 - attempts == 1 { 80 - return try await refreshViaOAuth() 81 } 82 83 return false 84 } 85 }
··· 31 } 32 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 + 47 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 + } 55 } 56 57 + if let accessToken = APEnvironment.current.accessToken { 58 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 59 } 60 } 61 + 62 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 78 } 79 + } 80 81 + if case .message(let message) = error as? AtError, 82 + message.error == AtErrorType.expiredToken.rawValue { 83 + return false 84 + } 85 + 86 + return false 87 + } 88 89 + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async { 90 + guard let manager = oauthManager else { return } 91 92 + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 93 + await manager.updateResourceServerNonce(nonce) 94 + } 95 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 109 } 110 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 117 + } 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 + 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 + 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 } 140 }
-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 - }
···