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

View file

@ -101,6 +101,29 @@ v2/main/
│ │ # needs to know WHICH candidate
│ │ # answered, since that is the base its
│ │ # token request is made against — #2110;
│ │ # revocation.ts RFC 7009 token revocation —
│ │ # the request the three clear paths send
│ │ # BEFORE wiping local state, since the
│ │ # token, the client credentials and the
│ │ # discovered `revocation_endpoint` all live
│ │ # in the store the clear empties. Names the
│ │ # REFRESH token when there is one (§2.1 asks
│ │ # the AS to invalidate the access tokens
│ │ # under the same grant, so one request
│ │ # covers both). BEST-EFFORT by construction:
│ │ # every path returns a
│ │ # TokenRevocationOutcome rather than
│ │ # throwing — no advertised endpoint means
│ │ # nothing is sent and the AS behaves exactly
│ │ # as before, and a network error / non-2xx /
│ │ # timeout is reported so the local clear
│ │ # always finishes. Opt out per server with
│ │ # `oauth.revokeOnClear`, read at CLEAR time
│ │ # rather than connect time. `lost_authorization_state`
│ │ # recovery passes `revoke: false` — it clears a
│ │ # half-finished flow to retry it, and an
│ │ # authorization that never completed has no
│ │ # grant to revoke — #2144;
│ │ # scopes.ts SEP-2350 scope union, oauthUx.ts
│ │ # shared copy, mcpAuth.ts force-reauthorization,
│ │ # issuerBinding.ts SEP-2352 callback-leg failure

View file

