feat: restore OAuth token auto-refresh with multi-account support
Restores token-refresh.ts that was deleted in 35356dd. Now works
with multi-account token rotation:
- Refreshes tokens from ~/.claude/.credentials.json (account 0)
and ~/.claude-accounts/{n}/.credentials.json (account 1+)
- Updates token-rotation memory and .env on refresh
- Syncs credentials to session directories
- 5-min check interval, refreshes 30 min before expiry
- forceRefreshToken() export for 401 recovery
This commit is contained in:
@@ -65,6 +65,10 @@ import { logger } from './logger.js';
|
|||||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||||
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
||||||
import { initTokenRotation } from './token-rotation.js';
|
import { initTokenRotation } from './token-rotation.js';
|
||||||
|
import {
|
||||||
|
startTokenRefreshLoop,
|
||||||
|
stopTokenRefreshLoop,
|
||||||
|
} from './token-refresh.js';
|
||||||
|
|
||||||
// Re-export for backwards compatibility during refactor
|
// Re-export for backwards compatibility during refactor
|
||||||
export { escapeXml, formatMessages } from './router.js';
|
export { escapeXml, formatMessages } from './router.js';
|
||||||
@@ -299,11 +303,16 @@ async function main(): Promise<void> {
|
|||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
initTokenRotation();
|
initTokenRotation();
|
||||||
initCodexTokenRotation();
|
initCodexTokenRotation();
|
||||||
|
|
||||||
|
// Start OAuth token auto-refresh (before subsystems that spawn agents)
|
||||||
|
startTokenRefreshLoop();
|
||||||
|
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
// Graceful shutdown handlers
|
// Graceful shutdown handlers
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
|
stopTokenRefreshLoop();
|
||||||
const interruptedGroups = queue
|
const interruptedGroups = queue
|
||||||
.getStatuses(Object.keys(registeredGroups))
|
.getStatuses(Object.keys(registeredGroups))
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
375
src/token-refresh.ts
Normal file
375
src/token-refresh.ts
Normal file
@@ -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<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: 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<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: 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<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) {
|
||||||
|
updateEnvTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newAccessToken;
|
||||||
|
}
|
||||||
@@ -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. */
|
/** Diagnostic info. */
|
||||||
export function getTokenRotationInfo(): {
|
export function getTokenRotationInfo(): {
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user