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 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-05 20:51:44 -07:00
parent 58a0eff09a
commit dfb255249f
5 changed files with 399 additions and 11 deletions

View File

@@ -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);
});
});
});