inspector/core/auth/ema/transportProvider.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

101 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { OAuthClientProvider } from "@modelcontextprotocol/client";
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/client";
import type { BaseOAuthClientProvider } from "../providers.js";
import {
refreshEmaResourceTokens,
startEmaIdpAuthorization,
type EmaFlowConfig,
} from "./emaFlow.js";
import { isJwtExpired } from "./jwt.js";
function isAccessTokenUsable(tokens: OAuthTokens, skewMs = 60_000): boolean {
if (!tokens.access_token) return false;
return !isJwtExpired(tokens.access_token, skewMs);
}
/**
* OAuth provider for MCP transport when enterprise-managed authorization is enabled.
*
* Proactively refreshes resource tokens (EMA legs 2–3) via {@link tokens} before
* requests. On 401 when the IdP session is still valid, the SDK {@link auth}
* call may still reach {@link redirectToAuthorization}; we ignore the resource-AS
* URL and redirect to the IdP instead.
*/
export class EmaTransportOAuthProvider implements OAuthClientProvider {
private readonly inner: BaseOAuthClientProvider;
private readonly emaConfig: EmaFlowConfig;
constructor(inner: BaseOAuthClientProvider, emaConfig: EmaFlowConfig) {
this.inner = inner;
this.emaConfig = emaConfig;
}
get redirectUrl(): string {
return this.inner.redirectUrl;
}
get clientMetadataUrl(): string | undefined {
return this.inner.clientMetadataUrl;
}
get clientMetadata(): OAuthClientMetadata {
return this.inner.clientMetadata;
}
state(): string | Promise<string> {
return this.inner.state();
}
clientInformation():
| OAuthClientInformationMixed
| undefined
| Promise<OAuthClientInformationMixed | undefined> {
return this.inner.clientInformation();
}
saveClientInformation(
clientInformation: OAuthClientInformationMixed,
): void | Promise<void> {
return this.inner.saveClientInformation(clientInformation);
}
async tokens(): Promise<OAuthTokens | undefined> {
const stored = await this.inner.tokens();
if (stored && isAccessTokenUsable(stored)) {
return stored;
}
const refreshed = await refreshEmaResourceTokens(this.emaConfig);
if (refreshed) {
return refreshed;
}
return undefined;
}
saveTokens(tokens: OAuthTokens): void | Promise<void> {
return this.emaConfig.storage.saveTokens(this.emaConfig.serverUrl, tokens, {
enterpriseManaged: true,
});
}
async redirectToAuthorization(_authorizationUrl: URL): Promise<void> {
const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig);
this.inner.clearCapturedAuthUrl();
// The IdP is a different authorization server than the one the inner
// provider was configured for, so the per-server custom authorization
// parameters (#2018) must not be appended here — hence the "external"
// entry point rather than `redirectToAuthorization`.
this.inner.redirectToExternalAuthorization(idpAuthorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void | Promise<void> {
return this.inner.saveCodeVerifier(codeVerifier);
}
codeVerifier(): string | Promise<string> {
return this.inner.codeVerifier();
}
}