Merge pull request #47 from timothyjlaurent/timothyjlaurent/fix-anthropic-logout
fix(auth): prevent credential resurrection after Anthropic logout
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">>,
|
||||
@@ -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<string, StoredCredential> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user