diff --git a/packages/server/src/plugins/claude-usage-source/server/usage.test.ts b/packages/server/src/plugins/claude-usage-source/server/usage.test.ts index b2c84af7d7..421d0abd8a 100644 --- a/packages/server/src/plugins/claude-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/claude-usage-source/server/usage.test.ts @@ -647,3 +647,131 @@ it("token-only Claude inputs use a cached OAuth profile identity", async () => { }); expect(calls).toBe(1); }); + +it("identifies the same configDir credentials that fetch uses when oauthAccount is absent", async () => { + const { identify } = await import("./usage.js"); + const directory = mkdtempSync(join(tmpdir(), "claude-credential-identity-")); + try { + writeClaudeCredentials(directory, "fixture-credential-token"); + writeFileSync(join(directory, ".claude.json"), JSON.stringify({})); + let usageRequested = false; + await fetchUsage({ configDir: directory }, async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }); + expect(usageRequested).toBe(true); + let profileRequested = false; + const account = await identify({ configDir: directory }, async () => { + profileRequested = true; + return jsonResponse({ + account: { uuid: "account-uuid", email: "owner@example.test" }, + organization: { uuid: "org-uuid" }, + }); + }); + expect(profileRequested).toBe(true); + expect(account).toEqual({ key: "account-uuid.org-uuid", label: "owner@example.test" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("expires cached token profiles after five minutes", async () => { + const { identify } = await import("./usage.js"); + let now = 0; + let calls = 0; + const fetchProfile: typeof fetch = async () => { + calls++; + return jsonResponse({ account: { uuid: "account-uuid" }, organization: { uuid: "org-uuid" } }); + }; + const input = { accessToken: "ttl-fixture-token" }; + await identify(input, fetchProfile, () => now); + await identify(input, fetchProfile, () => now); + expect(calls).toBe(1); + now = 300_001; + await identify(input, fetchProfile, () => now); + expect(calls).toBe(2); +}); + +it("identify and fetch share the macOS keychain credential fallback", async () => { + const { identify } = await import("./usage.js"); + const directory = mkdtempSync(join(tmpdir(), "claude-keychain-identity-")); + try { + const lookup = { + platform: "darwin" as const, + claudeHome: directory, + accountHome: directory, + readKeychainCredentials: async () => ({ + claudeAiOauth: { accessToken: "keychain-fixture-token" }, + }), + }; + let usageRequested = false; + await fetchUsage( + {}, + async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }, + lookup, + ); + expect(usageRequested).toBe(true); + const account = await identify( + {}, + async () => + jsonResponse({ + account: { uuid: "keychain-account" }, + organization: { uuid: "keychain-org" }, + }), + Date.now, + lookup, + ); + expect(account).toEqual({ key: "keychain-account.keychain-org" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("identify and fetch share the default Claude home credentials", async () => { + const { identify } = await import("./usage.js"); + const directory = mkdtempSync(join(tmpdir(), "claude-home-identity-")); + try { + writeClaudeCredentials(directory, "home-fixture-token"); + const lookup = { claudeHome: directory, accountHome: directory }; + let usageRequested = false; + await fetchUsage( + {}, + async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }, + lookup, + ); + expect(usageRequested).toBe(true); + const identity = await identify( + {}, + async () => + jsonResponse({ + account: { uuid: "home-account" }, + organization: { uuid: "home-org" }, + }), + Date.now, + lookup, + ); + expect(identity).toEqual({ key: "home-account.home-org" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("bounds cached profiles across many token rotations", async () => { + const { identify } = await import("./usage.js"); + let calls = 0; + const fetchProfile: typeof fetch = async () => { + calls++; + return jsonResponse({ account: { uuid: "account" }, organization: { uuid: "org" } }); + }; + for (let index = 0; index < 129; index++) { + await identify({ accessToken: `rotation-fixture-${index}` }, fetchProfile); + } + await identify({ accessToken: "rotation-fixture-0" }, fetchProfile); + expect(calls).toBe(130); +}); diff --git a/packages/server/src/plugins/claude-usage-source/server/usage.ts b/packages/server/src/plugins/claude-usage-source/server/usage.ts index 9be43dd6de..48ac928d16 100644 --- a/packages/server/src/plugins/claude-usage-source/server/usage.ts +++ b/packages/server/src/plugins/claude-usage-source/server/usage.ts @@ -327,16 +327,51 @@ export async function readClaudeKeychainCredentials( return null; } +interface ClaudeCredentialLookup { + platform?: NodeJS.Platform; + readKeychainCredentials?: () => Promise; + claudeHome?: string; + accountHome?: string; +} + +/** Shared credential lookup for usage fetches and account identification. */ +export async function resolveClaudeCredentials( + input: UsageInput, + lookup: ClaudeCredentialLookup = {}, +): Promise { + if ("accessToken" in input) return { oauth: { accessToken: input.accessToken } }; + if ("configDir" in input) return readCredentialFile(join(input.configDir, ".credentials.json")); + const claudeHome = lookup.claudeHome ?? process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude"); + const fileCredentials = await readCredentialFile(join(claudeHome, ".credentials.json")); + if (fileCredentials) return fileCredentials; + if ((lookup.platform ?? process.platform) !== "darwin") return null; + const parsed = ClaudeCredentialsSchema.safeParse( + await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(), + ); + return parsed.success ? toCredentialRecord(parsed.data) : null; +} + +async function readCredentialFile(path: string): Promise { + if (!existsSync(path)) return null; + try { + return toCredentialRecord( + ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), + ); + } catch { + return null; + } +} + +function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRecord | null { + const oauth = credentials.claudeAiOauth; + return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; +} + export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, + credentialLookup: ClaudeCredentialLookup = {}, ): Promise { - const claudeHome = process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); - const configDir = "configDir" in input ? input.configDir : undefined; - const accessToken = "accessToken" in input ? input.accessToken : undefined; - const readKeychainCredentials = readClaudeKeychainCredentials; - const platform = process.platform; - /** * Scoped limits carried by `limits[]`. * @@ -365,37 +400,6 @@ export async function fetchUsage( return parsed; } - async function readCredentials(): Promise { - if (accessToken) return { oauth: { accessToken: accessToken } }; - if (configDir) return readCredentialFile(join(configDir, ".credentials.json")); - const credPath = join(claudeHome, ".credentials.json"); - const fileCredentials = await readCredentialFile(credPath); - return fileCredentials ?? (platform === "darwin" ? await readKeychainCredential() : null); - } - - async function readCredentialFile(path: string): Promise { - if (!existsSync(path)) return null; - try { - return toCredentialRecord( - ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), - ); - } catch { - return null; - } - } - - async function readKeychainCredential(): Promise { - const parsed = ClaudeCredentialsSchema.safeParse(await readKeychainCredentials()); - return parsed.success ? toCredentialRecord(parsed.data) : null; - } - - function toCredentialRecord( - credentials: z.infer, - ): ClaudeCredentialRecord | null { - const oauth = credentials.claudeAiOauth; - return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; - } - async function callClaudeApi(token: string): Promise { const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { signal: AbortSignal.timeout(15_000), @@ -410,7 +414,7 @@ export async function fetchUsage( return ClaudeUsageResponseSchema.parse(await res.json()); } - const credentials = await readCredentials(); + const credentials = await resolveClaudeCredentials(input, credentialLookup); if (!credentials) { return unavailableUsage(); } @@ -463,10 +467,25 @@ const ClaudeAccountSchema = z.object({ organizationUuid: z.string().min(1), emailAddress: z.string().optional(), }); -const profileCache = new Map>(); +type ClaudeIdentity = { key: string; label?: string } | null; +const PROFILE_TTL_MS = 300_000; +const PROFILE_CACHE_LIMIT = 128; +const profileCache = new Map }>(); -export async function identify(input: UsageInput, fetchApi: typeof fetch = fetch) { - const directory = "configDir" in input ? input.configDir : homedir(); +function pruneProfileCache(now: number): void { + for (const [key, entry] of profileCache) { + if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key); + } +} + +export async function identify( + input: UsageInput, + fetchApi: typeof fetch = fetch, + now: () => number = Date.now, + credentialLookup: ClaudeCredentialLookup = {}, +) { + const directory = + "configDir" in input ? input.configDir : (credentialLookup.accountHome ?? homedir()); if (!("accessToken" in input)) { try { const config = z @@ -478,13 +497,21 @@ export async function identify(input: UsageInput, fetchApi: typeof fetch = fetch ...(account.emailAddress ? { label: account.emailAddress } : {}), }; } catch { - return null; + // Credentials may still be present even when account metadata is absent. } } - const token = input.accessToken; + const credentials = await resolveClaudeCredentials(input, credentialLookup); + if (!credentials) return null; + const token = credentials.oauth.accessToken; const tokenHash = hashAccountKey(token); + pruneProfileCache(now()); const pending = profileCache.get(tokenHash); - if (pending) return pending; + if (pending) return pending.result; + while (profileCache.size >= PROFILE_CACHE_LIMIT) { + const oldest = profileCache.keys().next().value; + if (oldest === undefined) break; + profileCache.delete(oldest); + } const request = (async () => { const response = await fetchApi("https://api.anthropic.com/api/oauth/profile", { signal: AbortSignal.timeout(15_000), @@ -508,11 +535,11 @@ export async function identify(input: UsageInput, fetchApi: typeof fetch = fetch ...(account.email ? { label: account.email } : {}), }; })(); - profileCache.set(tokenHash, request); + profileCache.set(tokenHash, { at: now(), result: request }); try { return await request; } catch (error) { - profileCache.delete(tokenHash); + if (profileCache.get(tokenHash)?.result === request) profileCache.delete(tokenHash); throw error; } } diff --git a/packages/server/src/plugins/codex-usage-source/server/usage.test.ts b/packages/server/src/plugins/codex-usage-source/server/usage.test.ts index a898e2770f..e45291bfbd 100644 --- a/packages/server/src/plugins/codex-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/codex-usage-source/server/usage.test.ts @@ -64,7 +64,7 @@ test("explicit codexHome reads only that auth file", async () => { } }); -test("explicit access token needs no auth file", async () => { +test("identify returns a key when fetch finds Codex token credentials", async () => { await fetchUsage( { accessToken: "fixture-supplied", accountId: "account-supplied" }, (_url, init) => response(init?.headers ?? {}, "account-supplied"), @@ -142,3 +142,22 @@ test("token-only account claim survives token rotation", async () => { expect(await identify({ accessToken: token("second") })).toEqual({ key: "work-id" }); expect(await identify({ accessToken: "opaque-token" })).toBeNull(); }); + +test("identify reads an email label from auth id_token when access token has no profile", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-codex-label-")); + try { + const idToken = `header.${Buffer.from(JSON.stringify({ email: "id-owner@example.test" })).toString("base64url")}.signature`; + await writeFile( + join(home, "auth.json"), + JSON.stringify({ + tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken }, + }), + ); + expect(await identify({ codexHome: home })).toEqual({ + key: "account-id", + label: "id-owner@example.test", + }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/plugins/codex-usage-source/server/usage.ts b/packages/server/src/plugins/codex-usage-source/server/usage.ts index 16aae8345e..c7b07ce8f3 100644 --- a/packages/server/src/plugins/codex-usage-source/server/usage.ts +++ b/packages/server/src/plugins/codex-usage-source/server/usage.ts @@ -13,7 +13,11 @@ import type { CodexUsageInput } from "../shared/input.js"; const authSchema = z.object({ tokens: z - .object({ access_token: z.string().optional(), account_id: z.string().optional() }) + .object({ + access_token: z.string().optional(), + account_id: z.string().optional(), + id_token: z.string().optional(), + }) .optional(), }); const number = z.coerce.number().finite(); @@ -30,7 +34,7 @@ const responseSchema = z.object({ export async function readAuth( input: CodexUsageInput, -): Promise<{ token: string; accountId?: string } | null> { +): Promise<{ token: string; accountId?: string; idToken?: string } | null> { if ("accessToken" in input) return { token: input.accessToken, accountId: input.accountId }; const candidates = "codexHome" in input @@ -44,7 +48,11 @@ export async function readAuth( try { const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8"))); if (auth.tokens?.access_token) - return { token: auth.tokens.access_token, accountId: auth.tokens.account_id }; + return { + token: auth.tokens.access_token, + accountId: auth.tokens.account_id, + idToken: auth.tokens.id_token, + }; } catch { continue; } @@ -118,33 +126,47 @@ export async function fetchUsage( }; } -/** The account claim is decoded locally; the token itself never becomes identity. */ +/** JWT claims are decoded locally; no token or email becomes an account key. */ +function jwtClaims(token: string | undefined): Record | null { + try { + const payload = token?.split(".")[1]; + return payload + ? (JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record) + : null; + } catch { + return null; + } +} + +function claimObject( + claims: Record | null, + name: string, +): Record | null { + const value = claims?.[name]; + return value && typeof value === "object" ? (value as Record) : null; +} + +function claimString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + export async function identify(input: CodexUsageInput) { const auth = await readAuth(input); if (!auth) return null; - const claims = (() => { - try { - const payload = auth.token.split(".")[1]; - return payload - ? (JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record< - string, - unknown - >) - : null; - } catch { - return null; - } - })(); - const nested = claims?.["https://api.openai.com/auth"]; - const claim = - nested && typeof nested === "object" - ? (nested as Record)["chatgpt_account_id"] - : null; + const access = jwtClaims(auth.token); + const id = jwtClaims(auth.idToken); + const accessAuth = claimObject(access, "https://api.openai.com/auth"); + const idAuth = claimObject(id, "https://api.openai.com/auth"); const key = auth.accountId ?? - (typeof claim === "string" ? claim : null) ?? - (typeof claims?.["chatgpt_account_id"] === "string" - ? (claims["chatgpt_account_id"] as string) - : null); - return key ? { key } : null; + claimString(accessAuth?.["chatgpt_account_id"]) ?? + claimString(access?.["chatgpt_account_id"]) ?? + claimString(idAuth?.["chatgpt_account_id"]); + if (!key) return null; + const label = + claimString(claimObject(access, "https://api.openai.com/profile")?.["email"]) ?? + claimString(access?.["email"]) ?? + claimString(claimObject(id, "https://api.openai.com/profile")?.["email"]) ?? + claimString(id?.["email"]); + return { key, ...(label ? { label } : {}) }; } diff --git a/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts b/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts index 13c8cf700d..9e34d83ad7 100644 --- a/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts @@ -112,10 +112,16 @@ describe("copilot usage source", () => { }); }); -it("identify uses the configured copilot account key", async () => { +it("identify returns a key when fetch finds copilot credentials", async () => { const previous = process.env["COPILOT_TOKEN"]; try { process.env["COPILOT_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); expect(await identify()).toEqual({ key: "default" }); } finally { if (previous === undefined) delete process.env["COPILOT_TOKEN"]; diff --git a/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts b/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts index c13cbc5a6f..acd841c14f 100644 --- a/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts @@ -259,10 +259,16 @@ describe("cursor usage source", () => { }); }); -it("identify uses the configured cursor account key", async () => { +it("identify returns a key when fetch finds cursor credentials", async () => { const previous = process.env["CURSOR_ACCESS_TOKEN"]; try { process.env["CURSOR_ACCESS_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); expect(await identify()).toEqual({ key: "default" }); } finally { if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"]; diff --git a/packages/server/src/plugins/grok-usage-source/server/usage.test.ts b/packages/server/src/plugins/grok-usage-source/server/usage.test.ts index 9b4d30668f..977c9a3fe9 100644 --- a/packages/server/src/plugins/grok-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/grok-usage-source/server/usage.test.ts @@ -267,10 +267,16 @@ describe("grok usage source", () => { }); }); -it("identify uses the configured grok account key", async () => { +it("identify returns a key when fetch finds grok credentials", async () => { const previous = process.env["GROK_TOKEN"]; try { process.env["GROK_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); expect(await identify()).toEqual({ key: "default" }); } finally { if (previous === undefined) delete process.env["GROK_TOKEN"]; diff --git a/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts b/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts index 3b3039a7c2..9dc83ec587 100644 --- a/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts @@ -369,10 +369,16 @@ describe("Kimi usage source usage windows", () => { }); }); -it("identify uses the configured kimi account key", async () => { +it("identify returns a key when fetch finds kimi credentials", async () => { const previous = process.env["KIMI_TOKEN"]; try { process.env["KIMI_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); expect(await identify()).toEqual({ key: "default" }); } finally { if (previous === undefined) delete process.env["KIMI_TOKEN"]; diff --git a/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts b/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts index b7660d7d68..8926b23980 100644 --- a/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts @@ -314,10 +314,16 @@ describe("minimax usage source", () => { }); }); -it("identify uses the configured minimax account key", async () => { +it("identify returns a key when fetch finds minimax credentials", async () => { const previous = process.env["MINIMAX_API_KEY"]; try { process.env["MINIMAX_API_KEY"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); expect(await identify()).toEqual({ key: "default" }); } finally { if (previous === undefined) delete process.env["MINIMAX_API_KEY"]; diff --git a/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts b/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts index 8072f68a00..a9120cbebe 100644 --- a/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts @@ -28,6 +28,7 @@ test("discovers and fetches the default key from read-only auth.json", async () return Response.json(upstreamResponse); }; const report = await fetchUsage({}, fetchApi as typeof fetch, path); + expect((await identify({}, path))?.key).toMatch(/^[a-f0-9]{64}$/); expect(requests).toEqual([ { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" }, ]); @@ -75,7 +76,13 @@ test.each([401, 403])("maps HTTP %i to unavailable", async (status) => { expect(report.windows).toEqual([]); }); -test("identifies OpenCode Go key without a usage request", async () => { +test("identify returns a key when fetch finds OpenCode Go credentials", async () => { + let requested = false; + await fetchUsage({ apiKey: "fixture-secret" }, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); const identity = await identify({ apiKey: "fixture-secret" }); expect(identity?.key).toMatch(/^[a-f0-9]{64}$/); expect(identity?.key).not.toBe("fixture-secret"); diff --git a/packages/server/src/plugins/zai-usage-source/server/usage.test.ts b/packages/server/src/plugins/zai-usage-source/server/usage.test.ts index 9988640e32..164543c00d 100644 --- a/packages/server/src/plugins/zai-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/zai-usage-source/server/usage.test.ts @@ -118,10 +118,16 @@ describe("zai usage source", () => { }); }); -it("identify uses the configured zai account key", async () => { +it("identify returns a key when fetch finds zai credentials", async () => { const previous = process.env["ZAI_API_KEY"]; try { process.env["ZAI_API_KEY"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); expect(await identify()).toEqual({ key: "default" }); } finally { if (previous === undefined) delete process.env["ZAI_API_KEY"];