fix(auth): revoke every issuer-bound grant, not just the active one (#2144)

Copilot review round 3.

`clear(serverUrl)` deletes EVERY `byIssuer` slot, but revocation read only the
context-free (active-issuer) token. A server that had authorized against
issuers A and B therefore revoked B, then destroyed the local record of A while
A's grant stayed live at its authorization server — the same leak this feature
exists to close, one level down.

- `OAuthStorage.listIssuers(serverUrl)` is the new seam (one implementation:
  every backend extends `OAuthStorageBase`). `collectGrants` walks it plus the
  ctx-less read, deduping by token, so every grant the clear will delete is
  covered.
- Metadata is cached once per server, not per issuer, so a grant bound to an
  issuer the cached document does not describe cannot be revoked: sending its
  token to that endpoint would hand a credential to a server that never minted
  it. That grant is reported as failed — dropped unrevoked — rather than
  silently discarded or misdirected.
- `aggregateOutcomes` gives a failure precedence over another grant's success,
  so one live grant cannot hide behind another's 200. Exported for its own
  test, since `computeOutcome` returns early on the empty case.

Also from that round:

- The fixture's /oauth/revoke now verifies token ownership (RFC 7009 §2.1).
  Access tokens record their owning `client_id`; a token belonging to another
  client is left alone and still answered 200 (§2.2 — the response must not
  tell one client whether another's token exists). New e2e case proves a
  second registered client cannot revoke the first's grant.
- The TUI rendered the revocation-failure note in cyan, the informational
  tone. It is a partial success — the local clear really happened — so it is
  not an `error` status, but the grant may still be live and cyan understated
  that. `AuthTab` takes an `oauthMessageTone`; the message says so explicitly.

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:
cliffhall 2026-08-28 02:40:28 -04:00
commit 781aa69d19

View file

@ -28,6 +28,14 @@ interface AuthTabProps {
inspectorClient: InspectorClient | null;
oauthStatus: "idle" | "authenticating" | "error";
oauthMessage: string | null;
/**
* How to colour {@link oauthMessage} on the `idle` status. Defaults to
* `"info"` (cyan). `"warning"` exists for the one message that reports a
* *partial* success: the OAuth state really was cleared, so this is not an
* error, but the grant may still be live at the authorization server and
* cyan would understate that (#2144).
*/
oauthMessageTone?: "info" | "warning";
oauthRevision: number;
pendingStepUp?: {
challenge: AuthChallenge;
@ -57,6 +65,7 @@ export function AuthTab({
inspectorClient,
oauthStatus,
oauthMessage,
oauthMessageTone = "info",
oauthRevision,
pendingStepUp,
onAuthorizeStepUp,
@ -190,7 +199,9 @@ export function AuthTab({
<Text color="red">{oauthMessage}</Text>
)}
{oauthStatus === "idle" && oauthMessage && (
<Text color="cyan">{oauthMessage}</Text>
<Text color={oauthMessageTone === "warning" ? "yellow" : "cyan"}>
{oauthMessage}
</Text>
)}
{pendingStepUp ? (