Files
EJClaw/src/token-refresh.ts
Eyejoker 47d532e869 fix: sync refreshed OAuth tokens to process.env for container exec
Token refresh updated internal array and .env file but not process.env,
so docker exec -e kept injecting the stale startup token. Now syncs
to process.env after refresh. Container kill on refresh is no longer
needed since docker exec picks up the latest token each turn.
2026-03-31 13:34:23 +09:00

430 lines
12 KiB
TypeScript

/**
* OAuth Token Auto-Refresh for Claude Code (Multi-Account)
*
* Periodically checks credentials files and refreshes access tokens
* before they expire. Supports multiple accounts via CLAUDE_CODE_OAUTH_TOKENS.
*
* Account credential paths:
* - Account 0 (default): ~/.claude/.credentials.json
* - Account 1+: ~/.claude-accounts/{index}/.credentials.json
*
* After refresh, updates both the in-memory token rotation state and
* the .env file so new tokens survive restarts.
*/
import fs from 'fs';
import os from 'os';
import { getErrorMessage, readJsonFile } from './utils.js';
import path from 'path';
import { logger } from './logger.js';
import { DATA_DIR } from './config.js';
import { getAllTokens, updateTokenValue } from './token-rotation.js';
import type { AgentType } from './types.js';
const TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const DEFAULT_SCOPES = [
'user:profile',
'user:inference',
'user:sessions:claude_code',
'user:mcp_servers',
];
// Check every 5 minutes, refresh if within 30 minutes of expiry
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
const REFRESH_BEFORE_EXPIRY_MS = 30 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 15_000;
export function shouldStartTokenRefreshLoop(
serviceAgentType: AgentType,
): boolean {
return serviceAgentType === 'claude-code';
}
interface OAuthCredentials {
accessToken: string;
refreshToken: string;
expiresAt: number;
scopes: string[];
subscriptionType?: string;
rateLimitTier?: string;
}
interface CredentialsFile {
claudeAiOauth: OAuthCredentials;
}
interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
scope?: string;
}
/**
* Get the credentials file path for a given account index.
* - Index 0: ~/.claude/.credentials.json
* - Index 1+: ~/.claude-accounts/{index}/.credentials.json
*/
function getCredentialsPath(accountIndex: number): string {
if (accountIndex === 0) {
return path.join(os.homedir(), '.claude', '.credentials.json');
}
return path.join(
os.homedir(),
'.claude-accounts',
String(accountIndex),
'.credentials.json',
);
}
function readCredentials(accountIndex: number): CredentialsFile | null {
const credsPath = getCredentialsPath(accountIndex);
if (!fs.existsSync(credsPath)) return null;
const data = readJsonFile<CredentialsFile>(credsPath);
if (!data) {
logger.warn({ accountIndex }, 'Failed to read Claude credentials');
}
return data;
}
function writeCredentials(accountIndex: number, creds: CredentialsFile): void {
const credsPath = getCredentialsPath(accountIndex);
const dir = path.dirname(credsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
const tempPath = `${credsPath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, credsPath);
// Sync to all per-group session directories so running agents pick up the new token
syncToSessionDirs(credsPath);
}
function syncToSessionDirs(credsPath: string): void {
const sessionsDir = path.join(DATA_DIR, 'sessions');
try {
if (!fs.existsSync(sessionsDir)) return;
const groups = fs.readdirSync(sessionsDir);
let synced = 0;
for (const group of groups) {
const dest = path.join(
sessionsDir,
group,
'.claude',
'.credentials.json',
);
if (fs.existsSync(path.dirname(dest))) {
fs.copyFileSync(credsPath, dest);
synced++;
}
}
if (synced > 0) {
logger.info(
{ count: synced },
'Synced credentials to session directories',
);
}
} catch (err) {
logger.warn(
{ err: getErrorMessage(err) },
'Failed to sync credentials to sessions',
);
}
}
/**
* Update CLAUDE_CODE_OAUTH_TOKENS in .env so refreshed tokens survive restarts.
*/
export function applyUpdatedTokensToEnvContent(
content: string,
tokens: string[],
): string {
if (tokens.length === 0) return content;
const multiValue = tokens.join(',');
const multiLineRe = /^CLAUDE_CODE_OAUTH_TOKENS=.*/m;
const singleLineRe = /^CLAUDE_CODE_OAUTH_TOKEN=.*/m;
let next = content;
if (multiLineRe.test(next)) {
next = next.replace(multiLineRe, `CLAUDE_CODE_OAUTH_TOKENS=${multiValue}`);
} else {
next = `${next.replace(/\s*$/, '')}\nCLAUDE_CODE_OAUTH_TOKENS=${multiValue}\n`;
}
if (singleLineRe.test(next)) {
next = next.replace(singleLineRe, `CLAUDE_CODE_OAUTH_TOKEN=${tokens[0]}`);
}
return next;
}
function updateEnvTokens(): void {
const envFile = path.join(process.cwd(), '.env');
try {
if (!fs.existsSync(envFile)) return;
const content = fs.readFileSync(envFile, 'utf-8');
const allTokens = getAllTokens();
if (allTokens.length === 0) return;
const nextContent = applyUpdatedTokensToEnvContent(
content,
allTokens.map((t) => t.token),
);
const tempPath = `${envFile}.tmp`;
fs.writeFileSync(tempPath, nextContent, { mode: 0o600 });
fs.renameSync(tempPath, envFile);
logger.debug('Updated .env with refreshed tokens');
} catch (err) {
logger.warn(
{ err: getErrorMessage(err) },
'Failed to update .env with refreshed tokens',
);
}
}
async function refreshToken(
refreshTokenStr: string,
scopes: string[],
): Promise<TokenResponse> {
const body = JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshTokenStr,
client_id: CLIENT_ID,
scope: (scopes.length > 0 ? scopes : DEFAULT_SCOPES).join(' '),
});
const headers = { 'Content-Type': 'application/json' };
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 200) {
return (await res.json()) as TokenResponse;
}
const errText = await res.text().catch(() => '');
logger.warn(
{ url: TOKEN_URL, status: res.status, body: errText.slice(0, 200) },
'Token refresh failed at endpoint',
);
} catch (err) {
logger.warn(
{ url: TOKEN_URL, err: getErrorMessage(err) },
'Token refresh request error',
);
}
throw new Error('Token refresh failed');
}
/**
* Check and refresh a single account's credentials.
* Returns the new access token if refreshed, null otherwise.
*/
async function checkAndRefreshAccount(
accountIndex: number,
): Promise<string | null> {
const creds = readCredentials(accountIndex);
if (!creds?.claudeAiOauth) return null;
const { expiresAt, refreshToken: rt } = creds.claudeAiOauth;
if (!rt) {
logger.debug({ accountIndex }, 'No refresh token in credentials, skipping');
return null;
}
const now = Date.now();
const remaining = expiresAt - now;
if (remaining > REFRESH_BEFORE_EXPIRY_MS) {
logger.debug(
{ accountIndex, remainingMin: Math.round(remaining / 60000) },
'Token still valid, no refresh needed',
);
return null;
}
const isExpired = remaining <= 0;
logger.info(
{ accountIndex, remainingMin: Math.round(remaining / 60000), isExpired },
'Refreshing Claude OAuth token',
);
try {
const response = await refreshToken(
rt,
creds.claudeAiOauth.scopes || DEFAULT_SCOPES,
);
creds.claudeAiOauth.accessToken = response.access_token;
creds.claudeAiOauth.refreshToken = response.refresh_token || rt;
creds.claudeAiOauth.expiresAt = now + response.expires_in * 1000;
if (response.scope) {
creds.claudeAiOauth.scopes = response.scope.split(' ');
}
writeCredentials(accountIndex, creds);
const newExpiryMin = Math.round(response.expires_in / 60);
logger.info(
{ accountIndex, expiresInMin: newExpiryMin },
'Claude OAuth token refreshed successfully',
);
return response.access_token;
} catch (err) {
logger.error(
{
accountIndex,
err: getErrorMessage(err),
},
'Failed to refresh Claude OAuth token — manual re-login may be required',
);
return null;
}
}
/**
* Check and refresh all accounts, updating token rotation and .env.
*/
async function checkAndRefreshAll(): Promise<void> {
const allTokens = getAllTokens();
const accountCount = Math.max(allTokens.length, 1);
let anyRefreshed = false;
for (let i = 0; i < accountCount; i++) {
const newAccessToken = await checkAndRefreshAccount(i);
if (newAccessToken && i < allTokens.length) {
updateTokenValue(i, newAccessToken);
anyRefreshed = true;
}
}
if (anyRefreshed) {
// Sync refreshed tokens to process.env so docker exec picks them up
const refreshedTokens = getAllTokens();
if (refreshedTokens.length > 0) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = refreshedTokens[0].token;
if (refreshedTokens.length > 1) {
process.env.CLAUDE_CODE_OAUTH_TOKENS = refreshedTokens
.map((t) => t.token)
.join(',');
}
}
updateEnvTokens();
if (onTokenRefreshedCallback) {
try {
onTokenRefreshedCallback();
} catch (err) {
logger.warn(
{ err: getErrorMessage(err) },
'Post-token-refresh callback failed',
);
}
}
}
}
// ── Post-refresh callback ───────────────────────────────────────
// Allows callers (e.g. index.ts) to register a hook that runs after
// any token is refreshed — used to recreate reviewer containers whose
// persistent Claude Code SDK process still holds the old token.
type TokenRefreshCallback = () => void;
let onTokenRefreshedCallback: TokenRefreshCallback | null = null;
/** Register a callback to be invoked after a successful token refresh. */
export function onTokenRefreshed(cb: TokenRefreshCallback): void {
onTokenRefreshedCallback = cb;
}
let refreshInterval: ReturnType<typeof setInterval> | null = null;
export function startTokenRefreshLoop(): void {
const allTokens = getAllTokens();
// Check if any credentials files exist (for any configured account)
const accountCount = Math.max(allTokens.length, 1);
let hasAnyCreds = false;
for (let i = 0; i < accountCount; i++) {
const creds = readCredentials(i);
if (creds?.claudeAiOauth) {
hasAnyCreds = true;
break;
}
}
if (!hasAnyCreds) {
logger.info('No OAuth credentials found, token refresh disabled');
return;
}
logger.info(
{ checkIntervalMin: CHECK_INTERVAL_MS / 60000, accountCount },
'Token auto-refresh started',
);
// Check immediately on startup
checkAndRefreshAll().catch((err) =>
logger.error({ err }, 'Initial token refresh check failed'),
);
refreshInterval = setInterval(() => {
checkAndRefreshAll().catch((err) =>
logger.error({ err }, 'Token refresh check failed'),
);
}, CHECK_INTERVAL_MS);
}
export function stopTokenRefreshLoop(): void {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
/**
* Force an immediate token refresh for a specific account index.
* Useful when a 401 is detected — attempt refresh before giving up.
* Returns the new access token if refresh succeeded, null otherwise.
*/
export async function forceRefreshToken(
tokenIndex: number,
): Promise<string | null> {
logger.info({ tokenIndex }, 'Force-refreshing token after 401');
const newAccessToken = await checkAndRefreshAccount(tokenIndex);
if (newAccessToken) {
const allTokens = getAllTokens();
if (tokenIndex < allTokens.length) {
updateTokenValue(tokenIndex, newAccessToken);
updateEnvTokens();
if (onTokenRefreshedCallback) {
try {
onTokenRefreshedCallback();
} catch (err) {
logger.warn(
{ err: getErrorMessage(err) },
'Post-token-refresh callback failed (force refresh)',
);
}
}
}
}
return newAccessToken;
}