Files
clawdbot/apps/macos/Sources/Clawdbot/AnthropicOAuth.swift
2026-01-04 14:38:51 +00:00

385 lines
15 KiB
Swift

import CryptoKit
import Foundation
import OSLog
import Security
struct AnthropicOAuthCredentials: Codable {
let type: String
let refresh: String
let access: String
let expires: Int64
}
enum AnthropicAuthMode: Equatable {
case oauthFile
case oauthEnv
case apiKeyEnv
case missing
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Clawdbot token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
}
}
var isConfigured: Bool {
switch self {
case .missing: false
case .oauthFile, .oauthEnv, .apiKeyEnv: true
}
}
}
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: ClawdbotOAuthStore.AnthropicOAuthStatus = ClawdbotOAuthStore
.anthropicOAuthStatus()) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return .oauthEnv
}
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!key.isEmpty
{
return .apiKeyEnv
}
return .missing
}
}
enum AnthropicOAuth {
private static let logger = Logger(subsystem: "com.clawdbot", category: "anthropic-oauth")
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
private static let scopes = "org:create_api_key user:profile user:inference"
struct PKCE {
let verifier: String
let challenge: String
}
static func generatePKCE() throws -> PKCE {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
let verifier = Data(bytes).base64URLEncodedString()
let hash = SHA256.hash(data: Data(verifier.utf8))
let challenge = Data(hash).base64URLEncodedString()
return PKCE(verifier: verifier, challenge: challenge)
}
static func buildAuthorizeURL(pkce: PKCE) -> URL {
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "code", value: "true"),
URLQueryItem(name: "client_id", value: self.clientId),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
URLQueryItem(name: "scope", value: self.scopes),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
// Match legacy flow: state is the verifier.
URLQueryItem(name: "state", value: pkce.verifier),
]
return components.url!
}
static func exchangeCode(
code: String,
state: String,
verifier: String) async throws -> AnthropicOAuthCredentials
{
let payload: [String: Any] = [
"grant_type": "authorization_code",
"client_id": self.clientId,
"code": code,
"state": state,
"redirect_uri": self.redirectURI,
"code_verifier": verifier,
]
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
var request = URLRequest(url: self.tokenURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
throw NSError(
domain: "AnthropicOAuth",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
}
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let access = decoded?["access_token"] as? String
let refresh = decoded?["refresh_token"] as? String
let expiresIn = decoded?["expires_in"] as? Double
guard let access, let refresh, let expiresIn else {
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected token response.",
])
}
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
let payload: [String: Any] = [
"grant_type": "refresh_token",
"client_id": self.clientId,
"refresh_token": refreshToken,
]
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
var request = URLRequest(url: self.tokenURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
throw NSError(
domain: "AnthropicOAuth",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
}
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let access = decoded?["access_token"] as? String
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
let expiresIn = decoded?["expires_in"] as? Double
guard let access, let expiresIn else {
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected token response.",
])
}
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
}
enum ClawdbotOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let clawdbotOAuthDirEnv = "CLAWDBOT_OAUTH_DIR"
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
case unreadableFile
case invalidJSON
case missingProviderEntry
case missingTokens
case connected(expiresAtMs: Int64?)
var isConnected: Bool {
if case .connected = self { return true }
return false
}
var shortDescription: String {
switch self {
case .missingFile: "Clawdbot OAuth token file not found"
case .unreadableFile: "Clawdbot OAuth token file not readable"
case .invalidJSON: "Clawdbot OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Clawdbot OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Clawdbot OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
}
static func oauthURL() -> URL {
self.oauthDir().appendingPathComponent(self.oauthFilename)
}
static func legacyOAuthURLs() -> [URL] {
var urls: [URL] = []
let env = ProcessInfo.processInfo.environment
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
var seen = Set<String>()
return urls.filter { url in
let path = url.standardizedFileURL.path
if seen.contains(path) { return false }
seen.insert(path)
return true
}
}
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue }
do {
try self.saveStorage(storage)
return url
} catch {
continue
}
}
return nil
}
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
self.anthropicOAuthStatus(at: self.oauthURL())
}
static func hasAnthropicOAuth() -> Bool {
self.anthropicOAuthStatus().isConnected
}
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
guard let storage = json as? [String: Any] else { return .invalidJSON }
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
ms
} else if let number = expiresAny as? NSNumber {
number.int64Value
} else if let ms = expiresAny as? Double {
Int64(ms)
} else {
nil
}
return .connected(expiresAtMs: expiresAtMs)
}
static func loadAnthropicOAuthRefreshToken() -> String? {
let url = self.oauthURL()
guard let storage = self.loadStorage(at: url) else { return nil }
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String { return value }
}
return nil
}
private static func loadStorage(at url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
return json as? [String: Any]
}
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
let url = self.oauthURL()
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
var updated = existing
updated[self.providerKey] = [
"type": creds.type,
"refresh": creds.refresh,
"access": creds.access,
"expires": creds.expires,
]
try self.saveStorage(updated)
}
private static func saveStorage(_ storage: [String: Any]) throws {
let dir = self.oauthDir()
try FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
let url = self.oauthURL()
let data = try JSONSerialization.data(
withJSONObject: storage,
options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
}
extension Data {
fileprivate func base64URLEncodedString() -> String {
self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}