+8
.gitignore
+8
.gitignore
+133
CODE_OF_CONDUCT.md
+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
+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
+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
+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
+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
+10
Sources/CoreATProtocol/APActor.swift
+26
Sources/CoreATProtocol/APEnvironment.swift
+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
+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
+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
+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
+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
+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
+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
+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
+7
Sources/CoreATProtocol/Networking/Extensions/Encodable.swift
+9
Sources/CoreATProtocol/Networking/Services/EndpointType.swift
+9
Sources/CoreATProtocol/Networking/Services/EndpointType.swift
+7
Sources/CoreATProtocol/Networking/Services/HTTPMethod.swift
+7
Sources/CoreATProtocol/Networking/Services/HTTPMethod.swift
+7
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
+7
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
+121
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+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
+8
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
+76
Sources/CoreATProtocol/Networking/Services/StatusCode.swift
+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
+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
+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
+6
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
+134
Tests/CoreATProtocolTests/OAuthTests.swift
+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
+
}