diff --git a/server.js b/server.js index bed1528..c9edb0e 100644 --- a/server.js +++ b/server.js @@ -21,15 +21,70 @@ const MODELS = { haiku: 'claude-haiku-4-5-20251001', }; -function getAccessToken() { - const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); - const credsPath = path.join(configDir, '.credentials.json'); - try { - const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8')); - return creds?.claudeAiOauth?.accessToken || null; - } catch (e) { - return null; +const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +const CREDS_PATH = path.join(CONFIG_DIR, '.credentials.json'); +const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const REFRESH_SKEW_MS = 5 * 60 * 1000; + +function readCreds() { + try { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); } catch { 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 = [ @@ -90,9 +145,9 @@ app.post('/api/chat', express.json({ limit: '50mb' }), async (req, res) => { content = text; } - const accessToken = getAccessToken(); + const accessToken = await ensureFreshToken(); 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');