Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/__tests__/profile-token-refresh-route.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
44 changes: 44 additions & 0 deletions src/__tests__/token-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 53 additions & 8 deletions src/proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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<void> {
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 {
Expand Down Expand Up @@ -500,6 +518,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): 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) {
Expand Down Expand Up @@ -980,7 +999,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): 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) {
Expand Down Expand Up @@ -1075,7 +1096,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): 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`)
Expand Down Expand Up @@ -1449,7 +1472,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): 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

Expand Down Expand Up @@ -1537,7 +1562,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): 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`)
Expand Down Expand Up @@ -2526,13 +2553,19 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): 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'." },
Expand Down Expand Up @@ -2975,6 +3008,17 @@ export async function startProxyServer(config: Partial<ProxyConfig> = {}): 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<typeof setInterval> | undefined
Expand All @@ -3001,6 +3045,7 @@ export async function startProxyServer(config: Partial<ProxyConfig> = {}): Promi
server,
config: finalConfig,
async close() {
clearInterval(profileTokenRefreshInterval)
if (authKeepaliveInterval) clearInterval(authKeepaliveInterval)
stopBackgroundRefresh()
await new Promise<void>((resolve, reject) => {
Expand Down
62 changes: 42 additions & 20 deletions src/proxy/tokenRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -70,6 +70,8 @@ interface CredentialsFile {
// ---------------------------------------------------------------------------

export interface CredentialStore {
/** Stable identity for in-flight refresh deduplication across store instances. */
refreshKey?: string
read(): Promise<CredentialsFile | null>
write(credentials: CredentialsFile): Promise<boolean>
}
Expand Down Expand Up @@ -117,6 +119,8 @@ const keychainWasHexByService = new Map<string, boolean>()

function buildMacosStore(serviceName: string): CredentialStore {
return {
refreshKey: `keychain:${serviceName}`,

async read() {
try {
const { stdout } = await execFile(
Expand Down Expand Up @@ -161,25 +165,28 @@ 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
}
},

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
}
},
Expand Down Expand Up @@ -216,8 +223,9 @@ export function credentialsFilePathForProfile(claudeConfigDir?: string): string
// OAuth refresh
// ---------------------------------------------------------------------------

/** In-flight refresh promise — deduplicates concurrent callers. */
let inflightRefresh: Promise<boolean> | null = null
/** In-flight refresh promises — deduplicates concurrent callers per credential store. */
const inflightRefreshByKey = new Map<string, Promise<boolean>>()
const inflightRefreshByStore = new WeakMap<CredentialStore, Promise<boolean>>()

/**
* Refresh the Claude Code OAuth access token.
Expand All @@ -231,13 +239,27 @@ let inflightRefresh: Promise<boolean> | null = null
* @param store Override the credential store (for testing).
*/
export async function refreshOAuthToken(store?: CredentialStore): Promise<boolean> {
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<boolean> {
Expand Down Expand Up @@ -447,5 +469,5 @@ export function isBackgroundRefreshActive(): boolean {

/** Reset in-flight state — for testing only. */
export function resetInflightRefresh(): void {
inflightRefresh = null
inflightRefreshByKey.clear()
}
Loading