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

View file

@ -107,6 +107,7 @@ async function callMethod(
callbackUrlConfig: RunnerOAuthCallbackConfig,
storedAuthOnly: boolean,
relogin: boolean,
revoke: boolean,
): Promise<void> {
// Clear after parse-time validation so a bad flag combo never deletes store
// entries. Deletes the shared URL-keyed OAuth entry (not "ignore for this run").
@ -116,7 +117,18 @@ async function callMethod(
"--relogin requires an HTTP/SSE server URL (no OAuth store entry for stdio)",
);
}
await clearStoredAuthForRelogin(serverConfig.url);
// RFC 7009 (#2144). The flag and the per-server setting are both opt-outs,
// so either one turns the revocation off; neither can turn it on for the
// other. Reported rather than thrown — `--relogin` is a local delete and
// must not start failing because an authorization server is unreachable.
const revocation = await clearStoredAuthForRelogin(serverConfig.url, {
revoke: revoke && serverSettings?.oauthRevokeOnClear !== false,
});
if (revocation?.status === "failed") {
process.stderr.write(
`Warning: could not revoke the OAuth grant at the authorization server (${revocation.detail}); it may still be valid there.\n`,
);
}
}
// Version comes from the single source of truth — the root package.json —
@ -570,6 +582,7 @@ type ParseResult =
callbackUrl?: string;
storedAuthOnly?: boolean;
relogin?: boolean;
revoke?: boolean;
}
// Short-circuit modes (`--list-stored-auth`, `--print-handoff`) do their own
// output and need no server connection; runCli returns immediately.
@ -765,6 +778,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
"--relogin",
"Delete stored OAuth for this server URL from the shared store before connect (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. Rejected for stdio (no URL-keyed store entry)",
)
.option(
"--no-revoke",
"With --relogin, skip the RFC 7009 revocation request that would otherwise end the grant at the authorization server before the local state is deleted. Also skipped when the server entry sets oauth.revokeOnClear to false.",
)
.option(
"--wait-for-auth <sec>",
"Poll the OAuth state file until a token for --server-url appears (or the timeout elapses), then proceed as if --use-stored-auth were set. Use after handing off to a human to complete OAuth in a browser.",
@ -820,6 +837,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
useStoredAuth?: boolean;
storedAuthOnly?: boolean;
relogin?: boolean;
revoke?: boolean;
waitForAuth?: number;
listStoredAuth?: boolean;
printHandoff?: boolean;
@ -1112,6 +1130,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
callbackUrl: options.callbackUrl,
storedAuthOnly: options.storedAuthOnly === true,
relogin: options.relogin === true,
// Commander's `--no-revoke` defaults this to true; only an explicit
// `--no-revoke` makes it false.
revoke: options.revoke !== false,
};
}
@ -1130,6 +1151,7 @@ export async function runCli(argv?: string[]): Promise<void> {
callbackUrl,
storedAuthOnly,
relogin,
revoke,
} = parsed;
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
// A bad --callback-url / MCP_OAUTH_CALLBACK_URL is a *usage* error, but its
@ -1159,5 +1181,6 @@ export async function runCli(argv?: string[]): Promise<void> {
callbackUrlConfig,
storedAuthOnly === true,
relogin === true,
revoke !== false,
);
}