···5// Created by Thomas Rademaker on 10/10/25.
6//
708import OAuthenticator
910@APActor
11public class APEnvironment {
12 public static var current: APEnvironment = APEnvironment()
13-014 public var host: String?
0015 public var accessToken: String?
16 public var refreshToken: String?
17 public var login: Login?
0018 public var dpopProofGenerator: DPoPSigner.JWTGenerator?
19 public var resourceServerNonce: String?
000000000000020 public var atProtocoldelegate: CoreATProtocolDelegate?
21 public let routerDelegate = APRouterDelegate()
22- public let resourceDPoPSigner = DPoPSigner()
23-0024 private init() {}
25-26-// func setup(apiKey: String, apiSecret: String, userAgent: String) {
27-// self.apiKey = apiKey
28-// self.apiSecret = apiSecret
29-// self.userAgent = userAgent
30-// }
0000000000000000000000000000000000000000000000000000000000000000000000000031}
···5// Created by Thomas Rademaker on 10/10/25.
6//
78+import Foundation
9import OAuthenticator
1011@APActor
12public 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}
···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+}
···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+}
+192-13
Sources/CoreATProtocol/LoginService.swift
···8import Foundation
9import OAuthenticator
10011@APActor
12public final class LoginService {
13- public enum Error: Swift.Error {
0014 case missingStoredLogin
0000015 }
1617 private let loginStorage: LoginStorage
18 private let jwtGenerator: DPoPSigner.JWTGenerator
0019000020 public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) {
21 self.jwtGenerator = jwtGenerator
22 self.loginStorage = loginStorage
023 }
240000000000000025 public func login(account: String, clientMetadataEndpoint: String) async throws -> Login {
26 let provider = URLSession.defaultProvider
27- let host = APEnvironment.current.host ?? ""
28- let server = if host.hasPrefix("https://") {
29- String(host.dropFirst(8))
30- } else if host.hasPrefix("http://") {
31- String(host.dropFirst(7))
32- } else { host }
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003334- let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider)
35- let serverConfig = try await ServerMetadata.load(for: server, provider: provider)
3637- let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator)
38- let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic)
39- let authenticator = Authenticator(config: config)
40- try await authenticator.authenticate()
000000000000000000000000000000000000004142 guard let storedLogin = try await loginStorage.retrieveLogin() else {
43 throw Error.missingStoredLogin
44 }
45000000046 return storedLogin
000000047 }
48}
···8import Foundation
9import OAuthenticator
1011+/// Service for handling AT Protocol OAuth authentication.
12@APActor
13public 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 }
2425 private let loginStorage: LoginStorage
26 private let jwtGenerator: DPoPSigner.JWTGenerator
27+ private let identityResolver: IdentityResolver
28+ private var authenticator: Authenticator?
2930+ /// 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 }
3940+ /// 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+ }
161162+ ATLogger.shared.info("Login successful for \(resolvedIdentity.handle)")
0163164+ 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+ }
206207 guard let storedLogin = try await loginStorage.retrieveLogin() else {
208 throw Error.missingStoredLogin
209 }
210211+ applyAuthenticationContext(
212+ login: storedLogin,
213+ generator: jwtGenerator,
214+ serverMetadata: serverMetadata,
215+ clientId: clientMetadata.clientId
216+ )
217+218 return storedLogin
219+ }
220+221+ /// Logs out by clearing all stored tokens and authentication state.
222+ public func logout() async {
223+ await clearAuthenticationContext()
224+ authenticator = nil
225+ ATLogger.shared.info("Logged out")
226 }
227}
+207-5
Sources/CoreATProtocol/Models/ATError.swift
···5// Created by Thomas Rademaker on 10/8/25.
6//
78-public enum AtError: Error {
00009 case message(ErrorMessage)
0010 case network(NetworkError)
00000000000011}
120000000000000000000000000000000000000000000000000000000000000000013public struct ErrorMessage: Codable, Sendable {
14- #warning("Should error be type string or AtErrorType?")
15 public let error: String
0016 public let message: String?
17-18 public init(error: String, message: String?) {
19 self.error = error
20 self.message = message
21 }
0000022}
2324-public enum AtErrorType: String, Codable, Sendable {
0025 case authenticationRequired = "AuthenticationRequired"
26 case expiredToken = "ExpiredToken"
000027 case invalidRequest = "InvalidRequest"
028 case methodNotImplemented = "MethodNotImplemented"
0029 case rateLimitExceeded = "RateLimitExceeded"
30- case authMissing = "AuthMissing"
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031}
···5// Created by Thomas Rademaker on 10/8/25.
6//
78+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}
3031+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.
96public 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}
113114+/// 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}
···0001public enum HTTPTask: Sendable {
02 case request
3-04 case requestParameters(encoding: ParameterEncoding)
5-6- // case download, upload...etc
00000000000000000000000000000000000000000000000000000000000000000000000007}
···1+import Foundation
2+3+/// Describes the type of HTTP task to perform.
4public 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}
···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+}
···1import Testing
02@testable import CoreATProtocol
34-@Test func example() async throws {
5- // Write your test here and use APIs like `#expect(...)` to check expected conditions.
0000000000000000000000000000000000000000000000000000000000000000000000000000000000006}
···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+}