-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
+12
Documentation.docc/Info.plist
+12
Documentation.docc/Info.plist
···
1
+
<?xml version="1.0" encoding="UTF-8"?>
2
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+
<plist version="1.0">
4
+
<dict>
5
+
<key>CFBundleDevelopmentRegion</key>
6
+
<string>en</string>
7
+
<key>CFBundleIdentifier</key>
8
+
<string>com.sparrowtek.coreatprotocol.documentation</string>
9
+
<key>CFBundleName</key>
10
+
<string>CoreATProtocol Documentation</string>
11
+
</dict>
12
+
</plist>
+204
Documentation.docc/OAuthIntegrationGuide.md
+204
Documentation.docc/OAuthIntegrationGuide.md
···
1
+
# OAuth Integration on iOS
2
+
3
+
@Metadata {
4
+
@Abstract(
5
+
"Step-by-step instructions for adopting CoreATProtocol's bespoke OAuth implementation inside an iOS app."
6
+
)
7
+
}
8
+
9
+
## Overview
10
+
11
+
CoreATProtocol ships with an actor-isolated `OAuthManager` that performs the AT Protocol OAuth 2.1 flow, including PAR, PKCE, and DPoP. On iOS you combine the manager with a Keychain-backed credential store and an `ASWebAuthenticationSession`-based UI provider. After configuration, API calls issued through CoreATProtocol automatically receive DPoP-bound authorization headers via `APRouterDelegate`.
12
+
13
+
The sections below walk through the recommended wiring for production iOS apps.
14
+
15
+
## Prerequisites
16
+
17
+
- Xcode 16 or later with Swift 6.
18
+
- A Bluesky/AT Protocol OAuth client metadata document hosted at a stable HTTPS URL.
19
+
- A custom URL scheme registered in your app to receive the OAuth redirect.
20
+
- Familiarity with Keychain Services and Swift concurrency.
21
+
22
+
## Step 1: Configure the Package
23
+
24
+
Add CoreATProtocol as a Swift Package dependency in Xcode. Ensure your iOS target links against CoreATProtocol and imports it inside the app module.
25
+
26
+
```swift
27
+
import CoreATProtocol
28
+
```
29
+
30
+
## Step 2: Provide a Credential Store
31
+
32
+
CoreATProtocol exposes `OAuthCredentialStore`. On iOS you typically persist credentials in the Keychain. Implement a store that conforms to the protocol and registers it with strong protections (biometric prompts are optional but encouraged).
33
+
34
+
```swift
35
+
import CoreATProtocol
36
+
import Security
37
+
38
+
actor KeychainCredentialStore: OAuthCredentialStore {
39
+
private enum Item {
40
+
static let account = "com.sparrowtek.coreatprotocol.oauth"
41
+
static let sessionKey = "session"
42
+
static let dpopKey = "dpop-key"
43
+
}
44
+
45
+
func loadSession() async throws -> OAuthSession? {
46
+
guard let data = try read(key: Item.sessionKey) else { return nil }
47
+
return try JSONDecoder().decode(OAuthSession.self, from: data)
48
+
}
49
+
50
+
func save(session: OAuthSession) async throws {
51
+
let data = try JSONEncoder().encode(session)
52
+
try write(data, key: Item.sessionKey)
53
+
}
54
+
55
+
func deleteSession() async throws {
56
+
try delete(key: Item.sessionKey)
57
+
}
58
+
59
+
func loadDPoPKey() async throws -> Data? {
60
+
try read(key: Item.dpopKey)
61
+
}
62
+
63
+
func saveDPoPKey(_ data: Data) async throws {
64
+
try write(data, key: Item.dpopKey)
65
+
}
66
+
67
+
func deleteDPoPKey() async throws {
68
+
try delete(key: Item.dpopKey)
69
+
}
70
+
71
+
// MARK: - Helpers
72
+
73
+
private func read(key: String) throws -> Data? { /* Keychain lookup */ }
74
+
private func write(_ data: Data, key: String) throws { /* Keychain add/update */ }
75
+
private func delete(key: String) throws { /* Keychain delete */ }
76
+
}
77
+
```
78
+
79
+
Persist both the serialized `OAuthSession` and the raw DPoP private key so refreshes survive app restarts.
80
+
81
+
## Step 3: Create an OAuth UI Provider
82
+
83
+
Provide an `OAuthUIProvider` that wraps `ASWebAuthenticationSession` and routes callbacks back into the manager.
84
+
85
+
```swift
86
+
import AuthenticationServices
87
+
import CoreATProtocol
88
+
89
+
final class WebAuthenticationProvider: NSObject, OAuthUIProvider {
90
+
private let presentationAnchor: ASPresentationAnchor
91
+
92
+
init(anchor: ASPresentationAnchor) {
93
+
self.presentationAnchor = anchor
94
+
}
95
+
96
+
func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL {
97
+
try await withCheckedThrowingContinuation { continuation in
98
+
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in
99
+
if let error { continuation.resume(throwing: error) }
100
+
else if let callbackURL { continuation.resume(returning: callbackURL) }
101
+
else { continuation.resume(throwing: OAuthManagerError.authorizationCancelled) }
102
+
}
103
+
session.presentationContextProvider = self
104
+
session.prefersEphemeralWebBrowserSession = true
105
+
session.start()
106
+
}
107
+
}
108
+
}
109
+
110
+
extension WebAuthenticationProvider: ASWebAuthenticationPresentationContextProviding {
111
+
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
112
+
presentationAnchor
113
+
}
114
+
}
115
+
```
116
+
117
+
## Step 4: Configure CoreATProtocol at Launch
118
+
119
+
Set up the OAuth manager once the app knows its client metadata URL and redirect URI. The helper below is typically invoked from an `@MainActor` app coordinator.
120
+
121
+
```swift
122
+
@MainActor
123
+
func configureCoreATProtocol() async {
124
+
let metadataURL = URL(string: "https://example.com/oauth/client-metadata.json")!
125
+
let redirectURI = URL(string: "myapp://oauth/callback")!
126
+
let configuration = OAuthConfiguration(
127
+
clientMetadataURL: metadataURL,
128
+
redirectURI: redirectURI
129
+
)
130
+
131
+
let credentialStore = KeychainCredentialStore()
132
+
try await CoreATProtocol.configureOAuth(configuration: configuration, credentialStore: credentialStore)
133
+
}
134
+
```
135
+
136
+
Once configured, all `NetworkRouter` instances created by CoreATProtocol use the shared `APRouterDelegate`, which injects DPoP headers and handles nonce/token refreshes automatically.
137
+
138
+
## Step 5: Initiate Authentication
139
+
140
+
Trigger the OAuth flow when the user picks a Bluesky handle. The manager resolves the handle to a DID, performs PAR, presents the auth session, exchanges codes, and caches the resulting `OAuthSession`.
141
+
142
+
```swift
143
+
@MainActor
144
+
func signIn(handle: String, anchor: ASPresentationAnchor) async {
145
+
do {
146
+
let provider = WebAuthenticationProvider(anchor: anchor)
147
+
let session = try await CoreATProtocol.authenticate(handle: handle, using: provider)
148
+
// Persist any additional app state and transition UI
149
+
print("Authenticated DID: \(session.did)")
150
+
} catch {
151
+
// Present user-friendly errors or retry guidance
152
+
print("OAuth failed: \(error)")
153
+
}
154
+
}
155
+
```
156
+
157
+
For returning users, call `CoreATProtocol.currentOAuthSession()` to check if a session already exists, and `CoreATProtocol.refreshOAuthSession()` to proactively refresh tokens.
158
+
159
+
## Step 6: Make Authenticated Requests
160
+
161
+
After authentication, issue XRPC calls through CoreATProtocol normally. The router delegate supplies `Authorization: DPoP <token>` and a matching `DPoP` proof. Nonce challenges (`use_dpop_nonce`) and 401 responses automatically trigger a retry or refresh.
162
+
163
+
```swift
164
+
@MainActor
165
+
func loadProfile() async throws -> ActorProfile {
166
+
// Example: using a CoreATProtocol service client
167
+
let service = try await SomeServiceClient()
168
+
return try await service.fetchProfile()
169
+
}
170
+
```
171
+
172
+
If you maintain your own URL sessions, route them through CoreATProtocol or call `OAuthManager.authenticateResourceRequest(_:)` manually to attach the DPoP header before sending.
173
+
174
+
## Step 7: Handle Sign-Out
175
+
176
+
When the user signs out, remove the stored session and DPoP key to enforce a clean re-authentication.
177
+
178
+
```swift
179
+
@MainActor
180
+
func signOut() async {
181
+
do {
182
+
try await CoreATProtocol.signOutOAuth()
183
+
} catch {
184
+
assertionFailure("Failed to sign out cleanly: \(error)")
185
+
}
186
+
}
187
+
```
188
+
189
+
You may also want to revoke tokens via the OAuth revocation endpoint once the server exposes it.
190
+
191
+
## Step 8: Testing and Diagnostics
192
+
193
+
- Use dependency injection to swap `IdentityResolver`, `OAuthHTTPClient`, and `OAuthCredentialStore` with mocks for unit tests.
194
+
- Exercise the new Swift Testing cases in `Tests/CoreATProtocolTests` to verify PKCE, DPoP, and session expiry logic after future changes.
195
+
- Capture and log `WWW-Authenticate` headers during development to monitor nonce churn.
196
+
197
+
## Troubleshooting
198
+
199
+
| Symptom | Suggested Fix |
200
+
| --- | --- |
201
+
| `authorization_in_progress` errors | Ensure `beginAuthorization` is not called twice in parallel. Await `resumeAuthorization` before retrying. |
202
+
| `invalid_redirect_uri` | Confirm the redirect URI in the client metadata exactly matches the one passed to `OAuthConfiguration`. |
203
+
| `use_dpop_nonce` loops | Inspect your networking stack for caching; DPoP proof URLs must not contain query fragments. |
204
+
| Token refresh failing after app relaunch | Verify the Keychain store persists both the session JSON and the raw DPoP key. |
-21
LICENSE
-21
LICENSE
···
1
-
MIT License
2
-
3
-
Copyright (c) 2026 SparrowTek
4
-
5
-
Permission is hereby granted, free of charge, to any person obtaining a copy
6
-
of this software and associated documentation files (the "Software"), to deal
7
-
in the Software without restriction, including without limitation the rights
8
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
-
copies of the Software, and to permit persons to whom the Software is
10
-
furnished to do so, subject to the following conditions:
11
-
12
-
The above copyright notice and this permission notice shall be included in all
13
-
copies or substantial portions of the Software.
14
-
15
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
-
SOFTWARE.
-60
Package.resolved
-60
Package.resolved
···
1
-
{
2
-
"originHash" : "46681c90ffb61eca5269d3e2ab8743c6f802287641f8bccf7c47227aa7a6a97a",
3
-
"pins" : [
4
-
{
5
-
"identity" : "jwt-kit",
6
-
"kind" : "remoteSourceControl",
7
-
"location" : "https://github.com/vapor/jwt-kit.git",
8
-
"state" : {
9
-
"revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c",
10
-
"version" : "5.3.0"
11
-
}
12
-
},
13
-
{
14
-
"identity" : "oauthenticator",
15
-
"kind" : "remoteSourceControl",
16
-
"location" : "https://github.com/radmakr/OAuthenticator.git",
17
-
"state" : {
18
-
"branch" : "CoreAtProtocol",
19
-
"revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70"
20
-
}
21
-
},
22
-
{
23
-
"identity" : "swift-asn1",
24
-
"kind" : "remoteSourceControl",
25
-
"location" : "https://github.com/apple/swift-asn1.git",
26
-
"state" : {
27
-
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
28
-
"version" : "1.5.1"
29
-
}
30
-
},
31
-
{
32
-
"identity" : "swift-certificates",
33
-
"kind" : "remoteSourceControl",
34
-
"location" : "https://github.com/apple/swift-certificates.git",
35
-
"state" : {
36
-
"revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130",
37
-
"version" : "1.17.0"
38
-
}
39
-
},
40
-
{
41
-
"identity" : "swift-crypto",
42
-
"kind" : "remoteSourceControl",
43
-
"location" : "https://github.com/apple/swift-crypto.git",
44
-
"state" : {
45
-
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
46
-
"version" : "4.2.0"
47
-
}
48
-
},
49
-
{
50
-
"identity" : "swift-log",
51
-
"kind" : "remoteSourceControl",
52
-
"location" : "https://github.com/apple/swift-log.git",
53
-
"state" : {
54
-
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
55
-
"version" : "1.8.0"
56
-
}
57
-
}
58
-
],
59
-
"version" : 3
60
-
}
+6
-21
Package.swift
+6
-21
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(
···
17
17
targets: ["CoreATProtocol"]
18
18
),
19
19
],
20
-
dependencies: [
21
-
// Using fork with fix for WebAuthenticationSession platform guards
22
-
// PR pending at https://github.com/ChimeHQ/OAuthenticator
23
-
// .package(url: "https://github.com/ChimeHQ/OAuthenticator.git", branch: "main"),
24
-
.package(url: "https://github.com/radmakr/OAuthenticator.git", branch: "CoreAtProtocol"),
25
-
// .package(path: "../OAuthenticator"),
26
-
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"),
27
-
],
28
20
targets: [
29
21
.target(
30
-
name: "CoreATProtocol",
31
-
dependencies: [
32
-
"OAuthenticator",
33
-
.product(name: "JWTKit", package: "jwt-kit"),
34
-
],
35
-
swiftSettings: [
36
-
.enableExperimentalFeature("StrictConcurrency")
37
-
]
22
+
name: "CoreATProtocol"
38
23
),
39
24
.testTarget(
40
25
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).
+5
-5
Sources/CoreATProtocol/APEnvironment.swift
+5
-5
Sources/CoreATProtocol/APEnvironment.swift
···
5
5
// Created by Thomas Rademaker on 10/10/25.
6
6
//
7
7
8
-
import JWTKit
9
-
10
8
@APActor
11
9
public class APEnvironment {
12
10
public static var current: APEnvironment = APEnvironment()
···
15
13
public var accessToken: String?
16
14
public var refreshToken: String?
17
15
public var atProtocoldelegate: CoreATProtocolDelegate?
18
-
public var tokenRefreshHandler: (@Sendable () async throws -> Bool)?
19
-
public var dpopPrivateKey: ES256PrivateKey?
20
-
public var dpopKeys: JWTKeyCollection?
21
16
public let routerDelegate = APRouterDelegate()
17
+
public var oauthManager: OAuthManager? {
18
+
didSet {
19
+
routerDelegate.oauthManager = oauthManager
20
+
}
21
+
}
22
22
23
23
private init() {}
24
24
+37
-50
Sources/CoreATProtocol/CoreATProtocol.swift
+37
-50
Sources/CoreATProtocol/CoreATProtocol.swift
···
1
1
// The Swift Programming Language
2
2
// https://docs.swift.org/swift-book
3
3
4
-
import JWTKit
5
-
6
-
// MARK: - Session
7
-
8
-
/// Represents an authenticated AT Protocol session
9
-
public struct Session: Sendable, Codable, Hashable {
10
-
public let did: String
11
-
public let handle: String
12
-
public let email: String?
13
-
public let accessJwt: String?
14
-
public let refreshJwt: String?
15
-
16
-
public init(did: String, handle: String, email: String? = nil, accessJwt: String? = nil, refreshJwt: String? = nil) {
17
-
self.did = did
18
-
self.handle = handle
19
-
self.email = email
20
-
self.accessJwt = accessJwt
21
-
self.refreshJwt = refreshJwt
22
-
}
23
-
}
24
-
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
30
-
}
31
-
32
-
// Default implementation for optional method
33
-
public extension CoreATProtocolDelegate {
34
-
func sessionUpdated(_ session: Session) async {}
35
-
}
4
+
public protocol CoreATProtocolDelegate: AnyObject {}
36
5
37
6
@APActor
38
7
public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) {
···
48
17
}
49
18
50
19
@APActor
51
-
public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) {
52
-
APEnvironment.current.tokenRefreshHandler = handler
20
+
public func updateTokens(access: String?, refresh: String?) {
21
+
APEnvironment.current.accessToken = access
22
+
APEnvironment.current.refreshToken = refresh
23
+
}
24
+
25
+
@APActor
26
+
public func update(hostURL: String?) {
27
+
APEnvironment.current.host = hostURL
28
+
}
29
+
30
+
@APActor
31
+
public func configureOAuth(
32
+
configuration: OAuthConfiguration,
33
+
credentialStore: OAuthCredentialStore? = nil
34
+
) async throws {
35
+
let store = credentialStore ?? InMemoryOAuthCredentialStore()
36
+
let manager = try await OAuthManager(configuration: configuration, credentialStore: store)
37
+
APEnvironment.current.oauthManager = manager
53
38
}
54
39
55
40
@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
41
+
public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession {
42
+
guard let manager = APEnvironment.current.oauthManager else {
43
+
throw OAuthManagerError.invalidAuthorizationState
61
44
}
45
+
let session = try await manager.authenticate(handle: handle, using: uiProvider)
46
+
APEnvironment.current.host = session.pdsURL.absoluteString
47
+
return session
48
+
}
62
49
63
-
let privateKey = try ES256PrivateKey(pem: pem)
64
-
let keys = JWTKeyCollection()
65
-
await keys.add(ecdsa: privateKey)
66
-
67
-
APEnvironment.current.dpopPrivateKey = privateKey
68
-
APEnvironment.current.dpopKeys = keys
50
+
@APActor
51
+
public func currentOAuthSession() -> OAuthSession? {
52
+
APEnvironment.current.oauthManager?.currentSession
69
53
}
70
54
71
55
@APActor
72
-
public func updateTokens(access: String?, refresh: String?) {
73
-
APEnvironment.current.accessToken = access
74
-
APEnvironment.current.refreshToken = refresh
56
+
public func refreshOAuthSession() async throws -> OAuthSession {
57
+
guard let manager = APEnvironment.current.oauthManager else {
58
+
throw OAuthManagerError.invalidAuthorizationState
59
+
}
60
+
return try await manager.refreshSession()
75
61
}
76
62
77
63
@APActor
78
-
public func update(hostURL: String?) {
79
-
APEnvironment.current.host = hostURL
64
+
public func signOutOAuth() async throws {
65
+
guard let manager = APEnvironment.current.oauthManager else { return }
66
+
try await manager.signOut()
80
67
}
+1
-2
Sources/CoreATProtocol/Models/ATError.swift
+1
-2
Sources/CoreATProtocol/Models/ATError.swift
···
11
11
}
12
12
13
13
public struct ErrorMessage: Codable, Sendable {
14
-
/// The error type as a string. Kept as String rather than AtErrorType
15
-
/// to handle unknown error types that the server may return.
14
+
#warning("Should error be type string or AtErrorType?")
16
15
public let error: String
17
16
public let message: String?
18
17
+7
-159
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+7
-159
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
3
@APActor
11
4
public protocol NetworkRouterDelegate: AnyObject {
12
5
func intercept(_ request: inout URLRequest) async
13
6
func shouldRetry(error: Error, attempts: Int) async throws -> Bool
7
+
func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async
8
+
}
9
+
10
+
extension NetworkRouterDelegate {
11
+
public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {}
14
12
}
15
13
16
14
/// Describes the implementation details of a NetworkRouter
···
43
41
let networking: Networking
44
42
let urlSessionTaskDelegate: URLSessionTaskDelegate?
45
43
var decoder: JSONDecoder
46
-
private let dpopActor = DPoPRequestActor()
47
44
48
45
public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) {
49
46
if let networking = networking {
···
69
66
guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed }
70
67
await delegate?.intercept(&request)
71
68
72
-
let (data, response) = try await executeRequest(request)
69
+
let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate)
73
70
guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode }
71
+
await delegate?.didReceive(response: httpResponse, data: data, for: request)
74
72
switch httpResponse.statusCode {
75
73
case 200...299:
76
74
return try decoder.decode(T.self, from: data)
···
93
91
return try await execute(route, attempts: attempts + 1)
94
92
}
95
93
}
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
94
203
95
func buildRequest(from route: Endpoint) async throws -> URLRequest {
204
96
···
233
125
}
234
126
}
235
127
}
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
+86
-31
Sources/CoreATProtocol/Networking.swift
+86
-31
Sources/CoreATProtocol/Networking.swift
···
31
31
}
32
32
33
33
@APActor
34
-
public class APRouterDelegate: NetworkRouterDelegate {
35
-
private var shouldRefreshToken = false
36
-
private var refreshTask: Task<Bool, Error>?
37
-
34
+
public final class APRouterDelegate: NetworkRouterDelegate {
35
+
public var oauthManager: OAuthManager? {
36
+
didSet { pendingRetryAction = .none }
37
+
}
38
+
39
+
private enum RetryAction {
40
+
case none
41
+
case refreshToken
42
+
case regenerateDPoP
43
+
}
44
+
45
+
private var pendingRetryAction: RetryAction = .none
46
+
38
47
public func intercept(_ request: inout URLRequest) async {
39
-
if APEnvironment.current.dpopPrivateKey != nil {
40
-
return
48
+
if let manager = oauthManager {
49
+
do {
50
+
try await manager.authenticateResourceRequest(&request)
51
+
return
52
+
} catch {
53
+
// Fall back to legacy bearer injection if OAuth authentication fails.
54
+
}
41
55
}
42
56
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 {
57
+
if let accessToken = APEnvironment.current.accessToken {
47
58
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
48
59
}
49
60
}
50
-
61
+
51
62
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
63
+
if let manager = oauthManager {
64
+
switch pendingRetryAction {
65
+
case .regenerateDPoP where attempts < 3:
66
+
pendingRetryAction = .none
67
+
return true
68
+
case .refreshToken:
69
+
pendingRetryAction = .none
70
+
do {
71
+
_ = try await manager.refreshSession(force: true)
72
+
return true
73
+
} catch {
74
+
return false
75
+
}
76
+
default:
77
+
pendingRetryAction = .none
55
78
}
79
+
}
56
80
57
-
if let refreshTask {
58
-
return try await refreshTask.value
59
-
}
81
+
if case .message(let message) = error as? AtError,
82
+
message.error == AtErrorType.expiredToken.rawValue {
83
+
return false
84
+
}
85
+
86
+
return false
87
+
}
60
88
61
-
let task = Task { try await handler() }
62
-
refreshTask = task
89
+
public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {
90
+
guard let manager = oauthManager else { return }
63
91
64
-
defer { refreshTask = nil }
92
+
if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false {
93
+
await manager.updateResourceServerNonce(nonce)
94
+
}
65
95
66
-
return try await task.value
96
+
guard (400..<500).contains(response.statusCode) else {
97
+
pendingRetryAction = .none
98
+
return
67
99
}
68
100
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()
101
+
if containsUseDPoPNonce(response: response, data: data) {
102
+
pendingRetryAction = .regenerateDPoP
103
+
return
75
104
}
76
105
77
-
if case .message(let message) = error as? AtError,
78
-
message.error == AtErrorType.expiredToken.rawValue,
79
-
attempts == 1 {
80
-
return try await refreshViaOAuth()
106
+
if containsInvalidToken(response: response, data: data) {
107
+
pendingRetryAction = .refreshToken
108
+
return
81
109
}
82
110
111
+
pendingRetryAction = .none
112
+
}
113
+
114
+
private func containsUseDPoPNonce(response: HTTPURLResponse, data: Data) -> Bool {
115
+
if header(response, containsError: "use_dpop_nonce") {
116
+
return true
117
+
}
118
+
if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data),
119
+
errorResponse.error == "use_dpop_nonce" {
120
+
return true
121
+
}
83
122
return false
123
+
}
124
+
125
+
private func containsInvalidToken(response: HTTPURLResponse, data: Data) -> Bool {
126
+
if header(response, containsError: "invalid_token") {
127
+
return true
128
+
}
129
+
if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data),
130
+
errorResponse.error == "invalid_token" {
131
+
return true
132
+
}
133
+
return false
134
+
}
135
+
136
+
private func header(_ response: HTTPURLResponse, containsError token: String) -> Bool {
137
+
guard let header = response.value(forHTTPHeaderField: "WWW-Authenticate") else { return false }
138
+
return header.range(of: "error=\"\(token)\"", options: .caseInsensitive) != nil || header.range(of: "error=\(token)", options: .caseInsensitive) != nil
84
139
}
85
140
}
-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
-
}
+58
Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift
+58
Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift
···
1
+
import Foundation
2
+
3
+
enum DNSResolverError: Error, Sendable {
4
+
case invalidResponse
5
+
}
6
+
7
+
protocol DNSResolving: Sendable {
8
+
func txtRecords(for host: String) async throws -> [String]
9
+
}
10
+
11
+
@APActor
12
+
final class DoHDNSResolver: DNSResolving {
13
+
private let baseURL: URL
14
+
private let httpClient: OAuthHTTPClient
15
+
16
+
init(baseURL: URL = URL(string: "https://cloudflare-dns.com/dns-query")!, httpClient: OAuthHTTPClient = OAuthHTTPClient()) {
17
+
self.baseURL = baseURL
18
+
self.httpClient = httpClient
19
+
}
20
+
21
+
func txtRecords(for host: String) async throws -> [String] {
22
+
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
23
+
throw DNSResolverError.invalidResponse
24
+
}
25
+
var queryItems = components.queryItems ?? []
26
+
queryItems.append(URLQueryItem(name: "name", value: host))
27
+
queryItems.append(URLQueryItem(name: "type", value: "TXT"))
28
+
components.queryItems = queryItems
29
+
guard let url = components.url else { throw DNSResolverError.invalidResponse }
30
+
var request = URLRequest(url: url)
31
+
request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
32
+
let (data, _) = try await httpClient.send(request)
33
+
let response = try httpClient.decodeJSON(DNSResponse.self, from: data)
34
+
return response.answers?.compactMap { $0.txtValue } ?? []
35
+
}
36
+
37
+
private struct DNSResponse: Decodable {
38
+
struct Answer: Decodable {
39
+
let data: String
40
+
41
+
var txtValue: String? {
42
+
guard data.count >= 2 else { return nil }
43
+
var trimmed = data
44
+
if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") {
45
+
trimmed.removeFirst()
46
+
trimmed.removeLast()
47
+
}
48
+
return trimmed
49
+
}
50
+
}
51
+
52
+
private enum CodingKeys: String, CodingKey {
53
+
case answers = "Answer"
54
+
}
55
+
56
+
let answers: [Answer]?
57
+
}
58
+
}
+134
Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift
+134
Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift
···
1
+
import Foundation
2
+
3
+
enum IdentityResolverError: Error, Sendable {
4
+
case unableToResolveHandle
5
+
case invalidDID
6
+
case unsupportedDIDMethod
7
+
case missingPDSService
8
+
}
9
+
10
+
@APActor
11
+
final class IdentityResolver: Sendable {
12
+
private let httpClient: OAuthHTTPClient
13
+
private let dnsResolver: DNSResolving
14
+
15
+
init(httpClient: OAuthHTTPClient = OAuthHTTPClient(), dnsResolver: DNSResolving = DoHDNSResolver()) {
16
+
self.httpClient = httpClient
17
+
self.dnsResolver = dnsResolver
18
+
}
19
+
20
+
func resolveHandle(_ handle: String) async throws -> String {
21
+
if handle.lowercased().hasPrefix("did:") {
22
+
return handle
23
+
}
24
+
25
+
if let did = try? await resolveViaHTTPS(handle: handle) {
26
+
return did
27
+
}
28
+
29
+
if let did = try? await resolveViaDNS(handle: handle) {
30
+
return did
31
+
}
32
+
33
+
throw IdentityResolverError.unableToResolveHandle
34
+
}
35
+
36
+
func fetchDIDDocument(for did: String) async throws -> DIDDocument {
37
+
if did.hasPrefix("did:plc:") {
38
+
guard let encodedDID = did.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
39
+
let url = URL(string: "https://plc.directory/\(encodedDID)") else {
40
+
throw IdentityResolverError.invalidDID
41
+
}
42
+
return try await fetchJSON(url: url, type: DIDDocument.self)
43
+
} else if did.hasPrefix("did:web:") {
44
+
let components = try webDIDComponents(did: did)
45
+
return try await fetchJSON(url: components.url, type: DIDDocument.self)
46
+
} else {
47
+
throw IdentityResolverError.unsupportedDIDMethod
48
+
}
49
+
}
50
+
51
+
func discoverProtectedResource(for pdsURL: URL) async throws -> OAuthProtectedResourceMetadata {
52
+
let endpoint = pdsURL.appendingPathComponent(".well-known/oauth-protected-resource")
53
+
return try await fetchJSON(url: endpoint, type: OAuthProtectedResourceMetadata.self)
54
+
}
55
+
56
+
func fetchAuthorizationServerMetadata(from url: URL) async throws -> OAuthAuthorizationServerMetadata {
57
+
let endpoint = url.appendingPathComponent(".well-known/oauth-authorization-server")
58
+
return try await fetchJSON(url: endpoint, type: OAuthAuthorizationServerMetadata.self)
59
+
}
60
+
61
+
func extractPDSEndpoint(from document: DIDDocument) throws -> URL {
62
+
guard let service = document.service(ofType: "AtprotoPersonalDataServer"), let url = URL(string: service.serviceEndpoint) else {
63
+
throw IdentityResolverError.missingPDSService
64
+
}
65
+
return url
66
+
}
67
+
68
+
// MARK: - Private
69
+
70
+
private func resolveViaHTTPS(handle: String) async throws -> String? {
71
+
var components = URLComponents()
72
+
components.scheme = "https"
73
+
components.host = handle
74
+
components.path = "/.well-known/atproto-did"
75
+
guard let url = components.url else { return nil }
76
+
var request = URLRequest(url: url)
77
+
request.timeoutInterval = 5
78
+
let (data, response) = try await httpClient.send(request)
79
+
guard (200..<300).contains(response.statusCode) else { return nil }
80
+
let did = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
81
+
guard did.isEmpty == false, did.lowercased().hasPrefix("did:") else { return nil }
82
+
return did
83
+
}
84
+
85
+
private func resolveViaDNS(handle: String) async throws -> String? {
86
+
let hostname = "_atproto.\(handle)"
87
+
let records = try await dnsResolver.txtRecords(for: hostname)
88
+
for record in records {
89
+
let parts = record.split(separator: "=", maxSplits: 1).map(String.init)
90
+
if parts.count == 2, parts[0] == "did" {
91
+
return parts[1]
92
+
}
93
+
}
94
+
return nil
95
+
}
96
+
97
+
private func fetchJSON<T: Decodable>(url: URL, type: T.Type) async throws -> T {
98
+
var request = URLRequest(url: url)
99
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
100
+
let (data, response) = try await httpClient.send(request)
101
+
guard (200..<300).contains(response.statusCode) else {
102
+
throw IdentityResolverError.invalidDID
103
+
}
104
+
return try httpClient.decodeJSON(T.self, from: data)
105
+
}
106
+
107
+
private func webDIDComponents(did: String) throws -> (host: String, pathSegments: [String], url: URL) {
108
+
let prefix = "did:web:"
109
+
guard did.hasPrefix(prefix) else { throw IdentityResolverError.invalidDID }
110
+
let suffix = String(did.dropFirst(prefix.count))
111
+
let segments = suffix.split(separator: ":").map { segment in
112
+
segment.removingPercentEncoding ?? String(segment)
113
+
}
114
+
guard let host = segments.first else {
115
+
throw IdentityResolverError.invalidDID
116
+
}
117
+
let pathSegments = Array(segments.dropFirst())
118
+
var components = URLComponents()
119
+
components.scheme = "https"
120
+
components.host = host
121
+
let path: String
122
+
if pathSegments.isEmpty {
123
+
path = "/.well-known/did.json"
124
+
} else {
125
+
let joined = pathSegments.joined(separator: "/")
126
+
path = "/\(joined)/did.json"
127
+
}
128
+
components.path = path
129
+
guard let url = components.url else {
130
+
throw IdentityResolverError.invalidDID
131
+
}
132
+
return (host, pathSegments, url)
133
+
}
134
+
}
-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
-
}
+47
Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift
+47
Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift
···
1
+
import Foundation
2
+
3
+
struct DIDDocument: Decodable, Sendable {
4
+
struct Service: Decodable, Sendable {
5
+
let id: String
6
+
let type: String
7
+
let serviceEndpoint: String
8
+
9
+
private enum CodingKeys: String, CodingKey {
10
+
case id
11
+
case type
12
+
case serviceEndpoint
13
+
}
14
+
15
+
init(from decoder: Decoder) throws {
16
+
let container = try decoder.container(keyedBy: CodingKeys.self)
17
+
self.id = try container.decode(String.self, forKey: .id)
18
+
self.type = try container.decode(String.self, forKey: .type)
19
+
if let endpoint = try? container.decode(String.self, forKey: .serviceEndpoint) {
20
+
self.serviceEndpoint = endpoint
21
+
} else if let endpointObject = try? container.decode(ServiceEndpoint.self, forKey: .serviceEndpoint) {
22
+
guard let uri = endpointObject.uri else {
23
+
throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Missing uri field in service endpoint object")
24
+
}
25
+
self.serviceEndpoint = uri
26
+
} else {
27
+
throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Unsupported service endpoint type")
28
+
}
29
+
}
30
+
31
+
private struct ServiceEndpoint: Decodable {
32
+
let uri: String?
33
+
}
34
+
}
35
+
36
+
let id: String
37
+
let services: [Service]
38
+
39
+
private enum CodingKeys: String, CodingKey {
40
+
case id
41
+
case services = "service"
42
+
}
43
+
44
+
func service(ofType type: String) -> Service? {
45
+
services.first { $0.type.localizedCaseInsensitiveCompare(type) == .orderedSame }
46
+
}
47
+
}
+30
Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift
+30
Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift
···
1
+
import Foundation
2
+
3
+
public struct OAuthConfiguration: Sendable {
4
+
public let clientMetadataURL: URL
5
+
public let redirectURI: URL
6
+
public let requestedScopes: [String]
7
+
public let additionalAuthorizationParameters: [String: String]
8
+
9
+
public init(
10
+
clientMetadataURL: URL,
11
+
redirectURI: URL,
12
+
requestedScopes: [String] = ["atproto"],
13
+
additionalAuthorizationParameters: [String: String] = [:]
14
+
) {
15
+
self.clientMetadataURL = clientMetadataURL
16
+
self.redirectURI = redirectURI
17
+
var scopes = requestedScopes
18
+
if scopes.isEmpty {
19
+
scopes = ["atproto"]
20
+
} else if scopes.contains("atproto") == false {
21
+
scopes.append("atproto")
22
+
}
23
+
var uniqueScopes: [String] = []
24
+
for scope in scopes where uniqueScopes.contains(scope) == false {
25
+
uniqueScopes.append(scope)
26
+
}
27
+
self.requestedScopes = uniqueScopes
28
+
self.additionalAuthorizationParameters = additionalAuthorizationParameters
29
+
}
30
+
}
+160
Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift
+160
Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift
···
1
+
import Foundation
2
+
3
+
struct OAuthProtectedResourceMetadata: Decodable, Sendable {
4
+
let authorizationServers: [URL]
5
+
6
+
private enum CodingKeys: String, CodingKey {
7
+
case authorizationServers
8
+
}
9
+
10
+
init(from decoder: Decoder) throws {
11
+
let container = try decoder.container(keyedBy: CodingKeys.self)
12
+
let values = try container.decodeIfPresent([String].self, forKey: .authorizationServers) ?? []
13
+
self.authorizationServers = try values.map { value in
14
+
guard let url = URL(string: value) else {
15
+
throw DecodingError.dataCorruptedError(forKey: .authorizationServers, in: container, debugDescription: "Invalid authorization server URL")
16
+
}
17
+
return url
18
+
}
19
+
}
20
+
}
21
+
22
+
struct OAuthAuthorizationServerMetadata: Decodable, Sendable {
23
+
let issuer: URL
24
+
let authorizationEndpoint: URL
25
+
let tokenEndpoint: URL
26
+
let pushedAuthorizationRequestEndpoint: URL
27
+
let codeChallengeMethodsSupported: [String]
28
+
let dPoPSigningAlgValuesSupported: [String]
29
+
let scopesSupported: [String]
30
+
31
+
private enum CodingKeys: String, CodingKey {
32
+
case issuer
33
+
case authorizationEndpoint
34
+
case tokenEndpoint
35
+
case pushedAuthorizationRequestEndpoint
36
+
case codeChallengeMethodsSupported
37
+
case dPoPSigningAlgValuesSupported = "dpopSigningAlgValuesSupported"
38
+
case scopesSupported
39
+
}
40
+
41
+
init(from decoder: Decoder) throws {
42
+
let container = try decoder.container(keyedBy: CodingKeys.self)
43
+
guard let issuer = URL(string: try container.decode(String.self, forKey: .issuer)) else {
44
+
throw DecodingError.dataCorruptedError(forKey: .issuer, in: container, debugDescription: "Invalid issuer URL")
45
+
}
46
+
guard let authorizationEndpoint = URL(string: try container.decode(String.self, forKey: .authorizationEndpoint)) else {
47
+
throw DecodingError.dataCorruptedError(forKey: .authorizationEndpoint, in: container, debugDescription: "Invalid authorization endpoint")
48
+
}
49
+
guard let tokenEndpoint = URL(string: try container.decode(String.self, forKey: .tokenEndpoint)) else {
50
+
throw DecodingError.dataCorruptedError(forKey: .tokenEndpoint, in: container, debugDescription: "Invalid token endpoint")
51
+
}
52
+
guard let parEndpoint = URL(string: try container.decode(String.self, forKey: .pushedAuthorizationRequestEndpoint)) else {
53
+
throw DecodingError.dataCorruptedError(forKey: .pushedAuthorizationRequestEndpoint, in: container, debugDescription: "Invalid PAR endpoint")
54
+
}
55
+
56
+
self.issuer = issuer
57
+
self.authorizationEndpoint = authorizationEndpoint
58
+
self.tokenEndpoint = tokenEndpoint
59
+
self.pushedAuthorizationRequestEndpoint = parEndpoint
60
+
self.codeChallengeMethodsSupported = try container.decodeIfPresent([String].self, forKey: .codeChallengeMethodsSupported) ?? []
61
+
self.dPoPSigningAlgValuesSupported = try container.decodeIfPresent([String].self, forKey: .dPoPSigningAlgValuesSupported) ?? []
62
+
self.scopesSupported = try container.decodeIfPresent([String].self, forKey: .scopesSupported) ?? []
63
+
}
64
+
}
65
+
66
+
struct OAuthClientMetadata: Decodable, Sendable {
67
+
let clientID: URL
68
+
let scope: String
69
+
let redirectURIs: [URL]
70
+
let grantTypes: [String]
71
+
let responseTypes: [String]
72
+
let tokenEndpointAuthMethod: String
73
+
let tokenEndpointAuthSigningAlg: String?
74
+
let dPoPBoundAccessTokens: Bool
75
+
76
+
private enum CodingKeys: String, CodingKey {
77
+
case clientID = "clientId"
78
+
case scope
79
+
case redirectURIs = "redirectUris"
80
+
case grantTypes
81
+
case responseTypes
82
+
case tokenEndpointAuthMethod
83
+
case tokenEndpointAuthSigningAlg
84
+
case dPoPBoundAccessTokens = "dpopBoundAccessTokens"
85
+
}
86
+
87
+
init(from decoder: Decoder) throws {
88
+
let container = try decoder.container(keyedBy: CodingKeys.self)
89
+
guard let clientID = URL(string: try container.decode(String.self, forKey: .clientID)) else {
90
+
throw DecodingError.dataCorruptedError(forKey: .clientID, in: container, debugDescription: "Invalid client metadata URL")
91
+
}
92
+
self.clientID = clientID
93
+
self.scope = try container.decode(String.self, forKey: .scope)
94
+
let redirectStrings = try container.decode([String].self, forKey: .redirectURIs)
95
+
self.redirectURIs = try redirectStrings.map { value in
96
+
guard let url = URL(string: value) else {
97
+
throw DecodingError.dataCorruptedError(forKey: .redirectURIs, in: container, debugDescription: "Invalid redirect URI")
98
+
}
99
+
return url
100
+
}
101
+
self.grantTypes = try container.decode([String].self, forKey: .grantTypes)
102
+
self.responseTypes = try container.decode([String].self, forKey: .responseTypes)
103
+
self.tokenEndpointAuthMethod = try container.decode(String.self, forKey: .tokenEndpointAuthMethod)
104
+
self.tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg)
105
+
self.dPoPBoundAccessTokens = try container.decode(Bool.self, forKey: .dPoPBoundAccessTokens)
106
+
}
107
+
}
108
+
109
+
struct OAuthTokenResponse: Decodable, Sendable {
110
+
let accessToken: String
111
+
let refreshToken: String?
112
+
let tokenType: String
113
+
let expiresIn: TimeInterval?
114
+
let scope: String?
115
+
let issuedTokenType: String?
116
+
let subject: String?
117
+
118
+
private enum CodingKeys: String, CodingKey {
119
+
case accessToken = "access_token"
120
+
case refreshToken = "refresh_token"
121
+
case tokenType = "token_type"
122
+
case expiresIn = "expires_in"
123
+
case scope
124
+
case issuedTokenType = "issued_token_type"
125
+
case subject = "sub"
126
+
}
127
+
}
128
+
129
+
struct PushedAuthorizationRequestResponse: Decodable, Sendable {
130
+
let requestURI: String
131
+
let expiresIn: Int
132
+
133
+
private enum CodingKeys: String, CodingKey {
134
+
case requestURI = "request_uri"
135
+
case expiresIn = "expires_in"
136
+
}
137
+
}
138
+
139
+
struct OAuthErrorResponse: Decodable, Error, Sendable {
140
+
let error: String
141
+
let errorDescription: String?
142
+
let errorURI: URL?
143
+
144
+
private enum CodingKeys: String, CodingKey {
145
+
case error
146
+
case errorDescription = "error_description"
147
+
case errorURI = "error_uri"
148
+
}
149
+
150
+
init(from decoder: Decoder) throws {
151
+
let container = try decoder.container(keyedBy: CodingKeys.self)
152
+
self.error = try container.decode(String.self, forKey: .error)
153
+
self.errorDescription = try container.decodeIfPresent(String.self, forKey: .errorDescription)
154
+
if let raw = try container.decodeIfPresent(String.self, forKey: .errorURI) {
155
+
self.errorURI = URL(string: raw)
156
+
} else {
157
+
self.errorURI = nil
158
+
}
159
+
}
160
+
}
+53
Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift
+53
Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift
···
1
+
import Foundation
2
+
3
+
public struct OAuthSession: Codable, Sendable {
4
+
public let did: String
5
+
public let pdsURL: URL
6
+
public let authorizationServer: URL
7
+
public let tokenEndpoint: URL
8
+
public let accessToken: String
9
+
public let refreshToken: String
10
+
public let tokenType: String
11
+
public let scope: String?
12
+
public let expiresIn: TimeInterval?
13
+
public let issuedAt: Date
14
+
15
+
public init(
16
+
did: String,
17
+
pdsURL: URL,
18
+
authorizationServer: URL,
19
+
tokenEndpoint: URL,
20
+
accessToken: String,
21
+
refreshToken: String,
22
+
tokenType: String,
23
+
scope: String?,
24
+
expiresIn: TimeInterval?,
25
+
issuedAt: Date
26
+
) {
27
+
self.did = did
28
+
self.pdsURL = pdsURL
29
+
self.authorizationServer = authorizationServer
30
+
self.tokenEndpoint = tokenEndpoint
31
+
self.accessToken = accessToken
32
+
self.refreshToken = refreshToken
33
+
self.tokenType = tokenType
34
+
self.scope = scope
35
+
self.expiresIn = expiresIn
36
+
self.issuedAt = issuedAt
37
+
}
38
+
39
+
public func isExpired(relativeTo date: Date = Date(), tolerance: TimeInterval = 0) -> Bool {
40
+
guard let expiresAt else { return false }
41
+
return expiresAt <= date.addingTimeInterval(tolerance * -1)
42
+
}
43
+
44
+
public func needsRefresh(relativeTo date: Date = Date(), threshold: TimeInterval = 300) -> Bool {
45
+
guard let expiresAt else { return false }
46
+
return expiresAt <= date.addingTimeInterval(threshold)
47
+
}
48
+
49
+
public var expiresAt: Date? {
50
+
guard let expiresIn else { return nil }
51
+
return issuedAt.addingTimeInterval(expiresIn)
52
+
}
53
+
}
+34
Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift
+34
Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift
···
1
+
import Foundation
2
+
3
+
enum OAuthNetworkingError: Error, Sendable {
4
+
case invalidResponse
5
+
}
6
+
7
+
@APActor
8
+
final class OAuthHTTPClient: Sendable {
9
+
private let networking: Networking
10
+
private let jsonDecoder: JSONDecoder
11
+
12
+
init(networking: Networking = URLSession.shared, decoder: JSONDecoder? = nil) {
13
+
self.networking = networking
14
+
if let decoder {
15
+
self.jsonDecoder = decoder
16
+
} else {
17
+
let decoder = JSONDecoder()
18
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
19
+
self.jsonDecoder = decoder
20
+
}
21
+
}
22
+
23
+
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
24
+
let (data, response) = try await networking.data(for: request, delegate: nil)
25
+
guard let httpResponse = response as? HTTPURLResponse else {
26
+
throw OAuthNetworkingError.invalidResponse
27
+
}
28
+
return (data, httpResponse)
29
+
}
30
+
31
+
func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
32
+
try jsonDecoder.decode(T.self, from: data)
33
+
}
34
+
}
+507
Sources/CoreATProtocol/OAuth/OAuthManager.swift
+507
Sources/CoreATProtocol/OAuth/OAuthManager.swift
···
1
+
import Foundation
2
+
3
+
public enum OAuthManagerError: Error, Sendable {
4
+
case missingAuthorizationServer
5
+
case invalidAuthorizationState
6
+
case authorizationInProgress
7
+
case callbackStateMismatch
8
+
case authorizationCancelled
9
+
case tokenExchangeFailed
10
+
case refreshFailed
11
+
case invalidRedirectURL
12
+
case unsupportedAuthorizationServer
13
+
case clientMetadataValidationFailed
14
+
case identityResolutionFailed
15
+
case missingSession
16
+
case invalidRequest
17
+
}
18
+
19
+
public struct AuthorizationRequest: Sendable {
20
+
public let authorizationURL: URL
21
+
public let redirectURI: URL
22
+
}
23
+
24
+
@APActor
25
+
public final class OAuthManager: Sendable {
26
+
private let configuration: OAuthConfiguration
27
+
private let credentialStore: OAuthCredentialStore
28
+
private let identityResolver: IdentityResolver
29
+
private let httpClient: OAuthHTTPClient
30
+
private let randomGenerator: RandomDataGenerating
31
+
private var dpopGenerator: DPoPGenerator
32
+
33
+
private var cachedClientMetadata: OAuthClientMetadata?
34
+
private var pendingAuthorization: PendingAuthorization?
35
+
private var cachedSession: OAuthSession?
36
+
private var authorizationServerNonce: String?
37
+
private var resourceServerNonce: String?
38
+
39
+
init(
40
+
configuration: OAuthConfiguration,
41
+
credentialStore: OAuthCredentialStore,
42
+
identityResolver: IdentityResolver = IdentityResolver(),
43
+
httpClient: OAuthHTTPClient = OAuthHTTPClient(),
44
+
randomGenerator: RandomDataGenerating = SecureRandomDataGenerator()
45
+
) async throws {
46
+
self.configuration = configuration
47
+
self.credentialStore = credentialStore
48
+
self.identityResolver = identityResolver
49
+
self.httpClient = httpClient
50
+
self.randomGenerator = randomGenerator
51
+
52
+
if let keyData = try await credentialStore.loadDPoPKey(),
53
+
(try? DPoPKeyPair(rawRepresentation: keyData)) != nil {
54
+
self.dpopGenerator = DPoPGenerator(keyPair: try DPoPKeyPair(rawRepresentation: keyData))
55
+
} else {
56
+
let keyPair = DPoPKeyPair()
57
+
self.dpopGenerator = DPoPGenerator(keyPair: keyPair)
58
+
try await credentialStore.saveDPoPKey(keyPair.export())
59
+
}
60
+
61
+
self.cachedSession = try await credentialStore.loadSession()
62
+
}
63
+
64
+
public convenience init(
65
+
configuration: OAuthConfiguration,
66
+
credentialStore: OAuthCredentialStore
67
+
) async throws {
68
+
try await self.init(
69
+
configuration: configuration,
70
+
credentialStore: credentialStore,
71
+
identityResolver: IdentityResolver(),
72
+
httpClient: OAuthHTTPClient(),
73
+
randomGenerator: SecureRandomDataGenerator()
74
+
)
75
+
}
76
+
77
+
public var currentSession: OAuthSession? {
78
+
cachedSession
79
+
}
80
+
81
+
public func authenticateResourceRequest(_ request: inout URLRequest) async throws {
82
+
guard let url = request.url else { throw OAuthManagerError.invalidRequest }
83
+
guard var session = cachedSession else { throw OAuthManagerError.missingSession }
84
+
85
+
if session.needsRefresh() {
86
+
session = try await refreshSession(force: true)
87
+
}
88
+
89
+
let proof = try dpopGenerator.generateProof(
90
+
method: request.httpMethod ?? "GET",
91
+
url: url,
92
+
nonce: resourceServerNonce,
93
+
accessToken: session.accessToken
94
+
)
95
+
96
+
request.setValue("DPoP \(session.accessToken)", forHTTPHeaderField: "Authorization")
97
+
request.setValue(proof, forHTTPHeaderField: "DPoP")
98
+
}
99
+
100
+
public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession {
101
+
let request = try await beginAuthorization(for: handle)
102
+
guard let callbackScheme = configuration.redirectURI.scheme else {
103
+
throw OAuthManagerError.invalidRedirectURL
104
+
}
105
+
let callbackURL = try await uiProvider.presentAuthorization(at: request.authorizationURL, callbackScheme: callbackScheme)
106
+
return try await resumeAuthorization(from: callbackURL)
107
+
}
108
+
109
+
public func beginAuthorization(for handle: String) async throws -> AuthorizationRequest {
110
+
guard pendingAuthorization == nil else { throw OAuthManagerError.authorizationInProgress }
111
+
112
+
let did = try await identityResolver.resolveHandle(handle)
113
+
let didDocument = try await identityResolver.fetchDIDDocument(for: did)
114
+
let pdsEndpoint = try identityResolver.extractPDSEndpoint(from: didDocument)
115
+
116
+
let protectedMetadata = try await identityResolver.discoverProtectedResource(for: pdsEndpoint)
117
+
guard let authorizationServerURL = protectedMetadata.authorizationServers.first else {
118
+
throw OAuthManagerError.missingAuthorizationServer
119
+
}
120
+
let authMetadata = try await identityResolver.fetchAuthorizationServerMetadata(from: authorizationServerURL)
121
+
try validateAuthorizationServerMetadata(authMetadata)
122
+
123
+
let clientMetadata = try await loadClientMetadata()
124
+
125
+
let pkce = try PKCEGenerator(randomGenerator: randomGenerator).makeValues()
126
+
let state = try generateState()
127
+
128
+
let parResult = try await performPushedAuthorizationRequest(
129
+
metadata: authMetadata,
130
+
clientMetadata: clientMetadata,
131
+
handle: handle,
132
+
did: did,
133
+
pkce: pkce,
134
+
state: state
135
+
)
136
+
137
+
authorizationServerNonce = parResult.nonce ?? authorizationServerNonce
138
+
139
+
let authorizationURL = makeAuthorizationURL(
140
+
endpoint: authMetadata.authorizationEndpoint,
141
+
clientID: clientMetadata.clientID,
142
+
requestURI: parResult.requestURI
143
+
)
144
+
145
+
pendingAuthorization = PendingAuthorization(
146
+
handle: handle,
147
+
did: did,
148
+
pdsURL: pdsEndpoint,
149
+
authorizationServerMetadata: authMetadata,
150
+
clientMetadata: clientMetadata,
151
+
state: state,
152
+
pkce: pkce,
153
+
requestURI: parResult.requestURI,
154
+
issuedAt: Date()
155
+
)
156
+
157
+
return AuthorizationRequest(authorizationURL: authorizationURL, redirectURI: configuration.redirectURI)
158
+
}
159
+
160
+
public func resumeAuthorization(from callbackURL: URL) async throws -> OAuthSession {
161
+
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
162
+
throw OAuthManagerError.invalidRedirectURL
163
+
}
164
+
guard let pending = pendingAuthorization else { throw OAuthManagerError.invalidAuthorizationState }
165
+
defer { pendingAuthorization = nil }
166
+
167
+
if components.scheme != configuration.redirectURI.scheme {
168
+
throw OAuthManagerError.invalidRedirectURL
169
+
}
170
+
171
+
let queryItems = components.queryItems ?? []
172
+
if queryItems.contains(where: { $0.name == "error" }) {
173
+
throw OAuthManagerError.tokenExchangeFailed
174
+
}
175
+
176
+
guard let state = queryItems.first(where: { $0.name == "state" })?.value, state == pending.state else {
177
+
throw OAuthManagerError.callbackStateMismatch
178
+
}
179
+
180
+
guard let code = queryItems.first(where: { $0.name == "code" })?.value else {
181
+
throw OAuthManagerError.tokenExchangeFailed
182
+
}
183
+
184
+
let tokenResponse = try await exchangeAuthorizationCode(
185
+
code: code,
186
+
pending: pending
187
+
)
188
+
189
+
guard let subject = tokenResponse.subject, subject == pending.did else {
190
+
throw OAuthManagerError.identityResolutionFailed
191
+
}
192
+
193
+
guard let refreshToken = tokenResponse.refreshToken, refreshToken.isEmpty == false else {
194
+
throw OAuthManagerError.tokenExchangeFailed
195
+
}
196
+
197
+
let session = OAuthSession(
198
+
did: pending.did,
199
+
pdsURL: pending.pdsURL,
200
+
authorizationServer: pending.authorizationServerMetadata.issuer,
201
+
tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint,
202
+
accessToken: tokenResponse.accessToken,
203
+
refreshToken: refreshToken,
204
+
tokenType: tokenResponse.tokenType,
205
+
scope: tokenResponse.scope,
206
+
expiresIn: tokenResponse.expiresIn,
207
+
issuedAt: Date()
208
+
)
209
+
try await store(session: session)
210
+
resourceServerNonce = nil
211
+
return session
212
+
}
213
+
214
+
public func refreshSession(force: Bool = false) async throws -> OAuthSession {
215
+
guard let session = cachedSession else { throw OAuthManagerError.refreshFailed }
216
+
if !force, session.needsRefresh() == false {
217
+
return session
218
+
}
219
+
220
+
let refreshed = try await performRefresh(session: session)
221
+
try await store(session: refreshed)
222
+
return refreshed
223
+
}
224
+
225
+
public func signOut() async throws {
226
+
cachedSession = nil
227
+
pendingAuthorization = nil
228
+
authorizationServerNonce = nil
229
+
resourceServerNonce = nil
230
+
cachedClientMetadata = nil
231
+
try await credentialStore.deleteSession()
232
+
APEnvironment.current.accessToken = nil
233
+
APEnvironment.current.refreshToken = nil
234
+
APEnvironment.current.host = nil
235
+
}
236
+
237
+
// MARK: - Nonce Management
238
+
239
+
public func updateAuthorizationServerNonce(_ nonce: String?) async {
240
+
authorizationServerNonce = nonce
241
+
}
242
+
243
+
public func updateResourceServerNonce(_ nonce: String?) async {
244
+
resourceServerNonce = nonce
245
+
}
246
+
247
+
public func currentResourceServerNonce() -> String? {
248
+
resourceServerNonce
249
+
}
250
+
251
+
public func currentAuthorizationServerNonce() -> String? {
252
+
authorizationServerNonce
253
+
}
254
+
255
+
// MARK: - Private helpers
256
+
257
+
private func loadClientMetadata() async throws -> OAuthClientMetadata {
258
+
if let metadata = cachedClientMetadata {
259
+
return metadata
260
+
}
261
+
262
+
var request = URLRequest(url: configuration.clientMetadataURL)
263
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
264
+
let (data, response) = try await httpClient.send(request)
265
+
guard (200..<300).contains(response.statusCode) else {
266
+
throw OAuthManagerError.clientMetadataValidationFailed
267
+
}
268
+
let metadata = try httpClient.decodeJSON(OAuthClientMetadata.self, from: data)
269
+
try validateClientMetadata(metadata)
270
+
cachedClientMetadata = metadata
271
+
return metadata
272
+
}
273
+
274
+
private func validateClientMetadata(_ metadata: OAuthClientMetadata) throws {
275
+
guard metadata.clientID == configuration.clientMetadataURL else {
276
+
throw OAuthManagerError.clientMetadataValidationFailed
277
+
}
278
+
guard metadata.redirectURIs.contains(configuration.redirectURI) else {
279
+
throw OAuthManagerError.clientMetadataValidationFailed
280
+
}
281
+
guard metadata.grantTypes.contains("authorization_code") else {
282
+
throw OAuthManagerError.clientMetadataValidationFailed
283
+
}
284
+
guard metadata.responseTypes.contains("code") else {
285
+
throw OAuthManagerError.clientMetadataValidationFailed
286
+
}
287
+
guard metadata.dPoPBoundAccessTokens else {
288
+
throw OAuthManagerError.clientMetadataValidationFailed
289
+
}
290
+
}
291
+
292
+
private func validateAuthorizationServerMetadata(_ metadata: OAuthAuthorizationServerMetadata) throws {
293
+
guard metadata.codeChallengeMethodsSupported.contains(where: { $0.caseInsensitiveCompare("S256") == .orderedSame }) else {
294
+
throw OAuthManagerError.unsupportedAuthorizationServer
295
+
}
296
+
guard metadata.dPoPSigningAlgValuesSupported.contains(where: { $0.caseInsensitiveCompare("ES256") == .orderedSame }) else {
297
+
throw OAuthManagerError.unsupportedAuthorizationServer
298
+
}
299
+
guard metadata.scopesSupported.isEmpty || metadata.scopesSupported.contains("atproto") else {
300
+
throw OAuthManagerError.unsupportedAuthorizationServer
301
+
}
302
+
}
303
+
304
+
private func generateState() throws -> String {
305
+
let data = try randomGenerator.data(count: 32)
306
+
return Base64URL.encode(data)
307
+
}
308
+
309
+
private func performPushedAuthorizationRequest(
310
+
metadata: OAuthAuthorizationServerMetadata,
311
+
clientMetadata: OAuthClientMetadata,
312
+
handle: String,
313
+
did: String,
314
+
pkce: PKCEValues,
315
+
state: String
316
+
) async throws -> (requestURI: String, nonce: String?) {
317
+
let parameters: [String: String] = {
318
+
var base: [String: String] = [
319
+
"client_id": configuration.clientMetadataURL.absoluteString,
320
+
"redirect_uri": configuration.redirectURI.absoluteString,
321
+
"response_type": "code",
322
+
"scope": configuration.requestedScopes.joined(separator: " "),
323
+
"code_challenge": pkce.challenge,
324
+
"code_challenge_method": "S256",
325
+
"state": state,
326
+
"login_hint": handle,
327
+
"resource": did
328
+
]
329
+
configuration.additionalAuthorizationParameters.forEach { base[$0.key] = $0.value }
330
+
return base
331
+
}()
332
+
333
+
var request = URLRequest(url: metadata.pushedAuthorizationRequestEndpoint)
334
+
request.httpMethod = "POST"
335
+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
336
+
request.httpBody = try formEncodedBody(from: parameters)
337
+
338
+
var currentNonce = authorizationServerNonce
339
+
for _ in 0..<2 {
340
+
let proof = try dpopGenerator.generateProof(
341
+
method: "POST",
342
+
url: metadata.pushedAuthorizationRequestEndpoint,
343
+
nonce: currentNonce,
344
+
accessToken: nil
345
+
)
346
+
request.setValue(proof, forHTTPHeaderField: "DPoP")
347
+
let (data, response) = try await httpClient.send(request)
348
+
if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false {
349
+
currentNonce = nonce
350
+
}
351
+
352
+
switch response.statusCode {
353
+
case 201:
354
+
authorizationServerNonce = currentNonce
355
+
let parResponse = try httpClient.decodeJSON(PushedAuthorizationRequestResponse.self, from: data)
356
+
return (parResponse.requestURI, currentNonce)
357
+
case 400, 401:
358
+
if currentNonce != nil {
359
+
continue
360
+
}
361
+
if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data),
362
+
errorResponse.error == "use_dpop_nonce" {
363
+
continue
364
+
}
365
+
throw OAuthManagerError.tokenExchangeFailed
366
+
default:
367
+
throw OAuthManagerError.tokenExchangeFailed
368
+
}
369
+
}
370
+
371
+
throw OAuthManagerError.tokenExchangeFailed
372
+
}
373
+
374
+
private func makeAuthorizationURL(endpoint: URL, clientID: URL, requestURI: String) -> URL {
375
+
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents()
376
+
var items = components.queryItems ?? []
377
+
items.append(URLQueryItem(name: "client_id", value: clientID.absoluteString))
378
+
items.append(URLQueryItem(name: "request_uri", value: requestURI))
379
+
components.queryItems = items
380
+
return components.url ?? endpoint
381
+
}
382
+
383
+
private func exchangeAuthorizationCode(code: String, pending: PendingAuthorization) async throws -> OAuthTokenResponse {
384
+
let parameters: [String: String] = [
385
+
"grant_type": "authorization_code",
386
+
"code": code,
387
+
"redirect_uri": configuration.redirectURI.absoluteString,
388
+
"client_id": configuration.clientMetadataURL.absoluteString,
389
+
"code_verifier": pending.pkce.verifier
390
+
]
391
+
392
+
var request = URLRequest(url: pending.authorizationServerMetadata.tokenEndpoint)
393
+
request.httpMethod = "POST"
394
+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
395
+
request.httpBody = try formEncodedBody(from: parameters)
396
+
397
+
let response = try await sendTokenRequest(request: request, tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint)
398
+
return response
399
+
}
400
+
401
+
private func performRefresh(session: OAuthSession) async throws -> OAuthSession {
402
+
let parameters: [String: String] = [
403
+
"grant_type": "refresh_token",
404
+
"refresh_token": session.refreshToken,
405
+
"client_id": configuration.clientMetadataURL.absoluteString,
406
+
"redirect_uri": configuration.redirectURI.absoluteString
407
+
]
408
+
409
+
var request = URLRequest(url: session.tokenEndpoint)
410
+
request.httpMethod = "POST"
411
+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
412
+
request.httpBody = try formEncodedBody(from: parameters)
413
+
414
+
let tokenResponse = try await sendTokenRequest(request: request, tokenEndpoint: session.tokenEndpoint)
415
+
guard let subject = tokenResponse.subject, subject == session.did else {
416
+
throw OAuthManagerError.refreshFailed
417
+
}
418
+
419
+
let refreshToken: String
420
+
if let newToken = tokenResponse.refreshToken, newToken.isEmpty == false {
421
+
refreshToken = newToken
422
+
} else {
423
+
refreshToken = session.refreshToken
424
+
}
425
+
426
+
return OAuthSession(
427
+
did: session.did,
428
+
pdsURL: session.pdsURL,
429
+
authorizationServer: session.authorizationServer,
430
+
tokenEndpoint: session.tokenEndpoint,
431
+
accessToken: tokenResponse.accessToken,
432
+
refreshToken: refreshToken,
433
+
tokenType: tokenResponse.tokenType,
434
+
scope: tokenResponse.scope,
435
+
expiresIn: tokenResponse.expiresIn,
436
+
issuedAt: Date()
437
+
)
438
+
}
439
+
440
+
private func sendTokenRequest(request: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse {
441
+
var request = request
442
+
let nonce = authorizationServerNonce
443
+
let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: nonce, accessToken: nil)
444
+
request.setValue(proof, forHTTPHeaderField: "DPoP")
445
+
446
+
let (data, response) = try await httpClient.send(request)
447
+
if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false {
448
+
authorizationServerNonce = newNonce
449
+
}
450
+
451
+
switch response.statusCode {
452
+
case 200:
453
+
return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data)
454
+
case 400, 401:
455
+
if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data),
456
+
errorResponse.error == "use_dpop_nonce",
457
+
let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false {
458
+
authorizationServerNonce = nonce
459
+
return try await retryTokenRequest(originalRequest: request, tokenEndpoint: tokenEndpoint)
460
+
}
461
+
fallthrough
462
+
default:
463
+
throw OAuthManagerError.tokenExchangeFailed
464
+
}
465
+
}
466
+
467
+
private func retryTokenRequest(originalRequest: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse {
468
+
var request = originalRequest
469
+
let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: authorizationServerNonce, accessToken: nil)
470
+
request.setValue(proof, forHTTPHeaderField: "DPoP")
471
+
let (data, response) = try await httpClient.send(request)
472
+
if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false {
473
+
authorizationServerNonce = newNonce
474
+
}
475
+
guard response.statusCode == 200 else { throw OAuthManagerError.tokenExchangeFailed }
476
+
return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data)
477
+
}
478
+
479
+
private func formEncodedBody(from parameters: [String: String]) throws -> Data {
480
+
var components = URLComponents()
481
+
components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
482
+
guard let query = components.percentEncodedQuery, let data = query.data(using: .utf8) else {
483
+
throw OAuthManagerError.tokenExchangeFailed
484
+
}
485
+
return data
486
+
}
487
+
488
+
private func store(session: OAuthSession) async throws {
489
+
cachedSession = session
490
+
try await credentialStore.save(session: session)
491
+
APEnvironment.current.accessToken = session.accessToken
492
+
APEnvironment.current.refreshToken = session.refreshToken
493
+
APEnvironment.current.host = session.pdsURL.absoluteString
494
+
}
495
+
}
496
+
497
+
private struct PendingAuthorization: Sendable {
498
+
let handle: String
499
+
let did: String
500
+
let pdsURL: URL
501
+
let authorizationServerMetadata: OAuthAuthorizationServerMetadata
502
+
let clientMetadata: OAuthClientMetadata
503
+
let state: String
504
+
let pkce: PKCEValues
505
+
let requestURI: String
506
+
let issuedAt: Date
507
+
}
+5
Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift
+5
Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift
+101
Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift
+101
Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift
···
1
+
import CryptoKit
2
+
import Foundation
3
+
4
+
enum DPoPGeneratorError: Error, Sendable {
5
+
case invalidURL
6
+
case keyUnavailable
7
+
}
8
+
9
+
@APActor
10
+
public final class DPoPGenerator: Sendable {
11
+
private var keyPair: DPoPKeyPair
12
+
private let clock: () -> Date
13
+
14
+
init(keyPair: DPoPKeyPair, clock: @escaping () -> Date = Date.init) {
15
+
self.keyPair = keyPair
16
+
self.clock = clock
17
+
}
18
+
19
+
public convenience init(clock: @escaping () -> Date = Date.init) {
20
+
self.init(keyPair: DPoPKeyPair(), clock: clock)
21
+
}
22
+
23
+
public func updateKey(using rawRepresentation: Data) throws {
24
+
self.keyPair = try DPoPKeyPair(rawRepresentation: rawRepresentation)
25
+
}
26
+
27
+
public func exportKey() -> Data {
28
+
keyPair.export()
29
+
}
30
+
31
+
public func generateProof(
32
+
method: String,
33
+
url: URL,
34
+
nonce: String?,
35
+
accessToken: String?
36
+
) throws -> String {
37
+
let normalizedHTU = try normalize(url: url)
38
+
let issuedAt = Int(clock().timeIntervalSince1970)
39
+
let header = Header(jwk: keyPair.publicKeyJWK)
40
+
let payload = Payload(
41
+
htm: method.uppercased(),
42
+
htu: normalizedHTU,
43
+
iat: issuedAt,
44
+
exp: issuedAt + 120,
45
+
jti: UUID().uuidString,
46
+
nonce: nonce,
47
+
ath: accessToken.flatMap { accessTokenHash(for: $0) }
48
+
)
49
+
50
+
let encoder = JSONEncoder()
51
+
encoder.outputFormatting = [.withoutEscapingSlashes]
52
+
let headerEncoded = Base64URL.encode(try encoder.encode(header))
53
+
let payloadEncoded = Base64URL.encode(try encoder.encode(payload))
54
+
let signingInput = Data("\(headerEncoded).\(payloadEncoded)".utf8)
55
+
let signature = try keyPair.privateKey.signature(for: signingInput)
56
+
let signatureEncoded = Base64URL.encode(signature.derRepresentation)
57
+
return "\(headerEncoded).\(payloadEncoded).\(signatureEncoded)"
58
+
}
59
+
60
+
private func normalize(url: URL) throws -> String {
61
+
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
62
+
throw DPoPGeneratorError.invalidURL
63
+
}
64
+
components.fragment = nil
65
+
guard let normalized = components.url?.absoluteString else {
66
+
throw DPoPGeneratorError.invalidURL
67
+
}
68
+
return normalized
69
+
}
70
+
71
+
private func accessTokenHash(for token: String) -> String {
72
+
let digest = SHA256.hash(data: Data(token.utf8))
73
+
return Base64URL.encode(Data(digest))
74
+
}
75
+
76
+
private struct Header: Encodable {
77
+
let typ = "dpop+jwt"
78
+
let alg = "ES256"
79
+
let jwk: [String: String]
80
+
}
81
+
82
+
private struct Payload: Encodable {
83
+
let htm: String
84
+
let htu: String
85
+
let iat: Int
86
+
let exp: Int
87
+
let jti: String
88
+
let nonce: String?
89
+
let ath: String?
90
+
91
+
private enum CodingKeys: String, CodingKey {
92
+
case htm
93
+
case htu
94
+
case iat
95
+
case exp
96
+
case jti
97
+
case nonce
98
+
case ath
99
+
}
100
+
}
101
+
}
+38
Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift
+38
Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift
···
1
+
import CryptoKit
2
+
import Foundation
3
+
4
+
struct DPoPKeyPair: Sendable {
5
+
let privateKey: P256.Signing.PrivateKey
6
+
7
+
init() {
8
+
self.privateKey = P256.Signing.PrivateKey()
9
+
}
10
+
11
+
init(privateKey: P256.Signing.PrivateKey) {
12
+
self.privateKey = privateKey
13
+
}
14
+
15
+
init(rawRepresentation: Data) throws {
16
+
self.privateKey = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation)
17
+
}
18
+
19
+
var publicKeyJWK: [String: String] {
20
+
let publicKeyData = privateKey.publicKey.x963Representation
21
+
// Strip leading 0x04 per SEC1 encoding to expose affine coordinates
22
+
let xData = Data(publicKeyData[1..<33])
23
+
let yData = Data(publicKeyData[33..<65])
24
+
25
+
return [
26
+
"kty": "EC",
27
+
"crv": "P-256",
28
+
"alg": "ES256",
29
+
"use": "sig",
30
+
"x": Base64URL.encode(xData),
31
+
"y": Base64URL.encode(yData)
32
+
]
33
+
}
34
+
35
+
func export() -> Data {
36
+
privateKey.rawRepresentation
37
+
}
38
+
}
+38
Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift
+38
Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift
···
1
+
import CryptoKit
2
+
import Foundation
3
+
4
+
struct PKCEValues: Sendable {
5
+
let verifier: String
6
+
let challenge: String
7
+
}
8
+
9
+
struct PKCEGenerator: Sendable {
10
+
private let randomGenerator: RandomDataGenerating
11
+
12
+
init(randomGenerator: RandomDataGenerating = SecureRandomDataGenerator()) {
13
+
self.randomGenerator = randomGenerator
14
+
}
15
+
16
+
func makeValues() throws -> PKCEValues {
17
+
let verifier = try makeVerifier()
18
+
let challenge = makeChallenge(from: verifier)
19
+
return PKCEValues(verifier: verifier, challenge: challenge)
20
+
}
21
+
22
+
func makeVerifier() throws -> String {
23
+
let candidateLengths = [32, 48, 64]
24
+
for length in candidateLengths {
25
+
let data = try randomGenerator.data(count: length)
26
+
let candidate = Base64URL.encode(data)
27
+
if (43...128).contains(candidate.count) {
28
+
return candidate
29
+
}
30
+
}
31
+
throw RandomDataGeneratorError.allocationFailed
32
+
}
33
+
34
+
func makeChallenge(from verifier: String) -> String {
35
+
let digest = SHA256.hash(data: Data(verifier.utf8))
36
+
return Base64URL.encode(Data(digest))
37
+
}
38
+
}
+23
Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift
+23
Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift
···
1
+
import Foundation
2
+
import Security
3
+
4
+
enum RandomDataGeneratorError: Error, Sendable {
5
+
case allocationFailed
6
+
case generationFailed(status: OSStatus)
7
+
}
8
+
9
+
protocol RandomDataGenerating: Sendable {
10
+
func data(count: Int) throws -> Data
11
+
}
12
+
13
+
struct SecureRandomDataGenerator: RandomDataGenerating {
14
+
func data(count: Int) throws -> Data {
15
+
guard count > 0 else { return Data() }
16
+
var buffer = Data(count: count)
17
+
let status = buffer.withUnsafeMutableBytes { pointer in
18
+
SecRandomCopyBytes(kSecRandomDefault, count, pointer.baseAddress!)
19
+
}
20
+
guard status == errSecSuccess else { throw RandomDataGeneratorError.generationFailed(status: status) }
21
+
return buffer
22
+
}
23
+
}
+41
Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift
+41
Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift
···
1
+
import Foundation
2
+
3
+
public protocol OAuthCredentialStore: Sendable {
4
+
func loadSession() async throws -> OAuthSession?
5
+
func save(session: OAuthSession) async throws
6
+
func deleteSession() async throws
7
+
func loadDPoPKey() async throws -> Data?
8
+
func saveDPoPKey(_ data: Data) async throws
9
+
func deleteDPoPKey() async throws
10
+
}
11
+
12
+
public actor InMemoryOAuthCredentialStore: OAuthCredentialStore {
13
+
private var session: OAuthSession?
14
+
private var dpopKey: Data?
15
+
16
+
public init() {}
17
+
18
+
public func loadSession() async throws -> OAuthSession? {
19
+
session
20
+
}
21
+
22
+
public func save(session: OAuthSession) async throws {
23
+
self.session = session
24
+
}
25
+
26
+
public func deleteSession() async throws {
27
+
session = nil
28
+
}
29
+
30
+
public func loadDPoPKey() async throws -> Data? {
31
+
dpopKey
32
+
}
33
+
34
+
public func saveDPoPKey(_ data: Data) async throws {
35
+
dpopKey = data
36
+
}
37
+
38
+
public func deleteDPoPKey() async throws {
39
+
dpopKey = nil
40
+
}
41
+
}
+26
Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift
+26
Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift
···
1
+
import Foundation
2
+
3
+
enum Base64URLError: Error, Sendable {
4
+
case invalidLength
5
+
case invalidCharacters
6
+
}
7
+
8
+
struct Base64URL: Sendable {
9
+
static func encode(_ data: Data) -> String {
10
+
data.base64EncodedString()
11
+
.replacingOccurrences(of: "+", with: "-")
12
+
.replacingOccurrences(of: "/", with: "_")
13
+
.replacingOccurrences(of: "=", with: "")
14
+
}
15
+
16
+
static func decode(_ string: String) throws -> Data {
17
+
let remainder = string.count % 4
18
+
guard remainder != 1 else { throw Base64URLError.invalidLength }
19
+
let paddingLength = remainder == 0 ? 0 : 4 - remainder
20
+
let padded = string + String(repeating: "=", count: paddingLength)
21
+
guard let data = Data(base64Encoded: padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")) else {
22
+
throw Base64URLError.invalidCharacters
23
+
}
24
+
return data
25
+
}
26
+
}
+53
Tests/CoreATProtocolTests/IdentityResolverTests.swift
+53
Tests/CoreATProtocolTests/IdentityResolverTests.swift
···
1
+
import Foundation
2
+
import Testing
3
+
@testable import CoreATProtocol
4
+
5
+
@APActor
6
+
final class MockNetworking: Networking {
7
+
var requestedURLs: [URL] = []
8
+
var responseData: Data
9
+
var statusCode: Int
10
+
11
+
init(responseData: Data, statusCode: Int = 200) {
12
+
self.responseData = responseData
13
+
self.statusCode = statusCode
14
+
}
15
+
16
+
func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) {
17
+
if let url = request.url {
18
+
requestedURLs.append(url)
19
+
}
20
+
let response = HTTPURLResponse(url: request.url ?? URL(string: "https://example.com")!, statusCode: statusCode, httpVersion: nil, headerFields: [:])!
21
+
return (responseData, response)
22
+
}
23
+
}
24
+
25
+
struct MockDNSResolver: DNSResolving {
26
+
func txtRecords(for host: String) async throws -> [String] { [] }
27
+
}
28
+
29
+
@Test("Identity resolver fetches PLC DID documents using full identifier path")
30
+
func identityResolverUsesFullPLCPath() async throws {
31
+
let documentJSON = """
32
+
{
33
+
"id": "did:plc:identifier",
34
+
"service": [
35
+
{
36
+
"id": "#atproto_pds",
37
+
"type": "AtprotoPersonalDataServer",
38
+
"serviceEndpoint": "https://example.com"
39
+
}
40
+
]
41
+
}
42
+
""".data(using: .utf8)!
43
+
44
+
let networking = await MockNetworking(responseData: documentJSON)
45
+
let httpClient = await OAuthHTTPClient(networking: networking)
46
+
let resolver = await IdentityResolver(httpClient: httpClient, dnsResolver: MockDNSResolver())
47
+
48
+
let document = try await resolver.fetchDIDDocument(for: "did:plc:identifier")
49
+
#expect(document.id == "did:plc:identifier")
50
+
51
+
let requestedPath = await networking.requestedURLs.first?.path
52
+
#expect(requestedPath == "/did:plc:identifier")
53
+
}
+37
Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift
+37
Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift
···
1
+
import Foundation
2
+
import Testing
3
+
@testable import CoreATProtocol
4
+
5
+
@Test("Client metadata decodes from sample JSON")
6
+
func decodeClientMetadata() throws {
7
+
let json = """
8
+
{
9
+
"client_id": "https://sparrowtek.com/plume.json",
10
+
"client_name": "Plume iOS",
11
+
"application_type": "native",
12
+
"grant_types": [
13
+
"authorization_code",
14
+
"refresh_token"
15
+
],
16
+
"scope": "atproto",
17
+
"response_types": [
18
+
"code"
19
+
],
20
+
"redirect_uris": [
21
+
"com.sparrowtek.plume:/oauth/callback"
22
+
],
23
+
"token_endpoint_auth_method": "none",
24
+
"dpop_bound_access_tokens": true,
25
+
"client_uri": "https://sparrowtek.com",
26
+
"policy_uri": "https://sparrowtek.com/privacy",
27
+
"tos_uri": "https://sparrowtek.com/terms"
28
+
}
29
+
""".data(using: .utf8)!
30
+
31
+
let decoder = JSONDecoder()
32
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
33
+
let metadata = try decoder.decode(OAuthClientMetadata.self, from: json)
34
+
#expect(metadata.clientID.absoluteString == "https://sparrowtek.com/plume.json")
35
+
#expect(metadata.redirectURIs.count == 1)
36
+
#expect(metadata.dPoPBoundAccessTokens)
37
+
}
+15
Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift
+15
Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift
···
1
+
import Foundation
2
+
import Testing
3
+
@testable import CoreATProtocol
4
+
5
+
@Test("Authorization server metadata decodes from sample JSON")
6
+
func decodeAuthorizationServerMetadata() throws {
7
+
let json = """
8
+
{"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","introspection_endpoint":"https://bsky.social/oauth/introspect","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true}
9
+
""".data(using: .utf8)!
10
+
11
+
let decoder = JSONDecoder()
12
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
13
+
let metadata = try decoder.decode(OAuthAuthorizationServerMetadata.self, from: json)
14
+
#expect(metadata.authorizationEndpoint.absoluteString == "https://bsky.social/oauth/authorize")
15
+
}
+97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
+97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
···
1
+
import CryptoKit
2
+
import Foundation
3
+
import Testing
4
+
@testable import CoreATProtocol
5
+
6
+
private struct DeterministicRandomGenerator: RandomDataGenerating {
7
+
func data(count: Int) throws -> Data {
8
+
Data(repeating: 0x42, count: count)
9
+
}
10
+
}
11
+
12
+
@Test("Base64URL encodes without padding and decodes back")
13
+
func base64URLRoundTrip() throws {
14
+
let data = Data([0xde, 0xad, 0xbe, 0xef])
15
+
let encoded = Base64URL.encode(data)
16
+
#expect(encoded.contains("=") == false)
17
+
let decoded = try Base64URL.decode(encoded)
18
+
#expect(decoded == data)
19
+
}
20
+
21
+
@Test("PKCE generator creates verifier within bounds and matching challenge")
22
+
func pkceGeneratorProducesExpectedValues() throws {
23
+
let generator = PKCEGenerator(randomGenerator: DeterministicRandomGenerator())
24
+
let values = try generator.makeValues()
25
+
#expect(values.verifier.count >= 43)
26
+
#expect(values.verifier.count <= 128)
27
+
28
+
let expectedDigest = SHA256.hash(data: Data(values.verifier.utf8))
29
+
let expectedChallenge = Base64URL.encode(Data(expectedDigest))
30
+
#expect(values.challenge == expectedChallenge)
31
+
}
32
+
33
+
@Test("DPoP generator signs payload with expected claims")
34
+
func dpopGeneratorProducesValidProof() async throws {
35
+
let keyPair = DPoPKeyPair()
36
+
let generator = await DPoPGenerator(clock: { Date(timeIntervalSince1970: 1_700_000_000) })
37
+
try await generator.updateKey(using: keyPair.export())
38
+
let url = URL(string: "https://example.com/resource")!
39
+
let proof = try await generator.generateProof(
40
+
method: "GET",
41
+
url: url,
42
+
nonce: "nonce-value",
43
+
accessToken: "access-token"
44
+
)
45
+
46
+
let components = proof.split(separator: ".")
47
+
#expect(components.count == 3)
48
+
49
+
let headerData = try Base64URL.decode(String(components[0]))
50
+
let payloadData = try Base64URL.decode(String(components[1]))
51
+
let signatureData = try Base64URL.decode(String(components[2]))
52
+
53
+
let header = try JSONSerialization.jsonObject(with: headerData) as? [String: Any]
54
+
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
55
+
56
+
#expect(header?["typ"] as? String == "dpop+jwt")
57
+
#expect(header?["alg"] as? String == "ES256")
58
+
let jwk = header?["jwk"] as? [String: String]
59
+
#expect(jwk?["kty"] == "EC")
60
+
#expect(jwk?["crv"] == "P-256")
61
+
62
+
#expect(payload?["htm"] as? String == "GET")
63
+
#expect(payload?["htu"] as? String == "https://example.com/resource")
64
+
#expect(payload?["nonce"] as? String == "nonce-value")
65
+
#expect(payload?["ath"] as? String == Base64URL.encode(Data(SHA256.hash(data: Data("access-token".utf8)))))
66
+
67
+
if let iat = payload?["iat"] as? Int {
68
+
#expect(iat == 1_700_000_000)
69
+
} else {
70
+
Issue.record("DPoP payload missing iat")
71
+
}
72
+
73
+
let signingInput = Data((components[0] + "." + components[1]).utf8)
74
+
let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData)
75
+
#expect(keyPair.privateKey.publicKey.isValidSignature(signature, for: signingInput))
76
+
}
77
+
78
+
@Test("OAuth session refresh heuristics")
79
+
func oauthSessionRefreshLogic() {
80
+
let issuedAt = Date()
81
+
let session = OAuthSession(
82
+
did: "did:plc:example",
83
+
pdsURL: URL(string: "https://pds.example.com")!,
84
+
authorizationServer: URL(string: "https://auth.example.com")!,
85
+
tokenEndpoint: URL(string: "https://auth.example.com/token")!,
86
+
accessToken: "token",
87
+
refreshToken: "refresh",
88
+
tokenType: "DPoP",
89
+
scope: "atproto",
90
+
expiresIn: 3600,
91
+
issuedAt: issuedAt
92
+
)
93
+
94
+
#expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3500)) == false)
95
+
#expect(session.needsRefresh(relativeTo: issuedAt.addingTimeInterval(3300), threshold: 400))
96
+
#expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3600)))
97
+
}
-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
-
}