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
2 changed files with 1064 additions and 6 deletions

View file

@ -700,10 +700,165 @@ describe("ServerSettingsForm", () => {
clientId: "a",
clientSecret: "",
scopes: "",
authorizationParams: [],
enterpriseManaged: false,
});
});
// #2018 — custom authorization-request parameters.
it("shows the empty hint for authorization parameters when none are set", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText("Additional authorization parameters"),
).toBeInTheDocument();
expect(
screen.getByText("No additional parameters configured"),
).toBeInTheDocument();
});
it("adds an authorization-parameter row through onOAuthChange", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.click(screen.getByRole("button", { name: "+ Add Parameter" }));
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "", value: "" }],
}),
);
});
it("edits and removes an authorization-parameter row", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
}}
expandedSections={["oauth"]}
/>,
);
const valueInput = screen.getByDisplayValue("corp");
await user.type(valueInput, "x");
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "kc_idp_hint", value: "corpx" }],
}),
);
onOAuthChange.mockClear();
const removeButton = screen
.getAllByRole("button")
.find((b) => b.textContent === "X");
await user.click(removeButton as HTMLElement);
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ authorizationParams: [] }),
);
});
it("clears an authorization-parameter key via its Clear button", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
}}
expandedSections={["oauth"]}
/>,
);
await user.click(clearButtonFor(screen.getByDisplayValue("kc_idp_hint")));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "", value: "corp" }],
}),
);
onOAuthChange.mockClear();
await user.click(clearButtonFor(screen.getByDisplayValue("corp")));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "kc_idp_hint", value: "" }],
}),
);
});
it("rejects a reserved authorization-parameter key inline and with a warning", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "state", value: "spoofed" }],
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText(
'"state" is set by the authorization flow and cannot be overridden.',
),
).toBeInTheDocument();
expect(screen.getByText("Reserved parameters ignored")).toBeInTheDocument();
expect(screen.getByDisplayValue("state")).toHaveAttribute(
"aria-invalid",
"true",
);
});
it("does not flag a blank authorization-parameter row as reserved", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "", value: "" }],
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText("Reserved parameters ignored"),
).not.toBeInTheDocument();
});
it("pluralizes the reserved-parameter warning for multiple keys", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [
{ key: "state", value: "x" },
{ key: "scope", value: "y" },
],
}}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
});
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
@ -744,6 +899,7 @@ describe("ServerSettingsForm", () => {
clientId: "",
clientSecret: "",
scopes: "",
authorizationParams: [],
enterpriseManaged: true,
});
});
@ -848,6 +1004,7 @@ describe("ServerSettingsForm", () => {
clientId: "",
clientSecret: "z",
scopes: "",
authorizationParams: [],
enterpriseManaged: false,
});
});

View file

