2140 lines · 93.2 KB · Swift
RawDownload
import Dispatchimport Foundation #if canImport(FoundationNetworking)import FoundationNetworking#endif #if os(macOS)import LocalAuthenticationimport Security#endif // swiftlint:disable type_body_length file_lengthpublic 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 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?    }     struct CredentialsFileFingerprint: Codable, Equatable {        let modifiedAtMs: Int?        let size: Int    }     struct CacheEntry: Codable {        let data: Data        let storedAt: Date        let owner: ClaudeOAuthCredentialOwner?         init(data: Data, storedAt: Date, owner: ClaudeOAuthCredentialOwner? = nil) {            self.data = data            self.storedAt = storedAt            self.owner = owner        }    }     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 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)                    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 KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) {                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)                        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),                                timestamp: Date())                            return record                        }                    } else {                        KeychainCacheStore.clear(key: ClaudeOAuthCredentialsStore.cacheKey)                    }                case .invalid:                    KeychainCacheStore.clear(key: ClaudeOAuthCredentialsStore.cacheKey)                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 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)                }                if case let .found(entry) = KeychainCacheStore.load(                    key: ClaudeOAuthCredentialsStore.cacheKey,                    as: CacheEntry.self),                    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)                }                 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.shouldShowClaudeKeychainPreAlert() {                    KeychainPromptHandler.notify(                        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 }            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 let current {                    if let modifiedAtMs = current.modifiedAtMs {                        let modifiedAt = Date(timeIntervalSince1970: TimeInterval(Double(modifiedAtMs) / 1000.0))                        switch KeychainCacheStore.load(                            key: ClaudeOAuthCredentialsStore.cacheKey,                            as: CacheEntry.self)                        {                        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                }                 if shouldClearKeychainCache {                    ClaudeOAuthCredentialsStore.clearCacheKeychain()                }                if shouldSaveFileFingerprint {                    ClaudeOAuthCredentialsStore.saveFileFingerprint(current)                }                return true            }        }         func invalidateCache() {            self.context.run {                ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil)                ClaudeOAuthCredentialsStore.clearCacheKeychain()            }        } 

Lines 1–500 of 2140.