this repo has no description

Make CoreATProtocol auth proxy support session-aware with direct fallback

+471 -65
+187 -48
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 48 48 public let storePrivateKey: @Sendable (Data) async throws -> Void 49 49 public let retrieveAuthProxyKeyID: (@Sendable () async throws -> String?)? 50 50 public let storeAuthProxyKeyID: (@Sendable (String) async throws -> Void)? 51 + public let clearAuthProxyKeyID: (@Sendable () async throws -> Void)? 52 + public let retrieveAuthProxyKeyIDForLogin: (@Sendable (Login) async throws -> String?)? 53 + public let storeAuthProxyKeyIDForLogin: (@Sendable (Login, String) async throws -> Void)? 54 + public let clearAuthProxyKeyIDForLogin: (@Sendable (Login) async throws -> Void)? 51 55 52 56 public init( 53 57 retrieveLogin: @escaping @Sendable () async throws -> Login?, ··· 55 59 retrievePrivateKey: @escaping @Sendable () async throws -> Data?, 56 60 storePrivateKey: @escaping @Sendable (Data) async throws -> Void, 57 61 retrieveAuthProxyKeyID: (@Sendable () async throws -> String?)? = nil, 58 - storeAuthProxyKeyID: (@Sendable (String) async throws -> Void)? = nil 62 + storeAuthProxyKeyID: (@Sendable (String) async throws -> Void)? = nil, 63 + clearAuthProxyKeyID: (@Sendable () async throws -> Void)? = nil, 64 + retrieveAuthProxyKeyIDForLogin: (@Sendable (Login) async throws -> String?)? = nil, 65 + storeAuthProxyKeyIDForLogin: (@Sendable (Login, String) async throws -> Void)? = nil, 66 + clearAuthProxyKeyIDForLogin: (@Sendable (Login) async throws -> Void)? = nil 59 67 ) { 60 68 self.retrieveLogin = retrieveLogin 61 69 self.storeLogin = storeLogin ··· 63 71 self.storePrivateKey = storePrivateKey 64 72 self.retrieveAuthProxyKeyID = retrieveAuthProxyKeyID 65 73 self.storeAuthProxyKeyID = storeAuthProxyKeyID 74 + self.clearAuthProxyKeyID = clearAuthProxyKeyID 75 + self.retrieveAuthProxyKeyIDForLogin = retrieveAuthProxyKeyIDForLogin 76 + self.storeAuthProxyKeyIDForLogin = storeAuthProxyKeyIDForLogin 77 + self.clearAuthProxyKeyIDForLogin = clearAuthProxyKeyIDForLogin 66 78 } 67 79 } 68 80 ··· 122 134 // JWT signing keys (pattern from AtProtocol) 123 135 private var keys: JWTKeyCollection 124 136 private var privateKey: ES256PrivateKey 137 + 138 + private struct AuthenticationAttemptResult { 139 + let login: Login 140 + let usedAuthProxy: Bool 141 + } 125 142 126 143 public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage) async { 127 144 self.config = config ··· 230 247 try await self.generateJWT(params: params) 231 248 } 232 249 233 - // Step 7: Create proxy URL loader if auth proxy is configured 250 + // Step 7: Create authenticator configuration 251 + let tokenHandling = buildTokenHandling( 252 + accountHint: identifier, 253 + server: serverConfig, 254 + jwtGenerator: jwtGenerator, 255 + expectedSubjectDID: identity.did, 256 + expectedAuthorizationServer: identity.authorizationServer 257 + ) 258 + 259 + let makeAuthenticatorConfiguration = { 260 + Authenticator.Configuration( 261 + appCredentials: clientConfig.credentials, 262 + loginStorage: loginStorage, 263 + tokenHandling: tokenHandling, 264 + mode: .manualOnly, 265 + userAuthenticator: userAuthenticator 266 + ) 267 + } 268 + 269 + // Step 8: Create authenticator 234 270 let proxyKeyIDStorage: AuthProxyKeyIDStorage? 235 - let proxyURLLoader: URLResponseProvider? 271 + let directAuthenticator = Authenticator(config: makeAuthenticatorConfiguration()) 272 + let authenticator: Authenticator 236 273 237 274 if let proxyBaseURL = config.authProxyBaseURL { 238 - let initialKeyID = try? await storage.retrieveAuthProxyKeyID?() 239 - let keyIDStorage = AuthProxyKeyIDStorage(keyID: initialKeyID) 275 + let keyIDStorage = AuthProxyKeyIDStorage() 240 276 proxyKeyIDStorage = keyIDStorage 241 - proxyURLLoader = makeProxyURLLoader( 277 + let proxyURLLoader = makeProxyURLLoader( 242 278 proxyBaseURL: proxyBaseURL, 243 279 tokenEndpoint: serverConfig.tokenEndpoint, 244 280 parEndpoint: serverConfig.pushedAuthorizationRequestEndpoint, 245 281 issuer: serverConfig.issuer, 246 - keyIDStorage: keyIDStorage 282 + keyIDStorage: keyIDStorage, 283 + fallbackProvider: provider 247 284 ) 285 + authenticator = Authenticator(config: makeAuthenticatorConfiguration(), urlLoader: proxyURLLoader) 248 286 } else { 249 287 proxyKeyIDStorage = nil 250 - proxyURLLoader = nil 288 + authenticator = directAuthenticator 251 289 } 252 290 253 - // Step 8: Create authenticator 254 - let tokenHandling = buildTokenHandling( 255 - accountHint: identifier, 256 - server: serverConfig, 257 - jwtGenerator: jwtGenerator, 258 - expectedSubjectDID: identity.did, 259 - expectedAuthorizationServer: identity.authorizationServer 260 - ) 261 - 262 - let authenticatorConfig = Authenticator.Configuration( 263 - appCredentials: clientConfig.credentials, 264 - loginStorage: loginStorage, 265 - tokenHandling: tokenHandling, 266 - mode: .manualOnly, 267 - userAuthenticator: userAuthenticator 268 - ) 269 - 270 - let authenticator = Authenticator(config: authenticatorConfig, urlLoader: proxyURLLoader) 271 - 272 291 // Step 9: Trigger authentication with user interaction 273 - let login: Login 292 + let authenticationResult: AuthenticationAttemptResult 274 293 do { 275 - login = try await authenticator.authenticate() 294 + authenticationResult = try await performAuthentication( 295 + preferredAuthenticator: authenticator, 296 + fallbackAuthenticator: config.authProxyBaseURL == nil ? nil : directAuthenticator, 297 + prefersAuthProxy: config.authProxyBaseURL != nil 298 + ) 276 299 } catch { 277 - if shouldRecoverFromRefreshFailure(error) { 278 - try await storage.storeLogin(Login(token: "invalid", validUntilDate: .distantPast)) 279 - try await resetDPoPKey() 280 - do { 281 - login = try await authenticator.authenticate() 282 - } catch { 283 - throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 284 - } 285 - } else { 286 - throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 287 - } 300 + throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 288 301 } 302 + let login = authenticationResult.login 289 303 290 304 // Step 10: Persist auth proxy key ID 291 - if let proxyKeyIDStorage, let keyID = await proxyKeyIDStorage.keyID { 292 - try? await storage.storeAuthProxyKeyID?(keyID) 305 + if config.authProxyBaseURL != nil { 306 + if authenticationResult.usedAuthProxy, 307 + let proxyKeyIDStorage, 308 + let keyID = await proxyKeyIDStorage.keyID { 309 + await persistAuthProxyKeyID(keyID, for: login) 310 + } else { 311 + await clearAuthProxyKeyID(for: login) 312 + } 293 313 } 294 314 295 315 // Step 11: Setup CoreATProtocol environment ··· 384 404 let proxyKeyIDStorage: AuthProxyKeyIDStorage? 385 405 386 406 if let proxyBaseURL = config.authProxyBaseURL { 387 - let initialKeyID = try? await storage.retrieveAuthProxyKeyID?() 407 + let initialKeyID = await retrieveAuthProxyKeyID(for: login) 388 408 let keyIDStorage = AuthProxyKeyIDStorage(keyID: initialKeyID) 389 409 proxyKeyIDStorage = keyIDStorage 390 410 baseProvider = makeProxyURLLoader( ··· 392 412 tokenEndpoint: serverConfig.tokenEndpoint, 393 413 parEndpoint: serverConfig.pushedAuthorizationRequestEndpoint, 394 414 issuer: serverConfig.issuer, 395 - keyIDStorage: keyIDStorage 415 + keyIDStorage: keyIDStorage, 416 + fallbackProvider: provider 396 417 ) 397 418 } else { 398 419 baseProvider = provider 399 420 proxyKeyIDStorage = nil 400 421 } 401 422 402 - let responseProvider: URLResponseProvider = { request in 423 + let directResponseProvider: URLResponseProvider = { request in 424 + try await self.dpopRequestActor.response( 425 + request: request, 426 + jwtGenerator: jwtGenerator, 427 + provider: provider, 428 + issuingServer: issuer 429 + ) 430 + } 431 + 432 + let proxyResponseProvider: URLResponseProvider = { request in 403 433 try await self.dpopRequestActor.response( 404 434 request: request, 405 435 jwtGenerator: jwtGenerator, ··· 408 438 ) 409 439 } 410 440 411 - let refreshedLogin = try await refreshProvider(login, clientConfig.credentials, responseProvider) 441 + let refreshedLogin: Login 442 + let usedAuthProxy: Bool 443 + 444 + if config.authProxyBaseURL != nil { 445 + do { 446 + refreshedLogin = try await refreshProvider(login, clientConfig.credentials, proxyResponseProvider) 447 + usedAuthProxy = true 448 + } catch { 449 + guard shouldRetryWithoutAuthProxy(after: error) else { 450 + throw error 451 + } 452 + 453 + refreshedLogin = try await refreshProvider(login, clientConfig.credentials, directResponseProvider) 454 + usedAuthProxy = false 455 + } 456 + } else { 457 + refreshedLogin = try await refreshProvider(login, clientConfig.credentials, directResponseProvider) 458 + usedAuthProxy = false 459 + } 460 + 412 461 try await storage.storeLogin(refreshedLogin) 413 462 414 463 // Persist updated auth proxy key ID 415 - if let proxyKeyIDStorage, let keyID = await proxyKeyIDStorage.keyID { 416 - try? await storage.storeAuthProxyKeyID?(keyID) 464 + if config.authProxyBaseURL != nil { 465 + if usedAuthProxy, let proxyKeyIDStorage, let keyID = await proxyKeyIDStorage.keyID { 466 + await persistAuthProxyKeyID(keyID, for: refreshedLogin) 467 + } else { 468 + await clearAuthProxyKeyID(for: refreshedLogin) 469 + } 417 470 } 418 471 419 472 return refreshedLogin ··· 509 562 try await persistPrivateKey() 510 563 } 511 564 565 + private func performAuthentication( 566 + preferredAuthenticator: Authenticator, 567 + fallbackAuthenticator: Authenticator?, 568 + prefersAuthProxy: Bool 569 + ) async throws -> AuthenticationAttemptResult { 570 + do { 571 + return AuthenticationAttemptResult( 572 + login: try await preferredAuthenticator.authenticate(), 573 + usedAuthProxy: prefersAuthProxy 574 + ) 575 + } catch { 576 + if shouldRecoverFromRefreshFailure(error) { 577 + let previousLogin = try? await storage.retrieveLogin() 578 + try await storage.storeLogin(Login(token: "invalid", validUntilDate: .distantPast)) 579 + await clearAuthProxyKeyID(for: previousLogin) 580 + try await resetDPoPKey() 581 + 582 + do { 583 + return AuthenticationAttemptResult( 584 + login: try await preferredAuthenticator.authenticate(), 585 + usedAuthProxy: prefersAuthProxy 586 + ) 587 + } catch { 588 + if let fallbackAuthenticator, shouldRetryWithoutAuthProxy(after: error) { 589 + return AuthenticationAttemptResult( 590 + login: try await fallbackAuthenticator.authenticate(), 591 + usedAuthProxy: false 592 + ) 593 + } 594 + 595 + throw error 596 + } 597 + } 598 + 599 + if let fallbackAuthenticator, shouldRetryWithoutAuthProxy(after: error) { 600 + return AuthenticationAttemptResult( 601 + login: try await fallbackAuthenticator.authenticate(), 602 + usedAuthProxy: false 603 + ) 604 + } 605 + 606 + throw error 607 + } 608 + } 609 + 512 610 private func shouldRecoverFromRefreshFailure(_ error: Error) -> Bool { 513 611 guard let authError = error as? AuthenticatorError else { 514 612 return false ··· 520 618 default: 521 619 return false 522 620 } 621 + } 622 + 623 + private func shouldRetryWithoutAuthProxy(after error: Error) -> Bool { 624 + guard let proxyError = error as? AuthProxyError else { 625 + return false 626 + } 627 + 628 + switch proxyError { 629 + case .unavailable: 630 + return true 631 + } 632 + } 633 + 634 + private func retrieveAuthProxyKeyID(for login: Login) async -> String? { 635 + if let retrieveForLogin = storage.retrieveAuthProxyKeyIDForLogin { 636 + return try? await retrieveForLogin(login) 637 + } 638 + 639 + if let retrieve = storage.retrieveAuthProxyKeyID { 640 + return try? await retrieve() 641 + } 642 + 643 + return nil 644 + } 645 + 646 + private func persistAuthProxyKeyID(_ keyID: String, for login: Login) async { 647 + if let storeForLogin = storage.storeAuthProxyKeyIDForLogin { 648 + try? await storeForLogin(login, keyID) 649 + return 650 + } 651 + 652 + try? await storage.storeAuthProxyKeyID?(keyID) 653 + } 654 + 655 + private func clearAuthProxyKeyID(for login: Login?) async { 656 + if let login, let clearForLogin = storage.clearAuthProxyKeyIDForLogin { 657 + try? await clearForLogin(login) 658 + return 659 + } 660 + 661 + try? await storage.clearAuthProxyKeyID?() 523 662 } 524 663 525 664 private func buildTokenHandling(
+143 -17
Sources/CoreATProtocol/OAuth/AuthProxy.swift
··· 51 51 } 52 52 } 53 53 54 + // MARK: - Auth Proxy Errors 55 + 56 + enum AuthProxyRequestKind: String, Sendable { 57 + case pushedAuthorizationRequest 58 + case tokenExchange 59 + case refresh 60 + 61 + var description: String { 62 + switch self { 63 + case .pushedAuthorizationRequest: 64 + "PAR request" 65 + case .tokenExchange: 66 + "token exchange" 67 + case .refresh: 68 + "refresh request" 69 + } 70 + } 71 + } 72 + 73 + enum AuthProxyError: LocalizedError, Sendable { 74 + case unavailable(requestKind: AuthProxyRequestKind, detail: String) 75 + 76 + var errorDescription: String? { 77 + switch self { 78 + case .unavailable(let requestKind, let detail): 79 + "Auth proxy unavailable during \(requestKind.description): \(detail)" 80 + } 81 + } 82 + } 83 + 54 84 // MARK: - Key ID Storage 55 85 56 86 /// Thread-safe storage for the auth proxy key ID, used during a single auth flow. 57 87 actor AuthProxyKeyIDStorage { 58 88 var keyID: String? 89 + var prefersDirectRequests = false 59 90 60 91 init(keyID: String? = nil) { 61 92 self.keyID = keyID ··· 64 95 func update(_ newKeyID: String) { 65 96 keyID = newKeyID 66 97 } 98 + 99 + func bypassProxy() { 100 + prefersDirectRequests = true 101 + keyID = nil 102 + } 67 103 } 68 104 69 105 // MARK: - Proxy URL Loader ··· 76 112 tokenEndpoint: String, 77 113 parEndpoint: String, 78 114 issuer: String, 79 - keyIDStorage: AuthProxyKeyIDStorage 115 + keyIDStorage: AuthProxyKeyIDStorage, 116 + fallbackProvider: @escaping URLResponseProvider = URLSession.defaultProvider 80 117 ) -> URLResponseProvider { 81 118 { request in 82 - guard let requestURL = request.url?.absoluteString else { 83 - return try await URLSession.shared.data(for: request) 119 + if await keyIDStorage.prefersDirectRequests { 120 + return try await fallbackProvider(request) 84 121 } 85 122 86 - let isTokenRequest = requestURL == tokenEndpoint 87 - let isPARRequest = requestURL == parEndpoint 123 + guard let requestURL = request.url?.absoluteString else { 124 + return try await fallbackProvider(request) 125 + } 88 126 89 - guard isTokenRequest || isPARRequest else { 90 - return try await URLSession.shared.data(for: request) 127 + guard let requestKind = authProxyRequestKind( 128 + requestURL: requestURL, 129 + tokenEndpoint: tokenEndpoint, 130 + parEndpoint: parEndpoint, 131 + requestBody: request.httpBody 132 + ) else { 133 + return try await fallbackProvider(request) 91 134 } 92 135 93 - let proxyPath = isTokenRequest ? "/oauth/token" : "/oauth/par" 136 + let proxyPath = requestKind == .pushedAuthorizationRequest ? "/oauth/par" : "/oauth/token" 94 137 guard let proxyURL = URL(string: proxyBaseURL + proxyPath) else { 95 - return try await URLSession.shared.data(for: request) 138 + if requestKind == .pushedAuthorizationRequest { 139 + await keyIDStorage.bypassProxy() 140 + return try await fallbackProvider(request) 141 + } 142 + 143 + throw AuthProxyError.unavailable( 144 + requestKind: requestKind, 145 + detail: "Invalid auth proxy base URL: \(proxyBaseURL)" 146 + ) 96 147 } 97 148 98 149 var proxyRequest = URLRequest(url: proxyURL) ··· 107 158 108 159 let currentKeyID = await keyIDStorage.keyID 109 160 110 - if isPARRequest { 161 + if requestKind == .pushedAuthorizationRequest { 111 162 // Convert form-encoded PAR body to JSON for the proxy 112 163 let formParams = parseFormEncoded(request.httpBody) 113 164 let parBody = AuthProxyPARRequest( ··· 136 187 proxyRequest.httpBody = try JSONSerialization.data(withJSONObject: bodyDict) 137 188 } 138 189 139 - let (data, response) = try await URLSession.shared.data(for: proxyRequest) 190 + do { 191 + let (data, response) = try await fallbackProvider(proxyRequest) 140 192 141 - // Capture Auth-Proxy-Key-ID from every proxy response 142 - if let httpResponse = response as? HTTPURLResponse, 143 - let newKeyID = httpResponse.value(forHTTPHeaderField: "Auth-Proxy-Key-ID") { 144 - await keyIDStorage.update(newKeyID) 145 - } 193 + if let httpResponse = response as? HTTPURLResponse { 194 + if let newKeyID = httpResponse.value(forHTTPHeaderField: "Auth-Proxy-Key-ID") { 195 + await keyIDStorage.update(newKeyID) 196 + } 146 197 147 - return (data, response) 198 + if shouldTreatProxyResponseAsUnavailable(httpResponse) { 199 + if requestKind == .pushedAuthorizationRequest { 200 + await keyIDStorage.bypassProxy() 201 + return try await fallbackProvider(request) 202 + } 203 + 204 + throw AuthProxyError.unavailable( 205 + requestKind: requestKind, 206 + detail: "Proxy returned HTTP \(httpResponse.statusCode)" 207 + ) 208 + } 209 + } 210 + 211 + return (data, response) 212 + } catch let error as AuthProxyError { 213 + throw error 214 + } catch { 215 + guard shouldBypassUnavailableAuthProxy(error) else { 216 + throw error 217 + } 218 + 219 + if requestKind == .pushedAuthorizationRequest { 220 + await keyIDStorage.bypassProxy() 221 + return try await fallbackProvider(request) 222 + } 223 + 224 + throw AuthProxyError.unavailable( 225 + requestKind: requestKind, 226 + detail: error.localizedDescription 227 + ) 228 + } 148 229 } 149 230 } 150 231 151 232 // MARK: - Helpers 233 + 234 + func authProxyRequestKind( 235 + requestURL: String, 236 + tokenEndpoint: String, 237 + parEndpoint: String, 238 + requestBody: Data? 239 + ) -> AuthProxyRequestKind? { 240 + if requestURL == parEndpoint { 241 + return .pushedAuthorizationRequest 242 + } 243 + 244 + guard requestURL == tokenEndpoint else { 245 + return nil 246 + } 247 + 248 + guard let requestBody, 249 + let json = (try? JSONSerialization.jsonObject(with: requestBody)) as? [String: Any], 250 + let grantType = json["grant_type"] as? String else { 251 + return .tokenExchange 252 + } 253 + 254 + return grantType == "refresh_token" ? .refresh : .tokenExchange 255 + } 256 + 257 + func shouldTreatProxyResponseAsUnavailable(_ response: HTTPURLResponse) -> Bool { 258 + return switch response.statusCode { 259 + case 404, 405, 502, 503, 504: 260 + true 261 + default: 262 + false 263 + } 264 + } 265 + 266 + func shouldBypassUnavailableAuthProxy(_ error: Error) -> Bool { 267 + guard let urlError = error as? URLError else { 268 + return false 269 + } 270 + 271 + return switch urlError.code { 272 + case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, .notConnectedToInternet, .resourceUnavailable: 273 + true 274 + default: 275 + false 276 + } 277 + } 152 278 153 279 /// Parses a `application/x-www-form-urlencoded` body into a dictionary. 154 280 func parseFormEncoded(_ data: Data?) -> [String: String] {
+141
Tests/CoreATProtocolTests/OAuthTests.swift
··· 212 212 #expect(parseFormEncoded(Data()).isEmpty) 213 213 } 214 214 215 + @Test("Auth proxy request kind detects PAR, token exchange, and refresh") 216 + func testAuthProxyRequestKind() throws { 217 + let tokenExchangeBody = try JSONSerialization.data(withJSONObject: [ 218 + "grant_type": "authorization_code", 219 + "code": "abc123" 220 + ]) 221 + let refreshBody = try JSONSerialization.data(withJSONObject: [ 222 + "grant_type": "refresh_token", 223 + "refresh_token": "refresh" 224 + ]) 225 + 226 + #expect( 227 + authProxyRequestKind( 228 + requestURL: "https://bsky.social/oauth/par", 229 + tokenEndpoint: "https://bsky.social/oauth/token", 230 + parEndpoint: "https://bsky.social/oauth/par", 231 + requestBody: nil 232 + ) == .pushedAuthorizationRequest 233 + ) 234 + #expect( 235 + authProxyRequestKind( 236 + requestURL: "https://bsky.social/oauth/token", 237 + tokenEndpoint: "https://bsky.social/oauth/token", 238 + parEndpoint: "https://bsky.social/oauth/par", 239 + requestBody: tokenExchangeBody 240 + ) == .tokenExchange 241 + ) 242 + #expect( 243 + authProxyRequestKind( 244 + requestURL: "https://bsky.social/oauth/token", 245 + tokenEndpoint: "https://bsky.social/oauth/token", 246 + parEndpoint: "https://bsky.social/oauth/par", 247 + requestBody: refreshBody 248 + ) == .refresh 249 + ) 250 + } 251 + 252 + @Test("Auth proxy availability checks only retry for recoverable transport errors") 253 + func testRecoverableProxyTransportErrors() { 254 + #expect(shouldBypassUnavailableAuthProxy(URLError(.cannotConnectToHost))) 255 + #expect(shouldBypassUnavailableAuthProxy(URLError(.dnsLookupFailed))) 256 + #expect(!shouldBypassUnavailableAuthProxy(URLError(.timedOut))) 257 + #expect(!shouldBypassUnavailableAuthProxy(URLError(.badServerResponse))) 258 + } 259 + 260 + @Test("Auth proxy availability checks only retry for recoverable HTTP statuses") 261 + func testRecoverableProxyStatuses() throws { 262 + let unavailableResponse = try #require( 263 + HTTPURLResponse( 264 + url: URL(string: "https://auth.example.com/oauth/token")!, 265 + statusCode: 503, 266 + httpVersion: nil, 267 + headerFields: nil 268 + ) 269 + ) 270 + let invalidRequestResponse = try #require( 271 + HTTPURLResponse( 272 + url: URL(string: "https://auth.example.com/oauth/token")!, 273 + statusCode: 400, 274 + httpVersion: nil, 275 + headerFields: nil 276 + ) 277 + ) 278 + 279 + #expect(shouldTreatProxyResponseAsUnavailable(unavailableResponse)) 280 + #expect(!shouldTreatProxyResponseAsUnavailable(invalidRequestResponse)) 281 + } 282 + 215 283 @Test("AuthProxyKeyIDStorage stores and retrieves key ID") 216 284 func testKeyIDStorage() async { 217 285 let storage = AuthProxyKeyIDStorage() ··· 227 295 #expect(rotated == "key-2") 228 296 } 229 297 298 + @Test("AuthProxyKeyIDStorage can bypass proxy for remaining flow") 299 + func testKeyIDStorageBypassesProxy() async { 300 + let storage = AuthProxyKeyIDStorage(keyID: "existing-key") 301 + 302 + #expect(await storage.prefersDirectRequests == false) 303 + #expect(await storage.keyID == "existing-key") 304 + 305 + await storage.bypassProxy() 306 + 307 + #expect(await storage.prefersDirectRequests) 308 + #expect(await storage.keyID == nil) 309 + } 310 + 230 311 @Test("AuthProxyKeyIDStorage initializes with existing key ID") 231 312 func testKeyIDStorageWithInitialValue() async { 232 313 let storage = AuthProxyKeyIDStorage(keyID: "existing-key") ··· 258 339 #expect(retrieved == "test-key") 259 340 } 260 341 342 + @Test("ATProtoAuthStorage accepts session-scoped auth proxy key ID closures") 343 + func testStorageWithSessionScopedProxyKeyID() async throws { 344 + actor SessionKeyIDStore { 345 + var records: [String: String] = [:] 346 + 347 + func retrieve(for login: Login) -> String? { 348 + records[login.accessToken.value] 349 + } 350 + 351 + func store(login: Login, keyID: String) { 352 + records[login.accessToken.value] = keyID 353 + } 354 + 355 + func clear(for login: Login) { 356 + records.removeValue(forKey: login.accessToken.value) 357 + } 358 + } 359 + 360 + let sessionStore = SessionKeyIDStore() 361 + let login = Login( 362 + accessToken: Token(value: "access-token"), 363 + refreshToken: Token(value: "refresh-token") 364 + ) 365 + 366 + let storage = ATProtoAuthStorage( 367 + retrieveLogin: { nil }, 368 + storeLogin: { _ in }, 369 + retrievePrivateKey: { nil }, 370 + storePrivateKey: { _ in }, 371 + retrieveAuthProxyKeyIDForLogin: { login in 372 + await sessionStore.retrieve(for: login) 373 + }, 374 + storeAuthProxyKeyIDForLogin: { login, keyID in 375 + await sessionStore.store(login: login, keyID: keyID) 376 + }, 377 + clearAuthProxyKeyIDForLogin: { login in 378 + await sessionStore.clear(for: login) 379 + } 380 + ) 381 + 382 + #expect(storage.retrieveAuthProxyKeyIDForLogin != nil) 383 + #expect(storage.storeAuthProxyKeyIDForLogin != nil) 384 + #expect(storage.clearAuthProxyKeyIDForLogin != nil) 385 + 386 + let initial = try await storage.retrieveAuthProxyKeyIDForLogin?(login) 387 + #expect(initial == nil) 388 + 389 + try await storage.storeAuthProxyKeyIDForLogin?(login, "proxy-key") 390 + let stored = try await storage.retrieveAuthProxyKeyIDForLogin?(login) 391 + #expect(stored == "proxy-key") 392 + 393 + try await storage.clearAuthProxyKeyIDForLogin?(login) 394 + let cleared = try await storage.retrieveAuthProxyKeyIDForLogin?(login) 395 + #expect(cleared == nil) 396 + } 397 + 261 398 @Test("ATProtoAuthStorage defaults proxy key ID closures to nil") 262 399 func testStorageWithoutProxyKeyID() { 263 400 let storage = ATProtoAuthStorage( ··· 269 406 270 407 #expect(storage.retrieveAuthProxyKeyID == nil) 271 408 #expect(storage.storeAuthProxyKeyID == nil) 409 + #expect(storage.clearAuthProxyKeyID == nil) 410 + #expect(storage.retrieveAuthProxyKeyIDForLogin == nil) 411 + #expect(storage.storeAuthProxyKeyIDForLogin == nil) 412 + #expect(storage.clearAuthProxyKeyIDForLogin == nil) 272 413 } 273 414 } 274 415