From dfb255249fe56135f7dd47a7f4fa6f9b0482eecf Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 20:51:44 -0700 Subject: [PATCH 1/5] fix(auth): prevent credential resurrection after Anthropic logout The logout flow had two bugs causing credentials to reappear immediately: 1. The codebase has two separate auth storage Proxy chains: - createFusionAuthStorage (engine, for agents) - mergeAuthStorageReads (CLI, for dashboard UI) Neither had a logout trap, so supplemental credentials from ~/.claude/.credentials.json were never excluded after logout. 2. The upstream AuthStorage.hasAuth() checks environment variables (ANTHROPIC_API_KEY), which always returns true regardless of logout. Fix: Add loggedOutProviders tracking to both Proxy chains. All query traps (has, hasAuth, get, getAll, list, getApiKey) return false/undefined for logged-out providers instead of delegating to the underlying storage. Co-Authored-By: Claude Opus 4.6 --- .../commands/__tests__/provider-auth.test.ts | 133 +++++++++++++++ packages/cli/src/commands/provider-auth.ts | 55 +++++- .../src/routes/register-auth-routes.ts | 1 + .../engine/src/__tests__/auth-storage.test.ts | 157 ++++++++++++++++++ packages/engine/src/auth-storage.ts | 64 ++++++- 5 files changed, 399 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/__tests__/provider-auth.test.ts b/packages/cli/src/commands/__tests__/provider-auth.test.ts index 227b95188..501a90587 100644 --- a/packages/cli/src/commands/__tests__/provider-auth.test.ts +++ b/packages/cli/src/commands/__tests__/provider-auth.test.ts @@ -209,4 +209,137 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(wrapped.hasApiKey("anthropic")).toBe(true); }); }); + + describe("logout with fallback credentials", () => { + it("hides fallback credentials after logout", () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + + // Before logout, fallback credentials are visible + expect(merged.has("anthropic")).toBe(true); + expect(merged.hasAuth("anthropic")).toBe(true); + expect(merged.get("anthropic")).toEqual({ type: "api_key", key: "claude-access-token" }); + + // Log out + merged.logout("anthropic"); + + // After logout, fallback credentials are hidden + expect(merged.has("anthropic")).toBe(false); + expect(merged.hasAuth("anthropic")).toBe(false); + expect(merged.get("anthropic")).toBeUndefined(); + }); + + it("does not resurrect fallback credentials on reload after logout", () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + merged.logout("anthropic"); + + // reload() should NOT bring back the fallback credential + merged.reload(); + + expect(merged.has("anthropic")).toBe(false); + expect(merged.hasAuth("anthropic")).toBe(false); + }); + + it("excludes logged-out providers from getAll()", () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + openrouter: { type: "api_key", key: "openrouter-key" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + merged.logout("anthropic"); + + const all = merged.getAll(); + expect("anthropic" in all).toBe(false); + expect("openrouter" in all).toBe(true); + }); + + it("excludes logged-out providers from list()", () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + openrouter: { type: "api_key", key: "openrouter-key" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + merged.logout("anthropic"); + + expect(merged.list()).not.toContain("anthropic"); + expect(merged.list()).toContain("openrouter"); + }); + + it("hides fallback getApiKey after logout", async () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + + expect(await merged.getApiKey("anthropic")).toBe("claude-access-token"); + + merged.logout("anthropic"); + + expect(await merged.getApiKey("anthropic")).toBeUndefined(); + }); + + it("re-enables fallback credentials after re-authentication via set()", () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + merged.logout("anthropic"); + + // Re-authenticate + merged.set("anthropic", { type: "api_key", key: "new-key" }); + + // Provider is visible again (from primary storage) + expect(merged.has("anthropic")).toBe(true); + }); + + it("only hides the logged-out provider, not other fallback providers", () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + openrouter: { type: "api_key", key: "openrouter-key" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + merged.logout("anthropic"); + + // anthropic is hidden + expect(merged.hasAuth("anthropic")).toBe(false); + // openrouter is still visible + expect(merged.hasAuth("openrouter")).toBe(true); + }); + + it("returns false for hasAuth even when underlying storage reports auth via env var", () => { + // Simulate the real AuthStorage which checks env vars in hasAuth + const fusionAuth = makeAuthStorage(); + fusionAuth.hasAuth = vi.fn(() => true); // env var would make this true + const fallbackAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "claude-access-token" }, + }); + + const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); + merged.logout("anthropic"); + + // Even though the underlying storage reports hasAuth=true (env var), + // the logged-out provider must still return false + expect(merged.hasAuth("anthropic")).toBe(false); + expect(merged.has("anthropic")).toBe(false); + }); + }); }); diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index 843331907..84ae26c31 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -138,6 +138,12 @@ export function mergeAuthStorageReads( readFallbackAuthStorages: ReadFallbackAuthStorage[] = [], ): AuthStorage { const readAuthStorages = [authStorage, ...readFallbackAuthStorages]; + + // 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(); + const selectCredential = ( providerId: string, storages: Array>, @@ -149,11 +155,19 @@ export function mergeAuthStorageReads( return best; }; - const getCredential = (providerId: string) => selectCredential(providerId, readAuthStorages); + const getCredential = (providerId: string) => { + if (loggedOutProviders.has(providerId)) { + return authStorage.get(providerId) as StoredCredential | undefined; + } + return selectCredential(providerId, readAuthStorages); + }; const syncFallbackOauthCredentials = () => { const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list())); for (const providerId of providerIds) { + if (loggedOutProviders.has(providerId)) { + continue; + } const current = authStorage.get(providerId) as StoredCredential | undefined; const candidate = selectCredential(providerId, readFallbackAuthStorages); if (!shouldHydrateStoredCredential(current, candidate)) { @@ -169,6 +183,20 @@ export function mergeAuthStorageReads( return new Proxy(authStorage, { get(target, prop, receiver) { + if (prop === "logout") { + return (provider: string) => { + loggedOutProviders.add(provider); + target.logout(provider); + }; + } + + if (prop === "set") { + return (provider: string, credential: AuthCredential) => { + loggedOutProviders.delete(provider); + target.set(provider, credential); + }; + } + if (prop === "reload") { return () => { for (const storage of readAuthStorages) { @@ -183,11 +211,21 @@ export function mergeAuthStorageReads( } if (prop === "has") { - return (provider: string) => readAuthStorages.some((storage) => Boolean(storage.get(provider))); + return (provider: string) => { + if (loggedOutProviders.has(provider)) { + return false; + } + return readAuthStorages.some((storage) => Boolean(storage.get(provider))); + }; } if (prop === "hasAuth") { - return (provider: string) => readAuthStorages.some((storage) => storage.hasAuth(provider)); + return (provider: string) => { + if (loggedOutProviders.has(provider)) { + return false; + } + return readAuthStorages.some((storage) => storage.hasAuth(provider)); + }; } if (prop === "getAll") { @@ -195,6 +233,9 @@ export function mergeAuthStorageReads( const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list())); const merged: Record = {}; for (const providerId of providerIds) { + if (loggedOutProviders.has(providerId)) { + continue; + } const credential = getCredential(providerId); if (credential) { merged[providerId] = credential; @@ -205,11 +246,17 @@ export function mergeAuthStorageReads( } if (prop === "list") { - return () => Array.from(new Set(readAuthStorages.flatMap((storage) => storage.list()))); + return () => { + const providers = readAuthStorages.flatMap((storage) => storage.list()); + return Array.from(new Set(providers.filter((p) => !loggedOutProviders.has(p)))); + }; } if (prop === "getApiKey") { return async (providerId: string) => { + if (loggedOutProviders.has(providerId)) { + return undefined; + } for (const storage of readAuthStorages) { const apiKey = await storage.getApiKey(providerId); if (apiKey) return apiKey; diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index e53a73046..c59fb1ede 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -899,6 +899,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { const storage = getAuthStorage(); storage.logout(provider); + clearUsageCache(); res.json({ success: true }); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 0745be632..2f80035c1 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -396,4 +396,161 @@ describe("createFusionAuthStorage", () => { expect(authStorage.hasAuth("dynamic-provider")).toBe(true); }); }); + + describe("logout with supplemental credentials", () => { + it("hides supplemental Claude credentials after logout", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() + 3_600_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + + // Before logout, supplemental credentials are visible + expect(authStorage.has("anthropic")).toBe(true); + expect(authStorage.hasAuth("anthropic")).toBe(true); + expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); + + // Log out + authStorage.logout("anthropic"); + + // After logout, supplemental credentials are hidden + expect(authStorage.has("anthropic")).toBe(false); + expect(authStorage.hasAuth("anthropic")).toBe(false); + expect(authStorage.get("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + }); + + it("does not resurrect supplemental credentials on reload after logout", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() + 3_600_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + authStorage.logout("anthropic"); + + // reload() should NOT bring back the supplemental credential + authStorage.reload(); + + expect(authStorage.has("anthropic")).toBe(false); + expect(authStorage.hasAuth("anthropic")).toBe(false); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + }); + + it("excludes logged-out providers from getAll()", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() + 3_600_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + authStorage.logout("anthropic"); + + const all = authStorage.getAll(); + expect("anthropic" in all).toBe(false); + }); + + it("excludes logged-out providers from list()", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() + 3_600_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + authStorage.logout("anthropic"); + + expect(authStorage.list()).not.toContain("anthropic"); + }); + + it("re-enables supplemental credentials after re-authentication via set()", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() + 3_600_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + authStorage.logout("anthropic"); + + // Re-authenticate + authStorage.set("anthropic", { type: "api_key", key: "new-key" }); + + // Provider is visible again + expect(authStorage.has("anthropic")).toBe(true); + expect(await authStorage.getApiKey("anthropic")).toBe("new-key"); + }); + + it("only hides the logged-out provider, not other supplemental providers", async () => { + const claudeDir = join(homeDir, ".claude"); + const legacyDir = join(homeDir, ".pi", "agent"); + mkdirSync(claudeDir, { recursive: true }); + mkdirSync(legacyDir, { recursive: true }); + + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() + 3_600_000, + }, + }), + ); + writeFileSync( + join(legacyDir, "auth.json"), + JSON.stringify({ + openrouter: { type: "api_key", key: "legacy-openrouter-key" }, + }), + ); + + const authStorage = createFusionAuthStorage(); + authStorage.logout("anthropic"); + + // anthropic is hidden + expect(authStorage.hasAuth("anthropic")).toBe(false); + // openrouter is still visible + expect(authStorage.hasAuth("openrouter")).toBe(true); + expect(await authStorage.getApiKey("openrouter")).toBe("legacy-openrouter-key"); + }); + }); }); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 274d1f3c5..6415b67e2 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -143,8 +143,16 @@ export function createFusionAuthStorage(): AuthStorage { // models.json provider API keys — final fallback after primary auth and supplemental auth.json files let modelsJsonApiKeys = readModelsJsonApiKeys(); + // 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(); + 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; @@ -168,6 +176,20 @@ export function createFusionAuthStorage(): AuthStorage { }, get(target, prop, receiver) { + if (prop === "logout") { + return (provider: string) => { + loggedOutProviders.add(provider); + target.logout(provider); + }; + } + + if (prop === "set") { + return (provider: string, credential: AuthCredential) => { + loggedOutProviders.delete(provider); + target.set(provider, credential); + }; + } + if (prop === "reload") { return () => { target.reload(); @@ -178,32 +200,48 @@ export function createFusionAuthStorage(): AuthStorage { } if (prop === "get") { - return (provider: string) => - choosePreferredStoredCredential( + return (provider: string) => { + if (loggedOutProviders.has(provider)) { + return target.get(provider); + } + return choosePreferredStoredCredential( target.get(provider) as StoredCredential | undefined, supplementalCredentials[provider], ); + }; } if (prop === "has") { - return (provider: string) => target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider); + return (provider: string) => { + if (loggedOutProviders.has(provider)) { + return false; + } + return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider); + }; } if (prop === "hasAuth") { - return (provider: string) => target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider); + return (provider: string) => { + if (loggedOutProviders.has(provider)) { + return false; + } + return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider); + }; } if (prop === "getAll") { return () => { const providerIds = new Set([ - ...Object.keys(supplementalCredentials), ...Object.keys(target.getAll() as Record), + ...(loggedOutProviders.size > 0 + ? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p)) + : Object.keys(supplementalCredentials)), ]); const merged: Record = {}; for (const providerId of providerIds) { const credential = choosePreferredStoredCredential( (target.get(providerId) as StoredCredential | undefined), - supplementalCredentials[providerId], + loggedOutProviders.has(providerId) ? undefined : supplementalCredentials[providerId], ); if (credential) { merged[providerId] = credential; @@ -214,11 +252,23 @@ export function createFusionAuthStorage(): AuthStorage { } if (prop === "list") { - return () => Array.from(new Set([...Object.keys(supplementalCredentials), ...target.list(), ...modelsJsonApiKeys.keys()])); + return () => { + const providers = new Set([...target.list(), ...modelsJsonApiKeys.keys()]); + for (const p of Object.keys(supplementalCredentials)) { + if (!loggedOutProviders.has(p)) { + providers.add(p); + } + } + return Array.from(providers); + }; } if (prop === "getApiKey") { return async (provider: string) => { + if (loggedOutProviders.has(provider)) { + return undefined; + } + // 1. Primary Fusion auth const primaryKey = await target.getApiKey(provider); if (primaryKey) return primaryKey; From b4948a4ee0ea3fe41ccda9f4b8adbbb87eb7b7d7 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 21:21:33 -0700 Subject: [PATCH 2/5] fix(auth): harden logout proxy traps from review feedback - get() now returns undefined for logged-out providers instead of delegating to target.get() which could bypass the guard - getCredential() in provider-auth returns undefined for logged-out providers instead of falling through to authStorage.get() - getAll() skips logged-out providers at top of loop - list() filters modelsJsonApiKeys against loggedOutProviders - Added remove() trap in provider-auth for clearApiKey flow Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/provider-auth.ts | 9 ++++++++- packages/engine/src/auth-storage.ts | 14 +++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index 84ae26c31..7c27040d6 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -157,7 +157,7 @@ export function mergeAuthStorageReads( const getCredential = (providerId: string) => { if (loggedOutProviders.has(providerId)) { - return authStorage.get(providerId) as StoredCredential | undefined; + return undefined; } return selectCredential(providerId, readAuthStorages); }; @@ -190,6 +190,13 @@ export function mergeAuthStorageReads( }; } + if (prop === "remove") { + return (provider: string) => { + loggedOutProviders.add(provider); + target.remove(provider); + }; + } + if (prop === "set") { return (provider: string, credential: AuthCredential) => { loggedOutProviders.delete(provider); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 6415b67e2..4ec201b45 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -202,7 +202,7 @@ export function createFusionAuthStorage(): AuthStorage { if (prop === "get") { return (provider: string) => { if (loggedOutProviders.has(provider)) { - return target.get(provider); + return undefined; } return choosePreferredStoredCredential( target.get(provider) as StoredCredential | undefined, @@ -239,9 +239,12 @@ export function createFusionAuthStorage(): AuthStorage { ]); const merged: Record = {}; for (const providerId of providerIds) { + if (loggedOutProviders.has(providerId)) { + continue; + } const credential = choosePreferredStoredCredential( (target.get(providerId) as StoredCredential | undefined), - loggedOutProviders.has(providerId) ? undefined : supplementalCredentials[providerId], + supplementalCredentials[providerId], ); if (credential) { merged[providerId] = credential; @@ -253,7 +256,12 @@ export function createFusionAuthStorage(): AuthStorage { if (prop === "list") { return () => { - const providers = new Set([...target.list(), ...modelsJsonApiKeys.keys()]); + 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); From 517061adbbcabcd1efb857e5760485b0f4348db2 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 21:33:44 -0700 Subject: [PATCH 3/5] fix(auth): filter target.list() against loggedOutProviders for consistency The list() trap now applies a final filter against loggedOutProviders, matching the defensive approach used in the CLI layer. While target.logout() removes entries from underlying storage, this prevents any edge case where a logged-out provider could appear in list() results. Co-Authored-By: Claude Opus 4.6 --- packages/engine/src/auth-storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 4ec201b45..b16fb7904 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -267,7 +267,7 @@ export function createFusionAuthStorage(): AuthStorage { providers.add(p); } } - return Array.from(providers); + return Array.from(providers).filter((p) => !loggedOutProviders.has(p)); }; } From 0a588b701f225f01887738927a40fa20bb074667 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 21:42:13 -0700 Subject: [PATCH 4/5] fix(auth): update tombstones after storage writes to prevent state drift Reorder logout/set/remove traps so in-memory loggedOutProviders is only updated after the underlying storage write succeeds. If target.logout() or target.set() throws, the tombstone set now stays consistent with the actual storage state. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/provider-auth.ts | 6 +++--- packages/engine/src/auth-storage.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index 7c27040d6..dc477e0b5 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -185,22 +185,22 @@ export function mergeAuthStorageReads( get(target, prop, receiver) { if (prop === "logout") { return (provider: string) => { - loggedOutProviders.add(provider); target.logout(provider); + loggedOutProviders.add(provider); }; } if (prop === "remove") { return (provider: string) => { - loggedOutProviders.add(provider); target.remove(provider); + loggedOutProviders.add(provider); }; } if (prop === "set") { return (provider: string, credential: AuthCredential) => { - loggedOutProviders.delete(provider); target.set(provider, credential); + loggedOutProviders.delete(provider); }; } diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index b16fb7904..507e6b001 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -178,15 +178,15 @@ export function createFusionAuthStorage(): AuthStorage { get(target, prop, receiver) { if (prop === "logout") { return (provider: string) => { - loggedOutProviders.add(provider); target.logout(provider); + loggedOutProviders.add(provider); }; } if (prop === "set") { return (provider: string, credential: AuthCredential) => { - loggedOutProviders.delete(provider); target.set(provider, credential); + loggedOutProviders.delete(provider); }; } From 0d29dc3eb9c59e6beedd2ea7f58cc669bd87f484 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 22:00:07 -0700 Subject: [PATCH 5/5] fix(auth): add remove() trap to engine auth storage proxy The CLI proxy already had a remove() trap, but the engine's createFusionAuthStorage was missing it. Without this trap, calling remove() on a provider would delete the credential from storage but not add it to loggedOutProviders, allowing fallback credentials to resurrect the provider on the next read. Co-Authored-By: Claude Opus 4.6 --- packages/engine/src/auth-storage.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 507e6b001..ba0fe5e57 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -183,6 +183,13 @@ export function createFusionAuthStorage(): AuthStorage { }; } + if (prop === "remove") { + return (provider: string) => { + target.remove(provider); + loggedOutProviders.add(provider); + }; + } + if (prop === "set") { return (provider: string, credential: AuthCredential) => { target.set(provider, credential);