/** * 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(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 { 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, opts?: { force?: boolean }, ): 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 (!opts?.force && 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, force: opts?.force === true, }, '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 { 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 subsequent turns pick 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(); } } 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, { force: true, }); if (newAccessToken) { const allTokens = getAllTokens(); if (tokenIndex < allTokens.length) { updateTokenValue(tokenIndex, newAccessToken); updateEnvTokens(); } } return newAccessToken; }