fix(oauth): filter the step-up scope union too, not just the provider

Copilot review round 8 on #2114.

The provider's `scope` getter is not the only request path. Mid-session
`insufficient_scope` recovery builds its union from *raw* storage in
`enrichChallengeWithScopes` and hands it to `mcpAuth` directly, so it
never reads that getter. An `offline_access` inherited from an earlier
grant therefore reappeared during step-up and the SDK re-added
`prompt=consent` with the setting off — the failure this option exists
to prevent, on the one path that bypassed it. The comment claiming the
getter was "the one seam both readers go through" was wrong, and is
corrected to name both.

`handleAuthChallenge` now applies the same filter, gated on the setting
so the default path is untouched. The forced-retry reuses that value,
so filtering once at the computation site covers it.

`scopeForDeclinedRefreshGrant` gains the scopes a challenge requires,
honored exactly like a configured one: stripping a scope the server is
asking for would loop — re-authorize, earn the same challenge, strip it
again.

Three regressions; the inherited-scope one fails when the filter is
reverted.

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 23:05:14 -04:00
commit 26b6f03b69

View file

@ -48,6 +48,7 @@ import {
isStrictScopeSuperset,
resolveEffectiveGrantedScope,
resolvePersistedScopeAfterGrant,
scopeForDeclinedRefreshGrant,
} from "../auth/scopes.js";
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
import type {
@ -783,13 +784,29 @@ export class OAuthManager {
resourceMetadataUrl,
});
const scopeForAuth =
// #2068 — the step-up union is built from *raw* storage
// (`enrichChallengeWithScopes`) and handed to the SDK directly, so it never
// passes through the provider's filtering `scope` getter. Without this an
// `offline_access` inherited from an earlier grant reappears here and the
// SDK re-adds `prompt=consent` even with the setting off — the same failure
// the option exists to prevent, on the one path that bypasses it. A scope
// the challenge itself requires is preserved, since stripping that would
// just re-earn the same challenge.
const requestedScopeForAuth =
enriched.reason === "insufficient_scope"
? enriched.authorizationScopes?.join(" ")
: this.oauthConfig.scope?.trim() ||
(enriched.requiredScopes?.length
? enriched.requiredScopes.join(" ")
: undefined);
const scopeForAuth =
this.oauthConfig.requestRefreshToken === false
? scopeForDeclinedRefreshGrant(
requestedScopeForAuth,
this.oauthConfig.scope,
enriched.requiredScopes,
)
: requestedScopeForAuth;
provider.setSuppressAuthorizationNavigation(true);
let result: Awaited<ReturnType<typeof mcpAuth>>;