diff --git a/src/index.ts b/src/index.ts index 45db441..4fad071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,10 @@ import { logger } from './logger.js'; import { normalizeStoredSeqCursor } from './message-cursor.js'; import { initCodexTokenRotation } from './codex-token-rotation.js'; import { initTokenRotation } from './token-rotation.js'; +import { + startTokenRefreshLoop, + stopTokenRefreshLoop, +} from './token-refresh.js'; // Re-export for backwards compatibility during refactor export { escapeXml, formatMessages } from './router.js'; @@ -299,11 +303,16 @@ async function main(): Promise { logger.info('Database initialized'); initTokenRotation(); initCodexTokenRotation(); + + // Start OAuth token auto-refresh (before subsystems that spawn agents) + startTokenRefreshLoop(); + loadState(); // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); + stopTokenRefreshLoop(); const interruptedGroups = queue .getStatuses(Object.keys(registeredGroups)) .filter( diff --git a/src/token-refresh.ts b/src/token-refresh.ts new file mode 100644 index 0000000..eb6ecfa --- /dev/null +++ b/src/token-refresh.ts @@ -0,0 +1,375 @@ +/** + * 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 path from 'path'; + +import { logger } from './logger.js'; +import { DATA_DIR } from './config.js'; +import { getAllTokens, updateTokenValue } from './token-rotation.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; + +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); + try { + if (!fs.existsSync(credsPath)) return null; + return JSON.parse(fs.readFileSync(credsPath, 'utf-8')); + } catch (err) { + logger.warn({ err, accountIndex }, 'Failed to read Claude credentials'); + return null; + } +} + +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: err instanceof Error ? err.message : String(err) }, + 'Failed to sync credentials to sessions', + ); + } +} + +/** + * Update CLAUDE_CODE_OAUTH_TOKENS in .env so refreshed tokens survive restarts. + */ +function updateEnvTokens(): void { + const envFile = path.join(process.cwd(), '.env'); + try { + if (!fs.existsSync(envFile)) return; + let content = fs.readFileSync(envFile, 'utf-8'); + + const allTokens = getAllTokens(); + if (allTokens.length === 0) return; + + const newValue = allTokens.map((t) => t.token).join(','); + + // Replace existing CLAUDE_CODE_OAUTH_TOKENS line, or append if missing + const tokenLineRe = /^CLAUDE_CODE_OAUTH_TOKENS=.*/m; + if (tokenLineRe.test(content)) { + content = content.replace( + tokenLineRe, + `CLAUDE_CODE_OAUTH_TOKENS=${newValue}`, + ); + } else { + // Also update the single-token var if present + const singleLineRe = /^CLAUDE_CODE_OAUTH_TOKEN=.*/m; + if (singleLineRe.test(content)) { + content = content.replace( + singleLineRe, + `CLAUDE_CODE_OAUTH_TOKEN=${allTokens[0].token}`, + ); + } + } + + const tempPath = `${envFile}.tmp`; + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, envFile); + logger.debug('Updated .env with refreshed tokens'); + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'Failed to update .env with refreshed tokens', + ); + } +} + +async function refreshToken( + refreshTokenStr: string, + scopes: string[], +): Promise { + 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: err instanceof Error ? err.message : String(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 { + 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: err instanceof Error ? err.message : String(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 { + 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) { + updateEnvTokens(); + } +} + +let refreshInterval: ReturnType | 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 { + 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(); + } + } + return newAccessToken; +} diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 664ceb8..d2836b2 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -208,6 +208,22 @@ export function getAllTokens(): { })); } +/** Update the access token value for a specific index (after OAuth refresh). */ +export function updateTokenValue(index: number, newAccessToken: string): void { + if (index < 0 || index >= tokens.length) { + logger.warn( + { index, total: tokens.length }, + 'updateTokenValue: index out of range', + ); + return; + } + tokens[index].token = newAccessToken; + logger.info( + { index, masked: `${newAccessToken.slice(0, 20)}...${newAccessToken.slice(-4)}` }, + 'Token value updated after OAuth refresh', + ); +} + /** Diagnostic info. */ export function getTokenRotationInfo(): { total: number;