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