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..dc477e0b5 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 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,27 @@ export function mergeAuthStorageReads( return new Proxy(authStorage, { get(target, prop, receiver) { + if (prop === "logout") { + return (provider: string) => { + target.logout(provider); + loggedOutProviders.add(provider); + }; + } + + if (prop === "remove") { + return (provider: string) => { + target.remove(provider); + loggedOutProviders.add(provider); + }; + } + + if (prop === "set") { + return (provider: string, credential: AuthCredential) => { + target.set(provider, credential); + loggedOutProviders.delete(provider); + }; + } + if (prop === "reload") { return () => { for (const storage of readAuthStorages) { @@ -183,11 +218,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 +240,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 +253,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..ba0fe5e57 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,27 @@ export function createFusionAuthStorage(): AuthStorage { }, get(target, prop, receiver) { + if (prop === "logout") { + return (provider: string) => { + target.logout(provider); + loggedOutProviders.add(provider); + }; + } + + if (prop === "remove") { + return (provider: string) => { + target.remove(provider); + loggedOutProviders.add(provider); + }; + } + + if (prop === "set") { + return (provider: string, credential: AuthCredential) => { + target.set(provider, credential); + loggedOutProviders.delete(provider); + }; + } + if (prop === "reload") { return () => { target.reload(); @@ -178,29 +207,48 @@ export function createFusionAuthStorage(): AuthStorage { } if (prop === "get") { - return (provider: string) => - choosePreferredStoredCredential( + return (provider: string) => { + if (loggedOutProviders.has(provider)) { + return undefined; + } + 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) { + if (loggedOutProviders.has(providerId)) { + continue; + } const credential = choosePreferredStoredCredential( (target.get(providerId) as StoredCredential | undefined), supplementalCredentials[providerId], @@ -214,11 +262,28 @@ 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()]); + 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); + } + } + return Array.from(providers).filter((p) => !loggedOutProviders.has(p)); + }; } 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;