FN-7576: delegate anthropic-subscription getApiKey to engine authStorage
Reads for the anthropic-subscription provider now delegate to the real engine authStorage so expired OAuth tokens are refreshed instead of silently failing.
- mergeAuthStorageReads getApiKey("anthropic-subscription") now calls target.getApiKey(providerId) directly, triggering the engine's refresh-token HTTP round trip, instead of a local static Date.now() >= credential.expires check
- Falls back to the read-only fallback storages' local resolution only when the primary engine call yields no key
- Added regression tests exercising the wrapper directly against the engine delegation and fallback behavior
- Added a patch changeset documenting the fix for @runfusion/fusion
Files changed:
.changeset/fn-7576-cli-wrapper-subscription-getapikey-delegation.md | 7 +
packages/cli/src/commands/__tests__/provider-auth.test.ts | 148 +++++++++++++++++++++
packages/cli/src/commands/provider-auth.ts | 12 +-
3 files changed, 166 insertions(+), 1 deletion(-)
Fusion-Task-Id: FN-7576
Fusion-Task-Lineage: 31e495ec-8487-468b-9b80-36e8d0f53806
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Anthropic subscription reads now refresh the OAuth token automatically instead of silently failing when expired.
|
||||||
|
category: fix
|
||||||
|
dev: mergeAuthStorageReads getApiKey("anthropic-subscription") now delegates to the underlying engine authStorage.getApiKey (the only refresh-token HTTP round trip) instead of a local static expiry check; regression tests drive the wrapper directly. No token material logged.
|
||||||
@@ -585,4 +585,152 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
|
|||||||
expect(merged.has("anthropic")).toBe(false);
|
expect(merged.has("anthropic")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("anthropic-subscription getApiKey delegation (FN-7576)", () => {
|
||||||
|
/*
|
||||||
|
FNXC:ProviderAuth 2026-07-05-09:15:
|
||||||
|
These tests drive `wrapAuthStorageWithApiKeyProviders`/`mergeAuthStorageReads` directly against an instrumented fake engine `authStorage.getApiKey`, not a mocked dashboard/engine `AuthStorageLike`, per FN-7576's "fix the invariant, not the repro" requirement. They assert the wrapper actually DELEGATES the anthropic-subscription read to the real engine authStorage (so the refresh HTTP round trip in packages/engine/src/auth-storage.ts executes) rather than short-circuiting to a local static `Date.now() >= credential.expires` check.
|
||||||
|
*/
|
||||||
|
const PAST_EXPIRY = Date.now() - 60_000;
|
||||||
|
|
||||||
|
it("delegates to the underlying engine authStorage.getApiKey and returns the refreshed key even when the stored credential is expired", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage({
|
||||||
|
"anthropic-subscription": {
|
||||||
|
type: "oauth",
|
||||||
|
access: "stale-oauth-access",
|
||||||
|
refresh: "stale-oauth-refresh",
|
||||||
|
expires: PAST_EXPIRY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
|
||||||
|
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
|
||||||
|
);
|
||||||
|
const modelRegistry = { getAll: vi.fn(() => []) } as any;
|
||||||
|
|
||||||
|
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
|
||||||
|
const apiKey = await wrapped.getApiKey("anthropic-subscription");
|
||||||
|
|
||||||
|
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
|
||||||
|
expect(apiKey).toBe("refreshed-oauth-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates via the mergeAuthStorageReads proxy directly", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage({
|
||||||
|
"anthropic-subscription": {
|
||||||
|
type: "oauth",
|
||||||
|
access: "stale-oauth-access",
|
||||||
|
refresh: "stale-oauth-refresh",
|
||||||
|
expires: PAST_EXPIRY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
|
||||||
|
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = mergeAuthStorageReads(fusionAuth);
|
||||||
|
const apiKey = await merged.getApiKey("anthropic-subscription");
|
||||||
|
|
||||||
|
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
|
||||||
|
expect(apiKey).toBe("refreshed-oauth-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates and returns the refreshed key when OAuth is stored under the legacy anthropic row", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage({
|
||||||
|
anthropic: {
|
||||||
|
type: "oauth",
|
||||||
|
access: "legacy-stale-oauth-access",
|
||||||
|
refresh: "legacy-stale-oauth-refresh",
|
||||||
|
expires: PAST_EXPIRY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
|
||||||
|
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
|
||||||
|
);
|
||||||
|
const modelRegistry = { getAll: vi.fn(() => []) } as any;
|
||||||
|
|
||||||
|
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
|
||||||
|
const apiKey = await wrapped.getApiKey("anthropic-subscription");
|
||||||
|
|
||||||
|
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
|
||||||
|
expect(apiKey).toBe("refreshed-oauth-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call the delegated engine getApiKey and returns undefined once the subscription is logged out", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage({
|
||||||
|
"anthropic-subscription": {
|
||||||
|
type: "oauth",
|
||||||
|
access: "oauth-access",
|
||||||
|
refresh: "oauth-refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
|
||||||
|
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
|
||||||
|
);
|
||||||
|
const modelRegistry = { getAll: vi.fn(() => []) } as any;
|
||||||
|
|
||||||
|
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
|
||||||
|
wrapped.logout("anthropic-subscription");
|
||||||
|
fusionAuth.getApiKey.mockClear();
|
||||||
|
|
||||||
|
const apiKey = await wrapped.getApiKey("anthropic-subscription");
|
||||||
|
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
expect(fusionAuth.getApiKey).not.toHaveBeenCalledWith("anthropic-subscription");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no credential is stored and the engine authStorage has nothing to refresh", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage();
|
||||||
|
const modelRegistry = { getAll: vi.fn(() => []) } as any;
|
||||||
|
|
||||||
|
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
|
||||||
|
const apiKey = await wrapped.getApiKey("anthropic-subscription");
|
||||||
|
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a read-only fallback storage's local resolution only when the primary engine yields no key", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage({
|
||||||
|
"anthropic-subscription": {
|
||||||
|
type: "oauth",
|
||||||
|
access: "stale-oauth-access",
|
||||||
|
refresh: "stale-oauth-refresh",
|
||||||
|
expires: PAST_EXPIRY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Primary engine authStorage genuinely cannot refresh (e.g. refresh token invalid/absent upstream).
|
||||||
|
fusionAuth.getApiKey = vi.fn(async () => undefined);
|
||||||
|
const fallbackAuth = makeAuthStorage({
|
||||||
|
"anthropic-subscription": {
|
||||||
|
type: "oauth",
|
||||||
|
access: "fallback-still-valid-access",
|
||||||
|
refresh: "fallback-refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fallbackAuth.getApiKey = vi.fn(async (provider: string) =>
|
||||||
|
provider === "anthropic-subscription" ? "fallback-still-valid-access" : undefined,
|
||||||
|
);
|
||||||
|
const modelRegistry = { getAll: vi.fn(() => []) } as any;
|
||||||
|
|
||||||
|
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [fallbackAuth]);
|
||||||
|
const apiKey = await wrapped.getApiKey("anthropic-subscription");
|
||||||
|
|
||||||
|
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
|
||||||
|
expect(apiKey).toBe("fallback-still-valid-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still resolves non-subscription providers exactly as before (no regression)", async () => {
|
||||||
|
const fusionAuth = makeAuthStorage({
|
||||||
|
openrouter: { type: "api_key", key: "fusion-openrouter-key" },
|
||||||
|
anthropic: { type: "api_key", key: "sk-ant-api03-raw-key" },
|
||||||
|
});
|
||||||
|
const modelRegistry = { getAll: vi.fn(() => []) } as any;
|
||||||
|
|
||||||
|
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
|
||||||
|
|
||||||
|
expect(await wrapped.getApiKey("openrouter")).toBe("fusion-openrouter-key");
|
||||||
|
expect(await wrapped.getApiKey("anthropic-api-key")).toBe("sk-ant-api03-raw-key");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -415,7 +415,17 @@ export function mergeAuthStorageReads(
|
|||||||
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
|
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
|
||||||
}
|
}
|
||||||
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID && credential) {
|
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID && credential) {
|
||||||
return resolveStoredCredentialApiKey(providerId, credential);
|
/*
|
||||||
|
FNXC:ProviderAuth 2026-07-05-09:10:
|
||||||
|
Reading `anthropic-subscription` through this merge proxy must delegate to the underlying real engine `authStorage.getApiKey(...)` (the `target` primary storage) so the refresh-token HTTP round trip in packages/engine/src/auth-storage.ts actually runs. The prior local static `Date.now() >= credential.expires` check (`resolveStoredCredentialApiKey`/`resolveOAuthApiKey`) never called the real engine and silently no-oped the refresh in production, e.g. the dashboard status route's best-effort refresh-on-expiry read (register-auth-routes.ts). `target.getApiKey` internally handles both the separated `anthropic-subscription` row and the legacy `anthropic` OAuth row, so this single delegated call covers both storage permutations without duplicating that logic here. Only fall back to the read-only fallback storages' local (non-refreshing) resolution when the primary engine yields no key; a logged-out subscription is already excluded above and must never reach this delegated call.
|
||||||
|
*/
|
||||||
|
const engineApiKey = await target.getApiKey(providerId);
|
||||||
|
if (engineApiKey) return engineApiKey;
|
||||||
|
for (const fallbackStorage of readFallbackAuthStorages) {
|
||||||
|
const fallbackApiKey = await fallbackStorage.getApiKey(providerId);
|
||||||
|
if (fallbackApiKey) return fallbackApiKey;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
for (const storage of readAuthStorages) {
|
for (const storage of readAuthStorages) {
|
||||||
const apiKey = await storage.getApiKey(providerId);
|
const apiKey = await storage.getApiKey(providerId);
|
||||||
|
|||||||
Reference in New Issue
Block a user