-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
-
}
···
+1
-9
Package.swift
+1
-9
Package.swift
···
17
targets: ["CoreATProtocol"]
18
),
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
-
],
24
targets: [
25
.target(
26
-
name: "CoreATProtocol",
27
-
dependencies: [
28
-
"OAuthenticator",
29
-
.product(name: "JWTKit", package: "jwt-kit"),
30
-
],
31
),
32
.testTarget(
33
name: "CoreATProtocolTests",
+12
-108
Sources/CoreATProtocol/APEnvironment.swift
+12
-108
Sources/CoreATProtocol/APEnvironment.swift
···
5
// Created by Thomas Rademaker on 10/10/25.
6
//
7
8
-
import Foundation
9
-
import OAuthenticator
10
-
11
@APActor
12
public class APEnvironment {
13
public static var current: APEnvironment = APEnvironment()
14
-
15
-
// MARK: - Connection Configuration
16
public var host: String?
17
-
18
-
// MARK: - Authentication Tokens
19
public var accessToken: String?
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
39
public var atProtocoldelegate: CoreATProtocolDelegate?
40
public let routerDelegate = APRouterDelegate()
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
57
-
}
58
-
return false
59
-
}
60
-
61
-
/// Attempts to refresh the access token if needed.
62
-
/// Returns true if refresh succeeded or wasn't needed, false if refresh failed.
63
-
public func refreshTokenIfNeeded() async -> Bool {
64
-
guard needsTokenRefresh else { return true }
65
-
66
-
// Prevent concurrent refresh attempts
67
-
guard !isRefreshing else { return false }
68
-
isRefreshing = true
69
-
defer { isRefreshing = false }
70
-
71
-
return await performTokenRefresh()
72
-
}
73
-
74
-
// MARK: - Configuration
75
-
76
-
/// Configures the environment for OAuth with token refresh support.
77
-
public func configureOAuth(
78
-
serverMetadata: ServerMetadata,
79
-
clientId: String,
80
-
tokenStorage: TokenStorageProtocol? = nil
81
-
) {
82
-
self.serverMetadata = serverMetadata
83
-
self.clientId = clientId
84
-
self.tokenStorage = tokenStorage
85
-
}
86
-
87
-
/// Stores the complete authentication state after successful login.
88
-
public func setAuthenticationState(_ state: AuthenticationState) async {
89
-
self.authState = state
90
-
self.accessToken = state.accessToken
91
-
self.refreshToken = state.refreshToken
92
-
93
-
// Update host from PDS URL
94
-
if let url = URL(string: state.pdsURL) {
95
-
self.host = url.absoluteString
96
-
}
97
-
98
-
// Persist if storage is configured
99
-
if let storage = tokenStorage {
100
-
try? await storage.store(state)
101
-
}
102
-
}
103
-
104
-
/// Restores authentication state from storage.
105
-
public func restoreAuthenticationState() async -> Bool {
106
-
guard let storage = tokenStorage else { return false }
107
-
108
-
do {
109
-
guard let state = try await storage.retrieve() else {
110
-
return false
111
-
}
112
-
113
-
self.authState = state
114
-
self.accessToken = state.accessToken
115
-
self.refreshToken = state.refreshToken
116
-
117
-
if let url = URL(string: state.pdsURL) {
118
-
self.host = url.absoluteString
119
-
}
120
-
121
-
return true
122
-
} catch {
123
-
return false
124
}
125
}
126
}
···
5
// Created by Thomas Rademaker on 10/10/25.
6
//
7
8
@APActor
9
public class APEnvironment {
10
public static var current: APEnvironment = APEnvironment()
11
+
12
public var host: String?
13
public var accessToken: String?
14
public var refreshToken: String?
15
public var atProtocoldelegate: CoreATProtocolDelegate?
16
public let routerDelegate = APRouterDelegate()
17
+
public var oauthManager: OAuthManager? {
18
+
didSet {
19
+
routerDelegate.oauthManager = oauthManager
20
}
21
}
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
+
// }
30
}
+24
-142
Sources/CoreATProtocol/CoreATProtocol.swift
+24
-142
Sources/CoreATProtocol/CoreATProtocol.swift
···
1
// The Swift Programming Language
2
// https://docs.swift.org/swift-book
3
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
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
38
@APActor
39
public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) {
40
APEnvironment.current.host = hostURL
···
43
APEnvironment.current.atProtocoldelegate = delegate
44
}
45
46
-
/// Configures the AT Protocol environment with OAuth support.
47
-
/// - Parameters:
48
-
/// - serverMetadata: OAuth authorization server metadata
49
-
/// - clientId: The client ID for this application
50
-
/// - tokenStorage: Optional persistent storage for tokens
51
-
/// - delegate: Optional delegate for receiving events
52
-
@APActor
53
-
public func setupOAuth(
54
-
serverMetadata: ServerMetadata,
55
-
clientId: String,
56
-
tokenStorage: TokenStorageProtocol? = nil,
57
-
delegate: CoreATProtocolDelegate? = nil
58
-
) {
59
-
APEnvironment.current.configureOAuth(
60
-
serverMetadata: serverMetadata,
61
-
clientId: clientId,
62
-
tokenStorage: tokenStorage
63
-
)
64
-
APEnvironment.current.atProtocoldelegate = delegate
65
-
}
66
-
67
-
/// Sets the delegate for receiving authentication events.
68
@APActor
69
public func setDelegate(_ delegate: CoreATProtocolDelegate) {
70
APEnvironment.current.atProtocoldelegate = delegate
71
}
72
73
-
/// Updates the stored tokens.
74
@APActor
75
public func updateTokens(access: String?, refresh: String?) {
76
APEnvironment.current.accessToken = access
77
APEnvironment.current.refreshToken = refresh
78
}
79
80
-
/// Updates the host URL.
81
@APActor
82
public func update(hostURL: String?) {
83
APEnvironment.current.host = hostURL
84
}
85
86
-
/// Applies a complete authentication context from a successful OAuth login.
87
-
/// - Parameters:
88
-
/// - login: The Login object from OAuthenticator
89
-
/// - generator: DPoP JWT generator for signing requests
90
-
/// - resourceNonce: Initial DPoP nonce from the resource server
91
-
/// - serverMetadata: OAuth server metadata for token refresh
92
-
/// - clientId: Client ID for token refresh
93
@APActor
94
-
public func applyAuthenticationContext(
95
-
login: Login,
96
-
generator: @escaping DPoPSigner.JWTGenerator,
97
-
resourceNonce: String? = nil,
98
-
serverMetadata: ServerMetadata? = nil,
99
-
clientId: String? = nil
100
-
) {
101
-
APEnvironment.current.login = login
102
-
APEnvironment.current.accessToken = login.accessToken.value
103
-
APEnvironment.current.refreshToken = login.refreshToken?.value
104
-
APEnvironment.current.dpopProofGenerator = generator
105
-
APEnvironment.current.resourceServerNonce = resourceNonce
106
-
APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce
107
-
108
-
// Store OAuth configuration if provided (needed for token refresh)
109
-
if let metadata = serverMetadata {
110
-
APEnvironment.current.serverMetadata = metadata
111
-
}
112
-
if let id = clientId {
113
-
APEnvironment.current.clientId = id
114
-
}
115
}
116
117
-
/// Clears all authentication context and tokens.
118
@APActor
119
-
public func clearAuthenticationContext() async {
120
-
APEnvironment.current.login = nil
121
-
APEnvironment.current.dpopProofGenerator = nil
122
-
APEnvironment.current.resourceServerNonce = nil
123
-
APEnvironment.current.accessToken = nil
124
-
APEnvironment.current.refreshToken = nil
125
-
APEnvironment.current.resourceDPoPSigner.nonce = nil
126
-
APEnvironment.current.authState = nil
127
-
APEnvironment.current.resolvedIdentity = nil
128
-
129
-
// Clear persistent storage if configured
130
-
if let storage = APEnvironment.current.tokenStorage {
131
-
try? await storage.clear()
132
}
133
}
134
135
-
/// Updates the resource server DPoP nonce.
136
@APActor
137
-
public func updateResourceDPoPNonce(_ nonce: String?) {
138
-
APEnvironment.current.resourceServerNonce = nonce
139
-
APEnvironment.current.resourceDPoPSigner.nonce = nonce
140
}
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
153
}
154
155
-
/// Resolves a DID to a complete identity with PDS and authorization server URLs.
156
-
/// - Parameter did: The DID to resolve (e.g., "did:plc:abc123")
157
-
/// - Returns: Complete resolved identity information
158
@APActor
159
-
public func resolveIdentity(did: String) async throws -> IdentityResolver.ResolvedIdentity {
160
-
let identity = try await APEnvironment.current.identityResolver.resolveIdentity(did: did)
161
-
APEnvironment.current.resolvedIdentity = identity
162
-
APEnvironment.current.host = identity.pdsURL
163
-
return identity
164
-
}
165
-
166
-
// MARK: - Session Management
167
-
168
-
/// Attempts to restore a previous session from persistent storage.
169
-
/// - Returns: true if a session was restored, false otherwise
170
-
@APActor
171
-
public func restoreSession() async -> Bool {
172
-
return await APEnvironment.current.restoreAuthenticationState()
173
-
}
174
-
175
-
/// Checks if the current session is valid and has non-expired tokens.
176
-
@APActor
177
-
public var hasValidSession: Bool {
178
-
if let state = APEnvironment.current.authState {
179
-
return !state.isAccessTokenExpired || state.canRefresh
180
-
}
181
-
if let login = APEnvironment.current.login {
182
-
return login.accessToken.valid || (login.refreshToken?.valid ?? false)
183
-
}
184
-
return APEnvironment.current.accessToken != nil
185
}
···
1
// The Swift Programming Language
2
// https://docs.swift.org/swift-book
3
4
+
public protocol CoreATProtocolDelegate: AnyObject {}
5
6
@APActor
7
public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) {
8
APEnvironment.current.host = hostURL
···
11
APEnvironment.current.atProtocoldelegate = delegate
12
}
13
14
@APActor
15
public func setDelegate(_ delegate: CoreATProtocolDelegate) {
16
APEnvironment.current.atProtocoldelegate = delegate
17
}
18
19
@APActor
20
public func updateTokens(access: String?, refresh: String?) {
21
APEnvironment.current.accessToken = access
22
APEnvironment.current.refreshToken = refresh
23
}
24
25
@APActor
26
public func update(hostURL: String?) {
27
APEnvironment.current.host = hostURL
28
}
29
30
@APActor
31
+
public func configureOAuth(
32
+
configuration: OAuthConfiguration,
33
+
credentialStore: OAuthCredentialStore? = nil
34
+
) async throws {
35
+
let store = credentialStore ?? InMemoryOAuthCredentialStore()
36
+
let manager = try await OAuthManager(configuration: configuration, credentialStore: store)
37
+
APEnvironment.current.oauthManager = manager
38
}
39
40
@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
44
}
45
+
let session = try await manager.authenticate(handle: handle, using: uiProvider)
46
+
APEnvironment.current.host = session.pdsURL.absoluteString
47
+
return session
48
}
49
50
@APActor
51
+
public func currentOAuthSession() -> OAuthSession? {
52
+
APEnvironment.current.oauthManager?.currentSession
53
}
54
55
@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()
61
}
62
63
@APActor
64
+
public func signOutOAuth() async throws {
65
+
guard let manager = APEnvironment.current.oauthManager else { return }
66
+
try await manager.signOut()
67
}
-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
-
}
···
+5
-207
Sources/CoreATProtocol/Models/ATError.swift
+5
-207
Sources/CoreATProtocol/Models/ATError.swift
···
5
// Created by Thomas Rademaker on 10/8/25.
6
//
7
8
-
import Foundation
9
-
10
-
/// Top-level error type for AT Protocol operations.
11
-
public enum AtError: Error, Sendable {
12
-
/// An error message returned by the server.
13
case message(ErrorMessage)
14
-
15
-
/// A network-level error.
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)
29
}
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.
96
public struct ErrorMessage: Codable, Sendable {
97
-
/// The error code/type string.
98
public let error: String
99
-
100
-
/// Optional human-readable error message.
101
public let message: String?
102
-
103
public init(error: String, message: String?) {
104
self.error = error
105
self.message = message
106
}
107
-
108
-
/// Attempts to parse the error string as a known error type.
109
-
public var errorType: AtErrorType? {
110
-
AtErrorType(rawValue: error)
111
-
}
112
}
113
114
-
/// Known AT Protocol error types.
115
-
public enum AtErrorType: String, Codable, Sendable, CaseIterable {
116
-
// Authentication errors
117
case authenticationRequired = "AuthenticationRequired"
118
case expiredToken = "ExpiredToken"
119
-
case authMissing = "AuthMissing"
120
-
case invalidToken = "InvalidToken"
121
-
122
-
// Request errors
123
case invalidRequest = "InvalidRequest"
124
-
case invalidSwap = "InvalidSwap"
125
case methodNotImplemented = "MethodNotImplemented"
126
-
127
-
// Rate limiting
128
case rateLimitExceeded = "RateLimitExceeded"
129
-
130
-
// Account errors
131
-
case accountTakedown = "AccountTakedown"
132
-
case accountSuspended = "AccountSuspended"
133
-
case accountDeactivated = "AccountDeactivated"
134
-
case accountNotFound = "AccountNotFound"
135
-
136
-
// Record errors
137
-
case recordNotFound = "RecordNotFound"
138
-
case repoNotFound = "RepoNotFound"
139
-
case blobNotFound = "BlobNotFound"
140
-
case blockNotFound = "BlockNotFound"
141
-
142
-
// Validation errors
143
-
case invalidHandle = "InvalidHandle"
144
-
case handleNotAvailable = "HandleNotAvailable"
145
-
case unsupportedDomain = "UnsupportedDomain"
146
-
case unresolvableDid = "UnresolvableDid"
147
-
148
-
// Blob errors
149
-
case blobTooLarge = "BlobTooLarge"
150
-
case invalidBlob = "InvalidBlob"
151
-
152
-
// Content errors
153
-
case duplicateCreate = "DuplicateCreate"
154
-
case unknownFeed = "UnknownFeed"
155
-
case unknownList = "UnknownList"
156
-
case notFound = "NotFound"
157
-
158
-
// Server errors
159
-
case upstreamFailure = "UpstreamFailure"
160
-
case upstreamTimeout = "UpstreamTimeout"
161
-
case internalServerError = "InternalServerError"
162
-
163
-
/// Human-readable description of the error type.
164
-
public var description: String {
165
-
switch self {
166
-
case .authenticationRequired: return "Authentication is required"
167
-
case .expiredToken: return "The access token has expired"
168
-
case .authMissing: return "Authentication credentials are missing"
169
-
case .invalidToken: return "The provided token is invalid"
170
-
case .invalidRequest: return "The request is invalid"
171
-
case .invalidSwap: return "The swap operation is invalid"
172
-
case .methodNotImplemented: return "This method is not implemented"
173
-
case .rateLimitExceeded: return "Rate limit exceeded"
174
-
case .accountTakedown: return "Account has been taken down"
175
-
case .accountSuspended: return "Account has been suspended"
176
-
case .accountDeactivated: return "Account has been deactivated"
177
-
case .accountNotFound: return "Account not found"
178
-
case .recordNotFound: return "Record not found"
179
-
case .repoNotFound: return "Repository not found"
180
-
case .blobNotFound: return "Blob not found"
181
-
case .blockNotFound: return "Block not found"
182
-
case .invalidHandle: return "The handle is invalid"
183
-
case .handleNotAvailable: return "The handle is not available"
184
-
case .unsupportedDomain: return "The domain is not supported"
185
-
case .unresolvableDid: return "The DID cannot be resolved"
186
-
case .blobTooLarge: return "The blob is too large"
187
-
case .invalidBlob: return "The blob is invalid"
188
-
case .duplicateCreate: return "A record with this key already exists"
189
-
case .unknownFeed: return "The feed is not known"
190
-
case .unknownList: return "The list is not known"
191
-
case .notFound: return "The resource was not found"
192
-
case .upstreamFailure: return "An upstream service failed"
193
-
case .upstreamTimeout: return "An upstream service timed out"
194
-
case .internalServerError: return "Internal server error"
195
-
}
196
-
}
197
-
}
198
-
199
-
/// Rate limit information from response headers.
200
-
public struct RateLimitInfo: Sendable {
201
-
/// Maximum number of requests allowed in the window.
202
-
public let limit: Int
203
-
204
-
/// Number of requests remaining in the current window.
205
-
public let remaining: Int
206
-
207
-
/// Unix timestamp when the rate limit resets.
208
-
public let resetTimestamp: TimeInterval
209
-
210
-
/// Date when the rate limit resets.
211
-
public var resetDate: Date {
212
-
Date(timeIntervalSince1970: resetTimestamp)
213
-
}
214
-
215
-
/// Time interval until the rate limit resets.
216
-
public var timeUntilReset: TimeInterval {
217
-
resetTimestamp - Date().timeIntervalSince1970
218
-
}
219
-
220
-
/// Parses rate limit information from HTTP response headers.
221
-
public static func from(response: HTTPURLResponse) -> RateLimitInfo? {
222
-
guard let limitStr = response.value(forHTTPHeaderField: "RateLimit-Limit"),
223
-
let remainingStr = response.value(forHTTPHeaderField: "RateLimit-Remaining"),
224
-
let resetStr = response.value(forHTTPHeaderField: "RateLimit-Reset"),
225
-
let limit = Int(limitStr),
226
-
let remaining = Int(remainingStr),
227
-
let reset = TimeInterval(resetStr) else {
228
-
return nil
229
-
}
230
-
231
-
return RateLimitInfo(limit: limit, remaining: remaining, resetTimestamp: reset)
232
-
}
233
}
···
5
// Created by Thomas Rademaker on 10/8/25.
6
//
7
8
+
public enum AtError: Error {
9
case message(ErrorMessage)
10
case network(NetworkError)
11
}
12
13
public struct ErrorMessage: Codable, Sendable {
14
+
#warning("Should error be type string or AtErrorType?")
15
public let error: String
16
public let message: String?
17
+
18
public init(error: String, message: String?) {
19
self.error = error
20
self.message = message
21
}
22
}
23
24
+
public enum AtErrorType: String, Codable, Sendable {
25
case authenticationRequired = "AuthenticationRequired"
26
case expiredToken = "ExpiredToken"
27
case invalidRequest = "InvalidRequest"
28
case methodNotImplemented = "MethodNotImplemented"
29
case rateLimitExceeded = "RateLimitExceeded"
30
+
case authMissing = "AuthMissing"
31
}
+3
-81
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
+3
-81
Sources/CoreATProtocol/Networking/Services/HTTPTask.swift
···
1
-
import Foundation
2
-
3
-
/// Describes the type of HTTP task to perform.
4
public enum HTTPTask: Sendable {
5
-
/// A simple request with no body.
6
case request
7
-
8
-
/// A request with encoded parameters (URL query or JSON body).
9
case requestParameters(encoding: ParameterEncoding)
10
-
11
-
/// A blob upload request with raw data and content type.
12
-
case uploadBlob(data: Data, mimeType: String)
13
-
14
-
/// A multipart form data upload.
15
-
case uploadMultipart(parts: [MultipartFormData])
16
-
}
17
-
18
-
/// Represents a single part in a multipart form data request.
19
-
public struct MultipartFormData: Sendable {
20
-
/// The field name for this part.
21
-
public let name: String
22
-
23
-
/// The filename for file uploads (nil for regular fields).
24
-
public let filename: String?
25
-
26
-
/// The content type of this part.
27
-
public let mimeType: String?
28
-
29
-
/// The data for this part.
30
-
public let data: Data
31
-
32
-
/// Creates a text field part.
33
-
public static func field(name: String, value: String) -> MultipartFormData {
34
-
MultipartFormData(
35
-
name: name,
36
-
filename: nil,
37
-
mimeType: nil,
38
-
data: Data(value.utf8)
39
-
)
40
-
}
41
-
42
-
/// Creates a file upload part.
43
-
public static func file(name: String, filename: String, mimeType: String, data: Data) -> MultipartFormData {
44
-
MultipartFormData(
45
-
name: name,
46
-
filename: filename,
47
-
mimeType: mimeType,
48
-
data: data
49
-
)
50
-
}
51
-
52
-
public init(name: String, filename: String?, mimeType: String?, data: Data) {
53
-
self.name = name
54
-
self.filename = filename
55
-
self.mimeType = mimeType
56
-
self.data = data
57
-
}
58
-
}
59
-
60
-
/// Response from a blob upload operation.
61
-
public struct BlobUploadResponse: Codable, Sendable {
62
-
public let blob: BlobRef
63
-
64
-
public struct BlobRef: Codable, Sendable {
65
-
public let type: String
66
-
public let ref: BlobLink
67
-
public let mimeType: String
68
-
public let size: Int
69
-
70
-
enum CodingKeys: String, CodingKey {
71
-
case type = "$type"
72
-
case ref
73
-
case mimeType
74
-
case size
75
-
}
76
-
77
-
public struct BlobLink: Codable, Sendable {
78
-
public let link: String
79
-
80
-
enum CodingKeys: String, CodingKey {
81
-
case link = "$link"
82
-
}
83
-
}
84
-
}
85
}
+11
-47
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+11
-47
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
···
1
import Foundation
2
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 {
6
func intercept(_ request: inout URLRequest) async
7
func shouldRetry(error: Error, attempts: Int) async throws -> Bool
8
}
9
10
/// Describes the implementation details of a NetworkRouter
···
64
65
let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate)
66
guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode }
67
switch httpResponse.statusCode {
68
case 200...299:
69
return try decoder.decode(T.self, from: data)
···
88
}
89
90
func buildRequest(from route: Endpoint) async throws -> URLRequest {
91
-
92
var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path),
93
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
94
-
timeoutInterval: 30.0)
95
-
96
request.httpMethod = route.httpMethod.rawValue
97
do {
98
switch await route.task {
99
case .request:
100
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
101
await addAdditionalHeaders(route.headers, request: &request)
102
-
103
case .requestParameters(let parameterEncoding):
104
await addAdditionalHeaders(route.headers, request: &request)
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)
118
}
119
return request
120
} catch {
121
throw error
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
151
}
152
153
private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws {
···
1
import Foundation
2
3
+
@APActor
4
+
public protocol NetworkRouterDelegate: AnyObject {
5
func intercept(_ request: inout URLRequest) async
6
func shouldRetry(error: Error, attempts: Int) async throws -> Bool
7
+
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
}
13
14
/// Describes the implementation details of a NetworkRouter
···
68
69
let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate)
70
guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode }
71
+
await delegate?.didReceive(response: httpResponse, data: data, for: request)
72
switch httpResponse.statusCode {
73
case 200...299:
74
return try decoder.decode(T.self, from: data)
···
93
}
94
95
func buildRequest(from route: Endpoint) async throws -> URLRequest {
96
+
97
var request = await URLRequest(url: route.baseURL.appendingPathComponent(route.path),
98
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
99
+
timeoutInterval: 10.0)
100
+
101
request.httpMethod = route.httpMethod.rawValue
102
do {
103
switch await route.task {
104
case .request:
105
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
106
await addAdditionalHeaders(route.headers, request: &request)
107
case .requestParameters(let parameterEncoding):
108
await addAdditionalHeaders(route.headers, request: &request)
109
try configureParameters(parameterEncoding: parameterEncoding, request: &request)
110
}
111
return request
112
} catch {
113
throw error
114
}
115
}
116
117
private func configureParameters(parameterEncoding: ParameterEncoding, request: inout URLRequest) throws {
+77
-185
Sources/CoreATProtocol/Networking.swift
+77
-185
Sources/CoreATProtocol/Networking.swift
···
6
//
7
8
import Foundation
9
-
import CryptoKit
10
-
@preconcurrency import OAuthenticator
11
12
extension JSONDecoder {
13
-
/// A JSON decoder configured for AT Protocol date formats.
14
-
/// Supports ISO 8601 dates with fractional seconds and timezone.
15
public static var atDecoder: JSONDecoder {
16
let decoder = JSONDecoder()
17
decoder.keyDecodingStrategy = .convertFromSnakeCase
18
-
decoder.dateDecodingStrategy = .custom { decoder in
19
-
let container = try decoder.singleValueContainer()
20
-
let dateString = try container.decode(String.self)
21
-
22
-
// Try multiple date formats that AT Protocol APIs may return
23
-
let formatters = Self.atDateFormatters
24
-
25
-
for formatter in formatters {
26
-
if let date = formatter.date(from: dateString) {
27
-
return date
28
-
}
29
-
}
30
-
31
-
// Try ISO8601 with fractional seconds
32
-
let iso8601 = ISO8601DateFormatter()
33
-
iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
34
-
if let date = iso8601.date(from: dateString) {
35
-
return date
36
-
}
37
-
38
-
// Try without fractional seconds
39
-
iso8601.formatOptions = [.withInternetDateTime]
40
-
if let date = iso8601.date(from: dateString) {
41
-
return date
42
-
}
43
-
44
-
throw DecodingError.dataCorruptedError(
45
-
in: container,
46
-
debugDescription: "Cannot decode date string: \(dateString)"
47
-
)
48
-
}
49
-
50
return decoder
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
-
}
72
}
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
79
func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool {
80
guard lastFetched != 0 else { return true }
81
let currentTime = Date.now
82
let lastFetchTime = Date(timeIntervalSince1970: lastFetched)
83
-
guard let differenceInSeconds = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
84
-
return differenceInSeconds >= timeLimit
85
}
86
87
@APActor
88
-
public class APRouterDelegate: NetworkRouterDelegate {
89
-
/// Maximum retry attempts for token refresh.
90
-
private let maxRefreshAttempts = 2
91
92
-
public init() {}
93
94
-
nonisolated public func intercept(_ request: inout URLRequest) async {
95
-
// Try DPoP-authenticated request first (preferred for AT Protocol)
96
-
if let generator = await APEnvironment.current.dpopProofGenerator,
97
-
let login = await APEnvironment.current.login {
98
-
let token = login.accessToken.value
99
-
let tokenHash = await tokenHash(for: token)
100
-
let signer = await APEnvironment.current.resourceDPoPSigner
101
-
await MainActor.run {
102
-
signer.nonce = nil
103
-
}
104
-
let nonce = await APEnvironment.current.resourceServerNonce
105
-
await MainActor.run {
106
-
signer.nonce = nonce
107
-
}
108
109
do {
110
-
try await signer.authenticateRequest(
111
-
&request,
112
-
isolation: MainActor.shared,
113
-
using: generator,
114
-
token: token,
115
-
tokenHash: tokenHash,
116
-
issuer: login.issuingServer
117
-
)
118
} catch {
119
-
// If DPoP signing fails, fall back to providing the token directly.
120
-
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
121
}
122
-
123
-
return
124
}
125
126
-
// Fall back to simple Bearer token authentication
127
-
if let accessToken = await APEnvironment.current.accessToken {
128
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
129
}
130
}
131
132
-
nonisolated public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
133
-
// Don't retry more than maxRefreshAttempts times
134
-
guard attempts <= maxRefreshAttempts else { return false }
135
-
136
-
// Check if the error indicates we need to refresh the token
137
-
let shouldAttemptRefresh = isTokenExpiredError(error)
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
154
}
155
156
-
// Check for explicit expired token error message
157
if case .message(let message) = error as? AtError,
158
message.error == AtErrorType.expiredToken.rawValue {
159
-
return true
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
166
}
167
168
return false
169
}
170
171
-
/// Performs token refresh using the configured OAuth settings.
172
-
nonisolated private func performTokenRefresh() async -> Bool {
173
-
let env = await APEnvironment.current
174
175
-
// Try using the authState-based refresh first
176
-
if await env.authState != nil {
177
-
return await env.performTokenRefresh()
178
}
179
180
-
// Fall back to OAuthenticator's refresh if we have a login with refresh token
181
-
guard let login = await env.login,
182
-
let refreshToken = login.refreshToken,
183
-
refreshToken.valid else {
184
-
return false
185
}
186
187
-
guard let serverMetadata = await env.serverMetadata,
188
-
let clientId = await env.clientId else {
189
-
return false
190
}
191
192
-
// Use RefreshService for the actual refresh
193
-
let refreshService = await RefreshService()
194
195
-
// Create an AuthenticationState from the current login if we don't have one
196
-
let state = AuthenticationState(
197
-
did: login.issuingServer ?? "",
198
-
handle: nil,
199
-
pdsURL: await env.host ?? "",
200
-
authServerURL: serverMetadata.issuer,
201
-
accessToken: login.accessToken.value,
202
-
accessTokenExpiry: login.accessToken.expiry,
203
-
refreshToken: refreshToken.value,
204
-
refreshTokenExpiry: refreshToken.expiry,
205
-
scope: login.scopes,
206
-
dpopPrivateKeyData: nil
207
-
)
208
209
-
do {
210
-
let newState = try await refreshService.refresh(
211
-
state: state,
212
-
serverMetadata: serverMetadata,
213
-
clientId: clientId,
214
-
dpopGenerator: await env.dpopProofGenerator
215
-
)
216
-
217
-
// Update the environment
218
-
await updateEnvironmentWithNewTokens(newState)
219
-
220
return true
221
-
} catch {
222
-
print("Token refresh failed: \(error)")
223
-
return false
224
}
225
}
226
227
-
/// Updates the environment with refreshed tokens.
228
-
private func updateEnvironmentWithNewTokens(_ state: AuthenticationState) async {
229
-
APEnvironment.current.accessToken = state.accessToken
230
-
APEnvironment.current.refreshToken = state.refreshToken
231
-
APEnvironment.current.authState = state
232
-
233
-
// Update login object if present
234
-
if var login = APEnvironment.current.login {
235
-
login.accessToken = Token(value: state.accessToken, expiry: state.accessTokenExpiry)
236
-
if let newRefresh = state.refreshToken {
237
-
login.refreshToken = Token(value: newRefresh, expiry: state.refreshTokenExpiry)
238
-
}
239
-
APEnvironment.current.login = login
240
}
241
}
242
243
-
/// Computes SHA-256 hash of the access token for DPoP `ath` claim.
244
-
nonisolated private func tokenHash(for token: String) -> String {
245
-
let digest = SHA256.hash(data: Data(token.utf8))
246
-
return Data(digest).base64URLEncodedString()
247
}
248
}
···
6
//
7
8
import Foundation
9
10
extension JSONDecoder {
11
public static var atDecoder: JSONDecoder {
12
+
let dateFormatter = DateFormatter()
13
+
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX"
14
+
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
15
+
dateFormatter.locale = Locale(identifier: "en_US")
16
+
17
let decoder = JSONDecoder()
18
decoder.keyDecodingStrategy = .convertFromSnakeCase
19
+
decoder.dateDecodingStrategy = .formatted(dateFormatter)
20
+
21
return decoder
22
}
23
}
24
25
func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool {
26
guard lastFetched != 0 else { return true }
27
let currentTime = Date.now
28
let lastFetchTime = Date(timeIntervalSince1970: lastFetched)
29
+
guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
30
+
return differenceInMinutes >= timeLimit
31
}
32
33
@APActor
34
+
public final class APRouterDelegate: NetworkRouterDelegate {
35
+
public var oauthManager: OAuthManager? {
36
+
didSet { pendingRetryAction = .none }
37
+
}
38
39
+
private enum RetryAction {
40
+
case none
41
+
case refreshToken
42
+
case regenerateDPoP
43
+
}
44
45
+
private var pendingRetryAction: RetryAction = .none
46
47
+
public func intercept(_ request: inout URLRequest) async {
48
+
if let manager = oauthManager {
49
do {
50
+
try await manager.authenticateResourceRequest(&request)
51
+
return
52
} catch {
53
+
// Fall back to legacy bearer injection if OAuth authentication fails.
54
}
55
}
56
57
+
if let accessToken = APEnvironment.current.accessToken {
58
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
59
}
60
}
61
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
+
}
79
}
80
81
if case .message(let message) = error as? AtError,
82
message.error == AtErrorType.expiredToken.rawValue {
83
+
return false
84
}
85
86
return false
87
}
88
89
+
public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {
90
+
guard let manager = oauthManager else { return }
91
92
+
if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false {
93
+
await manager.updateResourceServerNonce(nonce)
94
}
95
96
+
guard (400..<500).contains(response.statusCode) else {
97
+
pendingRetryAction = .none
98
+
return
99
}
100
101
+
if containsUseDPoPNonce(response: response, data: data) {
102
+
pendingRetryAction = .regenerateDPoP
103
+
return
104
}
105
106
+
if containsInvalidToken(response: response, data: data) {
107
+
pendingRetryAction = .refreshToken
108
+
return
109
+
}
110
111
+
pendingRetryAction = .none
112
+
}
113
114
+
private func containsUseDPoPNonce(response: HTTPURLResponse, data: Data) -> Bool {
115
+
if header(response, containsError: "use_dpop_nonce") {
116
+
return true
117
+
}
118
+
if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data),
119
+
errorResponse.error == "use_dpop_nonce" {
120
return true
121
}
122
+
return false
123
}
124
125
+
private func containsInvalidToken(response: HTTPURLResponse, data: Data) -> Bool {
126
+
if header(response, containsError: "invalid_token") {
127
+
return true
128
+
}
129
+
if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data),
130
+
errorResponse.error == "invalid_token" {
131
+
return true
132
}
133
+
return false
134
}
135
136
+
private func header(_ response: HTTPURLResponse, containsError token: String) -> Bool {
137
+
guard let header = response.value(forHTTPHeaderField: "WWW-Authenticate") else { return false }
138
+
return header.range(of: "error=\"\(token)\"", options: .caseInsensitive) != nil || header.range(of: "error=\(token)", options: .caseInsensitive) != nil
139
}
140
}
-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
-
}
···
+2
-87
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
+2
-87
Tests/CoreATProtocolTests/CoreATProtocolTests.swift
···
1
import Testing
2
-
import Foundation
3
@testable import CoreATProtocol
4
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
-
}
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
-
}
···
+37
-150
Tests/CoreATProtocolTests/IdentityResolverTests.swift
+37
-150
Tests/CoreATProtocolTests/IdentityResolverTests.swift
···
1
-
//
2
-
// IdentityResolverTests.swift
3
-
// CoreATProtocol
4
-
//
5
-
// Created by Claude on 2026-01-02.
6
-
//
7
-
8
import Testing
9
-
import Foundation
10
@testable import CoreATProtocol
11
12
-
@Suite("Identity Resolver Tests")
13
-
struct IdentityResolverTests {
14
-
15
-
// MARK: - DID Document Tests
16
17
-
@Test("DID Document parses correctly")
18
-
func testDIDDocumentParsing() throws {
19
-
let json = """
20
-
{
21
-
"@context": ["https://www.w3.org/ns/did/v1"],
22
-
"id": "did:plc:abc123",
23
-
"alsoKnownAs": ["at://alice.bsky.social"],
24
-
"verificationMethod": [{
25
-
"id": "#atproto",
26
-
"type": "Multikey",
27
-
"controller": "did:plc:abc123",
28
-
"publicKeyMultibase": "zDnae..."
29
-
}],
30
-
"service": [{
31
-
"id": "#atproto_pds",
32
-
"type": "AtprotoPersonalDataServer",
33
-
"serviceEndpoint": "https://bsky.social"
34
-
}]
35
-
}
36
-
""".data(using: .utf8)!
37
-
38
-
let document = try JSONDecoder().decode(DIDDocument.self, from: json)
39
-
40
-
#expect(document.id == "did:plc:abc123")
41
-
#expect(document.handle == "alice.bsky.social")
42
-
#expect(document.pdsEndpoint == "https://bsky.social")
43
-
#expect(document.verificationMethod?.count == 1)
44
-
#expect(document.service?.count == 1)
45
}
46
47
-
@Test("DID Document extracts handle from alsoKnownAs")
48
-
func testHandleExtraction() throws {
49
-
let document = DIDDocument(
50
-
id: "did:plc:test",
51
-
alsoKnownAs: ["at://user.example.com", "https://other.url"],
52
-
verificationMethod: nil,
53
-
service: nil
54
-
)
55
-
56
-
#expect(document.handle == "user.example.com")
57
}
58
59
-
@Test("DID Document returns nil handle when missing")
60
-
func testMissingHandle() throws {
61
-
let document = DIDDocument(
62
-
id: "did:plc:test",
63
-
alsoKnownAs: nil,
64
-
verificationMethod: nil,
65
-
service: nil
66
-
)
67
68
-
#expect(document.handle == nil)
69
-
}
70
-
71
-
@Test("PLC Directory response converts to DID Document")
72
-
func testPLCResponseConversion() throws {
73
-
let json = """
74
{
75
-
"did": "did:plc:xyz789",
76
-
"alsoKnownAs": ["at://bob.test.com"],
77
-
"verificationMethods": {
78
-
"#atproto": "did:key:zDnae..."
79
-
},
80
-
"services": {
81
-
"#atproto_pds": {
82
-
"type": "AtprotoPersonalDataServer",
83
-
"endpoint": "https://pds.example.com"
84
-
}
85
-
}
86
}
87
-
""".data(using: .utf8)!
88
-
89
-
let plcResponse = try JSONDecoder().decode(PLCDirectoryResponse.self, from: json)
90
-
let document = plcResponse.toDIDDocument()
91
-
92
-
#expect(document.id == "did:plc:xyz789")
93
-
#expect(document.alsoKnownAs?.contains("at://bob.test.com") == true)
94
}
95
96
-
// MARK: - Identity Error Tests
97
98
-
@Test("Identity errors are properly typed")
99
-
func testIdentityErrors() {
100
-
let handleError = IdentityError.invalidHandle("bad handle")
101
-
let pdsError = IdentityError.pdsNotFound
102
103
-
// Test error descriptions
104
-
#expect(String(describing: handleError).contains("invalidHandle"))
105
-
#expect(String(describing: pdsError).contains("pdsNotFound"))
106
-
}
107
-
108
-
// MARK: - Handle Validation Tests
109
-
110
-
@Test("Valid handles are accepted")
111
-
func testValidHandles() async throws {
112
-
// These should be valid handle formats
113
-
let validHandles = [
114
-
"alice.bsky.social",
115
-
"user.example.com",
116
-
"test.subdomain.domain.tld"
117
-
]
118
-
119
-
for handle in validHandles {
120
-
// Just testing the format is accepted
121
-
let normalized = handle.lowercased()
122
-
#expect(normalized.contains("."), "\(handle) should contain a dot")
123
-
}
124
-
}
125
-
126
-
// MARK: - Cache Tests
127
-
128
-
@Test("Cache is cleared correctly")
129
-
func testCacheClear() async {
130
-
let resolver = await IdentityResolver()
131
-
await resolver.clearCache()
132
-
// Should not throw
133
-
}
134
-
135
-
@Test("Cache TTL is configurable")
136
-
func testCacheTTL() async {
137
-
let resolver = await IdentityResolver()
138
-
let defaultTTL = await resolver.cacheTTL
139
-
#expect(defaultTTL == 600, "Default cache TTL should be 600 seconds")
140
-
141
-
await MainActor.run {
142
-
// Note: Need to access through proper isolation
143
-
}
144
-
}
145
-
146
-
// MARK: - Protected Resource Metadata Tests
147
-
148
-
@Test("Protected resource metadata parses correctly")
149
-
func testProtectedResourceMetadata() throws {
150
-
let json = """
151
-
{
152
-
"resource": "https://bsky.social",
153
-
"authorization_servers": ["https://bsky.social"]
154
-
}
155
-
""".data(using: .utf8)!
156
-
157
-
let metadata = try JSONDecoder().decode(
158
-
IdentityResolver.ProtectedResourceMetadata.self,
159
-
from: json
160
-
)
161
-
162
-
#expect(metadata.resource == "https://bsky.social")
163
-
#expect(metadata.authorizationServers.count == 1)
164
-
#expect(metadata.authorizationServers.first == "https://bsky.social")
165
-
}
166
}
···
1
+
import Foundation
2
import Testing
3
@testable import CoreATProtocol
4
5
+
@APActor
6
+
final class MockNetworking: Networking {
7
+
var requestedURLs: [URL] = []
8
+
var responseData: Data
9
+
var statusCode: Int
10
11
+
init(responseData: Data, statusCode: Int = 200) {
12
+
self.responseData = responseData
13
+
self.statusCode = statusCode
14
}
15
16
+
func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) {
17
+
if let url = request.url {
18
+
requestedURLs.append(url)
19
+
}
20
+
let response = HTTPURLResponse(url: request.url ?? URL(string: "https://example.com")!, statusCode: statusCode, httpVersion: nil, headerFields: [:])!
21
+
return (responseData, response)
22
}
23
+
}
24
25
+
struct MockDNSResolver: DNSResolving {
26
+
func txtRecords(for host: String) async throws -> [String] { [] }
27
+
}
28
29
+
@Test("Identity resolver fetches PLC DID documents using full identifier path")
30
+
func identityResolverUsesFullPLCPath() async throws {
31
+
let documentJSON = """
32
+
{
33
+
"id": "did:plc:identifier",
34
+
"service": [
35
{
36
+
"id": "#atproto_pds",
37
+
"type": "AtprotoPersonalDataServer",
38
+
"serviceEndpoint": "https://example.com"
39
}
40
+
]
41
}
42
+
""".data(using: .utf8)!
43
44
+
let networking = await MockNetworking(responseData: documentJSON)
45
+
let httpClient = await OAuthHTTPClient(networking: networking)
46
+
let resolver = await IdentityResolver(httpClient: httpClient, dnsResolver: MockDNSResolver())
47
48
+
let document = try await resolver.fetchDIDDocument(for: "did:plc:identifier")
49
+
#expect(document.id == "did:plc:identifier")
50
51
+
let requestedPath = await networking.requestedURLs.first?.path
52
+
#expect(requestedPath == "/did:plc:identifier")
53
}
+37
Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift
+37
Tests/CoreATProtocolTests/OAuthClientMetadataParsingTests.swift
···
···
1
+
import Foundation
2
+
import Testing
3
+
@testable import CoreATProtocol
4
+
5
+
@Test("Client metadata decodes from sample JSON")
6
+
func decodeClientMetadata() throws {
7
+
let json = """
8
+
{
9
+
"client_id": "https://sparrowtek.com/plume.json",
10
+
"client_name": "Plume iOS",
11
+
"application_type": "native",
12
+
"grant_types": [
13
+
"authorization_code",
14
+
"refresh_token"
15
+
],
16
+
"scope": "atproto",
17
+
"response_types": [
18
+
"code"
19
+
],
20
+
"redirect_uris": [
21
+
"com.sparrowtek.plume:/oauth/callback"
22
+
],
23
+
"token_endpoint_auth_method": "none",
24
+
"dpop_bound_access_tokens": true,
25
+
"client_uri": "https://sparrowtek.com",
26
+
"policy_uri": "https://sparrowtek.com/privacy",
27
+
"tos_uri": "https://sparrowtek.com/terms"
28
+
}
29
+
""".data(using: .utf8)!
30
+
31
+
let decoder = JSONDecoder()
32
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
33
+
let metadata = try decoder.decode(OAuthClientMetadata.self, from: json)
34
+
#expect(metadata.clientID.absoluteString == "https://sparrowtek.com/plume.json")
35
+
#expect(metadata.redirectURIs.count == 1)
36
+
#expect(metadata.dPoPBoundAccessTokens)
37
+
}
+15
Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift
+15
Tests/CoreATProtocolTests/OAuthMetadataParsingTests.swift
···
···
1
+
import Foundation
2
+
import Testing
3
+
@testable import CoreATProtocol
4
+
5
+
@Test("Authorization server metadata decodes from sample JSON")
6
+
func decodeAuthorizationServerMetadata() throws {
7
+
let json = """
8
+
{"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","introspection_endpoint":"https://bsky.social/oauth/introspect","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true}
9
+
""".data(using: .utf8)!
10
+
11
+
let decoder = JSONDecoder()
12
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
13
+
let metadata = try decoder.decode(OAuthAuthorizationServerMetadata.self, from: json)
14
+
#expect(metadata.authorizationEndpoint.absoluteString == "https://bsky.social/oauth/authorize")
15
+
}
+97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
+97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
···
···
1
+
import CryptoKit
2
+
import Foundation
3
+
import Testing
4
+
@testable import CoreATProtocol
5
+
6
+
private struct DeterministicRandomGenerator: RandomDataGenerating {
7
+
func data(count: Int) throws -> Data {
8
+
Data(repeating: 0x42, count: count)
9
+
}
10
+
}
11
+
12
+
@Test("Base64URL encodes without padding and decodes back")
13
+
func base64URLRoundTrip() throws {
14
+
let data = Data([0xde, 0xad, 0xbe, 0xef])
15
+
let encoded = Base64URL.encode(data)
16
+
#expect(encoded.contains("=") == false)
17
+
let decoded = try Base64URL.decode(encoded)
18
+
#expect(decoded == data)
19
+
}
20
+
21
+
@Test("PKCE generator creates verifier within bounds and matching challenge")
22
+
func pkceGeneratorProducesExpectedValues() throws {
23
+
let generator = PKCEGenerator(randomGenerator: DeterministicRandomGenerator())
24
+
let values = try generator.makeValues()
25
+
#expect(values.verifier.count >= 43)
26
+
#expect(values.verifier.count <= 128)
27
+
28
+
let expectedDigest = SHA256.hash(data: Data(values.verifier.utf8))
29
+
let expectedChallenge = Base64URL.encode(Data(expectedDigest))
30
+
#expect(values.challenge == expectedChallenge)
31
+
}
32
+
33
+
@Test("DPoP generator signs payload with expected claims")
34
+
func dpopGeneratorProducesValidProof() async throws {
35
+
let keyPair = DPoPKeyPair()
36
+
let generator = await DPoPGenerator(clock: { Date(timeIntervalSince1970: 1_700_000_000) })
37
+
try await generator.updateKey(using: keyPair.export())
38
+
let url = URL(string: "https://example.com/resource")!
39
+
let proof = try await generator.generateProof(
40
+
method: "GET",
41
+
url: url,
42
+
nonce: "nonce-value",
43
+
accessToken: "access-token"
44
+
)
45
+
46
+
let components = proof.split(separator: ".")
47
+
#expect(components.count == 3)
48
+
49
+
let headerData = try Base64URL.decode(String(components[0]))
50
+
let payloadData = try Base64URL.decode(String(components[1]))
51
+
let signatureData = try Base64URL.decode(String(components[2]))
52
+
53
+
let header = try JSONSerialization.jsonObject(with: headerData) as? [String: Any]
54
+
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
55
+
56
+
#expect(header?["typ"] as? String == "dpop+jwt")
57
+
#expect(header?["alg"] as? String == "ES256")
58
+
let jwk = header?["jwk"] as? [String: String]
59
+
#expect(jwk?["kty"] == "EC")
60
+
#expect(jwk?["crv"] == "P-256")
61
+
62
+
#expect(payload?["htm"] as? String == "GET")
63
+
#expect(payload?["htu"] as? String == "https://example.com/resource")
64
+
#expect(payload?["nonce"] as? String == "nonce-value")
65
+
#expect(payload?["ath"] as? String == Base64URL.encode(Data(SHA256.hash(data: Data("access-token".utf8)))))
66
+
67
+
if let iat = payload?["iat"] as? Int {
68
+
#expect(iat == 1_700_000_000)
69
+
} else {
70
+
Issue.record("DPoP payload missing iat")
71
+
}
72
+
73
+
let signingInput = Data((components[0] + "." + components[1]).utf8)
74
+
let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData)
75
+
#expect(keyPair.privateKey.publicKey.isValidSignature(signature, for: signingInput))
76
+
}
77
+
78
+
@Test("OAuth session refresh heuristics")
79
+
func oauthSessionRefreshLogic() {
80
+
let issuedAt = Date()
81
+
let session = OAuthSession(
82
+
did: "did:plc:example",
83
+
pdsURL: URL(string: "https://pds.example.com")!,
84
+
authorizationServer: URL(string: "https://auth.example.com")!,
85
+
tokenEndpoint: URL(string: "https://auth.example.com/token")!,
86
+
accessToken: "token",
87
+
refreshToken: "refresh",
88
+
tokenType: "DPoP",
89
+
scope: "atproto",
90
+
expiresIn: 3600,
91
+
issuedAt: issuedAt
92
+
)
93
+
94
+
#expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3500)) == false)
95
+
#expect(session.needsRefresh(relativeTo: issuedAt.addingTimeInterval(3300), threshold: 400))
96
+
#expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3600)))
97
+
}
-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
-
}
···