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
1 changed files with 2159 additions and 46 deletions
|
|
@ -5,6 +5,7 @@ import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
|||
import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
} from "@inspector/core/auth/index.js";
|
||||
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
import type {
|
||||
AuthChallenge,
|
||||
AuthChallengeReason,
|
||||
|
|
@ -31,6 +33,7 @@ import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientCo
|
|||
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
||||
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
||||
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
|
||||
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
|
||||
import { getAuthToken } from "../lib/authToken";
|
||||
import {
|
||||
|
|
@ -121,6 +124,36 @@ export interface ClearableServer {
|
|||
id: string;
|
||||
name: string;
|
||||
config: MCPServerConfig;
|
||||
/**
|
||||
* Read for `oauthRevokeOnClear` (#2144). Optional so a caller that only has
|
||||
* the identity fields still type-checks; an absent value means the default,
|
||||
* on.
|
||||
*/
|
||||
settings?: InspectorServerSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence appended to the "OAuth state cleared" toast describing what the
|
||||
* RFC 7009 leg did (#2144).
|
||||
*
|
||||
* Only the two outcomes a user can act on are surfaced. A success is worth
|
||||
* saying because the whole point of the feature is invisible otherwise — the
|
||||
* local clear looks identical either way. A failure is worth saying because the
|
||||
* grant is still live at the authorization server and the user may want to
|
||||
* revoke it by hand. The remaining skips ("this server advertises no revocation
|
||||
* endpoint", "there were no tokens") describe the status quo and would turn a
|
||||
* confirmation into a notice about something that did not need to happen.
|
||||
*/
|
||||
export function revocationSuffix(
|
||||
outcome: TokenRevocationOutcome | undefined,
|
||||
): string {
|
||||
if (outcome?.status === "revoked") {
|
||||
return " The grant was also revoked at the authorization server.";
|
||||
}
|
||||
if (outcome?.status === "failed") {
|
||||
return ` Revoking the grant at the authorization server failed (${outcome.detail}), so it may still be valid there.`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export interface UseOAuthRecoveryOptions {
|
||||
|
|
@ -1221,11 +1254,13 @@ export function useOAuthRecovery({
|
|||
const clearServerOAuthAndDisconnect = useCallback(
|
||||
async (server: ClearableServer) => {
|
||||
const isActive = server.id === activeServerId;
|
||||
const cleared = await clearServerOAuthState({
|
||||
const { cleared, revocation } = await clearServerOAuthState({
|
||||
config: server.config,
|
||||
inspectorClient: isActive ? inspectorClient : null,
|
||||
isActiveConnection: isActive,
|
||||
oauthStorage: webOAuthStorage,
|
||||
revoke: server.settings?.oauthRevokeOnClear !== false,
|
||||
fetchFn: getWebProxiedFetch(getAuthToken()),
|
||||
});
|
||||
if (!cleared) return;
|
||||
|
||||
|
|
@ -1243,8 +1278,8 @@ export function useOAuthRecovery({
|
|||
notifications.show({
|
||||
title: "OAuth state cleared",
|
||||
message: isActive
|
||||
? "Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow."
|
||||
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.`,
|
||||
? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
|
||||
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
|
||||
color: "blue",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue