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
+157
Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md
··· 1 + # Build a Bluesky Login Flow 2 + 3 + Learn how an iOS app can depend on ``CoreATProtocol`` and guide a user through the AT Protocol OAuth flow using Bluesky as the authorization server. 4 + 5 + ## Add the package to your app 6 + 7 + 1. In your app target's `Package.swift`, add the CoreATProtocol dependency: 8 + 9 + ```swift 10 + .package(url: "https://github.com/your-org/CoreATProtocol.git", from: "1.0.0") 11 + ``` 12 + 13 + 2. List ``CoreATProtocol`` in the target's dependencies: 14 + 15 + ```swift 16 + .target( 17 + name: "App", 18 + dependencies: [ 19 + .product(name: "CoreATProtocol", package: "CoreATProtocol") 20 + ] 21 + ) 22 + ``` 23 + 24 + 3. Import the module where you coordinate authentication: 25 + 26 + ```swift 27 + import CoreATProtocol 28 + ``` 29 + 30 + ## Persist a DPoP key 31 + 32 + Bluesky issues DPoP-bound access tokens, so the app must generate and persist a single ES256 key pair. The example below stores the private key in the Keychain and recreates it when needed. 33 + 34 + ```swift 35 + import CryptoKit 36 + import JWTKit 37 + 38 + final class DPoPKeyStore { 39 + private let keyTag = "com.example.app.dpop" 40 + 41 + func loadOrCreateKey() throws -> ES256PrivateKey { 42 + if let raw = try loadKeyData() { 43 + return try ES256PrivateKey(pem: raw) 44 + } 45 + 46 + let key = ES256PrivateKey() 47 + try persist(key.pemRepresentation) 48 + return key 49 + } 50 + 51 + private func loadKeyData() throws -> String? { 52 + // Read from the Keychain and return the PEM string if it exists. 53 + nil 54 + } 55 + 56 + private func persist(_ pem: String) throws { 57 + // Write the PEM string to the Keychain. 58 + } 59 + } 60 + ``` 61 + 62 + ## Expose a DPoP JWT generator 63 + 64 + Wrap the signing key with ``DPoPJWTGenerator`` so the library can mint proofs on demand. 65 + 66 + ```swift 67 + let keyStore = DPoPKeyStore() 68 + let privateKey = try await keyStore.loadOrCreateKey() 69 + let dpopGenerator = try await DPoPJWTGenerator(privateKey: privateKey) 70 + let jwtGenerator = dpopGenerator.jwtGenerator() 71 + ``` 72 + 73 + Pass ``DPoPJWTGenerator.jwtGenerator()`` to ``LoginService`` and later to ``applyAuthenticationContext(login:generator:resourceNonce:)`` so API calls share the same key material. 74 + 75 + ## Configure login storage 76 + 77 + Provide a ``LoginStorage`` implementation that reads and writes the userโ€™s Bluesky session securely. The storage runs on the calling actor, so use async APIs. 78 + 79 + ```swift 80 + import OAuthenticator 81 + 82 + struct BlueskyLoginStore { 83 + func makeStorage() -> LoginStorage { 84 + LoginStorage { 85 + try await loadLogin() 86 + } storeLogin: { login in 87 + try await persist(login) 88 + } 89 + } 90 + 91 + private func loadLogin() async throws -> Login? { 92 + // Decode and return the previously stored login if one exists. 93 + nil 94 + } 95 + 96 + private func persist(_ login: Login) async throws { 97 + // Save the login (for example, in the Keychain or the file system). 98 + } 99 + } 100 + ``` 101 + 102 + ## Perform the OAuth flow 103 + 104 + 1. Configure shared environment state early in your app lifecycle: 105 + 106 + ```swift 107 + await setup( 108 + hostURL: "https://bsky.social", 109 + accessJWT: nil, 110 + refreshJWT: nil, 111 + delegate: self 112 + ) 113 + ``` 114 + 115 + 2. Create the services needed for authentication: 116 + 117 + ```swift 118 + let loginStorage = BlueskyLoginStore().makeStorage() 119 + let loginService = LoginService(jwtGenerator: jwtGenerator, loginStorage: loginStorage) 120 + ``` 121 + 122 + 3. Start the Bluesky OAuth flow. Use the client metadata URL registered with the Authorization Server (for example, the one served from your appโ€™s hosted metadata file). 123 + 124 + ```swift 125 + let login = try await loginService.login( 126 + account: "did:plc:your-user", 127 + clientMetadataEndpoint: "https://example.com/.well-known/coreatprotocol-client.json" 128 + ) 129 + ``` 130 + 131 + 4. Share the authentication context with CoreATProtocol so the networking layer can add DPoP proofs automatically: 132 + 133 + ```swift 134 + await applyAuthenticationContext(login: login, generator: jwtGenerator) 135 + ``` 136 + 137 + 5. When Bluesky returns a new DPoP nonce (`DPoP-Nonce` header), call ``updateResourceDPoPNonce(_:)`` with the latest value before the next request. 138 + 139 + 6. To sign the user out, call ``clearAuthenticationContext()`` and erase any stored login and keychain items. 140 + 141 + ## Make API requests 142 + 143 + Attach the packageโ€™s router delegate to your networking stack (for example, the client that wraps ``URLSession``) so that access tokens and DPoP proofs are injected into outgoing requests. 144 + 145 + ```swift 146 + var router = NetworkRouter<SomeEndpoint>(decoder: .atDecoder) 147 + router.delegate = await APEnvironment.current.routerDelegate 148 + ``` 149 + 150 + With the context applied, subsequent calls through ``APRouterDelegate`` will refresh DPoP proofs, hash access tokens into the `ath` claim, and keep the nonce in sync with the server. 151 + 152 + ## Troubleshooting 153 + 154 + - Ensure the DPoP key persists across app launches. If the key changes, all tokens issued by Bluesky become invalid and the user must reauthenticate. 155 + - Always call ``applyAuthenticationContext(login:generator:resourceNonce:)`` after refreshing tokens via ``updateTokens(access:refresh:)`` or custom flows so the delegate has current credentials. 156 + - If Bluesky rejects requests with `use_dpop_nonce`, update the cached value via ``updateResourceDPoPNonce(_:)`` and retry. 157 +
-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.
+14 -14
Package.resolved
··· 1 1 { 2 - "originHash" : "46681c90ffb61eca5269d3e2ab8743c6f802287641f8bccf7c47227aa7a6a97a", 2 + "originHash" : "2237e2c10a8d530dcbd1f9770efc8fcf2a9fc2ca2c63a19882551fea7ab9fe25", 3 3 "pins" : [ 4 4 { 5 5 "identity" : "jwt-kit", 6 6 "kind" : "remoteSourceControl", 7 7 "location" : "https://github.com/vapor/jwt-kit.git", 8 8 "state" : { 9 - "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", 10 - "version" : "5.3.0" 9 + "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", 10 + "version" : "5.2.0" 11 11 } 12 12 }, 13 13 { 14 14 "identity" : "oauthenticator", 15 15 "kind" : "remoteSourceControl", 16 - "location" : "https://github.com/radmakr/OAuthenticator.git", 16 + "location" : "https://github.com/ChimeHQ/OAuthenticator", 17 17 "state" : { 18 - "branch" : "CoreAtProtocol", 19 - "revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70" 18 + "branch" : "main", 19 + "revision" : "618971d4d341650db664925fd0479032294064ad" 20 20 } 21 21 }, 22 22 { ··· 24 24 "kind" : "remoteSourceControl", 25 25 "location" : "https://github.com/apple/swift-asn1.git", 26 26 "state" : { 27 - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", 28 - "version" : "1.5.1" 27 + "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", 28 + "version" : "1.5.0" 29 29 } 30 30 }, 31 31 { ··· 33 33 "kind" : "remoteSourceControl", 34 34 "location" : "https://github.com/apple/swift-certificates.git", 35 35 "state" : { 36 - "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", 37 - "version" : "1.17.0" 36 + "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", 37 + "version" : "1.15.0" 38 38 } 39 39 }, 40 40 { ··· 42 42 "kind" : "remoteSourceControl", 43 43 "location" : "https://github.com/apple/swift-crypto.git", 44 44 "state" : { 45 - "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", 46 - "version" : "4.2.0" 45 + "revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18", 46 + "version" : "4.0.0" 47 47 } 48 48 }, 49 49 { ··· 51 51 "kind" : "remoteSourceControl", 52 52 "location" : "https://github.com/apple/swift-log.git", 53 53 "state" : { 54 - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", 55 - "version" : "1.8.0" 54 + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", 55 + "version" : "1.6.4" 56 56 } 57 57 } 58 58 ],
+6 -13
Package.swift
··· 5 5 let package = Package( 6 6 name: "CoreATProtocol", 7 7 platforms: [ 8 - .iOS(.v17), 9 - .watchOS(.v11), 10 - .tvOS(.v17), 11 - .macOS(.v14), 12 - .macCatalyst(.v17), 8 + .iOS(.v26), 9 + .watchOS(.v26), 10 + .tvOS(.v26), 11 + .macOS(.v26), 12 + .macCatalyst(.v26), 13 13 ], 14 14 products: [ 15 15 .library( ··· 18 18 ), 19 19 ], 20 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"), 21 + .package(url: "https://github.com/ChimeHQ/OAuthenticator", branch: "main"), 26 22 .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), 27 23 ], 28 24 targets: [ ··· 32 28 "OAuthenticator", 33 29 .product(name: "JWTKit", package: "jwt-kit"), 34 30 ], 35 - swiftSettings: [ 36 - .enableExperimentalFeature("StrictConcurrency") 37 - ] 38 31 ), 39 32 .testTarget( 40 33 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).
+108 -12
Sources/CoreATProtocol/APEnvironment.swift
··· 5 5 // Created by Thomas Rademaker on 10/10/25. 6 6 // 7 7 8 - import JWTKit 8 + import Foundation 9 + import OAuthenticator 9 10 10 11 @APActor 11 12 public class APEnvironment { 12 13 public static var current: APEnvironment = APEnvironment() 13 - 14 + 15 + // MARK: - Connection Configuration 14 16 public var host: String? 17 + 18 + // MARK: - Authentication Tokens 15 19 public var accessToken: String? 16 20 public var refreshToken: String? 21 + public var login: Login? 22 + 23 + // MARK: - DPoP Support 24 + public var dpopProofGenerator: DPoPSigner.JWTGenerator? 25 + public var resourceServerNonce: String? 26 + public let resourceDPoPSigner = DPoPSigner() 27 + 28 + // MARK: - OAuth Configuration (for token refresh) 29 + public var serverMetadata: ServerMetadata? 30 + public var clientId: String? 31 + public var authState: AuthenticationState? 32 + public var tokenStorage: TokenStorageProtocol? 33 + 34 + // MARK: - Identity 35 + public var resolvedIdentity: IdentityResolver.ResolvedIdentity? 36 + public let identityResolver = IdentityResolver() 37 + 38 + // MARK: - Delegates and Callbacks 17 39 public var atProtocoldelegate: CoreATProtocolDelegate? 18 - public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 19 - public var dpopPrivateKey: ES256PrivateKey? 20 - public var dpopKeys: JWTKeyCollection? 21 40 public let routerDelegate = APRouterDelegate() 22 - 41 + 42 + // MARK: - State Flags 43 + private var isRefreshing = false 44 + 23 45 private init() {} 24 - 25 - // func setup(apiKey: String, apiSecret: String, userAgent: String) { 26 - // self.apiKey = apiKey 27 - // self.apiSecret = apiSecret 28 - // self.userAgent = userAgent 29 - // } 46 + 47 + // MARK: - Token Refresh 48 + 49 + /// Checks if the current access token needs refresh. 50 + public var needsTokenRefresh: Bool { 51 + if let state = authState { 52 + return state.isAccessTokenExpired 53 + } 54 + // If no auth state, check login object 55 + if let login = login { 56 + return !login.accessToken.valid 57 + } 58 + return false 59 + } 60 + 61 + /// Attempts to refresh the access token if needed. 62 + /// Returns true if refresh succeeded or wasn't needed, false if refresh failed. 63 + public func refreshTokenIfNeeded() async -> Bool { 64 + guard needsTokenRefresh else { return true } 65 + 66 + // Prevent concurrent refresh attempts 67 + guard !isRefreshing else { return false } 68 + isRefreshing = true 69 + defer { isRefreshing = false } 70 + 71 + return await performTokenRefresh() 72 + } 73 + 74 + // MARK: - Configuration 75 + 76 + /// Configures the environment for OAuth with token refresh support. 77 + public func configureOAuth( 78 + serverMetadata: ServerMetadata, 79 + clientId: String, 80 + tokenStorage: TokenStorageProtocol? = nil 81 + ) { 82 + self.serverMetadata = serverMetadata 83 + self.clientId = clientId 84 + self.tokenStorage = tokenStorage 85 + } 86 + 87 + /// Stores the complete authentication state after successful login. 88 + public func setAuthenticationState(_ state: AuthenticationState) async { 89 + self.authState = state 90 + self.accessToken = state.accessToken 91 + self.refreshToken = state.refreshToken 92 + 93 + // Update host from PDS URL 94 + if let url = URL(string: state.pdsURL) { 95 + self.host = url.absoluteString 96 + } 97 + 98 + // Persist if storage is configured 99 + if let storage = tokenStorage { 100 + try? await storage.store(state) 101 + } 102 + } 103 + 104 + /// Restores authentication state from storage. 105 + public func restoreAuthenticationState() async -> Bool { 106 + guard let storage = tokenStorage else { return false } 107 + 108 + do { 109 + guard let state = try await storage.retrieve() else { 110 + return false 111 + } 112 + 113 + self.authState = state 114 + self.accessToken = state.accessToken 115 + self.refreshToken = state.refreshToken 116 + 117 + if let url = URL(string: state.pdsURL) { 118 + self.host = url.absoluteString 119 + } 120 + 121 + return true 122 + } catch { 123 + return false 124 + } 125 + } 30 126 }
+146 -41
Sources/CoreATProtocol/CoreATProtocol.swift
··· 1 1 // The Swift Programming Language 2 2 // https://docs.swift.org/swift-book 3 3 4 - import JWTKit 4 + @_exported import OAuthenticator 5 5 6 - // MARK: - Session 6 + /// Delegate protocol for receiving authentication and session lifecycle events. 7 + @MainActor 8 + public protocol CoreATProtocolDelegate: AnyObject, Sendable { 9 + /// Called when tokens have been refreshed. 10 + func tokensUpdated(accessToken: String, refreshToken: String?) async 7 11 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? 12 + /// Called when a session has expired and re-authentication is required. 13 + func sessionExpired() async 15 14 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 - } 15 + /// Called when authentication fails. 16 + func authenticationFailed(error: Error) async 24 17 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 18 + /// Called when DPoP nonce is updated from a server response. 19 + func dpopNonceUpdated(nonce: String) async 30 20 } 31 21 32 - // Default implementation for optional method 22 + /// Default implementations for optional delegate methods. 33 23 public extension CoreATProtocolDelegate { 34 - func sessionUpdated(_ session: Session) async {} 24 + func tokensUpdated(accessToken: String, refreshToken: String?) async {} 25 + func sessionExpired() async {} 26 + func authenticationFailed(error: Error) async {} 27 + func dpopNonceUpdated(nonce: String) async {} 35 28 } 36 29 30 + // MARK: - Setup Functions 31 + 32 + /// Configures the AT Protocol environment with basic authentication. 33 + /// - Parameters: 34 + /// - hostURL: The PDS host URL 35 + /// - accessJWT: Access token 36 + /// - refreshJWT: Refresh token 37 + /// - delegate: Optional delegate for receiving events 37 38 @APActor 38 39 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { 39 40 APEnvironment.current.host = hostURL ··· 42 43 APEnvironment.current.atProtocoldelegate = delegate 43 44 } 44 45 46 + /// Configures the AT Protocol environment with OAuth support. 47 + /// - Parameters: 48 + /// - serverMetadata: OAuth authorization server metadata 49 + /// - clientId: The client ID for this application 50 + /// - tokenStorage: Optional persistent storage for tokens 51 + /// - delegate: Optional delegate for receiving events 52 + @APActor 53 + public func setupOAuth( 54 + serverMetadata: ServerMetadata, 55 + clientId: String, 56 + tokenStorage: TokenStorageProtocol? = nil, 57 + delegate: CoreATProtocolDelegate? = nil 58 + ) { 59 + APEnvironment.current.configureOAuth( 60 + serverMetadata: serverMetadata, 61 + clientId: clientId, 62 + tokenStorage: tokenStorage 63 + ) 64 + APEnvironment.current.atProtocoldelegate = delegate 65 + } 66 + 67 + /// Sets the delegate for receiving authentication events. 45 68 @APActor 46 69 public func setDelegate(_ delegate: CoreATProtocolDelegate) { 47 70 APEnvironment.current.atProtocoldelegate = delegate 48 71 } 49 72 73 + /// Updates the stored tokens. 50 74 @APActor 51 - public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) { 52 - APEnvironment.current.tokenRefreshHandler = handler 75 + public func updateTokens(access: String?, refresh: String?) { 76 + APEnvironment.current.accessToken = access 77 + APEnvironment.current.refreshToken = refresh 78 + } 79 + 80 + /// Updates the host URL. 81 + @APActor 82 + public func update(hostURL: String?) { 83 + APEnvironment.current.host = hostURL 84 + } 85 + 86 + /// Applies a complete authentication context from a successful OAuth login. 87 + /// - Parameters: 88 + /// - login: The Login object from OAuthenticator 89 + /// - generator: DPoP JWT generator for signing requests 90 + /// - resourceNonce: Initial DPoP nonce from the resource server 91 + /// - serverMetadata: OAuth server metadata for token refresh 92 + /// - clientId: Client ID for token refresh 93 + @APActor 94 + public func applyAuthenticationContext( 95 + login: Login, 96 + generator: @escaping DPoPSigner.JWTGenerator, 97 + resourceNonce: String? = nil, 98 + serverMetadata: ServerMetadata? = nil, 99 + clientId: String? = nil 100 + ) { 101 + APEnvironment.current.login = login 102 + APEnvironment.current.accessToken = login.accessToken.value 103 + APEnvironment.current.refreshToken = login.refreshToken?.value 104 + APEnvironment.current.dpopProofGenerator = generator 105 + APEnvironment.current.resourceServerNonce = resourceNonce 106 + APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce 107 + 108 + // Store OAuth configuration if provided (needed for token refresh) 109 + if let metadata = serverMetadata { 110 + APEnvironment.current.serverMetadata = metadata 111 + } 112 + if let id = clientId { 113 + APEnvironment.current.clientId = id 114 + } 53 115 } 54 116 117 + /// Clears all authentication context and tokens. 55 118 @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 119 + public func clearAuthenticationContext() async { 120 + APEnvironment.current.login = nil 121 + APEnvironment.current.dpopProofGenerator = nil 122 + APEnvironment.current.resourceServerNonce = nil 123 + APEnvironment.current.accessToken = nil 124 + APEnvironment.current.refreshToken = nil 125 + APEnvironment.current.resourceDPoPSigner.nonce = nil 126 + APEnvironment.current.authState = nil 127 + APEnvironment.current.resolvedIdentity = nil 128 + 129 + // Clear persistent storage if configured 130 + if let storage = APEnvironment.current.tokenStorage { 131 + try? await storage.clear() 61 132 } 133 + } 134 + 135 + /// Updates the resource server DPoP nonce. 136 + @APActor 137 + public func updateResourceDPoPNonce(_ nonce: String?) { 138 + APEnvironment.current.resourceServerNonce = nonce 139 + APEnvironment.current.resourceDPoPSigner.nonce = nonce 140 + } 62 141 63 - let privateKey = try ES256PrivateKey(pem: pem) 64 - let keys = JWTKeyCollection() 65 - await keys.add(ecdsa: privateKey) 142 + // MARK: - Identity Resolution 66 143 67 - APEnvironment.current.dpopPrivateKey = privateKey 68 - APEnvironment.current.dpopKeys = keys 144 + /// Resolves a handle to a complete identity with PDS and authorization server URLs. 145 + /// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social") 146 + /// - Returns: Complete resolved identity information 147 + @APActor 148 + public func resolveIdentity(handle: String) async throws -> IdentityResolver.ResolvedIdentity { 149 + let identity = try await APEnvironment.current.identityResolver.resolveIdentity(handle: handle) 150 + APEnvironment.current.resolvedIdentity = identity 151 + APEnvironment.current.host = identity.pdsURL 152 + return identity 69 153 } 70 154 155 + /// Resolves a DID to a complete identity with PDS and authorization server URLs. 156 + /// - Parameter did: The DID to resolve (e.g., "did:plc:abc123") 157 + /// - Returns: Complete resolved identity information 71 158 @APActor 72 - public func updateTokens(access: String?, refresh: String?) { 73 - APEnvironment.current.accessToken = access 74 - APEnvironment.current.refreshToken = refresh 159 + public func resolveIdentity(did: String) async throws -> IdentityResolver.ResolvedIdentity { 160 + let identity = try await APEnvironment.current.identityResolver.resolveIdentity(did: did) 161 + APEnvironment.current.resolvedIdentity = identity 162 + APEnvironment.current.host = identity.pdsURL 163 + return identity 75 164 } 76 165 166 + // MARK: - Session Management 167 + 168 + /// Attempts to restore a previous session from persistent storage. 169 + /// - Returns: true if a session was restored, false otherwise 77 170 @APActor 78 - public func update(hostURL: String?) { 79 - APEnvironment.current.host = hostURL 171 + public func restoreSession() async -> Bool { 172 + return await APEnvironment.current.restoreAuthenticationState() 173 + } 174 + 175 + /// Checks if the current session is valid and has non-expired tokens. 176 + @APActor 177 + public var hasValidSession: Bool { 178 + if let state = APEnvironment.current.authState { 179 + return !state.isAccessTokenExpired || state.canRefresh 180 + } 181 + if let login = APEnvironment.current.login { 182 + return login.accessToken.valid || (login.refreshToken?.valid ?? false) 183 + } 184 + return APEnvironment.current.accessToken != nil 80 185 }
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
··· 1 + import Foundation 2 + import JWTKit 3 + import OAuthenticator 4 + 5 + public enum DPoPKeyMaterialError: Error, Equatable { 6 + case publicKeyUnavailable 7 + case invalidCoordinate 8 + } 9 + 10 + public actor DPoPJWTGenerator { 11 + private let privateKey: ES256PrivateKey 12 + private let keys: JWTKeyCollection 13 + private let jwkHeader: [String: JWTHeaderField] 14 + 15 + public init(privateKey: ES256PrivateKey) async throws { 16 + self.privateKey = privateKey 17 + self.keys = JWTKeyCollection() 18 + self.jwkHeader = try Self.makeJWKHeader(from: privateKey) 19 + await self.keys.add(ecdsa: privateKey) 20 + } 21 + 22 + public func jwtGenerator() -> DPoPSigner.JWTGenerator { 23 + { params in 24 + try await self.makeJWT(for: params) 25 + } 26 + } 27 + 28 + public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String { 29 + var header = JWTHeader() 30 + header.typ = params.keyType 31 + header.alg = header.alg ?? "ES256" 32 + header.jwk = jwkHeader 33 + 34 + let issuedAt = Date() 35 + let payload = DPoPPayload( 36 + htm: params.httpMethod, 37 + htu: params.requestEndpoint, 38 + iat: IssuedAtClaim(value: issuedAt), 39 + exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)), 40 + jti: IDClaim(value: UUID().uuidString), 41 + nonce: params.nonce, 42 + iss: params.issuingServer.map { IssuerClaim(value: $0) }, 43 + ath: params.tokenHash 44 + ) 45 + 46 + return try await keys.sign(payload, header: header) 47 + } 48 + 49 + private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] { 50 + guard let parameters = key.publicKey.parameters else { 51 + throw DPoPKeyMaterialError.publicKeyUnavailable 52 + } 53 + 54 + guard 55 + let xData = Data(base64Encoded: parameters.x), 56 + let yData = Data(base64Encoded: parameters.y) 57 + else { 58 + throw DPoPKeyMaterialError.invalidCoordinate 59 + } 60 + 61 + return [ 62 + "kty": .string("EC"), 63 + "crv": .string("P-256"), 64 + "x": .string(xData.base64URLEncodedString()), 65 + "y": .string(yData.base64URLEncodedString()) 66 + ] 67 + } 68 + } 69 + 70 + struct DPoPPayload: JWTPayload { 71 + let htm: String 72 + let htu: String 73 + let iat: IssuedAtClaim 74 + let exp: ExpirationClaim 75 + let jti: IDClaim 76 + let nonce: String? 77 + let iss: IssuerClaim? 78 + let ath: String? 79 + 80 + func verify(using key: some JWTAlgorithm) throws { 81 + try exp.verifyNotExpired(currentDate: Date()) 82 + } 83 + }
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
··· 1 + import Foundation 2 + 3 + extension Data { 4 + /// Returns a URL-safe Base64 representation without padding. 5 + func base64URLEncodedString() -> String { 6 + base64EncodedString() 7 + .replacingOccurrences(of: "+", with: "-") 8 + .replacingOccurrences(of: "/", with: "_") 9 + .replacingOccurrences(of: "=", with: "") 10 + } 11 + }
+123
Sources/CoreATProtocol/Identity/DIDDocument.swift
··· 1 + // 2 + // DIDDocument.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Represents a DID Document as specified by the AT Protocol. 11 + /// DID Documents contain the public key and service endpoints for an identity. 12 + public struct DIDDocument: Codable, Sendable, Hashable { 13 + public let context: [String] 14 + public let id: String 15 + public let alsoKnownAs: [String]? 16 + public let verificationMethod: [VerificationMethod]? 17 + public let service: [Service]? 18 + 19 + enum CodingKeys: String, CodingKey { 20 + case context = "@context" 21 + case id 22 + case alsoKnownAs 23 + case verificationMethod 24 + case service 25 + } 26 + 27 + public init( 28 + context: [String] = ["https://www.w3.org/ns/did/v1"], 29 + id: String, 30 + alsoKnownAs: [String]? = nil, 31 + verificationMethod: [VerificationMethod]? = nil, 32 + service: [Service]? = nil 33 + ) { 34 + self.context = context 35 + self.id = id 36 + self.alsoKnownAs = alsoKnownAs 37 + self.verificationMethod = verificationMethod 38 + self.service = service 39 + } 40 + 41 + /// Extracts the handle from the alsoKnownAs field. 42 + /// Handles are stored as `at://handle` URIs. 43 + public var handle: String? { 44 + alsoKnownAs?.compactMap { uri -> String? in 45 + guard uri.hasPrefix("at://") else { return nil } 46 + return String(uri.dropFirst(5)) 47 + }.first 48 + } 49 + 50 + /// Extracts the PDS (Personal Data Server) endpoint from the service array. 51 + public var pdsEndpoint: String? { 52 + service?.first { $0.id == "#atproto_pds" || $0.type == "AtprotoPersonalDataServer" }?.serviceEndpoint 53 + } 54 + } 55 + 56 + /// Represents a verification method in a DID Document. 57 + public struct VerificationMethod: Codable, Sendable, Hashable { 58 + public let id: String 59 + public let type: String 60 + public let controller: String 61 + public let publicKeyMultibase: String? 62 + 63 + public init(id: String, type: String, controller: String, publicKeyMultibase: String? = nil) { 64 + self.id = id 65 + self.type = type 66 + self.controller = controller 67 + self.publicKeyMultibase = publicKeyMultibase 68 + } 69 + } 70 + 71 + /// Represents a service endpoint in a DID Document. 72 + public struct Service: Codable, Sendable, Hashable { 73 + public let id: String 74 + public let type: String 75 + public let serviceEndpoint: String 76 + 77 + public init(id: String, type: String, serviceEndpoint: String) { 78 + self.id = id 79 + self.type = type 80 + self.serviceEndpoint = serviceEndpoint 81 + } 82 + } 83 + 84 + /// Represents the response from a PLC directory lookup. 85 + public struct PLCDirectoryResponse: Codable, Sendable { 86 + public let did: String 87 + public let verificationMethods: [String: String]? 88 + public let rotationKeys: [String]? 89 + public let alsoKnownAs: [String]? 90 + public let services: [String: PLCService]? 91 + 92 + public struct PLCService: Codable, Sendable { 93 + public let type: String 94 + public let endpoint: String 95 + } 96 + 97 + /// Converts PLC response to standard DID Document format. 98 + public func toDIDDocument() -> DIDDocument { 99 + let verificationMethods = self.verificationMethods?.map { (id, key) in 100 + VerificationMethod( 101 + id: "\(did)\(id)", 102 + type: "Multikey", 103 + controller: did, 104 + publicKeyMultibase: key 105 + ) 106 + } 107 + 108 + let services = self.services?.map { (id, service) in 109 + Service( 110 + id: id, 111 + type: service.type, 112 + serviceEndpoint: service.endpoint 113 + ) 114 + } 115 + 116 + return DIDDocument( 117 + id: did, 118 + alsoKnownAs: alsoKnownAs, 119 + verificationMethod: verificationMethods, 120 + service: services 121 + ) 122 + } 123 + }
+334
Sources/CoreATProtocol/Identity/IdentityResolver.swift
··· 1 + // 2 + // IdentityResolver.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + @preconcurrency import OAuthenticator 10 + 11 + /// Errors that can occur during identity resolution. 12 + public enum IdentityError: Error, Sendable { 13 + case invalidHandle(String) 14 + case invalidDID(String) 15 + case handleResolutionFailed(String) 16 + case didResolutionFailed(String) 17 + case pdsNotFound 18 + case authorizationServerNotFound 19 + case networkError(Error) 20 + case invalidURL(String) 21 + case bidirectionalVerificationFailed(handle: String, did: String) 22 + } 23 + 24 + /// Resolves AT Protocol identities (handles and DIDs) to their associated metadata. 25 + /// 26 + /// This resolver handles: 27 + /// - Handle to DID resolution via `.well-known/atproto-did` 28 + /// - DID document fetching for both `did:plc` and `did:web` methods 29 + /// - PDS (Personal Data Server) endpoint discovery 30 + /// - Authorization server metadata fetching 31 + /// - Bidirectional handle verification 32 + @APActor 33 + public final class IdentityResolver { 34 + 35 + /// Cache entry for resolved identities. 36 + private struct CacheEntry { 37 + let document: DIDDocument 38 + let timestamp: Date 39 + } 40 + 41 + private let urlSession: URLSession 42 + private var cache: [String: CacheEntry] = [:] 43 + 44 + /// Cache TTL in seconds. Default is 10 minutes as recommended by AT Protocol spec. 45 + public var cacheTTL: TimeInterval = 600 46 + 47 + /// The PLC directory URL for resolving did:plc identifiers. 48 + public var plcDirectoryURL: String = "https://plc.directory" 49 + 50 + public init(urlSession: URLSession = .shared) { 51 + self.urlSession = urlSession 52 + } 53 + 54 + // MARK: - Handle Resolution 55 + 56 + /// Resolves a handle to a DID using the `.well-known/atproto-did` endpoint. 57 + /// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social") 58 + /// - Returns: The DID string (e.g., "did:plc:abc123") 59 + public func resolveHandle(_ handle: String) async throws -> String { 60 + let normalizedHandle = handle.lowercased().trimmingCharacters(in: .whitespaces) 61 + 62 + guard isValidHandle(normalizedHandle) else { 63 + throw IdentityError.invalidHandle(handle) 64 + } 65 + 66 + let urlString = "https://\(normalizedHandle)/.well-known/atproto-did" 67 + guard let url = URL(string: urlString) else { 68 + throw IdentityError.invalidURL(urlString) 69 + } 70 + 71 + do { 72 + let (data, response) = try await urlSession.data(from: url) 73 + 74 + guard let httpResponse = response as? HTTPURLResponse, 75 + (200...299).contains(httpResponse.statusCode) else { 76 + throw IdentityError.handleResolutionFailed(handle) 77 + } 78 + 79 + guard let did = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), 80 + did.hasPrefix("did:") else { 81 + throw IdentityError.handleResolutionFailed(handle) 82 + } 83 + 84 + return did 85 + } catch let error as IdentityError { 86 + throw error 87 + } catch { 88 + throw IdentityError.networkError(error) 89 + } 90 + } 91 + 92 + // MARK: - DID Resolution 93 + 94 + /// Resolves a DID to its DID Document. 95 + /// - Parameter did: The DID to resolve (e.g., "did:plc:abc123" or "did:web:example.com") 96 + /// - Returns: The DID Document containing verification methods and service endpoints 97 + public func resolveDID(_ did: String) async throws -> DIDDocument { 98 + // Check cache first 99 + if let cached = cache[did], Date().timeIntervalSince(cached.timestamp) < cacheTTL { 100 + return cached.document 101 + } 102 + 103 + let document: DIDDocument 104 + 105 + if did.hasPrefix("did:plc:") { 106 + document = try await resolvePLCDID(did) 107 + } else if did.hasPrefix("did:web:") { 108 + document = try await resolveWebDID(did) 109 + } else { 110 + throw IdentityError.invalidDID(did) 111 + } 112 + 113 + // Cache the result 114 + cache[did] = CacheEntry(document: document, timestamp: Date()) 115 + 116 + return document 117 + } 118 + 119 + /// Resolves a did:plc identifier using the PLC directory. 120 + private func resolvePLCDID(_ did: String) async throws -> DIDDocument { 121 + let urlString = "\(plcDirectoryURL)/\(did)" 122 + guard let url = URL(string: urlString) else { 123 + throw IdentityError.invalidURL(urlString) 124 + } 125 + 126 + do { 127 + let (data, response) = try await urlSession.data(from: url) 128 + 129 + guard let httpResponse = response as? HTTPURLResponse, 130 + (200...299).contains(httpResponse.statusCode) else { 131 + throw IdentityError.didResolutionFailed(did) 132 + } 133 + 134 + // Try to decode as PLC directory response first 135 + if let plcResponse = try? JSONDecoder().decode(PLCDirectoryResponse.self, from: data) { 136 + return plcResponse.toDIDDocument() 137 + } 138 + 139 + // Fall back to standard DID document format 140 + return try JSONDecoder().decode(DIDDocument.self, from: data) 141 + } catch let error as IdentityError { 142 + throw error 143 + } catch { 144 + throw IdentityError.networkError(error) 145 + } 146 + } 147 + 148 + /// Resolves a did:web identifier. 149 + private func resolveWebDID(_ did: String) async throws -> DIDDocument { 150 + // did:web:example.com -> https://example.com/.well-known/did.json 151 + // did:web:example.com:path:to:resource -> https://example.com/path/to/resource/did.json 152 + let identifier = String(did.dropFirst("did:web:".count)) 153 + let parts = identifier.split(separator: ":").map(String.init) 154 + 155 + let urlString: String 156 + if parts.count == 1 { 157 + urlString = "https://\(parts[0])/.well-known/did.json" 158 + } else { 159 + let host = parts[0] 160 + let path = parts.dropFirst().joined(separator: "/") 161 + urlString = "https://\(host)/\(path)/did.json" 162 + } 163 + 164 + guard let url = URL(string: urlString) else { 165 + throw IdentityError.invalidURL(urlString) 166 + } 167 + 168 + do { 169 + let (data, response) = try await urlSession.data(from: url) 170 + 171 + guard let httpResponse = response as? HTTPURLResponse, 172 + (200...299).contains(httpResponse.statusCode) else { 173 + throw IdentityError.didResolutionFailed(did) 174 + } 175 + 176 + return try JSONDecoder().decode(DIDDocument.self, from: data) 177 + } catch let error as IdentityError { 178 + throw error 179 + } catch { 180 + throw IdentityError.networkError(error) 181 + } 182 + } 183 + 184 + // MARK: - PDS Discovery 185 + 186 + /// Gets the PDS endpoint for a given DID. 187 + /// - Parameter did: The DID to look up 188 + /// - Returns: The PDS service endpoint URL 189 + public func getPDSEndpoint(for did: String) async throws -> String { 190 + let document = try await resolveDID(did) 191 + 192 + guard let pds = document.pdsEndpoint else { 193 + throw IdentityError.pdsNotFound 194 + } 195 + 196 + return pds 197 + } 198 + 199 + // MARK: - Authorization Server Discovery 200 + 201 + /// Represents the OAuth Protected Resource metadata from a PDS. 202 + public struct ProtectedResourceMetadata: Codable, Sendable { 203 + public let resource: String 204 + public let authorizationServers: [String] 205 + 206 + enum CodingKeys: String, CodingKey { 207 + case resource 208 + case authorizationServers = "authorization_servers" 209 + } 210 + } 211 + 212 + /// Fetches the authorization server URL from a PDS. 213 + /// - Parameter pdsURL: The PDS base URL 214 + /// - Returns: The authorization server URL 215 + public func getAuthorizationServer(from pdsURL: String) async throws -> String { 216 + let normalizedPDS = pdsURL.hasSuffix("/") ? String(pdsURL.dropLast()) : pdsURL 217 + let urlString = "\(normalizedPDS)/.well-known/oauth-protected-resource" 218 + 219 + guard let url = URL(string: urlString) else { 220 + throw IdentityError.invalidURL(urlString) 221 + } 222 + 223 + do { 224 + let (data, response) = try await urlSession.data(from: url) 225 + 226 + guard let httpResponse = response as? HTTPURLResponse, 227 + (200...299).contains(httpResponse.statusCode) else { 228 + throw IdentityError.authorizationServerNotFound 229 + } 230 + 231 + let metadata = try JSONDecoder().decode(ProtectedResourceMetadata.self, from: data) 232 + 233 + guard let authServer = metadata.authorizationServers.first else { 234 + throw IdentityError.authorizationServerNotFound 235 + } 236 + 237 + return authServer 238 + } catch let error as IdentityError { 239 + throw error 240 + } catch { 241 + throw IdentityError.networkError(error) 242 + } 243 + } 244 + 245 + // MARK: - Full Resolution 246 + 247 + /// Result of resolving an identity including all metadata. 248 + public struct ResolvedIdentity: Sendable { 249 + public let handle: String 250 + public let did: String 251 + public let didDocument: DIDDocument 252 + public let pdsURL: String 253 + public let authorizationServerURL: String 254 + 255 + public init(handle: String, did: String, didDocument: DIDDocument, pdsURL: String, authorizationServerURL: String) { 256 + self.handle = handle 257 + self.did = did 258 + self.didDocument = didDocument 259 + self.pdsURL = pdsURL 260 + self.authorizationServerURL = authorizationServerURL 261 + } 262 + } 263 + 264 + /// Fully resolves an identity from a handle, including bidirectional verification. 265 + /// - Parameter handle: The handle to resolve 266 + /// - Returns: Complete identity information including PDS and auth server 267 + public func resolveIdentity(handle: String) async throws -> ResolvedIdentity { 268 + // Step 1: Resolve handle to DID 269 + let did = try await resolveHandle(handle) 270 + 271 + // Step 2: Resolve DID to document 272 + let document = try await resolveDID(did) 273 + 274 + // Step 3: Verify bidirectional handle claim 275 + let normalizedHandle = handle.lowercased() 276 + if let documentHandle = document.handle?.lowercased(), documentHandle != normalizedHandle { 277 + throw IdentityError.bidirectionalVerificationFailed(handle: handle, did: did) 278 + } 279 + 280 + // Step 4: Get PDS endpoint 281 + guard let pdsURL = document.pdsEndpoint else { 282 + throw IdentityError.pdsNotFound 283 + } 284 + 285 + // Step 5: Get authorization server 286 + let authServerURL = try await getAuthorizationServer(from: pdsURL) 287 + 288 + return ResolvedIdentity( 289 + handle: handle, 290 + did: did, 291 + didDocument: document, 292 + pdsURL: pdsURL, 293 + authorizationServerURL: authServerURL 294 + ) 295 + } 296 + 297 + /// Resolves identity starting from a DID. 298 + /// - Parameter did: The DID to resolve 299 + /// - Returns: Complete identity information 300 + public func resolveIdentity(did: String) async throws -> ResolvedIdentity { 301 + let document = try await resolveDID(did) 302 + 303 + guard let pdsURL = document.pdsEndpoint else { 304 + throw IdentityError.pdsNotFound 305 + } 306 + 307 + let authServerURL = try await getAuthorizationServer(from: pdsURL) 308 + 309 + return ResolvedIdentity( 310 + handle: document.handle ?? "", 311 + did: did, 312 + didDocument: document, 313 + pdsURL: pdsURL, 314 + authorizationServerURL: authServerURL 315 + ) 316 + } 317 + 318 + // MARK: - Validation 319 + 320 + /// Validates if a string is a valid handle format. 321 + private func isValidHandle(_ handle: String) -> Bool { 322 + // Basic validation: must have at least one dot, no spaces, reasonable length 323 + let parts = handle.split(separator: ".") 324 + guard parts.count >= 2 else { return false } 325 + guard handle.count >= 3 && handle.count <= 253 else { return false } 326 + guard !handle.contains(" ") else { return false } 327 + return true 328 + } 329 + 330 + /// Clears the identity cache. 331 + public func clearCache() { 332 + cache.removeAll() 333 + } 334 + }
+245
Sources/CoreATProtocol/Logging/ATLogger.swift
··· 1 + // 2 + // ATLogger.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + import os.log 10 + 11 + /// Log levels for AT Protocol operations. 12 + public enum ATLogLevel: Int, Comparable, Sendable { 13 + case debug = 0 14 + case info = 1 15 + case warning = 2 16 + case error = 3 17 + case none = 100 18 + 19 + public static func < (lhs: ATLogLevel, rhs: ATLogLevel) -> Bool { 20 + lhs.rawValue < rhs.rawValue 21 + } 22 + } 23 + 24 + /// Protocol for custom log handlers. 25 + public protocol ATLogHandler: Sendable { 26 + func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) 27 + } 28 + 29 + /// Logger for AT Protocol operations. 30 + /// Provides structured logging with support for custom handlers. 31 + public final class ATLogger: @unchecked Sendable { 32 + 33 + /// Shared logger instance. 34 + public static let shared = ATLogger() 35 + 36 + /// Current log level. Messages below this level are not logged. 37 + public var logLevel: ATLogLevel = .info 38 + 39 + /// Custom log handler. If nil, uses OSLog on Apple platforms. 40 + public var handler: ATLogHandler? 41 + 42 + /// Whether to include request/response bodies in logs (may contain sensitive data). 43 + public var logBodies: Bool = false 44 + 45 + /// Whether to redact authorization headers and tokens. 46 + public var redactTokens: Bool = true 47 + 48 + private let osLog: OSLog 49 + 50 + private init() { 51 + self.osLog = OSLog(subsystem: "com.atprotocol.core", category: "network") 52 + } 53 + 54 + // MARK: - Logging Methods 55 + 56 + /// Logs a debug message. 57 + public func debug( 58 + _ message: @autoclosure () -> String, 59 + metadata: [String: String]? = nil, 60 + file: String = #file, 61 + function: String = #function, 62 + line: Int = #line 63 + ) { 64 + log(level: .debug, message: message(), metadata: metadata, file: file, function: function, line: line) 65 + } 66 + 67 + /// Logs an info message. 68 + public func info( 69 + _ message: @autoclosure () -> String, 70 + metadata: [String: String]? = nil, 71 + file: String = #file, 72 + function: String = #function, 73 + line: Int = #line 74 + ) { 75 + log(level: .info, message: message(), metadata: metadata, file: file, function: function, line: line) 76 + } 77 + 78 + /// Logs a warning message. 79 + public func warning( 80 + _ message: @autoclosure () -> String, 81 + metadata: [String: String]? = nil, 82 + file: String = #file, 83 + function: String = #function, 84 + line: Int = #line 85 + ) { 86 + log(level: .warning, message: message(), metadata: metadata, file: file, function: function, line: line) 87 + } 88 + 89 + /// Logs an error message. 90 + public func error( 91 + _ message: @autoclosure () -> String, 92 + metadata: [String: String]? = nil, 93 + file: String = #file, 94 + function: String = #function, 95 + line: Int = #line 96 + ) { 97 + log(level: .error, message: message(), metadata: metadata, file: file, function: function, line: line) 98 + } 99 + 100 + // MARK: - Network Logging 101 + 102 + /// Logs an outgoing request. 103 + public func logRequest(_ request: URLRequest, id: String = UUID().uuidString) { 104 + guard logLevel <= .debug else { return } 105 + 106 + var metadata: [String: String] = [ 107 + "request_id": id, 108 + "method": request.httpMethod ?? "UNKNOWN", 109 + "url": request.url?.absoluteString ?? "unknown" 110 + ] 111 + 112 + // Add headers (redacting sensitive ones) 113 + if let headers = request.allHTTPHeaderFields { 114 + for (key, value) in headers { 115 + let redactedValue = shouldRedact(header: key) ? "[REDACTED]" : value 116 + metadata["header_\(key)"] = redactedValue 117 + } 118 + } 119 + 120 + // Optionally log body 121 + if logBodies, let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { 122 + let truncated = bodyString.count > 1000 ? String(bodyString.prefix(1000)) + "..." : bodyString 123 + metadata["body"] = truncated 124 + } 125 + 126 + debug("Request: \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")", metadata: metadata) 127 + } 128 + 129 + /// Logs an incoming response. 130 + public func logResponse(_ response: URLResponse, data: Data?, error: Error?, id: String = UUID().uuidString, duration: TimeInterval? = nil) { 131 + guard logLevel <= .debug else { return } 132 + 133 + var metadata: [String: String] = ["request_id": id] 134 + 135 + if let httpResponse = response as? HTTPURLResponse { 136 + metadata["status_code"] = String(httpResponse.statusCode) 137 + metadata["url"] = httpResponse.url?.absoluteString ?? "unknown" 138 + } 139 + 140 + if let duration = duration { 141 + metadata["duration_ms"] = String(format: "%.2f", duration * 1000) 142 + } 143 + 144 + if let data = data { 145 + metadata["response_size"] = String(data.count) 146 + 147 + if logBodies, let bodyString = String(data: data, encoding: .utf8) { 148 + let truncated = bodyString.count > 1000 ? String(bodyString.prefix(1000)) + "..." : bodyString 149 + metadata["body"] = truncated 150 + } 151 + } 152 + 153 + if let error = error { 154 + metadata["error"] = error.localizedDescription 155 + self.error("Response error: \(error.localizedDescription)", metadata: metadata) 156 + } else if let httpResponse = response as? HTTPURLResponse { 157 + let message = "Response: \(httpResponse.statusCode)" 158 + if httpResponse.statusCode >= 400 { 159 + warning(message, metadata: metadata) 160 + } else { 161 + debug(message, metadata: metadata) 162 + } 163 + } 164 + } 165 + 166 + /// Logs a token refresh attempt. 167 + public func logTokenRefresh(success: Bool, error: Error? = nil) { 168 + if success { 169 + info("Token refresh successful") 170 + } else if let error = error { 171 + self.error("Token refresh failed: \(error.localizedDescription)") 172 + } else { 173 + warning("Token refresh failed") 174 + } 175 + } 176 + 177 + /// Logs identity resolution. 178 + public func logIdentityResolution(handle: String? = nil, did: String? = nil, success: Bool, error: Error? = nil) { 179 + var metadata: [String: String] = [:] 180 + if let handle = handle { metadata["handle"] = handle } 181 + if let did = did { metadata["did"] = did } 182 + 183 + if success { 184 + debug("Identity resolved", metadata: metadata) 185 + } else if let error = error { 186 + self.error("Identity resolution failed: \(error.localizedDescription)", metadata: metadata) 187 + } 188 + } 189 + 190 + // MARK: - Private 191 + 192 + private func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) { 193 + guard level >= logLevel else { return } 194 + 195 + if let handler = handler { 196 + handler.log(level: level, message: message, metadata: metadata, file: file, function: function, line: line) 197 + } else { 198 + let fileName = (file as NSString).lastPathComponent 199 + let logMessage = "[\(fileName):\(line)] \(function) - \(message)" 200 + 201 + switch level { 202 + case .debug: 203 + os_log(.debug, log: osLog, "%{public}@", logMessage) 204 + case .info: 205 + os_log(.info, log: osLog, "%{public}@", logMessage) 206 + case .warning: 207 + os_log(.default, log: osLog, "โš ๏ธ %{public}@", logMessage) 208 + case .error: 209 + os_log(.error, log: osLog, "%{public}@", logMessage) 210 + case .none: 211 + break 212 + } 213 + } 214 + } 215 + 216 + private func shouldRedact(header: String) -> Bool { 217 + guard redactTokens else { return false } 218 + let sensitiveHeaders = ["authorization", "dpop", "cookie", "set-cookie"] 219 + return sensitiveHeaders.contains(header.lowercased()) 220 + } 221 + } 222 + 223 + /// Console log handler for development. 224 + public struct ConsoleLogHandler: ATLogHandler { 225 + public init() {} 226 + 227 + public func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) { 228 + let fileName = (file as NSString).lastPathComponent 229 + let prefix: String 230 + switch level { 231 + case .debug: prefix = "๐Ÿ” DEBUG" 232 + case .info: prefix = "โ„น๏ธ INFO" 233 + case .warning: prefix = "โš ๏ธ WARNING" 234 + case .error: prefix = "โŒ ERROR" 235 + case .none: return 236 + } 237 + 238 + var output = "\(prefix) [\(fileName):\(line)] \(message)" 239 + if let metadata = metadata, !metadata.isEmpty { 240 + let metaString = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") 241 + output += " {\(metaString)}" 242 + } 243 + print(output) 244 + } 245 + }
+227
Sources/CoreATProtocol/LoginService.swift
··· 1 + // 2 + // LoginService.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Thomas Rademaker on 10/17/25. 6 + // 7 + 8 + import Foundation 9 + import OAuthenticator 10 + 11 + /// Service for handling AT Protocol OAuth authentication. 12 + @APActor 13 + public final class LoginService { 14 + 15 + /// Errors that can occur during login. 16 + public enum Error: Swift.Error, Sendable { 17 + case missingStoredLogin 18 + case identityResolutionFailed(IdentityError) 19 + case serverMetadataFailed 20 + case clientMetadataFailed 21 + case authenticationFailed(Swift.Error) 22 + case subjectMismatch(expected: String, actual: String) 23 + } 24 + 25 + private let loginStorage: LoginStorage 26 + private let jwtGenerator: DPoPSigner.JWTGenerator 27 + private let identityResolver: IdentityResolver 28 + private var authenticator: Authenticator? 29 + 30 + /// Creates a new login service. 31 + /// - Parameters: 32 + /// - jwtGenerator: DPoP JWT generator for signing proofs 33 + /// - loginStorage: Storage for persisting login tokens 34 + public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) { 35 + self.jwtGenerator = jwtGenerator 36 + self.loginStorage = loginStorage 37 + self.identityResolver = IdentityResolver() 38 + } 39 + 40 + /// Performs OAuth login for an AT Protocol account. 41 + /// 42 + /// This method: 43 + /// 1. Resolves the account handle/DID to find the PDS 44 + /// 2. Discovers OAuth server metadata 45 + /// 3. Fetches client metadata 46 + /// 4. Performs PKCE + PAR + DPoP OAuth flow 47 + /// 5. Verifies the returned identity matches the expected account 48 + /// 6. Stores the tokens and updates the environment 49 + /// 50 + /// - Parameters: 51 + /// - account: Handle or DID of the account to authenticate 52 + /// - clientMetadataEndpoint: URL where the client metadata document is published 53 + /// - Returns: The Login object with access and refresh tokens 54 + public func login(account: String, clientMetadataEndpoint: String) async throws -> Login { 55 + let provider = URLSession.defaultProvider 56 + 57 + // Step 1: Resolve identity to find PDS and auth server 58 + let resolvedIdentity: IdentityResolver.ResolvedIdentity 59 + do { 60 + if account.hasPrefix("did:") { 61 + resolvedIdentity = try await identityResolver.resolveIdentity(did: account) 62 + } else { 63 + resolvedIdentity = try await identityResolver.resolveIdentity(handle: account) 64 + } 65 + } catch let error as IdentityError { 66 + ATLogger.shared.error("Identity resolution failed for \(account): \(error)") 67 + throw Error.identityResolutionFailed(error) 68 + } 69 + 70 + ATLogger.shared.info("Resolved identity: DID=\(resolvedIdentity.did), PDS=\(resolvedIdentity.pdsURL)") 71 + 72 + // Update environment with PDS 73 + APEnvironment.current.host = resolvedIdentity.pdsURL 74 + APEnvironment.current.resolvedIdentity = resolvedIdentity 75 + 76 + // Step 2: Extract server host for metadata fetch 77 + guard let serverURL = URL(string: resolvedIdentity.authorizationServerURL), 78 + let serverHost = serverURL.host else { 79 + throw Error.serverMetadataFailed 80 + } 81 + 82 + // Step 3: Fetch server metadata 83 + let serverConfig: ServerMetadata 84 + do { 85 + serverConfig = try await ServerMetadata.load(for: serverHost, provider: provider) 86 + APEnvironment.current.serverMetadata = serverConfig 87 + } catch { 88 + ATLogger.shared.error("Failed to load server metadata: \(error)") 89 + throw Error.serverMetadataFailed 90 + } 91 + 92 + // Step 4: Fetch client metadata 93 + let clientConfig: ClientMetadata 94 + do { 95 + clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider) 96 + APEnvironment.current.clientId = clientConfig.clientId 97 + } catch { 98 + ATLogger.shared.error("Failed to load client metadata: \(error)") 99 + throw Error.clientMetadataFailed 100 + } 101 + 102 + // Step 5: Configure and perform OAuth 103 + let tokenHandling = Bluesky.tokenHandling( 104 + account: account, 105 + server: serverConfig, 106 + jwtGenerator: jwtGenerator 107 + ) 108 + 109 + let config = Authenticator.Configuration( 110 + appCredentials: clientConfig.credentials, 111 + loginStorage: loginStorage, 112 + tokenHandling: tokenHandling, 113 + mode: .automatic 114 + ) 115 + 116 + authenticator = Authenticator(config: config) 117 + 118 + do { 119 + try await authenticator?.authenticate() 120 + } catch { 121 + ATLogger.shared.error("Authentication failed: \(error)") 122 + throw Error.authenticationFailed(error) 123 + } 124 + 125 + // Step 6: Retrieve and verify login 126 + guard let storedLogin = try await loginStorage.retrieveLogin() else { 127 + throw Error.missingStoredLogin 128 + } 129 + 130 + // Verify the subject matches expected DID 131 + if let issuer = storedLogin.issuingServer, issuer != resolvedIdentity.did { 132 + ATLogger.shared.warning("Subject mismatch: expected \(resolvedIdentity.did), got \(issuer)") 133 + // This is a security check - the token should be for the expected user 134 + throw Error.subjectMismatch(expected: resolvedIdentity.did, actual: issuer) 135 + } 136 + 137 + // Step 7: Update environment with complete authentication context 138 + applyAuthenticationContext( 139 + login: storedLogin, 140 + generator: jwtGenerator, 141 + serverMetadata: serverConfig, 142 + clientId: clientConfig.clientId 143 + ) 144 + 145 + // Store complete auth state if token storage is configured 146 + if let tokenStorage = APEnvironment.current.tokenStorage { 147 + let authState = AuthenticationState( 148 + did: resolvedIdentity.did, 149 + handle: resolvedIdentity.handle, 150 + pdsURL: resolvedIdentity.pdsURL, 151 + authServerURL: resolvedIdentity.authorizationServerURL, 152 + accessToken: storedLogin.accessToken.value, 153 + accessTokenExpiry: storedLogin.accessToken.expiry, 154 + refreshToken: storedLogin.refreshToken?.value, 155 + scope: storedLogin.scopes, 156 + dpopPrivateKeyData: nil // Key management is caller's responsibility 157 + ) 158 + try? await tokenStorage.store(authState) 159 + APEnvironment.current.authState = authState 160 + } 161 + 162 + ATLogger.shared.info("Login successful for \(resolvedIdentity.handle)") 163 + 164 + return storedLogin 165 + } 166 + 167 + /// Performs OAuth login using pre-resolved identity and server metadata. 168 + /// Use this when you've already resolved the identity and fetched metadata. 169 + /// 170 + /// - Parameters: 171 + /// - identity: Pre-resolved identity information 172 + /// - serverMetadata: Pre-fetched OAuth server metadata 173 + /// - clientMetadata: Pre-fetched client metadata 174 + /// - Returns: The Login object with access and refresh tokens 175 + public func login( 176 + identity: IdentityResolver.ResolvedIdentity, 177 + serverMetadata: ServerMetadata, 178 + clientMetadata: ClientMetadata 179 + ) async throws -> Login { 180 + // Update environment 181 + APEnvironment.current.host = identity.pdsURL 182 + APEnvironment.current.resolvedIdentity = identity 183 + APEnvironment.current.serverMetadata = serverMetadata 184 + APEnvironment.current.clientId = clientMetadata.clientId 185 + 186 + let tokenHandling = Bluesky.tokenHandling( 187 + account: identity.handle, 188 + server: serverMetadata, 189 + jwtGenerator: jwtGenerator 190 + ) 191 + 192 + let config = Authenticator.Configuration( 193 + appCredentials: clientMetadata.credentials, 194 + loginStorage: loginStorage, 195 + tokenHandling: tokenHandling, 196 + mode: .automatic 197 + ) 198 + 199 + authenticator = Authenticator(config: config) 200 + 201 + do { 202 + try await authenticator?.authenticate() 203 + } catch { 204 + throw Error.authenticationFailed(error) 205 + } 206 + 207 + guard let storedLogin = try await loginStorage.retrieveLogin() else { 208 + throw Error.missingStoredLogin 209 + } 210 + 211 + applyAuthenticationContext( 212 + login: storedLogin, 213 + generator: jwtGenerator, 214 + serverMetadata: serverMetadata, 215 + clientId: clientMetadata.clientId 216 + ) 217 + 218 + return storedLogin 219 + } 220 + 221 + /// Logs out by clearing all stored tokens and authentication state. 222 + public func logout() async { 223 + await clearAuthenticationContext() 224 + authenticator = nil 225 + ATLogger.shared.info("Logged out") 226 + } 227 + }
+207 -6
Sources/CoreATProtocol/Models/ATError.swift
··· 5 5 // Created by Thomas Rademaker on 10/8/25. 6 6 // 7 7 8 - public enum AtError: Error { 8 + import Foundation 9 + 10 + /// Top-level error type for AT Protocol operations. 11 + public enum AtError: Error, Sendable { 12 + /// An error message returned by the server. 9 13 case message(ErrorMessage) 14 + 15 + /// A network-level error. 10 16 case network(NetworkError) 17 + 18 + /// An OAuth/authentication error. 19 + case oauth(OAuthError) 20 + 21 + /// An identity resolution error. 22 + case identity(IdentityError) 23 + 24 + /// A decoding error. 25 + case decoding(DecodingError) 26 + 27 + /// An unknown error. 28 + case unknown(Error) 11 29 } 12 30 31 + extension AtError: LocalizedError { 32 + public var errorDescription: String? { 33 + switch self { 34 + case .message(let msg): 35 + return msg.message ?? msg.error 36 + case .network(let err): 37 + return err.localizedDescription 38 + case .oauth(let err): 39 + return err.localizedDescription 40 + case .identity(let err): 41 + return String(describing: err) 42 + case .decoding(let err): 43 + return err.localizedDescription 44 + case .unknown(let err): 45 + return err.localizedDescription 46 + } 47 + } 48 + 49 + /// Returns true if this error indicates the user needs to re-authenticate. 50 + public var requiresReauthentication: Bool { 51 + switch self { 52 + case .message(let msg): 53 + return msg.errorType == .authenticationRequired || 54 + msg.errorType == .expiredToken || 55 + msg.errorType == .authMissing 56 + case .network(let err): 57 + if case .statusCode(let code, _) = err, code?.rawValue == 401 { 58 + return true 59 + } 60 + return false 61 + case .oauth(let err): 62 + switch err { 63 + case .accessTokenExpired, .refreshTokenExpired, .refreshTokenMissing: 64 + return true 65 + default: 66 + return false 67 + } 68 + default: 69 + return false 70 + } 71 + } 72 + 73 + /// Returns true if this error might succeed if retried. 74 + public var isRetryable: Bool { 75 + switch self { 76 + case .message(let msg): 77 + return msg.errorType == .rateLimitExceeded 78 + case .network(let err): 79 + switch err { 80 + case .statusCode(let code, _): 81 + // 5xx errors and 429 are retryable 82 + guard let status = code?.rawValue else { return false } 83 + return status >= 500 || status == 429 84 + case .tokenRefresh: 85 + return true 86 + default: 87 + return false 88 + } 89 + default: 90 + return false 91 + } 92 + } 93 + } 94 + 95 + /// Error message returned by AT Protocol servers. 13 96 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. 97 + /// The error code/type string. 16 98 public let error: String 99 + 100 + /// Optional human-readable error message. 17 101 public let message: String? 18 - 102 + 19 103 public init(error: String, message: String?) { 20 104 self.error = error 21 105 self.message = message 22 106 } 107 + 108 + /// Attempts to parse the error string as a known error type. 109 + public var errorType: AtErrorType? { 110 + AtErrorType(rawValue: error) 111 + } 23 112 } 24 113 25 - public enum AtErrorType: String, Codable, Sendable { 114 + /// Known AT Protocol error types. 115 + public enum AtErrorType: String, Codable, Sendable, CaseIterable { 116 + // Authentication errors 26 117 case authenticationRequired = "AuthenticationRequired" 27 118 case expiredToken = "ExpiredToken" 119 + case authMissing = "AuthMissing" 120 + case invalidToken = "InvalidToken" 121 + 122 + // Request errors 28 123 case invalidRequest = "InvalidRequest" 124 + case invalidSwap = "InvalidSwap" 29 125 case methodNotImplemented = "MethodNotImplemented" 126 + 127 + // Rate limiting 30 128 case rateLimitExceeded = "RateLimitExceeded" 31 - case authMissing = "AuthMissing" 129 + 130 + // Account errors 131 + case accountTakedown = "AccountTakedown" 132 + case accountSuspended = "AccountSuspended" 133 + case accountDeactivated = "AccountDeactivated" 134 + case accountNotFound = "AccountNotFound" 135 + 136 + // Record errors 137 + case recordNotFound = "RecordNotFound" 138 + case repoNotFound = "RepoNotFound" 139 + case blobNotFound = "BlobNotFound" 140 + case blockNotFound = "BlockNotFound" 141 + 142 + // Validation errors 143 + case invalidHandle = "InvalidHandle" 144 + case handleNotAvailable = "HandleNotAvailable" 145 + case unsupportedDomain = "UnsupportedDomain" 146 + case unresolvableDid = "UnresolvableDid" 147 + 148 + // Blob errors 149 + case blobTooLarge = "BlobTooLarge" 150 + case invalidBlob = "InvalidBlob" 151 + 152 + // Content errors 153 + case duplicateCreate = "DuplicateCreate" 154 + case unknownFeed = "UnknownFeed" 155 + case unknownList = "UnknownList" 156 + case notFound = "NotFound" 157 + 158 + // Server errors 159 + case upstreamFailure = "UpstreamFailure" 160 + case upstreamTimeout = "UpstreamTimeout" 161 + case internalServerError = "InternalServerError" 162 + 163 + /// Human-readable description of the error type. 164 + public var description: String { 165 + switch self { 166 + case .authenticationRequired: return "Authentication is required" 167 + case .expiredToken: return "The access token has expired" 168 + case .authMissing: return "Authentication credentials are missing" 169 + case .invalidToken: return "The provided token is invalid" 170 + case .invalidRequest: return "The request is invalid" 171 + case .invalidSwap: return "The swap operation is invalid" 172 + case .methodNotImplemented: return "This method is not implemented" 173 + case .rateLimitExceeded: return "Rate limit exceeded" 174 + case .accountTakedown: return "Account has been taken down" 175 + case .accountSuspended: return "Account has been suspended" 176 + case .accountDeactivated: return "Account has been deactivated" 177 + case .accountNotFound: return "Account not found" 178 + case .recordNotFound: return "Record not found" 179 + case .repoNotFound: return "Repository not found" 180 + case .blobNotFound: return "Blob not found" 181 + case .blockNotFound: return "Block not found" 182 + case .invalidHandle: return "The handle is invalid" 183 + case .handleNotAvailable: return "The handle is not available" 184 + case .unsupportedDomain: return "The domain is not supported" 185 + case .unresolvableDid: return "The DID cannot be resolved" 186 + case .blobTooLarge: return "The blob is too large" 187 + case .invalidBlob: return "The blob is invalid" 188 + case .duplicateCreate: return "A record with this key already exists" 189 + case .unknownFeed: return "The feed is not known" 190 + case .unknownList: return "The list is not known" 191 + case .notFound: return "The resource was not found" 192 + case .upstreamFailure: return "An upstream service failed" 193 + case .upstreamTimeout: return "An upstream service timed out" 194 + case .internalServerError: return "Internal server error" 195 + } 196 + } 197 + } 198 + 199 + /// Rate limit information from response headers. 200 + public struct RateLimitInfo: Sendable { 201 + /// Maximum number of requests allowed in the window. 202 + public let limit: Int 203 + 204 + /// Number of requests remaining in the current window. 205 + public let remaining: Int 206 + 207 + /// Unix timestamp when the rate limit resets. 208 + public let resetTimestamp: TimeInterval 209 + 210 + /// Date when the rate limit resets. 211 + public var resetDate: Date { 212 + Date(timeIntervalSince1970: resetTimestamp) 213 + } 214 + 215 + /// Time interval until the rate limit resets. 216 + public var timeUntilReset: TimeInterval { 217 + resetTimestamp - Date().timeIntervalSince1970 218 + } 219 + 220 + /// Parses rate limit information from HTTP response headers. 221 + public static func from(response: HTTPURLResponse) -> RateLimitInfo? { 222 + guard let limitStr = response.value(forHTTPHeaderField: "RateLimit-Limit"), 223 + let remainingStr = response.value(forHTTPHeaderField: "RateLimit-Remaining"), 224 + let resetStr = response.value(forHTTPHeaderField: "RateLimit-Reset"), 225 + let limit = Int(limitStr), 226 + let remaining = Int(remainingStr), 227 + let reset = TimeInterval(resetStr) else { 228 + return nil 229 + } 230 + 231 + return RateLimitInfo(limit: limit, remaining: remaining, resetTimestamp: reset) 232 + } 32 233 }
+81 -3
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
··· 1 + import Foundation 2 + 3 + /// Describes the type of HTTP task to perform. 1 4 public enum HTTPTask: Sendable { 5 + /// A simple request with no body. 2 6 case request 3 - 7 + 8 + /// A request with encoded parameters (URL query or JSON body). 4 9 case requestParameters(encoding: ParameterEncoding) 5 - 6 - // case download, upload...etc 10 + 11 + /// A blob upload request with raw data and content type. 12 + case uploadBlob(data: Data, mimeType: String) 13 + 14 + /// A multipart form data upload. 15 + case uploadMultipart(parts: [MultipartFormData]) 16 + } 17 + 18 + /// Represents a single part in a multipart form data request. 19 + public struct MultipartFormData: Sendable { 20 + /// The field name for this part. 21 + public let name: String 22 + 23 + /// The filename for file uploads (nil for regular fields). 24 + public let filename: String? 25 + 26 + /// The content type of this part. 27 + public let mimeType: String? 28 + 29 + /// The data for this part. 30 + public let data: Data 31 + 32 + /// Creates a text field part. 33 + public static func field(name: String, value: String) -> MultipartFormData { 34 + MultipartFormData( 35 + name: name, 36 + filename: nil, 37 + mimeType: nil, 38 + data: Data(value.utf8) 39 + ) 40 + } 41 + 42 + /// Creates a file upload part. 43 + public static func file(name: String, filename: String, mimeType: String, data: Data) -> MultipartFormData { 44 + MultipartFormData( 45 + name: name, 46 + filename: filename, 47 + mimeType: mimeType, 48 + data: data 49 + ) 50 + } 51 + 52 + public init(name: String, filename: String?, mimeType: String?, data: Data) { 53 + self.name = name 54 + self.filename = filename 55 + self.mimeType = mimeType 56 + self.data = data 57 + } 58 + } 59 + 60 + /// Response from a blob upload operation. 61 + public struct BlobUploadResponse: Codable, Sendable { 62 + public let blob: BlobRef 63 + 64 + public struct BlobRef: Codable, Sendable { 65 + public let type: String 66 + public let ref: BlobLink 67 + public let mimeType: String 68 + public let size: Int 69 + 70 + enum CodingKeys: String, CodingKey { 71 + case type = "$type" 72 + case ref 73 + case mimeType 74 + case size 75 + } 76 + 77 + public struct BlobLink: Codable, Sendable { 78 + public let link: String 79 + 80 + enum CodingKeys: String, CodingKey { 81 + case link = "$link" 82 + } 83 + } 84 + } 7 85 }
+48 -164
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 1 1 import Foundation 2 - import JWTKit 3 - import OAuthenticator 4 - #if canImport(CryptoKit) 5 - import CryptoKit 6 - #else 7 - import Crypto 8 - #endif 9 2 10 - @APActor 11 - public protocol NetworkRouterDelegate: AnyObject { 3 + /// Protocol for intercepting and handling network requests. 4 + /// Implementations can be isolated to any actor since methods are async. 5 + public protocol NetworkRouterDelegate: AnyObject, Sendable { 12 6 func intercept(_ request: inout URLRequest) async 13 7 func shouldRetry(error: Error, attempts: Int) async throws -> Bool 14 8 } ··· 43 37 let networking: Networking 44 38 let urlSessionTaskDelegate: URLSessionTaskDelegate? 45 39 var decoder: JSONDecoder 46 - private let dpopActor = DPoPRequestActor() 47 40 48 41 public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) { 49 42 if let networking = networking { ··· 69 62 guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed } 70 63 await delegate?.intercept(&request) 71 64 72 - let (data, response) = try await executeRequest(request) 65 + let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 73 66 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 74 67 switch httpResponse.statusCode { 75 68 case 200...299: ··· 93 86 return try await execute(route, attempts: attempts + 1) 94 87 } 95 88 } 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 89 203 90 func buildRequest(from route: Endpoint) async throws -> URLRequest { 204 - 91 + 205 92 var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path), 206 93 cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 207 - timeoutInterval: 10.0) 208 - 94 + timeoutInterval: 30.0) 95 + 209 96 request.httpMethod = route.httpMethod.rawValue 210 97 do { 211 98 switch await route.task { 212 99 case .request: 213 100 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 214 101 await addAdditionalHeaders(route.headers, request: &request) 102 + 215 103 case .requestParameters(let parameterEncoding): 216 104 await addAdditionalHeaders(route.headers, request: &request) 217 105 try configureParameters(parameterEncoding: parameterEncoding, request: &request) 106 + 107 + case .uploadBlob(let data, let mimeType): 108 + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") 109 + request.setValue(String(data.count), forHTTPHeaderField: "Content-Length") 110 + request.httpBody = data 111 + await addAdditionalHeaders(route.headers, request: &request) 112 + 113 + case .uploadMultipart(let parts): 114 + let boundary = "Boundary-\(UUID().uuidString)" 115 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 116 + request.httpBody = buildMultipartBody(parts: parts, boundary: boundary) 117 + await addAdditionalHeaders(route.headers, request: &request) 218 118 } 219 119 return request 220 120 } catch { 221 121 throw error 222 122 } 223 123 } 124 + 125 + /// Builds a multipart form data body from parts. 126 + private func buildMultipartBody(parts: [MultipartFormData], boundary: String) -> Data { 127 + var body = Data() 128 + let lineBreak = "\r\n" 129 + 130 + for part in parts { 131 + body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) 132 + 133 + if let filename = part.filename { 134 + body.append("Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8)!) 135 + } else { 136 + body.append("Content-Disposition: form-data; name=\"\(part.name)\"\(lineBreak)".data(using: .utf8)!) 137 + } 138 + 139 + if let mimeType = part.mimeType { 140 + body.append("Content-Type: \(mimeType)\(lineBreak)".data(using: .utf8)!) 141 + } 142 + 143 + body.append(lineBreak.data(using: .utf8)!) 144 + body.append(part.data) 145 + body.append(lineBreak.data(using: .utf8)!) 146 + } 147 + 148 + body.append("--\(boundary)--\(lineBreak)".data(using: .utf8)!) 149 + 150 + return body 151 + } 224 152 225 153 private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws { 226 154 try parameterEncoding.encode(urlRequest: &request) ··· 233 161 } 234 162 } 235 163 } 236 - 237 - private struct DPoPRequestPayload: JWTPayload { 238 - let htm: String 239 - let htu: String 240 - let iat: IssuedAtClaim 241 - let jti: IDClaim 242 - let nonce: String? 243 - let ath: String? 244 - 245 - func verify(using key: some JWTAlgorithm) throws { 246 - // No additional verification needed for DPoP 247 - } 248 - } 249 - 250 - private actor DPoPRequestActor { 251 - private let signer = DPoPSigner() 252 - 253 - func response( 254 - request: URLRequest, 255 - jwtGenerator: DPoPSigner.JWTGenerator, 256 - token: String, 257 - tokenHash: String, 258 - provider: URLResponseProvider 259 - ) async throws -> (Data, URLResponse) { 260 - try await signer.response( 261 - isolation: self, 262 - for: request, 263 - using: jwtGenerator, 264 - token: token, 265 - tokenHash: tokenHash, 266 - issuingServer: nil, 267 - provider: provider 268 - ) 269 - } 270 - } 271 - 272 - private extension Data { 273 - func base64URLEncodedString() -> String { 274 - base64EncodedString() 275 - .replacingOccurrences(of: "+", with: "-") 276 - .replacingOccurrences(of: "/", with: "_") 277 - .replacingOccurrences(of: "=", with: "") 278 - } 279 - }
+1 -1
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
··· 1 1 @preconcurrency import Foundation 2 2 3 3 @APActor 4 - public protocol Networking: Sendable { 4 + public protocol Networking { 5 5 func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) 6 6 } 7 7
+203 -40
Sources/CoreATProtocol/Networking.swift
··· 6 6 // 7 7 8 8 import Foundation 9 + import CryptoKit 10 + @preconcurrency import OAuthenticator 9 11 10 12 extension JSONDecoder { 13 + /// A JSON decoder configured for AT Protocol date formats. 14 + /// Supports ISO 8601 dates with fractional seconds and timezone. 11 15 public static var atDecoder: JSONDecoder { 12 - let dateFormatter = DateFormatter() 13 - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX" 14 - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 15 - dateFormatter.locale = Locale(identifier: "en_US") 16 - 17 16 let decoder = JSONDecoder() 18 17 decoder.keyDecodingStrategy = .convertFromSnakeCase 19 - decoder.dateDecodingStrategy = .formatted(dateFormatter) 20 - 18 + decoder.dateDecodingStrategy = .custom { decoder in 19 + let container = try decoder.singleValueContainer() 20 + let dateString = try container.decode(String.self) 21 + 22 + // Try multiple date formats that AT Protocol APIs may return 23 + let formatters = Self.atDateFormatters 24 + 25 + for formatter in formatters { 26 + if let date = formatter.date(from: dateString) { 27 + return date 28 + } 29 + } 30 + 31 + // Try ISO8601 with fractional seconds 32 + let iso8601 = ISO8601DateFormatter() 33 + iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 34 + if let date = iso8601.date(from: dateString) { 35 + return date 36 + } 37 + 38 + // Try without fractional seconds 39 + iso8601.formatOptions = [.withInternetDateTime] 40 + if let date = iso8601.date(from: dateString) { 41 + return date 42 + } 43 + 44 + throw DecodingError.dataCorruptedError( 45 + in: container, 46 + debugDescription: "Cannot decode date string: \(dateString)" 47 + ) 48 + } 49 + 21 50 return decoder 22 51 } 52 + 53 + /// Date formatters for various AT Protocol date formats. 54 + private static var atDateFormatters: [DateFormatter] { 55 + let formats = [ 56 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // With microseconds and timezone 57 + "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // With milliseconds and timezone 58 + "yyyy-MM-dd'T'HH:mm:ss.SSSX", // With milliseconds and short timezone 59 + "yyyy-MM-dd'T'HH:mm:ssXXXXX", // Without fractional seconds 60 + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", // With Z timezone 61 + "yyyy-MM-dd'T'HH:mm:ss'Z'" // Without fractional, with Z 62 + ] 63 + 64 + return formats.map { format in 65 + let formatter = DateFormatter() 66 + formatter.dateFormat = format 67 + formatter.timeZone = TimeZone(secondsFromGMT: 0) 68 + formatter.locale = Locale(identifier: "en_US_POSIX") 69 + return formatter 70 + } 71 + } 23 72 } 24 73 74 + /// Checks if enough time has passed since last fetch to allow a new request. 75 + /// - Parameters: 76 + /// - lastFetched: Unix timestamp of last fetch (0 means never fetched) 77 + /// - timeLimit: Minimum seconds between fetches (default 1 hour) 78 + /// - Returns: true if a new request should be performed 25 79 func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { 26 80 guard lastFetched != 0 else { return true } 27 81 let currentTime = Date.now 28 82 let lastFetchTime = Date(timeIntervalSince1970: lastFetched) 29 - guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false } 30 - return differenceInMinutes >= timeLimit 83 + guard let differenceInSeconds = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false } 84 + return differenceInSeconds >= timeLimit 31 85 } 32 86 33 87 @APActor 34 88 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 { 89 + /// Maximum retry attempts for token refresh. 90 + private let maxRefreshAttempts = 2 91 + 92 + public init() {} 93 + 94 + nonisolated public func intercept(_ request: inout URLRequest) async { 95 + // Try DPoP-authenticated request first (preferred for AT Protocol) 96 + if let generator = await APEnvironment.current.dpopProofGenerator, 97 + let login = await APEnvironment.current.login { 98 + let token = login.accessToken.value 99 + let tokenHash = await tokenHash(for: token) 100 + let signer = await APEnvironment.current.resourceDPoPSigner 101 + await MainActor.run { 102 + signer.nonce = nil 103 + } 104 + let nonce = await APEnvironment.current.resourceServerNonce 105 + await MainActor.run { 106 + signer.nonce = nonce 107 + } 108 + 109 + do { 110 + try await signer.authenticateRequest( 111 + &request, 112 + isolation: MainActor.shared, 113 + using: generator, 114 + token: token, 115 + tokenHash: tokenHash, 116 + issuer: login.issuingServer 117 + ) 118 + } catch { 119 + // If DPoP signing fails, fall back to providing the token directly. 120 + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") 121 + } 122 + 40 123 return 41 124 } 42 125 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 { 126 + // Fall back to simple Bearer token authentication 127 + if let accessToken = await APEnvironment.current.accessToken { 47 128 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 48 129 } 49 130 } 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 - } 131 + 132 + nonisolated public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 133 + // Don't retry more than maxRefreshAttempts times 134 + guard attempts <= maxRefreshAttempts else { return false } 135 + 136 + // Check if the error indicates we need to refresh the token 137 + let shouldAttemptRefresh = isTokenExpiredError(error) 56 138 57 - if let refreshTask { 58 - return try await refreshTask.value 59 - } 139 + guard shouldAttemptRefresh else { return false } 60 140 61 - let task = Task { try await handler() } 62 - refreshTask = task 141 + // Attempt token refresh 142 + let refreshed = await performTokenRefresh() 63 143 64 - defer { refreshTask = nil } 144 + return refreshed 145 + } 65 146 66 - return try await task.value 147 + /// Determines if an error indicates the token has expired and needs refresh. 148 + nonisolated private func isTokenExpiredError(_ error: Error) -> Bool { 149 + // Check for 401 Unauthorized status code 150 + if case .network(let networkError) = error as? AtError, 151 + case .statusCode(let statusCode, _) = networkError, 152 + statusCode?.rawValue == 401 { 153 + return true 67 154 } 68 155 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() 156 + // Check for explicit expired token error message 157 + if case .message(let message) = error as? AtError, 158 + message.error == AtErrorType.expiredToken.rawValue { 159 + return true 75 160 } 76 161 162 + // Check for authentication required error 77 163 if case .message(let message) = error as? AtError, 78 - message.error == AtErrorType.expiredToken.rawValue, 79 - attempts == 1 { 80 - return try await refreshViaOAuth() 164 + message.error == AtErrorType.authenticationRequired.rawValue { 165 + return true 81 166 } 82 167 83 168 return false 169 + } 170 + 171 + /// Performs token refresh using the configured OAuth settings. 172 + nonisolated private func performTokenRefresh() async -> Bool { 173 + let env = await APEnvironment.current 174 + 175 + // Try using the authState-based refresh first 176 + if await env.authState != nil { 177 + return await env.performTokenRefresh() 178 + } 179 + 180 + // Fall back to OAuthenticator's refresh if we have a login with refresh token 181 + guard let login = await env.login, 182 + let refreshToken = login.refreshToken, 183 + refreshToken.valid else { 184 + return false 185 + } 186 + 187 + guard let serverMetadata = await env.serverMetadata, 188 + let clientId = await env.clientId else { 189 + return false 190 + } 191 + 192 + // Use RefreshService for the actual refresh 193 + let refreshService = await RefreshService() 194 + 195 + // Create an AuthenticationState from the current login if we don't have one 196 + let state = AuthenticationState( 197 + did: login.issuingServer ?? "", 198 + handle: nil, 199 + pdsURL: await env.host ?? "", 200 + authServerURL: serverMetadata.issuer, 201 + accessToken: login.accessToken.value, 202 + accessTokenExpiry: login.accessToken.expiry, 203 + refreshToken: refreshToken.value, 204 + refreshTokenExpiry: refreshToken.expiry, 205 + scope: login.scopes, 206 + dpopPrivateKeyData: nil 207 + ) 208 + 209 + do { 210 + let newState = try await refreshService.refresh( 211 + state: state, 212 + serverMetadata: serverMetadata, 213 + clientId: clientId, 214 + dpopGenerator: await env.dpopProofGenerator 215 + ) 216 + 217 + // Update the environment 218 + await updateEnvironmentWithNewTokens(newState) 219 + 220 + return true 221 + } catch { 222 + print("Token refresh failed: \(error)") 223 + return false 224 + } 225 + } 226 + 227 + /// Updates the environment with refreshed tokens. 228 + private func updateEnvironmentWithNewTokens(_ state: AuthenticationState) async { 229 + APEnvironment.current.accessToken = state.accessToken 230 + APEnvironment.current.refreshToken = state.refreshToken 231 + APEnvironment.current.authState = state 232 + 233 + // Update login object if present 234 + if var login = APEnvironment.current.login { 235 + login.accessToken = Token(value: state.accessToken, expiry: state.accessTokenExpiry) 236 + if let newRefresh = state.refreshToken { 237 + login.refreshToken = Token(value: newRefresh, expiry: state.refreshTokenExpiry) 238 + } 239 + APEnvironment.current.login = login 240 + } 241 + } 242 + 243 + /// Computes SHA-256 hash of the access token for DPoP `ath` claim. 244 + nonisolated private func tokenHash(for token: String) -> String { 245 + let digest = SHA256.hash(data: Data(token.utf8)) 246 + return Data(digest).base64URLEncodedString() 84 247 } 85 248 }
+263
Sources/CoreATProtocol/OAuth/ATClientMetadata.swift
··· 1 + // 2 + // ATClientMetadata.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + 10 + /// AT Protocol OAuth client metadata document. 11 + /// This document must be published at the `client_id` URL for OAuth registration. 12 + /// 13 + /// See: https://atproto.com/specs/oauth 14 + public struct ATClientMetadata: Codable, Sendable, Hashable { 15 + 16 + /// The client identifier. Must be a fully-qualified HTTPS URL pointing to this metadata. 17 + public let clientId: String 18 + 19 + /// Application type: "web" or "native". 20 + public let applicationType: ApplicationType 21 + 22 + /// Supported grant types. Must include "authorization_code" and "refresh_token". 23 + public let grantTypes: [String] 24 + 25 + /// Requested scopes. Must include "atproto". 26 + public let scope: String 27 + 28 + /// Supported response types. Must include "code". 29 + public let responseTypes: [String] 30 + 31 + /// Redirect URIs for OAuth callbacks. 32 + public let redirectUris: [String] 33 + 34 + /// Whether access tokens are DPoP-bound. Must be true for AT Protocol. 35 + public let dpopBoundAccessTokens: Bool 36 + 37 + /// Token endpoint authentication method. 38 + /// "none" for public clients, "private_key_jwt" for confidential clients. 39 + public let tokenEndpointAuthMethod: String 40 + 41 + /// Human-readable application name. 42 + public let clientName: String? 43 + 44 + /// URL to the application's logo. 45 + public let logoUri: String? 46 + 47 + /// URL to the application's homepage. 48 + public let clientUri: String? 49 + 50 + /// URL to the application's terms of service. 51 + public let tosUri: String? 52 + 53 + /// URL to the application's privacy policy. 54 + public let policyUri: String? 55 + 56 + /// JWK Set for confidential clients (inline). 57 + public let jwks: JWKSet? 58 + 59 + /// URL to JWK Set for confidential clients. 60 + public let jwksUri: String? 61 + 62 + enum CodingKeys: String, CodingKey { 63 + case clientId = "client_id" 64 + case applicationType = "application_type" 65 + case grantTypes = "grant_types" 66 + case scope 67 + case responseTypes = "response_types" 68 + case redirectUris = "redirect_uris" 69 + case dpopBoundAccessTokens = "dpop_bound_access_tokens" 70 + case tokenEndpointAuthMethod = "token_endpoint_auth_method" 71 + case clientName = "client_name" 72 + case logoUri = "logo_uri" 73 + case clientUri = "client_uri" 74 + case tosUri = "tos_uri" 75 + case policyUri = "policy_uri" 76 + case jwks 77 + case jwksUri = "jwks_uri" 78 + } 79 + 80 + /// Application type for OAuth clients. 81 + public enum ApplicationType: String, Codable, Sendable, Hashable { 82 + case web 83 + case native 84 + } 85 + 86 + /// Creates a new client metadata document for a public (native) client. 87 + /// - Parameters: 88 + /// - clientId: The client_id URL where this metadata will be published 89 + /// - redirectUri: The callback URI for OAuth redirects 90 + /// - clientName: Human-readable application name 91 + /// - scope: OAuth scopes (default includes "atproto" and "transition:generic") 92 + /// - logoUri: Optional logo URL 93 + /// - clientUri: Optional homepage URL 94 + /// - tosUri: Optional terms of service URL 95 + /// - policyUri: Optional privacy policy URL 96 + public init( 97 + clientId: String, 98 + redirectUri: String, 99 + clientName: String, 100 + scope: String = "atproto transition:generic", 101 + logoUri: String? = nil, 102 + clientUri: String? = nil, 103 + tosUri: String? = nil, 104 + policyUri: String? = nil 105 + ) { 106 + self.clientId = clientId 107 + self.applicationType = .native 108 + self.grantTypes = ["authorization_code", "refresh_token"] 109 + self.scope = scope 110 + self.responseTypes = ["code"] 111 + self.redirectUris = [redirectUri] 112 + self.dpopBoundAccessTokens = true 113 + self.tokenEndpointAuthMethod = "none" 114 + self.clientName = clientName 115 + self.logoUri = logoUri 116 + self.clientUri = clientUri 117 + self.tosUri = tosUri 118 + self.policyUri = policyUri 119 + self.jwks = nil 120 + self.jwksUri = nil 121 + } 122 + 123 + /// Creates a new client metadata document for a confidential (web) client. 124 + /// - Parameters: 125 + /// - clientId: The client_id URL where this metadata will be published 126 + /// - redirectUri: The callback URI for OAuth redirects 127 + /// - clientName: Human-readable application name 128 + /// - jwksUri: URL to the JWK Set containing the client's public keys 129 + /// - scope: OAuth scopes (default includes "atproto" and "transition:generic") 130 + /// - logoUri: Optional logo URL 131 + /// - clientUri: Optional homepage URL 132 + /// - tosUri: Optional terms of service URL 133 + /// - policyUri: Optional privacy policy URL 134 + public init( 135 + clientId: String, 136 + redirectUri: String, 137 + clientName: String, 138 + jwksUri: String, 139 + scope: String = "atproto transition:generic", 140 + logoUri: String? = nil, 141 + clientUri: String? = nil, 142 + tosUri: String? = nil, 143 + policyUri: String? = nil 144 + ) { 145 + self.clientId = clientId 146 + self.applicationType = .web 147 + self.grantTypes = ["authorization_code", "refresh_token"] 148 + self.scope = scope 149 + self.responseTypes = ["code"] 150 + self.redirectUris = [redirectUri] 151 + self.dpopBoundAccessTokens = true 152 + self.tokenEndpointAuthMethod = "private_key_jwt" 153 + self.clientName = clientName 154 + self.logoUri = logoUri 155 + self.clientUri = clientUri 156 + self.tosUri = tosUri 157 + self.policyUri = policyUri 158 + self.jwks = nil 159 + self.jwksUri = jwksUri 160 + } 161 + 162 + /// Encodes this metadata as JSON suitable for publishing. 163 + public func toJSON() throws -> Data { 164 + let encoder = JSONEncoder() 165 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 166 + return try encoder.encode(self) 167 + } 168 + 169 + /// Encodes this metadata as a JSON string suitable for publishing. 170 + public func toJSONString() throws -> String { 171 + let data = try toJSON() 172 + guard let string = String(data: data, encoding: .utf8) else { 173 + throw OAuthError.invalidConfiguration(reason: "Failed to encode metadata as UTF-8") 174 + } 175 + return string 176 + } 177 + 178 + /// Validates this metadata against AT Protocol OAuth requirements. 179 + public func validate() throws { 180 + // Validate client_id is HTTPS 181 + guard clientId.hasPrefix("https://") || clientId.hasPrefix("http://localhost") else { 182 + throw OAuthError.invalidConfiguration(reason: "client_id must be HTTPS URL (except localhost)") 183 + } 184 + 185 + // Validate required grant types 186 + guard grantTypes.contains("authorization_code") else { 187 + throw OAuthError.invalidConfiguration(reason: "grant_types must include 'authorization_code'") 188 + } 189 + guard grantTypes.contains("refresh_token") else { 190 + throw OAuthError.invalidConfiguration(reason: "grant_types must include 'refresh_token'") 191 + } 192 + 193 + // Validate scope includes atproto 194 + guard scope.contains("atproto") else { 195 + throw OAuthError.invalidConfiguration(reason: "scope must include 'atproto'") 196 + } 197 + 198 + // Validate response types 199 + guard responseTypes.contains("code") else { 200 + throw OAuthError.invalidConfiguration(reason: "response_types must include 'code'") 201 + } 202 + 203 + // Validate redirect URIs 204 + guard !redirectUris.isEmpty else { 205 + throw OAuthError.invalidConfiguration(reason: "At least one redirect_uri is required") 206 + } 207 + 208 + // Validate DPoP requirement 209 + guard dpopBoundAccessTokens else { 210 + throw OAuthError.invalidConfiguration(reason: "dpop_bound_access_tokens must be true") 211 + } 212 + 213 + // Validate confidential client has keys 214 + if tokenEndpointAuthMethod == "private_key_jwt" { 215 + guard jwks != nil || jwksUri != nil else { 216 + throw OAuthError.invalidConfiguration(reason: "Confidential clients must provide jwks or jwks_uri") 217 + } 218 + } 219 + } 220 + } 221 + 222 + /// JWK Set structure for confidential clients. 223 + public struct JWKSet: Codable, Sendable, Hashable { 224 + public let keys: [JWK] 225 + 226 + public init(keys: [JWK]) { 227 + self.keys = keys 228 + } 229 + } 230 + 231 + /// JSON Web Key structure. 232 + public struct JWK: Codable, Sendable, Hashable { 233 + public let kty: String 234 + public let crv: String? 235 + public let x: String? 236 + public let y: String? 237 + public let kid: String? 238 + public let use: String? 239 + public let alg: String? 240 + 241 + public init( 242 + kty: String, 243 + crv: String? = nil, 244 + x: String? = nil, 245 + y: String? = nil, 246 + kid: String? = nil, 247 + use: String? = nil, 248 + alg: String? = nil 249 + ) { 250 + self.kty = kty 251 + self.crv = crv 252 + self.x = x 253 + self.y = y 254 + self.kid = kid 255 + self.use = use 256 + self.alg = alg 257 + } 258 + 259 + /// Creates an ES256 public key JWK from coordinates. 260 + public static func es256PublicKey(x: String, y: String, kid: String? = nil) -> JWK { 261 + JWK(kty: "EC", crv: "P-256", x: x, y: y, kid: kid, use: "sig", alg: "ES256") 262 + } 263 + }
-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 - }
-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 - }
+139
Sources/CoreATProtocol/OAuth/OAuthError.swift
··· 1 + // 2 + // OAuthError.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Errors specific to OAuth operations in AT Protocol. 11 + public enum OAuthError: Error, Sendable, Hashable { 12 + // MARK: - Token Errors 13 + case accessTokenExpired 14 + case refreshTokenExpired 15 + case refreshTokenMissing 16 + case refreshFailed(reason: String) 17 + case tokenExchangeFailed(reason: String) 18 + 19 + // MARK: - Configuration Errors 20 + case missingServerMetadata 21 + case missingClientMetadata 22 + case missingCredentials 23 + case invalidConfiguration(reason: String) 24 + 25 + // MARK: - Authorization Errors 26 + case authorizationDenied 27 + case invalidState 28 + case invalidScope 29 + case parRequestFailed(reason: String) 30 + 31 + // MARK: - DPoP Errors 32 + case dpopRequired 33 + case dpopNonceMissing 34 + case dpopSigningFailed(reason: String) 35 + case dpopKeyMissing 36 + 37 + // MARK: - Identity Errors 38 + case subjectMismatch(expected: String, received: String) 39 + case issuerMismatch(expected: String, received: String) 40 + 41 + // MARK: - Storage Errors 42 + case storageFailed(reason: String) 43 + case loginNotFound 44 + } 45 + 46 + extension OAuthError: LocalizedError { 47 + public var errorDescription: String? { 48 + switch self { 49 + case .accessTokenExpired: 50 + return "Access token has expired" 51 + case .refreshTokenExpired: 52 + return "Refresh token has expired" 53 + case .refreshTokenMissing: 54 + return "No refresh token available" 55 + case .refreshFailed(let reason): 56 + return "Token refresh failed: \(reason)" 57 + case .tokenExchangeFailed(let reason): 58 + return "Token exchange failed: \(reason)" 59 + case .missingServerMetadata: 60 + return "Server metadata is not available" 61 + case .missingClientMetadata: 62 + return "Client metadata is not available" 63 + case .missingCredentials: 64 + return "App credentials are not configured" 65 + case .invalidConfiguration(let reason): 66 + return "Invalid OAuth configuration: \(reason)" 67 + case .authorizationDenied: 68 + return "Authorization was denied by the user" 69 + case .invalidState: 70 + return "State token mismatch - possible CSRF attack" 71 + case .invalidScope: 72 + return "Requested scope was not granted" 73 + case .parRequestFailed(let reason): 74 + return "Pushed Authorization Request failed: \(reason)" 75 + case .dpopRequired: 76 + return "DPoP is required but not configured" 77 + case .dpopNonceMissing: 78 + return "DPoP nonce was not provided by server" 79 + case .dpopSigningFailed(let reason): 80 + return "DPoP JWT signing failed: \(reason)" 81 + case .dpopKeyMissing: 82 + return "DPoP private key is not available" 83 + case .subjectMismatch(let expected, let received): 84 + return "Subject mismatch: expected \(expected), received \(received)" 85 + case .issuerMismatch(let expected, let received): 86 + return "Issuer mismatch: expected \(expected), received \(received)" 87 + case .storageFailed(let reason): 88 + return "Token storage failed: \(reason)" 89 + case .loginNotFound: 90 + return "No stored login found" 91 + } 92 + } 93 + } 94 + 95 + /// Response from a token refresh request. 96 + public struct TokenRefreshResponse: Codable, Sendable { 97 + public let accessToken: String 98 + public let refreshToken: String? 99 + public let tokenType: String 100 + public let expiresIn: Int 101 + public let scope: String? 102 + public let sub: String 103 + 104 + enum CodingKeys: String, CodingKey { 105 + case accessToken = "access_token" 106 + case refreshToken = "refresh_token" 107 + case tokenType = "token_type" 108 + case expiresIn = "expires_in" 109 + case scope 110 + case sub 111 + } 112 + 113 + public init( 114 + accessToken: String, 115 + refreshToken: String?, 116 + tokenType: String, 117 + expiresIn: Int, 118 + scope: String?, 119 + sub: String 120 + ) { 121 + self.accessToken = accessToken 122 + self.refreshToken = refreshToken 123 + self.tokenType = tokenType 124 + self.expiresIn = expiresIn 125 + self.scope = scope 126 + self.sub = sub 127 + } 128 + } 129 + 130 + /// Error response from OAuth endpoints. 131 + public struct OAuthErrorResponse: Codable, Sendable { 132 + public let error: String 133 + public let errorDescription: String? 134 + 135 + enum CodingKeys: String, CodingKey { 136 + case error 137 + case errorDescription = "error_description" 138 + } 139 + }
+204
Sources/CoreATProtocol/OAuth/RefreshService.swift
··· 1 + // 2 + // RefreshService.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + import CryptoKit 10 + @preconcurrency import OAuthenticator 11 + 12 + /// Handles token refresh operations for AT Protocol OAuth. 13 + @APActor 14 + public final class RefreshService { 15 + 16 + /// Request body for token refresh. 17 + struct RefreshTokenRequest: Codable, Sendable { 18 + let refreshToken: String 19 + let grantType: String 20 + let clientId: String 21 + 22 + enum CodingKeys: String, CodingKey { 23 + case refreshToken = "refresh_token" 24 + case grantType = "grant_type" 25 + case clientId = "client_id" 26 + } 27 + 28 + init(refreshToken: String, clientId: String) { 29 + self.refreshToken = refreshToken 30 + self.grantType = "refresh_token" 31 + self.clientId = clientId 32 + } 33 + } 34 + 35 + private let urlSession: URLSession 36 + 37 + public init(urlSession: URLSession = .shared) { 38 + self.urlSession = urlSession 39 + } 40 + 41 + /// Refreshes tokens using the stored authentication state. 42 + /// - Parameters: 43 + /// - state: Current authentication state with refresh token 44 + /// - serverMetadata: OAuth server metadata with token endpoint 45 + /// - clientId: The client ID for the application 46 + /// - dpopGenerator: DPoP JWT generator for signing requests 47 + /// - Returns: Updated authentication state with new tokens 48 + public func refresh( 49 + state: AuthenticationState, 50 + serverMetadata: ServerMetadata, 51 + clientId: String, 52 + dpopGenerator: DPoPSigner.JWTGenerator? 53 + ) async throws -> AuthenticationState { 54 + guard let refreshToken = state.refreshToken else { 55 + throw OAuthError.refreshTokenMissing 56 + } 57 + 58 + guard !state.isRefreshTokenExpired else { 59 + throw OAuthError.refreshTokenExpired 60 + } 61 + 62 + guard let tokenURL = URL(string: serverMetadata.tokenEndpoint) else { 63 + throw OAuthError.invalidConfiguration(reason: "Invalid token endpoint URL") 64 + } 65 + 66 + let requestBody = RefreshTokenRequest(refreshToken: refreshToken, clientId: clientId) 67 + 68 + var request = URLRequest(url: tokenURL) 69 + request.httpMethod = "POST" 70 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 71 + request.setValue("application/json", forHTTPHeaderField: "Accept") 72 + request.httpBody = try JSONEncoder().encode(requestBody) 73 + 74 + // Add DPoP header if generator is available 75 + if let generator = dpopGenerator { 76 + let dpopSigner = DPoPSigner() 77 + dpopSigner.nonce = await APEnvironment.current.resourceServerNonce 78 + 79 + try await dpopSigner.authenticateRequest( 80 + &request, 81 + isolation: APActor.shared, 82 + using: generator, 83 + token: nil, 84 + tokenHash: nil, 85 + issuer: serverMetadata.issuer 86 + ) 87 + } 88 + 89 + let (data, response) = try await urlSession.data(for: request) 90 + 91 + guard let httpResponse = response as? HTTPURLResponse else { 92 + throw OAuthError.refreshFailed(reason: "Invalid response type") 93 + } 94 + 95 + // Update DPoP nonce from response 96 + if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 97 + await APEnvironment.current.setResourceServerNonce(newNonce) 98 + } 99 + 100 + guard (200...299).contains(httpResponse.statusCode) else { 101 + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { 102 + throw OAuthError.refreshFailed(reason: errorResponse.errorDescription ?? errorResponse.error) 103 + } 104 + throw OAuthError.refreshFailed(reason: "HTTP \(httpResponse.statusCode)") 105 + } 106 + 107 + let tokenResponse = try JSONDecoder().decode(TokenRefreshResponse.self, from: data) 108 + 109 + // Verify token type is DPoP 110 + guard tokenResponse.tokenType.lowercased() == "dpop" else { 111 + throw OAuthError.dpopRequired 112 + } 113 + 114 + // Verify subject matches 115 + guard tokenResponse.sub == state.did else { 116 + throw OAuthError.subjectMismatch(expected: state.did, received: tokenResponse.sub) 117 + } 118 + 119 + return state.withUpdatedTokens( 120 + access: tokenResponse.accessToken, 121 + refresh: tokenResponse.refreshToken, 122 + expiresIn: tokenResponse.expiresIn 123 + ) 124 + } 125 + } 126 + 127 + // MARK: - APEnvironment Extension for Refresh 128 + 129 + extension APEnvironment { 130 + /// Performs token refresh and updates the environment. 131 + /// - Returns: true if refresh succeeded, false otherwise 132 + public func performTokenRefresh() async -> Bool { 133 + guard let state = authState else { 134 + return false 135 + } 136 + 137 + guard state.canRefresh else { 138 + return false 139 + } 140 + 141 + guard let serverMetadata = serverMetadata else { 142 + return false 143 + } 144 + 145 + guard let clientId = clientId else { 146 + return false 147 + } 148 + 149 + let refreshService = RefreshService() 150 + 151 + do { 152 + let newState = try await refreshService.refresh( 153 + state: state, 154 + serverMetadata: serverMetadata, 155 + clientId: clientId, 156 + dpopGenerator: dpopProofGenerator 157 + ) 158 + 159 + // Update environment with new tokens 160 + self.authState = newState 161 + self.accessToken = newState.accessToken 162 + self.refreshToken = newState.refreshToken 163 + 164 + // Update the Login object if present 165 + if var currentLogin = login { 166 + currentLogin.accessToken = Token( 167 + value: newState.accessToken, 168 + expiry: newState.accessTokenExpiry 169 + ) 170 + if let newRefresh = newState.refreshToken { 171 + currentLogin.refreshToken = Token(value: newRefresh) 172 + } 173 + self.login = currentLogin 174 + } 175 + 176 + // Notify delegate of token update 177 + await atProtocoldelegate?.tokensUpdated( 178 + accessToken: newState.accessToken, 179 + refreshToken: newState.refreshToken 180 + ) 181 + 182 + // Persist if storage is configured 183 + if let storage = tokenStorage { 184 + try? await storage.updateTokens( 185 + access: newState.accessToken, 186 + refresh: newState.refreshToken, 187 + expiresIn: Int(newState.accessTokenExpiry?.timeIntervalSinceNow ?? 3600) 188 + ) 189 + } 190 + 191 + return true 192 + } catch { 193 + // Log the error but don't throw - let caller handle retry logic 194 + print("Token refresh failed: \(error)") 195 + return false 196 + } 197 + } 198 + 199 + /// Sets the resource server DPoP nonce. 200 + public func setResourceServerNonce(_ nonce: String?) { 201 + resourceServerNonce = nonce 202 + resourceDPoPSigner.nonce = nonce 203 + } 204 + }
+239
Sources/CoreATProtocol/OAuth/TokenStorage.swift
··· 1 + // 2 + // TokenStorage.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Foundation 9 + @preconcurrency import OAuthenticator 10 + 11 + /// Protocol for persisting authentication tokens. 12 + /// Implementations should use secure storage such as Keychain on Apple platforms. 13 + public protocol TokenStorageProtocol: Sendable { 14 + /// Stores the complete authentication state. 15 + func store(_ authState: AuthenticationState) async throws 16 + 17 + /// Retrieves the stored authentication state. 18 + func retrieve() async throws -> AuthenticationState? 19 + 20 + /// Clears all stored authentication data. 21 + func clear() async throws 22 + 23 + /// Updates only the tokens without changing other state. 24 + func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws 25 + } 26 + 27 + /// Complete authentication state to be persisted. 28 + public struct AuthenticationState: Codable, Sendable { 29 + public let did: String 30 + public let handle: String? 31 + public let pdsURL: String 32 + public let authServerURL: String 33 + public let accessToken: String 34 + public let accessTokenExpiry: Date? 35 + public let refreshToken: String? 36 + public let refreshTokenExpiry: Date? 37 + public let scope: String? 38 + public let dpopPrivateKeyData: Data? 39 + public let createdAt: Date 40 + public let updatedAt: Date 41 + 42 + public init( 43 + did: String, 44 + handle: String?, 45 + pdsURL: String, 46 + authServerURL: String, 47 + accessToken: String, 48 + accessTokenExpiry: Date?, 49 + refreshToken: String?, 50 + refreshTokenExpiry: Date? = nil, 51 + scope: String?, 52 + dpopPrivateKeyData: Data?, 53 + createdAt: Date = Date(), 54 + updatedAt: Date = Date() 55 + ) { 56 + self.did = did 57 + self.handle = handle 58 + self.pdsURL = pdsURL 59 + self.authServerURL = authServerURL 60 + self.accessToken = accessToken 61 + self.accessTokenExpiry = accessTokenExpiry 62 + self.refreshToken = refreshToken 63 + self.refreshTokenExpiry = refreshTokenExpiry 64 + self.scope = scope 65 + self.dpopPrivateKeyData = dpopPrivateKeyData 66 + self.createdAt = createdAt 67 + self.updatedAt = updatedAt 68 + } 69 + 70 + /// Creates an updated state with new tokens. 71 + public func withUpdatedTokens( 72 + access: String, 73 + refresh: String?, 74 + expiresIn: Int 75 + ) -> AuthenticationState { 76 + AuthenticationState( 77 + did: did, 78 + handle: handle, 79 + pdsURL: pdsURL, 80 + authServerURL: authServerURL, 81 + accessToken: access, 82 + accessTokenExpiry: Date().addingTimeInterval(TimeInterval(expiresIn)), 83 + refreshToken: refresh ?? refreshToken, 84 + refreshTokenExpiry: refreshTokenExpiry, 85 + scope: scope, 86 + dpopPrivateKeyData: dpopPrivateKeyData, 87 + createdAt: createdAt, 88 + updatedAt: Date() 89 + ) 90 + } 91 + 92 + /// Checks if the access token is expired or about to expire. 93 + public var isAccessTokenExpired: Bool { 94 + guard let expiry = accessTokenExpiry else { return false } 95 + // Consider expired if less than 60 seconds remaining 96 + return expiry.timeIntervalSinceNow < 60 97 + } 98 + 99 + /// Checks if the refresh token is expired. 100 + public var isRefreshTokenExpired: Bool { 101 + guard let expiry = refreshTokenExpiry else { return false } 102 + return expiry.timeIntervalSinceNow < 0 103 + } 104 + 105 + /// Checks if we can attempt a token refresh. 106 + public var canRefresh: Bool { 107 + refreshToken != nil && !isRefreshTokenExpired 108 + } 109 + } 110 + 111 + /// In-memory token storage for testing or temporary use. 112 + /// Not recommended for production - use Keychain-based storage instead. 113 + @APActor 114 + public final class InMemoryTokenStorage: TokenStorageProtocol { 115 + private var state: AuthenticationState? 116 + 117 + public init() {} 118 + 119 + public func store(_ authState: AuthenticationState) async throws { 120 + self.state = authState 121 + } 122 + 123 + public func retrieve() async throws -> AuthenticationState? { 124 + return state 125 + } 126 + 127 + public func clear() async throws { 128 + state = nil 129 + } 130 + 131 + public func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws { 132 + guard let current = state else { 133 + throw OAuthError.loginNotFound 134 + } 135 + state = current.withUpdatedTokens(access: access, refresh: refresh, expiresIn: expiresIn) 136 + } 137 + } 138 + 139 + #if canImport(Security) 140 + import Security 141 + 142 + /// Keychain-based token storage for secure persistence on Apple platforms. 143 + @APActor 144 + public final class KeychainTokenStorage: TokenStorageProtocol { 145 + private let service: String 146 + private let account: String 147 + private let accessGroup: String? 148 + 149 + /// Creates a new Keychain storage instance. 150 + /// - Parameters: 151 + /// - service: The service identifier (typically your app's bundle ID) 152 + /// - account: The account identifier (can be a constant or user-specific) 153 + /// - accessGroup: Optional access group for sharing between apps 154 + public init(service: String, account: String = "atproto_auth", accessGroup: String? = nil) { 155 + self.service = service 156 + self.account = account 157 + self.accessGroup = accessGroup 158 + } 159 + 160 + public func store(_ authState: AuthenticationState) async throws { 161 + let data = try JSONEncoder().encode(authState) 162 + 163 + var query: [String: Any] = [ 164 + kSecClass as String: kSecClassGenericPassword, 165 + kSecAttrService as String: service, 166 + kSecAttrAccount as String: account, 167 + kSecValueData as String: data, 168 + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly 169 + ] 170 + 171 + if let group = accessGroup { 172 + query[kSecAttrAccessGroup as String] = group 173 + } 174 + 175 + // Delete existing item first 176 + let deleteQuery: [String: Any] = [ 177 + kSecClass as String: kSecClassGenericPassword, 178 + kSecAttrService as String: service, 179 + kSecAttrAccount as String: account 180 + ] 181 + SecItemDelete(deleteQuery as CFDictionary) 182 + 183 + let status = SecItemAdd(query as CFDictionary, nil) 184 + 185 + guard status == errSecSuccess else { 186 + throw OAuthError.storageFailed(reason: "Keychain write failed with status: \(status)") 187 + } 188 + } 189 + 190 + public func retrieve() async throws -> AuthenticationState? { 191 + var query: [String: Any] = [ 192 + kSecClass as String: kSecClassGenericPassword, 193 + kSecAttrService as String: service, 194 + kSecAttrAccount as String: account, 195 + kSecReturnData as String: true, 196 + kSecMatchLimit as String: kSecMatchLimitOne 197 + ] 198 + 199 + if let group = accessGroup { 200 + query[kSecAttrAccessGroup as String] = group 201 + } 202 + 203 + var result: AnyObject? 204 + let status = SecItemCopyMatching(query as CFDictionary, &result) 205 + 206 + guard status == errSecSuccess, let data = result as? Data else { 207 + if status == errSecItemNotFound { 208 + return nil 209 + } 210 + throw OAuthError.storageFailed(reason: "Keychain read failed with status: \(status)") 211 + } 212 + 213 + return try JSONDecoder().decode(AuthenticationState.self, from: data) 214 + } 215 + 216 + public func clear() async throws { 217 + let query: [String: Any] = [ 218 + kSecClass as String: kSecClassGenericPassword, 219 + kSecAttrService as String: service, 220 + kSecAttrAccount as String: account 221 + ] 222 + 223 + let status = SecItemDelete(query as CFDictionary) 224 + 225 + guard status == errSecSuccess || status == errSecItemNotFound else { 226 + throw OAuthError.storageFailed(reason: "Keychain delete failed with status: \(status)") 227 + } 228 + } 229 + 230 + public func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws { 231 + guard let current = try await retrieve() else { 232 + throw OAuthError.loginNotFound 233 + } 234 + 235 + let updated = current.withUpdatedTokens(access: access, refresh: refresh, expiresIn: expiresIn) 236 + try await store(updated) 237 + } 238 + } 239 + #endif
+190
Tests/CoreATProtocolTests/ATErrorTests.swift
··· 1 + // 2 + // ATErrorTests.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Testing 9 + import Foundation 10 + @testable import CoreATProtocol 11 + 12 + @Suite("AT Error Tests") 13 + struct ATErrorTests { 14 + 15 + // MARK: - ErrorMessage Tests 16 + 17 + @Test("ErrorMessage parses from JSON") 18 + func testErrorMessageParsing() throws { 19 + let json = """ 20 + { 21 + "error": "ExpiredToken", 22 + "message": "The access token has expired" 23 + } 24 + """.data(using: .utf8)! 25 + 26 + let message = try JSONDecoder().decode(ErrorMessage.self, from: json) 27 + 28 + #expect(message.error == "ExpiredToken") 29 + #expect(message.message == "The access token has expired") 30 + #expect(message.errorType == .expiredToken) 31 + } 32 + 33 + @Test("ErrorMessage handles unknown error types") 34 + func testUnknownErrorType() throws { 35 + let json = """ 36 + { 37 + "error": "SomeNewError", 38 + "message": "An unknown error occurred" 39 + } 40 + """.data(using: .utf8)! 41 + 42 + let message = try JSONDecoder().decode(ErrorMessage.self, from: json) 43 + 44 + #expect(message.error == "SomeNewError") 45 + #expect(message.errorType == nil) 46 + } 47 + 48 + @Test("ErrorMessage handles missing message field") 49 + func testMissingMessage() throws { 50 + let json = """ 51 + { 52 + "error": "InvalidRequest" 53 + } 54 + """.data(using: .utf8)! 55 + 56 + let message = try JSONDecoder().decode(ErrorMessage.self, from: json) 57 + 58 + #expect(message.error == "InvalidRequest") 59 + #expect(message.message == nil) 60 + } 61 + 62 + // MARK: - AtErrorType Tests 63 + 64 + @Test("All error types have descriptions") 65 + func testErrorTypeDescriptions() { 66 + for errorType in AtErrorType.allCases { 67 + #expect(!errorType.description.isEmpty, "\(errorType) should have a description") 68 + } 69 + } 70 + 71 + @Test("Error types decode correctly") 72 + func testErrorTypeDecoding() throws { 73 + let testCases: [(String, AtErrorType)] = [ 74 + ("\"AuthenticationRequired\"", .authenticationRequired), 75 + ("\"ExpiredToken\"", .expiredToken), 76 + ("\"RateLimitExceeded\"", .rateLimitExceeded), 77 + ("\"RecordNotFound\"", .recordNotFound), 78 + ("\"BlobTooLarge\"", .blobTooLarge) 79 + ] 80 + 81 + for (json, expected) in testCases { 82 + let data = json.data(using: .utf8)! 83 + let decoded = try JSONDecoder().decode(AtErrorType.self, from: data) 84 + #expect(decoded == expected) 85 + } 86 + } 87 + 88 + // MARK: - AtError Tests 89 + 90 + @Test("AtError.requiresReauthentication identifies auth errors") 91 + func testRequiresReauthentication() { 92 + let expiredTokenError = AtError.message(ErrorMessage(error: "ExpiredToken", message: nil)) 93 + #expect(expiredTokenError.requiresReauthentication == true) 94 + 95 + let authRequiredError = AtError.message(ErrorMessage(error: "AuthenticationRequired", message: nil)) 96 + #expect(authRequiredError.requiresReauthentication == true) 97 + 98 + let notFoundError = AtError.message(ErrorMessage(error: "NotFound", message: nil)) 99 + #expect(notFoundError.requiresReauthentication == false) 100 + 101 + let unauthorized = AtError.network(NetworkError.statusCode(.unauthorized, data: Data())) 102 + #expect(unauthorized.requiresReauthentication == true) 103 + 104 + let serverError = AtError.network(NetworkError.statusCode(.internalServerError, data: Data())) 105 + #expect(serverError.requiresReauthentication == false) 106 + } 107 + 108 + @Test("AtError.isRetryable identifies retryable errors") 109 + func testIsRetryable() { 110 + let rateLimitError = AtError.message(ErrorMessage(error: "RateLimitExceeded", message: nil)) 111 + #expect(rateLimitError.isRetryable == true) 112 + 113 + let serverError = AtError.network(NetworkError.statusCode(.internalServerError, data: Data())) 114 + #expect(serverError.isRetryable == true) 115 + 116 + let badRequestError = AtError.network(NetworkError.statusCode(.badRequest, data: Data())) 117 + #expect(badRequestError.isRetryable == false) 118 + 119 + let notFoundError = AtError.message(ErrorMessage(error: "NotFound", message: nil)) 120 + #expect(notFoundError.isRetryable == false) 121 + } 122 + 123 + // MARK: - RateLimitInfo Tests 124 + 125 + @Test("RateLimitInfo parses from headers") 126 + func testRateLimitParsing() { 127 + // Create a mock response with rate limit headers 128 + let url = URL(string: "https://example.com")! 129 + let headers = [ 130 + "RateLimit-Limit": "100", 131 + "RateLimit-Remaining": "50", 132 + "RateLimit-Reset": "1704067200" 133 + ] 134 + 135 + let response = HTTPURLResponse( 136 + url: url, 137 + statusCode: 200, 138 + httpVersion: nil, 139 + headerFields: headers 140 + )! 141 + 142 + let rateLimitInfo = RateLimitInfo.from(response: response) 143 + 144 + #expect(rateLimitInfo != nil) 145 + #expect(rateLimitInfo?.limit == 100) 146 + #expect(rateLimitInfo?.remaining == 50) 147 + #expect(rateLimitInfo?.resetTimestamp == 1704067200) 148 + } 149 + 150 + @Test("RateLimitInfo returns nil for missing headers") 151 + func testRateLimitMissingHeaders() { 152 + let url = URL(string: "https://example.com")! 153 + let response = HTTPURLResponse( 154 + url: url, 155 + statusCode: 200, 156 + httpVersion: nil, 157 + headerFields: [:] 158 + )! 159 + 160 + let rateLimitInfo = RateLimitInfo.from(response: response) 161 + #expect(rateLimitInfo == nil) 162 + } 163 + 164 + @Test("RateLimitInfo calculates time until reset") 165 + func testTimeUntilReset() { 166 + let futureReset = Date().timeIntervalSince1970 + 300 // 5 minutes from now 167 + let info = RateLimitInfo(limit: 100, remaining: 0, resetTimestamp: futureReset) 168 + 169 + #expect(info.timeUntilReset > 0) 170 + #expect(info.timeUntilReset <= 300) 171 + } 172 + 173 + // MARK: - OAuthError Tests 174 + 175 + @Test("OAuthError has localized descriptions") 176 + func testOAuthErrorDescriptions() { 177 + let errors: [OAuthError] = [ 178 + .accessTokenExpired, 179 + .refreshTokenMissing, 180 + .dpopRequired, 181 + .storageFailed(reason: "Test reason"), 182 + .subjectMismatch(expected: "did:plc:a", received: "did:plc:b") 183 + ] 184 + 185 + for error in errors { 186 + #expect(error.errorDescription != nil, "\(error) should have a description") 187 + #expect(!error.errorDescription!.isEmpty) 188 + } 189 + } 190 + }
+178
Tests/CoreATProtocolTests/ClientMetadataTests.swift
··· 1 + // 2 + // ClientMetadataTests.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Testing 9 + import Foundation 10 + @testable import CoreATProtocol 11 + 12 + @Suite("Client Metadata Tests") 13 + struct ClientMetadataTests { 14 + 15 + @Test("Creates valid public client metadata") 16 + func testPublicClientMetadata() throws { 17 + let metadata = ATClientMetadata( 18 + clientId: "https://example.com/client-metadata.json", 19 + redirectUri: "com.example.app://oauth/callback", 20 + clientName: "My AT Proto App" 21 + ) 22 + 23 + #expect(metadata.clientId == "https://example.com/client-metadata.json") 24 + #expect(metadata.applicationType == .native) 25 + #expect(metadata.tokenEndpointAuthMethod == "none") 26 + #expect(metadata.dpopBoundAccessTokens == true) 27 + #expect(metadata.grantTypes.contains("authorization_code")) 28 + #expect(metadata.grantTypes.contains("refresh_token")) 29 + #expect(metadata.responseTypes.contains("code")) 30 + #expect(metadata.scope.contains("atproto")) 31 + } 32 + 33 + @Test("Creates valid confidential client metadata") 34 + func testConfidentialClientMetadata() throws { 35 + let metadata = ATClientMetadata( 36 + clientId: "https://webapp.example.com/client-metadata.json", 37 + redirectUri: "https://webapp.example.com/oauth/callback", 38 + clientName: "My Web App", 39 + jwksUri: "https://webapp.example.com/.well-known/jwks.json" 40 + ) 41 + 42 + #expect(metadata.applicationType == .web) 43 + #expect(metadata.tokenEndpointAuthMethod == "private_key_jwt") 44 + #expect(metadata.jwksUri == "https://webapp.example.com/.well-known/jwks.json") 45 + } 46 + 47 + @Test("Metadata validates HTTPS requirement") 48 + func testHTTPSValidation() { 49 + let invalidMetadata = ATClientMetadata( 50 + clientId: "http://insecure.example.com/metadata.json", 51 + redirectUri: "com.example.app://callback", 52 + clientName: "Insecure App" 53 + ) 54 + 55 + #expect(throws: OAuthError.self) { 56 + try invalidMetadata.validate() 57 + } 58 + } 59 + 60 + @Test("Metadata allows localhost for development") 61 + func testLocalhostAllowed() throws { 62 + let metadata = ATClientMetadata( 63 + clientId: "http://localhost/client-metadata.json", 64 + redirectUri: "http://127.0.0.1/callback", 65 + clientName: "Dev App" 66 + ) 67 + 68 + // Should not throw 69 + try metadata.validate() 70 + } 71 + 72 + @Test("Metadata validates atproto scope requirement") 73 + func testScopeValidation() { 74 + // Create metadata with custom scope missing atproto 75 + let json = """ 76 + { 77 + "client_id": "https://example.com/metadata.json", 78 + "application_type": "native", 79 + "grant_types": ["authorization_code", "refresh_token"], 80 + "scope": "openid profile", 81 + "response_types": ["code"], 82 + "redirect_uris": ["com.example://callback"], 83 + "dpop_bound_access_tokens": true, 84 + "token_endpoint_auth_method": "none" 85 + } 86 + """.data(using: .utf8)! 87 + 88 + do { 89 + let metadata = try JSONDecoder().decode(ATClientMetadata.self, from: json) 90 + 91 + #expect(throws: OAuthError.self) { 92 + try metadata.validate() 93 + } 94 + } catch { 95 + Issue.record("Failed to decode metadata: \(error)") 96 + } 97 + } 98 + 99 + @Test("Metadata validates DPoP requirement") 100 + func testDPoPValidation() { 101 + let json = """ 102 + { 103 + "client_id": "https://example.com/metadata.json", 104 + "application_type": "native", 105 + "grant_types": ["authorization_code", "refresh_token"], 106 + "scope": "atproto", 107 + "response_types": ["code"], 108 + "redirect_uris": ["com.example://callback"], 109 + "dpop_bound_access_tokens": false, 110 + "token_endpoint_auth_method": "none" 111 + } 112 + """.data(using: .utf8)! 113 + 114 + do { 115 + let metadata = try JSONDecoder().decode(ATClientMetadata.self, from: json) 116 + 117 + #expect(throws: OAuthError.self) { 118 + try metadata.validate() 119 + } 120 + } catch { 121 + Issue.record("Failed to decode metadata: \(error)") 122 + } 123 + } 124 + 125 + @Test("Metadata encodes to valid JSON") 126 + func testJSONEncoding() throws { 127 + let metadata = ATClientMetadata( 128 + clientId: "https://myapp.example.com/client-metadata.json", 129 + redirectUri: "com.myapp://oauth", 130 + clientName: "My App", 131 + scope: "atproto transition:generic", 132 + logoUri: "https://myapp.example.com/logo.png", 133 + clientUri: "https://myapp.example.com", 134 + tosUri: "https://myapp.example.com/tos", 135 + policyUri: "https://myapp.example.com/privacy" 136 + ) 137 + 138 + let jsonString = try metadata.toJSONString() 139 + 140 + // Verify it's valid JSON by parsing it 141 + let data = jsonString.data(using: .utf8)! 142 + let parsed = try JSONDecoder().decode(ATClientMetadata.self, from: data) 143 + 144 + #expect(parsed.clientId == metadata.clientId) 145 + #expect(parsed.clientName == metadata.clientName) 146 + #expect(parsed.logoUri == metadata.logoUri) 147 + } 148 + 149 + @Test("JWK creates ES256 public key correctly") 150 + func testJWKCreation() { 151 + let jwk = JWK.es256PublicKey( 152 + x: "base64url-x-coordinate", 153 + y: "base64url-y-coordinate", 154 + kid: "key-1" 155 + ) 156 + 157 + #expect(jwk.kty == "EC") 158 + #expect(jwk.crv == "P-256") 159 + #expect(jwk.alg == "ES256") 160 + #expect(jwk.use == "sig") 161 + #expect(jwk.kid == "key-1") 162 + } 163 + 164 + @Test("JWKSet encodes correctly") 165 + func testJWKSetEncoding() throws { 166 + let jwkSet = JWKSet(keys: [ 167 + JWK.es256PublicKey(x: "x1", y: "y1", kid: "key-1"), 168 + JWK.es256PublicKey(x: "x2", y: "y2", kid: "key-2") 169 + ]) 170 + 171 + let encoded = try JSONEncoder().encode(jwkSet) 172 + let decoded = try JSONDecoder().decode(JWKSet.self, from: encoded) 173 + 174 + #expect(decoded.keys.count == 2) 175 + #expect(decoded.keys[0].kid == "key-1") 176 + #expect(decoded.keys[1].kid == "key-2") 177 + } 178 + }
+87 -2
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
··· 1 1 import Testing 2 + import Foundation 2 3 @testable import CoreATProtocol 3 4 4 - @Test func example() async throws { 5 - // Write your test here and use APIs like `#expect(...)` to check expected conditions. 5 + @Suite("CoreATProtocol Environment Tests", .serialized) 6 + struct CoreATProtocolTests { 7 + 8 + @Test("Environment singleton is accessible") 9 + func testEnvironmentSingleton() async { 10 + // Clear state first 11 + await clearAuthenticationContext() 12 + 13 + // Just verify we can access the singleton 14 + let host = await APEnvironment.current.host 15 + #expect(host == nil) // Default state should have nil host 16 + } 17 + 18 + @Test("Setup configures environment correctly") 19 + func testSetup() async { 20 + // Clear previous state 21 + await clearAuthenticationContext() 22 + 23 + await setup( 24 + hostURL: "https://bsky.social", 25 + accessJWT: "test-access", 26 + refreshJWT: "test-refresh" 27 + ) 28 + 29 + let host = await APEnvironment.current.host 30 + let access = await APEnvironment.current.accessToken 31 + let refresh = await APEnvironment.current.refreshToken 32 + 33 + #expect(host == "https://bsky.social") 34 + #expect(access == "test-access") 35 + #expect(refresh == "test-refresh") 36 + 37 + // Clean up 38 + await clearAuthenticationContext() 39 + } 40 + 41 + @Test("Clear authentication context removes all tokens") 42 + func testClearContext() async { 43 + await setup( 44 + hostURL: "https://test.social", 45 + accessJWT: "access", 46 + refreshJWT: "refresh" 47 + ) 48 + 49 + await clearAuthenticationContext() 50 + 51 + let access = await APEnvironment.current.accessToken 52 + let refresh = await APEnvironment.current.refreshToken 53 + let login = await APEnvironment.current.login 54 + 55 + #expect(access == nil) 56 + #expect(refresh == nil) 57 + #expect(login == nil) 58 + } 59 + 60 + @Test("Update tokens modifies existing tokens") 61 + func testUpdateTokens() async { 62 + await setup(hostURL: nil, accessJWT: "old-access", refreshJWT: "old-refresh") 63 + await updateTokens(access: "new-access", refresh: "new-refresh") 64 + 65 + let access = await APEnvironment.current.accessToken 66 + let refresh = await APEnvironment.current.refreshToken 67 + 68 + #expect(access == "new-access") 69 + #expect(refresh == "new-refresh") 70 + 71 + await clearAuthenticationContext() 72 + } 73 + 74 + @Test("DPoP nonce update works correctly") 75 + func testDPoPNonceUpdate() async { 76 + await updateResourceDPoPNonce("test-nonce-123") 77 + 78 + let nonce = await APEnvironment.current.resourceServerNonce 79 + 80 + #expect(nonce == "test-nonce-123") 81 + 82 + await updateResourceDPoPNonce(nil) 83 + } 84 + 85 + @Test("hasValidSession returns false when no session") 86 + func testNoValidSession() async { 87 + await clearAuthenticationContext() 88 + let valid = await hasValidSession 89 + #expect(valid == false) 90 + } 6 91 }
+51
Tests/CoreATProtocolTests/DPoPJWTGeneratorTests.swift
··· 1 + // 2 + // DPoPJWTGeneratorTests.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Testing 9 + import Foundation 10 + import JWTKit 11 + @testable import CoreATProtocol 12 + 13 + @Suite("DPoP JWT Generator Tests", .serialized) 14 + struct DPoPJWTGeneratorTests { 15 + 16 + @Test("DPoP JWT Generator can be created with ES256 key") 17 + func testGeneratorCreation() async throws { 18 + let privateKey = ES256PrivateKey() 19 + let generator = try await DPoPJWTGenerator(privateKey: privateKey) 20 + 21 + // Verify we can get a JWT generator function 22 + _ = await generator.jwtGenerator() 23 + // If we get here without throwing, the test passes 24 + } 25 + 26 + @Test("DPoPKeyMaterialError cases exist") 27 + func testKeyMaterialErrors() { 28 + // Test error cases exist and are equatable 29 + let error1 = DPoPKeyMaterialError.publicKeyUnavailable 30 + let error2 = DPoPKeyMaterialError.invalidCoordinate 31 + 32 + #expect(error1 != error2) 33 + #expect(error1 == DPoPKeyMaterialError.publicKeyUnavailable) 34 + } 35 + 36 + @Test("Resource server nonce can be updated") 37 + func testResourceServerNonce() async { 38 + // Clear state first 39 + await updateResourceDPoPNonce(nil) 40 + 41 + // Set nonce using the public function 42 + await updateResourceDPoPNonce("test-nonce-value") 43 + let nonce = await APEnvironment.current.resourceServerNonce 44 + #expect(nonce == "test-nonce-value") 45 + 46 + // Clear it 47 + await updateResourceDPoPNonce(nil) 48 + let clearedNonce = await APEnvironment.current.resourceServerNonce 49 + #expect(clearedNonce == nil) 50 + } 51 + }
+119
Tests/CoreATProtocolTests/DateDecodingTests.swift
··· 1 + // 2 + // DateDecodingTests.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Testing 9 + import Foundation 10 + @testable import CoreATProtocol 11 + 12 + @Suite("Date Decoding Tests") 13 + struct DateDecodingTests { 14 + 15 + struct DateContainer: Decodable { 16 + let date: Date 17 + } 18 + 19 + @Test("Decodes ISO 8601 with milliseconds and Z timezone") 20 + func testMillisecondsWithZ() throws { 21 + let json = """ 22 + {"date": "2024-01-15T10:30:00.123Z"} 23 + """.data(using: .utf8)! 24 + 25 + let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 26 + 27 + let calendar = Calendar(identifier: .gregorian) 28 + let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date) 29 + 30 + #expect(components.year == 2024) 31 + #expect(components.month == 1) 32 + #expect(components.day == 15) 33 + #expect(components.hour == 10) 34 + #expect(components.minute == 30) 35 + } 36 + 37 + @Test("Decodes ISO 8601 with offset timezone") 38 + func testWithOffset() throws { 39 + let json = """ 40 + {"date": "2024-06-20T15:45:30.000+00:00"} 41 + """.data(using: .utf8)! 42 + 43 + let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 44 + 45 + let calendar = Calendar(identifier: .gregorian) 46 + let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date) 47 + 48 + #expect(components.year == 2024) 49 + #expect(components.month == 6) 50 + #expect(components.day == 20) 51 + #expect(components.hour == 15) 52 + #expect(components.minute == 45) 53 + } 54 + 55 + @Test("Decodes ISO 8601 without fractional seconds") 56 + func testWithoutFractional() throws { 57 + let json = """ 58 + {"date": "2024-03-10T08:00:00Z"} 59 + """.data(using: .utf8)! 60 + 61 + let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 62 + 63 + let calendar = Calendar(identifier: .gregorian) 64 + let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date) 65 + 66 + #expect(components.year == 2024) 67 + #expect(components.month == 3) 68 + #expect(components.day == 10) 69 + } 70 + 71 + @Test("Decodes microseconds precision") 72 + func testMicroseconds() throws { 73 + let json = """ 74 + {"date": "2024-12-25T12:00:00.123456+00:00"} 75 + """.data(using: .utf8)! 76 + 77 + let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 78 + 79 + // Just verify it parses without error 80 + #expect(container.date != Date.distantPast) 81 + } 82 + 83 + @Test("Multiple date formats in same response") 84 + func testMultipleFormats() throws { 85 + struct MultipleDates: Decodable { 86 + let createdAt: Date 87 + let indexedAt: Date 88 + let updatedAt: Date 89 + } 90 + 91 + let json = """ 92 + { 93 + "createdAt": "2024-01-01T00:00:00.000Z", 94 + "indexedAt": "2024-01-01T00:00:00Z", 95 + "updatedAt": "2024-01-01T00:00:00.000+00:00" 96 + } 97 + """.data(using: .utf8)! 98 + 99 + let dates = try JSONDecoder.atDecoder.decode(MultipleDates.self, from: json) 100 + 101 + // All should parse to the same time (within a small margin) 102 + let interval1 = abs(dates.createdAt.timeIntervalSince(dates.indexedAt)) 103 + let interval2 = abs(dates.createdAt.timeIntervalSince(dates.updatedAt)) 104 + 105 + #expect(interval1 < 1, "Dates should be within 1 second of each other") 106 + #expect(interval2 < 1, "Dates should be within 1 second of each other") 107 + } 108 + 109 + @Test("Throws on invalid date format") 110 + func testInvalidFormat() { 111 + let json = """ 112 + {"date": "not-a-date"} 113 + """.data(using: .utf8)! 114 + 115 + #expect(throws: DecodingError.self) { 116 + _ = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json) 117 + } 118 + } 119 + }
+166
Tests/CoreATProtocolTests/IdentityResolverTests.swift
··· 1 + // 2 + // IdentityResolverTests.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Testing 9 + import Foundation 10 + @testable import CoreATProtocol 11 + 12 + @Suite("Identity Resolver Tests") 13 + struct IdentityResolverTests { 14 + 15 + // MARK: - DID Document Tests 16 + 17 + @Test("DID Document parses correctly") 18 + func testDIDDocumentParsing() throws { 19 + let json = """ 20 + { 21 + "@context": ["https://www.w3.org/ns/did/v1"], 22 + "id": "did:plc:abc123", 23 + "alsoKnownAs": ["at://alice.bsky.social"], 24 + "verificationMethod": [{ 25 + "id": "#atproto", 26 + "type": "Multikey", 27 + "controller": "did:plc:abc123", 28 + "publicKeyMultibase": "zDnae..." 29 + }], 30 + "service": [{ 31 + "id": "#atproto_pds", 32 + "type": "AtprotoPersonalDataServer", 33 + "serviceEndpoint": "https://bsky.social" 34 + }] 35 + } 36 + """.data(using: .utf8)! 37 + 38 + let document = try JSONDecoder().decode(DIDDocument.self, from: json) 39 + 40 + #expect(document.id == "did:plc:abc123") 41 + #expect(document.handle == "alice.bsky.social") 42 + #expect(document.pdsEndpoint == "https://bsky.social") 43 + #expect(document.verificationMethod?.count == 1) 44 + #expect(document.service?.count == 1) 45 + } 46 + 47 + @Test("DID Document extracts handle from alsoKnownAs") 48 + func testHandleExtraction() throws { 49 + let document = DIDDocument( 50 + id: "did:plc:test", 51 + alsoKnownAs: ["at://user.example.com", "https://other.url"], 52 + verificationMethod: nil, 53 + service: nil 54 + ) 55 + 56 + #expect(document.handle == "user.example.com") 57 + } 58 + 59 + @Test("DID Document returns nil handle when missing") 60 + func testMissingHandle() throws { 61 + let document = DIDDocument( 62 + id: "did:plc:test", 63 + alsoKnownAs: nil, 64 + verificationMethod: nil, 65 + service: nil 66 + ) 67 + 68 + #expect(document.handle == nil) 69 + } 70 + 71 + @Test("PLC Directory response converts to DID Document") 72 + func testPLCResponseConversion() throws { 73 + let json = """ 74 + { 75 + "did": "did:plc:xyz789", 76 + "alsoKnownAs": ["at://bob.test.com"], 77 + "verificationMethods": { 78 + "#atproto": "did:key:zDnae..." 79 + }, 80 + "services": { 81 + "#atproto_pds": { 82 + "type": "AtprotoPersonalDataServer", 83 + "endpoint": "https://pds.example.com" 84 + } 85 + } 86 + } 87 + """.data(using: .utf8)! 88 + 89 + let plcResponse = try JSONDecoder().decode(PLCDirectoryResponse.self, from: json) 90 + let document = plcResponse.toDIDDocument() 91 + 92 + #expect(document.id == "did:plc:xyz789") 93 + #expect(document.alsoKnownAs?.contains("at://bob.test.com") == true) 94 + } 95 + 96 + // MARK: - Identity Error Tests 97 + 98 + @Test("Identity errors are properly typed") 99 + func testIdentityErrors() { 100 + let handleError = IdentityError.invalidHandle("bad handle") 101 + let pdsError = IdentityError.pdsNotFound 102 + 103 + // Test error descriptions 104 + #expect(String(describing: handleError).contains("invalidHandle")) 105 + #expect(String(describing: pdsError).contains("pdsNotFound")) 106 + } 107 + 108 + // MARK: - Handle Validation Tests 109 + 110 + @Test("Valid handles are accepted") 111 + func testValidHandles() async throws { 112 + // These should be valid handle formats 113 + let validHandles = [ 114 + "alice.bsky.social", 115 + "user.example.com", 116 + "test.subdomain.domain.tld" 117 + ] 118 + 119 + for handle in validHandles { 120 + // Just testing the format is accepted 121 + let normalized = handle.lowercased() 122 + #expect(normalized.contains("."), "\(handle) should contain a dot") 123 + } 124 + } 125 + 126 + // MARK: - Cache Tests 127 + 128 + @Test("Cache is cleared correctly") 129 + func testCacheClear() async { 130 + let resolver = await IdentityResolver() 131 + await resolver.clearCache() 132 + // Should not throw 133 + } 134 + 135 + @Test("Cache TTL is configurable") 136 + func testCacheTTL() async { 137 + let resolver = await IdentityResolver() 138 + let defaultTTL = await resolver.cacheTTL 139 + #expect(defaultTTL == 600, "Default cache TTL should be 600 seconds") 140 + 141 + await MainActor.run { 142 + // Note: Need to access through proper isolation 143 + } 144 + } 145 + 146 + // MARK: - Protected Resource Metadata Tests 147 + 148 + @Test("Protected resource metadata parses correctly") 149 + func testProtectedResourceMetadata() throws { 150 + let json = """ 151 + { 152 + "resource": "https://bsky.social", 153 + "authorization_servers": ["https://bsky.social"] 154 + } 155 + """.data(using: .utf8)! 156 + 157 + let metadata = try JSONDecoder().decode( 158 + IdentityResolver.ProtectedResourceMetadata.self, 159 + from: json 160 + ) 161 + 162 + #expect(metadata.resource == "https://bsky.social") 163 + #expect(metadata.authorizationServers.count == 1) 164 + #expect(metadata.authorizationServers.first == "https://bsky.social") 165 + } 166 + }
-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 - }
+236
Tests/CoreATProtocolTests/TokenStorageTests.swift
··· 1 + // 2 + // TokenStorageTests.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Claude on 2026-01-02. 6 + // 7 + 8 + import Testing 9 + import Foundation 10 + @testable import CoreATProtocol 11 + 12 + @Suite("Token Storage Tests") 13 + struct TokenStorageTests { 14 + 15 + // MARK: - AuthenticationState Tests 16 + 17 + @Test("AuthenticationState initializes correctly") 18 + func testAuthStateInit() { 19 + let state = AuthenticationState( 20 + did: "did:plc:test123", 21 + handle: "test.bsky.social", 22 + pdsURL: "https://bsky.social", 23 + authServerURL: "https://bsky.social", 24 + accessToken: "access-token-value", 25 + accessTokenExpiry: Date().addingTimeInterval(3600), 26 + refreshToken: "refresh-token-value", 27 + scope: "atproto transition:generic", 28 + dpopPrivateKeyData: nil 29 + ) 30 + 31 + #expect(state.did == "did:plc:test123") 32 + #expect(state.handle == "test.bsky.social") 33 + #expect(state.accessToken == "access-token-value") 34 + #expect(state.refreshToken == "refresh-token-value") 35 + } 36 + 37 + @Test("AuthenticationState detects expired access token") 38 + func testAccessTokenExpiry() { 39 + let expiredState = AuthenticationState( 40 + did: "did:plc:test", 41 + handle: nil, 42 + pdsURL: "https://pds.example.com", 43 + authServerURL: "https://auth.example.com", 44 + accessToken: "expired", 45 + accessTokenExpiry: Date().addingTimeInterval(-100), // Already expired 46 + refreshToken: nil, 47 + scope: nil, 48 + dpopPrivateKeyData: nil 49 + ) 50 + 51 + #expect(expiredState.isAccessTokenExpired == true) 52 + 53 + let validState = AuthenticationState( 54 + did: "did:plc:test", 55 + handle: nil, 56 + pdsURL: "https://pds.example.com", 57 + authServerURL: "https://auth.example.com", 58 + accessToken: "valid", 59 + accessTokenExpiry: Date().addingTimeInterval(3600), // Valid for 1 hour 60 + refreshToken: nil, 61 + scope: nil, 62 + dpopPrivateKeyData: nil 63 + ) 64 + 65 + #expect(validState.isAccessTokenExpired == false) 66 + } 67 + 68 + @Test("AuthenticationState.canRefresh checks refresh token") 69 + func testCanRefresh() { 70 + let withRefresh = AuthenticationState( 71 + did: "did:plc:test", 72 + handle: nil, 73 + pdsURL: "https://pds.example.com", 74 + authServerURL: "https://auth.example.com", 75 + accessToken: "access", 76 + accessTokenExpiry: nil, 77 + refreshToken: "refresh-token", 78 + refreshTokenExpiry: Date().addingTimeInterval(86400), // Valid for 1 day 79 + scope: nil, 80 + dpopPrivateKeyData: nil 81 + ) 82 + 83 + #expect(withRefresh.canRefresh == true) 84 + 85 + let withoutRefresh = AuthenticationState( 86 + did: "did:plc:test", 87 + handle: nil, 88 + pdsURL: "https://pds.example.com", 89 + authServerURL: "https://auth.example.com", 90 + accessToken: "access", 91 + accessTokenExpiry: nil, 92 + refreshToken: nil, 93 + scope: nil, 94 + dpopPrivateKeyData: nil 95 + ) 96 + 97 + #expect(withoutRefresh.canRefresh == false) 98 + } 99 + 100 + @Test("AuthenticationState updates tokens correctly") 101 + func testUpdateTokens() { 102 + let original = AuthenticationState( 103 + did: "did:plc:test", 104 + handle: "test.user", 105 + pdsURL: "https://pds.example.com", 106 + authServerURL: "https://auth.example.com", 107 + accessToken: "old-access", 108 + accessTokenExpiry: Date(), 109 + refreshToken: "old-refresh", 110 + scope: "atproto", 111 + dpopPrivateKeyData: nil 112 + ) 113 + 114 + let updated = original.withUpdatedTokens( 115 + access: "new-access", 116 + refresh: "new-refresh", 117 + expiresIn: 1800 118 + ) 119 + 120 + #expect(updated.accessToken == "new-access") 121 + #expect(updated.refreshToken == "new-refresh") 122 + #expect(updated.did == original.did) // DID should not change 123 + #expect(updated.handle == original.handle) // Handle should not change 124 + #expect(updated.updatedAt > original.updatedAt) 125 + } 126 + 127 + // MARK: - InMemoryTokenStorage Tests 128 + 129 + @Test("InMemoryTokenStorage stores and retrieves") 130 + func testInMemoryStorage() async throws { 131 + let storage = await InMemoryTokenStorage() 132 + 133 + let state = AuthenticationState( 134 + did: "did:plc:memory", 135 + handle: "memory.test", 136 + pdsURL: "https://pds.example.com", 137 + authServerURL: "https://auth.example.com", 138 + accessToken: "memory-token", 139 + accessTokenExpiry: Date().addingTimeInterval(3600), 140 + refreshToken: "memory-refresh", 141 + scope: "atproto", 142 + dpopPrivateKeyData: nil 143 + ) 144 + 145 + try await storage.store(state) 146 + let retrieved = try await storage.retrieve() 147 + 148 + #expect(retrieved != nil) 149 + #expect(retrieved?.did == "did:plc:memory") 150 + #expect(retrieved?.accessToken == "memory-token") 151 + } 152 + 153 + @Test("InMemoryTokenStorage clears correctly") 154 + func testInMemoryClear() async throws { 155 + let storage = await InMemoryTokenStorage() 156 + 157 + let state = AuthenticationState( 158 + did: "did:plc:clear", 159 + handle: nil, 160 + pdsURL: "https://pds.example.com", 161 + authServerURL: "https://auth.example.com", 162 + accessToken: "token", 163 + accessTokenExpiry: nil, 164 + refreshToken: nil, 165 + scope: nil, 166 + dpopPrivateKeyData: nil 167 + ) 168 + 169 + try await storage.store(state) 170 + try await storage.clear() 171 + let retrieved = try await storage.retrieve() 172 + 173 + #expect(retrieved == nil) 174 + } 175 + 176 + @Test("InMemoryTokenStorage updates tokens") 177 + func testInMemoryUpdate() async throws { 178 + let storage = await InMemoryTokenStorage() 179 + 180 + let state = AuthenticationState( 181 + did: "did:plc:update", 182 + handle: nil, 183 + pdsURL: "https://pds.example.com", 184 + authServerURL: "https://auth.example.com", 185 + accessToken: "original", 186 + accessTokenExpiry: Date(), 187 + refreshToken: "original-refresh", 188 + scope: nil, 189 + dpopPrivateKeyData: nil 190 + ) 191 + 192 + try await storage.store(state) 193 + try await storage.updateTokens(access: "updated", refresh: "updated-refresh", expiresIn: 3600) 194 + 195 + let retrieved = try await storage.retrieve() 196 + #expect(retrieved?.accessToken == "updated") 197 + #expect(retrieved?.refreshToken == "updated-refresh") 198 + } 199 + 200 + @Test("InMemoryTokenStorage throws when updating without stored state") 201 + func testInMemoryUpdateWithoutState() async { 202 + let storage = await InMemoryTokenStorage() 203 + 204 + await #expect(throws: OAuthError.self) { 205 + try await storage.updateTokens(access: "new", refresh: nil, expiresIn: 3600) 206 + } 207 + } 208 + 209 + // MARK: - AuthenticationState Codable Tests 210 + 211 + @Test("AuthenticationState encodes and decodes") 212 + func testAuthStateCodable() throws { 213 + let original = AuthenticationState( 214 + did: "did:plc:codable", 215 + handle: "codable.test", 216 + pdsURL: "https://pds.example.com", 217 + authServerURL: "https://auth.example.com", 218 + accessToken: "codable-access", 219 + accessTokenExpiry: Date().addingTimeInterval(3600), 220 + refreshToken: "codable-refresh", 221 + refreshTokenExpiry: Date().addingTimeInterval(86400), 222 + scope: "atproto transition:generic", 223 + dpopPrivateKeyData: Data([1, 2, 3, 4]) 224 + ) 225 + 226 + let encoded = try JSONEncoder().encode(original) 227 + let decoded = try JSONDecoder().decode(AuthenticationState.self, from: encoded) 228 + 229 + #expect(decoded.did == original.did) 230 + #expect(decoded.handle == original.handle) 231 + #expect(decoded.accessToken == original.accessToken) 232 + #expect(decoded.refreshToken == original.refreshToken) 233 + #expect(decoded.scope == original.scope) 234 + #expect(decoded.dpopPrivateKeyData == original.dpopPrivateKeyData) 235 + } 236 + }