feat(oauth): make the refresh_token grant opt-out per server
The Inspector always declared `refresh_token` in its OAuth client metadata. The SDK's `determineScope()` then appends `offline_access` whenever the authorization server advertises it and the client declares that grant, and `startAuthorization()` 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. Adds a per-server "Request refresh token" setting (default on) that drops the grant from the client metadata, breaking that chain at its source. Persisted as `oauth.requestRefreshToken` and written only when off, so entries that never touch it keep a byte-stable round-trip. Web edits it in Server Settings → Authorization; the CLI and TUI pick it up from the same per-server settings via `buildRunnerClientAuthOptions`. 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:
parent
9d0448b644
commit
e2fb78d999
7 changed files with 405 additions and 5 deletions
|
|
@ -105,6 +105,21 @@ 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. Dropping
|
||||
* the grant here breaks that chain at its source.
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -126,6 +141,8 @@ 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;
|
||||
|
||||
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
|
||||
this.serverUrl = serverUrl;
|
||||
|
|
@ -134,6 +151,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
this.navigation = oauthConfig.navigation;
|
||||
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
|
||||
this.authorizationParams = oauthConfig.authorizationParams;
|
||||
this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -186,7 +204,13 @@ 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 also stops the
|
||||
// SDK adding `offline_access` (and therefore `prompt=consent`) to the
|
||||
// authorization request. 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",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ export function buildRunnerClientAuthOptions(
|
|||
savedSettings.oauthScopes ||
|
||||
serverAuthorizationParams ||
|
||||
serverEndpointOverrides ||
|
||||
savedSettings.enterpriseManaged)
|
||||
savedSettings.enterpriseManaged ||
|
||||
savedSettings.oauthRequestRefreshToken === false)
|
||||
? {
|
||||
...(savedSettings.oauthClientId && {
|
||||
clientId: savedSettings.oauthClientId,
|
||||
|
|
@ -105,6 +106,11 @@ export function buildRunnerClientAuthOptions(
|
|||
...(savedSettings.enterpriseManaged && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
// #2068: only the explicit opt-out is forwarded; omitting the key
|
||||
// leaves the provider's default (declare `refresh_token`) in place.
|
||||
...(savedSettings.oauthRequestRefreshToken === false && {
|
||||
requestRefreshToken: false,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -6330,6 +6330,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
/** Endpoint overrides applied to the discovered AS metadata (#1906). */
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
/** Declare the `refresh_token` grant in client metadata (#2068). */
|
||||
requestRefreshToken?: boolean;
|
||||
}): void {
|
||||
if (!this.oauthManager) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export class OAuthManager {
|
|||
authorizationParams?: Record<string, string>;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
/** Declare the `refresh_token` grant in client metadata (#2068). */
|
||||
requestRefreshToken?: boolean;
|
||||
}): void {
|
||||
this.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
|
|
@ -169,6 +171,10 @@ export class OAuthManager {
|
|||
// read from mcp.json, and nothing in the flow needs to persist or union
|
||||
// them.
|
||||
authorizationParams: this.oauthConfig.authorizationParams,
|
||||
// #2068: per-server opt-out of the `refresh_token` grant. Like
|
||||
// `authorizationParams` this is pure config off mcp.json, so it lives on
|
||||
// the provider rather than in OAuth storage.
|
||||
requestRefreshToken: this.oauthConfig.requestRefreshToken,
|
||||
});
|
||||
|
||||
provider.setEventTarget(this.params.getEventTarget());
|
||||
|
|
|
|||
|
|
@ -1391,6 +1391,7 @@ export function createRemoteApp(
|
|||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
requestRefreshToken?: boolean;
|
||||
} => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
|
|
@ -1418,6 +1419,14 @@ export function createRemoteApp(
|
|||
) {
|
||||
return false;
|
||||
}
|
||||
// #2068 — shape only; the read side keeps just an explicit `false`, so a
|
||||
// stray `true` from a hand-edited file reads back as the default anyway.
|
||||
if (
|
||||
o.requestRefreshToken !== undefined &&
|
||||
typeof o.requestRefreshToken !== "boolean"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// A roots array: each entry must have a string `uri` and, when present, a
|
||||
|
|
@ -1531,7 +1540,7 @@ export function createRemoteApp(
|
|||
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "oauth" },
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.",
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`.",
|
||||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
|
|
@ -1832,6 +1841,15 @@ export function createRemoteApp(
|
|||
error: "settings.enterpriseManaged must be a boolean",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.oauthRequestRefreshToken !== undefined &&
|
||||
typeof obj.oauthRequestRefreshToken !== "boolean"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.oauthRequestRefreshToken must be a boolean",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.oauthOnInsufficientScope !== undefined &&
|
||||
obj.oauthOnInsufficientScope !== "reauthorize" &&
|
||||
|
|
@ -1959,6 +1977,11 @@ export function createRemoteApp(
|
|||
if (obj.enterpriseManaged === true) {
|
||||
value.enterpriseManaged = true;
|
||||
}
|
||||
// #2068: the default is on, so only the explicit opt-out is carried; a
|
||||
// `true` on the wire reads back as unset, which means the same thing.
|
||||
if (obj.oauthRequestRefreshToken === false) {
|
||||
value.oauthRequestRefreshToken = false;
|
||||
}
|
||||
if (
|
||||
obj.oauthOnInsufficientScope === "reauthorize" ||
|
||||
obj.oauthOnInsufficientScope === "throw"
|
||||
|
|
|
|||
|
|
@ -555,6 +555,12 @@ export function storedFieldsToInspectorSettings(
|
|||
if (stored.oauth?.enterpriseManaged === true) {
|
||||
settings.enterpriseManaged = true;
|
||||
}
|
||||
// Inverted from the flags above: the default is *on*, so only an explicit
|
||||
// `false` is carried through. Anything else on disk (absent, or a
|
||||
// hand-edited non-boolean) reads back as unset, i.e. the default. (#2068)
|
||||
if (stored.oauth?.requestRefreshToken === false) {
|
||||
settings.oauthRequestRefreshToken = false;
|
||||
}
|
||||
// Mirror the stdio working directory for the form. Like the OAuth fields, an
|
||||
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
|
||||
if (stored.cwd) settings.cwd = stored.cwd;
|
||||
|
|
@ -682,6 +688,12 @@ export function inspectorSettingsToStoredFields(
|
|||
if (settings.enterpriseManaged === true) {
|
||||
oauthFields.enterpriseManaged = true;
|
||||
}
|
||||
// Only the non-default (off) is written; an absent field reads back as the
|
||||
// default (on), so writing `true` would inject the key into files that never
|
||||
// set it and break byte-stable round-trips. (#2068)
|
||||
if (settings.oauthRequestRefreshToken === false) {
|
||||
oauthFields.requestRefreshToken = false;
|
||||
}
|
||||
if (Object.keys(oauthFields).length > 0) {
|
||||
out.oauth = oauthFields;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
enterpriseManaged?: boolean;
|
||||
/** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */
|
||||
onInsufficientScope?: OnInsufficientScopePolicy;
|
||||
/**
|
||||
* Whether the Inspector declares the `refresh_token` grant when it
|
||||
* registers. Defaults to `true`; only `false` is written to disk, so an
|
||||
* entry that never touched the setting keeps a minimal diff. Turning it off
|
||||
* also stops the SDK adding `offline_access` to the requested scope and
|
||||
* `prompt=consent` to the authorization request. Inspector-specific.
|
||||
* (#2068)
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
/**
|
||||
* Custom query parameters appended to the OAuth **authorization request**
|
||||
* (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's
|
||||
|
|
@ -588,6 +597,11 @@ export interface OAuthSettings {
|
|||
tokenUrl?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
onInsufficientScope?: OnInsufficientScopePolicy;
|
||||
/**
|
||||
* Whether to declare the `refresh_token` grant (#2068). Optional for the same
|
||||
* reason as `authorizationParams`; `undefined` means the default, on.
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -773,6 +787,14 @@ export interface InspectorServerSettings {
|
|||
* server's HTTP transport. Defaults to `reauthorize` when unset.
|
||||
*/
|
||||
oauthOnInsufficientScope?: OnInsufficientScopePolicy;
|
||||
/**
|
||||
* Whether the Inspector declares the `refresh_token` grant in its OAuth
|
||||
* client metadata for this server. `undefined` (the default) means on;
|
||||
* persisted as `oauth.requestRefreshToken` only when explicitly off. Turning
|
||||
* it off also drops the SDK's automatic `offline_access` scope and the
|
||||
* `prompt=consent` it forces — the Entra admin-consent failure in #2068.
|
||||
*/
|
||||
oauthRequestRefreshToken?: boolean;
|
||||
/**
|
||||
* When true, connect via the configured enterprise IdP (EMA) instead of
|
||||
* interactive OAuth to the MCP authorization server. Per-server OAuth
|
||||
|
|
@ -1160,6 +1182,11 @@ export interface InspectorClientOptions {
|
|||
*/
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
/**
|
||||
* Declare the `refresh_token` grant in the registered client metadata
|
||||
* (#2068). Defaults to `true` when omitted.
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue