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

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
RESERVED_AUTHORIZATION_PARAMS,
applyAuthorizationParams,
authorizationParamKeyError,
isReservedAuthorizationParam,
} from "@inspector/core/auth/authorizationParams.js";
const AUTHORIZE_URL =
"https://as.example.com/authorize?client_id=abc&response_type=code&state=xyz";
afterEach(() => {
vi.restoreAllMocks();
});
describe("isReservedAuthorizationParam", () => {
it.each(RESERVED_AUTHORIZATION_PARAMS)("reserves %s", (key) => {
expect(isReservedAuthorizationParam(key)).toBe(true);
});
it("ignores case and surrounding whitespace", () => {
expect(isReservedAuthorizationParam(" Client_ID ")).toBe(true);
});
it("allows a provider-specific parameter", () => {
expect(isReservedAuthorizationParam("kc_idp_hint")).toBe(false);
});
});
describe("authorizationParamKeyError", () => {
it("returns no error for a blank key (a half-edited row)", () => {
expect(authorizationParamKeyError("")).toBeUndefined();
expect(authorizationParamKeyError(" ")).toBeUndefined();
});
it("returns no error for an allowed key", () => {
expect(authorizationParamKeyError("login_hint")).toBeUndefined();
});
it("names the offending key for a reserved one", () => {
expect(authorizationParamKeyError(" state ")).toBe(
'"state" is set by the authorization flow and cannot be overridden.',
);
});
});
describe("applyAuthorizationParams", () => {
it("returns the same URL instance when no params are configured", () => {
const url = new URL(AUTHORIZE_URL);
expect(applyAuthorizationParams(url, undefined)).toBe(url);
});
it("returns the same URL instance when the params object is empty", () => {
const url = new URL(AUTHORIZE_URL);
expect(applyAuthorizationParams(url, {})).toBe(url);
});
it("appends custom params without mutating the input URL", () => {
const url = new URL(AUTHORIZE_URL);
const merged = applyAuthorizationParams(url, {
kc_idp_hint: "corp-idp",
login_hint: "user@example.com",
});
expect(merged).not.toBe(url);
expect(url.searchParams.get("kc_idp_hint")).toBeNull();
expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp");
expect(merged.searchParams.get("login_hint")).toBe("user@example.com");
// The SDK-built parameters survive untouched.
expect(merged.searchParams.get("client_id")).toBe("abc");
expect(merged.searchParams.get("state")).toBe("xyz");
});
it("trims the key and preserves the value verbatim", () => {
const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), {
" prompt ": " login ",
});
expect(merged.searchParams.get("prompt")).toBe(" login ");
});
it("skips blank keys", () => {
const url = new URL(AUTHORIZE_URL);
const merged = applyAuthorizationParams(url, { " ": "nope" });
expect(merged).toBe(url);
});
it("drops a reserved key with a warning rather than overriding it", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), {
state: "attacker-controlled",
kc_idp_hint: "corp-idp",
});
expect(merged.searchParams.get("state")).toBe("xyz");
expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp");
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain("state");
});
it("returns the original URL when every configured key is reserved", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const url = new URL(AUTHORIZE_URL);
expect(
applyAuthorizationParams(url, {
Client_Id: "spoofed",
code_challenge: "spoofed",
}),
).toBe(url);
expect(warn).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
import {
BaseOAuthClientProvider,
CallbackNavigation,
MutableRedirectUrlProvider,
} from "@inspector/core/auth/providers.js";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js";
import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js";
import {
@ -39,6 +44,7 @@ interface FakeInner {
tokens: ReturnType<typeof vi.fn>;
saveTokens: ReturnType<typeof vi.fn>;
redirectToAuthorization: ReturnType<typeof vi.fn>;
redirectToExternalAuthorization: ReturnType<typeof vi.fn>;
clearCapturedAuthUrl: ReturnType<typeof vi.fn>;
saveCodeVerifier: ReturnType<typeof vi.fn>;
codeVerifier: ReturnType<typeof vi.fn>;
@ -55,6 +61,7 @@ function createInner(): FakeInner {
tokens: vi.fn(),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
redirectToExternalAuthorization: vi.fn(),
clearCapturedAuthUrl: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(() => "verifier-xyz"),
@ -178,6 +185,42 @@ describe("EmaTransportOAuthProvider", () => {
expect(startIdpMock).toHaveBeenCalledWith(emaConfig);
expect(inner.clearCapturedAuthUrl).toHaveBeenCalledTimes(1);
expect(inner.redirectToAuthorization).toHaveBeenCalledWith(idpUrl);
expect(inner.redirectToExternalAuthorization).toHaveBeenCalledWith(idpUrl);
// The custom-parameter merge lives on `redirectToAuthorization`, so the EMA
// leg must never take that path.
expect(inner.redirectToAuthorization).not.toHaveBeenCalled();
});
// #2018 — the inner provider carries the per-server custom authorization
// parameters, which belong to the MCP server's authorization server. The EMA
// leg sends the user to a *different* authorization server (the enterprise
// IdP), so those parameters must not reach it. Driven against a real
// BaseOAuthClientProvider rather than the fake inner above, since the defect
// is precisely in what the real provider does with the URL it is handed.
it("does not leak custom authorization params onto the IdP authorize URL", async () => {
const navCallback = vi.fn();
const realInner = new BaseOAuthClientProvider(SERVER_URL, {
storage: {
getScope: vi.fn().mockResolvedValue(undefined),
} as unknown as OAuthStorage,
redirectUrlProvider: new MutableRedirectUrlProvider(),
navigation: new CallbackNavigation(navCallback),
authorizationParams: { kc_idp_hint: "corp-idp", audience: "api://mcp" },
});
const emaProvider = new EmaTransportOAuthProvider(realInner, emaConfig);
const idpUrl = new URL("https://idp.test/authorize?state=abc");
startIdpMock.mockResolvedValue(idpUrl);
await emaProvider.redirectToAuthorization(
new URL("https://resource-as.test/authorize"),
);
const navigated = navCallback.mock.calls[0]?.[0] as URL;
expect(navigated.searchParams.get("kc_idp_hint")).toBeNull();
expect(navigated.searchParams.get("audience")).toBeNull();
expect(navigated.href).toBe(idpUrl.href);
// The captured URL (what the step-up UI shows) must agree.
expect(realInner.getCapturedAuthUrl()?.href).toBe(idpUrl.href);
});
});

View file

@ -352,6 +352,68 @@ describe("OAuthNavigation", () => {
expect(navCallback).toHaveBeenCalledWith(url);
});
// #2018 — the provider is the seam where per-server custom parameters reach
// the authorize URL, since the SDK builds that URL with no hook of its own.
function makeProviderWithParams(
storage: OAuthStorage,
authorizationParams: Record<string, string>,
navCallback = vi.fn(),
): BaseOAuthClientProvider {
return new BaseOAuthClientProvider(SERVER, {
storage,
redirectUrlProvider: new MutableRedirectUrlProvider(),
navigation: new CallbackNavigation(navCallback),
authorizationParams,
});
}
it("redirectToAuthorization merges configured authorization params", () => {
const storage = makeStorage();
const navCallback = vi.fn();
const provider = makeProviderWithParams(
storage,
{ kc_idp_hint: "corp-idp" },
navCallback,
);
const target = new EventTarget();
const handler = vi.fn();
target.addEventListener("oauthAuthorizationRequired", handler);
provider.setEventTarget(target);
const url = new URL("https://mcp.example.com/authorize?client_id=abc");
provider.redirectToAuthorization(url);
const navigated = navCallback.mock.calls[0]?.[0] as URL;
expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp");
expect(navigated.searchParams.get("client_id")).toBe("abc");
// The captured URL, the event detail, and the navigation all agree.
expect(provider.getCapturedAuthUrl()?.href).toBe(navigated.href);
const detail = (handler.mock.calls[0]?.[0] as CustomEvent<{ url: URL }>)
.detail;
expect(detail.url.href).toBe(navigated.href);
// The URL the SDK handed in is left untouched.
expect(url.searchParams.get("kc_idp_hint")).toBeNull();
});
it("redirectToAuthorization refuses a reserved authorization param", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const storage = makeStorage();
const navCallback = vi.fn();
const provider = makeProviderWithParams(
storage,
{ state: "spoofed" },
navCallback,
);
const url = new URL("https://mcp.example.com/authorize?state=real");
provider.redirectToAuthorization(url);
const navigated = navCallback.mock.calls[0]?.[0] as URL;
expect(navigated.searchParams.get("state")).toBe("real");
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("delegates token and scope persistence to storage", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);

View file

@ -86,6 +86,44 @@ describe("runner client auth options", () => {
expect(opts.oauth?.clientId).toBe("resource-client");
});
// #2018 — the CLI/TUI leg picks the authorization parameters up from the same
// per-server settings the web leg reads, so all three clients agree.
it("buildRunnerClientAuthOptions forwards custom authorization params", () => {
const settings: InspectorServerSettings = {
oauthAuthorizationParams: [
{ key: "kc_idp_hint", value: "corp" },
{ key: "", value: "dropped" },
],
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
const opts = buildRunnerClientAuthOptions({}, settings);
expect(opts.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" });
});
it("buildRunnerClientAuthOptions ignores all-blank authorization param rows", () => {
const settings: InspectorServerSettings = {
oauthAuthorizationParams: [{ key: " ", value: "x" }],
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
});
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
expect(buildRunnerClientAuthOptions({})).toEqual({});
});

View file

@ -10,6 +10,7 @@ import {
mcpConfigToServerEntries,
mergeSecretsIntoStored,
normalizeServerType,
oauthAuthorizationParamsFromSettings,
serverEntriesToMcpConfig,
serializeMcpConfig,
storedFieldsToInspectorSettings,
@ -924,6 +925,21 @@ describe("extractSecretsFromStored", () => {
expect(stripped.oauth).toEqual({ enterpriseManaged: true });
});
it("preserves oauth.authorizationParams when lifting clientSecret to keychain", () => {
const stored: StoredMCPServer = {
type: "streamable-http",
url: "https://x.test",
oauth: {
clientSecret: "shh",
authorizationParams: { kc_idp_hint: "corp" },
},
};
const { stripped } = extractSecretsFromStored(stored);
expect(stripped.oauth).toEqual({
authorizationParams: { kc_idp_hint: "corp" },
});
});
it("removes the oauth block entirely when clientSecret was its only property", () => {
const stored: StoredMCPServer = {
type: "streamable-http",
@ -1188,6 +1204,68 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => {
});
expect(stored.oauth?.onInsufficientScope).toBeUndefined();
});
// #2018 — custom authorization-request parameters.
it("lifts oauth.authorizationParams into settings rows", () => {
const settings = storedFieldsToInspectorSettings({
oauth: { authorizationParams: { kc_idp_hint: "corp", prompt: "login" } },
});
expect(settings?.oauthAuthorizationParams).toEqual([
{ key: "kc_idp_hint", value: "corp" },
{ key: "prompt", value: "login" },
]);
});
it("reads an empty authorizationParams object back as unset", () => {
const settings = storedFieldsToInspectorSettings({
oauth: { clientId: "cid", authorizationParams: {} },
});
expect(settings?.oauthAuthorizationParams).toBeUndefined();
});
it("persists authorizationParams under oauth, dropping blank-key rows", () => {
const stored = inspectorSettingsToStoredFields({
...baseSettings,
oauthAuthorizationParams: [
{ key: "kc_idp_hint", value: "corp" },
{ key: " ", value: "orphan" },
],
});
expect(stored.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" });
});
it("omits the oauth block when every authorization-param row is blank", () => {
const stored = inspectorSettingsToStoredFields({
...baseSettings,
oauthAuthorizationParams: [{ key: "", value: "" }],
});
expect(stored).not.toHaveProperty("oauth");
});
it("omits authorizationParams when the rows are absent", () => {
const stored = inspectorSettingsToStoredFields({
...baseSettings,
oauthClientId: "cid",
});
expect(stored.oauth?.authorizationParams).toBeUndefined();
});
it("derives the client option record, or undefined when nothing survives", () => {
expect(
oauthAuthorizationParamsFromSettings({
oauthAuthorizationParams: [
{ key: "kc_idp_hint", value: "corp" },
{ key: "", value: "x" },
],
}),
).toEqual({ kc_idp_hint: "corp" });
expect(
oauthAuthorizationParamsFromSettings({
oauthAuthorizationParams: [{ key: " ", value: "x" }],
}),
).toBeUndefined();
expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined();
});
});
describe("envRecordToPairs / envPairsToRecord", () => {