From e4f2c06d0c07417b50c7126176cf1ffd7dc59882 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 25 Oct 2026 01:01:14 -0400 Subject: [PATCH 1/2] feat(auth): opt-in scope on OAuth token refresh via scopeForRefresh (#2805) Some authorization servers reject a refresh-token request with no `scope`; Microsoft Entra ID answers AADSTS90009 when the client and the resource are the same app registration, so every refresh fails and the user must sign in again. SDK 2.4.0 adds an optional `OAuthClientProvider.scopeForRefresh`. Implement it on BaseOAuthClientProvider behind a new per-server setting, "Send scope on token refresh" (`oauth.scopeOnRefresh` in mcp.json, `oauthScopeOnRefresh` in settings), off by default so the refresh request is unchanged unless a user opts in. When on, the scope sent is the granted scope saved with the tokens, falling back to the authorization request's stored scope (with the #2068 offline_access filter) and then the configured scope. The EMA transport provider forwards the method. Plumbed through the config round-trip, the remote settings route, the OAuth manager, the CLI/TUI runner and the web connect path, with an e2e test that inspects the real refresh request body. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../ServerSettingsForm.test.tsx | 46 ++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 17 +++ .../ServerSettingsModal.test.tsx | 37 ++++++ .../ServerSettingsModal.tsx | 2 + .../src/hooks/useConnectionLifecycle.test.tsx | 19 +++- .../web/src/hooks/useConnectionLifecycle.ts | 8 +- .../core/auth/ema/transportProvider.test.ts | 10 ++ .../web/src/test/core/auth/providers.test.ts | 106 ++++++++++++++++++ .../web/src/test/core/client/runner.test.ts | 20 ++++ .../src/test/core/mcp/oauthManager.test.ts | 18 +++ .../web/src/test/core/mcp/serverList.test.ts | 49 ++++++++ .../mcp/inspectorClient-oauth-e2e.test.ts | 87 ++++++++++++++ .../mcp/remote/server-extra-coverage.test.ts | 53 +++++++++ core/auth/ema/transportProvider.ts | 8 ++ core/auth/providers.ts | 55 +++++++++ core/client/runner.ts | 8 +- core/mcp/inspectorClient.ts | 2 + core/mcp/oauthManager.ts | 5 + core/mcp/remote/node/server.ts | 23 +++- core/mcp/serverList.ts | 9 ++ core/mcp/types.ts | 28 +++++ docs/mcp-server-configuration.md | 6 +- 22 files changed, 610 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 48ef7f3ca7..53a349d519 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -923,6 +923,7 @@ describe("ServerSettingsForm", () => { tokenUrl: "", enterpriseManaged: false, requestRefreshToken: true, + scopeOnRefresh: false, revokeOnClear: true, }); }); @@ -968,6 +969,49 @@ describe("ServerSettingsForm", () => { ); }); + // #2805 — the refresh-scope opt-in. Off by default, so the SDK's refresh + // request stays exactly as it was unless the user asks otherwise. + it("renders Send scope on token refresh unchecked by default", () => { + renderWithMantine( + , + ); + expect( + screen.getByLabelText("Send scope on token refresh"), + ).not.toBeChecked(); + }); + + it("renders Send scope on token refresh checked when the server opted in", () => { + renderWithMantine( + , + ); + expect(screen.getByLabelText("Send scope on token refresh")).toBeChecked(); + }); + + it("toggles Send scope on token refresh through onOAuthChange", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByLabelText("Send scope on token refresh")); + expect(onOAuthChange).toHaveBeenLastCalledWith( + expect.objectContaining({ scopeOnRefresh: true }), + ); + }); + // #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", () => { @@ -1513,6 +1557,7 @@ describe("ServerSettingsForm", () => { tokenUrl: "", enterpriseManaged: true, requestRefreshToken: true, + scopeOnRefresh: false, revokeOnClear: true, }); }); @@ -1675,6 +1720,7 @@ describe("ServerSettingsForm", () => { tokenUrl: "", enterpriseManaged: false, requestRefreshToken: true, + scopeOnRefresh: false, revokeOnClear: true, }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index b2551e9460..d245ee1935 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -621,6 +621,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, off — only an explicit opt-in is stored. + scopeOnRefresh: settings.oauthScopeOnRefresh ?? false, // Unset means the default, on — only an explicit opt-out is stored. revokeOnClear: settings.oauthRevokeOnClear ?? true, }; @@ -672,6 +674,15 @@ export function ServerSettingsForm({ }); } + function handleScopeOnRefreshChange( + event: ChangeEvent, + ): void { + onOAuthChange({ + ...currentOAuth(), + scopeOnRefresh: event.currentTarget.checked, + }); + } + function handleRevokeOnClearChange( event: ChangeEvent, ): void { @@ -1067,6 +1078,12 @@ export function ServerSettingsForm({ checked={settings.oauthRequestRefreshToken ?? true} onChange={handleRequestRefreshTokenChange} /> + { ); }); + // #2805 — the inverse of the refresh-token mapping: off is the default, so + // only the opt-in persists and unchecking it writes nothing. + it("maps the refresh-scope opt-in into settings, and back to unset", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /OAuth Settings/i })); + await user.click(screen.getByLabelText("Send scope on token refresh")); + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ oauthScopeOnRefresh: true }), + ); + + rerender( + , + ); + await user.click(screen.getByLabelText("Send scope on token refresh")); + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ oauthScopeOnRefresh: undefined }), + ); + }); + // #2317 — omit-when-off, so an untouched server writes no field. it("maps the notification-stream suppression into settings, and back to unset", async () => { const user = userEvent.setup(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index 80231a9b0b..490e67d5e5 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -205,6 +205,8 @@ export function ServerSettingsModal({ // server that never touched the switch writes no field at all. oauthRequestRefreshToken: oauth.requestRefreshToken === false ? false : undefined, + // #2805: the inverse — `undefined` means off, so only the opt-in persists. + oauthScopeOnRefresh: oauth.scopeOnRefresh === true ? true : undefined, // #2144: same shape — `undefined` means on, so only the opt-out persists. oauthRevokeOnClear: oauth.revokeOnClear === false ? false : undefined, }); diff --git a/clients/web/src/hooks/useConnectionLifecycle.test.tsx b/clients/web/src/hooks/useConnectionLifecycle.test.tsx index e66c4c7bbc..d30f6196df 100644 --- a/clients/web/src/hooks/useConnectionLifecycle.test.tsx +++ b/clients/web/src/hooks/useConnectionLifecycle.test.tsx @@ -559,8 +559,8 @@ describe("useConnectionLifecycle", () => { }); it("forwards the refresh-token opt-out on its own", () => { - // The last operand of the "is there any per-server OAuth?" chain, so it - // is the only shape that reaches it — every other field short-circuits. + // A late operand of the "is there any per-server OAuth?" chain, so it + // is the only shape that decides it — every earlier field short-circuits. const settings: InspectorServerSettings = { ...EMPTY_SETTINGS, oauthRequestRefreshToken: false, @@ -574,6 +574,21 @@ describe("useConnectionLifecycle", () => { expect(client.getServerSettings()?.oauthRequestRefreshToken).toBe(false); }); + it("forwards the refresh-scope opt-in on its own (#2805)", () => { + // Now the last operand of the "is there any per-server OAuth?" chain. + const settings: InspectorServerSettings = { + ...EMPTY_SETTINGS, + oauthScopeOnRefresh: true, + }; + const h = harness({ + servers: [entry("a", { settings })], + persisted: { a: settings }, + }); + + const client = h.published()!(entry("a", { settings })); + expect(client.getServerSettings()?.oauthScopeOnRefresh).toBe(true); + }); + it("threads the per-server authorization params and endpoint overrides", () => { const settings: InspectorServerSettings = { ...EMPTY_SETTINGS, diff --git a/clients/web/src/hooks/useConnectionLifecycle.ts b/clients/web/src/hooks/useConnectionLifecycle.ts index bdf21cf7c2..cfb85260c7 100644 --- a/clients/web/src/hooks/useConnectionLifecycle.ts +++ b/clients/web/src/hooks/useConnectionLifecycle.ts @@ -446,7 +446,8 @@ export function useConnectionLifecycle({ serverAuthorizationParams || serverEndpointOverrides || savedSettings.enterpriseManaged || - savedSettings.oauthRequestRefreshToken === false) + savedSettings.oauthRequestRefreshToken === false || + savedSettings.oauthScopeOnRefresh === true) ? { ...(savedSettings.oauthClientId && { clientId: savedSettings.oauthClientId, @@ -469,6 +470,11 @@ export function useConnectionLifecycle({ ...(savedSettings.oauthRequestRefreshToken === false && { requestRefreshToken: false, }), + // #2805: only the opt-in is forwarded; omitting the key leaves the + // SDK's default refresh request (no `scope`) in place. + ...(savedSettings.oauthScopeOnRefresh === true && { + scopeOnRefresh: true, + }), } : undefined; const oauth = diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts index 6079a36c46..c3f3cf882d 100644 --- a/clients/web/src/test/core/auth/ema/transportProvider.test.ts +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -67,6 +67,7 @@ interface FakeInner { codeVerifier: ReturnType; saveDiscoveryState: ReturnType; discoveryState: ReturnType; + scopeForRefresh: ReturnType; } function createInner(): FakeInner { @@ -86,6 +87,7 @@ function createInner(): FakeInner { codeVerifier: vi.fn(() => "verifier-xyz"), saveDiscoveryState: vi.fn(), discoveryState: vi.fn(), + scopeForRefresh: vi.fn(async () => "mcp tools:read"), }; } @@ -170,6 +172,14 @@ describe("EmaTransportOAuthProvider", () => { expect(inner.discoveryState).toHaveBeenCalled(); }); + // #2805: the per-server setting lives on the inner provider, so the wrapper + // must forward it or an EMA connection silently refreshes without `scope`. + it("delegates scopeForRefresh to the inner provider", async () => { + const tokens = { access_token: "at", token_type: "Bearer" }; + expect(await provider.scopeForRefresh(tokens)).toBe("mcp tools:read"); + expect(inner.scopeForRefresh).toHaveBeenCalledWith(tokens); + }); + it("tokens() returns stored tokens when the access token is still usable", async () => { const stored: OAuthTokens = { access_token: VALID_ACCESS_TOKEN, diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index aeae0dca55..6a6f1ec952 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -598,6 +598,112 @@ describe("OAuthNavigation", () => { }); }); + describe("scopeForRefresh (#2805)", () => { + function makeRefreshProvider(opts: { + scopeOnRefresh?: boolean; + storedScope?: string; + configuredScope?: string; + requestRefreshToken?: boolean; + }): BaseOAuthClientProvider { + const storage = makeStorage(); + vi.mocked(storage.getScope).mockResolvedValue(opts.storedScope); + return new BaseOAuthClientProvider(SERVER, { + storage, + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(vi.fn()), + scopeOnRefresh: opts.scopeOnRefresh, + configuredScope: opts.configuredScope, + requestRefreshToken: opts.requestRefreshToken, + }); + } + + const grantedTokens = { + access_token: "at", + token_type: "Bearer", + refresh_token: "rt", + scope: "mcp tools:read", + }; + + // The default must leave the SDK's refresh request untouched: an + // `undefined` here is what makes `auth()` send no `scope` at all. + it("sends no scope by default", async () => { + const provider = makeRefreshProvider({ storedScope: "mcp" }); + expect(await provider.scopeForRefresh(grantedTokens)).toBeUndefined(); + }); + + it("sends no scope when explicitly off", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: false, + storedScope: "mcp", + }); + expect(await provider.scopeForRefresh(grantedTokens)).toBeUndefined(); + }); + + it("sends the granted scope saved with the tokens", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: true, + storedScope: "mcp", + configuredScope: "mcp", + }); + expect(await provider.scopeForRefresh(grantedTokens)).toBe( + "mcp tools:read", + ); + }); + + // RFC 6749 §5.1: a token response omits `scope` when it equals the + // requested one, so the request scope is what was granted. + it("falls back to the stored request scope when the tokens carry none", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: true, + storedScope: " mcp offline_access ", + configuredScope: "mcp", + }); + expect( + await provider.scopeForRefresh({ ...grantedTokens, scope: " " }), + ).toBe("mcp offline_access"); + }); + + it("falls back to the configured scope when storage holds none", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: true, + configuredScope: "mcp", + }); + expect( + await provider.scopeForRefresh({ + access_token: "at", + token_type: "Bearer", + }), + ).toBe("mcp"); + }); + + it("returns undefined when no scope is known anywhere", async () => { + const provider = makeRefreshProvider({ scopeOnRefresh: true }); + expect( + await provider.scopeForRefresh({ + access_token: "at", + token_type: "Bearer", + }), + ).toBeUndefined(); + }); + + // The fallback is the scope the authorization request used, and that + // request went through the #2068 filter — so the fallback does too. + it("applies the #2068 offline_access filter to the fallback scope", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: true, + storedScope: "mcp offline_access", + configuredScope: "mcp", + requestRefreshToken: false, + }); + expect( + await provider.scopeForRefresh({ + access_token: "at", + token_type: "Bearer", + }), + ).toBe("mcp"); + }); + }); + describe("SEP-2352 issuer threading", () => { it("forwards ctx.issuer to storage on clientInformation/tokens reads", async () => { const storage = makeStorage(); diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts index de5c080a8b..39b0f53466 100644 --- a/clients/web/src/test/core/client/runner.test.ts +++ b/clients/web/src/test/core/client/runner.test.ts @@ -114,6 +114,26 @@ describe("runner client auth options", () => { ).toBeUndefined(); }); + // #2805 — the refresh-scope opt-in reaches the CLI/TUI leg the same way. + it("buildRunnerClientAuthOptions forwards the refresh-scope opt-in", () => { + const base: InspectorServerSettings = { + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + metadata: {}, + headers: [], + env: [], + roots: [], + }; + expect( + buildRunnerClientAuthOptions({}, { ...base, oauthScopeOnRefresh: true }) + .oauth?.scopeOnRefresh, + ).toBe(true); + // Off (the default) forwards nothing, so the provider keeps its default. + expect(buildRunnerClientAuthOptions({}, base).oauth).toBeUndefined(); + }); + // #2018 — the CLI/TUI leg picks the authorization parameters up from the same // per-server settings the web leg reads, so all three clients agree. it("buildRunnerClientAuthOptions forwards custom authorization params", () => { diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 43c4a009e5..6c92558bdf 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -2258,6 +2258,24 @@ describe("OAuthManager", () => { expect(provider.clientMetadata.scope).toBe("mcp"); }); + // #2805 — the same bridge for the refresh-scope opt-in: the e2e suite + // proves the request, this pins the one line that carries the setting. + it("forwards the refresh-scope opt-in to the provider it builds", async () => { + const tokens = { + access_token: "at", + token_type: "Bearer", + scope: "mcp", + }; + const on = new OAuthManager(createMockParams()); + on.setOAuthConfig({ scopeOnRefresh: true }); + const onProvider = await on.createOAuthProviderForTransport(); + expect(await onProvider.scopeForRefresh?.(tokens)).toBe("mcp"); + + const off = new OAuthManager(createMockParams()); + const offProvider = await off.createOAuthProviderForTransport(); + expect(await offProvider.scopeForRefresh?.(tokens)).toBeUndefined(); + }); + it("declares the refresh_token grant when the opt-out is not configured", async () => { const manager = new OAuthManager(createMockParams()); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 6dd9dc9782..094dacfc23 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1461,6 +1461,55 @@ describe("oauth.requestRefreshToken (#2068)", () => { }); }); +describe("oauth.scopeOnRefresh (#2805)", () => { + const baseSettings = { + headers: [], + env: [], + metadata: {}, + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + }; + + it("lifts an explicit true to settings.oauthScopeOnRefresh", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", scopeOnRefresh: true }, + }); + expect(settings?.oauthScopeOnRefresh).toBe(true); + }); + + it("leaves the setting unset when absent or false (default: off)", () => { + expect( + storedFieldsToInspectorSettings({ oauth: { clientId: "cid" } }) + ?.oauthScopeOnRefresh, + ).toBeUndefined(); + expect( + storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", scopeOnRefresh: false }, + })?.oauthScopeOnRefresh, + ).toBeUndefined(); + }); + + it("writes only the opt-in, and round-trips it", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthScopeOnRefresh: true, + }); + expect(stored.oauth?.scopeOnRefresh).toBe(true); + expect(storedFieldsToInspectorSettings(stored)?.oauthScopeOnRefresh).toBe( + true, + ); + expect( + inspectorSettingsToStoredFields({ + ...baseSettings, + oauthScopeOnRefresh: false, + }).oauth, + ).toBeUndefined(); + }); +}); + // #2144 — same inverted-default shape as `requestRefreshToken` above: on by // default, and only the opt-out is written, so an entry that never touched the // switch keeps a byte-stable round-trip. diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index 86f3b2b678..9fabaffc72 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -1042,6 +1042,93 @@ describe("InspectorClient OAuth E2E", () => { const toolsResult = await client.listTools(); expect(toolsResult).toBeDefined(); }); + + // #2805: the opt-in must reach the token request itself, through the + // real SDK `auth()`, and the default must leave it carrying no `scope`. + it.each([ + { scopeOnRefresh: undefined, expectScope: false }, + { scopeOnRefresh: true, expectScope: true }, + ])( + "sends scope on the refresh request only when opted in (scopeOnRefresh: $scopeOnRefresh)", + async ({ scopeOnRefresh, expectScope }) => { + const staticClientId = "test-refresh-scope"; + const staticClientSecret = "test-secret-refresh-scope"; + server = new TestServerHttp({ + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + scope: "mcp", + ...(scopeOnRefresh !== undefined && { scopeOnRefresh }), + }, + }, + ); + const fetchRequestLogState = new FetchRequestLogState(client); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.refresh_token).toBeDefined(); + invalidateAccessToken(tokens!.access_token); + await client.disconnect(); + await client.connect(); + expect(client.getStatus()).toBe("connected"); + + const refreshRequests = fetchRequestLogState + .getFetchRequests() + .map((r) => new URLSearchParams(r.requestBody ?? "")) + .filter((body) => body.get("grant_type") === "refresh_token"); + expect(refreshRequests).toHaveLength(1); + const sentScope = refreshRequests[0]!.get("scope"); + if (expectScope) { + expect(sentScope?.split(" ")).toContain("mcp"); + } else { + expect(sentScope).toBeNull(); + } + }, + ); }, ); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 3ce1ab9444..c303fb23c0 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -1165,6 +1165,35 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/oauthRequestRefreshToken/); }); + it("rejects a non-boolean oauthScopeOnRefresh (#2805)", async () => { + const res = await postSettings({ ...base, oauthScopeOnRefresh: "yes" }); + expect((await res.json()).error).toMatch(/oauthScopeOnRefresh/); + }); + + // #2805 — read the saved entry back, for the reason the #2068 pair below + // gives: a 200 alone does not prove `normalizeSettings` carried it. + async function readScopeOnRefresh(): Promise { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record }>; + }; + return body.mcpServers.srv?.oauth?.scopeOnRefresh; + } + + it("persists the refresh-scope opt-in through a save", async () => { + expect( + (await postSettings({ ...base, oauthScopeOnRefresh: true })).status, + ).toBe(200); + expect(await readScopeOnRefresh()).toBe(true); + }); + + it("writes no refresh-scope field when the setting is off", async () => { + expect( + (await postSettings({ ...base, oauthScopeOnRefresh: false })).status, + ).toBe(200); + expect(await readScopeOnRefresh()).toBeUndefined(); + }); + it("rejects a non-boolean oauthRevokeOnClear (#2144)", async () => { const res = await postSettings({ ...base, oauthRevokeOnClear: "no" }); expect((await res.json()).error).toMatch(/oauthRevokeOnClear/); @@ -1456,6 +1485,30 @@ describe("server.ts supplemental coverage", () => { } }); + // #2805 — same all-or-nothing rule for the refresh-scope opt-in. + it("drops oauth whose scopeOnRefresh is not a boolean", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { scopeOnRefresh: "yes" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record>; + }; + expect(body.mcpServers.srv).not.toHaveProperty("oauth"); + } finally { + await stop(h); + } + }); + // #2144 — same all-or-nothing rule for the revocation opt-out. it("drops oauth whose revokeOnClear is not a boolean", async () => { const h = await start({ diff --git a/core/auth/ema/transportProvider.ts b/core/auth/ema/transportProvider.ts index 07c5c0c79b..5c922758f4 100644 --- a/core/auth/ema/transportProvider.ts +++ b/core/auth/ema/transportProvider.ts @@ -5,6 +5,7 @@ import type { OAuthClientMetadata, OAuthDiscoveryState, OAuthTokens, + StoredOAuthTokens, } from "@modelcontextprotocol/client"; import type { BaseOAuthClientProvider } from "../providers.js"; import { @@ -100,6 +101,13 @@ export class EmaTransportOAuthProvider implements OAuthClientProvider { return undefined; } + // #2805: the per-server "send scope on refresh" setting lives on the inner + // provider. Without this the SDK would find no `scopeForRefresh` here and + // refresh an EMA connection's resource tokens with no `scope` regardless. + scopeForRefresh(tokens: StoredOAuthTokens): Promise { + return this.inner.scopeForRefresh(tokens); + } + saveTokens(tokens: OAuthTokens): void | Promise { return this.emaConfig.storage.saveTokens(this.emaConfig.serverUrl, tokens, { enterpriseManaged: true, diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 3e4644475b..b854d347fd 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -2,6 +2,7 @@ import type { OAuthClientProvider, OAuthClientInformationContext, StoredOAuthClientInformation, + StoredOAuthTokens, } from "@modelcontextprotocol/client"; import type { OAuthClientInformation, @@ -138,6 +139,19 @@ export type OAuthProviderConfig = { * the request when {@link requestRefreshToken} is false. (#2068) */ configuredScope?: string; + /** + * Whether to send a `scope` on the refresh-token request (#2805). Defaults to + * `false`, which leaves the SDK's refresh request exactly as it was: no + * `scope`, which asks for the scope already granted (RFC 6749 §6). + * + * Some authorization servers reject that request. Microsoft Entra ID answers + * `AADSTS90009` when the client and the resource are the same app + * registration, so every refresh fails and the user has to sign in again. + * Opt-in per server, because a `scope` on refresh can narrow or alter the + * grant on other authorization servers — which is why the SDK keeps its own + * default unchanged too. See {@link BaseOAuthClientProvider.scopeForRefresh}. + */ + scopeOnRefresh?: boolean; /** * Where the in-flight PKCE verifier lives (#2726). When set, the verifier is * keyed by the OAuth `state` of the flow that created it instead of sharing @@ -170,6 +184,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { protected requestRefreshToken: boolean; /** Server-configured scope, for the #2068 `offline_access` distinction. */ protected configuredScope?: string; + /** Send `scope` on the refresh-token request (#2805). */ + protected scopeOnRefresh: boolean; /** Per-flow verifier store (#2726); absent → the shared storage slot. */ protected flowStore?: PkceFlowStore; /** @@ -187,6 +203,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.authorizationParams = oauthConfig.authorizationParams; this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true; this.configuredScope = oauthConfig.configuredScope; + this.scopeOnRefresh = oauthConfig.scopeOnRefresh ?? false; this.flowStore = oauthConfig.flowStore; } @@ -243,6 +260,44 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { return this.cachedScope; } + /** + * The `scope` the SDK's `auth()` sends on a refresh-token request (#2805). + * `undefined` — the answer whenever {@link scopeOnRefresh} is off — means the + * request carries no `scope`, the SDK's default. + * + * When it is on, the SDK requires the value not to exceed what was granted, + * so the sources are tried in order of how directly they say that: + * + * 1. **The granted scope saved with the tokens** (`tokens.scope`). It is the + * authorization server's own statement of the grant, and it already + * reflects a step-up: a widened grant comes back with its token response. + * 2. **The scope of the authorization request**, as persisted in OAuth + * storage. A token response may omit `scope` when it equals the requested + * one (RFC 6749 §5.1), so this is what was granted in that case. It is the + * same value {@link scope} requests with, so it gets the same #2068 + * `offline_access` filter — see {@link scopeForDeclinedRefreshGrant}. + * 3. **The configured scope**, for a store that recorded no request scope. + * + * A step-up that would *widen* the grant never reaches here: the SDK skips + * refresh for it (`forceReauthorization`) and runs a new authorization + * request, so this never asks a refresh for more than was granted. + */ + async scopeForRefresh( + tokens: StoredOAuthTokens, + ): Promise { + if (!this.scopeOnRefresh) return undefined; + const granted = tokens.scope?.trim(); + if (granted) return granted; + const requested = + (await this.storage.getScope(this.serverUrl))?.trim() || + this.configuredScope?.trim() || + undefined; + if (!this.requestRefreshToken) { + return scopeForDeclinedRefreshGrant(requested, this.configuredScope); + } + return requested; + } + get redirectUrl(): string { return this.redirectUrlProvider.getRedirectUrl(); } diff --git a/core/client/runner.ts b/core/client/runner.ts index 411ef22db0..95c72c0365 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -88,7 +88,8 @@ export function buildRunnerClientAuthOptions( serverAuthorizationParams || serverEndpointOverrides || savedSettings.enterpriseManaged || - savedSettings.oauthRequestRefreshToken === false) + savedSettings.oauthRequestRefreshToken === false || + savedSettings.oauthScopeOnRefresh === true) ? { ...(savedSettings.oauthClientId && { clientId: savedSettings.oauthClientId, @@ -111,6 +112,11 @@ export function buildRunnerClientAuthOptions( ...(savedSettings.oauthRequestRefreshToken === false && { requestRefreshToken: false, }), + // #2805: only the opt-in is forwarded; omitting the key leaves the + // SDK's default refresh request (no `scope`) in place. + ...(savedSettings.oauthScopeOnRefresh === true && { + scopeOnRefresh: true, + }), } : undefined; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index c0fe604f33..cae94d9fcc 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -6165,6 +6165,8 @@ export class InspectorClient extends InspectorClientEventTarget { tokenUrl?: string; /** Declare the `refresh_token` grant in client metadata (#2068). */ requestRefreshToken?: boolean; + /** Send `scope` on the refresh-token request (#2805). */ + scopeOnRefresh?: boolean; }): void { if (!this.oauthManager) { throw new Error( diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 474d6be6d3..fc92e6af66 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -117,6 +117,8 @@ export class OAuthManager { tokenUrl?: string; /** Declare the `refresh_token` grant in client metadata (#2068). */ requestRefreshToken?: boolean; + /** Send `scope` on the refresh-token request (#2805). */ + scopeOnRefresh?: boolean; }): void { this.oauthConfig = { ...this.oauthConfig, @@ -192,6 +194,9 @@ export class OAuthManager { // `authorizationParams` this is pure config off mcp.json, so it lives on // the provider rather than in OAuth storage. requestRefreshToken: this.oauthConfig.requestRefreshToken, + // #2805: per-server opt-in to a `scope` on the refresh-token request. + // Pure config, like the two above. + scopeOnRefresh: this.oauthConfig.scopeOnRefresh, // #2068: lets the provider tell an `offline_access` the user asked for // from one inherited from a previous grant's persisted scope. configuredScope: this.oauthConfig.scope, diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index db64eb74c9..391cf7afa3 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1759,6 +1759,7 @@ export function createRemoteApp( tokenUrl?: string; enterpriseManaged?: boolean; requestRefreshToken?: boolean; + scopeOnRefresh?: boolean; revokeOnClear?: boolean; } => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; @@ -1795,6 +1796,13 @@ export function createRemoteApp( ) { return false; } + // #2805 — shape only; the read side keeps just an explicit `true`. + if ( + o.scopeOnRefresh !== undefined && + typeof o.scopeOnRefresh !== "boolean" + ) { + return false; + } // #2144 — shape only, same as the flag above: the read side keeps just an // explicit `false`, so a stray `true` reads back as the default anyway. if (o.revokeOnClear !== undefined && typeof o.revokeOnClear !== "boolean") { @@ -1938,7 +1946,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken?, revokeOnClear? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken?, scopeOnRefresh?, revokeOnClear? }`.", ); delete valObj.oauth; } @@ -2272,6 +2280,15 @@ export function createRemoteApp( error: "settings.oauthRequestRefreshToken must be a boolean", }; } + if ( + obj.oauthScopeOnRefresh !== undefined && + typeof obj.oauthScopeOnRefresh !== "boolean" + ) { + return { + ok: false, + error: "settings.oauthScopeOnRefresh must be a boolean", + }; + } if ( obj.oauthRevokeOnClear !== undefined && typeof obj.oauthRevokeOnClear !== "boolean" @@ -2424,6 +2441,10 @@ export function createRemoteApp( if (obj.oauthRequestRefreshToken === false) { value.oauthRequestRefreshToken = false; } + // #2805: the default is off, so only the opt-in travels. + if (obj.oauthScopeOnRefresh === true) { + value.oauthScopeOnRefresh = true; + } // #2144: same shape — the default is on, so only the opt-out travels. if (obj.oauthRevokeOnClear === false) { value.oauthRevokeOnClear = false; diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 1849c2ff4e..b28f9067b7 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -683,6 +683,11 @@ export function storedFieldsToInspectorSettings( if (stored.oauth?.requestRefreshToken === false) { settings.oauthRequestRefreshToken = false; } + // Default *off*, like `enterpriseManaged`: only an explicit `true` is carried + // through, so a hand-edited non-boolean reads back as the default. (#2805) + if (stored.oauth?.scopeOnRefresh === true) { + settings.oauthScopeOnRefresh = true; + } // Same inverted default as the flag above: revocation is on unless the entry // explicitly opted out. (#2144) if (stored.oauth?.revokeOnClear === false) { @@ -860,6 +865,10 @@ export function inspectorSettingsToStoredFields( if (settings.oauthRequestRefreshToken === false) { oauthFields.requestRefreshToken = false; } + // The default here is off, so only `true` is written. (#2805) + if (settings.oauthScopeOnRefresh === true) { + oauthFields.scopeOnRefresh = true; + } // Same omit-the-default rule as the flag above. (#2144) if (settings.oauthRevokeOnClear === false) { oauthFields.revokeOnClear = false; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 513dbd12e6..9e9eca02fc 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -246,6 +246,13 @@ export type StoredMCPServer = MCPServerConfig & { * Inspector-specific. (#2068) */ requestRefreshToken?: boolean; + /** + * Whether a refresh-token request carries `scope`. Defaults to `false`; + * only `true` is written to disk. See + * {@link InspectorServerSettings.oauthScopeOnRefresh}. Inspector-specific. + * (#2805) + */ + scopeOnRefresh?: boolean; /** * Whether clearing this server's OAuth state also revokes the grant at the * authorization server (RFC 7009). Defaults to `true`; only `false` is @@ -718,6 +725,11 @@ export interface OAuthSettings { * reason as `authorizationParams`; `undefined` means the default, on. */ requestRefreshToken?: boolean; + /** + * Whether a refresh-token request carries `scope` (#2805). Optional for the + * same reason as `authorizationParams`; `undefined` means the default, off. + */ + scopeOnRefresh?: boolean; /** * Whether clearing the stored OAuth state revokes the grant at the * authorization server (#2144). Optional for the same reason as @@ -954,6 +966,20 @@ export interface InspectorServerSettings { * the stored OAuth state is cleared. */ oauthRequestRefreshToken?: boolean; + /** + * Whether the Inspector sends `scope` on a refresh-token request for this + * server (#2805). `undefined` (the default) means off, which leaves the + * SDK's refresh request unchanged: no `scope`, asking for the scope already + * granted. Persisted as `oauth.scopeOnRefresh` only when on. + * + * For an authorization server that rejects a refresh without `scope` — + * Microsoft Entra ID's `AADSTS90009`, when the client and the resource are + * the same app registration. Off by default because a `scope` on refresh can + * narrow or alter the grant elsewhere. The value sent is the granted scope; + * see `BaseOAuthClientProvider.scopeForRefresh`. Read at connect time like + * the rest of the OAuth block. + */ + oauthScopeOnRefresh?: boolean; /** * Whether clearing this server's stored OAuth state also revokes the grant * at the authorization server, per RFC 7009 (#2144). `undefined` (the @@ -1474,6 +1500,8 @@ export interface InspectorClientOptions { * (#2068). Defaults to `true` when omitted. */ requestRefreshToken?: boolean; + /** Send `scope` on the refresh-token request (#2805). Defaults to `false`. */ + scopeOnRefresh?: boolean; }; /** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 80cdaa1f2e..f2a1609b53 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -200,7 +200,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | | `skillCatalogMaxSkills` | `256` | The maximum number of skills whose files are read in one verification run (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane's `v` (verify all); verifying a single skill always reads it in full. Positive integer; there is no unlimited value | | `skillCatalogMaxBytes` | `67108864` | The maximum number of bytes read across all skills in one verification run (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer | -| `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, revokeOnClear, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | +| `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, scopeOnRefresh, revokeOnClear, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | `metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. The MCP spec does not restrict `_meta` value types in general, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied. @@ -230,6 +230,10 @@ The setting suppresses the grant declaration and the SDK's scope augmentation > **It applies on the next connect, not to the live connection.** OAuth is connection-time configuration here — like the client credentials, the scopes, and the endpoint overrides beside it — so a client that is already connected keeps the metadata it was built with. Reconnect for the change to reach the authorization flow. +`oauth.scopeOnRefresh` (default `false`) makes the Inspector send a `scope` on the refresh-token request. Set it to `true` — or check **Send scope on token refresh** in Server Settings → Authorization — for an authorization server that rejects a refresh without one. Microsoft Entra ID does when the client and the resource are the same app registration: it answers `AADSTS90009`, so every token refresh fails and you have to sign in again ([#2805](https://github.com/modelcontextprotocol/inspector/issues/2805)). Only `true` is written to disk. + +Off, the refresh request is the SDK's default: no `scope`, which asks for the scope already granted (RFC 6749 §6). It stays off unless you turn it on because a `scope` on refresh can narrow or alter the grant on other authorization servers. On, the scope sent is the one granted — the scope saved with the tokens, or, when the token response carried none, the scope the authorization request asked for (which is what an omitted response `scope` means). It never asks a refresh for more: a step-up that needs a wider grant skips refresh and runs a new authorization request instead. Like the settings above, it applies on the next connect. + > **A scope inherited from an earlier grant is dropped from the request.** A successful authorization persists the scope the authorization server granted, which for a default-on client includes `offline_access`. Left alone, that value would come straight back on the next connect and re-trigger `prompt=consent` with the box unchecked. So when the grant is declined the Inspector filters `offline_access` out of what it *requests* — unless you configured it in **Scopes** yourself, in which case it is requested regardless of what storage holds (honoring it has to mean *adding* it when the persisted scope predates your change, not merely declining to strip it). The stored scope is not rewritten at filter time: re-checking the box restores the previous behavior instead of finding the value destroyed. It does update on the next successful grant — the Inspector records what it actually requested, so an authorization server that omits `scope` from the token response (RFC 6749 §5.1, meaning it granted what was asked) still leaves storage agreeing with the narrowed request rather than keeping the old value. > **Two pieces of state outlive the setting, and neither is cleared by unchecking it.** A refresh token issued before the opt-out stays usable — the SDK's refresh path posts the stored token with `grant_type=refresh_token` without consulting the client metadata, so such a server can keep refreshing silently and you may not observe the cost described above at all. And the registration already held at the authorization server still lists the grant, because turning the setting off changes what the Inspector *declares*, not what the AS has recorded. From 0d620e3f8436e8c49e66175d09e6c8fb5d256d4c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 25 Oct 2026 01:17:52 -0400 Subject: [PATCH 2/2] fix(auth): keep the refresh-scope fallback within the recorded grant (#2805) The #2068 offline_access helper re-adds a configured offline_access, which could ask a refresh for a scope outside the grant after a configuration change and fail it with invalid_scope. Apply it only to remove tokens. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../web/src/test/core/auth/providers.test.ts | 34 +++++++++++++++++++ core/auth/providers.ts | 20 ++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 6a6f1ec952..9334b86caa 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -702,6 +702,40 @@ describe("OAuthNavigation", () => { }), ).toBe("mcp"); }); + + // Copilot on #2809: the #2068 helper re-adds a configured + // `offline_access`. After the configuration gained it, that token is not + // in the recorded grant, and asking a refresh for it fails with + // `invalid_scope` — so the fallback must stay within the record. + it("never adds a scope the recorded grant lacks", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: true, + storedScope: "mcp", + configuredScope: "mcp offline_access", + requestRefreshToken: false, + }); + expect( + await provider.scopeForRefresh({ + access_token: "at", + token_type: "Bearer", + }), + ).toBe("mcp"); + }); + + it("returns undefined when the filter removes the whole recorded scope", async () => { + const provider = makeRefreshProvider({ + scopeOnRefresh: true, + storedScope: "offline_access", + configuredScope: "mcp", + requestRefreshToken: false, + }); + expect( + await provider.scopeForRefresh({ + access_token: "at", + token_type: "Bearer", + }), + ).toBeUndefined(); + }); }); describe("SEP-2352 issuer threading", () => { diff --git a/core/auth/providers.ts b/core/auth/providers.ts index b854d347fd..3c60bb393f 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -275,7 +275,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { * storage. A token response may omit `scope` when it equals the requested * one (RFC 6749 §5.1), so this is what was granted in that case. It is the * same value {@link scope} requests with, so it gets the same #2068 - * `offline_access` filter — see {@link scopeForDeclinedRefreshGrant}. + * `offline_access` filter — see {@link scopeForDeclinedRefreshGrant} — + * applied only to remove tokens, never to add one the record lacks. * 3. **The configured scope**, for a store that recorded no request scope. * * A step-up that would *widen* the grant never reaches here: the SDK skips @@ -292,10 +293,19 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { (await this.storage.getScope(this.serverUrl))?.trim() || this.configuredScope?.trim() || undefined; - if (!this.requestRefreshToken) { - return scopeForDeclinedRefreshGrant(requested, this.configuredScope); - } - return requested; + if (!requested || this.requestRefreshToken) return requested; + // The #2068 filter may only *remove* here. It can also add a configured + // `offline_access` back, which is right for an authorization request but + // not for a refresh: after a configuration change that token may not be + // in the recorded grant, and asking for it fails an ordinary refresh with + // `invalid_scope`. So keep only what both the filter and the record allow. + const allowed = new Set( + scopeForDeclinedRefreshGrant(requested, this.configuredScope)?.split( + /\s+/, + ), + ); + const kept = requested.split(/\s+/).filter((token) => allowed.has(token)); + return kept.length > 0 ? kept.join(" ") : undefined; } get redirectUrl(): string {