@ -31,6 +31,7 @@ inspector/
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends);
│ │ # RFC 7009 token revocation on clear (revocation.ts);
│ │ # plus per-server secret storage — the keychain/file/memory SecretStore
│ │ # implementations, the selection policy, and the descriptor the banner and UI report
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
@ -159,6 +160,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
| `oauth-revocation-http.json` / `oauth-no-revocation-http.json` **(legacy era)** | RFC 7009 token revocation on clear, with and without a `revocation_endpoint` | [#2144](https://github.com/modelcontextprotocol/inspector/issues/2144) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
| `subscriptions-never-acknowledged-http.json` | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
@ -438,6 +440,21 @@ The same server is worth running against `--cli` / `--tui`, which reach it by a
The value now rides the normalized `AuthChallenge` as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a `URL` at the OAuth boundary, where it is handed to `auth()` as `resourceMetadataUrl` and to the CIMD pre-registration probe, which runs *before* `auth()` and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own `WWW-Authenticate` parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK `auth()` persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback.
#### Revoking tokens on clear (RFC 7009)
`oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
Add either server, connect and complete authorization, then use **Clear OAuth state and disconnect** (Server Settings → Authorization) and watch the Network tab.
- On `oauth-revocation-http.json` a `POST /oauth/revoke` goes out **before** the local state is dropped, naming the **refresh token**. RFC 7009 §2.1 asks the authorization server to invalidate the access tokens issued under the same grant, so one request ends both halves — the fixture implements that linkage, so re-sending the old bearer token to `/mcp` afterwards gets a 401.
- On `oauth-no-revocation-http.json` nothing is sent at all, and the clear behaves exactly as it did before the feature existed. That no-op path is what makes this safe against every authorization server with no RFC 7009 support ([#2144](https://github.com/modelcontextprotocol/inspector/issues/2144)).
On the broken build both servers behaved like the second: the Inspector deleted its local copy and the grant stayed valid at the authorization server until it expired on its own — which for a refresh token is a long time, by design.
Uncheck **Revoke tokens on clear** in the same panel (persisted as `oauth.revokeOnClear: false`) and the first server behaves like the second. That is not only an escape hatch: a client that disconnects still holding live tokens is a case worth reproducing when the server is the thing under test.
The same behavior is reachable from the other clients — the TUI's **Clear OAuth State**, and the CLI's `--relogin` (with `--no-revoke` as the per-run opt-out).
#### Logging, both eras
`logging-legacy-http.json` and `logging-modern-http.json` both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level. The legacy one is a plain streamable-HTTP server; the modern one sets `transport.modern: true`.

View file

@ -123,8 +123,24 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). |
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |
| `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. |
| `--no-revoke` | With `--relogin`, skip the [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) revocation request that would otherwise end the grant at the authorization server before the local state is deleted. The per-server `oauth.revokeOnClear` setting is the persistent form of the same opt-out; either one is enough to skip it. See [Revoking on `--relogin`](#revoking-on---relogin). |
| `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. |
#### Revoking on `--relogin`
`--relogin` deletes this server's stored OAuth state so the next connect cannot silently reuse it. By default it now also **revokes the grant at the authorization server** first, per [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) ([#2144](https://github.com/modelcontextprotocol/inspector/issues/2144)) — otherwise the delete is invisible to the AS, and the access token, plus the refresh token when one was issued, stay valid there until they expire on their own.
The request names the refresh token when there is one: RFC 7009 §2.1 asks the authorization server to invalidate the access tokens issued under the same grant, so a single request covers both.
It is best-effort and never changes the exit code. An authorization server that advertises no `revocation_endpoint` gets no request at all; a network error, a non-2xx, or the short timeout prints a one-line warning on stderr and the local delete proceeds either way.
Turn it off per run with `--no-revoke`, or per server with `oauth.revokeOnClear: false` in the catalog — either is enough, and neither can turn it on for the other. Disconnecting while still holding live tokens is a case worth reproducing when the server under test is the thing being debugged.
```bash
mcp-inspector --cli --server-url https://example.com/mcp --relogin --method tools/list
mcp-inspector --cli --server-url https://example.com/mcp --relogin --no-revoke --method tools/list
```
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
#### App probing (`--app-info`) and machine-readable output (`--format json`)

View file

@ -1,4 +1,4 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@ -66,6 +66,139 @@ describe("clearStoredAuthForRelogin", () => {
expect(blob.servers["not a url"]).toBeUndefined();
});
// #2144 — RFC 7009. The request has to go out *before* the delete, since it
// is built from the token, the client id and the cached metadata the delete
// removes.
describe("token revocation", () => {
function seed(over: Record<string, unknown> = {}): string {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-revoke-"));
const file = path.join(dir, "oauth.json");
fs.writeFileSync(
file,
JSON.stringify({
servers: {
"https://example.com/mcp": {
tokens: {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
},
serverMetadata: {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: "https://as.example.com/revoke",
response_types_supported: ["code"],
},
...over,
},
},
idpSessions: {},
}),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
return file;
}
it("revokes the stored grant before deleting it", async () => {
const file = seed();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 200 }));
try {
await expect(
clearStoredAuthForRelogin("https://example.com/mcp"),
).resolves.toMatchObject({
status: "revoked",
tokenTypeHint: "refresh_token",
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("https://as.example.com/revoke");
expect(new URLSearchParams(String(init?.body)).get("token")).toBe("r");
} finally {
fetchSpy.mockRestore();
}
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
});
// Both key spellings are cleared, but they are two spellings of one server:
// a second request would name a grant the first one already ended. The raw
// key here holds nothing, so this also proves the walk does not stop at the
// first empty one.
it("sends one request even though both key spellings are cleared", async () => {
seed();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 200 }));
try {
// Normalises to the stored `https://example.com/mcp`.
await expect(
clearStoredAuthForRelogin("https://Example.com/mcp"),
).resolves.toMatchObject({ status: "revoked" });
expect(fetchSpy).toHaveBeenCalledTimes(1);
} finally {
fetchSpy.mockRestore();
}
});
it("deletes the local entry even when the request fails", async () => {
const file = seed();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("unreachable"));
try {
await expect(
clearStoredAuthForRelogin("https://example.com/mcp"),
).resolves.toMatchObject({ status: "failed" });
} finally {
fetchSpy.mockRestore();
}
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
});
it("skips the request when revocation is turned off", async () => {
seed();
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
clearStoredAuthForRelogin("https://example.com/mcp", {
revoke: false,
}),
).resolves.toBeUndefined();
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("reports no_tokens when the store holds nothing for the server", async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-empty-"));
const file = path.join(dir, "oauth.json");
fs.writeFileSync(
file,
JSON.stringify({ servers: {}, idpSessions: {} }),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
await expect(
clearStoredAuthForRelogin("https://example.com/mcp"),
).resolves.toEqual({ status: "skipped", reason: "no_tokens" });
});
});
it("clears both raw and URL-normalised keys (bare origin / mixed-case host)", async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-norm-"));
const file = path.join(dir, "oauth.json");

View file

@ -2,6 +2,11 @@ import {
NodeOAuthStorage,
resetNodeOAuthStorageCache,
} from "@inspector/core/auth/node/storage-node.js";
import {
revokeStoredOAuthTokens,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
/** Same canonicalisation as CLI `normalizeServerUrl` (avoid cycles). */
function normalizeServerUrl(serverUrl: string): string {
@ -24,11 +29,24 @@ function normalizeServerUrl(serverUrl: string): string {
*/
export async function clearStoredAuthForRelogin(
serverUrl: string | undefined,
): Promise<void> {
if (!serverUrl?.trim()) return;
options?: { revoke?: boolean },
): Promise<TokenRevocationOutcome | undefined> {
if (!serverUrl?.trim()) return undefined;
const raw = serverUrl.trim();
const normalized = normalizeServerUrl(raw);
const storage = new NodeOAuthStorage();
// RFC 7009 (#2144): revoke before the clear, since the token, the client
// credentials and the discovered `revocation_endpoint` all live in the store
// this is about to empty. Best-effort — the outcome is returned for the
// caller to report, never thrown, so `--relogin` succeeds regardless.
//
// Only the key that actually holds the state is revoked from: the two keys
// below are two spellings of one server, so revoking from both would send a
// second request for a grant the first one already ended.
const revocation =
options?.revoke === false
? undefined
: await revokeFirstStoredKey(storage, [raw, normalized]);
await storage.clear(raw);
if (normalized !== raw) {
await storage.clear(normalized);
@ -36,4 +54,31 @@ export async function clearStoredAuthForRelogin(
// Drop the in-process singleton so the next connect cannot reuse a cleared
// entry from the NodeOAuthStorage cache.
resetNodeOAuthStorageCache();
return revocation;
}
/**
* Revoke against the first of `keys` that actually has a revocable token,
* returning that attempt's outcome. Reports the last "nothing to do" answer
* when no key holds one, so the caller can still distinguish "no tokens" from
* "this authorization server advertises no revocation endpoint".
*/
async function revokeFirstStoredKey(
storage: NodeOAuthStorage,
keys: string[],
): Promise<TokenRevocationOutcome | undefined> {
const fetchFn = createProxyFetch() ?? fetch;
let last: TokenRevocationOutcome | undefined;
for (const key of new Set(keys)) {
const outcome = await revokeStoredOAuthTokens({
serverUrl: key,
storage,
fetchFn,
});
if (!(outcome.status === "skipped" && outcome.reason === "no_tokens")) {
return outcome;
}
last = outcome;
}
return last;
}

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,
);
}

View file

@ -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.

View file

@ -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 () => {},
),

View file

@ -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(() => {

View file

@ -707,6 +707,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
revokeOnClear: true,
});
});
@ -751,6 +752,47 @@ describe("ServerSettingsForm", () => {
);
});
// #2144 — the RFC 7009 opt-out. On by default: leaving it off silently is
// what leaves the authorization server holding a live grant.
it("renders Revoke tokens on clear checked by default", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Revoke tokens on clear")).toBeChecked();
});
it("renders Revoke tokens on clear unchecked when the server opted out", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, oauthRevokeOnClear: false }}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Revoke tokens on clear")).not.toBeChecked();
});
it("toggles Revoke tokens on clear through onOAuthChange", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ revokeOnClear: false }),
);
});
it("warns when opting out while offline_access is still in Scopes", () => {
renderWithMantine(
<ServerSettingsForm
@ -1255,6 +1297,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: true,
requestRefreshToken: true,
revokeOnClear: true,
});
});
@ -1363,6 +1406,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
revokeOnClear: true,
});
});

