Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions packages/cli/src/commands/__tests__/provider-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
62 changes: 58 additions & 4 deletions packages/cli/src/commands/provider-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

const selectCredential = (
providerId: string,
storages: Array<Pick<ReadFallbackAuthStorage, "get">>,
Expand All @@ -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);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)) {
Expand All @@ -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);
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (prop === "reload") {
return () => {
for (const storage of readAuthStorages) {
Expand All @@ -183,18 +218,31 @@ 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") {
return () => {
const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list()));
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
if (loggedOutProviders.has(providerId)) {
continue;
}
const credential = getCredential(providerId);
if (credential) {
merged[providerId] = credential;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/src/routes/register-auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading