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) ?? "" 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) ?? "" 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() 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: "") } }