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

302 lines
11 KiB
Swift

import Foundation
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
#if os(macOS)
import Security
#endif
public struct ClaudeOAuthCredentials: Sendable {
public let accessToken: String
public let refreshToken: String?
public let expiresAt: Date?
public let scopes: [String]
public let rateLimitTier: String?
public let subscriptionType: String?
public init(
accessToken: String,
refreshToken: String?,
expiresAt: Date?,
scopes: [String],
rateLimitTier: String?,
subscriptionType: String? = nil)
{
self.accessToken = accessToken
self.refreshToken = refreshToken
self.expiresAt = expiresAt
self.scopes = scopes
self.rateLimitTier = rateLimitTier
self.subscriptionType = subscriptionType
}
public var isExpired: Bool {
guard let expiresAt else { return true }
return Date() >= expiresAt
}
public var expiresIn: TimeInterval? {
guard let expiresAt else { return nil }
return expiresAt.timeIntervalSinceNow
}
/// A one-way discriminator for history owned by this credential.
///
/// Prefer the refresh token because access tokens routinely rotate for the same principal. If a provider
/// supplies only an access token, rotating that token intentionally starts a new history bucket rather than
/// risking that two identityless accounts share one. The source secret never leaves this computation.
var historyOwnerIdentifier: String? {
let normalizedRefreshToken = self.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines)
if let normalizedRefreshToken, !normalizedRefreshToken.isEmpty {
return Self.makeHistoryOwnerIdentifier(secretKind: "refresh", secret: normalizedRefreshToken)
}
let normalizedAccessToken = self.accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedAccessToken.isEmpty else { return nil }
return Self.makeHistoryOwnerIdentifier(secretKind: "access", secret: normalizedAccessToken)
}
static func historyOwnerIdentifier(forRefreshToken refreshToken: String) -> String? {
let normalized = refreshToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return nil }
return Self.makeHistoryOwnerIdentifier(secretKind: "refresh", secret: normalized)
}
private static func makeHistoryOwnerIdentifier(secretKind: String, secret: String) -> String? {
let material = Data("codexbar:claude-oauth-history-owner:v1\0\(secretKind)\0\(secret)".utf8)
return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined()
}
static func normalizedHistoryOwnerIdentifier(_ identifier: String?) -> String? {
guard let identifier else { return nil }
let normalized = identifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalized.count == 64,
normalized.unicodeScalars.allSatisfy({ scalar in
switch scalar.value {
case 48...57, 97...102:
true
default:
false
}
})
else { return nil }
return normalized
}
public static func isMcpOAuthOnlyPayload(data: Data) -> Bool {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
return json["claudeAiOauth"] == nil && json["mcpOAuth"] != nil
}
public static func parse(data: Data) throws -> ClaudeOAuthCredentials {
if ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: data) {
throw ClaudeOAuthCredentialsError.mcpOAuthOnlyKeychain
}
let decoder = JSONDecoder()
guard let root = try? decoder.decode(Root.self, from: data) else {
throw ClaudeOAuthCredentialsError.decodeFailed
}
guard let oauth = root.claudeAiOauth else {
throw ClaudeOAuthCredentialsError.missingOAuth
}
let accessToken = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !accessToken.isEmpty else {
throw ClaudeOAuthCredentialsError.missingAccessToken
}
let expiresAt = oauth.expiresAt.map { millis in
Date(timeIntervalSince1970: millis / 1000.0)
}
return ClaudeOAuthCredentials(
accessToken: accessToken,
refreshToken: oauth.refreshToken,
expiresAt: expiresAt,
scopes: oauth.scopes ?? [],
rateLimitTier: oauth.rateLimitTier,
subscriptionType: oauth.subscriptionType)
}
private struct Root: Decodable {
let claudeAiOauth: OAuth?
}
private struct OAuth: Decodable {
let accessToken: String?
let refreshToken: String?
let expiresAt: Double?
let scopes: [String]?
let rateLimitTier: String?
let subscriptionType: String?
enum CodingKeys: String, CodingKey {
case accessToken
case refreshToken
case expiresAt
case scopes
case rateLimitTier
case subscriptionType
}
}
}
extension ClaudeOAuthCredentials {
func diagnosticsMetadata(now: Date = Date()) -> [String: String] {
let hasRefreshToken = !(self.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
let hasUserProfileScope = self.scopes.contains("user:profile")
var metadata: [String: String] = [
"hasRefreshToken": "\(hasRefreshToken)",
"scopesCount": "\(self.scopes.count)",
"hasUserProfileScope": "\(hasUserProfileScope)",
]
if let expiresAt = self.expiresAt {
let expiresAtMs = Int(expiresAt.timeIntervalSince1970 * 1000.0)
let expiresInSec = Int(expiresAt.timeIntervalSince(now).rounded())
metadata["expiresAtMs"] = "\(expiresAtMs)"
metadata["expiresInSec"] = "\(expiresInSec)"
metadata["isExpired"] = "\(now >= expiresAt)"
} else {
metadata["expiresAtMs"] = "nil"
metadata["expiresInSec"] = "nil"
metadata["isExpired"] = "true"
}
return metadata
}
}
public enum ClaudeOAuthCredentialOwner: String, Codable, Sendable {
case claudeCLI
case codexbar
case environment
}
public enum ClaudeOAuthCredentialSource: String, Sendable {
case environment
case memoryCache
case cacheKeychain
case credentialsFile
case claudeKeychain
}
enum ClaudeKeychainCredentialMatch: Equatable, Sendable {
case notApplicable
case absent
case unavailable
case mismatch
case matched(persistentRefHash: String)
var persistentRefHash: String? {
guard case let .matched(persistentRefHash) = self else { return nil }
return persistentRefHash
}
var isMismatch: Bool {
self == .mismatch
}
var isAbsent: Bool {
self == .absent
}
var isUnavailable: Bool {
self == .unavailable
}
}
public struct ClaudeOAuthCredentialRecord: Sendable {
public let credentials: ClaudeOAuthCredentials
public let owner: ClaudeOAuthCredentialOwner
public let source: ClaudeOAuthCredentialSource
private let inheritedHistoryOwnerIdentifier: String?
/// An opaque, one-way owner identifier that survives a refresh-token rotation proven by a successful refresh.
/// Records from unrelated credential sources do not inherit this value and derive a fresh identifier instead.
var historyOwnerIdentifier: String? {
self.inheritedHistoryOwnerIdentifier ?? self.credentials.historyOwnerIdentifier
}
public init(
credentials: ClaudeOAuthCredentials,
owner: ClaudeOAuthCredentialOwner,
source: ClaudeOAuthCredentialSource)
{
self.credentials = credentials
self.owner = owner
self.source = source
self.inheritedHistoryOwnerIdentifier = nil
}
init(
credentials: ClaudeOAuthCredentials,
owner: ClaudeOAuthCredentialOwner,
source: ClaudeOAuthCredentialSource,
historyOwnerIdentifier: String?)
{
self.credentials = credentials
self.owner = owner
self.source = source
self.inheritedHistoryOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier(
historyOwnerIdentifier)
}
}
public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable {
case decodeFailed
case missingOAuth
case mcpOAuthOnlyKeychain
case missingAccessToken
case notFound
case keychainError(Int)
case readFailed(String)
case refreshFailed(String)
case noRefreshToken
case refreshDelegatedToClaudeCLI
public var errorDescription: String? {
switch self {
case .decodeFailed:
return "Claude OAuth credentials are invalid."
case .missingOAuth:
return "Claude OAuth credentials missing. Run `claude` to authenticate."
case .mcpOAuthOnlyKeychain:
return "Claude keychain contains MCP OAuth state only (no claudeAiOauth). "
+ "Claude Code may store subscription OAuth elsewhere now. "
+ "Open the CodexBar menu and click Refresh to re-authenticate, "
+ "or switch Claude Usage source to Web/CLI."
case .missingAccessToken:
return "Claude OAuth access token missing. Run `claude` to authenticate."
case .notFound:
return "Claude OAuth credentials not found. Run `claude` to authenticate."
case let .keychainError(status):
#if os(macOS)
if status == Int(errSecUserCanceled)
|| status == Int(errSecAuthFailed)
|| status == Int(errSecInteractionNotAllowed)
|| status == Int(errSecNoAccessForItem)
{
return "Claude Keychain access was denied. CodexBar will back off in the background until you retry "
+ "via a user action (menu open / manual refresh). "
+ "Switch Claude Usage source to Web/CLI, or allow access in Keychain Access."
}
#endif
return "Claude OAuth keychain error: \(status)"
case let .readFailed(message):
return "Claude OAuth credentials read failed: \(message)"
case let .refreshFailed(message):
return "Claude OAuth token refresh failed: \(message)"
case .noRefreshToken:
return "Claude OAuth refresh token missing. Run `claude` to authenticate."
case .refreshDelegatedToClaudeCLI:
return "Claude OAuth refresh is delegated to Claude CLI."
}
}
}