···55// Created by Thomas Rademaker on 10/10/25.
66//
7788+import Foundation
89import OAuthenticator
9101011@APActor
1112public class APEnvironment {
1213 public static var current: APEnvironment = APEnvironment()
1313-1414+1515+ // MARK: - Connection Configuration
1416 public var host: String?
1717+1818+ // MARK: - Authentication Tokens
1519 public var accessToken: String?
1620 public var refreshToken: String?
1721 public var login: Login?
2222+2323+ // MARK: - DPoP Support
1824 public var dpopProofGenerator: DPoPSigner.JWTGenerator?
1925 public var resourceServerNonce: String?
2626+ public let resourceDPoPSigner = DPoPSigner()
2727+2828+ // MARK: - OAuth Configuration (for token refresh)
2929+ public var serverMetadata: ServerMetadata?
3030+ public var clientId: String?
3131+ public var authState: AuthenticationState?
3232+ public var tokenStorage: TokenStorageProtocol?
3333+3434+ // MARK: - Identity
3535+ public var resolvedIdentity: IdentityResolver.ResolvedIdentity?
3636+ public let identityResolver = IdentityResolver()
3737+3838+ // MARK: - Delegates and Callbacks
2039 public var atProtocoldelegate: CoreATProtocolDelegate?
2140 public let routerDelegate = APRouterDelegate()
2222- public let resourceDPoPSigner = DPoPSigner()
2323-4141+4242+ // MARK: - State Flags
4343+ private var isRefreshing = false
4444+2445 private init() {}
2525-2626-// func setup(apiKey: String, apiSecret: String, userAgent: String) {
2727-// self.apiKey = apiKey
2828-// self.apiSecret = apiSecret
2929-// self.userAgent = userAgent
3030-// }
4646+4747+ // MARK: - Token Refresh
4848+4949+ /// Checks if the current access token needs refresh.
5050+ public var needsTokenRefresh: Bool {
5151+ if let state = authState {
5252+ return state.isAccessTokenExpired
5353+ }
5454+ // If no auth state, check login object
5555+ if let login = login {
5656+ return !login.accessToken.valid
5757+ }
5858+ return false
5959+ }
6060+6161+ /// Attempts to refresh the access token if needed.
6262+ /// Returns true if refresh succeeded or wasn't needed, false if refresh failed.
6363+ public func refreshTokenIfNeeded() async -> Bool {
6464+ guard needsTokenRefresh else { return true }
6565+6666+ // Prevent concurrent refresh attempts
6767+ guard !isRefreshing else { return false }
6868+ isRefreshing = true
6969+ defer { isRefreshing = false }
7070+7171+ return await performTokenRefresh()
7272+ }
7373+7474+ // MARK: - Configuration
7575+7676+ /// Configures the environment for OAuth with token refresh support.
7777+ public func configureOAuth(
7878+ serverMetadata: ServerMetadata,
7979+ clientId: String,
8080+ tokenStorage: TokenStorageProtocol? = nil
8181+ ) {
8282+ self.serverMetadata = serverMetadata
8383+ self.clientId = clientId
8484+ self.tokenStorage = tokenStorage
8585+ }
8686+8787+ /// Stores the complete authentication state after successful login.
8888+ public func setAuthenticationState(_ state: AuthenticationState) async {
8989+ self.authState = state
9090+ self.accessToken = state.accessToken
9191+ self.refreshToken = state.refreshToken
9292+9393+ // Update host from PDS URL
9494+ if let url = URL(string: state.pdsURL) {
9595+ self.host = url.absoluteString
9696+ }
9797+9898+ // Persist if storage is configured
9999+ if let storage = tokenStorage {
100100+ try? await storage.store(state)
101101+ }
102102+ }
103103+104104+ /// Restores authentication state from storage.
105105+ public func restoreAuthenticationState() async -> Bool {
106106+ guard let storage = tokenStorage else { return false }
107107+108108+ do {
109109+ guard let state = try await storage.retrieve() else {
110110+ return false
111111+ }
112112+113113+ self.authState = state
114114+ self.accessToken = state.accessToken
115115+ self.refreshToken = state.refreshToken
116116+117117+ if let url = URL(string: state.pdsURL) {
118118+ self.host = url.absoluteString
119119+ }
120120+121121+ return true
122122+ } catch {
123123+ return false
124124+ }
125125+ }
31126}
+132-3
Sources/CoreATProtocol/CoreATProtocol.swift
···3344@_exported import OAuthenticator
5566-public protocol CoreATProtocolDelegate: AnyObject {}
66+/// Delegate protocol for receiving authentication and session lifecycle events.
77+@MainActor
88+public protocol CoreATProtocolDelegate: AnyObject, Sendable {
99+ /// Called when tokens have been refreshed.
1010+ func tokensUpdated(accessToken: String, refreshToken: String?) async
1111+1212+ /// Called when a session has expired and re-authentication is required.
1313+ func sessionExpired() async
1414+1515+ /// Called when authentication fails.
1616+ func authenticationFailed(error: Error) async
7171818+ /// Called when DPoP nonce is updated from a server response.
1919+ func dpopNonceUpdated(nonce: String) async
2020+}
2121+2222+/// Default implementations for optional delegate methods.
2323+public extension CoreATProtocolDelegate {
2424+ func tokensUpdated(accessToken: String, refreshToken: String?) async {}
2525+ func sessionExpired() async {}
2626+ func authenticationFailed(error: Error) async {}
2727+ func dpopNonceUpdated(nonce: String) async {}
2828+}
2929+3030+// MARK: - Setup Functions
3131+3232+/// Configures the AT Protocol environment with basic authentication.
3333+/// - Parameters:
3434+/// - hostURL: The PDS host URL
3535+/// - accessJWT: Access token
3636+/// - refreshJWT: Refresh token
3737+/// - delegate: Optional delegate for receiving events
838@APActor
939public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) {
1040 APEnvironment.current.host = hostURL
···1343 APEnvironment.current.atProtocoldelegate = delegate
1444}
15454646+/// Configures the AT Protocol environment with OAuth support.
4747+/// - Parameters:
4848+/// - serverMetadata: OAuth authorization server metadata
4949+/// - clientId: The client ID for this application
5050+/// - tokenStorage: Optional persistent storage for tokens
5151+/// - delegate: Optional delegate for receiving events
5252+@APActor
5353+public func setupOAuth(
5454+ serverMetadata: ServerMetadata,
5555+ clientId: String,
5656+ tokenStorage: TokenStorageProtocol? = nil,
5757+ delegate: CoreATProtocolDelegate? = nil
5858+) {
5959+ APEnvironment.current.configureOAuth(
6060+ serverMetadata: serverMetadata,
6161+ clientId: clientId,
6262+ tokenStorage: tokenStorage
6363+ )
6464+ APEnvironment.current.atProtocoldelegate = delegate
6565+}
6666+6767+/// Sets the delegate for receiving authentication events.
1668@APActor
1769public func setDelegate(_ delegate: CoreATProtocolDelegate) {
1870 APEnvironment.current.atProtocoldelegate = delegate
1971}
20727373+/// Updates the stored tokens.
2174@APActor
2275public func updateTokens(access: String?, refresh: String?) {
2376 APEnvironment.current.accessToken = access
2477 APEnvironment.current.refreshToken = refresh
2578}
26798080+/// Updates the host URL.
2781@APActor
2882public func update(hostURL: String?) {
2983 APEnvironment.current.host = hostURL
3084}
31858686+/// Applies a complete authentication context from a successful OAuth login.
8787+/// - Parameters:
8888+/// - login: The Login object from OAuthenticator
8989+/// - generator: DPoP JWT generator for signing requests
9090+/// - resourceNonce: Initial DPoP nonce from the resource server
9191+/// - serverMetadata: OAuth server metadata for token refresh
9292+/// - clientId: Client ID for token refresh
3293@APActor
3333-public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) {
9494+public func applyAuthenticationContext(
9595+ login: Login,
9696+ generator: @escaping DPoPSigner.JWTGenerator,
9797+ resourceNonce: String? = nil,
9898+ serverMetadata: ServerMetadata? = nil,
9999+ clientId: String? = nil
100100+) {
34101 APEnvironment.current.login = login
35102 APEnvironment.current.accessToken = login.accessToken.value
36103 APEnvironment.current.refreshToken = login.refreshToken?.value
37104 APEnvironment.current.dpopProofGenerator = generator
38105 APEnvironment.current.resourceServerNonce = resourceNonce
39106 APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce
107107+108108+ // Store OAuth configuration if provided (needed for token refresh)
109109+ if let metadata = serverMetadata {
110110+ APEnvironment.current.serverMetadata = metadata
111111+ }
112112+ if let id = clientId {
113113+ APEnvironment.current.clientId = id
114114+ }
40115}
41116117117+/// Clears all authentication context and tokens.
42118@APActor
4343-public func clearAuthenticationContext() {
119119+public func clearAuthenticationContext() async {
44120 APEnvironment.current.login = nil
45121 APEnvironment.current.dpopProofGenerator = nil
46122 APEnvironment.current.resourceServerNonce = nil
47123 APEnvironment.current.accessToken = nil
48124 APEnvironment.current.refreshToken = nil
49125 APEnvironment.current.resourceDPoPSigner.nonce = nil
126126+ APEnvironment.current.authState = nil
127127+ APEnvironment.current.resolvedIdentity = nil
128128+129129+ // Clear persistent storage if configured
130130+ if let storage = APEnvironment.current.tokenStorage {
131131+ try? await storage.clear()
132132+ }
50133}
51134135135+/// Updates the resource server DPoP nonce.
52136@APActor
53137public func updateResourceDPoPNonce(_ nonce: String?) {
54138 APEnvironment.current.resourceServerNonce = nonce
55139 APEnvironment.current.resourceDPoPSigner.nonce = nonce
56140}
141141+142142+// MARK: - Identity Resolution
143143+144144+/// Resolves a handle to a complete identity with PDS and authorization server URLs.
145145+/// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social")
146146+/// - Returns: Complete resolved identity information
147147+@APActor
148148+public func resolveIdentity(handle: String) async throws -> IdentityResolver.ResolvedIdentity {
149149+ let identity = try await APEnvironment.current.identityResolver.resolveIdentity(handle: handle)
150150+ APEnvironment.current.resolvedIdentity = identity
151151+ APEnvironment.current.host = identity.pdsURL
152152+ return identity
153153+}
154154+155155+/// Resolves a DID to a complete identity with PDS and authorization server URLs.
156156+/// - Parameter did: The DID to resolve (e.g., "did:plc:abc123")
157157+/// - Returns: Complete resolved identity information
158158+@APActor
159159+public func resolveIdentity(did: String) async throws -> IdentityResolver.ResolvedIdentity {
160160+ let identity = try await APEnvironment.current.identityResolver.resolveIdentity(did: did)
161161+ APEnvironment.current.resolvedIdentity = identity
162162+ APEnvironment.current.host = identity.pdsURL
163163+ return identity
164164+}
165165+166166+// MARK: - Session Management
167167+168168+/// Attempts to restore a previous session from persistent storage.
169169+/// - Returns: true if a session was restored, false otherwise
170170+@APActor
171171+public func restoreSession() async -> Bool {
172172+ return await APEnvironment.current.restoreAuthenticationState()
173173+}
174174+175175+/// Checks if the current session is valid and has non-expired tokens.
176176+@APActor
177177+public var hasValidSession: Bool {
178178+ if let state = APEnvironment.current.authState {
179179+ return !state.isAccessTokenExpired || state.canRefresh
180180+ }
181181+ if let login = APEnvironment.current.login {
182182+ return login.accessToken.valid || (login.refreshToken?.valid ?? false)
183183+ }
184184+ return APEnvironment.current.accessToken != nil
185185+}
+123
Sources/CoreATProtocol/Identity/DIDDocument.swift
···11+//
22+// DIDDocument.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+1010+/// Represents a DID Document as specified by the AT Protocol.
1111+/// DID Documents contain the public key and service endpoints for an identity.
1212+public struct DIDDocument: Codable, Sendable, Hashable {
1313+ public let context: [String]
1414+ public let id: String
1515+ public let alsoKnownAs: [String]?
1616+ public let verificationMethod: [VerificationMethod]?
1717+ public let service: [Service]?
1818+1919+ enum CodingKeys: String, CodingKey {
2020+ case context = "@context"
2121+ case id
2222+ case alsoKnownAs
2323+ case verificationMethod
2424+ case service
2525+ }
2626+2727+ public init(
2828+ context: [String] = ["https://www.w3.org/ns/did/v1"],
2929+ id: String,
3030+ alsoKnownAs: [String]? = nil,
3131+ verificationMethod: [VerificationMethod]? = nil,
3232+ service: [Service]? = nil
3333+ ) {
3434+ self.context = context
3535+ self.id = id
3636+ self.alsoKnownAs = alsoKnownAs
3737+ self.verificationMethod = verificationMethod
3838+ self.service = service
3939+ }
4040+4141+ /// Extracts the handle from the alsoKnownAs field.
4242+ /// Handles are stored as `at://handle` URIs.
4343+ public var handle: String? {
4444+ alsoKnownAs?.compactMap { uri -> String? in
4545+ guard uri.hasPrefix("at://") else { return nil }
4646+ return String(uri.dropFirst(5))
4747+ }.first
4848+ }
4949+5050+ /// Extracts the PDS (Personal Data Server) endpoint from the service array.
5151+ public var pdsEndpoint: String? {
5252+ service?.first { $0.id == "#atproto_pds" || $0.type == "AtprotoPersonalDataServer" }?.serviceEndpoint
5353+ }
5454+}
5555+5656+/// Represents a verification method in a DID Document.
5757+public struct VerificationMethod: Codable, Sendable, Hashable {
5858+ public let id: String
5959+ public let type: String
6060+ public let controller: String
6161+ public let publicKeyMultibase: String?
6262+6363+ public init(id: String, type: String, controller: String, publicKeyMultibase: String? = nil) {
6464+ self.id = id
6565+ self.type = type
6666+ self.controller = controller
6767+ self.publicKeyMultibase = publicKeyMultibase
6868+ }
6969+}
7070+7171+/// Represents a service endpoint in a DID Document.
7272+public struct Service: Codable, Sendable, Hashable {
7373+ public let id: String
7474+ public let type: String
7575+ public let serviceEndpoint: String
7676+7777+ public init(id: String, type: String, serviceEndpoint: String) {
7878+ self.id = id
7979+ self.type = type
8080+ self.serviceEndpoint = serviceEndpoint
8181+ }
8282+}
8383+8484+/// Represents the response from a PLC directory lookup.
8585+public struct PLCDirectoryResponse: Codable, Sendable {
8686+ public let did: String
8787+ public let verificationMethods: [String: String]?
8888+ public let rotationKeys: [String]?
8989+ public let alsoKnownAs: [String]?
9090+ public let services: [String: PLCService]?
9191+9292+ public struct PLCService: Codable, Sendable {
9393+ public let type: String
9494+ public let endpoint: String
9595+ }
9696+9797+ /// Converts PLC response to standard DID Document format.
9898+ public func toDIDDocument() -> DIDDocument {
9999+ let verificationMethods = self.verificationMethods?.map { (id, key) in
100100+ VerificationMethod(
101101+ id: "\(did)\(id)",
102102+ type: "Multikey",
103103+ controller: did,
104104+ publicKeyMultibase: key
105105+ )
106106+ }
107107+108108+ let services = self.services?.map { (id, service) in
109109+ Service(
110110+ id: id,
111111+ type: service.type,
112112+ serviceEndpoint: service.endpoint
113113+ )
114114+ }
115115+116116+ return DIDDocument(
117117+ id: did,
118118+ alsoKnownAs: alsoKnownAs,
119119+ verificationMethod: verificationMethods,
120120+ service: services
121121+ )
122122+ }
123123+}
···11+//
22+// IdentityResolver.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+@preconcurrency import OAuthenticator
1010+1111+/// Errors that can occur during identity resolution.
1212+public enum IdentityError: Error, Sendable {
1313+ case invalidHandle(String)
1414+ case invalidDID(String)
1515+ case handleResolutionFailed(String)
1616+ case didResolutionFailed(String)
1717+ case pdsNotFound
1818+ case authorizationServerNotFound
1919+ case networkError(Error)
2020+ case invalidURL(String)
2121+ case bidirectionalVerificationFailed(handle: String, did: String)
2222+}
2323+2424+/// Resolves AT Protocol identities (handles and DIDs) to their associated metadata.
2525+///
2626+/// This resolver handles:
2727+/// - Handle to DID resolution via `.well-known/atproto-did`
2828+/// - DID document fetching for both `did:plc` and `did:web` methods
2929+/// - PDS (Personal Data Server) endpoint discovery
3030+/// - Authorization server metadata fetching
3131+/// - Bidirectional handle verification
3232+@APActor
3333+public final class IdentityResolver {
3434+3535+ /// Cache entry for resolved identities.
3636+ private struct CacheEntry {
3737+ let document: DIDDocument
3838+ let timestamp: Date
3939+ }
4040+4141+ private let urlSession: URLSession
4242+ private var cache: [String: CacheEntry] = [:]
4343+4444+ /// Cache TTL in seconds. Default is 10 minutes as recommended by AT Protocol spec.
4545+ public var cacheTTL: TimeInterval = 600
4646+4747+ /// The PLC directory URL for resolving did:plc identifiers.
4848+ public var plcDirectoryURL: String = "https://plc.directory"
4949+5050+ public init(urlSession: URLSession = .shared) {
5151+ self.urlSession = urlSession
5252+ }
5353+5454+ // MARK: - Handle Resolution
5555+5656+ /// Resolves a handle to a DID using the `.well-known/atproto-did` endpoint.
5757+ /// - Parameter handle: The handle to resolve (e.g., "alice.bsky.social")
5858+ /// - Returns: The DID string (e.g., "did:plc:abc123")
5959+ public func resolveHandle(_ handle: String) async throws -> String {
6060+ let normalizedHandle = handle.lowercased().trimmingCharacters(in: .whitespaces)
6161+6262+ guard isValidHandle(normalizedHandle) else {
6363+ throw IdentityError.invalidHandle(handle)
6464+ }
6565+6666+ let urlString = "https://\(normalizedHandle)/.well-known/atproto-did"
6767+ guard let url = URL(string: urlString) else {
6868+ throw IdentityError.invalidURL(urlString)
6969+ }
7070+7171+ do {
7272+ let (data, response) = try await urlSession.data(from: url)
7373+7474+ guard let httpResponse = response as? HTTPURLResponse,
7575+ (200...299).contains(httpResponse.statusCode) else {
7676+ throw IdentityError.handleResolutionFailed(handle)
7777+ }
7878+7979+ guard let did = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
8080+ did.hasPrefix("did:") else {
8181+ throw IdentityError.handleResolutionFailed(handle)
8282+ }
8383+8484+ return did
8585+ } catch let error as IdentityError {
8686+ throw error
8787+ } catch {
8888+ throw IdentityError.networkError(error)
8989+ }
9090+ }
9191+9292+ // MARK: - DID Resolution
9393+9494+ /// Resolves a DID to its DID Document.
9595+ /// - Parameter did: The DID to resolve (e.g., "did:plc:abc123" or "did:web:example.com")
9696+ /// - Returns: The DID Document containing verification methods and service endpoints
9797+ public func resolveDID(_ did: String) async throws -> DIDDocument {
9898+ // Check cache first
9999+ if let cached = cache[did], Date().timeIntervalSince(cached.timestamp) < cacheTTL {
100100+ return cached.document
101101+ }
102102+103103+ let document: DIDDocument
104104+105105+ if did.hasPrefix("did:plc:") {
106106+ document = try await resolvePLCDID(did)
107107+ } else if did.hasPrefix("did:web:") {
108108+ document = try await resolveWebDID(did)
109109+ } else {
110110+ throw IdentityError.invalidDID(did)
111111+ }
112112+113113+ // Cache the result
114114+ cache[did] = CacheEntry(document: document, timestamp: Date())
115115+116116+ return document
117117+ }
118118+119119+ /// Resolves a did:plc identifier using the PLC directory.
120120+ private func resolvePLCDID(_ did: String) async throws -> DIDDocument {
121121+ let urlString = "\(plcDirectoryURL)/\(did)"
122122+ guard let url = URL(string: urlString) else {
123123+ throw IdentityError.invalidURL(urlString)
124124+ }
125125+126126+ do {
127127+ let (data, response) = try await urlSession.data(from: url)
128128+129129+ guard let httpResponse = response as? HTTPURLResponse,
130130+ (200...299).contains(httpResponse.statusCode) else {
131131+ throw IdentityError.didResolutionFailed(did)
132132+ }
133133+134134+ // Try to decode as PLC directory response first
135135+ if let plcResponse = try? JSONDecoder().decode(PLCDirectoryResponse.self, from: data) {
136136+ return plcResponse.toDIDDocument()
137137+ }
138138+139139+ // Fall back to standard DID document format
140140+ return try JSONDecoder().decode(DIDDocument.self, from: data)
141141+ } catch let error as IdentityError {
142142+ throw error
143143+ } catch {
144144+ throw IdentityError.networkError(error)
145145+ }
146146+ }
147147+148148+ /// Resolves a did:web identifier.
149149+ private func resolveWebDID(_ did: String) async throws -> DIDDocument {
150150+ // did:web:example.com -> https://example.com/.well-known/did.json
151151+ // did:web:example.com:path:to:resource -> https://example.com/path/to/resource/did.json
152152+ let identifier = String(did.dropFirst("did:web:".count))
153153+ let parts = identifier.split(separator: ":").map(String.init)
154154+155155+ let urlString: String
156156+ if parts.count == 1 {
157157+ urlString = "https://\(parts[0])/.well-known/did.json"
158158+ } else {
159159+ let host = parts[0]
160160+ let path = parts.dropFirst().joined(separator: "/")
161161+ urlString = "https://\(host)/\(path)/did.json"
162162+ }
163163+164164+ guard let url = URL(string: urlString) else {
165165+ throw IdentityError.invalidURL(urlString)
166166+ }
167167+168168+ do {
169169+ let (data, response) = try await urlSession.data(from: url)
170170+171171+ guard let httpResponse = response as? HTTPURLResponse,
172172+ (200...299).contains(httpResponse.statusCode) else {
173173+ throw IdentityError.didResolutionFailed(did)
174174+ }
175175+176176+ return try JSONDecoder().decode(DIDDocument.self, from: data)
177177+ } catch let error as IdentityError {
178178+ throw error
179179+ } catch {
180180+ throw IdentityError.networkError(error)
181181+ }
182182+ }
183183+184184+ // MARK: - PDS Discovery
185185+186186+ /// Gets the PDS endpoint for a given DID.
187187+ /// - Parameter did: The DID to look up
188188+ /// - Returns: The PDS service endpoint URL
189189+ public func getPDSEndpoint(for did: String) async throws -> String {
190190+ let document = try await resolveDID(did)
191191+192192+ guard let pds = document.pdsEndpoint else {
193193+ throw IdentityError.pdsNotFound
194194+ }
195195+196196+ return pds
197197+ }
198198+199199+ // MARK: - Authorization Server Discovery
200200+201201+ /// Represents the OAuth Protected Resource metadata from a PDS.
202202+ public struct ProtectedResourceMetadata: Codable, Sendable {
203203+ public let resource: String
204204+ public let authorizationServers: [String]
205205+206206+ enum CodingKeys: String, CodingKey {
207207+ case resource
208208+ case authorizationServers = "authorization_servers"
209209+ }
210210+ }
211211+212212+ /// Fetches the authorization server URL from a PDS.
213213+ /// - Parameter pdsURL: The PDS base URL
214214+ /// - Returns: The authorization server URL
215215+ public func getAuthorizationServer(from pdsURL: String) async throws -> String {
216216+ let normalizedPDS = pdsURL.hasSuffix("/") ? String(pdsURL.dropLast()) : pdsURL
217217+ let urlString = "\(normalizedPDS)/.well-known/oauth-protected-resource"
218218+219219+ guard let url = URL(string: urlString) else {
220220+ throw IdentityError.invalidURL(urlString)
221221+ }
222222+223223+ do {
224224+ let (data, response) = try await urlSession.data(from: url)
225225+226226+ guard let httpResponse = response as? HTTPURLResponse,
227227+ (200...299).contains(httpResponse.statusCode) else {
228228+ throw IdentityError.authorizationServerNotFound
229229+ }
230230+231231+ let metadata = try JSONDecoder().decode(ProtectedResourceMetadata.self, from: data)
232232+233233+ guard let authServer = metadata.authorizationServers.first else {
234234+ throw IdentityError.authorizationServerNotFound
235235+ }
236236+237237+ return authServer
238238+ } catch let error as IdentityError {
239239+ throw error
240240+ } catch {
241241+ throw IdentityError.networkError(error)
242242+ }
243243+ }
244244+245245+ // MARK: - Full Resolution
246246+247247+ /// Result of resolving an identity including all metadata.
248248+ public struct ResolvedIdentity: Sendable {
249249+ public let handle: String
250250+ public let did: String
251251+ public let didDocument: DIDDocument
252252+ public let pdsURL: String
253253+ public let authorizationServerURL: String
254254+255255+ public init(handle: String, did: String, didDocument: DIDDocument, pdsURL: String, authorizationServerURL: String) {
256256+ self.handle = handle
257257+ self.did = did
258258+ self.didDocument = didDocument
259259+ self.pdsURL = pdsURL
260260+ self.authorizationServerURL = authorizationServerURL
261261+ }
262262+ }
263263+264264+ /// Fully resolves an identity from a handle, including bidirectional verification.
265265+ /// - Parameter handle: The handle to resolve
266266+ /// - Returns: Complete identity information including PDS and auth server
267267+ public func resolveIdentity(handle: String) async throws -> ResolvedIdentity {
268268+ // Step 1: Resolve handle to DID
269269+ let did = try await resolveHandle(handle)
270270+271271+ // Step 2: Resolve DID to document
272272+ let document = try await resolveDID(did)
273273+274274+ // Step 3: Verify bidirectional handle claim
275275+ let normalizedHandle = handle.lowercased()
276276+ if let documentHandle = document.handle?.lowercased(), documentHandle != normalizedHandle {
277277+ throw IdentityError.bidirectionalVerificationFailed(handle: handle, did: did)
278278+ }
279279+280280+ // Step 4: Get PDS endpoint
281281+ guard let pdsURL = document.pdsEndpoint else {
282282+ throw IdentityError.pdsNotFound
283283+ }
284284+285285+ // Step 5: Get authorization server
286286+ let authServerURL = try await getAuthorizationServer(from: pdsURL)
287287+288288+ return ResolvedIdentity(
289289+ handle: handle,
290290+ did: did,
291291+ didDocument: document,
292292+ pdsURL: pdsURL,
293293+ authorizationServerURL: authServerURL
294294+ )
295295+ }
296296+297297+ /// Resolves identity starting from a DID.
298298+ /// - Parameter did: The DID to resolve
299299+ /// - Returns: Complete identity information
300300+ public func resolveIdentity(did: String) async throws -> ResolvedIdentity {
301301+ let document = try await resolveDID(did)
302302+303303+ guard let pdsURL = document.pdsEndpoint else {
304304+ throw IdentityError.pdsNotFound
305305+ }
306306+307307+ let authServerURL = try await getAuthorizationServer(from: pdsURL)
308308+309309+ return ResolvedIdentity(
310310+ handle: document.handle ?? "",
311311+ did: did,
312312+ didDocument: document,
313313+ pdsURL: pdsURL,
314314+ authorizationServerURL: authServerURL
315315+ )
316316+ }
317317+318318+ // MARK: - Validation
319319+320320+ /// Validates if a string is a valid handle format.
321321+ private func isValidHandle(_ handle: String) -> Bool {
322322+ // Basic validation: must have at least one dot, no spaces, reasonable length
323323+ let parts = handle.split(separator: ".")
324324+ guard parts.count >= 2 else { return false }
325325+ guard handle.count >= 3 && handle.count <= 253 else { return false }
326326+ guard !handle.contains(" ") else { return false }
327327+ return true
328328+ }
329329+330330+ /// Clears the identity cache.
331331+ public func clearCache() {
332332+ cache.removeAll()
333333+ }
334334+}
+245
Sources/CoreATProtocol/Logging/ATLogger.swift
···11+//
22+// ATLogger.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+import os.log
1010+1111+/// Log levels for AT Protocol operations.
1212+public enum ATLogLevel: Int, Comparable, Sendable {
1313+ case debug = 0
1414+ case info = 1
1515+ case warning = 2
1616+ case error = 3
1717+ case none = 100
1818+1919+ public static func < (lhs: ATLogLevel, rhs: ATLogLevel) -> Bool {
2020+ lhs.rawValue < rhs.rawValue
2121+ }
2222+}
2323+2424+/// Protocol for custom log handlers.
2525+public protocol ATLogHandler: Sendable {
2626+ func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int)
2727+}
2828+2929+/// Logger for AT Protocol operations.
3030+/// Provides structured logging with support for custom handlers.
3131+public final class ATLogger: @unchecked Sendable {
3232+3333+ /// Shared logger instance.
3434+ public static let shared = ATLogger()
3535+3636+ /// Current log level. Messages below this level are not logged.
3737+ public var logLevel: ATLogLevel = .info
3838+3939+ /// Custom log handler. If nil, uses OSLog on Apple platforms.
4040+ public var handler: ATLogHandler?
4141+4242+ /// Whether to include request/response bodies in logs (may contain sensitive data).
4343+ public var logBodies: Bool = false
4444+4545+ /// Whether to redact authorization headers and tokens.
4646+ public var redactTokens: Bool = true
4747+4848+ private let osLog: OSLog
4949+5050+ private init() {
5151+ self.osLog = OSLog(subsystem: "com.atprotocol.core", category: "network")
5252+ }
5353+5454+ // MARK: - Logging Methods
5555+5656+ /// Logs a debug message.
5757+ public func debug(
5858+ _ message: @autoclosure () -> String,
5959+ metadata: [String: String]? = nil,
6060+ file: String = #file,
6161+ function: String = #function,
6262+ line: Int = #line
6363+ ) {
6464+ log(level: .debug, message: message(), metadata: metadata, file: file, function: function, line: line)
6565+ }
6666+6767+ /// Logs an info message.
6868+ public func info(
6969+ _ message: @autoclosure () -> String,
7070+ metadata: [String: String]? = nil,
7171+ file: String = #file,
7272+ function: String = #function,
7373+ line: Int = #line
7474+ ) {
7575+ log(level: .info, message: message(), metadata: metadata, file: file, function: function, line: line)
7676+ }
7777+7878+ /// Logs a warning message.
7979+ public func warning(
8080+ _ message: @autoclosure () -> String,
8181+ metadata: [String: String]? = nil,
8282+ file: String = #file,
8383+ function: String = #function,
8484+ line: Int = #line
8585+ ) {
8686+ log(level: .warning, message: message(), metadata: metadata, file: file, function: function, line: line)
8787+ }
8888+8989+ /// Logs an error message.
9090+ public func error(
9191+ _ message: @autoclosure () -> String,
9292+ metadata: [String: String]? = nil,
9393+ file: String = #file,
9494+ function: String = #function,
9595+ line: Int = #line
9696+ ) {
9797+ log(level: .error, message: message(), metadata: metadata, file: file, function: function, line: line)
9898+ }
9999+100100+ // MARK: - Network Logging
101101+102102+ /// Logs an outgoing request.
103103+ public func logRequest(_ request: URLRequest, id: String = UUID().uuidString) {
104104+ guard logLevel <= .debug else { return }
105105+106106+ var metadata: [String: String] = [
107107+ "request_id": id,
108108+ "method": request.httpMethod ?? "UNKNOWN",
109109+ "url": request.url?.absoluteString ?? "unknown"
110110+ ]
111111+112112+ // Add headers (redacting sensitive ones)
113113+ if let headers = request.allHTTPHeaderFields {
114114+ for (key, value) in headers {
115115+ let redactedValue = shouldRedact(header: key) ? "[REDACTED]" : value
116116+ metadata["header_\(key)"] = redactedValue
117117+ }
118118+ }
119119+120120+ // Optionally log body
121121+ if logBodies, let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) {
122122+ let truncated = bodyString.count > 1000 ? String(bodyString.prefix(1000)) + "..." : bodyString
123123+ metadata["body"] = truncated
124124+ }
125125+126126+ debug("Request: \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")", metadata: metadata)
127127+ }
128128+129129+ /// Logs an incoming response.
130130+ public func logResponse(_ response: URLResponse, data: Data?, error: Error?, id: String = UUID().uuidString, duration: TimeInterval? = nil) {
131131+ guard logLevel <= .debug else { return }
132132+133133+ var metadata: [String: String] = ["request_id": id]
134134+135135+ if let httpResponse = response as? HTTPURLResponse {
136136+ metadata["status_code"] = String(httpResponse.statusCode)
137137+ metadata["url"] = httpResponse.url?.absoluteString ?? "unknown"
138138+ }
139139+140140+ if let duration = duration {
141141+ metadata["duration_ms"] = String(format: "%.2f", duration * 1000)
142142+ }
143143+144144+ if let data = data {
145145+ metadata["response_size"] = String(data.count)
146146+147147+ if logBodies, let bodyString = String(data: data, encoding: .utf8) {
148148+ let truncated = bodyString.count > 1000 ? String(bodyString.prefix(1000)) + "..." : bodyString
149149+ metadata["body"] = truncated
150150+ }
151151+ }
152152+153153+ if let error = error {
154154+ metadata["error"] = error.localizedDescription
155155+ self.error("Response error: \(error.localizedDescription)", metadata: metadata)
156156+ } else if let httpResponse = response as? HTTPURLResponse {
157157+ let message = "Response: \(httpResponse.statusCode)"
158158+ if httpResponse.statusCode >= 400 {
159159+ warning(message, metadata: metadata)
160160+ } else {
161161+ debug(message, metadata: metadata)
162162+ }
163163+ }
164164+ }
165165+166166+ /// Logs a token refresh attempt.
167167+ public func logTokenRefresh(success: Bool, error: Error? = nil) {
168168+ if success {
169169+ info("Token refresh successful")
170170+ } else if let error = error {
171171+ self.error("Token refresh failed: \(error.localizedDescription)")
172172+ } else {
173173+ warning("Token refresh failed")
174174+ }
175175+ }
176176+177177+ /// Logs identity resolution.
178178+ public func logIdentityResolution(handle: String? = nil, did: String? = nil, success: Bool, error: Error? = nil) {
179179+ var metadata: [String: String] = [:]
180180+ if let handle = handle { metadata["handle"] = handle }
181181+ if let did = did { metadata["did"] = did }
182182+183183+ if success {
184184+ debug("Identity resolved", metadata: metadata)
185185+ } else if let error = error {
186186+ self.error("Identity resolution failed: \(error.localizedDescription)", metadata: metadata)
187187+ }
188188+ }
189189+190190+ // MARK: - Private
191191+192192+ private func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) {
193193+ guard level >= logLevel else { return }
194194+195195+ if let handler = handler {
196196+ handler.log(level: level, message: message, metadata: metadata, file: file, function: function, line: line)
197197+ } else {
198198+ let fileName = (file as NSString).lastPathComponent
199199+ let logMessage = "[\(fileName):\(line)] \(function) - \(message)"
200200+201201+ switch level {
202202+ case .debug:
203203+ os_log(.debug, log: osLog, "%{public}@", logMessage)
204204+ case .info:
205205+ os_log(.info, log: osLog, "%{public}@", logMessage)
206206+ case .warning:
207207+ os_log(.default, log: osLog, "⚠️ %{public}@", logMessage)
208208+ case .error:
209209+ os_log(.error, log: osLog, "%{public}@", logMessage)
210210+ case .none:
211211+ break
212212+ }
213213+ }
214214+ }
215215+216216+ private func shouldRedact(header: String) -> Bool {
217217+ guard redactTokens else { return false }
218218+ let sensitiveHeaders = ["authorization", "dpop", "cookie", "set-cookie"]
219219+ return sensitiveHeaders.contains(header.lowercased())
220220+ }
221221+}
222222+223223+/// Console log handler for development.
224224+public struct ConsoleLogHandler: ATLogHandler {
225225+ public init() {}
226226+227227+ public func log(level: ATLogLevel, message: String, metadata: [String: String]?, file: String, function: String, line: Int) {
228228+ let fileName = (file as NSString).lastPathComponent
229229+ let prefix: String
230230+ switch level {
231231+ case .debug: prefix = "🔍 DEBUG"
232232+ case .info: prefix = "ℹ️ INFO"
233233+ case .warning: prefix = "⚠️ WARNING"
234234+ case .error: prefix = "❌ ERROR"
235235+ case .none: return
236236+ }
237237+238238+ var output = "\(prefix) [\(fileName):\(line)] \(message)"
239239+ if let metadata = metadata, !metadata.isEmpty {
240240+ let metaString = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
241241+ output += " {\(metaString)}"
242242+ }
243243+ print(output)
244244+ }
245245+}
+192-13
Sources/CoreATProtocol/LoginService.swift
···88import Foundation
99import OAuthenticator
10101111+/// Service for handling AT Protocol OAuth authentication.
1112@APActor
1213public final class LoginService {
1313- public enum Error: Swift.Error {
1414+1515+ /// Errors that can occur during login.
1616+ public enum Error: Swift.Error, Sendable {
1417 case missingStoredLogin
1818+ case identityResolutionFailed(IdentityError)
1919+ case serverMetadataFailed
2020+ case clientMetadataFailed
2121+ case authenticationFailed(Swift.Error)
2222+ case subjectMismatch(expected: String, actual: String)
1523 }
16241725 private let loginStorage: LoginStorage
1826 private let jwtGenerator: DPoPSigner.JWTGenerator
2727+ private let identityResolver: IdentityResolver
2828+ private var authenticator: Authenticator?
19293030+ /// Creates a new login service.
3131+ /// - Parameters:
3232+ /// - jwtGenerator: DPoP JWT generator for signing proofs
3333+ /// - loginStorage: Storage for persisting login tokens
2034 public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) {
2135 self.jwtGenerator = jwtGenerator
2236 self.loginStorage = loginStorage
3737+ self.identityResolver = IdentityResolver()
2338 }
24394040+ /// Performs OAuth login for an AT Protocol account.
4141+ ///
4242+ /// This method:
4343+ /// 1. Resolves the account handle/DID to find the PDS
4444+ /// 2. Discovers OAuth server metadata
4545+ /// 3. Fetches client metadata
4646+ /// 4. Performs PKCE + PAR + DPoP OAuth flow
4747+ /// 5. Verifies the returned identity matches the expected account
4848+ /// 6. Stores the tokens and updates the environment
4949+ ///
5050+ /// - Parameters:
5151+ /// - account: Handle or DID of the account to authenticate
5252+ /// - clientMetadataEndpoint: URL where the client metadata document is published
5353+ /// - Returns: The Login object with access and refresh tokens
2554 public func login(account: String, clientMetadataEndpoint: String) async throws -> Login {
2655 let provider = URLSession.defaultProvider
2727- let host = APEnvironment.current.host ?? ""
2828- let server = if host.hasPrefix("https://") {
2929- String(host.dropFirst(8))
3030- } else if host.hasPrefix("http://") {
3131- String(host.dropFirst(7))
3232- } else { host }
5656+5757+ // Step 1: Resolve identity to find PDS and auth server
5858+ let resolvedIdentity: IdentityResolver.ResolvedIdentity
5959+ do {
6060+ if account.hasPrefix("did:") {
6161+ resolvedIdentity = try await identityResolver.resolveIdentity(did: account)
6262+ } else {
6363+ resolvedIdentity = try await identityResolver.resolveIdentity(handle: account)
6464+ }
6565+ } catch let error as IdentityError {
6666+ ATLogger.shared.error("Identity resolution failed for \(account): \(error)")
6767+ throw Error.identityResolutionFailed(error)
6868+ }
6969+7070+ ATLogger.shared.info("Resolved identity: DID=\(resolvedIdentity.did), PDS=\(resolvedIdentity.pdsURL)")
7171+7272+ // Update environment with PDS
7373+ APEnvironment.current.host = resolvedIdentity.pdsURL
7474+ APEnvironment.current.resolvedIdentity = resolvedIdentity
7575+7676+ // Step 2: Extract server host for metadata fetch
7777+ guard let serverURL = URL(string: resolvedIdentity.authorizationServerURL),
7878+ let serverHost = serverURL.host else {
7979+ throw Error.serverMetadataFailed
8080+ }
8181+8282+ // Step 3: Fetch server metadata
8383+ let serverConfig: ServerMetadata
8484+ do {
8585+ serverConfig = try await ServerMetadata.load(for: serverHost, provider: provider)
8686+ APEnvironment.current.serverMetadata = serverConfig
8787+ } catch {
8888+ ATLogger.shared.error("Failed to load server metadata: \(error)")
8989+ throw Error.serverMetadataFailed
9090+ }
9191+9292+ // Step 4: Fetch client metadata
9393+ let clientConfig: ClientMetadata
9494+ do {
9595+ clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider)
9696+ APEnvironment.current.clientId = clientConfig.clientId
9797+ } catch {
9898+ ATLogger.shared.error("Failed to load client metadata: \(error)")
9999+ throw Error.clientMetadataFailed
100100+ }
101101+102102+ // Step 5: Configure and perform OAuth
103103+ let tokenHandling = Bluesky.tokenHandling(
104104+ account: account,
105105+ server: serverConfig,
106106+ jwtGenerator: jwtGenerator
107107+ )
108108+109109+ let config = Authenticator.Configuration(
110110+ appCredentials: clientConfig.credentials,
111111+ loginStorage: loginStorage,
112112+ tokenHandling: tokenHandling,
113113+ mode: .automatic
114114+ )
115115+116116+ authenticator = Authenticator(config: config)
117117+118118+ do {
119119+ try await authenticator?.authenticate()
120120+ } catch {
121121+ ATLogger.shared.error("Authentication failed: \(error)")
122122+ throw Error.authenticationFailed(error)
123123+ }
124124+125125+ // Step 6: Retrieve and verify login
126126+ guard let storedLogin = try await loginStorage.retrieveLogin() else {
127127+ throw Error.missingStoredLogin
128128+ }
129129+130130+ // Verify the subject matches expected DID
131131+ if let issuer = storedLogin.issuingServer, issuer != resolvedIdentity.did {
132132+ ATLogger.shared.warning("Subject mismatch: expected \(resolvedIdentity.did), got \(issuer)")
133133+ // This is a security check - the token should be for the expected user
134134+ throw Error.subjectMismatch(expected: resolvedIdentity.did, actual: issuer)
135135+ }
136136+137137+ // Step 7: Update environment with complete authentication context
138138+ applyAuthenticationContext(
139139+ login: storedLogin,
140140+ generator: jwtGenerator,
141141+ serverMetadata: serverConfig,
142142+ clientId: clientConfig.clientId
143143+ )
144144+145145+ // Store complete auth state if token storage is configured
146146+ if let tokenStorage = APEnvironment.current.tokenStorage {
147147+ let authState = AuthenticationState(
148148+ did: resolvedIdentity.did,
149149+ handle: resolvedIdentity.handle,
150150+ pdsURL: resolvedIdentity.pdsURL,
151151+ authServerURL: resolvedIdentity.authorizationServerURL,
152152+ accessToken: storedLogin.accessToken.value,
153153+ accessTokenExpiry: storedLogin.accessToken.expiry,
154154+ refreshToken: storedLogin.refreshToken?.value,
155155+ scope: storedLogin.scopes,
156156+ dpopPrivateKeyData: nil // Key management is caller's responsibility
157157+ )
158158+ try? await tokenStorage.store(authState)
159159+ APEnvironment.current.authState = authState
160160+ }
331613434- let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider)
3535- let serverConfig = try await ServerMetadata.load(for: server, provider: provider)
162162+ ATLogger.shared.info("Login successful for \(resolvedIdentity.handle)")
361633737- let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator)
3838- let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic)
3939- let authenticator = Authenticator(config: config)
4040- try await authenticator.authenticate()
164164+ return storedLogin
165165+ }
166166+167167+ /// Performs OAuth login using pre-resolved identity and server metadata.
168168+ /// Use this when you've already resolved the identity and fetched metadata.
169169+ ///
170170+ /// - Parameters:
171171+ /// - identity: Pre-resolved identity information
172172+ /// - serverMetadata: Pre-fetched OAuth server metadata
173173+ /// - clientMetadata: Pre-fetched client metadata
174174+ /// - Returns: The Login object with access and refresh tokens
175175+ public func login(
176176+ identity: IdentityResolver.ResolvedIdentity,
177177+ serverMetadata: ServerMetadata,
178178+ clientMetadata: ClientMetadata
179179+ ) async throws -> Login {
180180+ // Update environment
181181+ APEnvironment.current.host = identity.pdsURL
182182+ APEnvironment.current.resolvedIdentity = identity
183183+ APEnvironment.current.serverMetadata = serverMetadata
184184+ APEnvironment.current.clientId = clientMetadata.clientId
185185+186186+ let tokenHandling = Bluesky.tokenHandling(
187187+ account: identity.handle,
188188+ server: serverMetadata,
189189+ jwtGenerator: jwtGenerator
190190+ )
191191+192192+ let config = Authenticator.Configuration(
193193+ appCredentials: clientMetadata.credentials,
194194+ loginStorage: loginStorage,
195195+ tokenHandling: tokenHandling,
196196+ mode: .automatic
197197+ )
198198+199199+ authenticator = Authenticator(config: config)
200200+201201+ do {
202202+ try await authenticator?.authenticate()
203203+ } catch {
204204+ throw Error.authenticationFailed(error)
205205+ }
4120642207 guard let storedLogin = try await loginStorage.retrieveLogin() else {
43208 throw Error.missingStoredLogin
44209 }
45210211211+ applyAuthenticationContext(
212212+ login: storedLogin,
213213+ generator: jwtGenerator,
214214+ serverMetadata: serverMetadata,
215215+ clientId: clientMetadata.clientId
216216+ )
217217+46218 return storedLogin
219219+ }
220220+221221+ /// Logs out by clearing all stored tokens and authentication state.
222222+ public func logout() async {
223223+ await clearAuthenticationContext()
224224+ authenticator = nil
225225+ ATLogger.shared.info("Logged out")
47226 }
48227}
+207-5
Sources/CoreATProtocol/Models/ATError.swift
···55// Created by Thomas Rademaker on 10/8/25.
66//
7788-public enum AtError: Error {
88+import Foundation
99+1010+/// Top-level error type for AT Protocol operations.
1111+public enum AtError: Error, Sendable {
1212+ /// An error message returned by the server.
913 case message(ErrorMessage)
1414+1515+ /// A network-level error.
1016 case network(NetworkError)
1717+1818+ /// An OAuth/authentication error.
1919+ case oauth(OAuthError)
2020+2121+ /// An identity resolution error.
2222+ case identity(IdentityError)
2323+2424+ /// A decoding error.
2525+ case decoding(DecodingError)
2626+2727+ /// An unknown error.
2828+ case unknown(Error)
1129}
12303131+extension AtError: LocalizedError {
3232+ public var errorDescription: String? {
3333+ switch self {
3434+ case .message(let msg):
3535+ return msg.message ?? msg.error
3636+ case .network(let err):
3737+ return err.localizedDescription
3838+ case .oauth(let err):
3939+ return err.localizedDescription
4040+ case .identity(let err):
4141+ return String(describing: err)
4242+ case .decoding(let err):
4343+ return err.localizedDescription
4444+ case .unknown(let err):
4545+ return err.localizedDescription
4646+ }
4747+ }
4848+4949+ /// Returns true if this error indicates the user needs to re-authenticate.
5050+ public var requiresReauthentication: Bool {
5151+ switch self {
5252+ case .message(let msg):
5353+ return msg.errorType == .authenticationRequired ||
5454+ msg.errorType == .expiredToken ||
5555+ msg.errorType == .authMissing
5656+ case .network(let err):
5757+ if case .statusCode(let code, _) = err, code?.rawValue == 401 {
5858+ return true
5959+ }
6060+ return false
6161+ case .oauth(let err):
6262+ switch err {
6363+ case .accessTokenExpired, .refreshTokenExpired, .refreshTokenMissing:
6464+ return true
6565+ default:
6666+ return false
6767+ }
6868+ default:
6969+ return false
7070+ }
7171+ }
7272+7373+ /// Returns true if this error might succeed if retried.
7474+ public var isRetryable: Bool {
7575+ switch self {
7676+ case .message(let msg):
7777+ return msg.errorType == .rateLimitExceeded
7878+ case .network(let err):
7979+ switch err {
8080+ case .statusCode(let code, _):
8181+ // 5xx errors and 429 are retryable
8282+ guard let status = code?.rawValue else { return false }
8383+ return status >= 500 || status == 429
8484+ case .tokenRefresh:
8585+ return true
8686+ default:
8787+ return false
8888+ }
8989+ default:
9090+ return false
9191+ }
9292+ }
9393+}
9494+9595+/// Error message returned by AT Protocol servers.
1396public struct ErrorMessage: Codable, Sendable {
1414- #warning("Should error be type string or AtErrorType?")
9797+ /// The error code/type string.
1598 public let error: String
9999+100100+ /// Optional human-readable error message.
16101 public let message: String?
1717-102102+18103 public init(error: String, message: String?) {
19104 self.error = error
20105 self.message = message
21106 }
107107+108108+ /// Attempts to parse the error string as a known error type.
109109+ public var errorType: AtErrorType? {
110110+ AtErrorType(rawValue: error)
111111+ }
22112}
231132424-public enum AtErrorType: String, Codable, Sendable {
114114+/// Known AT Protocol error types.
115115+public enum AtErrorType: String, Codable, Sendable, CaseIterable {
116116+ // Authentication errors
25117 case authenticationRequired = "AuthenticationRequired"
26118 case expiredToken = "ExpiredToken"
119119+ case authMissing = "AuthMissing"
120120+ case invalidToken = "InvalidToken"
121121+122122+ // Request errors
27123 case invalidRequest = "InvalidRequest"
124124+ case invalidSwap = "InvalidSwap"
28125 case methodNotImplemented = "MethodNotImplemented"
126126+127127+ // Rate limiting
29128 case rateLimitExceeded = "RateLimitExceeded"
3030- case authMissing = "AuthMissing"
129129+130130+ // Account errors
131131+ case accountTakedown = "AccountTakedown"
132132+ case accountSuspended = "AccountSuspended"
133133+ case accountDeactivated = "AccountDeactivated"
134134+ case accountNotFound = "AccountNotFound"
135135+136136+ // Record errors
137137+ case recordNotFound = "RecordNotFound"
138138+ case repoNotFound = "RepoNotFound"
139139+ case blobNotFound = "BlobNotFound"
140140+ case blockNotFound = "BlockNotFound"
141141+142142+ // Validation errors
143143+ case invalidHandle = "InvalidHandle"
144144+ case handleNotAvailable = "HandleNotAvailable"
145145+ case unsupportedDomain = "UnsupportedDomain"
146146+ case unresolvableDid = "UnresolvableDid"
147147+148148+ // Blob errors
149149+ case blobTooLarge = "BlobTooLarge"
150150+ case invalidBlob = "InvalidBlob"
151151+152152+ // Content errors
153153+ case duplicateCreate = "DuplicateCreate"
154154+ case unknownFeed = "UnknownFeed"
155155+ case unknownList = "UnknownList"
156156+ case notFound = "NotFound"
157157+158158+ // Server errors
159159+ case upstreamFailure = "UpstreamFailure"
160160+ case upstreamTimeout = "UpstreamTimeout"
161161+ case internalServerError = "InternalServerError"
162162+163163+ /// Human-readable description of the error type.
164164+ public var description: String {
165165+ switch self {
166166+ case .authenticationRequired: return "Authentication is required"
167167+ case .expiredToken: return "The access token has expired"
168168+ case .authMissing: return "Authentication credentials are missing"
169169+ case .invalidToken: return "The provided token is invalid"
170170+ case .invalidRequest: return "The request is invalid"
171171+ case .invalidSwap: return "The swap operation is invalid"
172172+ case .methodNotImplemented: return "This method is not implemented"
173173+ case .rateLimitExceeded: return "Rate limit exceeded"
174174+ case .accountTakedown: return "Account has been taken down"
175175+ case .accountSuspended: return "Account has been suspended"
176176+ case .accountDeactivated: return "Account has been deactivated"
177177+ case .accountNotFound: return "Account not found"
178178+ case .recordNotFound: return "Record not found"
179179+ case .repoNotFound: return "Repository not found"
180180+ case .blobNotFound: return "Blob not found"
181181+ case .blockNotFound: return "Block not found"
182182+ case .invalidHandle: return "The handle is invalid"
183183+ case .handleNotAvailable: return "The handle is not available"
184184+ case .unsupportedDomain: return "The domain is not supported"
185185+ case .unresolvableDid: return "The DID cannot be resolved"
186186+ case .blobTooLarge: return "The blob is too large"
187187+ case .invalidBlob: return "The blob is invalid"
188188+ case .duplicateCreate: return "A record with this key already exists"
189189+ case .unknownFeed: return "The feed is not known"
190190+ case .unknownList: return "The list is not known"
191191+ case .notFound: return "The resource was not found"
192192+ case .upstreamFailure: return "An upstream service failed"
193193+ case .upstreamTimeout: return "An upstream service timed out"
194194+ case .internalServerError: return "Internal server error"
195195+ }
196196+ }
197197+}
198198+199199+/// Rate limit information from response headers.
200200+public struct RateLimitInfo: Sendable {
201201+ /// Maximum number of requests allowed in the window.
202202+ public let limit: Int
203203+204204+ /// Number of requests remaining in the current window.
205205+ public let remaining: Int
206206+207207+ /// Unix timestamp when the rate limit resets.
208208+ public let resetTimestamp: TimeInterval
209209+210210+ /// Date when the rate limit resets.
211211+ public var resetDate: Date {
212212+ Date(timeIntervalSince1970: resetTimestamp)
213213+ }
214214+215215+ /// Time interval until the rate limit resets.
216216+ public var timeUntilReset: TimeInterval {
217217+ resetTimestamp - Date().timeIntervalSince1970
218218+ }
219219+220220+ /// Parses rate limit information from HTTP response headers.
221221+ public static func from(response: HTTPURLResponse) -> RateLimitInfo? {
222222+ guard let limitStr = response.value(forHTTPHeaderField: "RateLimit-Limit"),
223223+ let remainingStr = response.value(forHTTPHeaderField: "RateLimit-Remaining"),
224224+ let resetStr = response.value(forHTTPHeaderField: "RateLimit-Reset"),
225225+ let limit = Int(limitStr),
226226+ let remaining = Int(remainingStr),
227227+ let reset = TimeInterval(resetStr) else {
228228+ return nil
229229+ }
230230+231231+ return RateLimitInfo(limit: limit, remaining: remaining, resetTimestamp: reset)
232232+ }
31233}
+186-40
Sources/CoreATProtocol/Networking.swift
···1010@preconcurrency import OAuthenticator
11111212extension JSONDecoder {
1313+ /// A JSON decoder configured for AT Protocol date formats.
1414+ /// Supports ISO 8601 dates with fractional seconds and timezone.
1315 public static var atDecoder: JSONDecoder {
1414- let dateFormatter = DateFormatter()
1515- dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX"
1616- dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
1717- dateFormatter.locale = Locale(identifier: "en_US")
1818-1916 let decoder = JSONDecoder()
2017 decoder.keyDecodingStrategy = .convertFromSnakeCase
2121- decoder.dateDecodingStrategy = .formatted(dateFormatter)
2222-1818+ decoder.dateDecodingStrategy = .custom { decoder in
1919+ let container = try decoder.singleValueContainer()
2020+ let dateString = try container.decode(String.self)
2121+2222+ // Try multiple date formats that AT Protocol APIs may return
2323+ let formatters = Self.atDateFormatters
2424+2525+ for formatter in formatters {
2626+ if let date = formatter.date(from: dateString) {
2727+ return date
2828+ }
2929+ }
3030+3131+ // Try ISO8601 with fractional seconds
3232+ let iso8601 = ISO8601DateFormatter()
3333+ iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
3434+ if let date = iso8601.date(from: dateString) {
3535+ return date
3636+ }
3737+3838+ // Try without fractional seconds
3939+ iso8601.formatOptions = [.withInternetDateTime]
4040+ if let date = iso8601.date(from: dateString) {
4141+ return date
4242+ }
4343+4444+ throw DecodingError.dataCorruptedError(
4545+ in: container,
4646+ debugDescription: "Cannot decode date string: \(dateString)"
4747+ )
4848+ }
4949+2350 return decoder
2451 }
5252+5353+ /// Date formatters for various AT Protocol date formats.
5454+ private static var atDateFormatters: [DateFormatter] {
5555+ let formats = [
5656+ "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // With microseconds and timezone
5757+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // With milliseconds and timezone
5858+ "yyyy-MM-dd'T'HH:mm:ss.SSSX", // With milliseconds and short timezone
5959+ "yyyy-MM-dd'T'HH:mm:ssXXXXX", // Without fractional seconds
6060+ "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", // With Z timezone
6161+ "yyyy-MM-dd'T'HH:mm:ss'Z'" // Without fractional, with Z
6262+ ]
6363+6464+ return formats.map { format in
6565+ let formatter = DateFormatter()
6666+ formatter.dateFormat = format
6767+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
6868+ formatter.locale = Locale(identifier: "en_US_POSIX")
6969+ return formatter
7070+ }
7171+ }
2572}
26737474+/// Checks if enough time has passed since last fetch to allow a new request.
7575+/// - Parameters:
7676+/// - lastFetched: Unix timestamp of last fetch (0 means never fetched)
7777+/// - timeLimit: Minimum seconds between fetches (default 1 hour)
7878+/// - Returns: true if a new request should be performed
2779func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool {
2880 guard lastFetched != 0 else { return true }
2981 let currentTime = Date.now
3082 let lastFetchTime = Date(timeIntervalSince1970: lastFetched)
3131- guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
3232- return differenceInMinutes >= timeLimit
8383+ guard let differenceInSeconds = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
8484+ return differenceInSeconds >= timeLimit
3385}
34863535-@MainActor
8787+@APActor
3688public class APRouterDelegate: NetworkRouterDelegate {
3737- private var shouldRefreshToken = false
3838-3939- public func intercept(_ request: inout URLRequest) async {
8989+ /// Maximum retry attempts for token refresh.
9090+ private let maxRefreshAttempts = 2
9191+9292+ public init() {}
9393+9494+ nonisolated public func intercept(_ request: inout URLRequest) async {
9595+ // Try DPoP-authenticated request first (preferred for AT Protocol)
4096 if let generator = await APEnvironment.current.dpopProofGenerator,
4197 let login = await APEnvironment.current.login {
4298 let token = login.accessToken.value
4343- let tokenHash = tokenHash(for: token)
9999+ let tokenHash = await tokenHash(for: token)
44100 let signer = await APEnvironment.current.resourceDPoPSigner
4545- signer.nonce = await APEnvironment.current.resourceServerNonce
101101+ await MainActor.run {
102102+ signer.nonce = nil
103103+ }
104104+ let nonce = await APEnvironment.current.resourceServerNonce
105105+ await MainActor.run {
106106+ signer.nonce = nonce
107107+ }
4610847109 do {
48110 try await signer.authenticateRequest(
···61123 return
62124 }
631256464- if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken {
6565- shouldRefreshToken = false
6666- request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
6767- } else if let accessToken = await APEnvironment.current.accessToken {
126126+ // Fall back to simple Bearer token authentication
127127+ if let accessToken = await APEnvironment.current.accessToken {
68128 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
69129 }
70130 }
7171-7272- public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
7373- func getNewToken() async throws -> Bool {
7474-// shouldRefreshToken = true
7575-// let newSession = try await AtProtoLexicons().refresh(attempts: attempts + 1)
7676-// APEnvironment.current.accessToken = newSession.accessJwt
7777-// APEnvironment.current.refreshToken = newSession.refreshJwt
7878-// await delegate?.sessionUpdated(newSession)
7979-//
8080-// return true
8181- false
8282- }
8383-8484- // TODO: verify this works!
131131+132132+ nonisolated public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
133133+ // Don't retry more than maxRefreshAttempts times
134134+ guard attempts <= maxRefreshAttempts else { return false }
135135+136136+ // Check if the error indicates we need to refresh the token
137137+ let shouldAttemptRefresh = isTokenExpiredError(error)
138138+139139+ guard shouldAttemptRefresh else { return false }
140140+141141+ // Attempt token refresh
142142+ let refreshed = await performTokenRefresh()
143143+144144+ return refreshed
145145+ }
146146+147147+ /// Determines if an error indicates the token has expired and needs refresh.
148148+ nonisolated private func isTokenExpiredError(_ error: Error) -> Bool {
149149+ // Check for 401 Unauthorized status code
85150 if case .network(let networkError) = error as? AtError,
86151 case .statusCode(let statusCode, _) = networkError,
8787- let statusCode = statusCode?.rawValue, (400..<500).contains(statusCode),
8888- attempts == 1 {
8989- return try await getNewToken()
9090- } else if case .message(let message) = error as? AtError,
9191- message.error == AtErrorType.expiredToken.rawValue {
9292- return try await getNewToken()
152152+ statusCode?.rawValue == 401 {
153153+ return true
154154+ }
155155+156156+ // Check for explicit expired token error message
157157+ if case .message(let message) = error as? AtError,
158158+ message.error == AtErrorType.expiredToken.rawValue {
159159+ return true
160160+ }
161161+162162+ // Check for authentication required error
163163+ if case .message(let message) = error as? AtError,
164164+ message.error == AtErrorType.authenticationRequired.rawValue {
165165+ return true
93166 }
9416795168 return false
96169 }
971709898- private func tokenHash(for token: String) -> String {
171171+ /// Performs token refresh using the configured OAuth settings.
172172+ nonisolated private func performTokenRefresh() async -> Bool {
173173+ let env = await APEnvironment.current
174174+175175+ // Try using the authState-based refresh first
176176+ if await env.authState != nil {
177177+ return await env.performTokenRefresh()
178178+ }
179179+180180+ // Fall back to OAuthenticator's refresh if we have a login with refresh token
181181+ guard let login = await env.login,
182182+ let refreshToken = login.refreshToken,
183183+ refreshToken.valid else {
184184+ return false
185185+ }
186186+187187+ guard let serverMetadata = await env.serverMetadata,
188188+ let clientId = await env.clientId else {
189189+ return false
190190+ }
191191+192192+ // Use RefreshService for the actual refresh
193193+ let refreshService = await RefreshService()
194194+195195+ // Create an AuthenticationState from the current login if we don't have one
196196+ let state = AuthenticationState(
197197+ did: login.issuingServer ?? "",
198198+ handle: nil,
199199+ pdsURL: await env.host ?? "",
200200+ authServerURL: serverMetadata.issuer,
201201+ accessToken: login.accessToken.value,
202202+ accessTokenExpiry: login.accessToken.expiry,
203203+ refreshToken: refreshToken.value,
204204+ refreshTokenExpiry: refreshToken.expiry,
205205+ scope: login.scopes,
206206+ dpopPrivateKeyData: nil
207207+ )
208208+209209+ do {
210210+ let newState = try await refreshService.refresh(
211211+ state: state,
212212+ serverMetadata: serverMetadata,
213213+ clientId: clientId,
214214+ dpopGenerator: await env.dpopProofGenerator
215215+ )
216216+217217+ // Update the environment
218218+ await updateEnvironmentWithNewTokens(newState)
219219+220220+ return true
221221+ } catch {
222222+ print("Token refresh failed: \(error)")
223223+ return false
224224+ }
225225+ }
226226+227227+ /// Updates the environment with refreshed tokens.
228228+ private func updateEnvironmentWithNewTokens(_ state: AuthenticationState) async {
229229+ APEnvironment.current.accessToken = state.accessToken
230230+ APEnvironment.current.refreshToken = state.refreshToken
231231+ APEnvironment.current.authState = state
232232+233233+ // Update login object if present
234234+ if var login = APEnvironment.current.login {
235235+ login.accessToken = Token(value: state.accessToken, expiry: state.accessTokenExpiry)
236236+ if let newRefresh = state.refreshToken {
237237+ login.refreshToken = Token(value: newRefresh, expiry: state.refreshTokenExpiry)
238238+ }
239239+ APEnvironment.current.login = login
240240+ }
241241+ }
242242+243243+ /// Computes SHA-256 hash of the access token for DPoP `ath` claim.
244244+ nonisolated private func tokenHash(for token: String) -> String {
99245 let digest = SHA256.hash(data: Data(token.utf8))
100246 return Data(digest).base64URLEncodedString()
101247 }
···11+import Foundation
22+33+/// Describes the type of HTTP task to perform.
14public enum HTTPTask: Sendable {
55+ /// A simple request with no body.
26 case request
33-77+88+ /// A request with encoded parameters (URL query or JSON body).
49 case requestParameters(encoding: ParameterEncoding)
55-66- // case download, upload...etc
1010+1111+ /// A blob upload request with raw data and content type.
1212+ case uploadBlob(data: Data, mimeType: String)
1313+1414+ /// A multipart form data upload.
1515+ case uploadMultipart(parts: [MultipartFormData])
1616+}
1717+1818+/// Represents a single part in a multipart form data request.
1919+public struct MultipartFormData: Sendable {
2020+ /// The field name for this part.
2121+ public let name: String
2222+2323+ /// The filename for file uploads (nil for regular fields).
2424+ public let filename: String?
2525+2626+ /// The content type of this part.
2727+ public let mimeType: String?
2828+2929+ /// The data for this part.
3030+ public let data: Data
3131+3232+ /// Creates a text field part.
3333+ public static func field(name: String, value: String) -> MultipartFormData {
3434+ MultipartFormData(
3535+ name: name,
3636+ filename: nil,
3737+ mimeType: nil,
3838+ data: Data(value.utf8)
3939+ )
4040+ }
4141+4242+ /// Creates a file upload part.
4343+ public static func file(name: String, filename: String, mimeType: String, data: Data) -> MultipartFormData {
4444+ MultipartFormData(
4545+ name: name,
4646+ filename: filename,
4747+ mimeType: mimeType,
4848+ data: data
4949+ )
5050+ }
5151+5252+ public init(name: String, filename: String?, mimeType: String?, data: Data) {
5353+ self.name = name
5454+ self.filename = filename
5555+ self.mimeType = mimeType
5656+ self.data = data
5757+ }
5858+}
5959+6060+/// Response from a blob upload operation.
6161+public struct BlobUploadResponse: Codable, Sendable {
6262+ public let blob: BlobRef
6363+6464+ public struct BlobRef: Codable, Sendable {
6565+ public let type: String
6666+ public let ref: BlobLink
6767+ public let mimeType: String
6868+ public let size: Int
6969+7070+ enum CodingKeys: String, CodingKey {
7171+ case type = "$type"
7272+ case ref
7373+ case mimeType
7474+ case size
7575+ }
7676+7777+ public struct BlobLink: Codable, Sendable {
7878+ public let link: String
7979+8080+ enum CodingKeys: String, CodingKey {
8181+ case link = "$link"
8282+ }
8383+ }
8484+ }
785}
···11+//
22+// ATClientMetadata.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+1010+/// AT Protocol OAuth client metadata document.
1111+/// This document must be published at the `client_id` URL for OAuth registration.
1212+///
1313+/// See: https://atproto.com/specs/oauth
1414+public struct ATClientMetadata: Codable, Sendable, Hashable {
1515+1616+ /// The client identifier. Must be a fully-qualified HTTPS URL pointing to this metadata.
1717+ public let clientId: String
1818+1919+ /// Application type: "web" or "native".
2020+ public let applicationType: ApplicationType
2121+2222+ /// Supported grant types. Must include "authorization_code" and "refresh_token".
2323+ public let grantTypes: [String]
2424+2525+ /// Requested scopes. Must include "atproto".
2626+ public let scope: String
2727+2828+ /// Supported response types. Must include "code".
2929+ public let responseTypes: [String]
3030+3131+ /// Redirect URIs for OAuth callbacks.
3232+ public let redirectUris: [String]
3333+3434+ /// Whether access tokens are DPoP-bound. Must be true for AT Protocol.
3535+ public let dpopBoundAccessTokens: Bool
3636+3737+ /// Token endpoint authentication method.
3838+ /// "none" for public clients, "private_key_jwt" for confidential clients.
3939+ public let tokenEndpointAuthMethod: String
4040+4141+ /// Human-readable application name.
4242+ public let clientName: String?
4343+4444+ /// URL to the application's logo.
4545+ public let logoUri: String?
4646+4747+ /// URL to the application's homepage.
4848+ public let clientUri: String?
4949+5050+ /// URL to the application's terms of service.
5151+ public let tosUri: String?
5252+5353+ /// URL to the application's privacy policy.
5454+ public let policyUri: String?
5555+5656+ /// JWK Set for confidential clients (inline).
5757+ public let jwks: JWKSet?
5858+5959+ /// URL to JWK Set for confidential clients.
6060+ public let jwksUri: String?
6161+6262+ enum CodingKeys: String, CodingKey {
6363+ case clientId = "client_id"
6464+ case applicationType = "application_type"
6565+ case grantTypes = "grant_types"
6666+ case scope
6767+ case responseTypes = "response_types"
6868+ case redirectUris = "redirect_uris"
6969+ case dpopBoundAccessTokens = "dpop_bound_access_tokens"
7070+ case tokenEndpointAuthMethod = "token_endpoint_auth_method"
7171+ case clientName = "client_name"
7272+ case logoUri = "logo_uri"
7373+ case clientUri = "client_uri"
7474+ case tosUri = "tos_uri"
7575+ case policyUri = "policy_uri"
7676+ case jwks
7777+ case jwksUri = "jwks_uri"
7878+ }
7979+8080+ /// Application type for OAuth clients.
8181+ public enum ApplicationType: String, Codable, Sendable, Hashable {
8282+ case web
8383+ case native
8484+ }
8585+8686+ /// Creates a new client metadata document for a public (native) client.
8787+ /// - Parameters:
8888+ /// - clientId: The client_id URL where this metadata will be published
8989+ /// - redirectUri: The callback URI for OAuth redirects
9090+ /// - clientName: Human-readable application name
9191+ /// - scope: OAuth scopes (default includes "atproto" and "transition:generic")
9292+ /// - logoUri: Optional logo URL
9393+ /// - clientUri: Optional homepage URL
9494+ /// - tosUri: Optional terms of service URL
9595+ /// - policyUri: Optional privacy policy URL
9696+ public init(
9797+ clientId: String,
9898+ redirectUri: String,
9999+ clientName: String,
100100+ scope: String = "atproto transition:generic",
101101+ logoUri: String? = nil,
102102+ clientUri: String? = nil,
103103+ tosUri: String? = nil,
104104+ policyUri: String? = nil
105105+ ) {
106106+ self.clientId = clientId
107107+ self.applicationType = .native
108108+ self.grantTypes = ["authorization_code", "refresh_token"]
109109+ self.scope = scope
110110+ self.responseTypes = ["code"]
111111+ self.redirectUris = [redirectUri]
112112+ self.dpopBoundAccessTokens = true
113113+ self.tokenEndpointAuthMethod = "none"
114114+ self.clientName = clientName
115115+ self.logoUri = logoUri
116116+ self.clientUri = clientUri
117117+ self.tosUri = tosUri
118118+ self.policyUri = policyUri
119119+ self.jwks = nil
120120+ self.jwksUri = nil
121121+ }
122122+123123+ /// Creates a new client metadata document for a confidential (web) client.
124124+ /// - Parameters:
125125+ /// - clientId: The client_id URL where this metadata will be published
126126+ /// - redirectUri: The callback URI for OAuth redirects
127127+ /// - clientName: Human-readable application name
128128+ /// - jwksUri: URL to the JWK Set containing the client's public keys
129129+ /// - scope: OAuth scopes (default includes "atproto" and "transition:generic")
130130+ /// - logoUri: Optional logo URL
131131+ /// - clientUri: Optional homepage URL
132132+ /// - tosUri: Optional terms of service URL
133133+ /// - policyUri: Optional privacy policy URL
134134+ public init(
135135+ clientId: String,
136136+ redirectUri: String,
137137+ clientName: String,
138138+ jwksUri: String,
139139+ scope: String = "atproto transition:generic",
140140+ logoUri: String? = nil,
141141+ clientUri: String? = nil,
142142+ tosUri: String? = nil,
143143+ policyUri: String? = nil
144144+ ) {
145145+ self.clientId = clientId
146146+ self.applicationType = .web
147147+ self.grantTypes = ["authorization_code", "refresh_token"]
148148+ self.scope = scope
149149+ self.responseTypes = ["code"]
150150+ self.redirectUris = [redirectUri]
151151+ self.dpopBoundAccessTokens = true
152152+ self.tokenEndpointAuthMethod = "private_key_jwt"
153153+ self.clientName = clientName
154154+ self.logoUri = logoUri
155155+ self.clientUri = clientUri
156156+ self.tosUri = tosUri
157157+ self.policyUri = policyUri
158158+ self.jwks = nil
159159+ self.jwksUri = jwksUri
160160+ }
161161+162162+ /// Encodes this metadata as JSON suitable for publishing.
163163+ public func toJSON() throws -> Data {
164164+ let encoder = JSONEncoder()
165165+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
166166+ return try encoder.encode(self)
167167+ }
168168+169169+ /// Encodes this metadata as a JSON string suitable for publishing.
170170+ public func toJSONString() throws -> String {
171171+ let data = try toJSON()
172172+ guard let string = String(data: data, encoding: .utf8) else {
173173+ throw OAuthError.invalidConfiguration(reason: "Failed to encode metadata as UTF-8")
174174+ }
175175+ return string
176176+ }
177177+178178+ /// Validates this metadata against AT Protocol OAuth requirements.
179179+ public func validate() throws {
180180+ // Validate client_id is HTTPS
181181+ guard clientId.hasPrefix("https://") || clientId.hasPrefix("http://localhost") else {
182182+ throw OAuthError.invalidConfiguration(reason: "client_id must be HTTPS URL (except localhost)")
183183+ }
184184+185185+ // Validate required grant types
186186+ guard grantTypes.contains("authorization_code") else {
187187+ throw OAuthError.invalidConfiguration(reason: "grant_types must include 'authorization_code'")
188188+ }
189189+ guard grantTypes.contains("refresh_token") else {
190190+ throw OAuthError.invalidConfiguration(reason: "grant_types must include 'refresh_token'")
191191+ }
192192+193193+ // Validate scope includes atproto
194194+ guard scope.contains("atproto") else {
195195+ throw OAuthError.invalidConfiguration(reason: "scope must include 'atproto'")
196196+ }
197197+198198+ // Validate response types
199199+ guard responseTypes.contains("code") else {
200200+ throw OAuthError.invalidConfiguration(reason: "response_types must include 'code'")
201201+ }
202202+203203+ // Validate redirect URIs
204204+ guard !redirectUris.isEmpty else {
205205+ throw OAuthError.invalidConfiguration(reason: "At least one redirect_uri is required")
206206+ }
207207+208208+ // Validate DPoP requirement
209209+ guard dpopBoundAccessTokens else {
210210+ throw OAuthError.invalidConfiguration(reason: "dpop_bound_access_tokens must be true")
211211+ }
212212+213213+ // Validate confidential client has keys
214214+ if tokenEndpointAuthMethod == "private_key_jwt" {
215215+ guard jwks != nil || jwksUri != nil else {
216216+ throw OAuthError.invalidConfiguration(reason: "Confidential clients must provide jwks or jwks_uri")
217217+ }
218218+ }
219219+ }
220220+}
221221+222222+/// JWK Set structure for confidential clients.
223223+public struct JWKSet: Codable, Sendable, Hashable {
224224+ public let keys: [JWK]
225225+226226+ public init(keys: [JWK]) {
227227+ self.keys = keys
228228+ }
229229+}
230230+231231+/// JSON Web Key structure.
232232+public struct JWK: Codable, Sendable, Hashable {
233233+ public let kty: String
234234+ public let crv: String?
235235+ public let x: String?
236236+ public let y: String?
237237+ public let kid: String?
238238+ public let use: String?
239239+ public let alg: String?
240240+241241+ public init(
242242+ kty: String,
243243+ crv: String? = nil,
244244+ x: String? = nil,
245245+ y: String? = nil,
246246+ kid: String? = nil,
247247+ use: String? = nil,
248248+ alg: String? = nil
249249+ ) {
250250+ self.kty = kty
251251+ self.crv = crv
252252+ self.x = x
253253+ self.y = y
254254+ self.kid = kid
255255+ self.use = use
256256+ self.alg = alg
257257+ }
258258+259259+ /// Creates an ES256 public key JWK from coordinates.
260260+ public static func es256PublicKey(x: String, y: String, kid: String? = nil) -> JWK {
261261+ JWK(kty: "EC", crv: "P-256", x: x, y: y, kid: kid, use: "sig", alg: "ES256")
262262+ }
263263+}
+139
Sources/CoreATProtocol/OAuth/OAuthError.swift
···11+//
22+// OAuthError.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+1010+/// Errors specific to OAuth operations in AT Protocol.
1111+public enum OAuthError: Error, Sendable, Hashable {
1212+ // MARK: - Token Errors
1313+ case accessTokenExpired
1414+ case refreshTokenExpired
1515+ case refreshTokenMissing
1616+ case refreshFailed(reason: String)
1717+ case tokenExchangeFailed(reason: String)
1818+1919+ // MARK: - Configuration Errors
2020+ case missingServerMetadata
2121+ case missingClientMetadata
2222+ case missingCredentials
2323+ case invalidConfiguration(reason: String)
2424+2525+ // MARK: - Authorization Errors
2626+ case authorizationDenied
2727+ case invalidState
2828+ case invalidScope
2929+ case parRequestFailed(reason: String)
3030+3131+ // MARK: - DPoP Errors
3232+ case dpopRequired
3333+ case dpopNonceMissing
3434+ case dpopSigningFailed(reason: String)
3535+ case dpopKeyMissing
3636+3737+ // MARK: - Identity Errors
3838+ case subjectMismatch(expected: String, received: String)
3939+ case issuerMismatch(expected: String, received: String)
4040+4141+ // MARK: - Storage Errors
4242+ case storageFailed(reason: String)
4343+ case loginNotFound
4444+}
4545+4646+extension OAuthError: LocalizedError {
4747+ public var errorDescription: String? {
4848+ switch self {
4949+ case .accessTokenExpired:
5050+ return "Access token has expired"
5151+ case .refreshTokenExpired:
5252+ return "Refresh token has expired"
5353+ case .refreshTokenMissing:
5454+ return "No refresh token available"
5555+ case .refreshFailed(let reason):
5656+ return "Token refresh failed: \(reason)"
5757+ case .tokenExchangeFailed(let reason):
5858+ return "Token exchange failed: \(reason)"
5959+ case .missingServerMetadata:
6060+ return "Server metadata is not available"
6161+ case .missingClientMetadata:
6262+ return "Client metadata is not available"
6363+ case .missingCredentials:
6464+ return "App credentials are not configured"
6565+ case .invalidConfiguration(let reason):
6666+ return "Invalid OAuth configuration: \(reason)"
6767+ case .authorizationDenied:
6868+ return "Authorization was denied by the user"
6969+ case .invalidState:
7070+ return "State token mismatch - possible CSRF attack"
7171+ case .invalidScope:
7272+ return "Requested scope was not granted"
7373+ case .parRequestFailed(let reason):
7474+ return "Pushed Authorization Request failed: \(reason)"
7575+ case .dpopRequired:
7676+ return "DPoP is required but not configured"
7777+ case .dpopNonceMissing:
7878+ return "DPoP nonce was not provided by server"
7979+ case .dpopSigningFailed(let reason):
8080+ return "DPoP JWT signing failed: \(reason)"
8181+ case .dpopKeyMissing:
8282+ return "DPoP private key is not available"
8383+ case .subjectMismatch(let expected, let received):
8484+ return "Subject mismatch: expected \(expected), received \(received)"
8585+ case .issuerMismatch(let expected, let received):
8686+ return "Issuer mismatch: expected \(expected), received \(received)"
8787+ case .storageFailed(let reason):
8888+ return "Token storage failed: \(reason)"
8989+ case .loginNotFound:
9090+ return "No stored login found"
9191+ }
9292+ }
9393+}
9494+9595+/// Response from a token refresh request.
9696+public struct TokenRefreshResponse: Codable, Sendable {
9797+ public let accessToken: String
9898+ public let refreshToken: String?
9999+ public let tokenType: String
100100+ public let expiresIn: Int
101101+ public let scope: String?
102102+ public let sub: String
103103+104104+ enum CodingKeys: String, CodingKey {
105105+ case accessToken = "access_token"
106106+ case refreshToken = "refresh_token"
107107+ case tokenType = "token_type"
108108+ case expiresIn = "expires_in"
109109+ case scope
110110+ case sub
111111+ }
112112+113113+ public init(
114114+ accessToken: String,
115115+ refreshToken: String?,
116116+ tokenType: String,
117117+ expiresIn: Int,
118118+ scope: String?,
119119+ sub: String
120120+ ) {
121121+ self.accessToken = accessToken
122122+ self.refreshToken = refreshToken
123123+ self.tokenType = tokenType
124124+ self.expiresIn = expiresIn
125125+ self.scope = scope
126126+ self.sub = sub
127127+ }
128128+}
129129+130130+/// Error response from OAuth endpoints.
131131+public struct OAuthErrorResponse: Codable, Sendable {
132132+ public let error: String
133133+ public let errorDescription: String?
134134+135135+ enum CodingKeys: String, CodingKey {
136136+ case error
137137+ case errorDescription = "error_description"
138138+ }
139139+}
+204
Sources/CoreATProtocol/OAuth/RefreshService.swift
···11+//
22+// RefreshService.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+import CryptoKit
1010+@preconcurrency import OAuthenticator
1111+1212+/// Handles token refresh operations for AT Protocol OAuth.
1313+@APActor
1414+public final class RefreshService {
1515+1616+ /// Request body for token refresh.
1717+ struct RefreshTokenRequest: Codable, Sendable {
1818+ let refreshToken: String
1919+ let grantType: String
2020+ let clientId: String
2121+2222+ enum CodingKeys: String, CodingKey {
2323+ case refreshToken = "refresh_token"
2424+ case grantType = "grant_type"
2525+ case clientId = "client_id"
2626+ }
2727+2828+ init(refreshToken: String, clientId: String) {
2929+ self.refreshToken = refreshToken
3030+ self.grantType = "refresh_token"
3131+ self.clientId = clientId
3232+ }
3333+ }
3434+3535+ private let urlSession: URLSession
3636+3737+ public init(urlSession: URLSession = .shared) {
3838+ self.urlSession = urlSession
3939+ }
4040+4141+ /// Refreshes tokens using the stored authentication state.
4242+ /// - Parameters:
4343+ /// - state: Current authentication state with refresh token
4444+ /// - serverMetadata: OAuth server metadata with token endpoint
4545+ /// - clientId: The client ID for the application
4646+ /// - dpopGenerator: DPoP JWT generator for signing requests
4747+ /// - Returns: Updated authentication state with new tokens
4848+ public func refresh(
4949+ state: AuthenticationState,
5050+ serverMetadata: ServerMetadata,
5151+ clientId: String,
5252+ dpopGenerator: DPoPSigner.JWTGenerator?
5353+ ) async throws -> AuthenticationState {
5454+ guard let refreshToken = state.refreshToken else {
5555+ throw OAuthError.refreshTokenMissing
5656+ }
5757+5858+ guard !state.isRefreshTokenExpired else {
5959+ throw OAuthError.refreshTokenExpired
6060+ }
6161+6262+ guard let tokenURL = URL(string: serverMetadata.tokenEndpoint) else {
6363+ throw OAuthError.invalidConfiguration(reason: "Invalid token endpoint URL")
6464+ }
6565+6666+ let requestBody = RefreshTokenRequest(refreshToken: refreshToken, clientId: clientId)
6767+6868+ var request = URLRequest(url: tokenURL)
6969+ request.httpMethod = "POST"
7070+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
7171+ request.setValue("application/json", forHTTPHeaderField: "Accept")
7272+ request.httpBody = try JSONEncoder().encode(requestBody)
7373+7474+ // Add DPoP header if generator is available
7575+ if let generator = dpopGenerator {
7676+ let dpopSigner = DPoPSigner()
7777+ dpopSigner.nonce = await APEnvironment.current.resourceServerNonce
7878+7979+ try await dpopSigner.authenticateRequest(
8080+ &request,
8181+ isolation: APActor.shared,
8282+ using: generator,
8383+ token: nil,
8484+ tokenHash: nil,
8585+ issuer: serverMetadata.issuer
8686+ )
8787+ }
8888+8989+ let (data, response) = try await urlSession.data(for: request)
9090+9191+ guard let httpResponse = response as? HTTPURLResponse else {
9292+ throw OAuthError.refreshFailed(reason: "Invalid response type")
9393+ }
9494+9595+ // Update DPoP nonce from response
9696+ if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") {
9797+ await APEnvironment.current.setResourceServerNonce(newNonce)
9898+ }
9999+100100+ guard (200...299).contains(httpResponse.statusCode) else {
101101+ if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) {
102102+ throw OAuthError.refreshFailed(reason: errorResponse.errorDescription ?? errorResponse.error)
103103+ }
104104+ throw OAuthError.refreshFailed(reason: "HTTP \(httpResponse.statusCode)")
105105+ }
106106+107107+ let tokenResponse = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
108108+109109+ // Verify token type is DPoP
110110+ guard tokenResponse.tokenType.lowercased() == "dpop" else {
111111+ throw OAuthError.dpopRequired
112112+ }
113113+114114+ // Verify subject matches
115115+ guard tokenResponse.sub == state.did else {
116116+ throw OAuthError.subjectMismatch(expected: state.did, received: tokenResponse.sub)
117117+ }
118118+119119+ return state.withUpdatedTokens(
120120+ access: tokenResponse.accessToken,
121121+ refresh: tokenResponse.refreshToken,
122122+ expiresIn: tokenResponse.expiresIn
123123+ )
124124+ }
125125+}
126126+127127+// MARK: - APEnvironment Extension for Refresh
128128+129129+extension APEnvironment {
130130+ /// Performs token refresh and updates the environment.
131131+ /// - Returns: true if refresh succeeded, false otherwise
132132+ public func performTokenRefresh() async -> Bool {
133133+ guard let state = authState else {
134134+ return false
135135+ }
136136+137137+ guard state.canRefresh else {
138138+ return false
139139+ }
140140+141141+ guard let serverMetadata = serverMetadata else {
142142+ return false
143143+ }
144144+145145+ guard let clientId = clientId else {
146146+ return false
147147+ }
148148+149149+ let refreshService = RefreshService()
150150+151151+ do {
152152+ let newState = try await refreshService.refresh(
153153+ state: state,
154154+ serverMetadata: serverMetadata,
155155+ clientId: clientId,
156156+ dpopGenerator: dpopProofGenerator
157157+ )
158158+159159+ // Update environment with new tokens
160160+ self.authState = newState
161161+ self.accessToken = newState.accessToken
162162+ self.refreshToken = newState.refreshToken
163163+164164+ // Update the Login object if present
165165+ if var currentLogin = login {
166166+ currentLogin.accessToken = Token(
167167+ value: newState.accessToken,
168168+ expiry: newState.accessTokenExpiry
169169+ )
170170+ if let newRefresh = newState.refreshToken {
171171+ currentLogin.refreshToken = Token(value: newRefresh)
172172+ }
173173+ self.login = currentLogin
174174+ }
175175+176176+ // Notify delegate of token update
177177+ await atProtocoldelegate?.tokensUpdated(
178178+ accessToken: newState.accessToken,
179179+ refreshToken: newState.refreshToken
180180+ )
181181+182182+ // Persist if storage is configured
183183+ if let storage = tokenStorage {
184184+ try? await storage.updateTokens(
185185+ access: newState.accessToken,
186186+ refresh: newState.refreshToken,
187187+ expiresIn: Int(newState.accessTokenExpiry?.timeIntervalSinceNow ?? 3600)
188188+ )
189189+ }
190190+191191+ return true
192192+ } catch {
193193+ // Log the error but don't throw - let caller handle retry logic
194194+ print("Token refresh failed: \(error)")
195195+ return false
196196+ }
197197+ }
198198+199199+ /// Sets the resource server DPoP nonce.
200200+ public func setResourceServerNonce(_ nonce: String?) {
201201+ resourceServerNonce = nonce
202202+ resourceDPoPSigner.nonce = nonce
203203+ }
204204+}
+239
Sources/CoreATProtocol/OAuth/TokenStorage.swift
···11+//
22+// TokenStorage.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Foundation
99+@preconcurrency import OAuthenticator
1010+1111+/// Protocol for persisting authentication tokens.
1212+/// Implementations should use secure storage such as Keychain on Apple platforms.
1313+public protocol TokenStorageProtocol: Sendable {
1414+ /// Stores the complete authentication state.
1515+ func store(_ authState: AuthenticationState) async throws
1616+1717+ /// Retrieves the stored authentication state.
1818+ func retrieve() async throws -> AuthenticationState?
1919+2020+ /// Clears all stored authentication data.
2121+ func clear() async throws
2222+2323+ /// Updates only the tokens without changing other state.
2424+ func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws
2525+}
2626+2727+/// Complete authentication state to be persisted.
2828+public struct AuthenticationState: Codable, Sendable {
2929+ public let did: String
3030+ public let handle: String?
3131+ public let pdsURL: String
3232+ public let authServerURL: String
3333+ public let accessToken: String
3434+ public let accessTokenExpiry: Date?
3535+ public let refreshToken: String?
3636+ public let refreshTokenExpiry: Date?
3737+ public let scope: String?
3838+ public let dpopPrivateKeyData: Data?
3939+ public let createdAt: Date
4040+ public let updatedAt: Date
4141+4242+ public init(
4343+ did: String,
4444+ handle: String?,
4545+ pdsURL: String,
4646+ authServerURL: String,
4747+ accessToken: String,
4848+ accessTokenExpiry: Date?,
4949+ refreshToken: String?,
5050+ refreshTokenExpiry: Date? = nil,
5151+ scope: String?,
5252+ dpopPrivateKeyData: Data?,
5353+ createdAt: Date = Date(),
5454+ updatedAt: Date = Date()
5555+ ) {
5656+ self.did = did
5757+ self.handle = handle
5858+ self.pdsURL = pdsURL
5959+ self.authServerURL = authServerURL
6060+ self.accessToken = accessToken
6161+ self.accessTokenExpiry = accessTokenExpiry
6262+ self.refreshToken = refreshToken
6363+ self.refreshTokenExpiry = refreshTokenExpiry
6464+ self.scope = scope
6565+ self.dpopPrivateKeyData = dpopPrivateKeyData
6666+ self.createdAt = createdAt
6767+ self.updatedAt = updatedAt
6868+ }
6969+7070+ /// Creates an updated state with new tokens.
7171+ public func withUpdatedTokens(
7272+ access: String,
7373+ refresh: String?,
7474+ expiresIn: Int
7575+ ) -> AuthenticationState {
7676+ AuthenticationState(
7777+ did: did,
7878+ handle: handle,
7979+ pdsURL: pdsURL,
8080+ authServerURL: authServerURL,
8181+ accessToken: access,
8282+ accessTokenExpiry: Date().addingTimeInterval(TimeInterval(expiresIn)),
8383+ refreshToken: refresh ?? refreshToken,
8484+ refreshTokenExpiry: refreshTokenExpiry,
8585+ scope: scope,
8686+ dpopPrivateKeyData: dpopPrivateKeyData,
8787+ createdAt: createdAt,
8888+ updatedAt: Date()
8989+ )
9090+ }
9191+9292+ /// Checks if the access token is expired or about to expire.
9393+ public var isAccessTokenExpired: Bool {
9494+ guard let expiry = accessTokenExpiry else { return false }
9595+ // Consider expired if less than 60 seconds remaining
9696+ return expiry.timeIntervalSinceNow < 60
9797+ }
9898+9999+ /// Checks if the refresh token is expired.
100100+ public var isRefreshTokenExpired: Bool {
101101+ guard let expiry = refreshTokenExpiry else { return false }
102102+ return expiry.timeIntervalSinceNow < 0
103103+ }
104104+105105+ /// Checks if we can attempt a token refresh.
106106+ public var canRefresh: Bool {
107107+ refreshToken != nil && !isRefreshTokenExpired
108108+ }
109109+}
110110+111111+/// In-memory token storage for testing or temporary use.
112112+/// Not recommended for production - use Keychain-based storage instead.
113113+@APActor
114114+public final class InMemoryTokenStorage: TokenStorageProtocol {
115115+ private var state: AuthenticationState?
116116+117117+ public init() {}
118118+119119+ public func store(_ authState: AuthenticationState) async throws {
120120+ self.state = authState
121121+ }
122122+123123+ public func retrieve() async throws -> AuthenticationState? {
124124+ return state
125125+ }
126126+127127+ public func clear() async throws {
128128+ state = nil
129129+ }
130130+131131+ public func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws {
132132+ guard let current = state else {
133133+ throw OAuthError.loginNotFound
134134+ }
135135+ state = current.withUpdatedTokens(access: access, refresh: refresh, expiresIn: expiresIn)
136136+ }
137137+}
138138+139139+#if canImport(Security)
140140+import Security
141141+142142+/// Keychain-based token storage for secure persistence on Apple platforms.
143143+@APActor
144144+public final class KeychainTokenStorage: TokenStorageProtocol {
145145+ private let service: String
146146+ private let account: String
147147+ private let accessGroup: String?
148148+149149+ /// Creates a new Keychain storage instance.
150150+ /// - Parameters:
151151+ /// - service: The service identifier (typically your app's bundle ID)
152152+ /// - account: The account identifier (can be a constant or user-specific)
153153+ /// - accessGroup: Optional access group for sharing between apps
154154+ public init(service: String, account: String = "atproto_auth", accessGroup: String? = nil) {
155155+ self.service = service
156156+ self.account = account
157157+ self.accessGroup = accessGroup
158158+ }
159159+160160+ public func store(_ authState: AuthenticationState) async throws {
161161+ let data = try JSONEncoder().encode(authState)
162162+163163+ var query: [String: Any] = [
164164+ kSecClass as String: kSecClassGenericPassword,
165165+ kSecAttrService as String: service,
166166+ kSecAttrAccount as String: account,
167167+ kSecValueData as String: data,
168168+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
169169+ ]
170170+171171+ if let group = accessGroup {
172172+ query[kSecAttrAccessGroup as String] = group
173173+ }
174174+175175+ // Delete existing item first
176176+ let deleteQuery: [String: Any] = [
177177+ kSecClass as String: kSecClassGenericPassword,
178178+ kSecAttrService as String: service,
179179+ kSecAttrAccount as String: account
180180+ ]
181181+ SecItemDelete(deleteQuery as CFDictionary)
182182+183183+ let status = SecItemAdd(query as CFDictionary, nil)
184184+185185+ guard status == errSecSuccess else {
186186+ throw OAuthError.storageFailed(reason: "Keychain write failed with status: \(status)")
187187+ }
188188+ }
189189+190190+ public func retrieve() async throws -> AuthenticationState? {
191191+ var query: [String: Any] = [
192192+ kSecClass as String: kSecClassGenericPassword,
193193+ kSecAttrService as String: service,
194194+ kSecAttrAccount as String: account,
195195+ kSecReturnData as String: true,
196196+ kSecMatchLimit as String: kSecMatchLimitOne
197197+ ]
198198+199199+ if let group = accessGroup {
200200+ query[kSecAttrAccessGroup as String] = group
201201+ }
202202+203203+ var result: AnyObject?
204204+ let status = SecItemCopyMatching(query as CFDictionary, &result)
205205+206206+ guard status == errSecSuccess, let data = result as? Data else {
207207+ if status == errSecItemNotFound {
208208+ return nil
209209+ }
210210+ throw OAuthError.storageFailed(reason: "Keychain read failed with status: \(status)")
211211+ }
212212+213213+ return try JSONDecoder().decode(AuthenticationState.self, from: data)
214214+ }
215215+216216+ public func clear() async throws {
217217+ let query: [String: Any] = [
218218+ kSecClass as String: kSecClassGenericPassword,
219219+ kSecAttrService as String: service,
220220+ kSecAttrAccount as String: account
221221+ ]
222222+223223+ let status = SecItemDelete(query as CFDictionary)
224224+225225+ guard status == errSecSuccess || status == errSecItemNotFound else {
226226+ throw OAuthError.storageFailed(reason: "Keychain delete failed with status: \(status)")
227227+ }
228228+ }
229229+230230+ public func updateTokens(access: String, refresh: String?, expiresIn: Int) async throws {
231231+ guard let current = try await retrieve() else {
232232+ throw OAuthError.loginNotFound
233233+ }
234234+235235+ let updated = current.withUpdatedTokens(access: access, refresh: refresh, expiresIn: expiresIn)
236236+ try await store(updated)
237237+ }
238238+}
239239+#endif
···11+//
22+// DPoPJWTGeneratorTests.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Testing
99+import Foundation
1010+import JWTKit
1111+@testable import CoreATProtocol
1212+1313+@Suite("DPoP JWT Generator Tests", .serialized)
1414+struct DPoPJWTGeneratorTests {
1515+1616+ @Test("DPoP JWT Generator can be created with ES256 key")
1717+ func testGeneratorCreation() async throws {
1818+ let privateKey = ES256PrivateKey()
1919+ let generator = try await DPoPJWTGenerator(privateKey: privateKey)
2020+2121+ // Verify we can get a JWT generator function
2222+ _ = await generator.jwtGenerator()
2323+ // If we get here without throwing, the test passes
2424+ }
2525+2626+ @Test("DPoPKeyMaterialError cases exist")
2727+ func testKeyMaterialErrors() {
2828+ // Test error cases exist and are equatable
2929+ let error1 = DPoPKeyMaterialError.publicKeyUnavailable
3030+ let error2 = DPoPKeyMaterialError.invalidCoordinate
3131+3232+ #expect(error1 != error2)
3333+ #expect(error1 == DPoPKeyMaterialError.publicKeyUnavailable)
3434+ }
3535+3636+ @Test("Resource server nonce can be updated")
3737+ func testResourceServerNonce() async {
3838+ // Clear state first
3939+ await updateResourceDPoPNonce(nil)
4040+4141+ // Set nonce using the public function
4242+ await updateResourceDPoPNonce("test-nonce-value")
4343+ let nonce = await APEnvironment.current.resourceServerNonce
4444+ #expect(nonce == "test-nonce-value")
4545+4646+ // Clear it
4747+ await updateResourceDPoPNonce(nil)
4848+ let clearedNonce = await APEnvironment.current.resourceServerNonce
4949+ #expect(clearedNonce == nil)
5050+ }
5151+}
+119
Tests/CoreATProtocolTests/DateDecodingTests.swift
···11+//
22+// DateDecodingTests.swift
33+// CoreATProtocol
44+//
55+// Created by Claude on 2026-01-02.
66+//
77+88+import Testing
99+import Foundation
1010+@testable import CoreATProtocol
1111+1212+@Suite("Date Decoding Tests")
1313+struct DateDecodingTests {
1414+1515+ struct DateContainer: Decodable {
1616+ let date: Date
1717+ }
1818+1919+ @Test("Decodes ISO 8601 with milliseconds and Z timezone")
2020+ func testMillisecondsWithZ() throws {
2121+ let json = """
2222+ {"date": "2024-01-15T10:30:00.123Z"}
2323+ """.data(using: .utf8)!
2424+2525+ let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json)
2626+2727+ let calendar = Calendar(identifier: .gregorian)
2828+ let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date)
2929+3030+ #expect(components.year == 2024)
3131+ #expect(components.month == 1)
3232+ #expect(components.day == 15)
3333+ #expect(components.hour == 10)
3434+ #expect(components.minute == 30)
3535+ }
3636+3737+ @Test("Decodes ISO 8601 with offset timezone")
3838+ func testWithOffset() throws {
3939+ let json = """
4040+ {"date": "2024-06-20T15:45:30.000+00:00"}
4141+ """.data(using: .utf8)!
4242+4343+ let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json)
4444+4545+ let calendar = Calendar(identifier: .gregorian)
4646+ let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date)
4747+4848+ #expect(components.year == 2024)
4949+ #expect(components.month == 6)
5050+ #expect(components.day == 20)
5151+ #expect(components.hour == 15)
5252+ #expect(components.minute == 45)
5353+ }
5454+5555+ @Test("Decodes ISO 8601 without fractional seconds")
5656+ func testWithoutFractional() throws {
5757+ let json = """
5858+ {"date": "2024-03-10T08:00:00Z"}
5959+ """.data(using: .utf8)!
6060+6161+ let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json)
6262+6363+ let calendar = Calendar(identifier: .gregorian)
6464+ let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: container.date)
6565+6666+ #expect(components.year == 2024)
6767+ #expect(components.month == 3)
6868+ #expect(components.day == 10)
6969+ }
7070+7171+ @Test("Decodes microseconds precision")
7272+ func testMicroseconds() throws {
7373+ let json = """
7474+ {"date": "2024-12-25T12:00:00.123456+00:00"}
7575+ """.data(using: .utf8)!
7676+7777+ let container = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json)
7878+7979+ // Just verify it parses without error
8080+ #expect(container.date != Date.distantPast)
8181+ }
8282+8383+ @Test("Multiple date formats in same response")
8484+ func testMultipleFormats() throws {
8585+ struct MultipleDates: Decodable {
8686+ let createdAt: Date
8787+ let indexedAt: Date
8888+ let updatedAt: Date
8989+ }
9090+9191+ let json = """
9292+ {
9393+ "createdAt": "2024-01-01T00:00:00.000Z",
9494+ "indexedAt": "2024-01-01T00:00:00Z",
9595+ "updatedAt": "2024-01-01T00:00:00.000+00:00"
9696+ }
9797+ """.data(using: .utf8)!
9898+9999+ let dates = try JSONDecoder.atDecoder.decode(MultipleDates.self, from: json)
100100+101101+ // All should parse to the same time (within a small margin)
102102+ let interval1 = abs(dates.createdAt.timeIntervalSince(dates.indexedAt))
103103+ let interval2 = abs(dates.createdAt.timeIntervalSince(dates.updatedAt))
104104+105105+ #expect(interval1 < 1, "Dates should be within 1 second of each other")
106106+ #expect(interval2 < 1, "Dates should be within 1 second of each other")
107107+ }
108108+109109+ @Test("Throws on invalid date format")
110110+ func testInvalidFormat() {
111111+ let json = """
112112+ {"date": "not-a-date"}
113113+ """.data(using: .utf8)!
114114+115115+ #expect(throws: DecodingError.self) {
116116+ _ = try JSONDecoder.atDecoder.decode(DateContainer.self, from: json)
117117+ }
118118+ }
119119+}