Files
2026-07-13 12:22:33 +08:00

2564 lines
110 KiB
Swift

import Dispatch
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if os(macOS)
import LocalAuthentication
import Security
#endif
// swiftlint:disable type_body_length file_length
public enum ClaudeOAuthCredentialsStore {
private static let credentialsPath = ".claude/.credentials.json"
static let claudeKeychainService = "Claude Code-credentials"
private static let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
public static let environmentTokenKey = "CODEXBAR_CLAUDE_OAUTH_TOKEN"
public static let environmentScopesKey = "CODEXBAR_CLAUDE_OAUTH_SCOPES"
// Claude CLI's OAuth client ID - this is a public identifier (not a secret).
// It's the same client ID used by Claude Code CLI for OAuth PKCE flow.
// Can be overridden via environment variable if Anthropic ever changes it.
public static let defaultOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
public static let environmentClientIDKey = "CODEXBAR_CLAUDE_OAUTH_CLIENT_ID"
private static let tokenRefreshEndpoint = "https://platform.claude.com/v1/oauth/token"
private static var oauthClientID: String {
ProcessInfo.processInfo.environment[self.environmentClientIDKey]?
.trimmingCharacters(in: .whitespacesAndNewlines)
?? self.defaultOAuthClientID
}
static let log = CodexBarLog.logger(LogCategories.claudeUsage)
private static let fileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV2"
private static let claudeKeychainPromptLock = NSLock()
private static let claudeKeychainFingerprintKey = "ClaudeOAuthClaudeKeychainFingerprintV2"
private static let claudeKeychainFingerprintLegacyKey = "ClaudeOAuthClaudeKeychainFingerprintV1"
private static let pendingCodexBarOAuthKeychainCacheClearKey =
"ClaudeOAuthPendingCodexBarOAuthKeychainCacheClearV1"
private static let pendingCodexBarOAuthKeychainCacheClearStore: ClaudeOAuthPendingCacheClearStore =
ClaudeOAuthPendingCacheClearUserDefaultsStore(
// The cache service is shared by release/debug apps and their CLIs, so its tombstone is shared too.
domain: "com.steipete.codexbar",
key: ClaudeOAuthCredentialsStore.pendingCodexBarOAuthKeychainCacheClearKey)
private static let claudeKeychainChangeCheckLock = NSLock()
private nonisolated(unsafe) static var lastClaudeKeychainChangeCheckAt: Date?
private static let claudeKeychainChangeCheckMinimumInterval: TimeInterval = 60
private static let reauthenticateHint = "Run `claude` to re-authenticate."
struct ClaudeKeychainFingerprint: Codable, Equatable {
let modifiedAt: Int?
let createdAt: Int?
let persistentRefHash: String?
}
private struct ClaudeKeychainCredentialEvidence {
let credentials: ClaudeOAuthCredentials
let persistentRefHash: String
}
struct CredentialsFileFingerprint: Codable, Equatable {
let modifiedAtMs: Int?
let size: Int
}
struct CacheEntry: Codable {
let data: Data
let storedAt: Date
let owner: ClaudeOAuthCredentialOwner?
let historyOwnerIdentifier: String?
init(
data: Data,
storedAt: Date,
owner: ClaudeOAuthCredentialOwner? = nil,
historyOwnerIdentifier: String? = nil)
{
self.data = data
self.storedAt = storedAt
self.owner = owner
self.historyOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier(
historyOwnerIdentifier)
}
}
private nonisolated(unsafe) static var credentialsURLOverride: URL?
#if DEBUG
@TaskLocal private static var taskCredentialsURLOverride: URL?
#endif
@TaskLocal static var allowBackgroundPromptBootstrap: Bool = false
// In-memory cache (nonisolated for synchronous access)
private static let memoryCacheLock = NSLock()
private nonisolated(unsafe) static var cachedCredentialRecord: ClaudeOAuthCredentialRecord?
private nonisolated(unsafe) static var cacheTimestamp: Date?
private static let memoryCacheValidityDuration: TimeInterval = 1800
private static func readMemoryCache() -> (record: ClaudeOAuthCredentialRecord?, timestamp: Date?) {
#if DEBUG
if let store = self.taskMemoryCacheStoreOverride {
return (store.record, store.timestamp)
}
#endif
self.memoryCacheLock.lock()
defer { self.memoryCacheLock.unlock() }
return (self.cachedCredentialRecord, self.cacheTimestamp)
}
private static func writeMemoryCache(record: ClaudeOAuthCredentialRecord?, timestamp: Date?) {
#if DEBUG
if let store = self.taskMemoryCacheStoreOverride {
store.record = record
store.timestamp = timestamp
return
}
#endif
self.memoryCacheLock.lock()
self.cachedCredentialRecord = record
self.cacheTimestamp = timestamp
self.memoryCacheLock.unlock()
}
private struct CollaboratorContext {
let allowBackgroundPromptBootstrap: Bool
#if DEBUG
let credentialsURLOverride: URL?
let testingOverrides: TestingOverridesSnapshot
#endif
func run<T>(_ operation: () throws -> T) rethrows -> T {
try ClaudeOAuthCredentialsStore.$allowBackgroundPromptBootstrap
.withValue(self.allowBackgroundPromptBootstrap) {
#if DEBUG
try ClaudeOAuthCredentialsStore.withTestingOverridesSnapshotForTask(self.testingOverrides) {
try ClaudeOAuthCredentialsStore
.withCredentialsURLOverrideForTesting(self.credentialsURLOverride) {
try operation()
}
}
#else
try operation()
#endif
}
}
func run<T>(_ operation: () async throws -> T) async rethrows -> T {
try await ClaudeOAuthCredentialsStore.$allowBackgroundPromptBootstrap
.withValue(self.allowBackgroundPromptBootstrap) {
#if DEBUG
try await ClaudeOAuthCredentialsStore.withTestingOverridesSnapshotForTask(self.testingOverrides) {
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(
self.credentialsURLOverride)
{
try await operation()
}
}
#else
try await operation()
#endif
}
}
}
private static func currentCollaboratorContext() -> CollaboratorContext {
#if DEBUG
CollaboratorContext(
allowBackgroundPromptBootstrap: self.allowBackgroundPromptBootstrap,
credentialsURLOverride: self.taskCredentialsURLOverride,
testingOverrides: self.currentTestingOverridesSnapshotForTask)
#else
CollaboratorContext(
allowBackgroundPromptBootstrap: self.allowBackgroundPromptBootstrap)
#endif
}
private struct Repository {
let context: CollaboratorContext
func load(environment: [String: String], allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool) throws
-> ClaudeOAuthCredentials
{
try self.loadRecord(
environment: environment,
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown,
allowClaudeKeychainRepairWithoutPrompt: true).credentials
}
func loadRecord(
environment: [String: String],
allowKeychainPrompt: Bool,
respectKeychainPromptCooldown: Bool,
allowClaudeKeychainRepairWithoutPrompt: Bool) throws -> ClaudeOAuthCredentialRecord
{
try self.context.run {
let shouldRespectKeychainPromptCooldownForSilentProbes =
respectKeychainPromptCooldown || !allowKeychainPrompt
if let credentials = ClaudeOAuthCredentialsStore.loadFromEnvironment(environment) {
return ClaudeOAuthCredentialRecord(
credentials: credentials,
owner: .environment,
source: .environment)
}
_ = self.invalidateCacheIfCredentialsFileChanged()
let recovery = Recovery(context: self.context)
let memory = ClaudeOAuthCredentialsStore.readMemoryCache()
if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache,
!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear,
let cachedRecord = memory.record,
let timestamp = memory.timestamp,
Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration,
!cachedRecord.credentials.isExpired
{
let owner = self.resolvedCacheOwner(cachedRecord.owner)
let record = ClaudeOAuthCredentialRecord(
credentials: cachedRecord.credentials,
owner: owner,
source: .memoryCache,
historyOwnerIdentifier: cachedRecord.historyOwnerIdentifier)
if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: record),
let synced = recovery.syncWithClaudeKeychainIfChanged(
cached: record,
respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes)
{
return synced
}
return record
}
var lastError: Error?
var expiredRecord: ClaudeOAuthCredentialRecord?
var cacheTemporarilyUnavailable = false
switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache() {
case let .found(entry):
if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) {
let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI)
let record = ClaudeOAuthCredentialRecord(
credentials: creds,
owner: owner,
source: .cacheKeychain,
historyOwnerIdentifier: entry.historyOwnerIdentifier)
if creds.isExpired {
expiredRecord = record
} else {
if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: record),
let synced = recovery.syncWithClaudeKeychainIfChanged(
cached: record,
respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes)
{
return synced
}
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: owner,
source: .memoryCache,
historyOwnerIdentifier: record.historyOwnerIdentifier),
timestamp: Date())
return record
}
} else {
ClaudeOAuthCredentialsStore.clearCacheKeychain()
}
case .invalid:
ClaudeOAuthCredentialsStore.clearCacheKeychain()
case .temporarilyUnavailable:
cacheTemporarilyUnavailable = true
case .missing:
break
}
do {
let fileData = try ClaudeOAuthCredentialsStore.loadFromFile()
let creds = try ClaudeOAuthCredentials.parse(data: fileData)
let record = ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .credentialsFile)
if creds.isExpired {
expiredRecord = record
} else {
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: Date())
if !cacheTemporarilyUnavailable {
ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI)
}
return record
}
} catch let error as ClaudeOAuthCredentialsError {
if case .notFound = error {
} else {
lastError = error
}
} catch {
lastError = error
}
if allowClaudeKeychainRepairWithoutPrompt, !allowKeychainPrompt {
if let repaired = recovery.repairFromClaudeKeychainWithoutPromptIfAllowed(
now: Date(),
respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes,
allowCacheKeychainWrite: !cacheTemporarilyUnavailable)
{
return repaired
}
}
if let prompted = self.loadFromClaudeKeychainWithPromptIfAllowed(
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown,
allowCacheKeychainWrite: !cacheTemporarilyUnavailable,
lastError: &lastError)
{
return prompted
}
if let expiredRecord {
return expiredRecord
}
if let lastError {
throw lastError
}
throw ClaudeOAuthCredentialsError.notFound
}
}
private func loadFromClaudeKeychainWithPromptIfAllowed(
allowKeychainPrompt: Bool,
respectKeychainPromptCooldown: Bool,
allowCacheKeychainWrite: Bool,
lastError: inout Error?) -> ClaudeOAuthCredentialRecord?
{
let shouldApplyPromptCooldown =
ClaudeOAuthCredentialsStore.isPromptPolicyApplicable && respectKeychainPromptCooldown
let promptAllowed =
allowKeychainPrompt
&& (!shouldApplyPromptCooldown || ClaudeOAuthKeychainAccessGate.shouldAllowPrompt())
guard promptAllowed else { return nil }
do {
ClaudeOAuthCredentialsStore.claudeKeychainPromptLock.lock()
defer { ClaudeOAuthCredentialsStore.claudeKeychainPromptLock.unlock() }
let memory = ClaudeOAuthCredentialsStore.readMemoryCache()
if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache,
!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear,
let cachedRecord = memory.record,
let timestamp = memory.timestamp,
Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration,
!cachedRecord.credentials.isExpired
{
let owner = self.resolvedCacheOwner(cachedRecord.owner)
return ClaudeOAuthCredentialRecord(
credentials: cachedRecord.credentials,
owner: owner,
source: .memoryCache,
historyOwnerIdentifier: cachedRecord.historyOwnerIdentifier)
}
if case let .found(entry) = ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache(),
let creds = try? ClaudeOAuthCredentials.parse(data: entry.data),
!creds.isExpired
{
let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI)
return ClaudeOAuthCredentialRecord(
credentials: creds,
owner: owner,
source: .cacheKeychain,
historyOwnerIdentifier: entry.historyOwnerIdentifier)
}
let promptMode = ClaudeOAuthKeychainPromptPreference.current()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else {
return nil
}
if ClaudeOAuthCredentialsStore.shouldPreferSecurityCLIKeychainRead(),
let keychainData = ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current)
{
let creds = try ClaudeOAuthCredentials.parse(data: keychainData)
let record = ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .claudeKeychain)
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: Date())
if allowCacheKeychainWrite {
ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI)
}
return record
}
let shouldPreferSecurityCLIKeychainRead =
ClaudeOAuthCredentialsStore.shouldPreferSecurityCLIKeychainRead()
var fallbackPromptMode = promptMode
if shouldPreferSecurityCLIKeychainRead {
fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode()
let fallbackDecision = ClaudeOAuthCredentialsStore.securityFrameworkFallbackPromptDecision(
promptMode: fallbackPromptMode,
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown)
ClaudeOAuthCredentialsStore.log.debug(
"Claude keychain Security.framework fallback prompt policy evaluated",
metadata: [
"reader": "securityFrameworkFallback",
"fallbackPromptMode": fallbackPromptMode.rawValue,
"fallbackPromptAllowed": "\(fallbackDecision.allowed)",
"fallbackBlockedReason": fallbackDecision.blockedReason ?? "none",
])
guard fallbackDecision.allowed else { return nil }
}
if ClaudeOAuthCredentialsStore.shouldNotifyClaudeKeychainPreAlert() {
ClaudeOAuthKeychainPreAlertGate.presentIfNeeded {
KeychainPromptHandler.notifyIfHandled(
KeychainPromptContext(
kind: .claudeOAuth,
service: ClaudeOAuthCredentialsStore.claudeKeychainService,
account: nil))
}
}
let keychainData: Data = if shouldPreferSecurityCLIKeychainRead {
try ClaudeOAuthCredentialsStore.loadFromClaudeKeychainUsingSecurityFramework(
promptMode: fallbackPromptMode,
allowKeychainPrompt: true)
} else {
try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
}
let creds = try ClaudeOAuthCredentials.parse(data: keychainData)
let record = ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .claudeKeychain)
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: Date())
if allowCacheKeychainWrite {
ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI)
}
return record
} catch let error as ClaudeOAuthCredentialsError {
if case .notFound = error {
} else {
lastError = error
}
} catch {
lastError = error
}
return nil
}
private func resolvedCacheOwner(_ owner: ClaudeOAuthCredentialOwner) -> ClaudeOAuthCredentialOwner {
guard owner == .codexbar else { return owner }
guard self.hasClaudeCLIStorageWithoutPrompt() else { return owner }
// Claude Code rotates refresh tokens; when its storage exists, it owns the refresh lifecycle.
return .claudeCLI
}
private func hasClaudeCLIStorageWithoutPrompt() -> Bool {
if ClaudeOAuthCredentialsStore.currentFileFingerprint() != nil {
return true
}
guard ClaudeOAuthKeychainPromptPreference.storedMode() != .never else { return false }
return ClaudeOAuthCredentialsStore.hasClaudeKeychainItemWithoutPrompt()
}
@discardableResult
func invalidateCacheIfCredentialsFileChanged() -> Bool {
self.context.run {
let current = ClaudeOAuthCredentialsStore.currentFileFingerprint()
let stored = ClaudeOAuthCredentialsStore.loadFileFingerprint()
guard current != stored else { return false }
ClaudeOAuthCredentialsStore.log.info("Claude OAuth credentials file changed; invalidating cache")
ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil)
var shouldClearKeychainCache = false
var shouldSaveFileFingerprint = true
if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache {
if ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear {
// The next cache access owns the deferred clear. Avoid repeated delete attempts inside one
// load and leave the fingerprint pending until that clear succeeds.
shouldSaveFileFingerprint = false
} else if let current {
if let modifiedAtMs = current.modifiedAtMs {
let modifiedAt = Date(
timeIntervalSince1970: TimeInterval(Double(modifiedAtMs) / 1000.0))
switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache() {
case let .found(entry):
if entry.storedAt < modifiedAt {
shouldClearKeychainCache = true
}
case .missing, .invalid:
shouldClearKeychainCache = true
case .temporarilyUnavailable:
shouldClearKeychainCache = false
shouldSaveFileFingerprint = false
}
} else {
shouldClearKeychainCache = true
}
} else {
shouldClearKeychainCache = true
}
} else {
ClaudeOAuthCredentialsStore.markPendingCodexBarOAuthKeychainCacheClear()
}
if shouldClearKeychainCache {
ClaudeOAuthCredentialsStore.clearCacheKeychain()
}
if shouldSaveFileFingerprint {
ClaudeOAuthCredentialsStore.saveFileFingerprint(current)
}
return true
}
}
func invalidateCache() {
self.context.run {
ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil)
ClaudeOAuthCredentialsStore.clearCacheKeychain()
}
}
func hasCachedCredentials(environment: [String: String]) -> Bool {
self.context.run {
func isRefreshableOrValid(_ record: ClaudeOAuthCredentialRecord) -> Bool {
let creds = record.credentials
if !creds.isExpired {
return true
}
switch record.owner {
case .claudeCLI:
return true
case .codexbar:
let refreshToken = creds.refreshToken?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
return !refreshToken.isEmpty
case .environment:
return false
}
}
if let creds = ClaudeOAuthCredentialsStore.loadFromEnvironment(environment),
isRefreshableOrValid(
ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .environment,
source: .environment))
{
return true
}
let memory = ClaudeOAuthCredentialsStore.readMemoryCache()
if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache,
!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear,
let timestamp = memory.timestamp,
let cached = memory.record,
Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration,
isRefreshableOrValid(cached)
{
return true
}
switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache() {
case let .found(entry):
guard let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) else { return false }
let record = ClaudeOAuthCredentialRecord(
credentials: creds,
owner: entry.owner ?? .claudeCLI,
source: .cacheKeychain)
return isRefreshableOrValid(record)
case .temporarilyUnavailable:
if ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear {
break
}
return true
case .missing, .invalid:
break
}
if let fileData = try? ClaudeOAuthCredentialsStore.loadFromFile(),
let creds = try? ClaudeOAuthCredentials.parse(data: fileData),
isRefreshableOrValid(
ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .credentialsFile))
{
return true
}
return false
}
}
func hasClaudeKeychainCredentialsWithoutPrompt() -> Bool {
self.context.run {
#if os(macOS)
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false }
if ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current) != nil
{
return true
}
let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else {
return false
}
if ProviderInteractionContext.current == .background,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()
{
return false
}
#if DEBUG
if let store = ClaudeOAuthCredentialsStore.taskClaudeKeychainOverrideStore,
let data = store.data
{
return (try? ClaudeOAuthCredentials.parse(data: data)) != nil
}
if let data = ClaudeOAuthCredentialsStore.taskClaudeKeychainDataOverride
?? ClaudeOAuthCredentialsStore.claudeKeychainDataOverride
{
return (try? ClaudeOAuthCredentials.parse(data: data)) != nil
}
#endif
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ClaudeOAuthCredentialsStore.claudeKeychainService,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
]
KeychainNoUIQuery.apply(to: &query)
let (status, _, durationMs) = ClaudeOAuthKeychainQueryTiming.copyMatching(query)
if ClaudeOAuthKeychainQueryTiming
.backoffIfSlowNoUIQuery(
durationMs,
ClaudeOAuthCredentialsStore.claudeKeychainService,
ClaudeOAuthCredentialsStore.log)
{
return false
}
switch status {
case errSecSuccess, errSecInteractionNotAllowed:
return true
case errSecUserCanceled, errSecAuthFailed, errSecNoAccessForItem:
ClaudeOAuthKeychainAccessGate.recordDenied()
return false
default:
return false
}
#else
return false
#endif
}
}
}
private struct Recovery {
let context: CollaboratorContext
func shouldAttemptFreshnessSyncFromClaudeKeychain(cached: ClaudeOAuthCredentialRecord) -> Bool {
guard !cached.credentials.isExpired else { return false }
guard cached.owner == .claudeCLI else { return false }
guard ClaudeOAuthCredentialsStore.keychainAccessAllowed else { return false }
let mode = ClaudeOAuthKeychainPromptPreference.storedMode()
switch mode {
case .never:
return false
case .onlyOnUserAction:
if ProviderInteractionContext.current != .userInitiated {
if ProcessInfo.processInfo.environment["CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW"] == "1" {
ClaudeOAuthCredentialsStore.log.debug(
"Claude OAuth keychain freshness sync skipped (background)",
metadata: ["promptMode": mode.rawValue, "owner": String(describing: cached.owner)])
}
return false
}
return true
case .always:
return true
}
}
func syncWithClaudeKeychainIfChanged(
cached: ClaudeOAuthCredentialRecord,
respectKeychainPromptCooldown: Bool,
now: Date = Date()) -> ClaudeOAuthCredentialRecord?
{
#if os(macOS)
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil }
if ClaudeOAuthCredentialsStore.isPromptPolicyApplicable,
respectKeychainPromptCooldown,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)
{
return nil
}
if ClaudeOAuthCredentialsStore.shouldShowClaudeKeychainPreAlert() {
return nil
}
if !ClaudeOAuthCredentialsStore.shouldCheckClaudeKeychainChange(now: now) {
return nil
}
guard let currentFingerprint = ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPrompt()
else {
return nil
}
let storedFingerprint = ClaudeOAuthCredentialsStore.loadClaudeKeychainFingerprint()
guard currentFingerprint != storedFingerprint else { return nil }
do {
guard let data = try ClaudeOAuthCredentialsStore.loadFromClaudeKeychainNonInteractive() else {
return nil
}
guard let keychainCreds = try? ClaudeOAuthCredentials.parse(data: data) else {
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(currentFingerprint)
return nil
}
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(currentFingerprint)
guard keychainCreds.accessToken != cached.credentials.accessToken else { return nil }
if keychainCreds.isExpired, !cached.credentials.isExpired {
return nil
}
ClaudeOAuthCredentialsStore.log.info("Claude keychain credentials changed; syncing OAuth cache")
let synced = ClaudeOAuthCredentialRecord(
credentials: keychainCreds,
owner: .claudeCLI,
source: .claudeKeychain)
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: keychainCreds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI)
return synced
} catch let error as ClaudeOAuthCredentialsError {
if case let .keychainError(status) = error,
status == Int(errSecUserCanceled)
|| status == Int(errSecAuthFailed)
|| status == Int(errSecInteractionNotAllowed)
|| status == Int(errSecNoAccessForItem)
{
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
}
return nil
} catch {
return nil
}
#else
_ = cached
_ = respectKeychainPromptCooldown
_ = now
return nil
#endif
}
func repairFromClaudeKeychainWithoutPromptIfAllowed(
now: Date,
respectKeychainPromptCooldown: Bool,
allowCacheKeychainWrite: Bool = true) -> ClaudeOAuthCredentialRecord?
{
#if os(macOS)
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil }
if ClaudeOAuthCredentialsStore.shouldShowClaudeKeychainPreAlert() {
return nil
}
if ClaudeOAuthCredentialsStore.isPromptPolicyApplicable,
respectKeychainPromptCooldown,
ProviderInteractionContext.current != .userInitiated,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)
{
return nil
}
do {
if ClaudeOAuthCredentialsStore.shouldPreferSecurityCLIKeychainRead(),
let securityData = ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current),
!securityData.isEmpty
{
guard let creds = try? ClaudeOAuthCredentials.parse(data: securityData) else { return nil }
if creds.isExpired {
return ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .claudeKeychain)
}
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
if allowCacheKeychainWrite {
ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI)
}
ClaudeOAuthCredentialsStore.log.info(
"Claude keychain credentials loaded without prompt; syncing OAuth cache",
metadata: ["interaction": ProviderInteractionContext.current == .userInitiated
? "user" : "background"])
return ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .claudeKeychain)
}
guard let data = try ClaudeOAuthCredentialsStore.loadFromClaudeKeychainNonInteractive(),
!data.isEmpty
else {
return nil
}
let fingerprint = ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPrompt()
guard let creds = try? ClaudeOAuthCredentials.parse(data: data) else {
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(fingerprint)
return nil
}
if creds.isExpired {
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(fingerprint)
return ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .claudeKeychain)
}
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(fingerprint)
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
if allowCacheKeychainWrite {
ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI)
}
ClaudeOAuthCredentialsStore.log.info(
"Claude keychain credentials loaded without prompt; syncing OAuth cache",
metadata: ["interaction": ProviderInteractionContext.current == .userInitiated
? "user" : "background"])
return ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .claudeKeychain)
} catch let error as ClaudeOAuthCredentialsError {
if case let .keychainError(status) = error,
status == Int(errSecUserCanceled)
|| status == Int(errSecAuthFailed)
|| status == Int(errSecInteractionNotAllowed)
|| status == Int(errSecNoAccessForItem)
{
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
}
return nil
} catch {
return nil
}
#else
_ = now
_ = respectKeychainPromptCooldown
return nil
#endif
}
@discardableResult
func syncFromClaudeKeychainWithoutPrompt(now: Date = Date()) -> Bool {
self.context.run {
#if os(macOS)
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false }
if let data = ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current),
!data.isEmpty
{
if let creds = try? ClaudeOAuthCredentials.parse(data: data), !creds.isExpired {
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI)
return true
}
}
let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else {
return false
}
if ProviderInteractionContext.current == .background,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)
{
return false
}
#if DEBUG
let override = ClaudeOAuthCredentialsStore.taskClaudeKeychainOverrideStore?.data
?? ClaudeOAuthCredentialsStore.taskClaudeKeychainDataOverride
?? ClaudeOAuthCredentialsStore.claudeKeychainDataOverride
if let override,
!override.isEmpty,
let creds = try? ClaudeOAuthCredentials.parse(data: override),
!creds.isExpired
{
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(
ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPrompt())
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
ClaudeOAuthCredentialsStore.saveToCacheKeychain(override, owner: .claudeCLI)
return true
}
#endif
if ClaudeOAuthCredentialsStore.shouldShowClaudeKeychainPreAlert() {
return false
}
if let candidate = ClaudeOAuthCredentialsStore.claudeKeychainCandidatesWithoutPrompt(
promptMode: fallbackPromptMode).first,
let data = try? ClaudeOAuthCredentialsStore.loadClaudeKeychainData(
candidate: candidate,
allowKeychainPrompt: false),
!data.isEmpty
{
let fingerprint = ClaudeKeychainFingerprint(
modifiedAt: candidate.modifiedAt.map { Int($0.timeIntervalSince1970) },
createdAt: candidate.createdAt.map { Int($0.timeIntervalSince1970) },
persistentRefHash: ClaudeOAuthCredentialsStore.sha256Prefix(candidate.persistentRef))
if let creds = try? ClaudeOAuthCredentials.parse(data: data), !creds.isExpired {
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(fingerprint)
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI)
return true
}
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(fingerprint)
}
let legacyData = try? ClaudeOAuthCredentialsStore.loadClaudeKeychainLegacyData(
allowKeychainPrompt: false,
promptMode: fallbackPromptMode)
if let legacyData,
!legacyData.isEmpty,
let creds = try? ClaudeOAuthCredentials.parse(data: legacyData),
!creds.isExpired
{
ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(
ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPrompt())
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: creds,
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
ClaudeOAuthCredentialsStore.saveToCacheKeychain(legacyData, owner: .claudeCLI)
return true
}
return false
#else
_ = now
return false
#endif
}
}
}
private struct Refresher {
let context: CollaboratorContext
func refreshAccessToken(
refreshToken: String,
existingScopes: [String],
existingRateLimitTier: String?,
existingSubscriptionType: String? = nil,
historyOwnerIdentifier: String?) async throws -> ClaudeOAuthCredentials
{
try await self.context.run {
let newCredentials = try await self.refreshAccessTokenCore(
refreshToken: refreshToken,
existingScopes: existingScopes,
existingRateLimitTier: existingRateLimitTier,
existingSubscriptionType: existingSubscriptionType)
ClaudeOAuthCredentialsStore.saveRefreshedCredentialsToCache(
newCredentials,
historyOwnerIdentifier: historyOwnerIdentifier)
ClaudeOAuthCredentialsStore.writeMemoryCache(
record: ClaudeOAuthCredentialRecord(
credentials: newCredentials,
owner: .codexbar,
source: .memoryCache,
historyOwnerIdentifier: historyOwnerIdentifier),
timestamp: Date())
ClaudeOAuthRefreshFailureGate.recordSuccess()
return newCredentials
}
}
private func refreshAccessTokenCore(
refreshToken: String,
existingScopes: [String],
existingRateLimitTier: String?,
existingSubscriptionType: String?) async throws -> ClaudeOAuthCredentials
{
guard ClaudeOAuthRefreshFailureGate.shouldAttempt() else {
let status = ClaudeOAuthRefreshFailureGate.currentBlockStatus()
let message = switch status {
case .terminal:
"Claude OAuth refresh blocked until auth changes. \(ClaudeOAuthCredentialsStore.reauthenticateHint)"
case .transient:
"Claude OAuth refresh temporarily backed off due to prior failures; will retry automatically."
case nil:
"Claude OAuth refresh temporarily suppressed due to prior failures; will retry automatically."
}
throw ClaudeOAuthCredentialsError.refreshFailed(message)
}
guard let url = URL(string: ClaudeOAuthCredentialsStore.tokenRefreshEndpoint) else {
throw ClaudeOAuthCredentialsError.refreshFailed("Invalid token endpoint URL")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: ClaudeOAuthCredentialsStore.oauthClientID),
]
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
let response = try await ProviderHTTPClient.shared.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
if let disposition = ClaudeOAuthCredentialsStore.refreshFailureDisposition(
statusCode: response.statusCode,
data: data)
{
let oauthError = ClaudeOAuthCredentialsStore.extractOAuthErrorCode(from: data)
ClaudeOAuthCredentialsStore.log.info(
"Claude OAuth refresh rejected",
metadata: [
"httpStatus": "\(response.statusCode)",
"oauthError": oauthError ?? "nil",
"disposition": disposition.rawValue,
])
switch disposition {
case .terminalInvalidGrant:
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure()
Repository(context: self.context).invalidateCache()
let message = "HTTP \(response.statusCode) invalid_grant. " +
ClaudeOAuthCredentialsStore.reauthenticateHint
throw ClaudeOAuthCredentialsError.refreshFailed(
message)
case .transientBackoff:
ClaudeOAuthRefreshFailureGate.recordTransientFailure()
let suffix = oauthError.map { " (\($0))" } ?? ""
throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(response.statusCode)\(suffix)")
}
}
throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(response.statusCode)")
}
let tokenResponse = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
let expiresAt = Date(timeIntervalSinceNow: TimeInterval(tokenResponse.expiresIn))
return ClaudeOAuthCredentials(
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken ?? refreshToken,
expiresAt: expiresAt,
scopes: existingScopes,
rateLimitTier: existingRateLimitTier,
subscriptionType: existingSubscriptionType)
}
}
public static func load(
environment: [String: String] = ProcessInfo.processInfo.environment,
allowKeychainPrompt: Bool = true,
respectKeychainPromptCooldown: Bool = false) throws -> ClaudeOAuthCredentials
{
let context = self.currentCollaboratorContext()
return try Repository(context: context).load(
environment: environment,
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown)
}
public static func loadRecord(
environment: [String: String] = ProcessInfo.processInfo.environment,
allowKeychainPrompt: Bool = true,
respectKeychainPromptCooldown: Bool = false,
allowClaudeKeychainRepairWithoutPrompt: Bool = true) throws -> ClaudeOAuthCredentialRecord
{
let context = self.currentCollaboratorContext()
return try Repository(context: context).loadRecord(
environment: environment,
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown,
allowClaudeKeychainRepairWithoutPrompt: allowClaudeKeychainRepairWithoutPrompt)
}
/// Async version of load that handles expired tokens based on credential ownership.
/// - Claude CLI-owned credentials delegate refresh to Claude CLI.
/// - CodexBar-owned credentials refresh directly via token endpoint.
public static func loadWithAutoRefresh(
environment: [String: String] = ProcessInfo.processInfo.environment,
allowKeychainPrompt: Bool = true,
respectKeychainPromptCooldown: Bool = false) async throws -> ClaudeOAuthCredentials
{
try await self.loadRecordWithAutoRefresh(
environment: environment,
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown).credentials
}
/// Record-preserving variant used when callers must distinguish the credential that actually won routing.
public static func loadRecordWithAutoRefresh(
environment: [String: String] = ProcessInfo.processInfo.environment,
allowKeychainPrompt: Bool = true,
respectKeychainPromptCooldown: Bool = false) async throws -> ClaudeOAuthCredentialRecord
{
let context = self.currentCollaboratorContext()
let repository = Repository(context: context)
let refresher = Refresher(context: context)
let record = try repository.loadRecord(
environment: environment,
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown,
allowClaudeKeychainRepairWithoutPrompt: true)
let credentials = record.credentials
let now = Date()
var expiryMetadata = credentials.diagnosticsMetadata(now: now)
expiryMetadata["source"] = record.source.rawValue
expiryMetadata["owner"] = record.owner.rawValue
expiryMetadata["allowKeychainPrompt"] = "\(allowKeychainPrompt)"
expiryMetadata["respectPromptCooldown"] = "\(respectKeychainPromptCooldown)"
expiryMetadata["readStrategy"] = ClaudeOAuthKeychainReadStrategyPreference.current().rawValue
let isExpired: Bool = if let expiresAt = credentials.expiresAt {
now >= expiresAt
} else {
true
}
// If not expired, return as-is
guard isExpired else {
self.log.debug("Claude OAuth credentials loaded for usage", metadata: expiryMetadata)
return record
}
self.log.info("Claude OAuth credentials considered expired", metadata: expiryMetadata)
switch record.owner {
case .claudeCLI:
if ProviderInteractionContext.current != .userInitiated,
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
interaction: ProviderInteractionContext.current,
environment: environment)
{
self.log.warning(
"Claude OAuth credentials expired; Claude keychain has MCP OAuth state only",
metadata: expiryMetadata)
throw ClaudeOAuthCredentialsError.mcpOAuthOnlyKeychain
}
self.log.info(
"Claude OAuth credentials expired; delegating refresh to Claude CLI",
metadata: expiryMetadata)
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
case .environment:
self.log.warning("Environment OAuth token expired and cannot be auto-refreshed")
throw ClaudeOAuthCredentialsError.noRefreshToken
case .codexbar:
break
}
// Try to refresh if we have a refresh token.
guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else {
self.log.warning("Token expired but no refresh token available")
throw ClaudeOAuthCredentialsError.noRefreshToken
}
self.log.info("Access token expired, attempting auto-refresh")
do {
let refreshed = try await refresher.refreshAccessToken(
refreshToken: refreshToken,
existingScopes: credentials.scopes,
existingRateLimitTier: credentials.rateLimitTier,
existingSubscriptionType: credentials.subscriptionType,
historyOwnerIdentifier: record.historyOwnerIdentifier)
self.log.info("Token refresh successful, expires in \(refreshed.expiresIn ?? 0) seconds")
return ClaudeOAuthCredentialRecord(
credentials: refreshed,
owner: .codexbar,
source: .memoryCache,
historyOwnerIdentifier: record.historyOwnerIdentifier)
} catch {
self.log.error("Token refresh failed: \(error.localizedDescription)")
throw error
}
}
/// Save refreshed credentials to CodexBar's keychain cache
private static func saveRefreshedCredentialsToCache(
_ credentials: ClaudeOAuthCredentials,
historyOwnerIdentifier: String?)
{
var oauth: [String: Any] = [
"accessToken": credentials.accessToken,
"expiresAt": (credentials.expiresAt?.timeIntervalSince1970 ?? 0) * 1000,
"scopes": credentials.scopes,
]
if let refreshToken = credentials.refreshToken {
oauth["refreshToken"] = refreshToken
}
if let rateLimitTier = credentials.rateLimitTier {
oauth["rateLimitTier"] = rateLimitTier
}
if let subscriptionType = credentials.subscriptionType {
oauth["subscriptionType"] = subscriptionType
}
let oauthData: [String: Any] = ["claudeAiOauth": oauth]
guard let jsonData = try? JSONSerialization.data(withJSONObject: oauthData) else {
self.log.error("Failed to serialize refreshed credentials for cache")
return
}
self.saveToCacheKeychain(
jsonData,
owner: .codexbar,
historyOwnerIdentifier: historyOwnerIdentifier)
self.log.debug("Saved refreshed credentials to CodexBar keychain cache")
}
/// Response from the OAuth token refresh endpoint
private struct TokenRefreshResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int
let tokenType: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
case tokenType = "token_type"
}
}
public static func loadFromFile() throws -> Data {
let url = self.credentialsFileURL()
do {
return try Data(contentsOf: url)
} catch {
if (error as NSError).code == NSFileReadNoSuchFileError {
throw ClaudeOAuthCredentialsError.notFound
}
throw ClaudeOAuthCredentialsError.readFailed(error.localizedDescription)
}
}
public static func credentialsFileFingerprintToken() -> String? {
guard let fingerprint = self.currentFileFingerprint() else { return nil }
let modifiedAt = fingerprint.modifiedAtMs.map(String.init) ?? "nil"
return "\(modifiedAt):\(fingerprint.size)"
}
public static func authFingerprintToken() -> String {
let file = self.credentialsFileFingerprintToken() ?? "nil"
let keychain = self.claudeKeychainFingerprintToken() ?? "nil"
return "file=\(file)|keychain=\(keychain)"
}
public static func consumeClaudeKeychainFingerprintChangeWithoutPrompt() -> Bool {
let current: ClaudeKeychainFingerprint?
switch self.probeClaudeKeychainFingerprintWithoutPrompt() {
case .unavailable:
return false
case let .value(fingerprint):
current = fingerprint
}
let stored = self.loadClaudeKeychainFingerprint()
guard current != stored else { return false }
self.saveClaudeKeychainFingerprint(current)
return true
}
public static func claudeKeychainFingerprintChangedWithoutConsuming() -> Bool {
let current: ClaudeKeychainFingerprint?
switch self.probeClaudeKeychainFingerprintWithoutPrompt() {
case .unavailable:
return false
case let .value(fingerprint):
current = fingerprint
}
return current != self.loadClaudeKeychainFingerprint()
}
public static func claudeKeychainFingerprintToken() -> String? {
let fingerprint: ClaudeKeychainFingerprint? = switch self.probeClaudeKeychainFingerprintWithoutPrompt() {
case .unavailable:
self.loadClaudeKeychainFingerprint()
case let .value(probed):
probed
}
guard let fingerprint else { return nil }
let modifiedAt = fingerprint.modifiedAt.map(String.init) ?? "nil"
let createdAt = fingerprint.createdAt.map(String.init) ?? "nil"
let persistentRefHash = fingerprint.persistentRefHash ?? "nil"
return "\(modifiedAt):\(createdAt):\(persistentRefHash)"
}
/// Returns the current Claude Code Keychain item's opaque persistent-reference hash without
/// falling back to a stored fingerprint. History ownership must prefer no identity over a
/// potentially stale identity when a non-interactive Keychain probe is unavailable.
public static func claudeKeychainPersistentRefHashWithoutPrompt() -> String? {
switch self.probeClaudeKeychainFingerprintWithoutPrompt() {
case .unavailable:
nil
case let .value(fingerprint):
fingerprint?.persistentRefHash
}
}
/// Returns the current Keychain item's persistent-reference hash only when it owns the credential
/// that actually won OAuth routing. Token material is compared in memory and is never hashed or persisted.
public static func matchingClaudeKeychainPersistentRefHashWithoutPrompt(
for record: ClaudeOAuthCredentialRecord) -> String?
{
self.claudeKeychainCredentialMatchWithoutPrompt(for: record).persistentRefHash
}
static func claudeKeychainCredentialMatchWithoutPrompt(
for record: ClaudeOAuthCredentialRecord) -> ClaudeKeychainCredentialMatch
{
guard record.owner == .claudeCLI else { return .notApplicable }
let evidence: ClaudeKeychainCredentialEvidence
switch self.newestClaudeKeychainCredentialEvidenceWithoutPrompt() {
case .unavailable:
return .unavailable
case .value(nil):
return .absent
case let .value(value?):
evidence = value
}
guard evidence.credentials.accessToken == record.credentials.accessToken else {
return .mismatch
}
return .matched(persistentRefHash: evidence.persistentRefHash)
}
private static func matchingClaudeKeychainPersistentRefHash(
for record: ClaudeOAuthCredentialRecord,
evidence: ClaudeKeychainCredentialEvidence?) -> String?
{
guard let evidence,
evidence.credentials.accessToken == record.credentials.accessToken
else {
return nil
}
return evidence.persistentRefHash
}
private static func newestClaudeKeychainCredentialEvidenceWithoutPrompt()
-> ClaudeKeychainProbe<ClaudeKeychainCredentialEvidence?>
{
#if DEBUG
if let store = self.taskClaudeKeychainOverrideStore {
guard store.data != nil || store.fingerprint != nil else { return .value(nil) }
return self.makeClaudeKeychainCredentialEvidence(
data: store.data,
persistentRefHash: store.fingerprint?.persistentRefHash).map { .value($0) } ?? .unavailable
}
let overrideData = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride
let overrideFingerprint = self.taskClaudeKeychainFingerprintOverride ?? self.claudeKeychainFingerprintOverride
if overrideData != nil || overrideFingerprint != nil {
return self.makeClaudeKeychainCredentialEvidence(
data: overrideData,
persistentRefHash: overrideFingerprint?.persistentRefHash).map { .value($0) } ?? .unavailable
}
if self.taskSecurityCLIReadOverride != nil || self.securityCLIReadOverride != nil {
// A security(1) result cannot be bound to a persistent reference without an exact candidate read.
return .unavailable
}
#endif
#if os(macOS)
let promptMode = ClaudeOAuthKeychainPromptPreference.current()
let newest: ClaudeKeychainCandidate?
switch self.claudeKeychainCandidatesProbeWithoutPrompt(promptMode: promptMode) {
case .unavailable:
return .unavailable
case let .value(candidates):
if let first = candidates.first {
newest = first
} else {
switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(promptMode: promptMode) {
case .unavailable:
return .unavailable
case let .value(candidate):
newest = candidate
}
}
}
guard let newest else { return .value(nil) }
guard let persistentRefHash = self.sha256Prefix(newest.persistentRef),
let data = try? self.loadClaudeKeychainData(
candidate: newest,
allowKeychainPrompt: false,
promptMode: promptMode)
else {
return .unavailable
}
guard let evidence = self.makeClaudeKeychainCredentialEvidence(
data: data,
persistentRefHash: persistentRefHash)
else { return .unavailable }
return .value(evidence)
#else
return .unavailable
#endif
}
private static func makeClaudeKeychainCredentialEvidence(
data: Data?,
persistentRefHash: String?) -> ClaudeKeychainCredentialEvidence?
{
guard let data,
let persistentRefHash,
let credentials = try? ClaudeOAuthCredentials.parse(data: data)
else {
return nil
}
return ClaudeKeychainCredentialEvidence(
credentials: credentials,
persistentRefHash: persistentRefHash)
}
#if DEBUG
static func _matchingClaudeKeychainPersistentRefHashForTesting(
record: ClaudeOAuthCredentialRecord,
candidateCredentials: ClaudeOAuthCredentials,
persistentRefHash: String) -> String?
{
self.matchingClaudeKeychainPersistentRefHash(
for: record,
evidence: ClaudeKeychainCredentialEvidence(
credentials: candidateCredentials,
persistentRefHash: persistentRefHash))
}
#endif
private enum ClaudeKeychainProbe<Value> {
case unavailable
case value(Value)
}
@discardableResult
public static func invalidateCacheIfCredentialsFileChanged() -> Bool {
Repository(context: self.currentCollaboratorContext()).invalidateCacheIfCredentialsFileChanged()
}
/// Invalidate the credentials cache (call after login/logout)
public static func invalidateCache() {
Repository(context: self.currentCollaboratorContext()).invalidateCache()
}
/// Check if CodexBar has cached credentials (in memory or keychain cache)
public static func hasCachedCredentials(environment: [String: String] = ProcessInfo.processInfo
.environment) -> Bool
{
Repository(context: self.currentCollaboratorContext()).hasCachedCredentials(environment: environment)
}
public static func hasClaudeKeychainCredentialsWithoutPrompt() -> Bool {
Repository(context: self.currentCollaboratorContext()).hasClaudeKeychainCredentialsWithoutPrompt()
}
private static func hasClaudeKeychainItemWithoutPrompt() -> Bool {
#if DEBUG
if let store = self.taskClaudeKeychainOverrideStore {
if let data = store.data, !data.isEmpty {
return true
}
if store.fingerprint != nil {
return true
}
}
if let data = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride,
!data.isEmpty
{
return true
}
if self.taskClaudeKeychainFingerprintOverride ?? self.claudeKeychainFingerprintOverride != nil {
return true
}
#endif
#if os(macOS)
switch self.claudeKeychainCandidatesProbeWithoutPrompt(enforcePromptPolicy: false) {
case let .value(candidates) where !candidates.isEmpty:
return true
case .value, .unavailable:
break
}
switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(enforcePromptPolicy: false) {
case let .value(candidate):
return candidate != nil
case .unavailable:
return false
}
#else
return false
#endif
}
private static func shouldCheckClaudeKeychainChange(now: Date = Date()) -> Bool {
#if DEBUG
// Unit tests can supply TaskLocal overrides for the Claude keychain data/fingerprint. Those tests often run
// concurrently with other suites, so the global throttle becomes nondeterministic. When an override is
// present, bypass the throttle so test expectations don't depend on unrelated activity.
if self.taskClaudeKeychainOverrideStore != nil || self.taskClaudeKeychainFingerprintOverride != nil
|| self.claudeKeychainFingerprintOverride != nil
{
return true
}
#endif
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? {
#if DEBUG
if let store = taskClaudeKeychainFingerprintStoreOverride {
return store.fingerprint
}
#endif
// 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?) {
#if DEBUG
if let store = taskClaudeKeychainFingerprintStoreOverride {
store.fingerprint = fingerprint
return
}
#endif
// 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? {
switch self.probeClaudeKeychainFingerprintWithoutPrompt() {
case .unavailable:
nil
case let .value(fingerprint):
fingerprint
}
}
private static func probeClaudeKeychainFingerprintWithoutPrompt()
-> ClaudeKeychainProbe<ClaudeKeychainFingerprint?> {
let mode = ClaudeOAuthKeychainPromptPreference.current()
#if DEBUG
if let store = taskClaudeKeychainOverrideStore {
return .value(store.fingerprint)
}
if let override = taskClaudeKeychainFingerprintOverride ?? self
.claudeKeychainFingerprintOverride
{
return .value(override)
}
#endif
guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return .unavailable }
if self.isPromptPolicyApplicable,
ProviderInteractionContext.current == .background,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()
{
return .unavailable
}
#if os(macOS)
let candidatesProbe = self.claudeKeychainCandidatesProbeWithoutPrompt(promptMode: mode)
let newest: ClaudeKeychainCandidate?
switch candidatesProbe {
case .unavailable:
return .unavailable
case let .value(candidates):
if let first = candidates.first {
newest = first
} else {
switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(promptMode: mode) {
case .unavailable:
return .unavailable
case let .value(candidate):
newest = candidate
}
}
}
guard let newest else { return .value(nil) }
let modifiedAt = newest.modifiedAt.map { Int($0.timeIntervalSince1970) }
let createdAt = newest.createdAt.map { Int($0.timeIntervalSince1970) }
let persistentRefHash = Self.sha256Prefix(newest.persistentRef)
return .value(ClaudeKeychainFingerprint(
modifiedAt: modifiedAt,
createdAt: createdAt,
persistentRefHash: persistentRefHash))
#else
return .unavailable
#endif
}
static func currentClaudeKeychainFingerprintWithoutPromptForAuthGate() -> ClaudeKeychainFingerprint? {
self.currentClaudeKeychainFingerprintWithoutPrompt()
}
static func currentCredentialsFileFingerprintWithoutPromptForAuthGate() -> String? {
guard let fingerprint = self.currentFileFingerprint() else { return nil }
let modifiedAt = fingerprint.modifiedAtMs ?? 0
return "\(modifiedAt):\(fingerprint.size)"
}
private static func loadFromClaudeKeychainNonInteractive(
readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current())
throws -> Data?
{
#if os(macOS)
let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode(
readStrategy: readStrategy)
if let data = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current,
readStrategy: readStrategy)
{
return data
}
// For experimental strategy, enforce stored prompt policy before any Security.framework fallback probes.
guard self.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { return nil }
#if DEBUG
if let store = taskClaudeKeychainOverrideStore {
return store.data
}
if let override = taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride {
return override
}
#endif
// 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(promptMode: fallbackPromptMode)
if let newest = candidates.first {
if let data = try self.loadClaudeKeychainData(candidate: newest, allowKeychainPrompt: false),
!data.isEmpty
{
return data
}
return nil
}
let legacyData = try self.loadClaudeKeychainLegacyData(
allowKeychainPrompt: false,
promptMode: fallbackPromptMode)
if let legacyData, !legacyData.isEmpty {
return legacyData
}
return nil
#else
return nil
#endif
}
static func readRawClaudeKeychainPayloadViaSecurityFrameworkWithoutPrompt() -> Data? {
#if os(macOS)
guard self.keychainAccessAllowed else { return nil }
#if DEBUG
if let store = self.taskClaudeKeychainOverrideStore {
return store.data
}
if let override = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride {
return override
}
#endif
// This probe must work under the default `onlyOnUserAction` policy, but must never show Keychain UI.
// The candidate and data queries both use KeychainNoUIQuery; `.always` only bypasses the prompt-policy gate.
switch self.claudeKeychainCandidatesProbeWithoutPrompt(
promptMode: .always,
enforcePromptPolicy: false)
{
case .unavailable:
return nil
case let .value(candidates):
if let newest = candidates.first {
return try? self.loadClaudeKeychainData(
candidate: newest,
allowKeychainPrompt: false,
promptMode: .always)
}
}
return try? self.loadClaudeKeychainLegacyData(
allowKeychainPrompt: false,
promptMode: .always)
#else
return nil
#endif
}
public static func loadFromClaudeKeychain() throws -> Data {
guard self.shouldAllowClaudeCodeKeychainAccess(mode: ClaudeOAuthKeychainPromptPreference.current()) else {
throw ClaudeOAuthCredentialsError.notFound
}
#if DEBUG
if let store = taskClaudeKeychainOverrideStore, let override = store.data {
return override
}
if let override = taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride {
return override
}
#endif
if let data = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current)
{
return data
}
if self.shouldPreferSecurityCLIKeychainRead() {
let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode()
let fallbackDecision = self.securityFrameworkFallbackPromptDecision(
promptMode: fallbackPromptMode,
allowKeychainPrompt: true,
respectKeychainPromptCooldown: false)
self.log.debug(
"Claude keychain Security.framework fallback prompt policy evaluated",
metadata: [
"reader": "securityFrameworkFallback",
"fallbackPromptMode": fallbackPromptMode.rawValue,
"fallbackPromptAllowed": "\(fallbackDecision.allowed)",
"fallbackBlockedReason": fallbackDecision.blockedReason ?? "none",
])
guard fallbackDecision.allowed else {
throw ClaudeOAuthCredentialsError.notFound
}
return try self.loadFromClaudeKeychainUsingSecurityFramework(
promptMode: fallbackPromptMode,
allowKeychainPrompt: true)
}
return try self.loadFromClaudeKeychainUsingSecurityFramework()
}
/// Legacy alias for backward compatibility
public static func loadFromKeychain() throws -> Data {
try self.loadFromClaudeKeychain()
}
private static func loadFromClaudeKeychainUsingSecurityFramework(
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current(),
allowKeychainPrompt: Bool = true) throws -> Data
{
#if DEBUG
if let store = taskClaudeKeychainOverrideStore, let override = store.data {
return override
}
if let override = taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride {
return override
}
#endif
#if os(macOS)
let candidates = self.claudeKeychainCandidatesWithoutPrompt(promptMode: promptMode)
if let newest = candidates.first {
do {
if let data = try self.loadClaudeKeychainData(
candidate: newest,
allowKeychainPrompt: allowKeychainPrompt,
promptMode: promptMode),
!data.isEmpty
{
// Store fingerprint after a successful interactive read so we don't immediately try to
// "sync" in the background (which can still show UI on some systems).
let modifiedAt = newest.modifiedAt.map { Int($0.timeIntervalSince1970) }
let createdAt = newest.createdAt.map { Int($0.timeIntervalSince1970) }
let persistentRefHash = Self.sha256Prefix(newest.persistentRef)
self.saveClaudeKeychainFingerprint(
ClaudeKeychainFingerprint(
modifiedAt: modifiedAt,
createdAt: createdAt,
persistentRefHash: persistentRefHash))
return data
}
} catch let error as ClaudeOAuthCredentialsError {
if case .keychainError = error {
ClaudeOAuthKeychainAccessGate.recordDenied()
}
throw error
}
}
// Fallback: legacy query (may pick an arbitrary duplicate).
do {
if let data = try self.loadClaudeKeychainLegacyData(
allowKeychainPrompt: allowKeychainPrompt,
promptMode: promptMode),
!data.isEmpty
{
// Same as above: store fingerprint after interactive read to avoid background "sync" reads.
self.saveClaudeKeychainFingerprint(self.currentClaudeKeychainFingerprintWithoutPrompt())
return data
}
} catch let error as ClaudeOAuthCredentialsError {
if case .keychainError = error {
ClaudeOAuthKeychainAccessGate.recordDenied()
}
throw error
}
throw ClaudeOAuthCredentialsError.notFound
#else
throw ClaudeOAuthCredentialsError.notFound
#endif
}
#if os(macOS)
private struct ClaudeKeychainCandidate {
let persistentRef: Data
let account: String?
let modifiedAt: Date?
let createdAt: Date?
}
private static func claudeKeychainCandidatesProbeWithoutPrompt(
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference
.current(),
enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]>
{
if enforcePromptPolicy {
guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable }
if self.isPromptPolicyApplicable,
ProviderInteractionContext.current == .background,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()
{
return .unavailable
}
} else {
guard self.keychainAccessAllowed else { return .unavailable }
}
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.claudeKeychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnPersistentRef as String: true,
]
KeychainNoUIQuery.apply(to: &query)
let (status, result, durationMs) = ClaudeOAuthKeychainQueryTiming.copyMatching(query)
if ClaudeOAuthKeychainQueryTiming
.backoffIfSlowNoUIQuery(durationMs, self.claudeKeychainService, self.log)
{
return .unavailable
}
if status == errSecUserCanceled || status == errSecAuthFailed || status == errSecNoAccessForItem {
ClaudeOAuthKeychainAccessGate.recordDenied()
}
if status == errSecItemNotFound {
return .value([])
}
guard status == errSecSuccess else { return .unavailable }
guard let rows = result as? [[String: Any]], !rows.isEmpty else { return .value([]) }
let candidates: [ClaudeKeychainCandidate] = rows.compactMap { row in
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)
}
let sorted = candidates.sorted { lhs, rhs in
let lhsDate = lhs.modifiedAt ?? lhs.createdAt ?? Date.distantPast
let rhsDate = rhs.modifiedAt ?? rhs.createdAt ?? Date.distantPast
return lhsDate > rhsDate
}
return .value(sorted)
}
private static func claudeKeychainCandidatesWithoutPrompt(
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference
.current()) -> [ClaudeKeychainCandidate]
{
switch self.claudeKeychainCandidatesProbeWithoutPrompt(promptMode: promptMode) {
case .unavailable:
[]
case let .value(candidates):
candidates
}
}
private static func claudeKeychainLegacyCandidateProbeWithoutPrompt(
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference
.current(),
enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe<ClaudeKeychainCandidate?>
{
if enforcePromptPolicy {
guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable }
if self.isPromptPolicyApplicable,
ProviderInteractionContext.current == .background,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()
{
return .unavailable
}
} else {
guard self.keychainAccessAllowed else { return .unavailable }
}
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)
let (status, result, durationMs) = ClaudeOAuthKeychainQueryTiming.copyMatching(query)
if ClaudeOAuthKeychainQueryTiming
.backoffIfSlowNoUIQuery(durationMs, self.claudeKeychainService, self.log)
{
return .unavailable
}
if status == errSecUserCanceled || status == errSecAuthFailed || status == errSecNoAccessForItem {
ClaudeOAuthKeychainAccessGate.recordDenied()
}
if status == errSecItemNotFound {
return .value(nil)
}
guard status == errSecSuccess else { return .unavailable }
guard let row = result as? [String: Any] else { return .value(nil) }
guard let persistentRef = row[kSecValuePersistentRef as String] as? Data else { return .value(nil) }
return .value(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 claudeKeychainLegacyCandidateWithoutPrompt(
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference
.current()) -> ClaudeKeychainCandidate?
{
switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(promptMode: promptMode) {
case .unavailable:
nil
case let .value(candidate):
candidate
}
}
private static func loadClaudeKeychainData(
candidate: ClaudeKeychainCandidate,
allowKeychainPrompt: Bool,
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) throws -> Data?
{
guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil }
self.log.debug(
"Claude keychain data read start",
metadata: [
"service": self.claudeKeychainService,
"interactive": "\(allowKeychainPrompt)",
"process": ProcessInfo.processInfo.processName,
])
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecValuePersistentRef as String: candidate.persistentRef,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
if !allowKeychainPrompt {
KeychainNoUIQuery.apply(to: &query)
}
var result: AnyObject?
let startedAtNs = DispatchTime.now().uptimeNanoseconds
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAtNs) / 1_000_000.0
self.log.debug(
"Claude keychain data read result",
metadata: [
"service": self.claudeKeychainService,
"interactive": "\(allowKeychainPrompt)",
"status": "\(status)",
"duration_ms": String(format: "%.2f", durationMs),
"process": ProcessInfo.processInfo.processName,
])
switch status {
case errSecSuccess:
if let data = result as? Data {
return data
}
return nil
case errSecItemNotFound:
return nil
case errSecInteractionNotAllowed:
if allowKeychainPrompt {
ClaudeOAuthKeychainAccessGate.recordDenied()
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
}
return nil
case errSecUserCanceled, errSecAuthFailed:
ClaudeOAuthKeychainAccessGate.recordDenied()
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
case errSecNoAccessForItem:
ClaudeOAuthKeychainAccessGate.recordDenied()
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
default:
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
}
}
private static func loadClaudeKeychainLegacyData(
allowKeychainPrompt: Bool,
promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) throws -> Data?
{
guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil }
self.log.debug(
"Claude keychain legacy data read start",
metadata: [
"service": self.claudeKeychainService,
"interactive": "\(allowKeychainPrompt)",
"process": ProcessInfo.processInfo.processName,
])
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.claudeKeychainService,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
if !allowKeychainPrompt {
KeychainNoUIQuery.apply(to: &query)
}
var result: AnyObject?
let startedAtNs = DispatchTime.now().uptimeNanoseconds
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAtNs) / 1_000_000.0
self.log.debug(
"Claude keychain legacy data read result",
metadata: [
"service": self.claudeKeychainService,
"interactive": "\(allowKeychainPrompt)",
"status": "\(status)",
"duration_ms": String(format: "%.2f", durationMs),
"process": ProcessInfo.processInfo.processName,
])
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
case errSecInteractionNotAllowed:
if allowKeychainPrompt {
ClaudeOAuthKeychainAccessGate.recordDenied()
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
}
return nil
case errSecUserCanceled, errSecAuthFailed:
ClaudeOAuthKeychainAccessGate.recordDenied()
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
case errSecNoAccessForItem:
ClaudeOAuthKeychainAccessGate.recordDenied()
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
default:
throw ClaudeOAuthCredentialsError.keychainError(Int(status))
}
}
#endif
private static func loadFromEnvironment(_ environment: [String: String])
-> ClaudeOAuthCredentials?
{
guard
let token = environment[self.environmentTokenKey]?.trimmingCharacters(
in: .whitespacesAndNewlines),
!token.isEmpty
else {
return nil
}
let scopes: [String] = {
guard let raw = environment[self.environmentScopesKey] else { return ["user:profile"] }
let parsed =
raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return parsed.isEmpty ? ["user:profile"] : parsed
}()
return ClaudeOAuthCredentials(
accessToken: token,
refreshToken: nil,
expiresAt: Date.distantFuture,
scopes: scopes,
rateLimitTier: nil)
}
static func setCredentialsURLOverrideForTesting(_ url: URL?) {
self.credentialsURLOverride = url
}
#if DEBUG
public static func withCredentialsURLOverrideForTesting<T>(_ url: URL?, operation: () throws -> T) rethrows -> T {
try self.$taskCredentialsURLOverride.withValue(url) {
try operation()
}
}
public static func withCredentialsURLOverrideForTesting<T>(_ url: URL?, operation: () async throws -> T)
async rethrows -> T {
try await self.$taskCredentialsURLOverride.withValue(url) {
try await operation()
}
}
public static var currentCredentialsURLOverrideForTesting: URL? {
self.taskCredentialsURLOverride
}
#endif
private static func saveToCacheKeychain(
_ data: Data,
owner: ClaudeOAuthCredentialOwner? = nil,
historyOwnerIdentifier: String? = nil)
{
guard self.shouldUseCodexBarOAuthKeychainCache else {
self.markPendingCodexBarOAuthKeychainCacheClear()
return
}
let entry = CacheEntry(
data: data,
storedAt: Date(),
owner: owner,
historyOwnerIdentifier: historyOwnerIdentifier)
self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in
if pending {
switch KeychainCacheStore.clearResult(key: self.cacheKey) {
case .removed, .missing:
pending = false
case .failed:
break
}
}
pending = !KeychainCacheStore.storeResult(key: self.cacheKey, entry: entry)
}
}
private static func clearCacheKeychain() {
if self.shouldUseCodexBarOAuthKeychainCache {
self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in
switch KeychainCacheStore.clearResult(key: self.cacheKey) {
case .removed, .missing:
pending = false
case .failed:
pending = true
}
}
} else {
self.markPendingCodexBarOAuthKeychainCacheClear()
}
}
private static func loadCodexBarOAuthKeychainCache() -> KeychainCacheStore.LoadResult<CacheEntry> {
guard self.shouldUseCodexBarOAuthKeychainCache else { return .missing }
var result: KeychainCacheStore.LoadResult<CacheEntry> = .temporarilyUnavailable
self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in
if pending {
switch KeychainCacheStore.clearResult(key: self.cacheKey) {
case .removed, .missing:
pending = false
case .failed:
return
}
}
result = KeychainCacheStore.load(key: self.cacheKey, as: CacheEntry.self)
}
return result
}
private static var shouldUseCodexBarOAuthKeychainCache: Bool {
ClaudeOAuthKeychainPromptPreference.storedMode() != .never
}
private static func markPendingCodexBarOAuthKeychainCacheClear() {
self.currentPendingCodexBarOAuthKeychainCacheClearStore.markPending()
}
private static var hasPendingCodexBarOAuthKeychainCacheClear: Bool {
self.currentPendingCodexBarOAuthKeychainCacheClearStore.isPending
}
private static var currentPendingCodexBarOAuthKeychainCacheClearStore: ClaudeOAuthPendingCacheClearStore {
#if DEBUG
if let store = self.taskPendingCacheClearStoreOverride {
return store
}
#endif
return self.pendingCodexBarOAuthKeychainCacheClearStore
}
private static var keychainAccessAllowed: Bool {
#if DEBUG
if let override = self.taskKeychainAccessOverride {
return !override
}
if KeychainAccessGate.currentOverrideForTesting == true {
return false
}
if self.hasTaskKeychainTestingOverride {
return true
}
#endif
return !KeychainAccessGate.isDisabled
}
#if DEBUG
private static var hasTaskKeychainTestingOverride: Bool {
self.taskClaudeKeychainOverrideStore != nil
|| self.taskClaudeKeychainDataOverride != nil
|| self.taskClaudeKeychainFingerprintOverride != nil
|| self.taskSecurityCLIReadOverride != nil
|| self.taskSecurityCLIReadAccountOverride != nil
}
#endif
private static var isPromptPolicyApplicable: Bool {
ClaudeOAuthKeychainPromptPreference.isApplicable()
}
private static func securityFrameworkFallbackPromptDecision(
promptMode: ClaudeOAuthKeychainPromptMode,
allowKeychainPrompt: Bool,
respectKeychainPromptCooldown: Bool) -> (allowed: Bool, blockedReason: String?)
{
guard allowKeychainPrompt else {
return (allowed: false, blockedReason: "allowKeychainPromptFalse")
}
guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else {
return (allowed: false, blockedReason: self.fallbackBlockedReason(promptMode: promptMode))
}
if respectKeychainPromptCooldown,
!ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()
{
return (allowed: false, blockedReason: "cooldown")
}
return (allowed: true, blockedReason: nil)
}
private static func fallbackBlockedReason(promptMode: ClaudeOAuthKeychainPromptMode) -> String {
if !self.keychainAccessAllowed {
return "keychainDisabled"
}
switch promptMode {
case .never:
return "never"
case .onlyOnUserAction:
return "onlyOnUserAction-background"
case .always:
return "disallowed"
}
}
private static func shouldAllowClaudeCodeKeychainAccess(
mode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) -> Bool
{
guard self.keychainAccessAllowed else { return false }
switch mode {
case .never: return false
case .onlyOnUserAction:
return ProviderInteractionContext.current == .userInitiated || self.allowBackgroundPromptBootstrap
case .always: return true
}
}
static func preferredClaudeKeychainAccountForSecurityCLIRead(
interaction: ProviderInteraction = ProviderInteractionContext.current) -> String?
{
// Keep the experimental background path fully on /usr/bin/security by default.
// Account pinning requires Security.framework candidate probing, so only allow it on explicit user actions.
guard interaction == .userInitiated else { return nil }
#if DEBUG
if let override = self.taskSecurityCLIReadAccountOverride {
return override
}
#endif
#if os(macOS)
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil }
// Keep experimental mode prompt-safe: avoid Security.framework candidate probes when preflight says
// interaction is likely.
if self.shouldShowClaudeKeychainPreAlert() {
return nil
}
guard let account = self.claudeKeychainCandidatesWithoutPrompt(promptMode: mode).first?.account,
!account.isEmpty
else {
return nil
}
return account
#else
return nil
#endif
}
private static func credentialsFileURL() -> URL {
#if DEBUG
if let override = self.taskCredentialsURLOverride {
return override
}
#endif
return self.credentialsURLOverride ?? self.defaultCredentialsURL()
}
private static func loadFileFingerprint() -> CredentialsFileFingerprint? {
#if DEBUG
if let store = self.taskCredentialsFileFingerprintStoreOverride {
return store.load()
}
#endif
guard let data = UserDefaults.standard.data(forKey: self.fileFingerprintKey) else {
return nil
}
return try? JSONDecoder().decode(CredentialsFileFingerprint.self, from: data)
}
private static func saveFileFingerprint(_ fingerprint: CredentialsFileFingerprint?) {
#if DEBUG
if let store = self.taskCredentialsFileFingerprintStoreOverride {
store.save(fingerprint); return
}
#endif
guard let fingerprint else {
UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey)
return
}
if let data = try? JSONEncoder().encode(fingerprint) {
UserDefaults.standard.set(data, forKey: self.fileFingerprintKey)
}
}
private static func currentFileFingerprint() -> CredentialsFileFingerprint? {
let url = self.credentialsFileURL()
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else {
return nil
}
let size = (attrs[.size] as? NSNumber)?.intValue ?? 0
let modifiedAtMs = (attrs[.modificationDate] as? Date).map { Int($0.timeIntervalSince1970 * 1000) }
return CredentialsFileFingerprint(modifiedAtMs: modifiedAtMs, size: size)
}
#if DEBUG
static func _resetCredentialsFileTrackingForTesting() {
if let store = self.taskCredentialsFileFingerprintStoreOverride {
store.save(nil)
} else {
UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey)
}
if self.taskPendingCacheClearStoreOverride != nil {
self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in
pending = false
}
}
}
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 {
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent(self.credentialsPath)
}
}
// swiftlint:enable type_body_length
extension ClaudeOAuthCredentialsStore {
/// After delegated Claude CLI refresh, re-load the Claude keychain entry without prompting and sync it into
/// CodexBar's caches. This is used to avoid triggering a second OS keychain dialog during the OAuth retry.
@discardableResult
static func syncFromClaudeKeychainWithoutPrompt(now: Date = Date()) -> Bool {
Recovery(context: self.currentCollaboratorContext()).syncFromClaudeKeychainWithoutPrompt(now: now)
}
private static func shouldShowClaudeKeychainPreAlert() -> Bool {
#if DEBUG
// Synthetic Claude Keychain fixtures must not fall through to the real preflight. Tests that explicitly
// override the preflight still exercise its prompt-policy branches.
if self.hasTaskKeychainTestingOverride,
!KeychainAccessPreflight.hasCheckGenericPasswordOverrideForTesting
{
return false
}
#endif
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false }
return switch KeychainAccessPreflight.checkGenericPassword(service: self.claudeKeychainService, account: nil) {
case .interactionRequired:
true
case .failure:
// If preflight fails, we can't be sure whether interaction is required (or if the preflight itself
// is impacted by a misbehaving Keychain configuration). Be conservative and show the pre-alert.
true
case .allowed, .notFound:
false
}
}
private static func shouldNotifyClaudeKeychainPreAlert() -> Bool {
let mode = ClaudeOAuthKeychainPromptPreference.current()
guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false }
// Attribute-only preflight can report success even when reading the secret will prompt. Explicit user
// actions are rare and intentional, so always explain the read before Security.framework can show UI.
return ProviderInteractionContext.current == .userInitiated || self.shouldShowClaudeKeychainPreAlert()
}
/// Refresh the access token using a refresh token.
/// Updates CodexBar's keychain cache with the new credentials.
public static func refreshAccessToken(
refreshToken: String,
existingScopes: [String],
existingRateLimitTier: String?,
existingSubscriptionType: String? = nil) async throws -> ClaudeOAuthCredentials
{
let historyOwnerIdentifier = ClaudeOAuthCredentials.historyOwnerIdentifier(forRefreshToken: refreshToken)
return try await Refresher(context: self.currentCollaboratorContext()).refreshAccessToken(
refreshToken: refreshToken,
existingScopes: existingScopes,
existingRateLimitTier: existingRateLimitTier,
existingSubscriptionType: existingSubscriptionType,
historyOwnerIdentifier: historyOwnerIdentifier)
}
private enum RefreshFailureDisposition: String {
case terminalInvalidGrant
case transientBackoff
}
private static func extractOAuthErrorCode(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return json["error"] as? String
}
private static func refreshFailureDisposition(statusCode: Int, data: Data) -> RefreshFailureDisposition? {
guard statusCode == 400 || statusCode == 401 else { return nil }
if let error = self.extractOAuthErrorCode(from: data)?.lowercased(), error == "invalid_grant" {
return .terminalInvalidGrant
}
return .transientBackoff
}
#if DEBUG
static func extractOAuthErrorCodeForTesting(from data: Data) -> String? {
self.extractOAuthErrorCode(from: data)
}
static func refreshFailureDispositionForTesting(statusCode: Int, data: Data) -> String? {
self.refreshFailureDisposition(statusCode: statusCode, data: data)?.rawValue
}
#endif
}