fix(oauth): drop an inherited offline_access when the grant is declined

Copilot review round 4 on #2114 — a real gap, not a wording one.

Declining the grant stops the SDK *adding* `offline_access`, but the
Inspector's own storage puts it back. A successful default-on
authorization persists the scope the AS granted
(`resolvePersistedScopeAfterGrant`), `offline_access` included;
`prepareForAuth` reloads it and `authenticate` hands it to the SDK as
`scope`, which sees the token in scope and appends `prompt=consent`
again. So any server that had authorized once could uncheck the box and
still hit AADSTS90094 — the users most likely to reach for the setting
were the ones it did not help.

`scopeForDeclinedRefreshGrant` filters it out of the *request* only,
applied in the provider's `scope` getter — the one seam both
`clientMetadata.scope` and `authenticate`'s `scope:` argument read
through. An `offline_access` the user configured is honored; storage is
never rewritten, so re-checking the box restores the old behavior
instead of finding the value destroyed, and scope-satisfaction checks
still see the real grant. Verified by reverting the filter: the new e2e
regression fails.

Also from that round, both previously suppressed:

The checkbox looked effective under enterprise-managed authorization,
which never builds this provider at all — it authorizes against the
enterprise IdP with a fixed `openid offline_access`
(`ema/constants.ts:16`). It now annotates itself the way the endpoint
overrides do. The existing count assertion covered two such notices and
was named for the overrides alone; it is renamed and expects three, with
a dedicated case pinning the notice to this control so the count cannot
be met by three copies elsewhere.

And "obtains no new refresh token" was another overstatement — the AS
decides issuance, and a preconfigured client is reused with whatever its
registration already permits. Replaced with what the setting actually
controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 21:30:29 -04:00
commit 89dedd8dea

View file

@ -1459,6 +1459,83 @@ describe("InspectorClient OAuth E2E", () => {
// addition, not what the user asked for.
expect(scope.split(/\s+/)).toContain("mcp");
});
// #2068 — the case that makes the opt-out worth anything to the user who
// reported it: a server that already authorized successfully. That grant
// persists the AS-granted scope, `offline_access` included, and the
// provider reloads it on the next connect. Handing it back unfiltered puts
// the token in scope again and `startAuthorization` re-adds
// `prompt=consent` off the scope alone — box unchecked, AADSTS90094 intact.
it("drops an offline_access inherited from an earlier grant's stored scope", async () => {
const transport = transports[1]!;
const staticClientId = "test-inherited-offline-client";
const staticClientSecret = "test-inherited-offline-secret";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
scopesSupported: ["mcp", "offline_access"],
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
scope: "mcp",
});
// Exactly what a completed default-on authorization leaves behind (see
// `resolvePersistedScopeAfterGrant`): the scope the AS granted.
const mcpUrl = `${serverUrl}${transport.endpoint}`;
await oauthConfig.storage.saveScope(mcpUrl, "mcp offline_access");
client = new InspectorClient(
{ type: transport.clientType, url: mcpUrl } as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
requestRefreshToken: false,
},
},
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const scope = authUrl.searchParams.get("scope") ?? "";
expect(scope.split(/\s+/)).not.toContain("offline_access");
expect(authUrl.searchParams.get("prompt")).toBeNull();
expect(scope.split(/\s+/)).toContain("mcp");
// Filtered on the way out, not in storage — re-checking the box has to
// restore the old behavior rather than find the scope destroyed.
expect(await oauthConfig.storage.getScope(mcpUrl)).toBe(
"mcp offline_access",
);
});
});
describe("SEP-2352 AS migration (Streamable HTTP)", () => {