inspector/core/auth/scopes.ts
cliffhall 89dedd8dea 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>
2026-08-24 21:30:29 -04:00

93 lines
3.6 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.
*
* 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.
*/
export function scopeForDeclinedRefreshGrant(
effectiveScope: string | undefined,
configuredScope: string | undefined,
): string | undefined {
if (!effectiveScope) return effectiveScope;
const configured = configuredScope?.trim().split(/\s+/) ?? [];
if (configured.includes(OFFLINE_ACCESS_SCOPE)) return effectiveScope;
const kept = effectiveScope
.trim()
.split(/\s+/)
.filter((token) => token !== "" && token !== OFFLINE_ACCESS_SCOPE);
// An all-`offline_access` scope collapses to undefined rather than "", so the
// SDK falls back to its own resolution instead of requesting an empty scope.
return kept.length > 0 ? kept.join(" ") : 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);
}