diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 1806f78b..3f59bd6a 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1251,7 +1251,9 @@ extension UsageStore { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) { msg in lines.append(msg) } } // Don't prompt for keychain access during debug dump - let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load(allowKeychainPrompt: false)) != nil + let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load( + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true)) != nil let hasClaudeBinary = BinaryLocator.resolveClaudeBinary( env: ProcessInfo.processInfo.environment, loginPATH: LoginShellPathCache.shared.current) != nil diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index eb72fa42..d3bb3f52 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -4,6 +4,10 @@ import Foundation import FoundationNetworking #endif +#if canImport(CryptoKit) +import CryptoKit +#endif + #if os(macOS) import LocalAuthentication import Security @@ -149,12 +153,33 @@ public enum ClaudeOAuthCredentialsStore { private static let log = CodexBarLog.logger(LogCategories.claudeUsage) private static let fileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV1" private static let claudeKeychainPromptLock = NSLock() + private static let claudeKeychainFingerprintKey = "ClaudeOAuthClaudeKeychainFingerprintV2" + private static let claudeKeychainFingerprintLegacyKey = "ClaudeOAuthClaudeKeychainFingerprintV1" + private static let claudeKeychainChangeCheckLock = NSLock() + private nonisolated(unsafe) static var lastClaudeKeychainChangeCheckAt: Date? + private static let claudeKeychainChangeCheckMinimumInterval: TimeInterval = 60 + + struct ClaudeKeychainFingerprint: Codable, Equatable, Sendable { + let modifiedAt: Int? + let createdAt: Int? + let persistentRefHash: String? + } #if DEBUG private nonisolated(unsafe) static var keychainAccessOverride: Bool? + private nonisolated(unsafe) static var claudeKeychainDataOverride: Data? + private nonisolated(unsafe) static var claudeKeychainFingerprintOverride: ClaudeKeychainFingerprint? static func setKeychainAccessOverrideForTesting(_ disabled: Bool?) { self.keychainAccessOverride = disabled } + + static func setClaudeKeychainDataOverrideForTesting(_ data: Data?) { + self.claudeKeychainDataOverride = data + } + + static func setClaudeKeychainFingerprintOverrideForTesting(_ fingerprint: ClaudeKeychainFingerprint?) { + self.claudeKeychainFingerprintOverride = fingerprint + } #endif private struct CredentialsFileFingerprint: Codable, Equatable, Sendable { @@ -192,6 +217,10 @@ public enum ClaudeOAuthCredentialsStore { allowKeychainPrompt: Bool = true, respectKeychainPromptCooldown: Bool = false) throws -> ClaudeOAuthCredentials { + // "Silent" keychain probes can still show UI on some macOS configurations. If the caller disallows prompts, + // always honor the Claude keychain access cooldown gate to prevent prompt storms in Auto-mode paths. + let shouldRespectKeychainPromptCooldownForSilentProbes = respectKeychainPromptCooldown || !allowKeychainPrompt + if let credentials = self.loadFromEnvironment(environment) { return credentials } @@ -204,6 +233,12 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration, !cached.isExpired { + if let synced = self.syncWithClaudeKeychainIfChanged( + cached: cached, + respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) + { + return synced + } return cached } @@ -217,6 +252,12 @@ public enum ClaudeOAuthCredentialsStore { if creds.isExpired { expiredCredentials = creds } else { + if let synced = self.syncWithClaudeKeychainIfChanged( + cached: creds, + respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) + { + return synced + } self.writeMemoryCache(credentials: creds, timestamp: Date()) return creds } @@ -536,6 +577,169 @@ public enum ClaudeOAuthCredentialsStore { #endif } + private static func syncWithClaudeKeychainIfChanged( + cached: ClaudeOAuthCredentials, + respectKeychainPromptCooldown: Bool, + now: Date = Date()) -> ClaudeOAuthCredentials? + { + #if os(macOS) + if !self.keychainAccessAllowed { return nil } + if respectKeychainPromptCooldown, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) + { + return nil + } + + if !self.shouldCheckClaudeKeychainChange(now: now) { + return nil + } + + guard let currentFingerprint = self.currentClaudeKeychainFingerprintWithoutPrompt() else { + return nil + } + let storedFingerprint = self.loadClaudeKeychainFingerprint() + guard currentFingerprint != storedFingerprint else { return nil } + + do { + guard let data = try self.loadFromClaudeKeychainNonInteractive() else { + return nil + } + guard let keychainCreds = try? ClaudeOAuthCredentials.parse(data: data) else { + self.saveClaudeKeychainFingerprint(currentFingerprint) + return nil + } + self.saveClaudeKeychainFingerprint(currentFingerprint) + + // Only sync if token actually changed to avoid churn on unrelated keychain metadata updates. + guard keychainCreds.accessToken != cached.accessToken else { return nil } + // Avoid regressing a working cached token if the keychain entry looks invalid/expired. + if keychainCreds.isExpired, !cached.isExpired { return nil } + + self.log.info("Claude keychain credentials changed; syncing OAuth cache") + self.writeMemoryCache(credentials: keychainCreds, timestamp: now) + self.saveToCacheKeychain(data) + return keychainCreds + } catch let error as ClaudeOAuthCredentialsError { + if case let .keychainError(status) = error, + status == Int(errSecUserCanceled) + || status == Int(errSecAuthFailed) + || status == Int(errSecInteractionNotAllowed) + || status == Int(errSecNoAccessForItem) + { + // Back off to avoid repeated keychain probes on systems that still show prompts. + ClaudeOAuthKeychainAccessGate.recordDenied(now: now) + } + return nil + } catch { + return nil + } + #else + _ = cached + _ = respectKeychainPromptCooldown + _ = now + return nil + #endif + } + + private static func shouldCheckClaudeKeychainChange(now: Date = Date()) -> Bool { + self.claudeKeychainChangeCheckLock.lock() + defer { self.claudeKeychainChangeCheckLock.unlock() } + if let last = self.lastClaudeKeychainChangeCheckAt, + now.timeIntervalSince(last) < self.claudeKeychainChangeCheckMinimumInterval + { + return false + } + self.lastClaudeKeychainChangeCheckAt = now + return true + } + + private static func loadClaudeKeychainFingerprint() -> ClaudeKeychainFingerprint? { + // Proactively remove the legacy V1 key (it included the keychain account string, which can be identifying). + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintLegacyKey) + + guard let data = UserDefaults.standard.data(forKey: self.claudeKeychainFingerprintKey) else { + return nil + } + return try? JSONDecoder().decode(ClaudeKeychainFingerprint.self, from: data) + } + + private static func saveClaudeKeychainFingerprint(_ fingerprint: ClaudeKeychainFingerprint?) { + // Proactively remove the legacy V1 key (it included the keychain account string, which can be identifying). + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintLegacyKey) + + guard let fingerprint else { + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintKey) + return + } + if let data = try? JSONEncoder().encode(fingerprint) { + UserDefaults.standard.set(data, forKey: self.claudeKeychainFingerprintKey) + } + } + + private static func currentClaudeKeychainFingerprintWithoutPrompt() -> ClaudeKeychainFingerprint? { + #if DEBUG + if let override = self.claudeKeychainFingerprintOverride { return override } + #endif + #if os(macOS) + let newest: ClaudeKeychainCandidate? = self.claudeKeychainCandidatesWithoutPrompt().first + ?? self.claudeKeychainLegacyCandidateWithoutPrompt() + guard let newest else { return nil } + + let modifiedAt = newest.modifiedAt.map { Int($0.timeIntervalSince1970) } + let createdAt = newest.createdAt.map { Int($0.timeIntervalSince1970) } + let persistentRefHash = Self.sha256Prefix(newest.persistentRef) + return ClaudeKeychainFingerprint( + modifiedAt: modifiedAt, + createdAt: createdAt, + persistentRefHash: persistentRefHash) + #else + return nil + #endif + } + + private static func sha256Prefix(_ data: Data) -> String? { + #if canImport(CryptoKit) + let digest = SHA256.hash(data: data) + let hex = digest.compactMap { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) + #else + _ = data + return nil + #endif + } + + private static func loadFromClaudeKeychainNonInteractive() throws -> Data? { + #if DEBUG + if let override = self.claudeKeychainDataOverride { return override } + #endif + #if os(macOS) + if !self.keychainAccessAllowed { + return nil + } + + // Keep semantics aligned with fingerprinting: if there are multiple entries, we only ever consult the newest + // candidate (same as currentClaudeKeychainFingerprintWithoutPrompt()) to avoid syncing from a different item. + let candidates = self.claudeKeychainCandidatesWithoutPrompt() + if let newest = candidates.first { + if let data = try self.loadClaudeKeychainData(candidate: newest, allowKeychainPrompt: false), + !data.isEmpty + { + return data + } + return nil + } + + if let data = try self.loadClaudeKeychainLegacyData(allowKeychainPrompt: false), + !data.isEmpty + { + return data + } + return nil + #else + return nil + #endif + } + public static func loadFromClaudeKeychain() throws -> Data { #if os(macOS) if !self.keychainAccessAllowed { @@ -633,6 +837,28 @@ public enum ClaudeOAuthCredentialsStore { } } + private static func claudeKeychainLegacyCandidateWithoutPrompt() -> ClaudeKeychainCandidate? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnPersistentRef as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + guard let row = result as? [String: Any] else { return nil } + guard let persistentRef = row[kSecValuePersistentRef as String] as? Data else { return nil } + return ClaudeKeychainCandidate( + persistentRef: persistentRef, + account: row[kSecAttrAccount as String] as? String, + modifiedAt: row[kSecAttrModificationDate as String] as? Date, + createdAt: row[kSecAttrCreationDate as String] as? Date) + } + private static func loadClaudeKeychainData( candidate: ClaudeKeychainCandidate, allowKeychainPrompt: Bool) throws -> Data? @@ -795,6 +1021,22 @@ public enum ClaudeOAuthCredentialsStore { static func _resetCredentialsFileTrackingForTesting() { UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) } + + static func _resetClaudeKeychainChangeTrackingForTesting() { + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintKey) + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintLegacyKey) + self.setClaudeKeychainDataOverrideForTesting(nil) + self.setClaudeKeychainFingerprintOverrideForTesting(nil) + self.claudeKeychainChangeCheckLock.lock() + self.lastClaudeKeychainChangeCheckAt = nil + self.claudeKeychainChangeCheckLock.unlock() + } + + static func _resetClaudeKeychainChangeThrottleForTesting() { + self.claudeKeychainChangeCheckLock.lock() + self.lastClaudeKeychainChangeCheckAt = nil + self.claudeKeychainChangeCheckLock.unlock() + } #endif private static func defaultCredentialsURL() -> URL { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 9853c73e..f2fcee49 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -145,7 +145,8 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { // - Claude Code has stored OAuth creds in Keychain and we may be able to bootstrap (one prompt max). if let creds = try? ClaudeOAuthCredentialsStore.load( environment: context.env, - allowKeychainPrompt: false) + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) { let hasRequiredScope = creds.scopes.contains("user:profile") if hasRequiredScope { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 989edb94..0e7d037a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -230,7 +230,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .auto: let oauthCreds = try? ClaudeOAuthCredentialsStore.load( environment: self.environment, - allowKeychainPrompt: false) + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) let hasOAuthCredentials = oauthCreds?.scopes.contains("user:profile") ?? false let hasWebSession = if let header = self.manualCookieHeader { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 57207d78..b425b84e 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -205,4 +205,234 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == false) } + + @Test + func syncsCacheWhenClaudeKeychainFingerprintChangesAndTokenDiffers() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + + let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1") + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint1) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) + + let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(first.accessToken == "cached-token") + + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + + let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2") + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint2) + + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) + + let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(second.accessToken == "keychain-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "keychain-token") + default: + #expect(Bool(false)) + } + } + + @Test + func doesNotSyncWhenClaudeKeychainFingerprintUnchanged() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + + let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1") + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) + + let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(first.accessToken == "cached-token") + + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) + + let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(second.accessToken == "cached-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + default: + #expect(Bool(false)) + } + } + + @Test + func doesNotSyncWhenKeychainCredentialsExpiredButCacheValid() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( + ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1")) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) + + let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(first.accessToken == "cached-token") + + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( + ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2")) + let expiredKeychainData = self.makeCredentialsData( + accessToken: "expired-keychain-token", + expiresAt: Date(timeIntervalSinceNow: -3600)) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(expiredKeychainData) + + let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(second.accessToken == "cached-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + default: + #expect(Bool(false)) + } + } + + @Test + func respectsPromptCooldownGateWhenDisabledPrompting() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( + ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1")) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) + + let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(first.accessToken == "cached-token") + + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + ClaudeOAuthKeychainAccessGate.recordDenied(now: Date()) + + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( + ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2")) + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) + + let second = try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + #expect(second.accessToken == "cached-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + default: + #expect(Bool(false)) + } + } }