@ -1,6 +1,7 @@
import {
Accordion,
ActionIcon,
Alert,
Button,
Checkbox,
Flex,
@ -26,6 +27,11 @@ import {
MODERN_LOG_LEVELS,
} from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
import {
RESERVED_AUTHORIZATION_PARAMS,
authorizationParamKeyError,
isReservedAuthorizationParam,
} from "@inspector/core/auth/authorizationParams.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import type { Root } from "@modelcontextprotocol/client";
@ -184,6 +190,23 @@ const EmptyHint = Text.withProps({
fs: "italic",
});
// Matches the label/description pair Mantine renders for a single input, so the
// authorization-parameter row list reads as one labelled field next to Scopes.
const FieldLabel = Text.withProps({
size: "sm",
fw: 500,
});
const FieldDescription = Text.withProps({
size: "xs",
c: "dimmed",
});
const ReservedParamError = Text.withProps({
size: "xs",
c: "var(--inspector-danger-text)",
});
const ClearStoredOAuthButton = Button.withProps({
variant: "light",
color: "red",
@ -245,6 +268,63 @@ function KeyValueRows({
);
}
// Reserved-key rejection is inline per row (#2018): the key input goes red with
// the reason under it, and the section-level warning below repeats it so the
// rejection is visible even when the offending row is scrolled out of view.
function AuthorizationParamRows({
params,
onChange,
onRemove,
}: {
params: { key: string; value: string }[];
onChange: (index: number, key: string, value: string) => void;
onRemove: (index: number) => void;
}) {
return (
<>
{params.map((param, index) => (
<Stack key={index} gap={4}>
<Group grow>
<ClearableTextInput
placeholder="Parameter (e.g. kc_idp_hint)"
value={param.key}
error={authorizationParamKeyError(param.key) !== undefined}
onChange={(e) =>
onChange(index, e.currentTarget.value, param.value)
}
rightSection={
param.key ? (
<ClearButton
onClick={() => onChange(index, "", param.value)}
/>
) : null
}
/>
<ClearableTextInput
placeholder="Value"
value={param.value}
onChange={(e) =>
onChange(index, param.key, e.currentTarget.value)
}
rightSection={
param.value ? (
<ClearButton onClick={() => onChange(index, param.key, "")} />
) : null
}
/>
<RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon>
</Group>
{authorizationParamKeyError(param.key) ? (
<ReservedParamError>
{authorizationParamKeyError(param.key)}
</ReservedParamError>
) : null}
</Stack>
))}
</>
);
}
function RootRows({
roots,
onChange,
@ -374,16 +454,52 @@ export function ServerSettingsForm({
onTimeoutChange(field, numValue);
};
// #2018 — custom authorization-request parameters. The rows ride the single
// `onOAuthChange` callback with the rest of the Authorization section rather
// than growing three more props on this form.
const authorizationParams = settings.oauthAuthorizationParams ?? [];
function currentOAuth(): OAuthSettings {
return {
clientId: settings.oauthClientId ?? "",
clientSecret: settings.oauthClientSecret ?? "",
scopes: settings.oauthScopes ?? "",
authorizationParams,
enterpriseManaged: settings.enterpriseManaged ?? false,
onInsufficientScope: settings.oauthOnInsufficientScope,
};
}
const rejectedParamKeys = authorizationParams
.map((p) => p.key.trim())
.filter((key) => isReservedAuthorizationParam(key));
function changeAuthorizationParams(
params: { key: string; value: string }[],
): void {
onOAuthChange({ ...currentOAuth(), authorizationParams: params });
}
function handleAddAuthorizationParam(): void {
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
}
function handleRemoveAuthorizationParam(index: number): void {
changeAuthorizationParams(
authorizationParams.filter((_, i) => i !== index),
);
}
function handleAuthorizationParamChange(
index: number,
key: string,
value: string,
): void {
changeAuthorizationParams(
authorizationParams.map((p, i) => (i === index ? { key, value } : p)),
);
}
return (
// Stays inline: Accordion is a compound, `multiple`-discriminated generic,
// so `.withProps({ multiple: true, ... })` loses its JSX call signature
@ -699,6 +815,38 @@ export function ServerSettingsForm({
) : null
}
/>
<Stack gap="xs">
<FieldLabel>Additional authorization parameters</FieldLabel>
<FieldDescription>
Extra query parameters appended to the authorization request
only — never the token request. Use them for provider-specific
hints such as kc_idp_hint, login_hint, prompt, acr_values, or
audience.
</FieldDescription>
{authorizationParams.length === 0 ? (
<EmptyHint>No additional parameters configured</EmptyHint>
) : (
<AuthorizationParamRows
params={authorizationParams}
onChange={handleAuthorizationParamChange}
onRemove={handleRemoveAuthorizationParam}
/>
)}
{rejectedParamKeys.length > 0 ? (
<Alert color="red" title="Reserved parameters ignored">
{`${rejectedParamKeys.join(", ")} ${
rejectedParamKeys.length === 1 ? "is" : "are"
} set by the authorization flow and will not be sent. Overriding ${
rejectedParamKeys.length === 1 ? "it" : "them"
} would break PKCE, the CSRF state binding, or the resource indicator. Reserved: ${RESERVED_AUTHORIZATION_PARAMS.join(
", ",
)}.`}
</Alert>
) : null}
<AddButton onClick={handleAddAuthorizationParam}>
+ Add Parameter
</AddButton>
</Stack>
<Select
label="Insufficient-scope response"
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."