/** * Step: login — Sign in to Claude (Pro/Max) via OAuth PKCE manual flow. * * Mirrors the URL Claude Code's `claude auth login --claudeai` actually * builds (captured from the official CLI). This is the manual / paste-code * variant: the user visits the authorize URL, completes login in their * browser, and pastes the code back. We exchange it for tokens and write * `~/.claude/.credentials.json`. * * Usage: * bun setup/index.ts --step login # phase 1: print authorize URL * bun setup/index.ts --step login --code # phase 2: exchange the code * * Phase 1 stashes the PKCE verifier + state in /tmp/ejclaw-claude-login.json * (mode 0600). Phase 2 reads it back, exchanges the code, writes credentials. * * This step is run by hand for re-auth. Once `.credentials.json` exists, the * existing token-refresh loop (src/token-refresh.ts) keeps it fresh. */ import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; const AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize'; const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; const REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback'; const SCOPES = [ 'org:create_api_key', 'user:profile', 'user:inference', 'user:sessions:claude_code', 'user:mcp_servers', 'user:file_upload', ]; const STATE_FILE = path.join(os.tmpdir(), 'ejclaw-claude-login.json'); const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json'); interface PendingState { verifier: string; state: string; createdAt: number; } interface ExchangeResponse { access_token: string; refresh_token?: string; expires_in: number; scope?: string; account?: { email_address?: string; has_claude_max?: boolean; has_claude_pro?: boolean; }; } function base64url(buf: Buffer): string { return buf .toString('base64') .replace(/=+$/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function generatePkce(): { verifier: string; challenge: string } { const verifier = base64url(crypto.randomBytes(32)); const challenge = base64url( crypto.createHash('sha256').update(verifier).digest(), ); return { verifier, challenge }; } function buildAuthorizeUrl(state: string, challenge: string): string { const url = new URL(AUTHORIZE_URL); url.searchParams.append('code', 'true'); url.searchParams.append('client_id', CLIENT_ID); url.searchParams.append('response_type', 'code'); url.searchParams.append('redirect_uri', REDIRECT_URI); url.searchParams.append('scope', SCOPES.join(' ')); url.searchParams.append('code_challenge', challenge); url.searchParams.append('code_challenge_method', 'S256'); url.searchParams.append('state', state); return url.toString(); } function writePendingState(p: PendingState): void { fs.writeFileSync(STATE_FILE, JSON.stringify(p), { mode: 0o600 }); } function readPendingState(): PendingState | null { if (!fs.existsSync(STATE_FILE)) return null; try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PendingState; } catch { return null; } } async function exchangeCode( code: string, verifier: string, state: string, ): Promise { // The success page can hand the user a value of `code#state` or just // `code` — accept both. const [bareCode, embeddedState] = code.split('#'); const stateForExchange = embeddedState || state; const body = JSON.stringify({ grant_type: 'authorization_code', code: bareCode, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, state: stateForExchange, }); const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `Token exchange failed (${res.status}): ${text.slice(0, 400)}`, ); } return (await res.json()) as ExchangeResponse; } function writeCredentials(resp: ExchangeResponse): void { const expiresAt = Date.now() + resp.expires_in * 1000; const creds = { claudeAiOauth: { accessToken: resp.access_token, refreshToken: resp.refresh_token || '', expiresAt, scopes: resp.scope ? resp.scope.split(' ') : SCOPES, subscriptionType: resp.account?.has_claude_max ? 'max' : resp.account?.has_claude_pro ? 'pro' : '', }, }; const dir = path.dirname(CREDS_PATH); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const tmp = `${CREDS_PATH}.tmp`; fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 }); fs.renameSync(tmp, CREDS_PATH); } interface Args { code: string | null; } function parseArgs(args: string[]): Args { let code: string | null = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--code' && args[i + 1]) { code = args[++i]; } } return { code }; } export async function run(args: string[]): Promise { const { code } = parseArgs(args); if (!code) { // Phase 1: produce the authorize URL. const { verifier, challenge } = generatePkce(); const state = base64url(crypto.randomBytes(32)); writePendingState({ verifier, state, createdAt: Date.now() }); const url = buildAuthorizeUrl(state, challenge); console.log('AUTHORIZE_URL=' + url); console.log( 'NEXT: open the URL above in your browser, complete the login,' + ' and re-run this command with --code .', ); emitStatus('LOGIN_URL', { STATUS: 'awaiting_code', URL: url }); return; } // Phase 2: exchange the code. const pending = readPendingState(); if (!pending) { emitStatus('LOGIN', { STATUS: 'failed', ERROR: 'no_pending_state — run phase 1 first', }); process.exit(4); } if (Date.now() - pending.createdAt > 30 * 60 * 1000) { logger.warn( 'Pending login state is older than 30 minutes — proceeding anyway', ); } try { const resp = await exchangeCode(code, pending.verifier, pending.state); writeCredentials(resp); fs.unlinkSync(STATE_FILE); const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort(); logger.info( { scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) }, 'Wrote ~/.claude/.credentials.json', ); emitStatus('LOGIN', { STATUS: 'success', CREDENTIALS_PATH: CREDS_PATH, SCOPES: newScopes.join(','), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error({ err: message }, 'Token exchange failed'); emitStatus('LOGIN', { STATUS: 'failed', ERROR: message }); process.exit(4); } }