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
2 changed files with 479 additions and 52 deletions

View file

@ -161,6 +161,13 @@ function App({
"idle" | "authenticating" | "error"
>("idle");
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
// #2144: a revocation failure is a partial success — the local state really
// was cleared — so it is not an `error` status, but it must not read as an
// ordinary note either: the grant may still be live at the authorization
// server.
const [oauthMessageTone, setOauthMessageTone] = useState<"info" | "warning">(
"info",
);
const [oauthRevision, setOauthRevision] = useState(0);
const [pendingStepUp, setPendingStepUp] = useState<{
serverName: string;
@ -949,9 +956,10 @@ function App({
revoke: selectedServerEntry?.settings?.oauthRevokeOnClear !== false,
});
setOauthStatus("idle");
setOauthMessageTone(revocation.status === "failed" ? "warning" : "info");
setOauthMessage(
revocation.status === "failed"
? `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}`
? `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}. It may still be valid there.`
: null,
);
setConnectError(null);
@ -1756,6 +1764,7 @@ function App({
inspectorClient={selectedInspectorClient}
oauthStatus={oauthStatus}
oauthMessage={oauthMessage}
oauthMessageTone={oauthMessageTone}
oauthRevision={oauthRevision}
pendingStepUp={
pendingStepUp?.serverName === selectedServer

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 ? (