-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" : "46681c90ffb61eca5269d3e2ab8743c6f802287641f8bccf7c47227aa7a6a97a",
2
+
"originHash" : "2237e2c10a8d530dcbd1f9770efc8fcf2a9fc2ca2c63a19882551fea7ab9fe25",
3
3
"pins" : [
4
4
{
5
5
"identity" : "jwt-kit",
6
6
"kind" : "remoteSourceControl",
7
7
"location" : "https://github.com/vapor/jwt-kit.git",
8
8
"state" : {
9
-
"revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c",
10
-
"version" : "5.3.0"
9
+
"revision" : "2033b3e661238dda3d30e36a2d40987499d987de",
10
+
"version" : "5.2.0"
11
11
}
12
12
},
13
13
{
14
14
"identity" : "oauthenticator",
15
15
"kind" : "remoteSourceControl",
16
-
"location" : "https://github.com/radmakr/OAuthenticator.git",
16
+
"location" : "https://github.com/ChimeHQ/OAuthenticator",
17
17
"state" : {
18
-
"branch" : "CoreAtProtocol",
19
-
"revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70"
18
+
"branch" : "main",
19
+
"revision" : "618971d4d341650db664925fd0479032294064ad"
20
20
}
21
21
},
22
22
{
···
24
24
"kind" : "remoteSourceControl",
25
25
"location" : "https://github.com/apple/swift-asn1.git",
26
26
"state" : {
27
-
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
28
-
"version" : "1.5.1"
27
+
"revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d",
28
+
"version" : "1.5.0"
29
29
}
30
30
},
31
31
{
···
33
33
"kind" : "remoteSourceControl",
34
34
"location" : "https://github.com/apple/swift-certificates.git",
35
35
"state" : {
36
-
"revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130",
37
-
"version" : "1.17.0"
36
+
"revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a",
37
+
"version" : "1.15.0"
38
38
}
39
39
},
40
40
{
···
42
42
"kind" : "remoteSourceControl",
43
43
"location" : "https://github.com/apple/swift-crypto.git",
44
44
"state" : {
45
-
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
46
-
"version" : "4.2.0"
45
+
"revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18",
46
+
"version" : "4.0.0"
47
47
}
48
48
},
49
49
{
···
51
51
"kind" : "remoteSourceControl",
52
52
"location" : "https://github.com/apple/swift-log.git",
53
53
"state" : {
54
-
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
55
-
"version" : "1.8.0"
54
+
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
55
+
"version" : "1.6.4"
56
56
}
57
57
}
58
58
],
+6
-13
Package.swift
+6
-13
Package.swift
···
5
5
let package = Package(
6
6
name: "CoreATProtocol",
7
7
platforms: [
8
-
.iOS(.v17),
9
-
.watchOS(.v11),
10
-
.tvOS(.v17),
11
-
.macOS(.v14),
12
-
.macCatalyst(.v17),
8
+
.iOS(.v26),
9
+
.watchOS(.v26),
10
+
.tvOS(.v26),
11
+
.macOS(.v26),
12
+
.macCatalyst(.v26),
13
13
],
14
14
products: [
15
15
.library(
···
18
18
),
19
19
],
20
20
dependencies: [
21
-
// Using fork with fix for WebAuthenticationSession platform guards
22
-
// PR pending at https://github.com/ChimeHQ/OAuthenticator
23
-
// .package(url: "https://github.com/ChimeHQ/OAuthenticator.git", branch: "main"),
24
-
.package(url: "https://github.com/radmakr/OAuthenticator.git", branch: "CoreAtProtocol"),
25
-
// .package(path: "../OAuthenticator"),
21
+
.package(url: "https://github.com/ChimeHQ/OAuthenticator", branch: "main"),
26
22
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"),
27
23
],
28
24
targets: [
···
32
28
"OAuthenticator",
33
29
.product(name: "JWTKit", package: "jwt-kit"),
34
30
],
35
-
swiftSettings: [
36
-
.enableExperimentalFeature("StrictConcurrency")
37
-
]
38
31
),
39
32
.testTarget(
40
33
name: "CoreATProtocolTests",
-230
README.md
-230
README.md
···
1
-
# CoreATProtocol
2
-
3
-
A Swift package providing the foundational networking layer for interacting with the [AT Protocol](https://atproto.com) (Authenticated Transfer Protocol). This library handles the core HTTP communication, authentication token management, and request/response encoding required to build AT Protocol clients.
4
-
5
-
## Overview
6
-
7
-
CoreATProtocol is designed to be protocol-agnostic within the AT Protocol ecosystem. It provides the networking infrastructure that higher-level packages (like [bskyKit](https://tangled.org/@sparrowtek.com/bskyKit) for Bluesky) can build upon to implement specific lexicons.
8
-
9
-
### Key Features
10
-
11
-
- **Modern Swift Concurrency** - Built with Swift 6.2 using async/await and actors for thread-safe operations
12
-
- **Global Actor Isolation** - Uses `@APActor` for consistent thread safety across all AT Protocol operations
13
-
- **Flexible Network Routing** - Generic `NetworkRouter` that works with any endpoint conforming to `EndpointType`
14
-
- **Automatic Token Management** - Built-in support for JWT access/refresh token handling with automatic retry on expiration
15
-
- **Multiple Parameter Encodings** - URL, JSON, and combined encoding strategies for request parameters
16
-
- **AT Protocol Error Handling** - Typed error responses matching AT Protocol error specifications
17
-
- **Testable Architecture** - Protocol-based design allows easy mocking for unit tests
18
-
19
-
## Requirements
20
-
21
-
- Swift 6.2+
22
-
- iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+
23
-
24
-
## Installation
25
-
26
-
### Swift Package Manager
27
-
28
-
Add CoreATProtocol to your `Package.swift` dependencies:
29
-
30
-
```swift
31
-
dependencies: [
32
-
.package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"),
33
-
]
34
-
```
35
-
36
-
Then add it to your target dependencies:
37
-
38
-
```swift
39
-
.target(
40
-
name: "YourTarget",
41
-
dependencies: ["CoreATProtocol"]
42
-
),
43
-
```
44
-
45
-
Or in Xcode: File > Add Package Dependencies and enter:
46
-
```
47
-
https://tangled.org/@sparrowtek.com/CoreATProtocol
48
-
```
49
-
50
-
## Usage
51
-
52
-
### Initial Setup
53
-
54
-
Configure the environment with your host URL and authentication tokens:
55
-
56
-
```swift
57
-
import CoreATProtocol
58
-
59
-
// Setup with host and tokens
60
-
await setup(
61
-
hostURL: "https://bsky.social",
62
-
accessJWT: "your-access-token",
63
-
refreshJWT: "your-refresh-token"
64
-
)
65
-
66
-
// Or update tokens later
67
-
await updateTokens(access: newAccessToken, refresh: newRefreshToken)
68
-
69
-
// Change host
70
-
await update(hostURL: "https://different-pds.example")
71
-
```
72
-
73
-
### Defining Endpoints
74
-
75
-
Create endpoints by conforming to `EndpointType`:
76
-
77
-
```swift
78
-
import CoreATProtocol
79
-
80
-
enum MyEndpoint: EndpointType {
81
-
case getProfile(actor: String)
82
-
case createPost(text: String)
83
-
84
-
var baseURL: URL {
85
-
get async {
86
-
URL(string: APEnvironment.current.host ?? "https://bsky.social")!
87
-
}
88
-
}
89
-
90
-
var path: String {
91
-
switch self {
92
-
case .getProfile:
93
-
return "/xrpc/app.bsky.actor.getProfile"
94
-
case .createPost:
95
-
return "/xrpc/com.atproto.repo.createRecord"
96
-
}
97
-
}
98
-
99
-
var httpMethod: HTTPMethod {
100
-
switch self {
101
-
case .getProfile: return .get
102
-
case .createPost: return .post
103
-
}
104
-
}
105
-
106
-
var task: HTTPTask {
107
-
get async {
108
-
switch self {
109
-
case .getProfile(let actor):
110
-
return .requestParameters(encoding: .urlEncoding(parameters: ["actor": actor]))
111
-
case .createPost(let text):
112
-
let body: [String: Any] = ["text": text]
113
-
return .requestParameters(encoding: .jsonEncoding(parameters: body))
114
-
}
115
-
}
116
-
}
117
-
118
-
var headers: HTTPHeaders? {
119
-
get async { nil }
120
-
}
121
-
}
122
-
```
123
-
124
-
### Making Requests
125
-
126
-
Use `NetworkRouter` to execute requests:
127
-
128
-
```swift
129
-
@APActor
130
-
class MyATClient {
131
-
private let router = NetworkRouter<MyEndpoint>()
132
-
133
-
init() {
134
-
router.delegate = APEnvironment.current.routerDelegate
135
-
}
136
-
137
-
func getProfile(actor: String) async throws -> ProfileResponse {
138
-
try await router.execute(.getProfile(actor: actor))
139
-
}
140
-
}
141
-
```
142
-
143
-
### Custom JSON Decoding
144
-
145
-
Use the pre-configured AT Protocol decoder for proper date handling:
146
-
147
-
```swift
148
-
let router = NetworkRouter<MyEndpoint>(decoder: .atDecoder)
149
-
```
150
-
151
-
### Error Handling
152
-
153
-
Handle AT Protocol specific errors:
154
-
155
-
```swift
156
-
do {
157
-
let profile: Profile = try await router.execute(.getProfile(actor: "did:plc:example"))
158
-
} catch let error as AtError {
159
-
switch error {
160
-
case .message(let errorMessage):
161
-
print("AT Error: \(errorMessage.error) - \(errorMessage.message ?? "")")
162
-
case .network(let networkError):
163
-
switch networkError {
164
-
case .statusCode(let code, let data):
165
-
print("HTTP \(code?.rawValue ?? 0)")
166
-
case .encodingFailed:
167
-
print("Failed to encode request")
168
-
default:
169
-
print("Network error: \(networkError)")
170
-
}
171
-
}
172
-
}
173
-
```
174
-
175
-
## Architecture
176
-
177
-
### Core Components
178
-
179
-
| Component | Description |
180
-
|-----------|-------------|
181
-
| `APActor` | Global actor ensuring thread-safe access to AT Protocol state |
182
-
| `APEnvironment` | Singleton holding host URL, tokens, and delegates |
183
-
| `NetworkRouter` | Generic router executing typed endpoint requests |
184
-
| `EndpointType` | Protocol defining API endpoint requirements |
185
-
| `ParameterEncoding` | Enum supporting URL, JSON, and hybrid encoding |
186
-
| `AtError` | AT Protocol error types with message parsing |
187
-
188
-
### Thread Safety
189
-
190
-
All AT Protocol operations are isolated to `@APActor` ensuring thread-safe access:
191
-
192
-
```swift
193
-
@APActor
194
-
public func myFunction() async {
195
-
// Safe access to APEnvironment.current
196
-
}
197
-
```
198
-
199
-
## Parameter Encoding Options
200
-
201
-
```swift
202
-
// URL query parameters
203
-
.urlEncoding(parameters: ["key": "value"])
204
-
205
-
// JSON body
206
-
.jsonEncoding(parameters: ["key": "value"])
207
-
208
-
// Pre-encoded JSON data
209
-
.jsonDataEncoding(data: jsonData)
210
-
211
-
// Encodable objects
212
-
.jsonEncodableEncoding(encodable: myStruct)
213
-
214
-
// Combined URL + JSON body
215
-
.urlAndJsonEncoding(urlParameters: ["q": "search"], bodyParameters: ["data": "value"])
216
-
```
217
-
218
-
## Related Packages
219
-
220
-
- **[bskyKit](https://tangled.org/@sparrowtek.com/bskyKit)** - Bluesky-specific lexicon implementations built on CoreATProtocol
221
-
222
-
## License
223
-
224
-
This project is licensed under an [MIT license](https://tangled.org/sparrowtek.com/CoreATProtocol/blob/main/LICENSE).
225
-
226
-
## Contributing
227
-
228
-
It is always a good idea to discuss before taking on a significant task. That said, I have a strong bias towards enthusiasm. If you are excited about doing something, I'll do my best to get out of your way.
229
-
230
-
By participating in this project you agree to abide by the [Contributor Code of Conduct](https://tangled.org/sparrowtek.com/CoreATProtocol/blob/main/CODE_OF_CONDUCT.md).
+108
-12
Sources/CoreATProtocol/APEnvironment.swift
+108
-12
Sources/CoreATProtocol/APEnvironment.swift
···
5
5
// Created by Thomas Rademaker on 10/10/25.
6
6
//
7
7
8
-
import JWTKit
8
+
import Foundation
9
+
import OAuthenticator
9
10
10
11
@APActor
11
12
public class APEnvironment {
12
13
public static var current: APEnvironment = APEnvironment()
13
-
14
+
15
+
// MARK: - Connection Configuration
14
16
public var host: String?
17
+
18
+
// MARK: - Authentication Tokens
15
19
public var accessToken: String?
16
20
public var refreshToken: String?
21
+
public var login: Login?
22
+
23
+
// MARK: - DPoP Support
24
+
public var dpopProofGenerator: DPoPSigner.JWTGenerator?
25
+
public var resourceServerNonce: String?
26
+
public let resourceDPoPSigner = DPoPSigner()
27
+
28
+
// MARK: - OAuth Configuration (for token refresh)
29
+
public var serverMetadata: ServerMetadata?
30
+
public var clientId: String?
31
+
public var authState: AuthenticationState?
32
+
public var tokenStorage: TokenStorageProtocol?
33
+
34
+
// MARK: - Identity
35
+
public var resolvedIdentity: IdentityResolver.ResolvedIdentity?
36
+
public let identityResolver = IdentityResolver()
37
+
38
+
// MARK: - Delegates and Callbacks
17
39
public var atProtocoldelegate: CoreATProtocolDelegate?
18
-
public var tokenRefreshHandler: (@Sendable () async throws -> Bool)?
19
-
public var dpopPrivateKey: ES256PrivateKey?
20
-
public var dpopKeys: JWTKeyCollection?
21
40
public let routerDelegate = APRouterDelegate()
22
-
41
+
42
+
// MARK: - State Flags
43
+
private var isRefreshing = false
44
+
23
45
private init() {}
24
-
25
-
// func setup(apiKey: String, apiSecret: String, userAgent: String) {
26
-
// self.apiKey = apiKey
27
-
// self.apiSecret = apiSecret
28
-
// self.userAgent = userAgent
29
-
// }
46
+
47
+
// MARK: - Token Refresh
48
+
49
+
/// Checks if the current access token needs refresh.
50
+
public var needsTokenRefresh: Bool {
51
+
if let state = authState {
52
+
return state.isAccessTokenExpired
53
+
}
54
+
// If no auth state, check login object
55
+
if let login = login {
56
+
return !login.accessToken.valid
57
+
}
58
+
return false
59
+
}
60
+
61
+
/// Attempts to refresh the access token if needed.
62
+
/// Returns true if refresh succeeded or wasn't needed, false if refresh failed.
63
+
public func refreshTokenIfNeeded() async -> Bool {
64
+
guard needsTokenRefresh else { return true }
65
+
66
+
// Prevent concurrent refresh attempts
67
+
guard !isRefreshing else { return false }
68
+
isRefreshing = true
69
+
defer { isRefreshing = false }
70
+
71
+
return await performTokenRefresh()
72
+
}
73
+
74
+
// MARK: - Configuration
75
+
76
+
/// Configures the environment for OAuth with token refresh support.
77
+
public func configureOAuth(
78
+
serverMetadata: ServerMetadata,
79
+
clientId: String,
80
+
tokenStorage: TokenStorageProtocol? = nil
81
+
) {
82
+
self.serverMetadata = serverMetadata
83
+
self.clientId = clientId
84
+
self.tokenStorage = tokenStorage
85
+
}
86
+
87
+
/// Stores the complete authentication state after successful login.
88
+
public func setAuthenticationState(_ state: AuthenticationState) async {
89
+
self.authState = state
90
+
self.accessToken = state.accessToken
91
+
self.refreshToken = state.refreshToken
92
+
93
+
// Update host from PDS URL
94
+
if let url = URL(string: state.pdsURL) {
95
+
self.host = url.absoluteString
96
+
}
97
+
98
+
// Persist if storage is configured
99
+
if let storage = tokenStorage {
100
+
try? await storage.store(state)
101
+
}
102
+
}
103
+
104
+
/// Restores authentication state from storage.
105
+
public func restoreAuthenticationState() async -> Bool {
106
+
guard let storage = tokenStorage else { return false }
107
+
108
+
do {
109
+
guard let state = try await storage.retrieve() else {
110
+
return false
111
+
}
112
+
113
+
self.authState = state
114
+
self.accessToken = state.accessToken
115
+
self.refreshToken = state.refreshToken
116
+
117
+
if let url = URL(string: state.pdsURL) {
118
+
self.host = url.absoluteString
119
+
}
120
+
121
+
return true
122
+
} catch {
123
+
return false
124
+
}
125
+
}
30
126
}
+146
-41
Sources/CoreATProtocol/CoreATProtocol.swift
+146
-41
Sources/CoreATProtocol/CoreATProtocol.swift
···
1
1
// The Swift Programming Language
2
2
// https://docs.swift.org/swift-book
3
3
4
-
import JWTKit
4
+
@_exported import OAuthenticator
5
5
6
-
// MARK: - Session
6
+
/// Delegate protocol for receiving authentication and session lifecycle events.
7
+
@MainActor
8
+
public protocol CoreATProtocolDelegate: AnyObject, Sendable {
9
+
/// Called when tokens have been refreshed.
10
+
func tokensUpdated(accessToken: String, refreshToken: String?) async
7
11
8
-
/// Represents an authenticated AT Protocol session
9
-
public struct Session: Sendable, Codable, Hashable {
10
-
public let did: String
11
-
public let handle: String
12
-
public let email: String?
13
-
public let accessJwt: String?
14
-
public let refreshJwt: String?
12
+
/// Called when a session has expired and re-authentication is required.
13
+
func sessionExpired() async
15
14
16
-
public init(did: String, handle: String, email: String? = nil, accessJwt: String? = nil, refreshJwt: String? = nil) {
17
-
self.did = did
18
-
self.handle = handle
19
-
self.email = email
20
-
self.accessJwt = accessJwt
21
-
self.refreshJwt = refreshJwt
22
-
}
23
-
}
15
+
/// Called when authentication fails.
16
+
func authenticationFailed(error: Error) async
24
17
25
-
// MARK: - Delegate
26
-
27
-
public protocol CoreATProtocolDelegate: AnyObject, Sendable {
28
-
/// Called when the session is updated (e.g., tokens refreshed)
29
-
func sessionUpdated(_ session: Session) async
18
+
/// Called when DPoP nonce is updated from a server response.
19
+
func dpopNonceUpdated(nonce: String) async
30
20
}
31
21
32
-
// Default implementation for optional method
22
+
/// Default implementations for optional delegate methods.
33
23
public extension CoreATProtocolDelegate {
34
-
func sessionUpdated(_ session: Session) async {}
24
+
func tokensUpdated(accessToken: String, refreshToken: String?) async {}
25
+
func sessionExpired() async {}
26
+
func authenticationFailed(error: Error) async {}
27
+
func dpopNonceUpdated(nonce: String) async {}
35
28
}
36
29
30
+
// MARK: - Setup Functions
31
+
32
+
/// Configures the AT Protocol environment with basic authentication.
33
+
/// - Parameters:
34
+
/// - hostURL: The PDS host URL
35
+
/// - accessJWT: Access token
36
+
/// - refreshJWT: Refresh token
37
+
/// - delegate: Optional delegate for receiving events
37
38
@APActor
38
39
public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) {
39
40
APEnvironment.current.host = hostURL
···
42
43
APEnvironment.current.atProtocoldelegate = delegate
43
44
}
44
45
46
+
/// Configures the AT Protocol environment with OAuth support.
47
+
/// - Parameters:
48
+
/// - serverMetadata: OAuth authorization server metadata
49
+
/// - clientId: The client ID for this application
50
+
/// - tokenStorage: Optional persistent storage for tokens
51
+
/// - delegate: Optional delegate for receiving events
52
+
@APActor
53
+
public func setupOAuth(
54
+
serverMetadata: ServerMetadata,
55
+
clientId: String,
56
+
tokenStorage: TokenStorageProtocol? = nil,
57
+
delegate: CoreATProtocolDelegate? = nil
58
+
) {
59
+
APEnvironment.current.configureOAuth(
60
+
serverMetadata: serverMetadata,
61
+
clientId: clientId,
62
+
tokenStorage: tokenStorage
63
+
)
64
+
APEnvironment.current.atProtocoldelegate = delegate
65
+
}
66
+
67
+
/// Sets the delegate for receiving authentication events.
45
68
@APActor
46
69
public func setDelegate(_ delegate: CoreATProtocolDelegate) {
47
70
APEnvironment.current.atProtocoldelegate = delegate
48
71
}
49
72
73
+
/// Updates the stored tokens.
50
74
@APActor
51
-
public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) {
52
-
APEnvironment.current.tokenRefreshHandler = handler
75
+
public func updateTokens(access: String?, refresh: String?) {
76
+
APEnvironment.current.accessToken = access
77
+
APEnvironment.current.refreshToken = refresh
78
+
}
79
+
80
+
/// Updates the host URL.
81
+
@APActor
82
+
public func update(hostURL: String?) {
83
+
APEnvironment.current.host = hostURL
84
+
}
85
+
86
+
/// Applies a complete authentication context from a successful OAuth login.
87
+
/// - Parameters:
88
+
/// - login: The Login object from OAuthenticator
89
+
/// - generator: DPoP JWT generator for signing requests
90
+
/// - resourceNonce: Initial DPoP nonce from the resource server
91
+
/// - serverMetadata: OAuth server metadata for token refresh
92
+
/// - clientId: Client ID for token refresh
93
+
@APActor
94
+
public func applyAuthenticationContext(
95
+
login: Login,
96
+
generator: @escaping DPoPSigner.JWTGenerator,
97
+
resourceNonce: String? = nil,
98
+
serverMetadata: ServerMetadata? = nil,
99
+
clientId: String? = nil
100
+
) {
101
+
APEnvironment.current.login = login
102
+
APEnvironment.current.accessToken = login.accessToken.value
103
+
APEnvironment.current.refreshToken = login.refreshToken?.value
104
+
APEnvironment.current.dpopProofGenerator = generator
105
+
APEnvironment.current.resourceServerNonce = resourceNonce
106
+
APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce
107
+
108
+
// Store OAuth configuration if provided (needed for token refresh)
109
+
if let metadata = serverMetadata {
110
+
APEnvironment.current.serverMetadata = metadata
111
+
}
112
+
if let id = clientId {
113
+
APEnvironment.current.clientId = id
114
+
}
53
115
}
54
116
117
+
/// Clears all authentication context and tokens.
55
118
@APActor
56
-
public func setDPoPPrivateKey(pem: String?) async throws {
57
-
guard let pem, !pem.isEmpty else {
58
-
APEnvironment.current.dpopPrivateKey = nil
59
-
APEnvironment.current.dpopKeys = nil
60
-
return
119
+
public func clearAuthenticationContext() async {
120
+
APEnvironment.current.login = nil
121
+
APEnvironment.current.dpopProofGenerator = nil
122
+
APEnvironment.current.resourceServerNonce = nil
123
+
APEnvironment.current.accessToken = nil
124
+
APEnvironment.current.refreshToken = nil
125
+
APEnvironment.current.resourceDPoPSigner.nonce = nil
126
+
APEnvironment.current.authState = nil
127
+
APEnvironment.current.resolvedIdentity = nil
128
+
129
+
// Clear persistent storage if configured
130
+
if let storage = APEnvironment.current.tokenStorage {
131
+
try? await storage.clear()
61
132
}
133
+
}
134
+
135
+
/// Updates the resource server DPoP nonce.
136
+
@APActor
137
+
public func updateResourceDPoPNonce(_ nonce: String?) {
138
+
APEnvironment.current.resourceServerNonce = nonce
139
+
APEnvironment.current.resourceDPoPSigner.nonce = nonce
140
+
}
62
141
63
-
let privateKey = try ES256PrivateKey(pem: pem)
64
-
let keys = JWTKeyCollection()
65
-
await keys.add(ecdsa: privateKey)
142
+
// MARK: - Identity Resolution
66
143
67
-
APEnvironment.current.dpopPrivateKey = privateKey
68
-
APEnvironment.current.dpopKeys = keys
144
+
/// Resolves a handle to a complete identity with PDS and authorization server URLs.
145
+
/// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social")
146
+
/// - Returns: Complete resolved identity information
147
+
@APActor
148
+
public func resolveIdentity(handle: String) async throws -> IdentityResolver.ResolvedIdentity {
149
+
let identity = try await APEnvironment.current.identityResolver.resolveIdentity(handle: handle)
150
+
APEnvironment.current.resolvedIdentity = identity
151
+
APEnvironment.current.host = identity.pdsURL
152
+
return identity
69
153
}
70
154
155
+
/// Resolves a DID to a complete identity with PDS and authorization server URLs.
156
+
/// - Parameter did: The DID to resolve (e.g., "did:plc:abc123")
157
+
/// - Returns: Complete resolved identity information
71
158
@APActor
72
-
public func updateTokens(access: String?, refresh: String?) {
73
-
APEnvironment.current.accessToken = access
74
-
APEnvironment.current.refreshToken = refresh
159
+
public func resolveIdentity(did: String) async throws -> IdentityResolver.ResolvedIdentity {
160
+
let identity = try await APEnvironment.current.identityResolver.resolveIdentity(did: did)
161
+
APEnvironment.current.resolvedIdentity = identity
162
+
APEnvironment.current.host = identity.pdsURL
163
+
return identity
75
164
}
76
165
166
+
// MARK: - Session Management
167
+
168
+
/// Attempts to restore a previous session from persistent storage.
169
+
/// - Returns: true if a session was restored, false otherwise
77
170
@APActor
78
-
public func update(hostURL: String?) {
79
-
APEnvironment.current.host = hostURL
171
+
public func restoreSession() async -> Bool {
172
+
return await APEnvironment.current.restoreAuthenticationState()
173
+
}
174
+
175
+
/// Checks if the current session is valid and has non-expired tokens.
176
+
@APActor
177
+
public var hasValidSession: Bool {
178
+
if let state = APEnvironment.current.authState {
179
+
return !state.isAccessTokenExpired || state.canRefresh
180
+
}
181
+
if let login = APEnvironment.current.login {
182
+
return login.accessToken.valid || (login.refreshToken?.valid ?? false)
183
+
}
184
+
return APEnvironment.current.accessToken != nil
80
185
}
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
+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
+
}
+207
-6
Sources/CoreATProtocol/Models/ATError.swift
+207
-6
Sources/CoreATProtocol/Models/ATError.swift
···
5
5
// Created by Thomas Rademaker on 10/8/25.
6
6
//
7
7
8
-
public enum AtError: Error {
8
+
import Foundation
9
+
10
+
/// Top-level error type for AT Protocol operations.
11
+
public enum AtError: Error, Sendable {
12
+
/// An error message returned by the server.
9
13
case message(ErrorMessage)
14
+
15
+
/// A network-level error.
10
16
case network(NetworkError)
17
+
18
+
/// An OAuth/authentication error.
19
+
case oauth(OAuthError)
20
+
21
+
/// An identity resolution error.
22
+
case identity(IdentityError)
23
+
24
+
/// A decoding error.
25
+
case decoding(DecodingError)
26
+
27
+
/// An unknown error.
28
+
case unknown(Error)
11
29
}
12
30
31
+
extension AtError: LocalizedError {
32
+
public var errorDescription: String? {
33
+
switch self {
34
+
case .message(let msg):
35
+
return msg.message ?? msg.error
36
+
case .network(let err):
37
+
return err.localizedDescription
38
+
case .oauth(let err):
39
+
return err.localizedDescription
40
+
case .identity(let err):
41
+
return String(describing: err)
42
+
case .decoding(let err):
43
+
return err.localizedDescription
44
+
case .unknown(let err):
45
+
return err.localizedDescription
46
+
}
47
+
}
48
+
49
+
/// Returns true if this error indicates the user needs to re-authenticate.
50
+
public var requiresReauthentication: Bool {
51
+
switch self {
52
+
case .message(let msg):
53
+
return msg.errorType == .authenticationRequired ||
54
+
msg.errorType == .expiredToken ||
55
+
msg.errorType == .authMissing
56
+
case .network(let err):
57
+
if case .statusCode(let code, _) = err, code?.rawValue == 401 {
58
+
return true
59
+
}
60
+
return false
61
+
case .oauth(let err):
62
+
switch err {
63
+
case .accessTokenExpired, .refreshTokenExpired, .refreshTokenMissing:
64
+
return true
65
+
default:
66
+
return false
67
+
}
68
+
default:
69
+
return false
70
+
}
71
+
}
72
+
73
+
/// Returns true if this error might succeed if retried.
74
+
public var isRetryable: Bool {
75
+
switch self {
76
+
case .message(let msg):
77
+
return msg.errorType == .rateLimitExceeded
78
+
case .network(let err):
79
+
switch err {
80
+
case .statusCode(let code, _):
81
+
// 5xx errors and 429 are retryable
82
+
guard let status = code?.rawValue else { return false }
83
+
return status >= 500 || status == 429
84
+
case .tokenRefresh:
85
+
return true
86
+
default:
87
+
return false
88
+
}
89
+
default:
90
+
return false
91
+
}
92
+
}
93
+
}
94
+
95
+
/// Error message returned by AT Protocol servers.
13
96
public struct ErrorMessage: Codable, Sendable {
14
-
/// The error type as a string. Kept as String rather than AtErrorType
15
-
/// to handle unknown error types that the server may return.
97
+
/// The error code/type string.
16
98
public let error: String
99
+
100
+
/// Optional human-readable error message.
17
101
public let message: String?
18
-
102
+
19
103
public init(error: String, message: String?) {
20
104
self.error = error
21
105
self.message = message
22
106
}
107
+
108
+
/// Attempts to parse the error string as a known error type.
109
+
public var errorType: AtErrorType? {
110
+
AtErrorType(rawValue: error)
111
+
}
23
112
}
24
113
25
-
public enum AtErrorType: String, Codable, Sendable {
114
+
/// Known AT Protocol error types.
115
+
public enum AtErrorType: String, Codable, Sendable, CaseIterable {
116
+
// Authentication errors
26
117
case authenticationRequired = "AuthenticationRequired"
27
118
case expiredToken = "ExpiredToken"
119
+
case authMissing = "AuthMissing"
120
+
case invalidToken = "InvalidToken"
121
+
122
+
// Request errors
28
123
case invalidRequest = "InvalidRequest"
124
+
case invalidSwap = "InvalidSwap"
29
125
case methodNotImplemented = "MethodNotImplemented"
126
+
127
+
// Rate limiting
30
128
case rateLimitExceeded = "RateLimitExceeded"
31
-
case authMissing = "AuthMissing"
129
+
130
+
// Account errors
131
+
case accountTakedown = "AccountTakedown"
132
+
case accountSuspended = "AccountSuspended"
133
+
case accountDeactivated = "AccountDeactivated"
134
+
case accountNotFound = "AccountNotFound"
135
+
136
+
// Record errors
137
+
case recordNotFound = "RecordNotFound"
138
+
case repoNotFound = "RepoNotFound"
139
+
case blobNotFound = "BlobNotFound"
140
+
case blockNotFound = "BlockNotFound"
141
+
142
+
// Validation errors
143
+
case invalidHandle = "InvalidHandle"
144
+
case handleNotAvailable = "HandleNotAvailable"
145
+
case unsupportedDomain = "UnsupportedDomain"
146
+
case unresolvableDid = "UnresolvableDid"
147
+
148
+
// Blob errors
149
+
case blobTooLarge = "BlobTooLarge"
150
+
case invalidBlob = "InvalidBlob"
151
+
152
+
// Content errors
153
+
case duplicateCreate = "DuplicateCreate"
154
+
case unknownFeed = "UnknownFeed"
155
+
case unknownList = "UnknownList"
156
+
case notFound = "NotFound"
157
+
158
+
// Server errors
159
+
case upstreamFailure = "UpstreamFailure"
160
+
case upstreamTimeout = "UpstreamTimeout"
161
+
case internalServerError = "InternalServerError"
162
+
163
+
/// Human-readable description of the error type.
164
+
public var description: String {
165
+
switch self {
166
+
case .authenticationRequired: return "Authentication is required"
167
+
case .expiredToken: return "The access token has expired"
168
+
case .authMissing: return "Authentication credentials are missing"
169
+
case .invalidToken: return "The provided token is invalid"
170
+
case .invalidRequest: return "The request is invalid"
171
+
case .invalidSwap: return "The swap operation is invalid"
172
+
case .methodNotImplemented: return "This method is not implemented"
173
+
case .rateLimitExceeded: return "Rate limit exceeded"
174
+
case .accountTakedown: return "Account has been taken down"
175
+
case .accountSuspended: return "Account has been suspended"
176
+
case .accountDeactivated: return "Account has been deactivated"
177
+
case .accountNotFound: return "Account not found"
178
+
case .recordNotFound: return "Record not found"
179
+
case .repoNotFound: return "Repository not found"
180
+
case .blobNotFound: return "Blob not found"
181
+
case .blockNotFound: return "Block not found"
182
+
case .invalidHandle: return "The handle is invalid"
183
+
case .handleNotAvailable: return "The handle is not available"
184
+
case .unsupportedDomain: return "The domain is not supported"
185
+
case .unresolvableDid: return "The DID cannot be resolved"
186
+
case .blobTooLarge: return "The blob is too large"
187
+
case .invalidBlob: return "The blob is invalid"
188
+
case .duplicateCreate: return "A record with this key already exists"
189
+
case .unknownFeed: return "The feed is not known"
190
+
case .unknownList: return "The list is not known"
191
+
case .notFound: return "The resource was not found"
192
+
case .upstreamFailure: return "An upstream service failed"
193
+
case .upstreamTimeout: return "An upstream service timed out"
194
+
case .internalServerError: return "Internal server error"
195
+
}
196
+
}
197
+
}
198
+
199
+
/// Rate limit information from response headers.
200
+
public struct RateLimitInfo: Sendable {
201
+
/// Maximum number of requests allowed in the window.
202
+
public let limit: Int
203
+
204
+
/// Number of requests remaining in the current window.
205
+
public let remaining: Int
206
+
207
+
/// Unix timestamp when the rate limit resets.
208
+
public let resetTimestamp: TimeInterval
209
+
210
+
/// Date when the rate limit resets.
211
+
public var resetDate: Date {
212
+
Date(timeIntervalSince1970: resetTimestamp)
213
+
}
214
+
215
+
/// Time interval until the rate limit resets.
216
+
public var timeUntilReset: TimeInterval {
217
+
resetTimestamp - Date().timeIntervalSince1970
218
+
}
219
+
220
+
/// Parses rate limit information from HTTP response headers.
221
+
public static func from(response: HTTPURLResponse) -> RateLimitInfo? {
222
+
guard let limitStr = response.value(forHTTPHeaderField: "RateLimit-Limit"),
223
+
let remainingStr = response.value(forHTTPHeaderField: "RateLimit-Remaining"),
224
+
let resetStr = response.value(forHTTPHeaderField: "RateLimit-Reset"),
225
+
let limit = Int(limitStr),
226
+
let remaining = Int(remainingStr),
227
+
let reset = TimeInterval(resetStr) else {
228
+
return nil
229
+
}
230
+
231
+
return RateLimitInfo(limit: limit, remaining: remaining, resetTimestamp: reset)
232
+
}
32
233
}
+81
-3
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
+81
-3
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
···
1
+
import Foundation
2
+
3
+
/// Describes the type of HTTP task to perform.
1
4
public enum HTTPTask: Sendable {
5
+
/// A simple request with no body.
2
6
case request
3
-
7
+
8
+
/// A request with encoded parameters (URL query or JSON body).
4
9
case requestParameters(encoding: ParameterEncoding)
5
-
6
-
// case download, upload...etc
10
+
11
+
/// A blob upload request with raw data and content type.
12
+
case uploadBlob(data: Data, mimeType: String)
13
+
14
+
/// A multipart form data upload.
15
+
case uploadMultipart(parts: [MultipartFormData])
16
+
}
17
+
18
+
/// Represents a single part in a multipart form data request.
19
+
public struct MultipartFormData: Sendable {
20
+
/// The field name for this part.
21
+
public let name: String
22
+
23
+
/// The filename for file uploads (nil for regular fields).
24
+
public let filename: String?
25
+
26
+
/// The content type of this part.
27
+
public let mimeType: String?
28
+
29
+
/// The data for this part.
30
+
public let data: Data
31
+
32
+
/// Creates a text field part.
33
+
public static func field(name: String, value: String) -> MultipartFormData {
34
+
MultipartFormData(
35
+
name: name,
36
+
filename: nil,
37
+
mimeType: nil,
38
+
data: Data(value.utf8)
39
+
)
40
+
}
41
+
42
+
/// Creates a file upload part.
43
+
public static func file(name: String, filename: String, mimeType: String, data: Data) -> MultipartFormData {
44
+
MultipartFormData(
45
+
name: name,
46
+
filename: filename,
47
+
mimeType: mimeType,
48
+
data: data
49
+
)
50
+
}
51
+
52
+
public init(name: String, filename: String?, mimeType: String?, data: Data) {
53
+
self.name = name
54
+
self.filename = filename
55
+
self.mimeType = mimeType
56
+
self.data = data
57
+
}
58
+
}
59
+
60
+
/// Response from a blob upload operation.
61
+
public struct BlobUploadResponse: Codable, Sendable {
62
+
public let blob: BlobRef
63
+
64
+
public struct BlobRef: Codable, Sendable {
65
+
public let type: String
66
+
public let ref: BlobLink
67
+
public let mimeType: String
68
+
public let size: Int
69
+
70
+
enum CodingKeys: String, CodingKey {
71
+
case type = "$type"
72
+
case ref
73
+
case mimeType
74
+
case size
75
+
}
76
+
77
+
public struct BlobLink: Codable, Sendable {
78
+
public let link: String
79
+
80
+
enum CodingKeys: String, CodingKey {
81
+
case link = "$link"
82
+
}
83
+
}
84
+
}
7
85
}
+48
-164
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+48
-164
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
···
1
1
import Foundation
2
-
import JWTKit
3
-
import OAuthenticator
4
-
#if canImport(CryptoKit)
5
-
import CryptoKit
6
-
#else
7
-
import Crypto
8
-
#endif
9
2
10
-
@APActor
11
-
public protocol NetworkRouterDelegate: AnyObject {
3
+
/// Protocol for intercepting and handling network requests.
4
+
/// Implementations can be isolated to any actor since methods are async.
5
+
public protocol NetworkRouterDelegate: AnyObject, Sendable {
12
6
func intercept(_ request: inout URLRequest) async
13
7
func shouldRetry(error: Error, attempts: Int) async throws -> Bool
14
8
}
···
43
37
let networking: Networking
44
38
let urlSessionTaskDelegate: URLSessionTaskDelegate?
45
39
var decoder: JSONDecoder
46
-
private let dpopActor = DPoPRequestActor()
47
40
48
41
public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) {
49
42
if let networking = networking {
···
69
62
guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed }
70
63
await delegate?.intercept(&request)
71
64
72
-
let (data, response) = try await executeRequest(request)
65
+
let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate)
73
66
guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode }
74
67
switch httpResponse.statusCode {
75
68
case 200...299:
···
93
86
return try await execute(route, attempts: attempts + 1)
94
87
}
95
88
}
96
-
97
-
private func executeRequest(_ request: URLRequest) async throws -> (Data, URLResponse) {
98
-
if let accessToken = APEnvironment.current.accessToken,
99
-
let privateKey = APEnvironment.current.dpopPrivateKey,
100
-
let keys = APEnvironment.current.dpopKeys {
101
-
return try await dpopResponse(
102
-
for: request,
103
-
accessToken: accessToken,
104
-
privateKey: privateKey,
105
-
keys: keys
106
-
)
107
-
}
108
-
109
-
return try await networking.data(for: request, delegate: urlSessionTaskDelegate)
110
-
}
111
-
112
-
private func dpopResponse(
113
-
for request: URLRequest,
114
-
accessToken: String,
115
-
privateKey: ES256PrivateKey,
116
-
keys: JWTKeyCollection
117
-
) async throws -> (Data, URLResponse) {
118
-
let tokenHash = hashToken(accessToken)
119
-
let jwtGenerator: DPoPSigner.JWTGenerator = { params in
120
-
try await self.generateDPoPJWT(
121
-
params: params,
122
-
tokenHash: tokenHash,
123
-
privateKey: privateKey,
124
-
keys: keys
125
-
)
126
-
}
127
-
128
-
let responseProvider: URLResponseProvider = { request in
129
-
try await self.networking.data(for: request, delegate: nil)
130
-
}
131
-
132
-
return try await dpopActor.response(
133
-
request: request,
134
-
jwtGenerator: jwtGenerator,
135
-
token: accessToken,
136
-
tokenHash: tokenHash,
137
-
provider: responseProvider
138
-
)
139
-
}
140
-
141
-
private func generateDPoPJWT(
142
-
params: DPoPSigner.JWTParameters,
143
-
tokenHash: String,
144
-
privateKey: ES256PrivateKey,
145
-
keys: JWTKeyCollection
146
-
) async throws -> String {
147
-
let htu = stripQueryAndFragment(from: params.requestEndpoint)
148
-
let payload = DPoPRequestPayload(
149
-
htm: params.httpMethod,
150
-
htu: htu,
151
-
iat: .init(value: .now),
152
-
jti: .init(value: UUID().uuidString),
153
-
nonce: params.nonce,
154
-
ath: tokenHash
155
-
)
156
-
157
-
var header = JWTHeader()
158
-
header.typ = "dpop+jwt"
159
-
header.alg = "ES256"
160
-
161
-
if let keyParams = privateKey.parameters {
162
-
let xBase64URL = keyParams.x
163
-
.replacingOccurrences(of: "+", with: "-")
164
-
.replacingOccurrences(of: "/", with: "_")
165
-
.replacingOccurrences(of: "=", with: "")
166
-
let yBase64URL = keyParams.y
167
-
.replacingOccurrences(of: "+", with: "-")
168
-
.replacingOccurrences(of: "/", with: "_")
169
-
.replacingOccurrences(of: "=", with: "")
170
-
171
-
header.jwk = [
172
-
"kty": .string("EC"),
173
-
"crv": .string("P-256"),
174
-
"x": .string(xBase64URL),
175
-
"y": .string(yBase64URL)
176
-
]
177
-
}
178
-
179
-
return try await keys.sign(payload, header: header)
180
-
}
181
-
182
-
private func stripQueryAndFragment(from url: String) -> String {
183
-
let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1
184
-
let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1
185
-
186
-
let end: Int
187
-
if fragmentIndex == -1 {
188
-
end = queryIndex
189
-
} else if queryIndex == -1 {
190
-
end = fragmentIndex
191
-
} else {
192
-
end = min(fragmentIndex, queryIndex)
193
-
}
194
-
195
-
return end == -1 ? url : String(url.prefix(end))
196
-
}
197
-
198
-
private func hashToken(_ token: String) -> String {
199
-
let digest = SHA256.hash(data: Data(token.utf8))
200
-
return Data(digest).base64URLEncodedString()
201
-
}
202
89
203
90
func buildRequest(from route: Endpoint) async throws -> URLRequest {
204
-
91
+
205
92
var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path),
206
93
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
207
-
timeoutInterval: 10.0)
208
-
94
+
timeoutInterval: 30.0)
95
+
209
96
request.httpMethod = route.httpMethod.rawValue
210
97
do {
211
98
switch await route.task {
212
99
case .request:
213
100
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
214
101
await addAdditionalHeaders(route.headers, request: &request)
102
+
215
103
case .requestParameters(let parameterEncoding):
216
104
await addAdditionalHeaders(route.headers, request: &request)
217
105
try configureParameters(parameterEncoding: parameterEncoding, request: &request)
106
+
107
+
case .uploadBlob(let data, let mimeType):
108
+
request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
109
+
request.setValue(String(data.count), forHTTPHeaderField: "Content-Length")
110
+
request.httpBody = data
111
+
await addAdditionalHeaders(route.headers, request: &request)
112
+
113
+
case .uploadMultipart(let parts):
114
+
let boundary = "Boundary-\(UUID().uuidString)"
115
+
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
116
+
request.httpBody = buildMultipartBody(parts: parts, boundary: boundary)
117
+
await addAdditionalHeaders(route.headers, request: &request)
218
118
}
219
119
return request
220
120
} catch {
221
121
throw error
222
122
}
223
123
}
124
+
125
+
/// Builds a multipart form data body from parts.
126
+
private func buildMultipartBody(parts: [MultipartFormData], boundary: String) -> Data {
127
+
var body = Data()
128
+
let lineBreak = "\r\n"
129
+
130
+
for part in parts {
131
+
body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
132
+
133
+
if let filename = part.filename {
134
+
body.append("Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8)!)
135
+
} else {
136
+
body.append("Content-Disposition: form-data; name=\"\(part.name)\"\(lineBreak)".data(using: .utf8)!)
137
+
}
138
+
139
+
if let mimeType = part.mimeType {
140
+
body.append("Content-Type: \(mimeType)\(lineBreak)".data(using: .utf8)!)
141
+
}
142
+
143
+
body.append(lineBreak.data(using: .utf8)!)
144
+
body.append(part.data)
145
+
body.append(lineBreak.data(using: .utf8)!)
146
+
}
147
+
148
+
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8)!)
149
+
150
+
return body
151
+
}
224
152
225
153
private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws {
226
154
try parameterEncoding.encode(urlRequest: &request)
···
233
161
}
234
162
}
235
163
}
236
-
237
-
private struct DPoPRequestPayload: JWTPayload {
238
-
let htm: String
239
-
let htu: String
240
-
let iat: IssuedAtClaim
241
-
let jti: IDClaim
242
-
let nonce: String?
243
-
let ath: String?
244
-
245
-
func verify(using key: some JWTAlgorithm) throws {
246
-
// No additional verification needed for DPoP
247
-
}
248
-
}
249
-
250
-
private actor DPoPRequestActor {
251
-
private let signer = DPoPSigner()
252
-
253
-
func response(
254
-
request: URLRequest,
255
-
jwtGenerator: DPoPSigner.JWTGenerator,
256
-
token: String,
257
-
tokenHash: String,
258
-
provider: URLResponseProvider
259
-
) async throws -> (Data, URLResponse) {
260
-
try await signer.response(
261
-
isolation: self,
262
-
for: request,
263
-
using: jwtGenerator,
264
-
token: token,
265
-
tokenHash: tokenHash,
266
-
issuingServer: nil,
267
-
provider: provider
268
-
)
269
-
}
270
-
}
271
-
272
-
private extension Data {
273
-
func base64URLEncodedString() -> String {
274
-
base64EncodedString()
275
-
.replacingOccurrences(of: "+", with: "-")
276
-
.replacingOccurrences(of: "/", with: "_")
277
-
.replacingOccurrences(of: "=", with: "")
278
-
}
279
-
}
+1
-1
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
+1
-1
Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift
+203
-40
Sources/CoreATProtocol/Networking.swift
+203
-40
Sources/CoreATProtocol/Networking.swift
···
6
6
//
7
7
8
8
import Foundation
9
+
import CryptoKit
10
+
@preconcurrency import OAuthenticator
9
11
10
12
extension JSONDecoder {
13
+
/// A JSON decoder configured for AT Protocol date formats.
14
+
/// Supports ISO 8601 dates with fractional seconds and timezone.
11
15
public static var atDecoder: JSONDecoder {
12
-
let dateFormatter = DateFormatter()
13
-
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX"
14
-
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
15
-
dateFormatter.locale = Locale(identifier: "en_US")
16
-
17
16
let decoder = JSONDecoder()
18
17
decoder.keyDecodingStrategy = .convertFromSnakeCase
19
-
decoder.dateDecodingStrategy = .formatted(dateFormatter)
20
-
18
+
decoder.dateDecodingStrategy = .custom { decoder in
19
+
let container = try decoder.singleValueContainer()
20
+
let dateString = try container.decode(String.self)
21
+
22
+
// Try multiple date formats that AT Protocol APIs may return
23
+
let formatters = Self.atDateFormatters
24
+
25
+
for formatter in formatters {
26
+
if let date = formatter.date(from: dateString) {
27
+
return date
28
+
}
29
+
}
30
+
31
+
// Try ISO8601 with fractional seconds
32
+
let iso8601 = ISO8601DateFormatter()
33
+
iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
34
+
if let date = iso8601.date(from: dateString) {
35
+
return date
36
+
}
37
+
38
+
// Try without fractional seconds
39
+
iso8601.formatOptions = [.withInternetDateTime]
40
+
if let date = iso8601.date(from: dateString) {
41
+
return date
42
+
}
43
+
44
+
throw DecodingError.dataCorruptedError(
45
+
in: container,
46
+
debugDescription: "Cannot decode date string: \(dateString)"
47
+
)
48
+
}
49
+
21
50
return decoder
22
51
}
52
+
53
+
/// Date formatters for various AT Protocol date formats.
54
+
private static var atDateFormatters: [DateFormatter] {
55
+
let formats = [
56
+
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // With microseconds and timezone
57
+
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // With milliseconds and timezone
58
+
"yyyy-MM-dd'T'HH:mm:ss.SSSX", // With milliseconds and short timezone
59
+
"yyyy-MM-dd'T'HH:mm:ssXXXXX", // Without fractional seconds
60
+
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", // With Z timezone
61
+
"yyyy-MM-dd'T'HH:mm:ss'Z'" // Without fractional, with Z
62
+
]
63
+
64
+
return formats.map { format in
65
+
let formatter = DateFormatter()
66
+
formatter.dateFormat = format
67
+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
68
+
formatter.locale = Locale(identifier: "en_US_POSIX")
69
+
return formatter
70
+
}
71
+
}
23
72
}
24
73
74
+
/// Checks if enough time has passed since last fetch to allow a new request.
75
+
/// - Parameters:
76
+
/// - lastFetched: Unix timestamp of last fetch (0 means never fetched)
77
+
/// - timeLimit: Minimum seconds between fetches (default 1 hour)
78
+
/// - Returns: true if a new request should be performed
25
79
func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool {
26
80
guard lastFetched != 0 else { return true }
27
81
let currentTime = Date.now
28
82
let lastFetchTime = Date(timeIntervalSince1970: lastFetched)
29
-
guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
30
-
return differenceInMinutes >= timeLimit
83
+
guard let differenceInSeconds = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
84
+
return differenceInSeconds >= timeLimit
31
85
}
32
86
33
87
@APActor
34
88
public class APRouterDelegate: NetworkRouterDelegate {
35
-
private var shouldRefreshToken = false
36
-
private var refreshTask: Task<Bool, Error>?
37
-
38
-
public func intercept(_ request: inout URLRequest) async {
39
-
if APEnvironment.current.dpopPrivateKey != nil {
89
+
/// Maximum retry attempts for token refresh.
90
+
private let maxRefreshAttempts = 2
91
+
92
+
public init() {}
93
+
94
+
nonisolated public func intercept(_ request: inout URLRequest) async {
95
+
// Try DPoP-authenticated request first (preferred for AT Protocol)
96
+
if let generator = await APEnvironment.current.dpopProofGenerator,
97
+
let login = await APEnvironment.current.login {
98
+
let token = login.accessToken.value
99
+
let tokenHash = await tokenHash(for: token)
100
+
let signer = await APEnvironment.current.resourceDPoPSigner
101
+
await MainActor.run {
102
+
signer.nonce = nil
103
+
}
104
+
let nonce = await APEnvironment.current.resourceServerNonce
105
+
await MainActor.run {
106
+
signer.nonce = nonce
107
+
}
108
+
109
+
do {
110
+
try await signer.authenticateRequest(
111
+
&request,
112
+
isolation: MainActor.shared,
113
+
using: generator,
114
+
token: token,
115
+
tokenHash: tokenHash,
116
+
issuer: login.issuingServer
117
+
)
118
+
} catch {
119
+
// If DPoP signing fails, fall back to providing the token directly.
120
+
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
121
+
}
122
+
40
123
return
41
124
}
42
125
43
-
if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken {
44
-
shouldRefreshToken = false
45
-
request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
46
-
} else if let accessToken = APEnvironment.current.accessToken {
126
+
// Fall back to simple Bearer token authentication
127
+
if let accessToken = await APEnvironment.current.accessToken {
47
128
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
48
129
}
49
130
}
50
-
51
-
public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
52
-
func refreshViaOAuth() async throws -> Bool {
53
-
guard let handler = APEnvironment.current.tokenRefreshHandler else {
54
-
return false
55
-
}
131
+
132
+
nonisolated public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
133
+
// Don't retry more than maxRefreshAttempts times
134
+
guard attempts <= maxRefreshAttempts else { return false }
135
+
136
+
// Check if the error indicates we need to refresh the token
137
+
let shouldAttemptRefresh = isTokenExpiredError(error)
56
138
57
-
if let refreshTask {
58
-
return try await refreshTask.value
59
-
}
139
+
guard shouldAttemptRefresh else { return false }
60
140
61
-
let task = Task { try await handler() }
62
-
refreshTask = task
141
+
// Attempt token refresh
142
+
let refreshed = await performTokenRefresh()
63
143
64
-
defer { refreshTask = nil }
144
+
return refreshed
145
+
}
65
146
66
-
return try await task.value
147
+
/// Determines if an error indicates the token has expired and needs refresh.
148
+
nonisolated private func isTokenExpiredError(_ error: Error) -> Bool {
149
+
// Check for 401 Unauthorized status code
150
+
if case .network(let networkError) = error as? AtError,
151
+
case .statusCode(let statusCode, _) = networkError,
152
+
statusCode?.rawValue == 401 {
153
+
return true
67
154
}
68
155
69
-
if attempts == 1,
70
-
case .network(let networkError) = error as? AtError,
71
-
case .statusCode(let statusCode, _) = networkError,
72
-
let statusCode = statusCode?.rawValue,
73
-
statusCode == 401 || statusCode == 403 {
74
-
return try await refreshViaOAuth()
156
+
// Check for explicit expired token error message
157
+
if case .message(let message) = error as? AtError,
158
+
message.error == AtErrorType.expiredToken.rawValue {
159
+
return true
75
160
}
76
161
162
+
// Check for authentication required error
77
163
if case .message(let message) = error as? AtError,
78
-
message.error == AtErrorType.expiredToken.rawValue,
79
-
attempts == 1 {
80
-
return try await refreshViaOAuth()
164
+
message.error == AtErrorType.authenticationRequired.rawValue {
165
+
return true
81
166
}
82
167
83
168
return false
169
+
}
170
+
171
+
/// Performs token refresh using the configured OAuth settings.
172
+
nonisolated private func performTokenRefresh() async -> Bool {
173
+
let env = await APEnvironment.current
174
+
175
+
// Try using the authState-based refresh first
176
+
if await env.authState != nil {
177
+
return await env.performTokenRefresh()
178
+
}
179
+
180
+
// Fall back to OAuthenticator's refresh if we have a login with refresh token
181
+
guard let login = await env.login,
182
+
let refreshToken = login.refreshToken,
183
+
refreshToken.valid else {
184
+
return false
185
+
}
186
+
187
+
guard let serverMetadata = await env.serverMetadata,
188
+
let clientId = await env.clientId else {
189
+
return false
190
+
}
191
+
192
+
// Use RefreshService for the actual refresh
193
+
let refreshService = await RefreshService()
194
+
195
+
// Create an AuthenticationState from the current login if we don't have one
196
+
let state = AuthenticationState(
197
+
did: login.issuingServer ?? "",
198
+
handle: nil,
199
+
pdsURL: await env.host ?? "",
200
+
authServerURL: serverMetadata.issuer,
201
+
accessToken: login.accessToken.value,
202
+
accessTokenExpiry: login.accessToken.expiry,
203
+
refreshToken: refreshToken.value,
204
+
refreshTokenExpiry: refreshToken.expiry,
205
+
scope: login.scopes,
206
+
dpopPrivateKeyData: nil
207
+
)
208
+
209
+
do {
210
+
let newState = try await refreshService.refresh(
211
+
state: state,
212
+
serverMetadata: serverMetadata,
213
+
clientId: clientId,
214
+
dpopGenerator: await env.dpopProofGenerator
215
+
)
216
+
217
+
// Update the environment
218
+
await updateEnvironmentWithNewTokens(newState)
219
+
220
+
return true
221
+
} catch {
222
+
print("Token refresh failed: \(error)")
223
+
return false
224
+
}
225
+
}
226
+
227
+
/// Updates the environment with refreshed tokens.
228
+
private func updateEnvironmentWithNewTokens(_ state: AuthenticationState) async {
229
+
APEnvironment.current.accessToken = state.accessToken
230
+
APEnvironment.current.refreshToken = state.refreshToken
231
+
APEnvironment.current.authState = state
232
+
233
+
// Update login object if present
234
+
if var login = APEnvironment.current.login {
235
+
login.accessToken = Token(value: state.accessToken, expiry: state.accessTokenExpiry)
236
+
if let newRefresh = state.refreshToken {
237
+
login.refreshToken = Token(value: newRefresh, expiry: state.refreshTokenExpiry)
238
+
}
239
+
APEnvironment.current.login = login
240
+
}
241
+
}
242
+
243
+
/// Computes SHA-256 hash of the access token for DPoP `ath` claim.
244
+
nonisolated private func tokenHash(for token: String) -> String {
245
+
let digest = SHA256.hash(data: Data(token.utf8))
246
+
return Data(digest).base64URLEncodedString()
84
247
}
85
248
}
+263
Sources/CoreATProtocol/OAuth/ATClientMetadata.swift
+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
+
}
+87
-2
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
+87
-2
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
···
1
1
import Testing
2
+
import Foundation
2
3
@testable import CoreATProtocol
3
4
4
-
@Test func example() async throws {
5
-
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
5
+
@Suite("CoreATProtocol Environment Tests", .serialized)
6
+
struct CoreATProtocolTests {
7
+
8
+
@Test("Environment singleton is accessible")
9
+
func testEnvironmentSingleton() async {
10
+
// Clear state first
11
+
await clearAuthenticationContext()
12
+
13
+
// Just verify we can access the singleton
14
+
let host = await APEnvironment.current.host
15
+
#expect(host == nil) // Default state should have nil host
16
+
}
17
+
18
+
@Test("Setup configures environment correctly")
19
+
func testSetup() async {
20
+
// Clear previous state
21
+
await clearAuthenticationContext()
22
+
23
+
await setup(
24
+
hostURL: "https://bsky.social",
25
+
accessJWT: "test-access",
26
+
refreshJWT: "test-refresh"
27
+
)
28
+
29
+
let host = await APEnvironment.current.host
30
+
let access = await APEnvironment.current.accessToken
31
+
let refresh = await APEnvironment.current.refreshToken
32
+
33
+
#expect(host == "https://bsky.social")
34
+
#expect(access == "test-access")
35
+
#expect(refresh == "test-refresh")
36
+
37
+
// Clean up
38
+
await clearAuthenticationContext()
39
+
}
40
+
41
+
@Test("Clear authentication context removes all tokens")
42
+
func testClearContext() async {
43
+
await setup(
44
+
hostURL: "https://test.social",
45
+
accessJWT: "access",
46
+
refreshJWT: "refresh"
47
+
)
48
+
49
+
await clearAuthenticationContext()
50
+
51
+
let access = await APEnvironment.current.accessToken
52
+
let refresh = await APEnvironment.current.refreshToken
53
+
let login = await APEnvironment.current.login
54
+
55
+
#expect(access == nil)
56
+
#expect(refresh == nil)
57
+
#expect(login == nil)
58
+
}
59
+
60
+
@Test("Update tokens modifies existing tokens")
61
+
func testUpdateTokens() async {
62
+
await setup(hostURL: nil, accessJWT: "old-access", refreshJWT: "old-refresh")
63
+
await updateTokens(access: "new-access", refresh: "new-refresh")
64
+
65
+
let access = await APEnvironment.current.accessToken
66
+
let refresh = await APEnvironment.current.refreshToken
67
+
68
+
#expect(access == "new-access")
69
+
#expect(refresh == "new-refresh")
70
+
71
+
await clearAuthenticationContext()
72
+
}
73
+
74
+
@Test("DPoP nonce update works correctly")
75
+
func testDPoPNonceUpdate() async {
76
+
await updateResourceDPoPNonce("test-nonce-123")
77
+
78
+
let nonce = await APEnvironment.current.resourceServerNonce
79
+
80
+
#expect(nonce == "test-nonce-123")
81
+
82
+
await updateResourceDPoPNonce(nil)
83
+
}
84
+
85
+
@Test("hasValidSession returns false when no session")
86
+
func testNoValidSession() async {
87
+
await clearAuthenticationContext()
88
+
let valid = await hasValidSession
89
+
#expect(valid == false)
90
+
}
6
91
}
+51
Tests/CoreATProtocolTests/DPoPJWTGeneratorTests.swift
+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
+
}