diff --git a/src/__tests__/profile-token-refresh-route.test.ts b/src/__tests__/profile-token-refresh-route.test.ts new file mode 100644 index 00000000..62c7d646 --- /dev/null +++ b/src/__tests__/profile-token-refresh-route.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createProxyServer } from "../proxy/server" +import { resetInflightRefresh, stopBackgroundRefresh } from "../proxy/tokenRefresh" + +const TOKEN_RESPONSE = { + access_token: "new-profile-access-token", + refresh_token: "new-profile-refresh-token", + expires_in: 3600, +} + +function credentials(accessToken: string, refreshToken: string) { + return { + claudeAiOauth: { + accessToken, + refreshToken, + expiresAt: Date.now() - 1000, + subscriptionType: "max", + }, + } +} + +describe("profile-scoped token refresh route", () => { + let originalFetch: typeof globalThis.fetch + let tempDir: string + + beforeEach(() => { + originalFetch = globalThis.fetch + tempDir = mkdtempSync(join(tmpdir(), "meridian-profile-refresh-")) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + resetInflightRefresh() + stopBackgroundRefresh() + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("refreshes the requested profile credentials instead of the default store", async () => { + const personalDir = join(tempDir, "personal") + const workDir = join(tempDir, "work") + mkdirSync(personalDir, { recursive: true }) + mkdirSync(workDir, { recursive: true }) + writeFileSync(join(personalDir, ".credentials.json"), JSON.stringify(credentials("personal-old", "personal-refresh"))) + writeFileSync(join(workDir, ".credentials.json"), JSON.stringify(credentials("work-old", "work-refresh"))) + + const mockFetch: typeof fetch = Object.assign( + async () => new Response(JSON.stringify(TOKEN_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + { preconnect: originalFetch.preconnect } + ) + globalThis.fetch = mockFetch + + const { app } = createProxyServer({ + port: 0, + host: "127.0.0.1", + profiles: [ + { id: "personal", claudeConfigDir: personalDir }, + { id: "work", claudeConfigDir: workDir }, + ], + defaultProfile: "personal", + silent: true, + }) + + const res = await app.fetch(new Request("http://localhost/auth/refresh", { + method: "POST", + headers: { "x-meridian-profile": "work" }, + })) + const body = await res.json() as { success?: boolean; profile?: string } + + expect(res.status).toBe(200) + expect(body).toMatchObject({ success: true, profile: "work" }) + expect(JSON.parse(readFileSync(join(workDir, ".credentials.json"), "utf-8")).claudeAiOauth.accessToken).toBe("new-profile-access-token") + expect(JSON.parse(readFileSync(join(personalDir, ".credentials.json"), "utf-8")).claudeAiOauth.accessToken).toBe("personal-old") + }) +}) diff --git a/src/__tests__/token-refresh.test.ts b/src/__tests__/token-refresh.test.ts index 0a8fb989..2d147370 100644 --- a/src/__tests__/token-refresh.test.ts +++ b/src/__tests__/token-refresh.test.ts @@ -229,6 +229,50 @@ describe("refreshOAuthToken", () => { expect(fetchCount).toBe(1) }) + it("deduplicates distinct store instances with the same refreshKey", async () => { + const first = makeStore().store + const second = makeStore().store + first.refreshKey = "file:/tmp/same-profile/.credentials.json" + second.refreshKey = "file:/tmp/same-profile/.credentials.json" + let fetchCount = 0 + mockFetch(mock(async () => { + fetchCount++ + return makeSuccessResponse(MOCK_TOKEN_RESPONSE) + })) + const { refreshOAuthToken } = await import("../proxy/tokenRefresh") + + const [r1, r2] = await Promise.all([ + refreshOAuthToken(first), + refreshOAuthToken(second), + ]) + + expect(r1).toBe(true) + expect(r2).toBe(true) + expect(fetchCount).toBe(1) + }) + + it("does not share in-flight refreshes across different refreshKeys", async () => { + const first = makeStore().store + const second = makeStore().store + first.refreshKey = "file:/tmp/personal/.credentials.json" + second.refreshKey = "file:/tmp/work/.credentials.json" + let fetchCount = 0 + mockFetch(mock(async () => { + fetchCount++ + return makeSuccessResponse(MOCK_TOKEN_RESPONSE) + })) + const { refreshOAuthToken } = await import("../proxy/tokenRefresh") + + const [r1, r2] = await Promise.all([ + refreshOAuthToken(first), + refreshOAuthToken(second), + ]) + + expect(r1).toBe(true) + expect(r2).toBe(true) + expect(fetchCount).toBe(2) + }) + it("allows a second refresh after the first completes", async () => { const { store } = makeStore() let fetchCount = 0 diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 049c8059..bd2298ae 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -44,7 +44,7 @@ import { LRUMap } from "../utils/lruMap" import { telemetryStore, diagnosticLog, createTelemetryRoutes, landingHtml, renderPrometheusMetrics } from "../telemetry" import type { RequestMetric } from "../telemetry" import { classifyError, extractSdkTermination, formatSdkTermination, isStaleSessionError, isRateLimitError, isExtraUsageRequiredError, isExpiredTokenError } from "./errors" -import { refreshOAuthToken, ensureFreshToken, startBackgroundRefresh, stopBackgroundRefresh } from "./tokenRefresh" +import { refreshOAuthToken, ensureFreshToken, startBackgroundRefresh, stopBackgroundRefresh, createPlatformCredentialStore, type CredentialStore } from "./tokenRefresh" import { checkPluginConfigured } from "./setup" import { mapModelToClaudeModel, resolveClaudeExecutableAsync, resolveSdkModelDefaults, isClosedControllerError, getClaudeAuthStatusAsync, getAuthCacheInfo, getResolvedClaudeExecutableInfo, hasExtendedContext, stripExtendedContext, recordExtendedContextUnavailable } from "./models" import type { AnthropicSseEvent } from "./openai" @@ -57,7 +57,7 @@ import { runTransformHook, buildPipeline, createRequestContext } from "./transfo import { getAdapterTransforms } from "./transforms/registry" import { loadPlugins, getActiveTransforms } from "./plugins/loader" import type { LoadedPlugin } from "./plugins/types" -import { resolveProfile, listProfiles, setActiveProfile, getActiveProfileId, getEffectiveProfiles, restoreActiveProfile } from "./profiles" +import { resolveProfile, listProfiles, setActiveProfile, getActiveProfileId, getEffectiveProfiles, restoreActiveProfile, type ResolvedProfile } from "./profiles" import { filterBetasForProfile, getBetaPolicyFromEnv } from "./betas" import { createFileChangeHook, extractFileChangesFromMessages, formatFileChangeSummary, type FileChange } from "./fileChanges" import { detectTokenAnomalies, formatAnomalyAlerts, type TokenSnapshot } from "./tokenHealth" @@ -95,6 +95,24 @@ const exec = promisify(execCallback) let claudeExecutable = "" +function credentialStoreForProfile(profile: ResolvedProfile): CredentialStore | undefined { + if (profile.type !== "claude-max") return undefined + return createPlatformCredentialStore( + profile.env.CLAUDE_CONFIG_DIR ? { claudeConfigDir: profile.env.CLAUDE_CONFIG_DIR } : undefined + ) +} + +async function ensureFreshTokenForProfiles(config: ProxyConfig): Promise { + const profiles = getEffectiveProfiles(config.profiles) + if (profiles.length === 0) return + + for (const profile of profiles) { + const resolved = resolveProfile(config.profiles, config.defaultProfile, profile.id) + const store = credentialStoreForProfile(resolved) + if (store) await ensureFreshToken(store).catch(() => {}) + } +} + const MULTIMODAL_TYPES = new Set(["image", "document", "file"]) function hasMultimodalContent(content: any): boolean { @@ -500,6 +518,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Overlay profile-specific env vars (e.g. CLAUDE_CONFIG_DIR for multi-account) const profileEnv = { ...sdkModelDefaults, ...cleanEnv, ...profile.env } + const profileCredentialStore = credentialStoreForProfile(profile) let systemContext = "" if (body.system) { @@ -980,7 +999,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // of expiry. Best-effort — the reactive 401 path below picks up // anything this misses. Saves a round-trip on the common case // where the previous request left the token close to expiry. - await ensureFreshToken().catch(() => { /* reactive path handles */ }) + if (profileCredentialStore) { + await ensureFreshToken(profileCredentialStore).catch(() => { /* reactive path handles */ }) + } let tokenRefreshed = false while (true) { @@ -1075,7 +1096,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Expired OAuth token: refresh once and retry if (isExpiredTokenError(errMsg) && !tokenRefreshed) { tokenRefreshed = true - const refreshed = await refreshOAuthToken() + const refreshed = profileCredentialStore + ? await refreshOAuthToken(profileCredentialStore) + : false if (refreshed) { claudeLog("token_refresh.retrying", { mode: "non_stream" }) console.error(`[PROXY] ${requestMeta.requestId} OAuth token expired — refreshed, retrying`) @@ -1449,7 +1472,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe let rateLimitRetries = 0 // Proactive token refresh — see non-stream path above. - await ensureFreshToken().catch(() => { /* reactive path handles */ }) + if (profileCredentialStore) { + await ensureFreshToken(profileCredentialStore).catch(() => { /* reactive path handles */ }) + } let tokenRefreshed = false @@ -1537,7 +1562,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Expired OAuth token: refresh once and retry if (isExpiredTokenError(errMsg) && !tokenRefreshed) { tokenRefreshed = true - const refreshed = await refreshOAuthToken() + const refreshed = profileCredentialStore + ? await refreshOAuthToken(profileCredentialStore) + : false if (refreshed) { claudeLog("token_refresh.retrying", { mode: "stream" }) console.error(`[PROXY] ${requestMeta.requestId} OAuth token expired — refreshed, retrying`) @@ -2526,13 +2553,19 @@ export function createProxyServer(config: Partial = {}): ProxyServe }) app.post("/auth/refresh", async (c) => { - const success = await refreshOAuthToken() + const profile = resolveProfile( + finalConfig.profiles, + finalConfig.defaultProfile, + c.req.header("x-meridian-profile") || undefined + ) + const store = credentialStoreForProfile(profile) + const success = store ? await refreshOAuthToken(store) : false if (success) { // Drop the rate-limit snapshot — old quotas were observed under the // previous credential and may belong to a different account if the // refresh swapped profiles. The next SDK call repopulates. rateLimitStore.clear() - return c.json({ success: true, message: "OAuth token refreshed successfully" }) + return c.json({ success: true, message: "OAuth token refreshed successfully", profile: profile.id }) } return c.json( { success: false, message: "Token refresh failed. If the problem persists, run 'claude login'." }, @@ -2975,6 +3008,17 @@ export async function startProxyServer(config: Partial = {}): Promi // Idempotent — re-calling start() on a hot-reload is a no-op. startBackgroundRefresh() + // Profile-scoped OAuth token refresh: the default scheduler above only + // watches the default Claude credential store. Multi-profile credentials + // live under each profile's CLAUDE_CONFIG_DIR, so poll the discovered + // profile list and refresh any browser-login profile that is near expiry. + const PROFILE_TOKEN_REFRESH_MS = 45_000 + void ensureFreshTokenForProfiles(finalConfig) + const profileTokenRefreshInterval = setInterval(() => { + void ensureFreshTokenForProfiles(finalConfig) + }, PROFILE_TOKEN_REFRESH_MS) + if (profileTokenRefreshInterval.unref) profileTokenRefreshInterval.unref() + // Background auth keepalive: periodically refresh auth status for all // configured profiles so switching is instant (no stale token delay). let authKeepaliveInterval: ReturnType | undefined @@ -3001,6 +3045,7 @@ export async function startProxyServer(config: Partial = {}): Promi server, config: finalConfig, async close() { + clearInterval(profileTokenRefreshInterval) if (authKeepaliveInterval) clearInterval(authKeepaliveInterval) stopBackgroundRefresh() await new Promise((resolve, reject) => { diff --git a/src/proxy/tokenRefresh.ts b/src/proxy/tokenRefresh.ts index fca89d8d..a76b26c4 100644 --- a/src/proxy/tokenRefresh.ts +++ b/src/proxy/tokenRefresh.ts @@ -14,12 +14,12 @@ * issuing a second network request and racing on the write. */ -import { execFile as execFileCb } from "child_process" -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" -import { homedir, platform, userInfo } from "os" -import { join, dirname, resolve } from "path" -import { createHash } from "crypto" -import { promisify } from "util" +import { execFile as execFileCb } from "node:child_process" +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { homedir, platform, userInfo } from "node:os" +import { dirname, join, resolve } from "node:path" +import { promisify } from "node:util" import { claudeLog } from "../logger" const execFile = promisify(execFileCb) @@ -70,6 +70,8 @@ interface CredentialsFile { // --------------------------------------------------------------------------- export interface CredentialStore { + /** Stable identity for in-flight refresh deduplication across store instances. */ + refreshKey?: string read(): Promise write(credentials: CredentialsFile): Promise } @@ -117,6 +119,8 @@ const keychainWasHexByService = new Map() function buildMacosStore(serviceName: string): CredentialStore { return { + refreshKey: `keychain:${serviceName}`, + async read() { try { const { stdout } = await execFile( @@ -161,13 +165,16 @@ const macosStore: CredentialStore = buildMacosStore(KEYCHAIN_SERVICE) // --------------------------------------------------------------------------- function buildFileStore(filePath: string): CredentialStore { + const absPath = resolve(filePath) return { + refreshKey: `file:${absPath}`, + async read() { try { - if (!existsSync(filePath)) return null - return JSON.parse(readFileSync(filePath, "utf-8")) as CredentialsFile + if (!existsSync(absPath)) return null + return JSON.parse(readFileSync(absPath, "utf-8")) as CredentialsFile } catch (err) { - claudeLog("token_refresh.file_read_failed", { path: filePath, error: String(err) }) + claudeLog("token_refresh.file_read_failed", { path: absPath, error: String(err) }) return null } }, @@ -175,11 +182,11 @@ function buildFileStore(filePath: string): CredentialStore { async write(credentials) { try { // Ensure parent dir exists for non-default paths. - mkdirSync(dirname(filePath), { recursive: true }) - writeFileSync(filePath, serializeCredentials(credentials), "utf-8") + mkdirSync(dirname(absPath), { recursive: true }) + writeFileSync(absPath, serializeCredentials(credentials), "utf-8") return true } catch (err) { - claudeLog("token_refresh.file_write_failed", { path: filePath, error: String(err) }) + claudeLog("token_refresh.file_write_failed", { path: absPath, error: String(err) }) return false } }, @@ -216,8 +223,9 @@ export function credentialsFilePathForProfile(claudeConfigDir?: string): string // OAuth refresh // --------------------------------------------------------------------------- -/** In-flight refresh promise — deduplicates concurrent callers. */ -let inflightRefresh: Promise | null = null +/** In-flight refresh promises — deduplicates concurrent callers per credential store. */ +const inflightRefreshByKey = new Map>() +const inflightRefreshByStore = new WeakMap>() /** * Refresh the Claude Code OAuth access token. @@ -231,13 +239,27 @@ let inflightRefresh: Promise | null = null * @param store Override the credential store (for testing). */ export async function refreshOAuthToken(store?: CredentialStore): Promise { - if (inflightRefresh) return inflightRefresh + const s = store ?? createPlatformCredentialStore() + const refreshKey = s.refreshKey + if (refreshKey) { + const inflight = inflightRefreshByKey.get(refreshKey) + if (inflight) return inflight - inflightRefresh = doRefresh(store ?? createPlatformCredentialStore()).finally(() => { - inflightRefresh = null - }) + const refresh = doRefresh(s).finally(() => { + inflightRefreshByKey.delete(refreshKey) + }) + inflightRefreshByKey.set(refreshKey, refresh) + return refresh + } - return inflightRefresh + const inflight = inflightRefreshByStore.get(s) + if (inflight) return inflight + + const refresh = doRefresh(s).finally(() => { + inflightRefreshByStore.delete(s) + }) + inflightRefreshByStore.set(s, refresh) + return refresh } async function doRefresh(store: CredentialStore): Promise { @@ -447,5 +469,5 @@ export function isBackgroundRefreshActive(): boolean { /** Reset in-flight state — for testing only. */ export function resetInflightRefresh(): void { - inflightRefresh = null + inflightRefreshByKey.clear() }