this repo has no description

Initial commit

+8
.gitignore
··· 1 + .DS_Store 2 + /.build 3 + /Packages 4 + xcuserdata/ 5 + DerivedData/ 6 + .swiftpm/configuration/registries.json 7 + .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 + .netrc
+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
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 SparrowTek 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+60
Package.resolved
··· 1 + { 2 + "originHash" : "aef8443d2c26c1b290a85bc86c844a258c5dd2c9b4979e9f7b3da92cf56bb581", 3 + "pins" : [ 4 + { 5 + "identity" : "jwt-kit", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/vapor/jwt-kit.git", 8 + "state" : { 9 + "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", 10 + "version" : "5.3.0" 11 + } 12 + }, 13 + { 14 + "identity" : "oauthenticator", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/radmakr/OAuthenticator.git", 17 + "state" : { 18 + "branch" : "CoreAtProtocol", 19 + "revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70" 20 + } 21 + }, 22 + { 23 + "identity" : "swift-asn1", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/apple/swift-asn1.git", 26 + "state" : { 27 + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", 28 + "version" : "1.5.1" 29 + } 30 + }, 31 + { 32 + "identity" : "swift-certificates", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/apple/swift-certificates.git", 35 + "state" : { 36 + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", 37 + "version" : "1.17.0" 38 + } 39 + }, 40 + { 41 + "identity" : "swift-crypto", 42 + "kind" : "remoteSourceControl", 43 + "location" : "https://github.com/apple/swift-crypto.git", 44 + "state" : { 45 + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", 46 + "version" : "4.2.0" 47 + } 48 + }, 49 + { 50 + "identity" : "swift-log", 51 + "kind" : "remoteSourceControl", 52 + "location" : "https://github.com/apple/swift-log.git", 53 + "state" : { 54 + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", 55 + "version" : "1.8.0" 56 + } 57 + } 58 + ], 59 + "version" : 3 60 + }
+43
Package.swift
··· 1 + // swift-tools-version: 6.2 2 + 3 + import PackageDescription 4 + 5 + let package = Package( 6 + name: "CoreATProtocol", 7 + platforms: [ 8 + .iOS(.v17), 9 + .watchOS(.v11), 10 + .tvOS(.v17), 11 + .macOS(.v14), 12 + .macCatalyst(.v17), 13 + ], 14 + products: [ 15 + .library( 16 + name: "CoreATProtocol", 17 + targets: ["CoreATProtocol"] 18 + ), 19 + ], 20 + dependencies: [ 21 + // Using fork with fix for WebAuthenticationSession platform guards 22 + // PR pending at https://github.com/ChimeHQ/OAuthenticator 23 + // .package(url: "https://github.com/ChimeHQ/OAuthenticator.git", branch: "main"), 24 + .package(url: "https://github.com/radmakr/OAuthenticator.git", branch: "CoreAtProtocol"), 25 + .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), 26 + ], 27 + targets: [ 28 + .target( 29 + name: "CoreATProtocol", 30 + dependencies: [ 31 + "OAuthenticator", 32 + .product(name: "JWTKit", package: "jwt-kit"), 33 + ], 34 + swiftSettings: [ 35 + .enableExperimentalFeature("StrictConcurrency") 36 + ] 37 + ), 38 + .testTarget( 39 + name: "CoreATProtocolTests", 40 + dependencies: ["CoreATProtocol"] 41 + ), 42 + ] 43 + )
+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).
+10
Sources/CoreATProtocol/APActor.swift
··· 1 + // 2 + // APActor.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Thomas Rademaker on 10/8/25. 6 + // 7 + 8 + @globalActor public actor APActor { 9 + public static let shared = APActor() 10 + }
+26
Sources/CoreATProtocol/APEnvironment.swift
··· 1 + // 2 + // APEnvironment.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Thomas Rademaker on 10/10/25. 6 + // 7 + 8 + @APActor 9 + public class APEnvironment { 10 + public static var current: APEnvironment = APEnvironment() 11 + 12 + public var host: String? 13 + public var accessToken: String? 14 + public var refreshToken: String? 15 + public var atProtocoldelegate: CoreATProtocolDelegate? 16 + public let routerDelegate = APRouterDelegate() 17 + 18 + private init() {} 19 + 20 + // func setup(apiKey: String, apiSecret: String, userAgent: String) { 21 + // self.apiKey = apiKey 22 + // self.apiSecret = apiSecret 23 + // self.userAgent = userAgent 24 + // } 25 + } 26 +
+57
Sources/CoreATProtocol/CoreATProtocol.swift
··· 1 + // The Swift Programming Language 2 + // https://docs.swift.org/swift-book 3 + 4 + // MARK: - Session 5 + 6 + /// Represents an authenticated AT Protocol session 7 + public struct Session: Sendable, Codable, Hashable { 8 + public let did: String 9 + public let handle: String 10 + public let email: String? 11 + public let accessJwt: String? 12 + public let refreshJwt: String? 13 + 14 + public init(did: String, handle: String, email: String? = nil, accessJwt: String? = nil, refreshJwt: String? = nil) { 15 + self.did = did 16 + self.handle = handle 17 + self.email = email 18 + self.accessJwt = accessJwt 19 + self.refreshJwt = refreshJwt 20 + } 21 + } 22 + 23 + // MARK: - Delegate 24 + 25 + public protocol CoreATProtocolDelegate: AnyObject, Sendable { 26 + /// Called when the session is updated (e.g., tokens refreshed) 27 + func sessionUpdated(_ session: Session) async 28 + } 29 + 30 + // Default implementation for optional method 31 + public extension CoreATProtocolDelegate { 32 + func sessionUpdated(_ session: Session) async {} 33 + } 34 + 35 + @APActor 36 + public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { 37 + APEnvironment.current.host = hostURL 38 + APEnvironment.current.accessToken = accessJWT 39 + APEnvironment.current.refreshToken = refreshJWT 40 + APEnvironment.current.atProtocoldelegate = delegate 41 + } 42 + 43 + @APActor 44 + public func setDelegate(_ delegate: CoreATProtocolDelegate) { 45 + APEnvironment.current.atProtocoldelegate = delegate 46 + } 47 + 48 + @APActor 49 + public func updateTokens(access: String?, refresh: String?) { 50 + APEnvironment.current.accessToken = access 51 + APEnvironment.current.refreshToken = refresh 52 + } 53 + 54 + @APActor 55 + public func update(hostURL: String?) { 56 + APEnvironment.current.host = hostURL 57 + }
+32
Sources/CoreATProtocol/Models/ATError.swift
··· 1 + // 2 + // ATError.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Thomas Rademaker on 10/8/25. 6 + // 7 + 8 + public enum AtError: Error { 9 + case message(ErrorMessage) 10 + case network(NetworkError) 11 + } 12 + 13 + public struct ErrorMessage: Codable, Sendable { 14 + /// The error type as a string. Kept as String rather than AtErrorType 15 + /// to handle unknown error types that the server may return. 16 + public let error: String 17 + public let message: String? 18 + 19 + public init(error: String, message: String?) { 20 + self.error = error 21 + self.message = message 22 + } 23 + } 24 + 25 + public enum AtErrorType: String, Codable, Sendable { 26 + case authenticationRequired = "AuthenticationRequired" 27 + case expiredToken = "ExpiredToken" 28 + case invalidRequest = "InvalidRequest" 29 + case methodNotImplemented = "MethodNotImplemented" 30 + case rateLimitExceeded = "RateLimitExceeded" 31 + case authMissing = "AuthMissing" 32 + }
+71
Sources/CoreATProtocol/Networking.swift
··· 1 + // 2 + // Networking.swift 3 + // CoreATProtocol 4 + // 5 + // Created by Thomas Rademaker on 10/10/25. 6 + // 7 + 8 + import Foundation 9 + 10 + extension JSONDecoder { 11 + 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 + let decoder = JSONDecoder() 18 + decoder.keyDecodingStrategy = .convertFromSnakeCase 19 + decoder.dateDecodingStrategy = .formatted(dateFormatter) 20 + 21 + return decoder 22 + } 23 + } 24 + 25 + func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { 26 + guard lastFetched != 0 else { return true } 27 + let currentTime = Date.now 28 + 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 31 + } 32 + 33 + @APActor 34 + public class APRouterDelegate: NetworkRouterDelegate { 35 + private var shouldRefreshToken = false 36 + 37 + public func intercept(_ request: inout URLRequest) async { 38 + if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { 39 + shouldRefreshToken = false 40 + request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") 41 + } else if let accessToken = APEnvironment.current.accessToken { 42 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 43 + } 44 + } 45 + 46 + public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 47 + func getNewToken() async throws -> Bool { 48 + // shouldRefreshToken = true 49 + // let newSession = try await AtProtoLexicons().refresh(attempts: attempts + 1) 50 + // APEnvironment.current.accessToken = newSession.accessJwt 51 + // APEnvironment.current.refreshToken = newSession.refreshJwt 52 + // await delegate?.sessionUpdated(newSession) 53 + // 54 + // return true 55 + false 56 + } 57 + 58 + // TODO: verify this works! 59 + if case .network(let networkError) = error as? AtError, 60 + case .statusCode(let statusCode, _) = networkError, 61 + let statusCode = statusCode?.rawValue, (400..<500).contains(statusCode), 62 + attempts == 1 { 63 + return try await getNewToken() 64 + } else if case .message(let message) = error as? AtError, 65 + message.error == AtErrorType.expiredToken.rawValue { 66 + return try await getNewToken() 67 + } 68 + 69 + return false 70 + } 71 + }
+29
Sources/CoreATProtocol/Networking/Encoding/JSONParameterEncoder.swift
··· 1 + import Foundation 2 + 3 + struct JSONParameterEncoder: ParameterEncoder { 4 + func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws { 5 + do { 6 + let jsonAsData = try JSONSerialization.data(withJSONObject: parameters, options: .prettyPrinted) 7 + encode(urlRequest: &urlRequest, with: jsonAsData) 8 + } catch { 9 + throw NetworkError.encodingFailed 10 + } 11 + } 12 + 13 + func encode(urlRequest: inout URLRequest, with encodable: Encodable) throws { 14 + do { 15 + let data = try encodable.toJSONData() 16 + encode(urlRequest: &urlRequest, with: data) 17 + } catch { 18 + throw NetworkError.encodingFailed 19 + } 20 + } 21 + 22 + func encode(urlRequest: inout URLRequest, with data: Data) { 23 + urlRequest.httpBody = data 24 + 25 + if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { 26 + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 27 + } 28 + } 29 + }
+37
Sources/CoreATProtocol/Networking/Encoding/ParameterEncoding.swift
··· 1 + import Foundation 2 + 3 + public typealias Parameters = [String : Any] 4 + 5 + protocol ParameterEncoder { 6 + func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws 7 + } 8 + 9 + @APActor 10 + public enum ParameterEncoding: Sendable { 11 + 12 + case urlEncoding(parameters: Parameters) 13 + case jsonEncoding(parameters: Parameters) 14 + case jsonDataEncoding(data: Data?) 15 + case jsonEncodableEncoding(encodable: Encodable) 16 + case urlAndJsonEncoding(urlParameters: Parameters, bodyParameters: Parameters) 17 + 18 + func encode(urlRequest: inout URLRequest) throws { 19 + do { 20 + switch self { 21 + case .urlEncoding(let parameters): 22 + try URLParameterEncoder().encode(urlRequest: &urlRequest, with: parameters) 23 + case .jsonEncoding(let parameters): 24 + try JSONParameterEncoder().encode(urlRequest: &urlRequest, with: parameters) 25 + case .jsonDataEncoding(let data): 26 + try JSONParameterEncoder().encode(urlRequest: &urlRequest, with: data) 27 + case .jsonEncodableEncoding(let encodable): 28 + try JSONParameterEncoder().encode(urlRequest: &urlRequest, with: encodable) 29 + case .urlAndJsonEncoding(let urlParameters, let bodyParameters): 30 + try URLParameterEncoder().encode(urlRequest: &urlRequest, with: urlParameters) 31 + try JSONParameterEncoder().encode(urlRequest: &urlRequest, with: bodyParameters) 32 + } 33 + } catch { 34 + throw NetworkError.encodingFailed 35 + } 36 + } 37 + }
+130
Sources/CoreATProtocol/Networking/Encoding/URLParameterEncoder.swift
··· 1 + import Foundation 2 + 3 + struct URLParameterEncoder: ParameterEncoder { 4 + /// Configures how `Array` parameters are encoded. 5 + enum ArrayEncoding { 6 + /// An empty set of square brackets is appended to the key for every value. This is the default behavior. 7 + case brackets 8 + /// No brackets are appended. The key is encoded as is. 9 + case noBrackets 10 + /// Brackets containing the item index are appended. This matches the jQuery and Node.js behavior. 11 + case indexInBrackets 12 + 13 + func encode(key: String, atIndex index: Int) -> String { 14 + switch self { 15 + case .brackets: 16 + return "\(key)[]" 17 + case .noBrackets: 18 + return key 19 + case .indexInBrackets: 20 + return "\(key)[\(index)]" 21 + } 22 + } 23 + } 24 + 25 + /// Configures how `Bool` parameters are encoded. 26 + enum BoolEncoding { 27 + /// Encode `true` as `1` and `false` as `0`. This is the default behavior. 28 + case numeric 29 + /// Encode `true` and `false` as string literals. 30 + case literal 31 + 32 + func encode(value: Bool) -> String { 33 + switch self { 34 + case .numeric: 35 + return value ? "1" : "0" 36 + case .literal: 37 + return value ? "true" : "false" 38 + } 39 + } 40 + } 41 + 42 + /// The encoding to use for `Array` parameters. 43 + let arrayEncoding: ArrayEncoding 44 + 45 + /// The encoding to use for `Bool` parameters. 46 + let boolEncoding: BoolEncoding 47 + 48 + /// The character set tp use for escaping 49 + let characterSet: CharacterSet 50 + 51 + init(arrayEncoding: ArrayEncoding = .brackets, boolEncoding: BoolEncoding = .numeric, characterSet: CharacterSet = .apURLQueryAllowed) { 52 + self.arrayEncoding = arrayEncoding 53 + self.boolEncoding = boolEncoding 54 + self.characterSet = characterSet 55 + } 56 + 57 + func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws { 58 + 59 + guard let url = urlRequest.url else { throw NetworkError.missingURL } 60 + 61 + if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), !parameters.isEmpty { 62 + let percentEncodedQuery = (urlComponents.percentEncodedQuery.map { $0 + "&" } ?? "") + query(parameters) 63 + urlComponents.percentEncodedQuery = percentEncodedQuery 64 + urlRequest.url = urlComponents.url 65 + } 66 + 67 + if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { 68 + urlRequest.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") 69 + } 70 + } 71 + 72 + private func query(_ parameters: [String: Any]) -> String { 73 + var components: [(String, String)] = [] 74 + 75 + for key in parameters.keys.sorted(by: <) { 76 + let value = parameters[key]! 77 + components += queryComponents(fromKey: key, value: value) 78 + } 79 + return components.map { "\($0)=\($1)" }.joined(separator: "&") 80 + } 81 + 82 + /// Creates a percent-escaped, URL encoded query string components from the given key-value pair recursively. 83 + /// 84 + /// - Parameters: 85 + /// - key: Key of the query component. 86 + /// - value: Value of the query component. 87 + /// 88 + /// - Returns: The percent-escaped, URL encoded query string components. 89 + func queryComponents(fromKey key: String, value: Any) -> [(String, String)] { 90 + var components: [(String, String)] = [] 91 + switch value { 92 + case let dictionary as [String: Any]: 93 + for (nestedKey, value) in dictionary { 94 + components += queryComponents(fromKey: "\(key)[\(nestedKey)]", value: value) 95 + } 96 + case let array as [Any]: 97 + for (index, value) in array.enumerated() { 98 + components += queryComponents(fromKey: arrayEncoding.encode(key: key, atIndex: index), value: value) 99 + } 100 + case let number as NSNumber: 101 + if number.isBool { 102 + components.append((escape(key), escape(boolEncoding.encode(value: number.boolValue)))) 103 + } else { 104 + components.append((escape(key), escape("\(number)"))) 105 + } 106 + case let bool as Bool: 107 + components.append((escape(key), escape(boolEncoding.encode(value: bool)))) 108 + default: 109 + components.append((escape(key), escape("\(value)"))) 110 + } 111 + return components 112 + } 113 + 114 + /// Creates a percent-escaped string following RFC 3986 for a query string key or value. 115 + /// 116 + /// - Parameter string: `String` to be percent-escaped. 117 + /// 118 + /// - Returns: The percent-escaped `String`. 119 + func escape(_ string: String) -> String { 120 + string.addingPercentEncoding(withAllowedCharacters: characterSet) ?? string 121 + } 122 + } 123 + 124 + extension NSNumber { 125 + fileprivate var isBool: Bool { 126 + // Use Obj-C type encoding to check whether the underlying type is a `Bool`, as it's guaranteed as part of 127 + // swift-corelibs-foundation, per [this discussion on the Swift forums](https://forums.swift.org/t/alamofire-on-linux-possible-but-not-release-ready/34553/22). 128 + String(cString: objCType) == "c" 129 + } 130 + }
+21
Sources/CoreATProtocol/Networking/Extensions/CharacterSet.swift
··· 1 + import Foundation 2 + 3 + extension CharacterSet { 4 + /// Creates a CharacterSet from RFC 3986 allowed characters. 5 + /// 6 + /// RFC 3986 states that the following characters are "reserved" characters. 7 + /// 8 + /// - General Delimiters: ":", "#", "[", "]", "@", "?", "/" 9 + /// - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "=" 10 + /// 11 + /// In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow 12 + /// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/" 13 + /// should be percent-escaped in the query string. 14 + static let apURLQueryAllowed: CharacterSet = { 15 + let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 16 + let subDelimitersToEncode = "!$&'()*+,;=" 17 + let encodableDelimiters = CharacterSet(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") 18 + 19 + return CharacterSet.urlQueryAllowed.subtracting(encodableDelimiters) 20 + }() 21 + }
+7
Sources/CoreATProtocol/Networking/Extensions/Encodable.swift
··· 1 + import Foundation 2 + 3 + extension Encodable { 4 + func toJSONData() throws -> Data { 5 + try JSONEncoder().encode(self) 6 + } 7 + }
+9
Sources/CoreATProtocol/Networking/Services/EndpointType.swift
··· 1 + import Foundation 2 + 3 + public protocol EndpointType: Sendable { 4 + var baseURL: URL { get async } 5 + var path: String { get } 6 + var httpMethod: HTTPMethod { get } 7 + var task: HTTPTask { get async } 8 + var headers: HTTPHeaders? { get async } 9 + }
+7
Sources/CoreATProtocol/Networking/Services/HTTPMethod.swift
··· 1 + public enum HTTPMethod : String { 2 + case get = "GET" 3 + case post = "POST" 4 + case put = "PUT" 5 + case patch = "PATCH" 6 + case delete = "DELETE" 7 + }
+7
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
··· 1 + public enum HTTPTask: Sendable { 2 + case request 3 + 4 + case requestParameters(encoding: ParameterEncoding) 5 + 6 + // case download, upload...etc 7 + }
+121
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 1 + import Foundation 2 + 3 + @APActor 4 + public protocol NetworkRouterDelegate: AnyObject { 5 + func intercept(_ request: inout URLRequest) async 6 + func shouldRetry(error: Error, attempts: Int) async throws -> Bool 7 + } 8 + 9 + /// Describes the implementation details of a NetworkRouter 10 + /// 11 + /// ``NetworkRouter`` is the only implementation of this protocol available to the end user, but they can create their own 12 + /// implementations that can be used for testing for instance. 13 + @APActor 14 + public protocol NetworkRouterProtocol: AnyObject { 15 + associatedtype Endpoint: EndpointType 16 + var delegate: NetworkRouterDelegate? { get set } 17 + func execute<T: Decodable>(_ route: Endpoint, attempts: Int) async throws -> T 18 + } 19 + 20 + public enum NetworkError : Error, Sendable { 21 + case encodingFailed 22 + case missingURL 23 + case statusCode(_ statusCode: StatusCode?, data: Data) 24 + case noStatusCode 25 + case noData 26 + case tokenRefresh 27 + } 28 + 29 + public typealias HTTPHeaders = [String:String] 30 + 31 + /// The NetworkRouter is a generic class that has an ``EndpointType`` and it conforms to ``NetworkRouterProtocol` 32 + @APActor 33 + public class NetworkRouter<Endpoint: EndpointType>: NetworkRouterProtocol { 34 + 35 + public weak var delegate: NetworkRouterDelegate? 36 + let networking: Networking 37 + let urlSessionTaskDelegate: URLSessionTaskDelegate? 38 + var decoder: JSONDecoder 39 + 40 + public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) { 41 + if let networking = networking { 42 + self.networking = networking 43 + } else { 44 + self.networking = URLSession(configuration: URLSessionConfiguration.default, delegate: urlSessionDelegate, delegateQueue: nil) 45 + } 46 + 47 + self.urlSessionTaskDelegate = urlSessionTaskDelegate 48 + 49 + if let decoder = decoder { 50 + self.decoder = decoder 51 + } else { 52 + self.decoder = JSONDecoder() 53 + self.decoder.keyDecodingStrategy = .convertFromSnakeCase 54 + } 55 + } 56 + 57 + /// This generic method will take a route and return the desired type via a network call 58 + /// This method is async and it can throw errors 59 + /// - Returns: The generic type is returned 60 + public func execute<T: Decodable>(_ route: Endpoint, attempts: Int = 1) async throws -> T { 61 + guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed } 62 + await delegate?.intercept(&request) 63 + 64 + let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 65 + guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 66 + switch httpResponse.statusCode { 67 + case 200...299: 68 + return try decoder.decode(T.self, from: data) 69 + default: 70 + let statusCode = StatusCode(rawValue: httpResponse.statusCode) 71 + let statusNetworkError = AtError.network(NetworkError.statusCode(statusCode, data: data)) 72 + guard let delegate else { throw statusNetworkError } 73 + 74 + let decoder = JSONDecoder() 75 + decoder.keyDecodingStrategy = .convertFromSnakeCase 76 + 77 + let errorToThrow: AtError 78 + if let errorMessage = try? decoder.decode(ErrorMessage.self, from: data) { 79 + errorToThrow = AtError.message(errorMessage) 80 + } else { 81 + errorToThrow = statusNetworkError 82 + } 83 + 84 + guard try await delegate.shouldRetry(error: errorToThrow, attempts: attempts) else { throw errorToThrow } 85 + return try await execute(route, attempts: attempts + 1) 86 + } 87 + } 88 + 89 + func buildRequest(from route: Endpoint) async throws -> URLRequest { 90 + 91 + var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path), 92 + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 93 + timeoutInterval: 10.0) 94 + 95 + request.httpMethod = route.httpMethod.rawValue 96 + do { 97 + switch await route.task { 98 + case .request: 99 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 100 + await addAdditionalHeaders(route.headers, request: &request) 101 + case .requestParameters(let parameterEncoding): 102 + await addAdditionalHeaders(route.headers, request: &request) 103 + try configureParameters(parameterEncoding: parameterEncoding, request: &request) 104 + } 105 + return request 106 + } catch { 107 + throw error 108 + } 109 + } 110 + 111 + private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws { 112 + try parameterEncoding.encode(urlRequest: &request) 113 + } 114 + 115 + private func addAdditionalHeaders(_ additionalHeaders: HTTPHeaders?, request: inout URLRequest) { 116 + guard let headers = additionalHeaders else { return } 117 + for (key, value) in headers { 118 + request.setValue(value, forHTTPHeaderField: key) 119 + } 120 + } 121 + }
+8
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
··· 1 + @preconcurrency import Foundation 2 + 3 + @APActor 4 + public protocol Networking { 5 + func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) 6 + } 7 + 8 + extension URLSession: Networking { }
+76
Sources/CoreATProtocol/Networking/Services/StatusCode.swift
··· 1 + import Foundation 2 + 3 + public enum StatusCode: Int, Sendable { 4 + // 1xx 5 + case continueCode = 100 6 + case switchingProtocols = 101 7 + case processing = 102 8 + case earlyHints = 103 9 + 10 + // 2xx 11 + case ok = 200 12 + case created = 201 13 + case accepted = 202 14 + case nonAuthoritativeInformation = 203 15 + case noContent = 204 16 + case resetContent = 205 17 + case partialContent = 206 18 + case mutliStatus = 207 19 + case alreadyReported = 208 20 + case IMUsed = 226 21 + 22 + // 3xx 23 + case multipleChoices = 300 24 + case movedPermanently = 301 25 + case found = 302 26 + case seeOthers = 303 27 + case notModified = 304 28 + case useProxy = 305 29 + case switchProxy = 306 30 + case temporaryRedirect = 307 31 + case permanentRedirect = 308 32 + 33 + // 4xx 34 + case badRequest = 400 35 + case unauthorized = 401 36 + case paymentRequired = 402 37 + case forbidden = 403 38 + case notFound = 404 39 + case methodNotAllowed = 405 40 + case notAcceptable = 406 41 + case proxyAuthenticationRequired = 407 42 + case requestTimeout = 408 43 + case conflict = 409 44 + case gone = 410 45 + case lengthRequired = 411 46 + case preconditionFailed = 412 47 + case payloadTooLarge = 413 48 + case uriTooLong = 414 49 + case unsupportedMediaType = 415 50 + case rangeNotSatisfiable = 416 51 + case expectationFailed = 417 52 + case imATeapot = 418 53 + case misdirectedRequest = 421 54 + case unprocessableEntity = 422 55 + case locked = 423 56 + case failedDependency = 424 57 + case tooEarly = 425 58 + case upgradeRequire = 426 59 + case preconditionRequire = 428 60 + case tooManyRequests = 429 61 + case requestHeaderFieldsTooLarge = 431 62 + case unavailableForLegalResons = 451 63 + 64 + // 5xx 65 + case internalServerError = 500 66 + case notImplemented = 501 67 + case badGateway = 502 68 + case serviceUnavailable = 503 69 + case gatewayTimeout = 504 70 + case httpVersionNotSupported = 505 71 + case variantAlsoNegatiates = 506 72 + case insufficientStorage = 507 73 + case loopDetected = 508 74 + case notExtended = 510 75 + case networkAuthenticationRequired = 511 76 + }
+295
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 + 80 + // JWT signing keys (pattern from AtProtocol) 81 + private var keys: JWTKeyCollection 82 + private var privateKey: ES256PrivateKey 83 + 84 + public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage) async { 85 + self.config = config 86 + self.storage = storage 87 + self.identityResolver = IdentityResolver() 88 + 89 + // Initialize JWT keys (from AtProto.swift lines 19-23) 90 + self.privateKey = ES256PrivateKey() 91 + self.keys = JWTKeyCollection() 92 + await self.keys.add(ecdsa: privateKey) 93 + } 94 + 95 + /// Initialize with existing private key (for session restoration) 96 + public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage, privateKeyPEM: String) async throws { 97 + self.config = config 98 + self.storage = storage 99 + self.identityResolver = IdentityResolver() 100 + 101 + // Restore existing key 102 + self.privateKey = try ES256PrivateKey(pem: privateKeyPEM) 103 + self.keys = JWTKeyCollection() 104 + await self.keys.add(ecdsa: privateKey) 105 + } 106 + 107 + /// Authenticate user by handle 108 + /// - Parameters: 109 + /// - handle: The user's AT Protocol handle (e.g., "alice.bsky.social") 110 + /// - userAuthenticator: Callback to present the authorization URL and return the callback URL 111 + /// - Returns: Authentication result with tokens and user info 112 + public func authenticate( 113 + handle: String, 114 + userAuthenticator: @escaping UserAuthenticator 115 + ) async throws -> ATProtoAuthResult { 116 + // Step 1: Resolve identity 117 + let identity: IdentityResolver.ResolvedIdentity 118 + do { 119 + identity = try await identityResolver.resolve(handle: handle) 120 + } catch { 121 + throw ATProtoOAuthError.authenticationFailed("Identity resolution failed: \(error.localizedDescription)") 122 + } 123 + 124 + // Step 2: Store private key for future sessions 125 + let keyPEM = privateKey.pemRepresentation 126 + guard let keyData = keyPEM.data(using: .utf8) else { 127 + throw ATProtoOAuthError.privateKeyExportFailed 128 + } 129 + try await storage.storePrivateKey(keyData) 130 + 131 + // Step 3: Load client metadata 132 + let provider = URLSession.defaultProvider 133 + let clientConfig: ClientMetadata 134 + do { 135 + clientConfig = try await ClientMetadata.load( 136 + for: config.clientMetadataURL, 137 + provider: provider 138 + ) 139 + } catch { 140 + throw ATProtoOAuthError.authenticationFailed("Failed to load client metadata from \(config.clientMetadataURL): \(error.localizedDescription)") 141 + } 142 + 143 + // Step 4: Load server metadata 144 + let serverConfig: ServerMetadata 145 + do { 146 + serverConfig = try await ServerMetadata.load( 147 + for: identity.authServerHost, 148 + provider: provider 149 + ) 150 + } catch { 151 + throw ATProtoOAuthError.authenticationFailed("Failed to load server metadata from \(identity.authServerHost): \(error.localizedDescription)") 152 + } 153 + 154 + // Step 5: Create login storage 155 + let loginStorage = LoginStorage( 156 + retrieveLogin: storage.retrieveLogin, 157 + storeLogin: storage.storeLogin 158 + ) 159 + 160 + // Step 6: Create JWT generator 161 + let jwtGenerator: DPoPSigner.JWTGenerator = { [self] params in 162 + try await self.generateJWT(params: params) 163 + } 164 + 165 + // Step 7: Create authenticator 166 + let tokenHandling = ATProto.tokenHandling( 167 + account: handle, 168 + server: serverConfig, 169 + jwtGenerator: jwtGenerator 170 + ) 171 + 172 + let authenticatorConfig = Authenticator.Configuration( 173 + appCredentials: clientConfig.credentials, 174 + loginStorage: loginStorage, 175 + tokenHandling: tokenHandling, 176 + mode: .manualOnly, 177 + userAuthenticator: userAuthenticator 178 + ) 179 + 180 + let authenticator = Authenticator(config: authenticatorConfig) 181 + 182 + // Step 8: Trigger authentication with user interaction 183 + let login: Login 184 + do { 185 + login = try await authenticator.authenticate() 186 + } catch { 187 + throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 188 + } 189 + 190 + // Step 9: Setup CoreATProtocol environment 191 + setup( 192 + hostURL: identity.pdsEndpoint, 193 + accessJWT: login.accessToken.value, 194 + refreshJWT: login.refreshToken?.value, 195 + delegate: nil 196 + ) 197 + 198 + return ATProtoAuthResult( 199 + did: identity.did, 200 + handle: identity.handle, 201 + accessToken: login.accessToken.value, 202 + refreshToken: login.refreshToken?.value, 203 + expiresIn: Int(login.accessToken.expiry?.timeIntervalSinceNow ?? 3600), 204 + pdsEndpoint: identity.pdsEndpoint 205 + ) 206 + } 207 + 208 + /// Export private key PEM for persistence 209 + public var privateKeyPEM: String { 210 + privateKey.pemRepresentation 211 + } 212 + 213 + // MARK: - Private (from AtProto.swift lines 60-72) 214 + 215 + private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String { 216 + // Strip query params and fragments from htu per DPoP spec 217 + let htu = stripQueryAndFragment(from: params.requestEndpoint) 218 + 219 + let payload = DPoPPayload( 220 + htm: params.httpMethod, 221 + htu: htu, 222 + iat: .init(value: .now), 223 + jti: .init(value: UUID().uuidString), 224 + nonce: params.nonce 225 + ) 226 + 227 + // DPoP requires typ="dpop+jwt", alg="ES256", and the public key in jwk header 228 + var header = JWTHeader() 229 + header.typ = "dpop+jwt" 230 + header.alg = "ES256" 231 + 232 + // Get public key parameters and convert to base64url for JWK 233 + if let keyParams = privateKey.parameters { 234 + // Convert from base64 to base64url (replace + with -, / with _, remove =) 235 + let xBase64URL = keyParams.x 236 + .replacingOccurrences(of: "+", with: "-") 237 + .replacingOccurrences(of: "/", with: "_") 238 + .replacingOccurrences(of: "=", with: "") 239 + let yBase64URL = keyParams.y 240 + .replacingOccurrences(of: "+", with: "-") 241 + .replacingOccurrences(of: "/", with: "_") 242 + .replacingOccurrences(of: "=", with: "") 243 + 244 + header.jwk = [ 245 + "kty": .string("EC"), 246 + "crv": .string("P-256"), 247 + "x": .string(xBase64URL), 248 + "y": .string(yBase64URL) 249 + ] 250 + } 251 + 252 + return try await self.keys.sign(payload, header: header) 253 + } 254 + 255 + /// Strip query string and fragment from URL per DPoP spec 256 + private func stripQueryAndFragment(from url: String) -> String { 257 + let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1 258 + let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1 259 + 260 + let end: Int 261 + if fragmentIndex == -1 { 262 + end = queryIndex 263 + } else if queryIndex == -1 { 264 + end = fragmentIndex 265 + } else { 266 + end = min(fragmentIndex, queryIndex) 267 + } 268 + 269 + return end == -1 ? url : String(url.prefix(end)) 270 + } 271 + } 272 + 273 + // MARK: - DPoP Payload (from AtProto.swift lines 88-98) 274 + 275 + private struct DPoPPayload: JWTPayload { 276 + let htm: String 277 + let htu: String 278 + let iat: IssuedAtClaim 279 + let jti: IDClaim 280 + let nonce: String? 281 + 282 + func verify(using key: some JWTAlgorithm) throws { 283 + // No additional verification needed for DPoP 284 + } 285 + } 286 + 287 + // MARK: - URLSession Extension 288 + 289 + extension URLSession { 290 + static var defaultProvider: URLResponseProvider { 291 + { request in 292 + try await URLSession.shared.data(for: request) 293 + } 294 + } 295 + }
+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 + }
+6
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
··· 1 + import Testing 2 + @testable import CoreATProtocol 3 + 4 + @Test func example() async throws { 5 + // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 + }
+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 + }