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
6 changed files with 2159 additions and 46 deletions

View file

@ -0,0 +1,20 @@
{
"serverInfo": {
"name": "oauth-no-revocation",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }],
"oauth": {
"enabled": true,
"mode": "combined",
"requireAuth": true,
"scopesSupported": ["mcp"],
"supportDCR": true,
"supportRefreshTokens": true,
"supportRevocation": false
},
"transport": {
"type": "streamable-http",
"port": 8084
}
}

View file

@ -0,0 +1,20 @@
{
"serverInfo": {
"name": "oauth-revocation",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }],
"oauth": {
"enabled": true,
"mode": "combined",
"requireAuth": true,
"scopesSupported": ["mcp"],
"supportDCR": true,
"supportRefreshTokens": true,
"supportRevocation": true
},
"transport": {
"type": "streamable-http",
"port": 8083
}
}

View file

@ -670,6 +670,14 @@ export interface ServerConfig {
* Whether to support refresh tokens (default: true)
*/
supportRefreshTokens?: boolean;
/**
* Whether to advertise and serve the RFC 7009 `revocation_endpoint`
* (default: true). Set to `false` to reproduce an authorization server that
* offers no revocation, where the Inspector must send nothing and clear
* local state exactly as it always has. (#2144)
*/
supportRevocation?: boolean;
};
/**
* Serve the modern (2026-07-28) protocol era via the SDK's

View file

@ -37,6 +37,8 @@ export interface ConfigFileOAuth {
supportCIMD?: boolean;
tokenExpirationSeconds?: number;
supportRefreshTokens?: boolean;
/** RFC 7009 revocation endpoint; default true (#2144). */
supportRevocation?: boolean;
}
export interface ConfigFile {

View file

@ -2910,6 +2910,8 @@ export function createOAuthTestServerConfig(options: {
supportCIMD?: boolean;
tokenExpirationSeconds?: number;
supportRefreshTokens?: boolean;
/** RFC 7009 revocation endpoint; default true (#2144). */
supportRevocation?: boolean;
/**
* Move the RFC 9728 metadata document off the well-known path and advertise
* it via `WWW-Authenticate: Bearer resource_metadata="…"` (#2071).
@ -2933,6 +2935,7 @@ export function createOAuthTestServerConfig(options: {
supportCIMD: options.supportCIMD ?? false,
tokenExpirationSeconds: options.tokenExpirationSeconds ?? 3600,
supportRefreshTokens: options.supportRefreshTokens ?? true,
supportRevocation: options.supportRevocation ?? true,
},
};
}

View file

@ -116,6 +116,9 @@ export function setupOAuthRoutes(
if (getOAuthMode(config) === "combined") {
setupAuthorizationEndpoint(app, config);
setupTokenEndpoint(app, config);
if (config.supportRevocation !== false) {
setupRevocationEndpoint(app);
}
if (config.supportDCR) {
setupDCREndpoint(app);
}
@ -243,6 +246,17 @@ function setupMetadataEndpoints(
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
// clients must validate (and our e2e can exercise reject paths).
authorization_response_iss_parameter_supported: true,
...(config.supportRevocation !== false && {
// RFC 7009 (#2144). Advertised by default so the in-repo servers
// exercise the Inspector's revocation leg; set
// `oauth.supportRevocation: false` to reproduce an authorization
// server that offers none, where the Inspector must do nothing.
revocation_endpoint: new URL("/oauth/revoke", actualIssuerUrl).href,
revocation_endpoint_auth_methods_supported: [
"client_secret_basic",
"none",
],
}),
...(config.supportDCR && {
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
.href,
@ -645,6 +659,7 @@ function setupTokenEndpoint(
storeRefreshToken(refreshToken, {
clientId: client_id,
scope: authCodeData.scope,
accessTokens: new Set([accessToken]),
});
}
@ -665,6 +680,9 @@ function setupTokenEndpoint(
const tokenScope =
refreshTokenData.scope || config.scopesSupported?.[0] || "mcp";
const accessToken = generateAccessToken(tokenScope);
// Keep the grant linkage current so a later revocation of this refresh
// token also kills the access token it just minted.
refreshTokenData.accessTokens.add(accessToken);
const tokenExpiration = config.tokenExpirationSeconds || 3600;
res.json({
@ -680,6 +698,52 @@ function setupTokenEndpoint(
);
}
/**
* RFC 7009 token revocation (#2144).
*
* Deliberately faithful on the two points the Inspector depends on, both of
* which are easy to get wrong in a fixture:
*
* - **§2.2 — an unknown token is a success.** A client revoking a token the
* server has already expired must not be told it failed, so the only 400 here
* is a structurally invalid request (no `token` at all).
* - **§2.1 — revoking a refresh token also invalidates its access tokens.**
* That is why the Inspector sends one request naming the refresh token, and a
* fixture that ignored the linkage would let a regression through silently.
*
* Client authentication is accepted but not enforced: the point of the fixture
* is the revocation semantics, and every client this repo drives it with is
* either public or has already authenticated at the token endpoint.
*/
function setupRevocationEndpoint(app: express.Application): void {
app.post(
"/oauth/revoke",
express.urlencoded({ extended: true }),
(req: Request, res: Response) => {
const token: unknown = req.body?.token;
if (typeof token !== "string" || token === "") {
res.status(400).json({ error: "invalid_request" });
return;
}
const refreshTokenData = refreshTokens.get(token);
if (refreshTokenData) {
for (const accessToken of refreshTokenData.accessTokens) {
accessTokens.delete(accessToken);
accessTokenScopes.delete(accessToken);
}
refreshTokens.delete(token);
} else {
accessTokens.delete(token);
accessTokenScopes.delete(token);
}
// §2.2: 200 whether or not the token was known to us.
res.status(200).end();
},
);
}
/**
* Set up Dynamic Client Registration endpoint
*/
@ -732,6 +796,15 @@ interface AuthorizationCodeData {
interface RefreshTokenData {
clientId: string;
scope?: string;
/**
* Access tokens minted under the same grant. RFC 7009 §2.1 says an
* authorization server asked to revoke a refresh token SHOULD also invalidate
* the access tokens issued from it, and the Inspector relies on exactly that
* — it sends one request naming the refresh token and expects both halves to
* die. A fixture that dropped only the refresh token would let a client that
* leaves live access tokens behind pass. (#2144)
*/
accessTokens: Set<string>;
}
interface RegisteredClient {