feat: auto-refresh OAuth token (5min skew, serialized, atomic write)

This commit is contained in:
aedes
2026-04-22 08:36:41 +03:00
parent 7baf0cfa4a
commit 40d7c16af0

View File

@@ -21,15 +21,70 @@ const MODELS = {
haiku: 'claude-haiku-4-5-20251001', haiku: 'claude-haiku-4-5-20251001',
}; };
function getAccessToken() { const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const CREDS_PATH = path.join(CONFIG_DIR, '.credentials.json');
const credsPath = path.join(configDir, '.credentials.json'); const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
try { const REFRESH_SKEW_MS = 5 * 60 * 1000;
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8'));
return creds?.claudeAiOauth?.accessToken || null; function readCreds() {
} catch (e) { try { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); } catch { return null; }
return null; }
function writeCreds(creds) {
const tmp = CREDS_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2));
fs.renameSync(tmp, CREDS_PATH);
}
async function refreshTokens(refreshToken) {
const resp = await fetch('https://console.anthropic.com/v1/oauth/token', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-cli/2.1.114 (external, cli)',
'Accept': '*/*',
'anthropic-beta': 'oauth-2025-04-20',
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
}),
});
if (!resp.ok) throw new Error(`refresh ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
return resp.json();
}
let refreshInFlight = null;
async function ensureFreshToken() {
const creds = readCreds();
if (!creds?.claudeAiOauth) return null;
const t = creds.claudeAiOauth;
if (t.expiresAt && t.expiresAt > Date.now() + REFRESH_SKEW_MS) return t.accessToken;
// Serialize concurrent refreshes
if (!refreshInFlight) {
refreshInFlight = (async () => {
// Re-read: another fetch may have refreshed while we waited
const latest = readCreds();
if (latest.claudeAiOauth.expiresAt > Date.now() + REFRESH_SKEW_MS) return;
console.log('[oauth] refreshing token…');
const r = await refreshTokens(latest.claudeAiOauth.refreshToken);
latest.claudeAiOauth.accessToken = r.access_token;
latest.claudeAiOauth.refreshToken = r.refresh_token;
latest.claudeAiOauth.expiresAt = Date.now() + r.expires_in * 1000;
writeCreds(latest);
console.log(`[oauth] ✓ refreshed, expires ${new Date(latest.claudeAiOauth.expiresAt).toISOString()}`);
})().finally(() => { refreshInFlight = null; });
} }
try { await refreshInFlight; } catch (e) { console.error('[oauth] refresh failed:', e.message); }
return readCreds()?.claudeAiOauth?.accessToken || null;
}
// keep old sync name for compatibility
function getAccessToken() {
return readCreds()?.claudeAiOauth?.accessToken || null;
} }
const SYSTEM_PROMPT = [ const SYSTEM_PROMPT = [
@@ -90,9 +145,9 @@ app.post('/api/chat', express.json({ limit: '50mb' }), async (req, res) => {
content = text; content = text;
} }
const accessToken = getAccessToken(); const accessToken = await ensureFreshToken();
if (!accessToken) { if (!accessToken) {
return res.status(500).json({ error: 'No credentials in ' + (process.env.CLAUDE_CONFIG_DIR || '~/.claude') }); return res.status(500).json({ error: 'No credentials in ' + CONFIG_DIR });
} }
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');