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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user