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
3 changed files with 2159 additions and 46 deletions
|
|
@ -65,7 +65,7 @@ OAuth redirect URIs must match **exactly** what you register on the authorizatio
|
|||
1. Select an HTTP/SSE server and press **C** to connect.
|
||||
2. If authorization is required, the TUI starts OAuth automatically (browser opens for sign-in).
|
||||
3. After the callback completes, connect finishes without a second **C**.
|
||||
4. Use the **Auth** tab to inspect OAuth state (same fields as web Connection Info) or **Clear OAuth state** (disconnects when connected).
|
||||
4. Use the **Auth** tab to inspect OAuth state (same fields as web Connection Info) or **Clear OAuth state** (disconnects when connected). Clearing also revokes the grant at the authorization server when it advertises an RFC 7009 `revocation_endpoint` — best-effort, with any failure reported in the status line and the local clear finishing regardless. Set `oauth.revokeOnClear: false` on the server entry to skip it ([#2144](https://github.com/modelcontextprotocol/inspector/issues/2144)).
|
||||
|
||||
See also [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for staging servers and verification steps.
|
||||
|
||||
|
|
|
|||
|
|
@ -59,9 +59,10 @@ const h = vi.hoisted(() => {
|
|||
authenticate: vi.fn<InspectorClient["authenticate"]>(
|
||||
async () => new URL("https://auth.example/start"),
|
||||
),
|
||||
clearOAuthTokens: vi.fn<InspectorClient["clearOAuthTokens"]>(
|
||||
async () => {},
|
||||
),
|
||||
clearOAuthTokens: vi.fn<InspectorClient["clearOAuthTokens"]>(async () => ({
|
||||
status: "skipped" as const,
|
||||
reason: "no_endpoint" as const,
|
||||
})),
|
||||
completeOAuthFlow: vi.fn<InspectorClient["completeOAuthFlow"]>(
|
||||
async () => {},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -941,15 +941,30 @@ function App({
|
|||
|
||||
const handleClearOAuth = useCallback(async () => {
|
||||
if (!selectedInspectorClient) return;
|
||||
await selectedInspectorClient.clearOAuthTokens();
|
||||
// RFC 7009 (#2144). Best-effort: the outcome is reported in the status
|
||||
// line, never thrown, so clearing always completes. The per-server
|
||||
// `oauthRevokeOnClear` opt-out is honored here the same way the web client
|
||||
// honors it.
|
||||
const revocation = await selectedInspectorClient.clearOAuthTokens({
|
||||
revoke: selectedServerEntry?.settings?.oauthRevokeOnClear !== false,
|
||||
});
|
||||
setOauthStatus("idle");
|
||||
setOauthMessage(null);
|
||||
setOauthMessage(
|
||||
revocation.status === "failed"
|
||||
? `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}`
|
||||
: null,
|
||||
);
|
||||
setConnectError(null);
|
||||
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
|
||||
await disconnectInspector();
|
||||
}
|
||||
setOauthRevision((n) => n + 1);
|
||||
}, [selectedInspectorClient, inspectorStatus, disconnectInspector]);
|
||||
}, [
|
||||
selectedInspectorClient,
|
||||
selectedServerEntry,
|
||||
inspectorStatus,
|
||||
disconnectInspector,
|
||||
]);
|
||||
|
||||
// Build current server state from InspectorClient data (tools from ManagedToolsState)
|
||||
const currentServerState = useMemo(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue