fix(auth): key grant dedup by issuer, and isolate per-slot read failures (#2144)
Copilot review round 5. - Deduplicating grants by token string alone could conflate two grants: a token is only meaningful to the authorization server that minted it, so two issuers minting the same opaque value are two grants. The second was dropped before the issuer-mismatch check could report it, and `clear` then deleted it. The key is now issuer+token; the ctx-less read stays suppressed only when its token already came from a slot, which is exactly the active-issuer duplicate and never the legacy one. - A read failure in one issuer slot aborted `collectGrants`, so one corrupt slot left every other revocable grant untouched while the clear deleted them all. Each slot is now read independently and its failure carried as an outcome alongside the grants that could still be revoked; the no-metadata and no-endpoint returns carry them too, so a failure is never swallowed by a skip. - The CLI no longer deduplicates its two key spellings. That could only be done by pre-reading a single token, and `revokeStoredOAuthTokens` now enumerates every issuer slot under a key — so a shared active token skipped the second key entirely, taking any other issuer-bound grant under it with the local delete. A duplicate RFC 7009 request is harmless (§2.2 makes an unknown token a success); a missed one is the leak. 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
6c499f3ea1
commit
e9cd678cd0
3 changed files with 230 additions and 84 deletions
|
|
@ -287,10 +287,11 @@ 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 () => {
|
||||
// Keys are revoked from independently. Deduplicating them could only be
|
||||
// done by pre-reading one token, which would skip the second key's OTHER
|
||||
// issuer-bound grants along with it; a duplicate RFC 7009 request is
|
||||
// harmless (§2.2 makes an unknown token a success), a missed one is not.
|
||||
it("revokes from both keys even when they hold the same token", async () => {
|
||||
seedBothSpellings("same-r", "same-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
revokeStoredOAuthTokens,
|
||||
selectRevocableToken,
|
||||
type TokenRevocationOutcome,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
|
||||
|
|
@ -44,10 +43,10 @@ export async function clearStoredAuthForRelogin(
|
|||
// Both spellings are cleared below, so both are revoked from — a stale entry
|
||||
// under the other key is a live grant at the authorization server, and
|
||||
// deleting it locally without revoking is exactly the leak this closes. The
|
||||
// normalised key goes first because that is the precedence `findStoredServerState`
|
||||
// reads with, so the grant actually in use is the one whose outcome is
|
||||
// reported; a second key holding the *same* token is skipped rather than
|
||||
// revoked twice.
|
||||
// normalised key goes first because that is the precedence
|
||||
// `findStoredServerState` reads with, so the grant actually in use is the one
|
||||
// whose outcome is reported. They are deliberately not deduplicated; see
|
||||
// `revokeStoredKeys`.
|
||||
const revocation =
|
||||
options?.revoke === false
|
||||
? undefined
|
||||
|
|
@ -63,22 +62,26 @@ export async function clearStoredAuthForRelogin(
|
|||
}
|
||||
|
||||
/**
|
||||
* Revoke every distinct grant held under `keys`, in order, and report the first
|
||||
* key that had something to revoke.
|
||||
* Revoke every grant held under `keys`, in order, and report the outcome that
|
||||
* matters most.
|
||||
*
|
||||
* Both keys are about to be deleted, so both are revoked from: a stale entry
|
||||
* under the other spelling is still a live grant at the authorization server,
|
||||
* and deleting it locally without revoking is the leak this whole change
|
||||
* closes. Two keys holding the *same* token are one grant, so the duplicate is
|
||||
* skipped rather than producing a second request for something already ended —
|
||||
* but only once one has actually been *revoked*, since a failed or unsupported
|
||||
* attempt has ended nothing and the other key may hold the credentials that
|
||||
* would have worked.
|
||||
* closes.
|
||||
*
|
||||
* Reporting prefers a **failure** over any earlier success — a grant still live
|
||||
* at the authorization server is what the user needs to hear about, and an
|
||||
* earlier success would otherwise silence the warning. Failing that, it is the
|
||||
* first key's outcome that was not "nothing to do", which with `keys` in
|
||||
* There is deliberately **no** cross-key deduplication. It looked cheap — the
|
||||
* two keys are usually two spellings of one server — but it can only be done
|
||||
* by pre-reading a single token, and `revokeStoredOAuthTokens` enumerates every
|
||||
* issuer slot under a key. So a shared *active* token would have skipped the
|
||||
* second key entirely, taking any additional issuer-bound grant under it with
|
||||
* the local delete. A duplicate RFC 7009 request is harmless (§2.2 makes an
|
||||
* unknown token a success), which is a much better trade than a missed one.
|
||||
*
|
||||
* Reporting prefers a **failure** over any success — a grant still live at the
|
||||
* authorization server is what the user needs to hear about, and a success
|
||||
* would otherwise silence the warning. Failing that it is the first key's
|
||||
* outcome that was not "nothing to do", which with `keys` in
|
||||
* `findStoredServerState` precedence means the grant the CLI would actually
|
||||
* have connected with. When no key holds a token, the last "nothing to do"
|
||||
* answer is returned so the caller can still tell "no tokens" from "this
|
||||
|
|
@ -89,26 +92,9 @@ async function revokeStoredKeys(
|
|||
keys: string[],
|
||||
): Promise<TokenRevocationOutcome | undefined> {
|
||||
const fetchFn = createProxyFetch() ?? fetch;
|
||||
const revokedTokens = new Set<string>();
|
||||
let reported: TokenRevocationOutcome | undefined;
|
||||
let lastSkip: TokenRevocationOutcome | undefined;
|
||||
for (const key of new Set(keys)) {
|
||||
// This read is OUTSIDE `revokeStoredOAuthTokens`'s own best-effort catch,
|
||||
// so it needs its own: `getTokens` parses through `OAuthTokensSchema` and
|
||||
// rejects on a persisted token that no longer validates. Letting that
|
||||
// escape would abandon the *local* delete `--relogin` promises, over a
|
||||
// grant we could not have revoked anyway.
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = selectRevocableToken(await storage.getTokens(key))?.token;
|
||||
} catch (err) {
|
||||
reported ??= {
|
||||
status: "failed",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (token !== undefined && revokedTokens.has(token)) continue;
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: key,
|
||||
storage,
|
||||
|
|
@ -118,16 +104,6 @@ async function revokeStoredKeys(
|
|||
lastSkip = outcome;
|
||||
continue;
|
||||
}
|
||||
// Only a token the authorization server actually accepted counts as spent.
|
||||
// Marking one revoked on a `failed` or `no_endpoint` outcome would skip a
|
||||
// duplicate entry under the other key that may carry usable metadata or
|
||||
// credentials — the one chance left to end that grant.
|
||||
if (token !== undefined && outcome.status === "revoked") {
|
||||
revokedTokens.add(token);
|
||||
}
|
||||
// A failure outranks an earlier success for reporting: a grant that is
|
||||
// still live is what the user needs to hear about, and reporting the
|
||||
// success would print no warning while a stale grant survives.
|
||||
if (outcome.status === "failed") {
|
||||
if (reported?.status !== "failed") reported = outcome;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -617,13 +617,15 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
it("reports a non-Error store failure as failed", async () => {
|
||||
vi.spyOn(storage, "listIssuers").mockRejectedValue("store exploded");
|
||||
|
||||
await expect(
|
||||
revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
}),
|
||||
).resolves.toEqual({ status: "failed", detail: "store exploded" });
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"store exploded",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a store read failure as failed", async () => {
|
||||
|
|
@ -631,12 +633,104 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
new Error("store unreadable"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
}),
|
||||
).resolves.toEqual({ status: "failed", detail: "store unreadable" });
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"store unreadable",
|
||||
);
|
||||
});
|
||||
|
||||
// A corrupt slot must not abandon the grants that are still revocable — the
|
||||
// clear deletes them all either way, so the failure has to be reported
|
||||
// BESIDE the successes rather than instead of them.
|
||||
it("still revokes the readable grants when one issuer slot cannot be read", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as.example.com" }),
|
||||
);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a", token_type: "Bearer", refresh_token: "r-good" },
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
// A second issuer whose exact read throws.
|
||||
vi.spyOn(storage, "listIssuers").mockResolvedValue([
|
||||
"https://as.example.com",
|
||||
"https://broken.example.com",
|
||||
]);
|
||||
vi.spyOn(storage, "getIssuerTokens").mockImplementation(
|
||||
async (_url: string, issuer: string) => {
|
||||
if (issuer === "https://broken.example.com") {
|
||||
throw new Error("corrupt slot");
|
||||
}
|
||||
return {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r-good",
|
||||
};
|
||||
},
|
||||
);
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
// The good grant was still revoked...
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
|
||||
).toBe("r-good");
|
||||
// ...and the unreadable slot is reported rather than swallowed.
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"corrupt slot",
|
||||
);
|
||||
});
|
||||
|
||||
// A token is only meaningful to the AS that minted it, so two issuers minting
|
||||
// the same opaque string are two grants. Collapsing them would drop the
|
||||
// second before the issuer-mismatch check could even report it.
|
||||
it("does not collapse two issuers that minted the same token value", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as-a.example.com" }),
|
||||
);
|
||||
for (const issuer of [
|
||||
"https://as-a.example.com",
|
||||
"https://as-b.example.com",
|
||||
]) {
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "same", token_type: "Bearer", refresh_token: "same-r" },
|
||||
{ issuer },
|
||||
);
|
||||
}
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
// Only as-a matches the cached metadata, so only it is revocable — but
|
||||
// as-b is REPORTED rather than silently dropped, which is what collapsing
|
||||
// by token alone would have done.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"as-b.example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue