/** * Claude OAuth PKCE flow for Anthropic subscription authentication. * Adapted from CodeDeck's ClaudeOAuthService for server-side Node.js. * * User signs in at claude.ai, receives a code, pastes it back. * Credentials stored in ~/.claude/.credentials.json + macOS Keychain. */ import crypto from 'crypto'; import { execFileSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { log } from '../shared/logger.js'; const OAUTH_CONFIG = { AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', SCOPES: 'org:create_api_key user:profile user:inference', }; const CLAUDE_DIR = path.join(os.homedir(), '.claude'); const CREDENTIALS_FILE = path.join(CLAUDE_DIR, '.credentials.json'); let codeVerifier: string | null = null; /* ── Public API ── */ export function startClaudeOAuth(): { success: boolean; authUrl?: string; error?: string } { // Generate PKCE codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); const params = new URLSearchParams({ code: 'true', client_id: OAUTH_CONFIG.CLIENT_ID, response_type: 'code', redirect_uri: OAUTH_CONFIG.REDIRECT_URI, scope: OAUTH_CONFIG.SCOPES, code_challenge: codeChallenge, code_challenge_method: 'S256', state: codeVerifier, // state = verifier (OpenClaw legacy flow) }); const authUrl = `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}`; log.ok('Claude OAuth flow started'); return { success: true, authUrl }; } export async function exchangeClaudeCode(codeInput: string): Promise<{ success: boolean; error?: string }> { if (!codeVerifier) { return { success: false, error: 'OAuth flow not started. Click "Authenticate" first.' }; } // Parse code — might be "code#state" or just "code" const parts = codeInput.trim().split('#'); const code = parts[0].trim(); const state = parts[1]?.trim() || codeVerifier; if (!code) { return { success: false, error: 'Invalid code. Please copy the full code from the page.' }; } try { // Token exchange uses JSON body (not form-urlencoded) const payload = { grant_type: 'authorization_code', client_id: OAUTH_CONFIG.CLIENT_ID, code, state, redirect_uri: OAUTH_CONFIG.REDIRECT_URI, code_verifier: codeVerifier, }; const response = await fetch(OAUTH_CONFIG.TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { return { success: false, error: `Authentication failed (${response.status}). Please try again.` }; } const tokens = await response.json(); storeCredentials(tokens); codeVerifier = null; return { success: true }; } catch (err: any) { return { success: false, error: err.message }; } } export async function getClaudeAuthStatus(): Promise<{ authenticated: boolean; error?: string }> { const oauth = readOAuthBlock(); if (!oauth) return { authenticated: false }; if (oauth.accessToken && oauth.expiresAt && Date.now() < oauth.expiresAt) { return { authenticated: true }; } // Token expired or missing — try refresh if (oauth.refreshToken) { const refreshed = await refreshClaudeToken(oauth.refreshToken); if (refreshed) return { authenticated: true }; } return { authenticated: false, error: 'Token expired' }; } export function readClaudeAccessToken(): string | null { const oauth = readOAuthBlock(); if (!oauth?.accessToken) return null; if (oauth.expiresAt && Date.now() >= oauth.expiresAt) return null; return oauth.accessToken; } /** Read a valid access token, refreshing if expired. Returns null if unavailable. */ export async function getClaudeAccessToken(): Promise { const oauth = readOAuthBlock(); if (!oauth?.accessToken) return null; // Token still valid if (oauth.expiresAt && Date.now() < oauth.expiresAt) { return oauth.accessToken; } // Try refresh if (oauth.refreshToken) { const refreshed = await refreshClaudeToken(oauth.refreshToken); if (refreshed) { // Re-read after refresh const fresh = readOAuthBlock(); return fresh?.accessToken || null; } } return null; } /* ── Helpers ── */ /** * Read the OAuth block from the most reliable source. * On macOS: Keychain is the source of truth (Claude Code reads/writes there on refresh). * If Keychain has no valid entry, stale files are not trusted. * On Linux/Windows: credentials file is the source of truth. * Handles both formats: * - Claude Code format: { claudeAiOauth: { accessToken, refreshToken, expiresAt, ... } } * - Legacy flat format: { accessToken, refreshToken, expiresAt } */ function readOAuthBlock(): { accessToken?: string; refreshToken?: string; expiresAt?: number } | null { // macOS: Keychain is the source of truth if (process.platform === 'darwin') { try { const result = execFileSync('security', [ 'find-generic-password', '-s', 'Claude Code-credentials', '-w', ], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); const parsed = JSON.parse(result); const oauth = parsed.claudeAiOauth || parsed; if (oauth.accessToken) { log.ok('Read credentials from macOS Keychain'); return oauth; } } catch {} // On macOS, if Keychain has no valid entry, don't trust stale files log.warn('No valid credentials in macOS Keychain'); return null; } // Linux/Windows: credentials file try { if (fs.existsSync(CREDENTIALS_FILE)) { const creds = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8')); const oauth = creds.claudeAiOauth || creds; if (oauth.accessToken) return oauth; } } catch {} return null; } /** * Refresh an expired token using the refresh_token grant. * Returns true if refresh succeeded and new credentials were stored. */ async function refreshClaudeToken(refreshToken: string): Promise { try { log.ok('Attempting Claude token refresh...'); const response = await fetch(OAUTH_CONFIG.TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', client_id: OAUTH_CONFIG.CLIENT_ID, refresh_token: refreshToken, }), }); if (!response.ok) { log.warn(`Claude token refresh failed: ${response.status}`); return false; } const tokens = await response.json(); storeCredentials(tokens); log.ok('Claude token refreshed successfully'); return true; } catch (err: any) { log.warn(`Claude token refresh error: ${err.message}`); return false; } } function storeCredentials(tokens: any): void { if (!fs.existsSync(CLAUDE_DIR)) { fs.mkdirSync(CLAUDE_DIR, { recursive: true }); } // Read existing file to preserve other fields let fileData: Record = {}; try { if (fs.existsSync(CREDENTIALS_FILE)) { fileData = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8')); } } catch {} // Build oauth block — merge with existing claudeAiOauth to preserve scopes etc. const existing = fileData.claudeAiOauth || {}; const oauth: Record = { ...existing }; oauth.accessToken = tokens.access_token; if (tokens.refresh_token) oauth.refreshToken = tokens.refresh_token; if (tokens.expires_in) { oauth.expiresAt = Date.now() + (tokens.expires_in - 300) * 1000; } // Write in Claude Code's format fileData.claudeAiOauth = oauth; // Remove legacy flat keys if they exist delete fileData.accessToken; delete fileData.refreshToken; delete fileData.expiresAt; fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(fileData, null, 2), 'utf-8'); try { fs.chmodSync(CREDENTIALS_FILE, 0o600); } catch {} log.ok('Claude credentials stored'); // macOS: also write to Keychain if (process.platform === 'darwin') { try { const keychainValue = JSON.stringify({ claudeAiOauth: oauth }); try { execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'], { stdio: ['pipe', 'pipe', 'pipe'], }); } catch {} // OK if entry doesn't exist execFileSync('security', [ 'add-generic-password', '-s', 'Claude Code-credentials', '-a', os.userInfo().username, '-w', keychainValue, ], { stdio: ['pipe', 'pipe', 'pipe'] }); } catch {} } // Legacy fallback (~/.claude.json) try { const legacyPath = path.join(os.homedir(), '.claude.json'); let legacyConfig: Record = {}; try { if (fs.existsSync(legacyPath)) { legacyConfig = JSON.parse(fs.readFileSync(legacyPath, 'utf-8')); } } catch {} legacyConfig.oauthAccessToken = tokens.access_token; legacyConfig.hasCompletedOnboarding = true; fs.writeFileSync(legacyPath, JSON.stringify(legacyConfig, null, 2), 'utf-8'); try { fs.chmodSync(legacyPath, 0o600); } catch {} } catch {} }