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

View file

@ -164,6 +164,9 @@ type StoredInspectorFields = Pick<
* Convert a stored stdio `env` record into the controlled key/value rows the
* settings form edits, preserving the object's key insertion order. Empty when
* absent. Inverse of `envPairsToRecord`.
*
* The transform is generic key/value, so the OAuth authorization-parameters
* record (#2018) reuses it rather than growing a second copy.
*/
export function envRecordToPairs(
env: Record<string, string> | undefined,
@ -176,6 +179,9 @@ export function envRecordToPairs(
* with an empty/whitespace key (the form lets users leave a new row blank
* mid-edit). Inverse of `envRecordToPairs`. Used by the `/api/servers` PUT
* write-through that maps `settings.env` back onto `config.env`.
*
* Generic key/value like its inverse, so the OAuth authorization-parameters
* rows (#2018) collapse through it too.
*/
export function envPairsToRecord(
pairs: { key: string; value: string }[],
@ -188,6 +194,21 @@ export function envPairsToRecord(
return out;
}
/**
* Collapse a server's custom authorization-parameter rows into the record the
* `InspectorClientOptions.oauth.authorizationParams` option takes, or
* `undefined` when nothing survives (no rows, or every row blank-keyed). Shared
* by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths so both
* derive the option identically. (#2018)
*/
export function oauthAuthorizationParamsFromSettings(
settings: Pick<InspectorServerSettings, "oauthAuthorizationParams">,
): Record<string, string> | undefined {
if (!settings.oauthAuthorizationParams) return undefined;
const record = envPairsToRecord(settings.oauthAuthorizationParams);
return Object.keys(record).length > 0 ? record : undefined;
}
/**
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
@ -286,6 +307,17 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.clientSecret)
settings.oauthClientSecret = stored.oauth.clientSecret;
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
// Optional record with no default; carried through only when the file has a
// non-empty object, so an absent/empty field reads back as unset and the
// write side then omits it — keeping a byte-stable round-trip. (#2018)
if (
stored.oauth?.authorizationParams &&
Object.keys(stored.oauth.authorizationParams).length > 0
) {
settings.oauthAuthorizationParams = envRecordToPairs(
stored.oauth.authorizationParams,
);
}
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
@ -395,6 +427,14 @@ export function inspectorSettingsToStoredFields(
if (settings.oauthClientSecret)
oauthFields.clientSecret = settings.oauthClientSecret;
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
// Blank-key rows (a row the user added but never filled in) are dropped, so
// an all-blank list writes nothing — matching the read side's omit-on-empty.
if (settings.oauthAuthorizationParams) {
const authParams = envPairsToRecord(settings.oauthAuthorizationParams);
if (Object.keys(authParams).length > 0) {
oauthFields.authorizationParams = authParams;
}
}
if (settings.oauthOnInsufficientScope) {
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
}
@ -605,12 +645,17 @@ export function extractSecretsFromStored(
const restOauth: {
clientId?: string;
scopes?: string;
authorizationParams?: Record<string, string>;
enterpriseManaged?: boolean;
} = {};
if (stored.oauth.clientId !== undefined)
restOauth.clientId = stored.oauth.clientId;
if (stored.oauth.scopes !== undefined)
restOauth.scopes = stored.oauth.scopes;
// Not a secret — the authorization parameters stay on the stripped entry so
// stripping the client secret doesn't silently discard them. (#2018)
if (stored.oauth.authorizationParams !== undefined)
restOauth.authorizationParams = stored.oauth.authorizationParams;
if (stored.oauth.enterpriseManaged === true) {
restOauth.enterpriseManaged = true;
}