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:
parent
4a24c4880d
commit
781aa69d19
2 changed files with 479 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue