inspector/core/client/runner.ts
cliffhall b25ece8ded 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>
2026-08-15 19:31:41 -04:00

136 lines
4.2 KiB
TypeScript

/**
* Install-level client config loading and InspectorClient auth option wiring
* for Node runners (TUI, CLI).
*/
import {
KeyringSecretStore,
type SecretStore,
} from "../auth/node/secret-store.js";
import type {
InspectorClientOptions,
InspectorServerSettings,
} from "../mcp/types.js";
import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js";
import { loadClientConfig } from "./config.js";
import type { ClientConfig } from "./types.js";
import {
getActiveCimdClientMetadataUrl,
getActiveEnterpriseManagedAuthIdp,
} from "./types.js";
export interface LoadRunnerClientConfigOptions {
/** Explicit path from `--client-config` (or MCP_CLIENT_CONFIG_PATH when unset). */
clientConfigPath?: string;
/** Secret store for the IdP clientSecret; defaults to the OS keychain. Tests
* inject an in-memory store for determinism. */
secretStore?: SecretStore;
}
/** Load install-level client.json with keychain-backed IdP secrets. */
export async function loadRunnerClientConfig(
options?: LoadRunnerClientConfigOptions,
): Promise<ClientConfig> {
const customPath =
options?.clientConfigPath?.trim() ||
process.env.MCP_CLIENT_CONFIG_PATH?.trim() ||
undefined;
const secretStore = options?.secretStore ?? new KeyringSecretStore();
return loadClientConfig({ filePath: customPath, secretStore });
}
export interface RunnerClientConfigOverrides {
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
}
/** HTTP transports (SSE, streamable-http) can use OAuth. */
export function isOAuthCapableServerConfig(
config: { type?: string } | null | undefined,
): boolean {
if (!config) return false;
return config.type === "sse" || config.type === "streamable-http";
}
/**
* Derive OAuth / EMA / CIMD InspectorClient options from install client.json,
* per-server settings, and CLI flag overrides (flags win over client.json).
*/
export function buildRunnerClientAuthOptions(
clientConfig: ClientConfig,
savedSettings?: InspectorServerSettings,
cliOverrides?: RunnerClientConfigOverrides,
): Pick<
InspectorClientOptions,
| "oauth"
| "enterpriseManagedAuth"
| "installEnterpriseManagedAuth"
| "directAuthRecovery"
> {
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
clientId: savedSettings.oauthClientId,
}),
...(savedSettings.oauthClientSecret && {
clientSecret: savedSettings.oauthClientSecret,
}),
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),
}
: undefined;
const clientMetadataUrl =
cliOverrides?.clientMetadataUrl?.trim() || activeCimdUrl;
const oauthFromCli =
cliOverrides?.clientId || cliOverrides?.clientSecret || clientMetadataUrl
? {
...(cliOverrides?.clientId && { clientId: cliOverrides.clientId }),
...(cliOverrides?.clientSecret && {
clientSecret: cliOverrides.clientSecret,
}),
...(clientMetadataUrl && { clientMetadataUrl }),
}
: undefined;
const oauth =
oauthFromServer || oauthFromCli
? {
...(oauthFromServer ?? {}),
...(oauthFromCli ?? {}),
}
: undefined;
return {
...(oauth && { oauth }),
...(activeIdp && {
enterpriseManagedAuth: { idp: activeIdp },
}),
...(clientConfig.enterpriseManagedAuth && {
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
}),
...(oauth && { directAuthRecovery: true }),
};
}