Files
fusion/packages/engine/src/auth-storage.ts
gsxdsm ed823c794c fix: preserve Claude OAuth scopes on token refresh so inference keeps working
The Anthropic OAuth refresh request sent `scope: user:profile`, which under
RFC 6749 §6 re-issues the access token with exactly that scope — stripping
`user:inference` and 403-ing every model call while the account still read
as "logged in via OAuth". Stop sending `scope` on refresh (Anthropic then
preserves the originally-granted scopes, matching pi-ai), and widen
ANTHROPIC_DEFAULT_SCOPES to mirror pi-ai's full granted Claude Code scope
set so any fallback describes a usable token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:47:40 -07:00

837 lines
34 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
choosePreferredStoredCredential,
getClaudeCodeCredentialPaths,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { AuthCredential } from "@earendil-works/pi-coding-agent";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
type StoredCredential = StoredAuthCredential;
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574: a 60s reactive refresh buffer meant a healthy Anthropic subscription token was
only ever refreshed a few seconds before (or after) it actually expired — too late to
reliably beat a slow/failed network round trip, so subscriptions routinely lapsed and
forced a manual re-login even though the refresh token was still valid. Widen the
proactive-refresh window to 5 minutes so both the reactive getApiKey() path AND the new
background OAuthRefreshScheduler (see notification/oauth-refresh-scheduler.ts) renew the
access token well ahead of expiry, without refreshing needlessly often (the scheduler
runs on a multi-minute interval, and the in-flight dedupe + failure cooldown below still
apply so a single stuck token doesn't get hammered).
*/
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
/*
FNXC:ClaudeOAuth 2026-07-05-18:52:
Anthropic subscription login (delegated to pi-ai) grants the full Claude Code scope set — `user:inference` is what authorizes model calls. Earlier this constant was `["user:profile"]`, which was WRONG twice over: (1) it under-describes the token pi-ai actually obtains, and (2) it was fed into the refresh request's `scope` param, which under RFC 6749 §6 NARROWS the refreshed access token to profile-only and strips `user:inference`. The symptom: the account reads "logged in via OAuth" (token present + unexpired) yet every model call 403s with "OAuth token does not meet scope requirement any_of(user:inference, ...)". The default must mirror pi-ai's granted scopes so any fallback describes a usable token, and the refresh path (below) must NOT send it as a narrowing scope.
*/
const ANTHROPIC_DEFAULT_SCOPES = [
"org:create_api_key",
"user:profile",
"user:inference",
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload",
];
const OAUTH_REFRESH_TIMEOUT_MS = 10_000;
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
type OAuthTokenResponse = {
access_token?: unknown;
accessToken?: unknown;
refresh_token?: unknown;
refreshToken?: unknown;
expires_in?: unknown;
expiresIn?: unknown;
expires_at?: unknown;
expiresAt?: unknown;
scope?: unknown;
scopes?: unknown;
};
export function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getFusionAuthPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "auth.json");
}
export function getFusionOAuthAlertStatePath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "oauth-alert-state.json");
}
export function getFusionModelsPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "models.json");
}
function getLegacyAuthPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
}
function getSupplementalAuthPaths(home = getHomeDir()): string[] {
return [
...getLegacyAuthPaths(home),
getCodexCliAuthPath(home),
...getClaudeCodeCredentialPaths(home),
];
}
function getLegacyModelsPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = getHomeDir()): string {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readSupplementalCredentials(authPaths = getSupplementalAuthPaths()): Record<string, StoredCredential> {
const credentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] = choosePreferredStoredCredential(credentials[provider], credential) ?? credential;
}
}
return credentials;
}
function resolveStoredApiKey(key: string | undefined): string | undefined {
if (!key) return undefined;
return process.env[key] ?? key;
}
function getOAuthResolutionProviderId(providerId: string): string {
return providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID ? ANTHROPIC_PROVIDER_ID : providerId;
}
function resolveOAuthApiKey(providerId: string, credential: StoredCredential): string | undefined {
if (
credential.type !== "oauth" ||
typeof credential.access !== "string" ||
typeof credential.refresh !== "string" ||
typeof credential.expires !== "number" ||
Date.now() >= credential.expires
) {
return undefined;
}
return getOAuthProvider(getOAuthResolutionProviderId(providerId))?.getApiKey(credential as OAuthCredentials);
}
function shouldRefreshOAuthCredential(credential: StoredCredential): boolean {
return credential.type === "oauth"
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires)
&& Date.now() >= credential.expires - OAUTH_REFRESH_BUFFER_MS;
}
function isSameOAuthCredentialIdentity(
left: StoredCredential | undefined,
right: StoredCredential,
): boolean {
return left?.type === "oauth"
&& right.type === "oauth"
&& left.access === right.access
&& left.refresh === right.refresh
&& left.expires === right.expires;
}
function getOAuthScopes(credential: StoredCredential): string[] {
const scopes = Array.isArray(credential.scopes)
? credential.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0)
: [];
return scopes.length > 0 ? scopes : ANTHROPIC_DEFAULT_SCOPES;
}
function parseExpiryMs(data: OAuthTokenResponse, now: number): number {
const expiresAt = data.expires_at ?? data.expiresAt;
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
return expiresAt;
}
if (typeof expiresAt === "string") {
const parsed = Date.parse(expiresAt);
if (Number.isFinite(parsed)) {
return parsed;
}
}
const expiresIn = data.expires_in ?? data.expiresIn;
if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) {
return now + expiresIn * 1000;
}
return now + 3_600_000;
}
function parseScopes(data: OAuthTokenResponse, fallback: string[]): string[] {
if (Array.isArray(data.scopes)) {
const scopes = data.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0);
if (scopes.length > 0) {
return scopes;
}
}
if (typeof data.scope === "string") {
const scopes = data.scope.split(/\s+/).filter(Boolean);
if (scopes.length > 0) {
return scopes;
}
}
return fallback;
}
async function refreshAnthropicOAuthCredential(credential: StoredCredential): Promise<StoredCredential | undefined> {
const refresh = credential.refresh;
if (!refresh) {
return undefined;
}
const scopes = getOAuthScopes(credential);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OAUTH_REFRESH_TIMEOUT_MS);
try {
/*
FNXC:ClaudeOAuth 2026-06-13-22:46:
Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires.
Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths.
*/
/*
FNXC:ClaudeOAuth 2026-07-05-18:52:
Do NOT send `scope` on refresh. RFC 6749 §6: a refresh request that includes `scope` re-issues the access token with EXACTLY that scope (never broader), so sending our stored/derived scope list can only strip capabilities — and did: it narrowed refreshed tokens to `user:profile` and broke inference. Omitting `scope` makes Anthropic preserve the originally-granted scopes (this is what pi-ai's own `refreshAnthropicToken` does). `scopes` is still resolved above and used only as the parseScopes fallback for the persisted credential record.
*/
const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "claude-code-fusion-dashboard",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refresh,
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
}),
signal: controller.signal,
});
if (!response.ok) {
return undefined;
}
const data = await response.json() as OAuthTokenResponse;
const access = typeof data.access_token === "string"
? data.access_token
: typeof data.accessToken === "string"
? data.accessToken
: undefined;
if (!access) {
return undefined;
}
const now = Date.now();
const nextRefresh = typeof data.refresh_token === "string"
? data.refresh_token
: typeof data.refreshToken === "string"
? data.refreshToken
: refresh;
return {
...credential,
type: "oauth",
access,
refresh: nextRefresh,
expires: parseExpiryMs(data, now),
scopes: parseScopes(data, scopes),
};
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
async function refreshOAuthCredential(providerId: string, credential: StoredCredential): Promise<StoredCredential | undefined> {
if (!shouldRefreshOAuthCredential(credential)) {
return credential;
}
if (getOAuthResolutionProviderId(providerId) !== ANTHROPIC_PROVIDER_ID) {
return undefined;
}
return refreshAnthropicOAuthCredential(credential);
}
function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined {
if (credential?.type === "api_key") {
return resolveStoredApiKey(credential.key);
}
if (credential?.type === "oauth") {
return resolveOAuthApiKey(providerId, credential);
}
return undefined;
}
/**
* Reads API keys from the resolved models.json file.
*
* Some providers (e.g., kimi-coding, lmstudio, ollama) store their API keys
* in `models.json` under `providers.<providerId>.apiKey` rather than in
* `auth.json`. This function extracts those keys so the auth storage proxy
* can return them as a fallback when neither Fusion auth nor legacy auth.json
* contains a key for the provider.
*/
function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> {
const apiKeys = new Map<string, string>();
const modelsPath = getModelRegistryModelsPath(home);
if (!existsSync(modelsPath)) {
return apiKeys;
}
try {
const parsed = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
providers?: Record<string, { apiKey?: string }>;
};
const providers = parsed?.providers;
if (providers) {
for (const [providerId, config] of Object.entries(providers)) {
if (config.apiKey) {
apiKeys.set(providerId, config.apiKey);
}
}
}
} catch {
// Ignore invalid models.json files.
}
return apiKeys;
}
/*
* FNXC:ModelRegistry 2026-07-03-07:00:
* Shared model-registry factory so non-CLI hosts (the desktop embedded runtime) can wire the
* dashboard's models API without depending on @earendil-works/pi-coding-agent directly. Mirrors the
* CLI's ModelRegistry.create(authStorage, getModelRegistryModelsPath()). Without a ModelRegistry the
* /api/models endpoint returns an empty list, so the onboarding model picker shows "no models" even
* when a provider (e.g. Anthropic) is connected.
*/
export function createFusionModelRegistry(authStorage: AuthStorage, home?: string): ModelRegistry {
return ModelRegistry.create(authStorage, getModelRegistryModelsPath(home));
}
export function createFusionAuthStorage(): AuthStorage {
const primary = AuthStorage.create(getFusionAuthPath());
let supplementalCredentials = readSupplementalCredentials();
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
let modelsJsonApiKeys = readModelsJsonApiKeys();
/*
FNXC:ClaudeOAuth 2026-06-13-22:46:
Dashboard auth-status polling can run while model execution also resolves credentials, so expired Claude credentials need one refresh attempt per provider at a time.
Cache an in-flight refresh and briefly cool down failed attempts so repeated polls do not stampede the Anthropic token endpoint.
*/
const oauthRefreshInFlight = new Map<string, Promise<StoredCredential | undefined>>();
const oauthRefreshCooldownUntil = new Map<string, number>();
// Providers the user has explicitly logged out from. These should not be
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json).
// Cleared when the user re-authenticates via set().
const loggedOutProviders = new Set<string>();
/*
FNXC:ProviderAuth 2026-07-05-00:00:
Re-authenticating a provider must clear its in-memory logged-out suppression so the settings card flips back to connected.
Anthropic subscription OAuth is aliased across the legacy `anthropic` row — where interactive login persists the credential — and the separated `anthropic-subscription` id, where the card's logged-out flag and status read are keyed. Because a re-login only writes `anthropic`, clearing just the written id left `anthropic-subscription` suppressed: a user who logged out of the subscription earlier in the same dashboard session saw every successful re-login reported as "Login did not complete. Please try again." until the process restarted (the credential was valid on disk the whole time). Clear BOTH aliases when either is re-authenticated. Only OAuth credentials alias this way; a raw `anthropic` API key stays scoped to its own card, so api_key writes never clear the subscription alias.
*/
const clearReauthenticatedLogoutState = (
provider: string,
credentialType?: StoredCredential["type"],
) => {
loggedOutProviders.delete(provider);
oauthRefreshCooldownUntil.delete(provider);
const isAnthropicAlias =
provider === ANTHROPIC_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID;
const aliasesSubscriptionOAuth = credentialType === undefined || credentialType === "oauth";
if (isAnthropicAlias && aliasesSubscriptionOAuth) {
loggedOutProviders.delete(ANTHROPIC_PROVIDER_ID);
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
};
const syncSupplementalOauthCredentials = () => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
if (loggedOutProviders.has(provider)) {
continue;
}
const current = primary.get(provider) as StoredCredential | undefined;
if (!shouldHydrateStoredCredential(current, credential)) {
continue;
}
if (credential.type === "oauth") {
if (typeof credential.expires !== "number" || Date.now() >= credential.expires) {
continue;
}
primary.set(provider, credential as AuthCredential);
continue;
}
if (credential.type === "api_key") {
primary.set(provider, credential as AuthCredential);
}
}
};
const refreshProviderOAuthCredential = async (
storageProvider: string,
credential: StoredCredential,
): Promise<StoredCredential | undefined> => {
if (!shouldRefreshOAuthCredential(credential)) {
return credential;
}
const now = Date.now();
const cooldownUntil = oauthRefreshCooldownUntil.get(storageProvider);
if (cooldownUntil && cooldownUntil > now) {
return undefined;
}
const existing = oauthRefreshInFlight.get(storageProvider);
if (existing) {
return existing;
}
const refreshPromise = refreshOAuthCredential(storageProvider, credential)
.then((refreshed) => {
if (refreshed) {
oauthRefreshCooldownUntil.delete(storageProvider);
} else {
oauthRefreshCooldownUntil.set(storageProvider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
}
return refreshed;
})
.catch(() => {
oauthRefreshCooldownUntil.set(storageProvider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
return undefined;
})
.finally(() => {
oauthRefreshInFlight.delete(storageProvider);
});
oauthRefreshInFlight.set(storageProvider, refreshPromise);
return refreshPromise;
};
const selectStoredCredential = (provider: string) => choosePreferredStoredCredential(
primary.get(provider) as StoredCredential | undefined,
supplementalCredentials[provider],
);
const selectStoredCredentialByType = (
provider: string,
type: StoredCredential["type"],
) => choosePreferredStoredCredential(
((primary.get(provider) as StoredCredential | undefined)?.type === type
? primary.get(provider) as StoredCredential
: undefined),
supplementalCredentials[provider]?.type === type ? supplementalCredentials[provider] : undefined,
);
const isAnthropicSubscriptionLoggedOut = () => loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
const isAnthropicRawProviderLoggedOut = () => loggedOutProviders.has(ANTHROPIC_PROVIDER_ID);
const selectAnthropicSubscriptionCredential = (): { credential?: StoredCredential; sourceProvider?: string } => {
if (isAnthropicSubscriptionLoggedOut()) {
return {};
}
const separatedCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
if (separatedCredential?.type === "oauth") {
return { credential: separatedCredential, sourceProvider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID };
}
if (!isAnthropicRawProviderLoggedOut()) {
const legacyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
if (legacyCredential) {
return { credential: legacyCredential, sourceProvider: ANTHROPIC_PROVIDER_ID };
}
}
return {};
};
const hasVisibleAnthropicSubscriptionCredential = () => Boolean(selectAnthropicSubscriptionCredential().credential);
const selectVisibleStoredCredential = (provider: string) => {
if (loggedOutProviders.has(provider)) {
return undefined;
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return selectAnthropicSubscriptionCredential().credential;
}
if (provider === ANTHROPIC_PROVIDER_ID && isAnthropicSubscriptionLoggedOut()) {
return selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
}
return selectStoredCredential(provider);
};
const resolveTargetFallbackApiKey = (provider: string): string | undefined => {
const fallbackResolver = (primary as unknown as {
fallbackResolver?: (provider: string) => string | undefined;
}).fallbackResolver;
return fallbackResolver?.(provider);
};
const hasTargetFallbackAuth = (provider: string): boolean => Boolean(resolveTargetFallbackApiKey(provider));
const hasVisibleAnthropicCredential = () => {
const hasVisibleRawAnthropicApiKey = !isAnthropicRawProviderLoggedOut()
&& (Boolean(selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key"))
|| modelsJsonApiKeys.has(ANTHROPIC_PROVIDER_ID));
const hasVisibleLegacyAnthropicOAuth = !isAnthropicRawProviderLoggedOut()
&& Boolean(selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth"));
const hasVisibleSubscriptionCredential = hasVisibleAnthropicSubscriptionCredential();
const hasVisibleAnthropicFallback = !isAnthropicRawProviderLoggedOut()
&& hasTargetFallbackAuth(ANTHROPIC_PROVIDER_ID);
if (!isAnthropicSubscriptionLoggedOut()) {
/*
FNXC:ProviderAuth 2026-06-30-12:23:
Logging out of the raw Anthropic API-key provider must suppress only the raw/legacy `anthropic` storage slot.
Model-runtime reads for `anthropic` still need to see an independently logged-in `anthropic-subscription` credential from the separated subscription card.
FNXC:ProviderAuth 2026-06-30-12:47:
Anthropic's subscription alias is an extra runtime credential source, not a replacement for AuthStorage's existing fallback-resolver contract.
Keep custom fallback auth visible after explicit raw/subscription credentials are checked so ModelRegistry provider request configs still work for `anthropic` like every other provider.
*/
return hasVisibleRawAnthropicApiKey
|| hasVisibleLegacyAnthropicOAuth
|| hasVisibleSubscriptionCredential
|| hasVisibleAnthropicFallback;
}
/*
FNXC:ProviderAuth 2026-06-30-12:05:
Logging out of the Anthropic subscription must suppress legacy `anthropic` OAuth aliases from status/list reads as well as model-runtime resolution.
Keep raw API-key credentials and models.json fallback visible so the separate API-key card is not hidden by subscription logout.
*/
return hasVisibleRawAnthropicApiKey || hasVisibleAnthropicFallback;
};
const resolveRefreshableCredentialApiKey = async (
storageProvider: string,
credential: StoredCredential | undefined,
): Promise<string | undefined> => {
if (!credential) {
return undefined;
}
const refreshWasNeeded = shouldRefreshOAuthCredential(credential);
const refreshedCredential = await refreshProviderOAuthCredential(storageProvider, credential);
if (refreshedCredential?.type === "oauth" && refreshedCredential.access) {
if (refreshWasNeeded) {
/*
FNXC:ClaudeOAuth 2026-06-13-22:46:
A manual re-login or replacement credential must win over an older in-flight refresh response.
Re-check the credential identity before persisting so a delayed refresh cannot restore stale OAuth material after the user already fixed auth.
*/
const latestCredential = selectStoredCredential(storageProvider);
if (!isSameOAuthCredentialIdentity(latestCredential, credential)) {
return resolveStoredCredentialApiKey(storageProvider, latestCredential);
}
}
primary.set(storageProvider, refreshedCredential as AuthCredential);
loggedOutProviders.delete(storageProvider);
return resolveStoredCredentialApiKey(storageProvider, refreshedCredential);
}
return resolveStoredCredentialApiKey(storageProvider, credential);
};
const resolveAnthropicRuntimeApiKey = async (): Promise<string | undefined> => {
const rawProviderLoggedOut = isAnthropicRawProviderLoggedOut();
/*
FNXC:ProviderAuth 2026-07-01-14:55:
Anthropic runtime auth (`getApiKey("anthropic")`) resolves in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key; subscription/OAuth tokens must resolve here so the built-in provider runs them on `/v1` with Claude Code impersonation. Do NOT gate OAuth behind the CLI or reroute it to an `/v1` `anthropic-subscription` provider — that reintroduced the #1857 regression (FN-7391/FN-7396).
*/
if (!rawProviderLoggedOut) {
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
if (anthropicApiKeyCredential) {
return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential);
}
}
const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
const legacyAnthropicOAuthCredential = rawProviderLoggedOut
? undefined
: selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
if (!subscriptionLoggedOut && legacyAnthropicOAuthCredential) {
const legacyKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_PROVIDER_ID, legacyAnthropicOAuthCredential);
if (legacyKey) return legacyKey;
}
if (!subscriptionLoggedOut) {
const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
if (subscriptionCredential?.type === "oauth") {
/*
FNXC:ProviderAuth 2026-06-30-11:26:
The separated subscription login stores OAuth material under `anthropic-subscription` so the API-key card stays raw-key-only, but Anthropic model execution still requests provider `anthropic`. Resolve and refresh the subscription credential (persisting rotated tokens back to `anthropic-subscription`) so a subscription user's `anthropic/<model>` selection runs on their OAuth token.
*/
const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
if (subscriptionKey) return subscriptionKey;
}
}
if (!rawProviderLoggedOut) {
/*
FNXC:ProviderAuth 2026-06-30-13:28:
Logging out of the raw Anthropic provider must suppress raw-key sources consistently across status and runtime resolution. Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material; subscription OAuth stays governed by the separate `anthropic-subscription` logout state above.
*/
const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID);
if (modelsJsonApiKey) return modelsJsonApiKey;
return resolveTargetFallbackApiKey(ANTHROPIC_PROVIDER_ID);
}
return undefined;
};
syncSupplementalOauthCredentials();
return new Proxy(primary, {
// Forward property writes to the target so that methods like
// `setFallbackResolver` (called by ModelRegistry) correctly update the
// underlying AuthStorage. Without this trap, writes land on the Proxy
// object itself and the target's fallbackResolver stays undefined.
set(target: AuthStorage, prop: string | symbol, value: unknown) {
(target as unknown as Record<string | symbol, unknown>)[prop] = value;
return true;
},
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
target.logout(ANTHROPIC_PROVIDER_ID);
}
}
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
target.remove(ANTHROPIC_PROVIDER_ID);
}
}
};
}
if (prop === "setFallbackResolver") {
return (resolver: (provider: string) => string | undefined) => {
(target as unknown as { fallbackResolver?: (provider: string) => string | undefined }).fallbackResolver = resolver;
};
}
if (prop === "login") {
// Preserve the original invocation semantics (bind `this` to the proxy so
// any internal credential writes still flow through the set/logout traps),
// and only ADD the alias-aware logged-out clearing on top.
const originalLogin = Reflect.get(target, prop, receiver) as (
provider: string,
callbacks: unknown,
) => Promise<void>;
return async (provider: string, callbacks: unknown) => {
const result = await originalLogin.call(receiver, provider, callbacks);
/*
FNXC:ProviderAuth 2026-07-05-00:00:
A completed interactive login means the user re-authenticated this provider, so lift its logged-out suppression (and the Anthropic subscription alias) even though the credential is persisted under `anthropic`. Without this, subscription re-login after an in-session logout stays invisible to the status card. See clearReauthenticatedLogoutState.
*/
clearReauthenticatedLogoutState(provider);
return result;
};
}
if (prop === "set") {
return (provider: string, credential: AuthCredential) => {
target.set(provider, credential);
clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type);
};
}
if (prop === "reload") {
return () => {
target.reload();
supplementalCredentials = readSupplementalCredentials();
syncSupplementalOauthCredentials();
modelsJsonApiKeys = readModelsJsonApiKeys();
};
}
if (prop === "get") {
return (provider: string) => selectVisibleStoredCredential(provider);
}
if (prop === "has") {
return (provider: string) => {
if (provider === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential();
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return hasVisibleAnthropicSubscriptionCredential();
}
if (loggedOutProviders.has(provider)) {
return false;
}
return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider);
};
}
if (prop === "hasAuth") {
return (provider: string) => {
if (provider === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential();
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return hasVisibleAnthropicSubscriptionCredential();
}
if (loggedOutProviders.has(provider)) {
return false;
}
return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider);
};
}
if (prop === "getAll") {
return () => {
const providerIds = new Set([
...Object.keys(target.getAll() as Record<string, StoredCredential>),
...(loggedOutProviders.size > 0
? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p))
: Object.keys(supplementalCredentials)),
]);
if (hasVisibleAnthropicSubscriptionCredential()) {
providerIds.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
const credential = selectVisibleStoredCredential(providerId);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => {
const providers = new Set([...target.list()]);
for (const p of modelsJsonApiKeys.keys()) {
if (!loggedOutProviders.has(p)) {
providers.add(p);
}
}
for (const p of Object.keys(supplementalCredentials)) {
if (!loggedOutProviders.has(p)) {
providers.add(p);
}
}
if (hasVisibleAnthropicSubscriptionCredential()) {
providers.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
providers.add(ANTHROPIC_PROVIDER_ID);
}
return Array.from(providers).filter((p) => {
if (p === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential();
}
if (loggedOutProviders.has(p)) {
return false;
}
return true;
});
};
}
if (prop === "getApiKey") {
return async (provider: string) => {
if (provider === ANTHROPIC_PROVIDER_ID) {
return resolveAnthropicRuntimeApiKey();
}
if (loggedOutProviders.has(provider)) {
return undefined;
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const { credential: subscriptionCredential, sourceProvider } = selectAnthropicSubscriptionCredential();
if (subscriptionCredential?.type !== "oauth") {
return undefined;
}
if (sourceProvider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
/*
FNXC:ProviderAuth 2026-07-01-12:34:
Legacy Anthropic OAuth rows are subscription credentials, not raw API keys. Hydrate them into `anthropic-subscription` before refresh so status, usage, and banner clearing share the same provider id without overwriting a raw `anthropic` API-key credential.
*/
primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as AuthCredential);
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
}
// 1. Primary Fusion auth
const primaryKey = await target.getApiKey(provider);
if (primaryKey) return primaryKey;
// 2. Supplemental auth.json credentials (.pi + .codex)
const refreshCandidate = selectStoredCredential(provider);
const refreshedKey = await resolveRefreshableCredentialApiKey(provider, refreshCandidate);
if (refreshedKey) return refreshedKey;
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
if (supplementalKey) return supplementalKey;
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
const modelsJsonApiKey = modelsJsonApiKeys.get(provider);
if (modelsJsonApiKey) return modelsJsonApiKey;
// 4. ModelRegistry fallback resolver (env-backed provider configs)
return resolveTargetFallbackApiKey(provider);
};
}
return Reflect.get(target, prop, receiver);
},
}) as AuthStorage;
}