302 lines
11 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|