Merge pull request #47 from timothyjlaurent/timothyjlaurent/fix-anthropic-logout
fix(auth): prevent credential resurrection after Anthropic logout
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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<string, StoredCredential>),
|
||||
...(loggedOutProviders.size > 0
|
||||
? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p))
|
||||
: Object.keys(supplementalCredentials)),
|
||||
]);
|
||||
const merged: Record<string, StoredCredential> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user