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
2 changed files with 1064 additions and 6 deletions
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Reference in a new issue