backup current stable ejclaw state
This commit is contained in:
228
setup/login.ts
Normal file
228
setup/login.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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 <c> # 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<ExchangeResponse> {
|
||||
// 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<void> {
|
||||
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 <pasted_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user