fix(auth): address Copilot review round 2 on the RFC 7009 revocation (#2144)
- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token that no longer validates rejected and abandoned the local delete `--relogin` promises — over a grant that could not have been revoked anyway. Caught and retained as a failed outcome; the clear proceeds. - The multi-key bookkeeping was wrong twice. A token is now marked spent only on a `revoked` outcome, so a failed or unsupported attempt no longer skips a duplicate entry under the other key that may hold the credentials or metadata that would have worked. And a failure now outranks an earlier success for reporting, so a stale grant left live cannot be hidden behind the first key's 200. - `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit returns, matching the parser's existing stance on accepted-but-inert flags (`--strict`). On its own it reads as "this run will not revoke anything", true only because nothing was being cleared. - Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule has a test. The hook tests receive whatever they are handed and cannot tell a draft from a persisted entry, and `App.test.tsx`'s settings harness connects a stdio server where the OAuth section never renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
7f27d7e9c7
commit
4a24c4880d
2 changed files with 278 additions and 20 deletions
|
|
@ -103,6 +103,44 @@ describe("clearStoredAuthForRelogin", () => {
|
|||
return file;
|
||||
}
|
||||
|
||||
/** Seed both key spellings of one server, each with its own refresh token. */
|
||||
function seedBothSpellings(
|
||||
normalizedToken: string,
|
||||
rawToken: string,
|
||||
): void {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-two-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
const metadata = {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
const entry = (refresh: string) => ({
|
||||
tokens: {
|
||||
access_token: `a-${refresh}`,
|
||||
token_type: "Bearer",
|
||||
refresh_token: refresh,
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com": entry(rawToken),
|
||||
"https://example.com/": entry(normalizedToken),
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
}
|
||||
|
||||
it("revokes the stored grant before deleting it", async () => {
|
||||
const file = seed();
|
||||
const fetchSpy = vi
|
||||
|
|
@ -249,6 +287,74 @@ describe("clearStoredAuthForRelogin", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// A token the AS never accepted is not spent, so the other key — which may
|
||||
// carry the credentials or metadata that would have worked — must still be
|
||||
// tried rather than skipped as a duplicate.
|
||||
it("does not treat a failed attempt as having ended the grant", async () => {
|
||||
seedBothSpellings("same-r", "same-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A grant still live at the authorization server is what the user needs to
|
||||
// hear about; reporting the earlier success would print no warning at all.
|
||||
it("reports a later failure over an earlier success", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// `getTokens` parses through `OAuthTokensSchema`, so a persisted token that
|
||||
// no longer validates rejects. That must not abandon the local delete
|
||||
// `--relogin` promises — over a grant that could not have been revoked.
|
||||
it("still clears when the stored token cannot be parsed", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-bad-token-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com/mcp": {
|
||||
// No `token_type` — fails OAuthTokensSchema.
|
||||
tokens: { access_token: 42 },
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com/mcp"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
|
||||
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports no_tokens when the store holds nothing for the server", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-empty-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
|
|
|
|||
|
|
@ -185,6 +185,20 @@ describe("--relogin token revocation", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Inert-but-accepted flags are rejected in this parser (see the `--strict`
|
||||
// rationale in `cli.ts`). On its own `--no-revoke` reads as "this run will
|
||||
// not revoke anything", which is true only because nothing was being cleared.
|
||||
it("rejects --no-revoke without --relogin", async () => {
|
||||
const result = await runCli([
|
||||
"--no-revoke",
|
||||
"--server-url",
|
||||
SERVER_URL,
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expect(result.stderr).toMatch(/--no-revoke requires --relogin/);
|
||||
});
|
||||
|
||||
// A failed revocation must be visible — the grant is still live at the
|
||||
// authorization server — without turning `--relogin` into a failure, which
|
||||
// is a local delete the user still gets.
|
||||
|
|
|
|||
Loading…
Reference in a new issue