Merge pull request #2037 from modelcontextprotocol/v2/feat/1906-auth-token-url-overrides
feat(auth): Auth URL and Token URL overrides in Server Settings
This commit is contained in:
commit
4e4aa49db1
5 changed files with 1558 additions and 6 deletions
|
|
@ -218,6 +218,7 @@ import {
|
|||
type AuthChallengeOutcome,
|
||||
type HandleAuthChallengeOptions,
|
||||
} from "../auth/challenge.js";
|
||||
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||
import { createFetchTracker } from "./fetchTracking.js";
|
||||
|
|
@ -650,6 +651,16 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
|
||||
// Effective auth fetch: base fetch + tracking with category 'auth'
|
||||
// #1906: apply the per-server authorization/token endpoint overrides to the
|
||||
// authorization-server metadata document in flight. This wraps the *base*
|
||||
// fetch rather than `effectiveAuthFetch` so it also covers the discovery the
|
||||
// SDK runs from inside the transport (the 401/refresh path), which is handed
|
||||
// `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is
|
||||
// created a few lines below, and `setOAuthConfig` can change them later — so
|
||||
// the wrapper is inert until a server actually configures one.
|
||||
this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () =>
|
||||
this.oauthManager?.getEndpointOverrides(),
|
||||
);
|
||||
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
|
||||
|
||||
this.sessionId = options.sessionId;
|
||||
|
|
@ -5988,6 +5999,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
scope?: string;
|
||||
/** Custom authorization-request parameters (#2018). Authorize URL only. */
|
||||
authorizationParams?: Record<string, string>;
|
||||
/** Endpoint overrides applied to the discovered AS metadata (#1906). */
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
}): void {
|
||||
if (!this.oauthManager) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { BaseOAuthClientProvider } from "../auth/providers.js";
|
||||
import type { OAuthEndpointOverrides } from "../auth/endpointOverrides.js";
|
||||
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
|
||||
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||
|
|
@ -95,6 +96,8 @@ export class OAuthManager {
|
|||
scope?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
authorizationParams?: Record<string, string>;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
}): void {
|
||||
this.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
|
|
@ -106,6 +109,31 @@ export class OAuthManager {
|
|||
return this.params.getServerUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-server authorization/token endpoint overrides, read live so a
|
||||
* `setOAuthConfig` between requests takes effect without rebuilding the
|
||||
* client's fetch. `InspectorClient` reads this from inside its fetch wrapper.
|
||||
* (#1906)
|
||||
*
|
||||
* Returns nothing under enterprise-managed authorization, for the same reason
|
||||
* `redirectToExternalAuthorization` skips the custom authorization parameters
|
||||
* (#2018): the EMA leg authorizes against the enterprise IdP, a *different*
|
||||
* authorization server, and its OIDC discovery runs through this same fetch
|
||||
* (`emaFlow` → `idpOidc`). Rewriting that document would point the IdP login —
|
||||
* or the IdP code/refresh-token exchange — at the resource server's
|
||||
* authorization server, which is both wrong and a way to leak an IdP
|
||||
* credential across an authorization-server boundary.
|
||||
*/
|
||||
getEndpointOverrides(): OAuthEndpointOverrides | undefined {
|
||||
if (this.isEnterpriseManaged()) return undefined;
|
||||
const { authorizationUrl, tokenUrl } = this.oauthConfig;
|
||||
if (!authorizationUrl && !tokenUrl) return undefined;
|
||||
return {
|
||||
...(authorizationUrl && { authorizationUrl }),
|
||||
...(tokenUrl && { tokenUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the configured OAuth storage, throwing a clear error if it is
|
||||
* absent. Used on the EMA re-mint/persist paths, which are only reached for
|
||||
|
|
|
|||
|
|
@ -1147,11 +1147,22 @@ export function createRemoteApp(
|
|||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
authorizationParams?: Record<string, string>;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
} => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
|
||||
for (const k of [
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"scopes",
|
||||
// #1906 — shape only; the values are validated as absolute http(s) URLs
|
||||
// where they're applied (core/auth/endpointOverrides.ts), so a typo drops
|
||||
// one field with a warning instead of dropping the whole `oauth` block.
|
||||
"authorizationUrl",
|
||||
"tokenUrl",
|
||||
] as const) {
|
||||
if (o[k] !== undefined && typeof o[k] !== "string") return false;
|
||||
}
|
||||
if (
|
||||
|
|
@ -1271,7 +1282,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?, enterpriseManaged?, onInsufficientScope? }`.",
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.",
|
||||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
|
|
@ -1538,6 +1549,10 @@ export function createRemoteApp(
|
|||
"oauthClientId",
|
||||
"oauthClientSecret",
|
||||
"oauthScopes",
|
||||
// #1906 — string-shaped on the wire; URL validity is checked where the
|
||||
// override is applied, not here.
|
||||
"oauthAuthorizationUrl",
|
||||
"oauthTokenUrl",
|
||||
] as const) {
|
||||
if (obj[optional] !== undefined && typeof obj[optional] !== "string") {
|
||||
return { ok: false, error: `settings.${optional} must be a string` };
|
||||
|
|
@ -1676,6 +1691,18 @@ export function createRemoteApp(
|
|||
if (isKvArray(obj.oauthAuthorizationParams)) {
|
||||
value.oauthAuthorizationParams = obj.oauthAuthorizationParams;
|
||||
}
|
||||
// Empty-string coerces to absent like the credential fields above: the form
|
||||
// emits `""` when a user clears the input, and an empty override on disk
|
||||
// would read back as a configured-but-blank endpoint. (#1906)
|
||||
if (
|
||||
typeof obj.oauthAuthorizationUrl === "string" &&
|
||||
obj.oauthAuthorizationUrl !== ""
|
||||
) {
|
||||
value.oauthAuthorizationUrl = obj.oauthAuthorizationUrl;
|
||||
}
|
||||
if (typeof obj.oauthTokenUrl === "string" && obj.oauthTokenUrl !== "") {
|
||||
value.oauthTokenUrl = obj.oauthTokenUrl;
|
||||
}
|
||||
if (obj.enterpriseManaged === true) {
|
||||
value.enterpriseManaged = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,56 @@ export function oauthAuthorizationParamsFromSettings(
|
|||
return Object.keys(record).length > 0 ? record : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one of a server's `oauth` endpoint overrides as it comes off disk,
|
||||
* or `undefined` when there is nothing usable. (#1906)
|
||||
*
|
||||
* Same reasoning as `cleanAuthorizationParams` above: `StoredMCPServer` types
|
||||
* these as `string`, but that is a compile-time promise a hand-edited file does
|
||||
* not keep, and the CLI/TUI read `mcp.json` directly. A non-string reaches
|
||||
* `oauthEndpointOverridesFromSettings`, which calls `.trim()` on it — so without
|
||||
* this the whole server list fails to load. URL *validity* is not checked here;
|
||||
* that happens where the override is applied, so a typo drops one field with a
|
||||
* warning instead of anything louder.
|
||||
*/
|
||||
export function cleanEndpointOverride(
|
||||
value: string | undefined,
|
||||
field: "authorizationUrl" | "tokenUrl",
|
||||
): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
// Keep: unreachable per the parameter type, reachable from disk.
|
||||
if (typeof value !== "string") {
|
||||
console.warn(
|
||||
`Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a server's endpoint-override fields into the
|
||||
* `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set.
|
||||
* Shared by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths
|
||||
* so both derive the option identically. The values are passed through as
|
||||
* typed; validation (absolute http(s) URL) happens once at the point of use, in
|
||||
* `core/auth/endpointOverrides.ts`. (#1906)
|
||||
*/
|
||||
export function oauthEndpointOverridesFromSettings(
|
||||
settings: Pick<
|
||||
InspectorServerSettings,
|
||||
"oauthAuthorizationUrl" | "oauthTokenUrl"
|
||||
>,
|
||||
): { authorizationUrl?: string; tokenUrl?: string } | undefined {
|
||||
const authorizationUrl = settings.oauthAuthorizationUrl?.trim();
|
||||
const tokenUrl = settings.oauthTokenUrl?.trim();
|
||||
if (!authorizationUrl && !tokenUrl) return undefined;
|
||||
return {
|
||||
...(authorizationUrl && { authorizationUrl }),
|
||||
...(tokenUrl && { tokenUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
|
||||
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
|
||||
|
|
@ -357,6 +407,21 @@ export function storedFieldsToInspectorSettings(
|
|||
if (authorizationParams) {
|
||||
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
|
||||
}
|
||||
// Truthiness drops empty strings like the credential fields above, so a
|
||||
// cleared field reads back as unset rather than as an empty override. The
|
||||
// `string` type is a compile-time promise a hand-edited `mcp.json` does not
|
||||
// keep, and only the web client's `/api/servers` route checks it — the CLI and
|
||||
// TUI read the file directly and then call `.trim()` on these in
|
||||
// `oauthEndpointOverridesFromSettings`, so a non-string would crash the server
|
||||
// load. Sanitized like `authorizationParams` above rather than trusted.
|
||||
// (#1906)
|
||||
const authorizationUrl = cleanEndpointOverride(
|
||||
stored.oauth?.authorizationUrl,
|
||||
"authorizationUrl",
|
||||
);
|
||||
if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl;
|
||||
const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl");
|
||||
if (tokenUrl) settings.oauthTokenUrl = tokenUrl;
|
||||
if (stored.oauth?.onInsufficientScope) {
|
||||
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
|
||||
}
|
||||
|
|
@ -474,6 +539,14 @@ export function inspectorSettingsToStoredFields(
|
|||
oauthFields.authorizationParams = authParams;
|
||||
}
|
||||
}
|
||||
// Empty/blank reads back as unset (above), so a cleared field writes nothing
|
||||
// and the file diff stays minimal for servers that never set one. (#1906)
|
||||
if (settings.oauthAuthorizationUrl?.trim()) {
|
||||
oauthFields.authorizationUrl = settings.oauthAuthorizationUrl.trim();
|
||||
}
|
||||
if (settings.oauthTokenUrl?.trim()) {
|
||||
oauthFields.tokenUrl = settings.oauthTokenUrl.trim();
|
||||
}
|
||||
if (settings.oauthOnInsufficientScope) {
|
||||
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,19 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
* (#2018)
|
||||
*/
|
||||
authorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* Overrides the `authorization_endpoint` the authorization server's
|
||||
* metadata document advertises — for pointing a server at a development or
|
||||
* staging authorization server without changing what it publishes.
|
||||
* Inspector-specific. (#1906)
|
||||
*/
|
||||
authorizationUrl?: string;
|
||||
/**
|
||||
* Overrides the `token_endpoint` the authorization server's metadata
|
||||
* document advertises. Independent of {@link authorizationUrl}.
|
||||
* Inspector-specific. (#1906)
|
||||
*/
|
||||
tokenUrl?: string;
|
||||
};
|
||||
/**
|
||||
* Filesystem/URI roots advertised to the server via the `roots` client
|
||||
|
|
@ -534,6 +547,13 @@ export interface OAuthSettings {
|
|||
* (#2018)
|
||||
*/
|
||||
authorizationParams?: { key: string; value: string }[];
|
||||
/**
|
||||
* Endpoint overrides applied to the discovered authorization-server metadata
|
||||
* (#1906). Optional for the same reason as `authorizationParams` — callers
|
||||
* building an `OAuthSettings` for the other fields need not supply them.
|
||||
*/
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
onInsufficientScope?: OnInsufficientScopePolicy;
|
||||
}
|
||||
|
|
@ -699,6 +719,18 @@ export interface InspectorServerSettings {
|
|||
* the way there. Applied to the authorization request only. (#2018)
|
||||
*/
|
||||
oauthAuthorizationParams?: { key: string; value: string }[];
|
||||
/**
|
||||
* Overrides the authorization endpoint discovery resolved, so a server can be
|
||||
* pointed at a development or staging authorization server without changing
|
||||
* what it advertises. Empty/absent means "use what discovery returned".
|
||||
* Persisted as `oauth.authorizationUrl`. (#1906)
|
||||
*/
|
||||
oauthAuthorizationUrl?: string;
|
||||
/**
|
||||
* Overrides the token endpoint discovery resolved. Independent of
|
||||
* {@link oauthAuthorizationUrl}; persisted as `oauth.tokenUrl`. (#1906)
|
||||
*/
|
||||
oauthTokenUrl?: string;
|
||||
/**
|
||||
* SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this
|
||||
* server's HTTP transport. Defaults to `reauthorize` when unset.
|
||||
|
|
@ -1064,6 +1096,13 @@ export interface InspectorClientOptions {
|
|||
* (#2018). Reserved, protocol-critical keys are dropped with a warning.
|
||||
*/
|
||||
authorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* Overrides for the endpoints the authorization server's metadata document
|
||||
* advertises (#1906). Applied to the discovered document, so they reach both
|
||||
* the authorization request and the token request.
|
||||
*/
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue