/** * Anthropic OAuth credential store with multi-account support. * * If the host runtime has installed the account-pool bridge on * `globalThis` (app-core does this when the multi-account `LinkedAccountConfig` * store is non-empty), token reads route through the pool: `select` picks * the active account, the OAuth fetch wrapper retries on 401 against a * different account, and the pool tracks rate-limited / invalid health. * * Without the bridge, behavior uses the single-source flow * (env var → keychain → ~/.claude/.credentials.json). */ import { type AnthropicAccountPoolBridge, ElizaError } from "@elizaos/core"; interface OAuthToken { accessToken: string; expiresAt: number; /** * Account identifier resolved through the pool; undefined when the token * came from the env var or the single-source reader. */ accountId?: string; } interface ClaudeCredentials { claudeAiOauth?: { accessToken: string; refreshToken: string; expiresAt: number; scopes: string[]; subscriptionType: string; rateLimitTier: string; }; } function getAccountPoolBridge(): AnthropicAccountPoolBridge | undefined { const { getAnthropicAccountPoolBridge } = require("@elizaos/core") as typeof import("@elizaos/core"); return getAnthropicAccountPoolBridge() ?? undefined; } const tokenCache = new Map(); const ENV_CACHE_KEY = "__env__"; const APP_CREDENTIAL_CACHE_KEY = "__app_anthropic_subscription__"; interface AppSubscriptionCredentials { credentials?: { access?: string; expires?: number; }; } /** * Ref: https://code.claude.com/docs/en/authentication * * Returns a synchronously-loaded token. When the multi-account bridge is * installed, callers should prefer `getClaudeOAuthTokenAsync` so the pool * can pick a fresh account on cache miss; the sync path falls back to the * file/keychain reader. */ export function getClaudeOAuthToken(opts?: { accountId?: string }): OAuthToken { const appToken = readAppManagedAnthropicToken(); if (appToken) { tokenCache.set(APP_CREDENTIAL_CACHE_KEY, appToken); return appToken; } const cacheKey = opts?.accountId ?? ENV_CACHE_KEY; const cached = tokenCache.get(cacheKey); if (cached && Date.now() < cached.expiresAt - 60_000) { return cached; } const envToken = getEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ?? getEnvVar("ANTHROPIC_OAUTH_TOKEN"); if (envToken) { const token: OAuthToken = { accessToken: envToken, expiresAt: Number.POSITIVE_INFINITY, }; tokenCache.set(ENV_CACHE_KEY, token); return token; } const credentials = readFromCredentialStore(); if (!credentials?.claudeAiOauth?.accessToken) { throw new Error( "[Anthropic] Could not read Claude OAuth token. " + "Either set CLAUDE_CODE_OAUTH_TOKEN env var (via `claude setup-token`), " + "or ensure Claude Code is authenticated (run `claude auth login`)." ); } const token: OAuthToken = { accessToken: credentials.claudeAiOauth.accessToken, expiresAt: credentials.claudeAiOauth.expiresAt, }; tokenCache.set(cacheKey, token); return token; } /** * Multi-account aware token resolution. Picks an account through the pool * if installed; otherwise falls back to `getClaudeOAuthToken`. * * `exclude` is propagated to the pool so callers can request a *different* * account after a 401 / 429. */ export async function getClaudeOAuthTokenAsync(opts?: { sessionKey?: string; exclude?: string[]; }): Promise { const appToken = readAppManagedAnthropicToken(); if (appToken) { tokenCache.set(APP_CREDENTIAL_CACHE_KEY, appToken); return appToken; } const envToken = getEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ?? getEnvVar("ANTHROPIC_OAUTH_TOKEN"); if (envToken) { const token: OAuthToken = { accessToken: envToken, expiresAt: Number.POSITIVE_INFINITY, }; tokenCache.set(ENV_CACHE_KEY, token); return token; } const bridge = getAccountPoolBridge(); if (bridge) { const account = await bridge.selectAnthropicSubscription(opts); if (account) { const cached = tokenCache.get(account.id); if (cached && Date.now() < cached.expiresAt - 60_000) { return cached; } const access = await bridge.getAccessToken("anthropic-subscription", account.id); if (access) { const token: OAuthToken = { accessToken: access, expiresAt: account.expiresAt || Number.POSITIVE_INFINITY, accountId: account.id, }; tokenCache.set(account.id, token); return token; } } } return getClaudeOAuthToken(); } /** * Notify the pool that the supplied account is no longer valid (e.g. 401 * after refresh) and clear the in-memory cache entry. Returns true when the * pool was notified, false when no bridge is installed. */ export function reportClaudeOAuthInvalid(accountId: string | undefined, detail?: string): boolean { if (accountId) { tokenCache.delete(accountId); } else { tokenCache.clear(); } const bridge = getAccountPoolBridge(); if (!bridge || !accountId) return false; void bridge.markInvalid(accountId, detail); return true; } export function reportClaudeOAuthRateLimited( accountId: string | undefined, untilMs: number, detail?: string ): boolean { if (accountId) { tokenCache.delete(accountId); } const bridge = getAccountPoolBridge(); if (!bridge || !accountId) return false; void bridge.markRateLimited(accountId, untilMs, detail); return true; } export function getClaudeOAuthMeta(): ClaudeCredentials["claudeAiOauth"] | null { const appToken = readAppManagedAnthropicToken(); if (appToken) { return { accessToken: appToken.accessToken, refreshToken: "", expiresAt: appToken.expiresAt, scopes: [], subscriptionType: "app-managed", rateLimitTier: "", }; } const envToken = getEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ?? getEnvVar("ANTHROPIC_OAUTH_TOKEN"); if (envToken) return null; const credentials = readFromCredentialStore(); return credentials?.claudeAiOauth ?? null; } export function clearTokenCache(accountId?: string): void { if (accountId) { tokenCache.delete(accountId); return; } tokenCache.clear(); } function getEnvVar(key: string): string | undefined { if (typeof process === "undefined") return undefined; return process.env[key]; } function readAppManagedAnthropicToken(): OAuthToken | null { if (typeof process === "undefined") return null; const cached = tokenCache.get(APP_CREDENTIAL_CACHE_KEY); if (cached && Date.now() < cached.expiresAt - 60_000) { return cached; } const { join } = require("node:path") as typeof import("node:path"); const { homedir } = require("node:os") as typeof import("node:os"); const { readFileSync } = require("node:fs") as typeof import("node:fs"); const { resolveStateDir } = require("@elizaos/core") as typeof import("@elizaos/core"); const stateDir = resolveStateDir(); const accountId = getEnvVar("ANTHROPIC_SUBSCRIPTION_ACCOUNT_ID")?.trim() || "default"; const paths = [ join(stateDir, "auth", "anthropic-subscription", `${accountId}.json`), join(homedir(), ".eliza", "auth", "anthropic-subscription", `${accountId}.json`), join(homedir(), ".eliza", "auth", "anthropic-subscription.json"), ]; for (const credentialPath of paths) { try { const parsed = JSON.parse( readFileSync(credentialPath, "utf-8") ) as AppSubscriptionCredentials; const access = parsed.credentials?.access?.trim(); if (!access) continue; const expires = parsed.credentials?.expires; const token: OAuthToken = { accessToken: access, expiresAt: typeof expires === "number" && Number.isFinite(expires) ? expires : Number.POSITIVE_INFINITY, }; if (Date.now() < token.expiresAt - 60_000) { return token; } } catch { // error-policy:J3 untrusted-input sanitizing — this probes a fixed list of // known app-managed credential locations of which at most one exists; // a read/parse miss at one location is the expected "not here" signal and // moves to the next. Exhausting all yields the honest null below. } } tokenCache.delete(APP_CREDENTIAL_CACHE_KEY); return null; } function readFromCredentialStore(): ClaudeCredentials | null { if (typeof process === "undefined") return null; const { join } = require("node:path") as typeof import("node:path"); const { homedir } = require("node:os") as typeof import("node:os"); const configDirOverride = getEnvVar("CLAUDE_CONFIG_DIR")?.trim(); const configDir = configDirOverride || join(homedir(), ".claude"); if (!configDirOverride && process.platform === "darwin") { const fromKeychain = readFromMacKeychain(); if (fromKeychain) return fromKeychain; } const credPath = join(configDir, ".credentials.json"); const { readFileSync } = require("node:fs") as typeof import("node:fs"); let raw: string; try { raw = readFileSync(credPath, "utf-8"); } catch (error) { // error-policy:J3 untrusted-input sanitizing — a missing credential file is // the expected "not authenticated this way" signal (null). Any other read // failure (permissions, I/O) is a real problem the caller must not read as // "no credentials", so it surfaces as a typed error. if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return null; throw new ElizaError("Failed to read Claude credential file", { code: "CREDENTIALS_UNREADABLE", cause: error, context: { credPath }, }); } // A file that exists but does not parse is corrupt, not absent — surfacing it // prevents a silent downgrade of auth that would look identical to "no creds". try { return JSON.parse(raw); } catch (error) { // error-policy:J2 context-adding rethrow — typed CREDENTIALS_CORRUPT with cause. throw new ElizaError("Claude credential file is corrupt (invalid JSON)", { code: "CREDENTIALS_CORRUPT", cause: error, context: { credPath }, }); } } function readFromMacKeychain(): ClaudeCredentials | null { const { execSync } = require("node:child_process") as typeof import("node:child_process"); let raw: string; try { raw = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); } catch { // error-policy:J3 untrusted-input sanitizing — a non-zero `security` exit // means no matching keychain entry (absent → null), the expected "not stored // here" outcome. The file-based reader is the next source tried by the caller. return null; } // The entry exists; if its payload does not parse it is corrupt, not absent. try { return JSON.parse(raw); } catch (error) { // error-policy:J2 context-adding rethrow — typed CREDENTIALS_CORRUPT with cause. throw new ElizaError("Claude keychain credential is corrupt (invalid JSON)", { code: "CREDENTIALS_CORRUPT", cause: error, context: { source: "macos-keychain" }, }); } }