diff --git a/clients/web/src/test/core/auth/scopes.test.ts b/clients/web/src/test/core/auth/scopes.test.ts index b6b248a6..7fe91bfc 100644 --- a/clients/web/src/test/core/auth/scopes.test.ts +++ b/clients/web/src/test/core/auth/scopes.test.ts @@ -89,6 +89,30 @@ describe("scopeForDeclinedRefreshGrant (#2068)", () => { ).toBe("mcp offline_access"); }); + // Storage can predate the settings change — `createOAuthProvider` preserves a + // stored scope rather than reseeding from current settings. Merely declining + // to strip would silently omit a scope the user just typed, and the form's + // "still requested" warning would be lying. + it("adds a newly configured offline_access that stale storage lacks", () => { + expect(scopeForDeclinedRefreshGrant("mcp", "mcp offline_access")).toBe( + "mcp offline_access", + ); + }); + + // Only the token itself is added; unrelated configured scopes are not unioned + // in, since widening the request could force a step-up authorization. + it("does not union unrelated configured scopes into the request", () => { + expect( + scopeForDeclinedRefreshGrant("mcp", "tools:read offline_access"), + ).toBe("mcp offline_access"); + }); + + it("does not duplicate an offline_access already present", () => { + expect( + scopeForDeclinedRefreshGrant(" mcp offline_access ", "offline_access"), + ).toBe("mcp offline_access"); + }); + it("leaves a scope without offline_access untouched", () => { expect(scopeForDeclinedRefreshGrant("mcp tools:read", "mcp")).toBe( "mcp tools:read", diff --git a/core/auth/scopes.ts b/core/auth/scopes.ts index ba11a2c1..7cc0bfa7 100644 --- a/core/auth/scopes.ts +++ b/core/auth/scopes.ts @@ -40,8 +40,18 @@ export const OFFLINE_ACCESS_SCOPE = "offline_access"; * self-heals, while re-checking the box restores the old behavior from the * user's own configuration rather than from a value we destroyed. * - * A scope the user explicitly configured is left alone — they asked for it, and - * the web form warns that it keeps the consent prompt alive. + * An `offline_access` the user explicitly configured is honored — they asked + * for it, and the web form warns that it keeps the consent prompt alive. That + * means *ensuring* it is requested, not merely declining to strip it: the + * persisted scope can predate the configuration change (`createOAuthProvider` + * preserves a stored scope rather than reseeding from current settings), and + * silently omitting a scope the user typed would make both this promise and the + * form's warning false. + * + * Only `offline_access` is added, never the rest of the configured scope. + * Widening the request with unrelated configured tokens is outside what this + * setting governs and could push an otherwise-satisfied connection into a + * step-up authorization. */ export function scopeForDeclinedRefreshGrant( effectiveScope: string | undefined, @@ -49,7 +59,16 @@ export function scopeForDeclinedRefreshGrant( ): string | undefined { if (!effectiveScope) return effectiveScope; const configured = configuredScope?.trim().split(/\s+/) ?? []; - if (configured.includes(OFFLINE_ACCESS_SCOPE)) return effectiveScope; + if (configured.includes(OFFLINE_ACCESS_SCOPE)) { + const tokens = effectiveScope + .trim() + .split(/\s+/) + .filter((token) => token !== ""); + if (!tokens.includes(OFFLINE_ACCESS_SCOPE)) { + tokens.push(OFFLINE_ACCESS_SCOPE); + } + return tokens.join(" "); + } const kept = effectiveScope .trim() diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index b930dff9..704f2df5 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -205,7 +205,7 @@ 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. -> **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, which it honors. The stored scope is not rewritten: re-checking the box restores the previous behavior instead of finding the value destroyed. +> **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: re-checking the box restores the previous behavior instead of finding the value destroyed. > **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. >