From ca831ffd916fc7d0c7cbe0efe2dec295d513f992 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 4 Feb 2026 23:53:52 +0530 Subject: [PATCH 1/3] Sync Claude cache with keychain changes Detect and sync Claude OAuth credentials when the macOS keychain entry changes. Adds a ClaudeKeychainFingerprint struct, non-interactive keychain fingerprinting and data loaders, SHA-256 prefix hashing, and throttling to avoid frequent prompts. Integrates a syncWithClaudeKeychainIfChanged path into the existing load flow so cached credentials are updated only when the keychain token actually changes (and avoids regressing to expired tokens). Provides debug/testing overrides and reset helpers, and adds unit tests covering fingerprint-change-driven sync and no-op when the fingerprint is unchanged. --- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 205 ++++++++++++++++++ .../ClaudeOAuthCredentialsStoreTests.swift | 106 +++++++++ 2 files changed, 311 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index eb72fa42..8ee336f3 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 = "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 account: String? + 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 { @@ -204,6 +229,12 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration, !cached.isExpired { + if let synced = self.syncWithClaudeKeychainIfChanged( + cached: cached, + respectKeychainPromptCooldown: respectKeychainPromptCooldown) + { + return synced + } return cached } @@ -217,6 +248,12 @@ public enum ClaudeOAuthCredentialsStore { if creds.isExpired { expiredCredentials = creds } else { + if let synced = self.syncWithClaudeKeychainIfChanged( + cached: creds, + respectKeychainPromptCooldown: respectKeychainPromptCooldown) + { + return synced + } self.writeMemoryCache(credentials: creds, timestamp: Date()) return creds } @@ -536,6 +573,159 @@ 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? { + 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?) { + 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 candidates = self.claudeKeychainCandidatesWithoutPrompt() + guard let newest = candidates.first 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( + account: newest.account, + 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 + } + + for candidate in self.claudeKeychainCandidatesWithoutPrompt() { + if let data = try self.loadClaudeKeychainData(candidate: candidate, allowKeychainPrompt: false), + !data.isEmpty + { + return data + } + } + + 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 { @@ -795,6 +985,21 @@ public enum ClaudeOAuthCredentialsStore { static func _resetCredentialsFileTrackingForTesting() { UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) } + + static func _resetClaudeKeychainChangeTrackingForTesting() { + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintKey) + 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/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 57207d78..2b654166 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -205,4 +205,110 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == false) } + + @Test + func syncsCacheWhenClaudeKeychainFingerprintChangesAndTokenDiffers() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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( + account: "a", + 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( + account: "b", + 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(false) + } + } + + @Test + func doesNotSyncWhenClaudeKeychainFingerprintUnchanged() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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( + account: "a", + 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(false) + } + } } From b1fa6ca8b8c0e434d71c57d1497b6d23e5abe76b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 5 Feb 2026 03:04:04 +0530 Subject: [PATCH 2/3] Reset credentials file tracking in Claude tests --- .../ClaudeOAuthCredentialsStoreTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 2b654166..d14fdcce 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -211,6 +211,9 @@ struct ClaudeOAuthCredentialsStoreTests { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() defer { @@ -259,7 +262,7 @@ struct ClaudeOAuthCredentialsStoreTests { let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) #expect(parsed.accessToken == "keychain-token") default: - #expect(false) + #expect(Bool(false)) } } @@ -268,6 +271,9 @@ struct ClaudeOAuthCredentialsStoreTests { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() defer { @@ -308,7 +314,7 @@ struct ClaudeOAuthCredentialsStoreTests { let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) #expect(parsed.accessToken == "cached-token") default: - #expect(false) + #expect(Bool(false)) } } } From 181a88a588fd21a5aa3cf7b87cd5f7f790426421 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 5 Feb 2026 03:33:54 +0530 Subject: [PATCH 3/3] Claude OAuth: keychain fingerprint & cooldown Improve Claude OAuth keychain handling and avoid unwanted keychain prompts. Renamed the stored fingerprint key to V2 and proactively remove the legacy V1 entry (which contained the account string) to reduce identifying data. Removed the account field from the fingerprint struct and added logic to read a legacy keychain candidate when needed. Load logic now consults only the newest candidate (matching fingerprint semantics) and preserves silent-probe behavior by honoring a new respectKeychainPromptCooldown gate when callers request no prompts. Updated callers (UsageStore, Provider descriptor, UsageFetcher) to pass the cooldown flag for silent loads. Added tests to cover expired keychain vs cached credentials and to ensure the prompt-cooldown gate is respected. Also added helpers and test cleanup for legacy fingerprint handling. --- Sources/CodexBar/UsageStore.swift | 4 +- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 55 ++++++-- .../Claude/ClaudeProviderDescriptor.swift | 3 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 3 +- .../ClaudeOAuthCredentialsStoreTests.swift | 124 +++++++++++++++++- 5 files changed, 174 insertions(+), 15 deletions(-) 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 8ee336f3..d3bb3f52 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -153,13 +153,13 @@ 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 = "ClaudeOAuthClaudeKeychainFingerprintV1" + 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 account: String? let modifiedAt: Int? let createdAt: Int? let persistentRefHash: String? @@ -217,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 } @@ -231,7 +235,7 @@ public enum ClaudeOAuthCredentialsStore { { if let synced = self.syncWithClaudeKeychainIfChanged( cached: cached, - respectKeychainPromptCooldown: respectKeychainPromptCooldown) + respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) { return synced } @@ -250,7 +254,7 @@ public enum ClaudeOAuthCredentialsStore { } else { if let synced = self.syncWithClaudeKeychainIfChanged( cached: creds, - respectKeychainPromptCooldown: respectKeychainPromptCooldown) + respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) { return synced } @@ -650,6 +654,9 @@ public enum ClaudeOAuthCredentialsStore { } 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 } @@ -657,6 +664,9 @@ public enum ClaudeOAuthCredentialsStore { } 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 @@ -671,14 +681,14 @@ public enum ClaudeOAuthCredentialsStore { if let override = self.claudeKeychainFingerprintOverride { return override } #endif #if os(macOS) - let candidates = self.claudeKeychainCandidatesWithoutPrompt() - guard let newest = candidates.first else { return nil } + 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( - account: newest.account, modifiedAt: modifiedAt, createdAt: createdAt, persistentRefHash: persistentRefHash) @@ -707,12 +717,16 @@ public enum ClaudeOAuthCredentialsStore { return nil } - for candidate in self.claudeKeychainCandidatesWithoutPrompt() { - if let data = try self.loadClaudeKeychainData(candidate: candidate, allowKeychainPrompt: false), + // 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), @@ -823,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? @@ -988,6 +1024,7 @@ public enum ClaudeOAuthCredentialsStore { static func _resetClaudeKeychainChangeTrackingForTesting() { UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintKey) + UserDefaults.standard.removeObject(forKey: self.claudeKeychainFingerprintLegacyKey) self.setClaudeKeychainDataOverrideForTesting(nil) self.setClaudeKeychainFingerprintOverrideForTesting(nil) self.claudeKeychainChangeCheckLock.lock() 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 d14fdcce..b425b84e 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -230,7 +230,6 @@ struct ClaudeOAuthCredentialsStoreTests { entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - account: "a", modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1") @@ -243,7 +242,6 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - account: "b", modifiedAt: 2, createdAt: 2, persistentRefHash: "ref2") @@ -290,7 +288,6 @@ struct ClaudeOAuthCredentialsStoreTests { entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - account: "a", modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1") @@ -317,4 +314,125 @@ struct ClaudeOAuthCredentialsStoreTests { #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)) + } + } }