FN-7292: bridge Anthropic subscription auth into runtime requests
Anthropic model requests now resolve subscription OAuth credentials when no raw API key is present. - Route Anthropic runtime auth through raw API keys, legacy OAuth, subscription OAuth, models.json, and fallback resolver sources with explicit logout precedence. - Refresh subscription OAuth using the Anthropic OAuth provider while persisting rotated tokens under the subscription storage id. - Cover subscription alias resolution, logout behavior, fallback visibility, refresh persistence, and reload behavior with auth-storage tests. - Add a patch changeset for the published Fusion CLI package. Files changed: .../fn-7292-anthropic-subscription-runtime-auth.md | 7 + packages/engine/src/__tests__/auth-storage.test.ts | 357 +++++++++++++++++++++ packages/engine/src/auth-storage.ts | 266 ++++++++++++--- 3 files changed, 577 insertions(+), 53 deletions(-) Fusion-Task-Id: FN-7292 Fusion-Task-Lineage: c98c2f1c-8297-4473-ba66-5b6320bc0c00 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Let Anthropic subscription login power Anthropic model requests without a raw API key.
|
||||
category: fix
|
||||
dev: Bridges runtime provider `anthropic` to OAuth credentials stored under `anthropic-subscription` while preserving raw API-key precedence.
|
||||
@@ -17,6 +17,12 @@ function createJwt(payload: Record<string, unknown>): string {
|
||||
].join(".");
|
||||
}
|
||||
|
||||
function writeFusionAuth(homeDir: string, credentials: Record<string, unknown>): void {
|
||||
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||
mkdirSync(fusionAgentDir, { recursive: true });
|
||||
writeFileSync(getFusionAuthPath(homeDir), JSON.stringify(credentials));
|
||||
}
|
||||
|
||||
describe("createFusionAuthStorage", () => {
|
||||
// HOME override required — createFusionAuthStorage() has no dir parameter
|
||||
const originalHome = process.env.HOME;
|
||||
@@ -168,6 +174,357 @@ describe("createFusionAuthStorage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anthropic subscription runtime auth alias", () => {
|
||||
it("returns undefined for Anthropic model runtime auth when no credential exists", async () => {
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves Anthropic fallback resolver auth when no stored credential exists", async () => {
|
||||
const authStorage = createFusionAuthStorage();
|
||||
(authStorage as unknown as { setFallbackResolver(resolver: (provider: string) => string | undefined): void })
|
||||
.setFallbackResolver((provider) => (provider === "anthropic" ? "fallback-anthropic-runtime-key" : undefined));
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("fallback-anthropic-runtime-key");
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses Anthropic fallback resolver auth after raw provider logout", async () => {
|
||||
const authStorage = createFusionAuthStorage();
|
||||
(authStorage as unknown as { setFallbackResolver(resolver: (provider: string) => string | undefined): void })
|
||||
.setFallbackResolver((provider) => (provider === "anthropic" ? "fallback-anthropic-runtime-key" : undefined));
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("fallback-anthropic-runtime-key");
|
||||
|
||||
authStorage.logout("anthropic");
|
||||
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(false);
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses raw Anthropic API-key credentials for model runtime auth", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("sk-ant-api03-runtime-key");
|
||||
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-runtime-key" });
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses Anthropic subscription OAuth for model runtime auth when no raw API key exists", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
||||
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
|
||||
});
|
||||
|
||||
it("keeps raw Anthropic API-key precedence when subscription OAuth also exists", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("sk-ant-api03-runtime-key");
|
||||
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
|
||||
});
|
||||
|
||||
it("keeps raw Anthropic API-key precedence over legacy OAuth hydration", async () => {
|
||||
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||
mkdirSync(legacyAgentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(legacyAgentDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
anthropic: {
|
||||
type: "oauth",
|
||||
access: "legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("sk-ant-api03-runtime-key");
|
||||
});
|
||||
|
||||
it("refreshes expired Anthropic subscription OAuth and persists it under the subscription storage id", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "expired-subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
scopes: ["user:profile", "org:create_api_key"],
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: "refreshed-subscription-access-token",
|
||||
refresh_token: "rotated-subscription-refresh-token",
|
||||
expires_in: 3600,
|
||||
scope: "user:profile org:create_api_key",
|
||||
}),
|
||||
} as Response);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""),
|
||||
}),
|
||||
);
|
||||
expect(authStorage.get("anthropic-subscription")).toEqual({
|
||||
type: "oauth",
|
||||
access: "refreshed-subscription-access-token",
|
||||
refresh: "rotated-subscription-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
scopes: ["user:profile", "org:create_api_key"],
|
||||
});
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
|
||||
const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8"));
|
||||
expect(persisted["anthropic-subscription"]).toEqual({
|
||||
type: "oauth",
|
||||
access: "refreshed-subscription-access-token",
|
||||
refresh: "rotated-subscription-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
scopes: ["user:profile", "org:create_api_key"],
|
||||
});
|
||||
expect(persisted.anthropic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resurrect stale Anthropic subscription OAuth after failed refresh", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "expired-subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
});
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8"));
|
||||
expect(persisted["anthropic-subscription"]).toEqual({
|
||||
type: "oauth",
|
||||
access: "expired-subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps subscription OAuth available for runtime auth after raw Anthropic logout", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
authStorage.logout("anthropic");
|
||||
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
expect(authStorage.has("anthropic")).toBe(true);
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
||||
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
|
||||
});
|
||||
|
||||
it("uses a newly set subscription credential for runtime auth after raw Anthropic logout", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
authStorage.logout("anthropic");
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
|
||||
authStorage.set("anthropic-subscription", {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
|
||||
});
|
||||
|
||||
it("keeps raw Anthropic API keys visible when subscription logout suppresses OAuth aliases", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
authStorage.logout("anthropic-subscription");
|
||||
|
||||
expect(authStorage.get("anthropic-subscription")).toBeUndefined();
|
||||
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-runtime-key" });
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("sk-ant-api03-runtime-key");
|
||||
});
|
||||
|
||||
it("suppresses legacy Anthropic OAuth after subscription logout", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: {
|
||||
type: "oauth",
|
||||
access: "legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token");
|
||||
|
||||
authStorage.logout("anthropic-subscription");
|
||||
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
expect(authStorage.has("anthropic")).toBe(false);
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(false);
|
||||
expect(authStorage.list()).not.toContain("anthropic");
|
||||
expect(authStorage.getAll()).not.toHaveProperty("anthropic");
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses supplemental legacy Anthropic OAuth status after subscription logout", async () => {
|
||||
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||
mkdirSync(legacyAgentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(legacyAgentDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
anthropic: {
|
||||
type: "oauth",
|
||||
access: "legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
||||
expect(authStorage.list()).toContain("anthropic");
|
||||
|
||||
authStorage.logout("anthropic-subscription");
|
||||
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
expect(authStorage.has("anthropic")).toBe(false);
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(false);
|
||||
expect(authStorage.list()).not.toContain("anthropic");
|
||||
expect(authStorage.getAll()).not.toHaveProperty("anthropic");
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps models.json Anthropic fallback visible after subscription logout", async () => {
|
||||
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||
mkdirSync(fusionAgentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(fusionAgentDir, "models.json"),
|
||||
JSON.stringify({ providers: { anthropic: { apiKey: "models-runtime-key" } } }),
|
||||
);
|
||||
writeFusionAuth(homeDir, {
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
authStorage.logout("anthropic-subscription");
|
||||
|
||||
expect(authStorage.has("anthropic")).toBe(true);
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
||||
expect(authStorage.list()).toContain("anthropic");
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("models-runtime-key");
|
||||
});
|
||||
|
||||
it("suppresses models.json Anthropic fallback after raw provider logout", async () => {
|
||||
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||
mkdirSync(fusionAgentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(fusionAgentDir, "models.json"),
|
||||
JSON.stringify({ providers: { anthropic: { apiKey: "models-runtime-key" } } }),
|
||||
);
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
expect(authStorage.has("anthropic")).toBe(true);
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("models-runtime-key");
|
||||
|
||||
authStorage.logout("anthropic");
|
||||
|
||||
expect(authStorage.has("anthropic")).toBe(false);
|
||||
expect(authStorage.hasAuth("anthropic")).toBe(false);
|
||||
expect(authStorage.list()).not.toContain("anthropic");
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reloads Anthropic subscription OAuth alias state for model runtime auth", async () => {
|
||||
writeFusionAuth(homeDir, {});
|
||||
const authStorage = createFusionAuthStorage();
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
|
||||
writeFusionAuth(homeDir, {
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
authStorage.reload();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes and persists expired Claude OAuth credentials from Claude credential files", async () => {
|
||||
const claudeDir = join(homeDir, ".claude");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
|
||||
type StoredCredential = StoredAuthCredential;
|
||||
|
||||
const OAUTH_REFRESH_BUFFER_MS = 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";
|
||||
const ANTHROPIC_DEFAULT_SCOPES = ["user:profile"];
|
||||
@@ -101,6 +103,10 @@ function resolveStoredApiKey(key: string | undefined): string | 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" ||
|
||||
@@ -112,7 +118,7 @@ function resolveOAuthApiKey(providerId: string, credential: StoredCredential): s
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getOAuthProvider(providerId)?.getApiKey(credential as OAuthCredentials);
|
||||
return getOAuthProvider(getOAuthResolutionProviderId(providerId))?.getApiKey(credential as OAuthCredentials);
|
||||
}
|
||||
|
||||
function shouldRefreshOAuthCredential(credential: StoredCredential): boolean {
|
||||
@@ -249,7 +255,7 @@ async function refreshOAuthCredential(providerId: string, credential: StoredCred
|
||||
if (!shouldRefreshOAuthCredential(credential)) {
|
||||
return credential;
|
||||
}
|
||||
if (providerId !== "anthropic") {
|
||||
if (getOAuthResolutionProviderId(providerId) !== ANTHROPIC_PROVIDER_ID) {
|
||||
return undefined;
|
||||
}
|
||||
return refreshAnthropicOAuthCredential(credential);
|
||||
@@ -342,7 +348,7 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
};
|
||||
|
||||
const refreshProviderOAuthCredential = async (
|
||||
provider: string,
|
||||
storageProvider: string,
|
||||
credential: StoredCredential,
|
||||
): Promise<StoredCredential | undefined> => {
|
||||
if (!shouldRefreshOAuthCredential(credential)) {
|
||||
@@ -350,37 +356,184 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cooldownUntil = oauthRefreshCooldownUntil.get(provider);
|
||||
const cooldownUntil = oauthRefreshCooldownUntil.get(storageProvider);
|
||||
if (cooldownUntil && cooldownUntil > now) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const existing = oauthRefreshInFlight.get(provider);
|
||||
const existing = oauthRefreshInFlight.get(storageProvider);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const refreshPromise = refreshOAuthCredential(provider, credential)
|
||||
const refreshPromise = refreshOAuthCredential(storageProvider, credential)
|
||||
.then((refreshed) => {
|
||||
if (refreshed) {
|
||||
oauthRefreshCooldownUntil.delete(provider);
|
||||
oauthRefreshCooldownUntil.delete(storageProvider);
|
||||
} else {
|
||||
oauthRefreshCooldownUntil.set(provider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
|
||||
oauthRefreshCooldownUntil.set(storageProvider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
|
||||
}
|
||||
return refreshed;
|
||||
})
|
||||
.catch(() => {
|
||||
oauthRefreshCooldownUntil.set(provider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
|
||||
oauthRefreshCooldownUntil.set(storageProvider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
oauthRefreshInFlight.delete(provider);
|
||||
oauthRefreshInFlight.delete(storageProvider);
|
||||
});
|
||||
|
||||
oauthRefreshInFlight.set(provider, refreshPromise);
|
||||
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 selectVisibleStoredCredential = (provider: string) => {
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return undefined;
|
||||
}
|
||||
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 = !isAnthropicSubscriptionLoggedOut()
|
||||
&& (primary.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)
|
||||
|| ANTHROPIC_SUBSCRIPTION_PROVIDER_ID in supplementalCredentials);
|
||||
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();
|
||||
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:
|
||||
Anthropic model execution still requests provider `anthropic`, but the separated subscription login now stores OAuth material under `anthropic-subscription` so the API-key card can remain raw-key-only.
|
||||
Resolve and refresh the subscription credential with the upstream Anthropic OAuth provider id while persisting rotated tokens back to `anthropic-subscription`.
|
||||
*/
|
||||
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, while subscription OAuth remains 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, {
|
||||
@@ -398,6 +551,12 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,6 +564,12 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,19 +591,14 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
}
|
||||
|
||||
if (prop === "get") {
|
||||
return (provider: string) => {
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return undefined;
|
||||
}
|
||||
return choosePreferredStoredCredential(
|
||||
target.get(provider) as StoredCredential | undefined,
|
||||
supplementalCredentials[provider],
|
||||
);
|
||||
};
|
||||
return (provider: string) => selectVisibleStoredCredential(provider);
|
||||
}
|
||||
|
||||
if (prop === "has") {
|
||||
return (provider: string) => {
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicCredential();
|
||||
}
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return false;
|
||||
}
|
||||
@@ -448,6 +608,9 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
|
||||
if (prop === "hasAuth") {
|
||||
return (provider: string) => {
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicCredential();
|
||||
}
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return false;
|
||||
}
|
||||
@@ -465,13 +628,7 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
]);
|
||||
const merged: Record<string, StoredCredential> = {};
|
||||
for (const providerId of providerIds) {
|
||||
if (loggedOutProviders.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
const credential = choosePreferredStoredCredential(
|
||||
(target.get(providerId) as StoredCredential | undefined),
|
||||
supplementalCredentials[providerId],
|
||||
);
|
||||
const credential = selectVisibleStoredCredential(providerId);
|
||||
if (credential) {
|
||||
merged[providerId] = credential;
|
||||
}
|
||||
@@ -493,46 +650,49 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
providers.add(p);
|
||||
}
|
||||
}
|
||||
return Array.from(providers).filter((p) => !loggedOutProviders.has(p));
|
||||
if (
|
||||
!loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)
|
||||
&& (providers.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) || supplementalCredentials[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 subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
return subscriptionCredential?.type === "oauth"
|
||||
? resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// 1. Primary Fusion auth
|
||||
const primaryKey = await target.getApiKey(provider);
|
||||
if (primaryKey) return primaryKey;
|
||||
|
||||
// 2. Supplemental auth.json credentials (.pi + .codex)
|
||||
const refreshCandidate = choosePreferredStoredCredential(
|
||||
target.get(provider) as StoredCredential | undefined,
|
||||
supplementalCredentials[provider],
|
||||
) ?? {};
|
||||
const refreshWasNeeded = shouldRefreshOAuthCredential(refreshCandidate);
|
||||
const refreshedCredential = await refreshProviderOAuthCredential(provider, refreshCandidate);
|
||||
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 = choosePreferredStoredCredential(
|
||||
target.get(provider) as StoredCredential | undefined,
|
||||
supplementalCredentials[provider],
|
||||
);
|
||||
if (!isSameOAuthCredentialIdentity(latestCredential, refreshCandidate)) {
|
||||
return resolveStoredCredentialApiKey(provider, latestCredential);
|
||||
}
|
||||
}
|
||||
target.set(provider, refreshedCredential as AuthCredential);
|
||||
loggedOutProviders.delete(provider);
|
||||
return resolveStoredCredentialApiKey(provider, refreshedCredential);
|
||||
}
|
||||
const refreshCandidate = selectStoredCredential(provider);
|
||||
const refreshedKey = await resolveRefreshableCredentialApiKey(provider, refreshCandidate);
|
||||
if (refreshedKey) return refreshedKey;
|
||||
|
||||
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
|
||||
if (supplementalKey) return supplementalKey;
|
||||
|
||||
Reference in New Issue
Block a user