feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)

Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.

Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.

Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.

Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.

test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.

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 00:48:57 -04:00
commit 65fd2b9e35
2 changed files with 2159 additions and 46 deletions

View file

@ -707,6 +707,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
revokeOnClear: true,
});
});
@ -751,6 +752,47 @@ describe("ServerSettingsForm", () => {
);
});
// #2144 — the RFC 7009 opt-out. On by default: leaving it off silently is
// what leaves the authorization server holding a live grant.
it("renders Revoke tokens on clear checked by default", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Revoke tokens on clear")).toBeChecked();
});
it("renders Revoke tokens on clear unchecked when the server opted out", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, oauthRevokeOnClear: false }}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Revoke tokens on clear")).not.toBeChecked();
});
it("toggles Revoke tokens on clear through onOAuthChange", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ revokeOnClear: false }),
);
});
it("warns when opting out while offline_access is still in Scopes", () => {
renderWithMantine(
<ServerSettingsForm
@ -1255,6 +1297,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: true,
requestRefreshToken: true,
revokeOnClear: true,
});
});
@ -1363,6 +1406,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
revokeOnClear: true,
});
});

View file

@ -521,6 +521,8 @@ export function ServerSettingsForm({
onInsufficientScope: settings.oauthOnInsufficientScope,
// Unset means the default, on — only an explicit opt-out is stored.
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
// Unset means the default, on — only an explicit opt-out is stored.
revokeOnClear: settings.oauthRevokeOnClear ?? true,
};
}
@ -570,6 +572,15 @@ export function ServerSettingsForm({
});
}
function handleRevokeOnClearChange(
event: ChangeEvent<HTMLInputElement>,
): void {
onOAuthChange({
...currentOAuth(),
revokeOnClear: event.currentTarget.checked,
});
}
function handleAddAuthorizationParam(): void {
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
}
@ -917,6 +928,12 @@ export function ServerSettingsForm({
checked={settings.oauthRequestRefreshToken ?? true}
onChange={handleRequestRefreshTokenChange}
/>
<Checkbox
label="Revoke tokens on clear"
description="Calls the authorization server's RFC 7009 revocation endpoint before clearing the stored OAuth state, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
checked={settings.oauthRevokeOnClear ?? true}
onChange={handleRevokeOnClearChange}
/>
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
<Alert color="yellow" title="offline_access is still requested">
The Scopes field above lists offline_access, and