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:
parent
ebeaa142df
commit
b25ece8ded
5 changed files with 1064 additions and 6 deletions
|
|
@ -5421,6 +5421,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
clientSecret?: string;
|
||||
clientMetadataUrl?: string;
|
||||
scope?: string;
|
||||
/** Custom authorization-request parameters (#2018). Authorize URL only. */
|
||||
authorizationParams?: Record<string, string>;
|
||||
}): void {
|
||||
if (!this.oauthManager) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export class OAuthManager {
|
|||
clientMetadataUrl?: string;
|
||||
scope?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
authorizationParams?: Record<string, string>;
|
||||
}): void {
|
||||
this.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
|
|
@ -135,6 +136,11 @@ export class OAuthManager {
|
|||
redirectUrlProvider: this.oauthConfig.redirectUrlProvider,
|
||||
navigation: this.oauthConfig.navigation,
|
||||
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
|
||||
// #2018: per-server custom authorization-request parameters. Carried on
|
||||
// the provider (not in OAuth storage like `scope`) — they are pure config
|
||||
// read from mcp.json, and nothing in the flow needs to persist or union
|
||||
// them.
|
||||
authorizationParams: this.oauthConfig.authorizationParams,
|
||||
});
|
||||
|
||||
provider.setEventTarget(this.params.getEventTarget());
|
||||
|
|
|
|||
|
|
@ -1130,6 +1130,7 @@ export function createRemoteApp(
|
|||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
authorizationParams?: Record<string, string>;
|
||||
enterpriseManaged?: boolean;
|
||||
} => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
|
|
@ -1137,6 +1138,12 @@ export function createRemoteApp(
|
|||
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
|
||||
if (o[k] !== undefined && typeof o[k] !== "string") return false;
|
||||
}
|
||||
if (
|
||||
o.authorizationParams !== undefined &&
|
||||
!isStringRecord(o.authorizationParams)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
o.enterpriseManaged !== undefined &&
|
||||
typeof o.enterpriseManaged !== "boolean"
|
||||
|
|
@ -1248,7 +1255,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?, enterpriseManaged? }`.",
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged? }`.",
|
||||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
|
|
@ -1520,6 +1527,18 @@ export function createRemoteApp(
|
|||
return { ok: false, error: `settings.${optional} must be a string` };
|
||||
}
|
||||
}
|
||||
// Optional on the wire (older clients won't send it); when present it must
|
||||
// be the same `{ key, value }` row shape the headers/metadata lists use.
|
||||
if (
|
||||
obj.oauthAuthorizationParams !== undefined &&
|
||||
!isKvArray(obj.oauthAuthorizationParams)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"settings.oauthAuthorizationParams must be an array of { key, value }",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.enterpriseManaged !== undefined &&
|
||||
typeof obj.enterpriseManaged !== "boolean"
|
||||
|
|
@ -1635,6 +1654,12 @@ export function createRemoteApp(
|
|||
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
|
||||
value.oauthScopes = obj.oauthScopes;
|
||||
}
|
||||
// Carried through with its blank rows intact — the omit-on-empty filtering
|
||||
// happens on the way to disk (inspectorSettingsToStoredFields), matching
|
||||
// how the headers/metadata rows are handled. (#2018)
|
||||
if (isKvArray(obj.oauthAuthorizationParams)) {
|
||||
value.oauthAuthorizationParams = obj.oauthAuthorizationParams;
|
||||
}
|
||||
if (obj.enterpriseManaged === true) {
|
||||
value.enterpriseManaged = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,16 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
enterpriseManaged?: boolean;
|
||||
/** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */
|
||||
onInsufficientScope?: OnInsufficientScopePolicy;
|
||||
/**
|
||||
* Custom query parameters appended to the OAuth **authorization request**
|
||||
* (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's
|
||||
* `login_hint` / `prompt` / `acr_values`, Auth0's `audience`. Reserved,
|
||||
* protocol-critical keys (`client_id`, `state`, `code_challenge`, …) are
|
||||
* rejected by the form and dropped with a warning at merge time. Omitted on
|
||||
* disk when empty, keeping the file diff minimal. Inspector-specific.
|
||||
* (#2018)
|
||||
*/
|
||||
authorizationParams?: Record<string, string>;
|
||||
};
|
||||
/**
|
||||
* Filesystem/URI roots advertised to the server via the `roots` client
|
||||
|
|
@ -516,6 +526,14 @@ export interface OAuthSettings {
|
|||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
/**
|
||||
* Custom authorization-request parameters as controlled key/value rows.
|
||||
* Optional so callers constructing an `OAuthSettings` for the other fields
|
||||
* don't have to supply an empty array; the form always passes the current
|
||||
* rows (blank ones included, so a half-typed row survives a re-render).
|
||||
* (#2018)
|
||||
*/
|
||||
authorizationParams?: { key: string; value: string }[];
|
||||
enterpriseManaged?: boolean;
|
||||
onInsufficientScope?: OnInsufficientScopePolicy;
|
||||
}
|
||||
|
|
@ -674,6 +692,13 @@ export interface InspectorServerSettings {
|
|||
oauthClientId?: string;
|
||||
oauthClientSecret?: string;
|
||||
oauthScopes?: string;
|
||||
/**
|
||||
* Custom authorization-request parameters, edited as controlled key/value
|
||||
* rows (mirrors `headers` / `metadata`). Persisted as the
|
||||
* `oauth.authorizationParams` record on disk; blank-key rows are dropped on
|
||||
* the way there. Applied to the authorization request only. (#2018)
|
||||
*/
|
||||
oauthAuthorizationParams?: { key: string; value: string }[];
|
||||
/**
|
||||
* SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this
|
||||
* server's HTTP transport. Defaults to `reauthorize` when unset.
|
||||
|
|
@ -1026,6 +1051,11 @@ export interface InspectorClientOptions {
|
|||
scope?: string;
|
||||
/** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */
|
||||
enterpriseManaged?: boolean;
|
||||
/**
|
||||
* Custom query parameters merged into the authorization request URL only
|
||||
* (#2018). Reserved, protocol-critical keys are dropped with a warning.
|
||||
*/
|
||||
authorizationParams?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue