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:
parent
baff393675
commit
65fd2b9e35
6 changed files with 2159 additions and 46 deletions
20
test-servers/configs/oauth-no-revocation-http.json
Normal file
20
test-servers/configs/oauth-no-revocation-http.json
Normal 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
|
||||
}
|
||||
}
|
||||
20
test-servers/configs/oauth-revocation-http.json
Normal file
20
test-servers/configs/oauth-revocation-http.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue