inspector/core/auth/scopes.ts
cliffhall 26b6f03b69 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>
2026-08-24 23:05:14 -04:00

141 lines
5.9 KiB
TypeScript

/**
* SEP-2350 scope helpers. Union / strict-superset algorithms come from
* `@modelcontextprotocol/client`; Inspector-only persistence helpers stay local.
*/
import {
computeScopeUnion as sdkComputeScopeUnion,
isStrictScopeSuperset as sdkIsStrictScopeSuperset,
} from "@modelcontextprotocol/client";
/** Union space-delimited scope strings (order-preserving, deduped). */
export const computeScopeUnion = sdkComputeScopeUnion;
/**
* Whether `union` contains a scope token not present in `current`.
* When the AS omits `scope` on the token response, `current` is empty and any
* non-empty union is a strict superset — step-up must re-authorize, not refresh.
*/
export const isStrictScopeSuperset = sdkIsStrictScopeSuperset;
/** The scope token whose presence makes the SDK force `prompt=consent`. */
export const OFFLINE_ACCESS_SCOPE = "offline_access";
/**
* The scope to *request* when the `refresh_token` grant has been declined
* (#2068).
*
* Declining the grant stops the SDK adding `offline_access`, but it cannot
* remove one that is already in the scope — and the Inspector's own storage is
* the way it gets there. A successful authorization persists the scope the AS
* granted (`resolvePersistedScopeAfterGrant`), which for a default-on client
* includes `offline_access`; the provider reloads that on the next connect and
* hands it back to the SDK, which sees the token in scope and appends
* `prompt=consent` again. So a server that authorized once before the opt-out
* would keep hitting AADSTS90094 with the box unchecked — the exact failure the
* setting exists to prevent.
*
* Dropping it from the *request* (not from storage) is what breaks that loop:
* the next grant comes back without `offline_access` and the persisted scope
* self-heals, while re-checking the box restores the old behavior from the
* user's own configuration rather than from a value we destroyed.
*
* 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.
*
* `requiredScopes` carries scopes a `403 insufficient_scope` challenge demands
* (SEP-2350). They are honored exactly like a configured one: the server is
* asking for them, and stripping a scope the challenge requires would loop —
* re-authorize, get the same challenge back, strip it again. The step-up path
* needs this because it does not read the scope through the provider at all:
* it unions the *raw* stored scope with the challenge and hands that to the
* SDK directly, so an inherited `offline_access` would otherwise reappear
* there with the setting off.
*/
export function scopeForDeclinedRefreshGrant(
effectiveScope: string | undefined,
configuredScope: string | undefined,
requiredScopes?: readonly string[],
): string | undefined {
if (!effectiveScope) return effectiveScope;
const configured = configuredScope?.trim().split(/\s+/) ?? [];
const required = requiredScopes ?? [];
if (
configured.includes(OFFLINE_ACCESS_SCOPE) ||
required.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()
.split(/\s+/)
.filter((token) => token !== "" && token !== OFFLINE_ACCESS_SCOPE);
if (kept.length > 0) return kept.join(" ");
// Filtering removed everything (the persisted scope was `offline_access`
// alone). Returning `undefined` here would be read as "nothing stored" by
// `OAuthManager.createOAuthProvider`, whose seeding branch then *writes* the
// configured scope to storage — turning this request-only filter into a
// silent overwrite of the persisted value, so re-enabling the grant could no
// longer restore it. Hand back the configured scope instead: it is the right
// thing to request, and it leaves storage alone.
// Cannot itself contain `offline_access`: that case returned above.
const fallback = configuredScope?.trim();
if (fallback) return fallback;
// Nothing configured either, so there is no value to preserve and the seeding
// branch is inert (it requires a configured scope). `undefined` rather than
// `""` so the SDK falls back to its own resolution instead of requesting an
// empty scope.
return undefined;
}
/**
* Scope to persist after a successful token grant (RFC 6749 §5.1).
* When the AS returns `scope`, it is the authoritative full grant.
* When `scope` is omitted on success, granted equals what was requested.
*/
export function resolvePersistedScopeAfterGrant(
grantedScope: string | undefined,
requestedScope: string | undefined,
): string | undefined {
const granted = grantedScope?.trim();
if (granted) {
return granted;
}
const requested = requestedScope?.trim();
return requested || undefined;
}
/**
* Scope coverage for satisfaction checks: prefer the token's explicit grant;
* when omitted, fall back to stored scope (RFC implied grant on prior success).
*/
export function resolveEffectiveGrantedScope(
storedScope: string | undefined,
tokenScope: string | undefined,
): string | undefined {
const granted = tokenScope?.trim();
if (granted) {
return granted;
}
return computeScopeUnion(storedScope, tokenScope);
}