Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope
#2114 landed on v2/main, which is the PR this one follows up. Two conflicts, one of them silent. The marked conflict was cosmetic: keep v2/main's comment on the `provider.scope` read, which now notes the getter filters (#2068). The real one git merged cleanly and would have left dead: #2114's if (this.oauthConfig.requestRefreshToken === false && requestedScope) { this.pendingAuthorizationScope = requestedScope; } sat directly above this branch's unconditional `recordAuthorizationRequestScope`, which overwrites the same field a few lines later. Dropping that gate is what #2117 is for, so it is removed rather than kept as a no-op. The two changes compose: the SDK adds `offline_access` only when `clientMetadata.grant_types` includes `refresh_token`, which the provider drops while the opt-out is on -- so reading the authorize URL yields the filtered scope there, exactly what #2114's gated branch persisted. #2114's third case pinned the gate and inverts here, as its issue predicted. It now asserts the request is persisted even with the grant on; that records no new claim, since the request equals what storage already held. 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:
commit
71b960e078
2 changed files with 1424 additions and 8 deletions
|
|
@ -12,6 +12,7 @@ import type {
|
|||
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
|
||||
import { generateOAuthState } from "./utils.js";
|
||||
import { applyAuthorizationParams } from "./authorizationParams.js";
|
||||
import { scopeForDeclinedRefreshGrant } from "./scopes.js";
|
||||
|
||||
/**
|
||||
* Redirect URL provider. Returns the redirect URL for OAuth flows.
|
||||
|
|
@ -105,6 +106,36 @@ export type OAuthProviderConfig = {
|
|||
* unaffected.
|
||||
*/
|
||||
authorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* Whether to declare the `refresh_token` grant in the registered client
|
||||
* metadata (#2068). Defaults to `true`; set `false` to register (and
|
||||
* authorize) as an authorization-code-only client.
|
||||
*
|
||||
* This is not merely cosmetic. The SDK's `determineScope()` appends
|
||||
* `offline_access` to the effective scope whenever the authorization server
|
||||
* advertises it **and** the client metadata declares `refresh_token`, and
|
||||
* `startAuthorization()` then appends `prompt=consent` whenever
|
||||
* `offline_access` is in scope. Against Microsoft Entra ID, that forced
|
||||
* consent prompt routes a non-admin user into the admin-consent workflow and
|
||||
* fails with `AADSTS90094` even after a tenant admin has consented.
|
||||
*
|
||||
* It removes that *automatic* augmentation only. `startAuthorization()` reads
|
||||
* the scope, never `grant_types`, so an `offline_access` the caller passed in
|
||||
* `scope` — or one the resource advertises when `scope` is unset, which
|
||||
* `determineScope` falls back to — still produces the prompt. This is not on
|
||||
* its own a guarantee that `prompt=consent` is absent.
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
/**
|
||||
* The scope configured for this server (mcp.json / Server Settings), as
|
||||
* distinct from the scope carried in OAuth storage after a previous grant.
|
||||
*
|
||||
* Used only to decide whether an `offline_access` in the effective scope was
|
||||
* *asked for* or merely inherited — see {@link scopeForDeclinedRefreshGrant}.
|
||||
* An explicitly configured one is honored; an inherited one is dropped from
|
||||
* the request when {@link requestRefreshToken} is false. (#2068)
|
||||
*/
|
||||
configuredScope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -126,6 +157,10 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
public clientMetadataUrl?: string;
|
||||
/** Custom authorization-request parameters (#2018). Authorize URL only. */
|
||||
protected authorizationParams?: Record<string, string>;
|
||||
/** Declare the `refresh_token` grant in {@link clientMetadata} (#2068). */
|
||||
protected requestRefreshToken: boolean;
|
||||
/** Server-configured scope, for the #2068 `offline_access` distinction. */
|
||||
protected configuredScope?: string;
|
||||
|
||||
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
|
||||
this.serverUrl = serverUrl;
|
||||
|
|
@ -134,6 +169,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
this.navigation = oauthConfig.navigation;
|
||||
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
|
||||
this.authorizationParams = oauthConfig.authorizationParams;
|
||||
this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true;
|
||||
this.configuredScope = oauthConfig.configuredScope;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -171,6 +208,21 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
get scope(): string | undefined {
|
||||
// #2068: filter at the point of *request*, not in storage. Covers the two
|
||||
// readers that go through this getter — `clientMetadata.scope` below, and
|
||||
// the `scope:` argument `OAuthManager.authenticate` hands to the SDK.
|
||||
//
|
||||
// It is NOT the only request path: mid-session `insufficient_scope` step-up
|
||||
// builds its union from raw storage and passes it to `mcpAuth` directly,
|
||||
// never reading this getter. `OAuthManager.handleAuthChallenge` applies the
|
||||
// same filter there. Both call `scopeForDeclinedRefreshGrant`, so the rule
|
||||
// lives in one place even though it has to be applied twice.
|
||||
if (!this.requestRefreshToken) {
|
||||
return scopeForDeclinedRefreshGrant(
|
||||
this.cachedScope,
|
||||
this.configuredScope,
|
||||
);
|
||||
}
|
||||
return this.cachedScope;
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +238,14 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
const metadata: OAuthClientMetadata = {
|
||||
redirect_uris: this.redirect_uris,
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
// #2068: `refresh_token` is declared by default, and dropped when the
|
||||
// server's "Request refresh token" setting is off — which stops the SDK
|
||||
// *adding* `offline_access` (and so the `prompt=consent` it forces). It
|
||||
// does not remove an `offline_access` that reaches the scope another way.
|
||||
// See `OAuthProviderConfig.requestRefreshToken`.
|
||||
grant_types: this.requestRefreshToken
|
||||
? ["authorization_code", "refresh_token"]
|
||||
: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
client_name: "MCP Inspector",
|
||||
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,96 @@ export const computeScopeUnion = sdkComputeScopeUnion;
|
|||
*/
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue