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