View file

@ -521,6 +521,8 @@ export function ServerSettingsForm({
onInsufficientScope: settings.oauthOnInsufficientScope,
// Unset means the default, on — only an explicit opt-out is stored.
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
// Unset means the default, on — only an explicit opt-out is stored.
revokeOnClear: settings.oauthRevokeOnClear ?? true,
};
}
@ -570,6 +572,15 @@ export function ServerSettingsForm({
});
}
function handleRevokeOnClearChange(
event: ChangeEvent<HTMLInputElement>,
): void {
onOAuthChange({
...currentOAuth(),
revokeOnClear: event.currentTarget.checked,
});
}
function handleAddAuthorizationParam(): void {
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
}
@ -917,6 +928,12 @@ export function ServerSettingsForm({
checked={settings.oauthRequestRefreshToken ?? true}
onChange={handleRequestRefreshTokenChange}
/>
<Checkbox
label="Revoke tokens on clear"
description="Calls the authorization server's RFC 7009 revocation endpoint before clearing the stored OAuth state, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
checked={settings.oauthRevokeOnClear ?? true}
onChange={handleRevokeOnClearChange}
/>
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
<Alert color="yellow" title="offline_access is still requested">
The Scopes field above lists offline_access, and

View file

@ -156,6 +156,42 @@ describe("ServerSettingsModal", () => {
);
});
// #2144 — same omit-the-default shape as the refresh-token pair above.
it("maps the revoke-on-clear opt-out into settings, and back to unset", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const { rerender } = renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onSettingsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ oauthRevokeOnClear: false }),
);
rerender(
<ServerSettingsModal
opened
settings={{ ...initialSettings, oauthRevokeOnClear: false }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onSettingsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ oauthRevokeOnClear: undefined }),
);
});
it("maps the selected protocol era into settings (#1626)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();

View file

@ -191,6 +191,8 @@ export function ServerSettingsModal({
// server that never touched the switch writes no field at all.
oauthRequestRefreshToken:
oauth.requestRefreshToken === false ? false : undefined,
// #2144: same shape — `undefined` means on, so only the opt-out persists.
oauthRevokeOnClear: oauth.revokeOnClear === false ? false : undefined,
});
}

