feat: per-server custom OAuth authorization-request parameters (#2018)

Real deployments gate IdP selection, prompt behavior, or audience on a query
parameter the OAuth/OIDC core specs never standardized (Keycloak's
`kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
`audience`), and none of them could be driven from the Inspector.

The Inspector never builds the authorize URL — the SDK's `auth()` does, and it
exposes no hook for extra parameters — so the merge happens at the one seam
that sees the finished URL: `BaseOAuthClientProvider.redirectToAuthorization`.
It lives in `core/`, so web, CLI, and TUI all inherit it.

- `core/auth/authorizationParams.ts`: the reserved-key list (`client_id`,
  `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`,
  `response_type`, `scope`, `state`), the key validator the form renders
  inline, and the merge itself. Reserved keys are dropped with a warning
  rather than silently overriding PKCE / the state binding / RFC 8707;
  blank-key rows are skipped; the caller's URL is never mutated.
- Applied to the authorization request only — nothing wires it into the token
  request.
- Persisted as `oauth.authorizationParams` (a string record) on the per-server
  config beside `scopes`, edited as key/value rows in Server Settings ->
  Authorization ("Additional authorization parameters"), with an inline error
  per offending row plus a section-level warning.

- The enterprise-managed (EMA) leg redirects to a *different* authorization
  server (the enterprise IdP), so it goes through the explicit
  `redirectToExternalAuthorization` entry point, which skips the merge — a
  `kc_idp_hint` or `audience` meant for the MCP server's AS must never be
  appended to the IdP's authorize URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:58:46 -04:00
commit b25ece8ded
4 changed files with 1064 additions and 6 deletions

View file

@ -0,0 +1,105 @@
/**
* Per-server custom authorization-request parameters (#2018).
*
* Real deployments gate IdP selection, prompt behavior, or audience on a query
* parameter the OAuth/OIDC core specs never standardized — Keycloak's
* `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
* `audience`. The Inspector never builds the authorize URL itself (the SDK's
* `auth()` does, and exposes no hook for extra parameters), so the merge
* happens at the one seam that sees the finished URL:
* `BaseOAuthClientProvider.redirectToAuthorization`.
*
* The parameters apply to the **authorization request only** — never the token
* request, which the SDK builds separately and which this module is not wired
* into. They also apply only to the server's **own** authorization server:
* the enterprise-managed (EMA) leg redirects to a different one (the enterprise
* IdP) and goes through `BaseOAuthClientProvider.redirectToExternalAuthorization`,
* which deliberately skips the merge.
*/
/**
* Parameters the OAuth flow owns and a user may not override.
*
* Overriding any of these does not produce a useful debugging scenario — it
* breaks PKCE (`code_challenge`, `code_challenge_method`), CSRF binding
* (`state`), the callback leg (`redirect_uri`, `response_type`, `client_id`),
* or RFC 8707 resource indicators (`resource`), and the failure surfaces as an
* opaque authorization-server error rather than as an Inspector problem.
* `scope` is excluded too: it has its own field, and the SEP-2350 step-up union
* is computed from that field rather than from these parameters.
*/
export const RESERVED_AUTHORIZATION_PARAMS = [
"client_id",
"code_challenge",
"code_challenge_method",
"redirect_uri",
"resource",
"response_type",
"scope",
"state",
] as const;
export type ReservedAuthorizationParam =
(typeof RESERVED_AUTHORIZATION_PARAMS)[number];
const RESERVED: ReadonlySet<string> = new Set(RESERVED_AUTHORIZATION_PARAMS);
/**
* Whether `key` names a parameter the OAuth flow owns. Compared case- and
* whitespace-insensitively: query parameter names are case-sensitive on the
* wire, but a `Client_Id` typed into the form is plainly an attempt to set the
* reserved one, and letting it through would be the silent override this rule
* exists to prevent.
*/
export function isReservedAuthorizationParam(key: string): boolean {
return RESERVED.has(key.trim().toLowerCase());
}
/**
* Validation message for one authorization-parameter key, or `undefined` when
* the key is acceptable. A blank key is not an error — the form lets a user
* leave a freshly-added row empty mid-edit, and blank rows are dropped rather
* than rejected.
*/
export function authorizationParamKeyError(key: string): string | undefined {
const trimmed = key.trim();
if (trimmed === "") return undefined;
if (isReservedAuthorizationParam(trimmed)) {
return `"${trimmed}" is set by the authorization flow and cannot be overridden.`;
}
return undefined;
}
/**
* Merge custom parameters into a finished authorization URL.
*
* Blank keys are skipped (a half-edited form row) and reserved keys are dropped
* with a warning — never silently, and never overriding what the SDK put there.
* Returns the original URL instance when there is nothing to apply, and
* otherwise a copy, so the caller's URL is never mutated.
*/
export function applyAuthorizationParams(
authorizationUrl: URL,
params: Record<string, string> | undefined,
): URL {
if (!params) return authorizationUrl;
const applicable = Object.entries(params).filter(([key]) => {
if (key.trim() === "") return false;
if (isReservedAuthorizationParam(key)) {
console.warn(
`[oauth] Ignoring reserved authorization parameter "${key.trim()}" — it is set by the authorization flow.`,
);
return false;
}
return true;
});
if (applicable.length === 0) return authorizationUrl;
const merged = new URL(authorizationUrl.href);
for (const [key, value] of applicable) {
merged.searchParams.set(key.trim(), value);
}
return merged;
}

View file

@ -84,7 +84,11 @@ export class EmaTransportOAuthProvider implements OAuthClientProvider {
async redirectToAuthorization(_authorizationUrl: URL): Promise<void> {
const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig);
this.inner.clearCapturedAuthUrl();
this.inner.redirectToAuthorization(idpAuthorizationUrl);
// The IdP is a different authorization server than the one the inner
// provider was configured for, so the per-server custom authorization
// parameters (#2018) must not be appended here — hence the "external"
// entry point rather than `redirectToAuthorization`.
this.inner.redirectToExternalAuthorization(idpAuthorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void | Promise<void> {

View file

@ -27,6 +27,15 @@ export { ensureCimdClientRegistration } from "./cimd.js";
export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js";
export { computeScopeUnion, isStrictScopeSuperset } from "./scopes.js";
// Custom authorization-request parameters (#2018)
export {
RESERVED_AUTHORIZATION_PARAMS,
isReservedAuthorizationParam,
authorizationParamKeyError,
applyAuthorizationParams,
} from "./authorizationParams.js";
export type { ReservedAuthorizationParam } from "./authorizationParams.js";
// Storage
export type {
OAuthStorage,

View file

@ -11,6 +11,7 @@ import type {
} from "@modelcontextprotocol/client";
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
import { generateOAuthState } from "./utils.js";
import { applyAuthorizationParams } from "./authorizationParams.js";
/**
* Redirect URL provider. Returns the redirect URL for OAuth flows.
@ -96,6 +97,14 @@ export type OAuthProviderConfig = {
redirectUrlProvider: RedirectUrlProvider;
navigation: OAuthNavigation;
clientMetadataUrl?: string;
/**
* Per-server custom authorization-request parameters (#2018), merged into the
* finished authorize URL in {@link BaseOAuthClientProvider.redirectToAuthorization}.
* Reserved (protocol-critical) keys are dropped with a warning — see
* `authorizationParams.ts`. Authorization request only; the token request is
* unaffected.
*/
authorizationParams?: Record<string, string>;
};
/**
@ -115,6 +124,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
protected redirectUrlProvider: RedirectUrlProvider;
protected navigation: OAuthNavigation;
public clientMetadataUrl?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
protected authorizationParams?: Record<string, string>;
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
this.serverUrl = serverUrl;
@ -122,6 +133,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
this.redirectUrlProvider = oauthConfig.redirectUrlProvider;
this.navigation = oauthConfig.navigation;
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
this.authorizationParams = oauthConfig.authorizationParams;
}
/**
@ -272,6 +284,34 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
}
redirectToAuthorization(authorizationUrl: URL): void {
// #2018: the SDK builds the authorize URL and offers no hook for extra
// parameters, so the per-server ones are merged here — the one seam that
// sees the finished URL before navigation. Everything downstream (the
// captured URL used by the step-up modal, the event, the navigation) uses
// the merged URL so all three agree on what was actually requested.
this.performAuthorizationRedirect(
applyAuthorizationParams(authorizationUrl, this.authorizationParams),
);
}
/**
* Redirect to an authorize URL belonging to a **different** authorization
* server than the one this provider was configured for, so the per-server
* custom parameters (#2018) MUST NOT be applied.
*
* The only caller is the enterprise-managed (EMA) transport provider, which
* discards the resource AS URL and sends the user to the enterprise IdP
* instead. Parameters a user configured for their MCP server's authorization
* server — a `kc_idp_hint`, an `audience` — are meaningless and potentially
* flow-breaking on the IdP's, and appending them there would leak per-server
* config across an authorization-server boundary.
*/
redirectToExternalAuthorization(authorizationUrl: URL): void {
this.performAuthorizationRedirect(authorizationUrl);
}
/** Capture, announce, and navigate to a finished authorize URL. */
private performAuthorizationRedirect(authorizationUrl: URL): void {
// Capture URL for return value
this.capturedAuthUrl = authorizationUrl;