View file

@ -912,6 +912,11 @@ export function useConnectionLifecycle({
serverId === activeServerId ? inspectorClient : null,
isActiveConnection: serverId === activeServerId,
oauthStorage: webOAuthStorage,
// No RFC 7009 revocation here (#2144): this clears a *half-finished*
// flow so it can be retried. The authorization never completed, so
// there is no grant to revoke — and any token still on disk belongs
// to the very session this recovery is trying to rebuild.
revoke: false,
});
} catch (err) {
notifications.show({

View file

@ -21,6 +21,7 @@ import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults";
import { useSessionRef } from "./useSessionRef";
import { useTabUiState } from "./useTabUiState";
import {
revocationSuffix,
useOAuthRecovery,
type FetchRequestSource,
type OAuthRecovery,
@ -58,6 +59,14 @@ vi.mock("@inspector/core/mcp/remote/index.js", () => ({
RemoteInspectorClientStorage: class {
saveSession = saveSessionMock;
},
// #2144: `clearServerOAuthAndDisconnect` builds the backend-proxied fetch the
// revocation POST would travel on. Nothing here exercises a real request, so
// this only has to exist.
createRemoteFetch: () => remoteFetchMock,
}));
const { remoteFetchMock } = vi.hoisted(() => ({
remoteFetchMock: vi.fn<typeof fetch>(async () => new Response(null)),
}));
vi.mock("../lib/authToken", () => ({ getAuthToken: () => "test-token" }));
@ -262,7 +271,7 @@ beforeEach(() => {
window.sessionStorage.clear();
window.history.replaceState({}, "", "/");
oauthStorageMock.load.mockResolvedValue(undefined);
clearServerOAuthStateMock.mockResolvedValue(true);
clearServerOAuthStateMock.mockResolvedValue({ cleared: true });
saveSessionMock.mockResolvedValue(undefined);
});
@ -1271,9 +1280,37 @@ describe("useOAuthRecovery", () => {
});
});
// #2144 — only the two outcomes a user can act on are surfaced. A skip
// describes the status quo, and reporting it would turn a confirmation into a
// notice about something that did not need to happen.
describe("revocationSuffix", () => {
it("announces a successful revocation", () => {
expect(
revocationSuffix({
status: "revoked",
tokenTypeHint: "refresh_token",
endpoint: "https://as.example/revoke",
}),
).toContain("revoked at the authorization server");
});
it("warns that the grant may still be live after a failure", () => {
const text = revocationSuffix({ status: "failed", detail: "boom" });
expect(text).toContain("boom");
expect(text).toContain("may still be valid");
});
it("says nothing for a skip or an absent outcome", () => {
expect(
revocationSuffix({ status: "skipped", reason: "no_endpoint" }),
).toBe("");
expect(revocationSuffix(undefined)).toBe("");
});
});
describe("clearing stored OAuth state", () => {
it("says nothing when there was nothing to clear", async () => {
clearServerOAuthStateMock.mockResolvedValue(false);
clearServerOAuthStateMock.mockResolvedValue({ cleared: false });
const h = harness({ servers: [entry("a")], activeServerId: "a" });
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("a"));

View file

@ -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",
});
},

View file

@ -1,40 +1,47 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { clearServerOAuthState } from "./clearServerOAuthState";
const SERVER_URL = "https://mcp.example.com/mcp";
function skipped(): TokenRevocationOutcome {
return { status: "skipped", reason: "no_endpoint" };
}
describe("clearServerOAuthState", () => {
let storage: BrowserOAuthStorage;
beforeEach(async () => {
storage = new BrowserOAuthStorage();
await storage.clear("https://mcp.example.com/mcp");
await storage.clear(SERVER_URL);
});
it("clears storage by server URL when not the active connection", async () => {
await storage.saveTokens("https://mcp.example.com/mcp", {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
});
const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
const { cleared } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
});
expect(cleared).toBe(true);
expect(
await storage.getTokens("https://mcp.example.com/mcp"),
).toBeUndefined();
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});
it("uses the live client when clearing the active connection", async () => {
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>(
async () => skipped(),
);
const inspectorClient = { clearOAuthTokens };
const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
const { cleared } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
inspectorClient,
isActiveConnection: true,
oauthStorage: storage,
@ -42,15 +49,108 @@ describe("clearServerOAuthState", () => {
expect(cleared).toBe(true);
expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: true });
});
it("returns false for stdio servers", async () => {
it("returns cleared: false for stdio servers", async () => {
await expect(
clearServerOAuthState({
config: { type: "stdio", command: "node", args: [] },
isActiveConnection: false,
oauthStorage: storage,
}),
).resolves.toBe(false);
).resolves.toEqual({ cleared: false });
});
// #2144 — the opt-out has to reach the live client, since that is where the
// RFC 7009 request is actually made.
it("forwards revoke: false to the live client", async () => {
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>(
async () => skipped(),
);
await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
inspectorClient: { clearOAuthTokens },
isActiveConnection: true,
oauthStorage: storage,
revoke: false,
});
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: false });
});
// #2144 — the non-active path revokes from the store directly. The whole
// point is that it happens *before* the clear: after it there is no token,
// no client id and no cached metadata left to build a request from.
it("revokes before clearing when this server is not the active connection", async () => {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
refresh_token: "refresh-tok",
});
await storage.saveServerMetadata(SERVER_URL, {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: "https://as.example.com/revoke",
response_types_supported: ["code"],
});
let tokensAtRequestTime: unknown;
const fetchFn = vi.fn<typeof fetch>(async () => {
tokensAtRequestTime = await storage.getTokens(SERVER_URL);
return new Response(null, { status: 200 });
});
const { revocation } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
fetchFn,
});
expect(revocation).toEqual({
status: "revoked",
tokenTypeHint: "refresh_token",
endpoint: "https://as.example.com/revoke",
});
expect(tokensAtRequestTime).toBeDefined();
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});
// Without a backend-proxied fetch the request would go out on the page
// origin, where a real authorization server's missing CORS headers reject it.
// Skipping is honest; attempting it would fail loudly on real deployments and
// succeed on permissive ones.
it("skips revocation on the non-active path when given no fetch", async () => {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
});
const { revocation } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
});
expect(revocation).toEqual({ status: "skipped", reason: "disabled" });
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});
it("skips the request on the non-active path when revoke is off", async () => {
const fetchFn = vi.fn<typeof fetch>();
const { revocation } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
revoke: false,
fetchFn,
});
expect(fetchFn).not.toHaveBeenCalled();
expect(revocation).toEqual({ status: "skipped", reason: "disabled" });
});
});

View file

@ -1,3 +1,7 @@
import {
revokeStoredOAuthTokens,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
@ -10,25 +14,71 @@ export interface ClearServerOAuthStateParams {
isActiveConnection: boolean;
/** Shared web OAuth store; required so clear hits the same blob as connect. */
oauthStorage: OAuthStorage;
/**
* Whether to revoke the grant at the authorization server first (RFC 7009,
* #2144). Defaults to on. Two callers turn it off: a server whose settings
* opted out, and `lost_authorization_state` recovery — that path clears a
* half-finished flow in order to retry it, so there is no completed grant to
* revoke and the request would be noise at best.
*/
revoke?: boolean;
/**
* Fetch used for the revocation POST when this server is **not** the active
* connection (there is no live client to borrow one from). In the browser
* this must be the backend-proxied fetch the OAuth flow itself uses —
* `globalThis.fetch` would put the request on the page's origin, where an
* authorization server that serves no CORS headers rejects it. Omitting it
* skips revocation on that path rather than sending a request that cannot
* work.
*/
fetchFn?: typeof fetch;
}
export interface ClearServerOAuthStateResult {
/** False when the config has no OAuth server URL — nothing was cleared. */
cleared: boolean;
/** What the RFC 7009 leg did. Absent when nothing was cleared. */
revocation?: TokenRevocationOutcome;
}
/**
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
* HTTP MCP server. When clearing the active connection, uses the live client so
* in-memory flow state is reset too.
* HTTP MCP server, revoking the grant at the authorization server first. When
* clearing the active connection, uses the live client so in-memory flow state
* is reset too.
*
* Revocation is best-effort throughout — an authorization server advertising no
* `revocation_endpoint` is untouched, and a failure is reported in the result
* rather than thrown — so the local clear always finishes.
*/
export async function clearServerOAuthState(
params: ClearServerOAuthStateParams,
): Promise<boolean> {
): Promise<ClearServerOAuthStateResult> {
const serverUrl = getOAuthServerUrl(params.config);
if (!serverUrl) {
return false;
return { cleared: false };
}
const revoke = params.revoke !== false;
if (params.isActiveConnection && params.inspectorClient) {
await params.inspectorClient.clearOAuthTokens();
} else {
await params.oauthStorage.clear(serverUrl);
const revocation = await params.inspectorClient.clearOAuthTokens({
revoke,
});
return { cleared: true, revocation };
}
return true;
// No proxied fetch on hand means no request we could usefully make, so the
// leg is reported as skipped rather than attempted against the page origin.
const fetchFn = params.fetchFn;
const revocation: TokenRevocationOutcome =
revoke && fetchFn
? await revokeStoredOAuthTokens({
serverUrl,
storage: params.oauthStorage,
fetchFn,
})
: { status: "skipped", reason: "disabled" };
await params.oauthStorage.clear(serverUrl);
return { cleared: true, revocation };
}

View file

@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const { createRemoteFetchMock } = vi.hoisted(() => ({
createRemoteFetchMock: vi.fn(),
}));
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
createRemoteFetch: createRemoteFetchMock,
}));
import {
getWebProxiedFetch,
resetWebProxiedFetchCacheForTests,
} from "./webProxiedFetch";
describe("getWebProxiedFetch", () => {
beforeEach(() => {
resetWebProxiedFetchCacheForTests();
createRemoteFetchMock.mockReset();
createRemoteFetchMock.mockImplementation(() => vi.fn());
});
it("builds a remote fetch against the page origin and the API token", () => {
getWebProxiedFetch("tok");
expect(createRemoteFetchMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: `${window.location.protocol}//${window.location.host}`,
authToken: "tok",
}),
);
});
it("reuses the instance for the same origin and token", () => {
const first = getWebProxiedFetch("tok");
expect(getWebProxiedFetch("tok")).toBe(first);
expect(createRemoteFetchMock).toHaveBeenCalledTimes(1);
});
it("rebuilds when the token changes", () => {
const first = getWebProxiedFetch("tok");
expect(getWebProxiedFetch("other")).not.toBe(first);
expect(createRemoteFetchMock).toHaveBeenCalledTimes(2);
});
it("forwards a fetch that reaches the injected base fetch", async () => {
const inner = vi.fn<typeof fetch>(async () => new Response(null));
createRemoteFetchMock.mockImplementation(
({ fetchFn }: { fetchFn: typeof fetch }) => fetchFn,
);
const proxied = getWebProxiedFetch();
const globalFetch = vi
.spyOn(globalThis, "fetch")
.mockImplementation(inner as typeof fetch);
await proxied("https://example.com");
expect(inner).toHaveBeenCalled();
globalFetch.mockRestore();
});
});

View file

@ -0,0 +1,44 @@
import { createRemoteFetch } from "@inspector/core/mcp/remote/index.js";
let cached: { cacheKey: string; fetchFn: typeof fetch } | undefined;
const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args);
/**
* The backend-proxied fetch the browser must use for any request aimed at an
* authorization server rather than at the Inspector itself.
*
* `createWebEnvironment` hands the same thing to `InspectorClient`, so an OAuth
* request made through a live client already travels this way. This exists for
* the paths that have no client to borrow one from — clearing (and revoking)
* the stored OAuth state of a server that is not the active connection.
*
* Going direct is not an option there: an authorization server serves no CORS
* headers for a page origin it has never heard of, so `globalThis.fetch` would
* fail on nearly every real deployment while working on a permissive one — the
* worst shape of bug to carry.
*
* Cached as a cache of one, keyed the same way `getWebRemoteOAuthStorage` keys
* its store: the web app has a stable origin and a page-lifetime API token, so
* the key does not change within a session.
*/
export function getWebProxiedFetch(authToken?: string): typeof fetch {
if (typeof window === "undefined") {
throw new Error("getWebProxiedFetch requires a browser environment");
}
const baseUrl = `${window.location.protocol}//${window.location.host}`;
const cacheKey = `${baseUrl}\0${authToken ?? ""}`;
if (cached?.cacheKey === cacheKey) {
return cached.fetchFn;
}
cached = {
cacheKey,
fetchFn: createRemoteFetch({ baseUrl, authToken, fetchFn: defaultFetch }),
};
return cached.fetchFn;
}
/** @internal Vitest isolation */
export function resetWebProxiedFetchCacheForTests(): void {
cached = undefined;
}

Some files were not shown because too many files have changed in this diff Show more