feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 7585 additions and 595 deletions

View file

@ -0,0 +1,22 @@
{
"serverInfo": {
"name": "xaa-ema-resource",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }, { "preset": "get_temp" }],
"transport": {
"type": "streamable-http",
"port": 8080
},
"oauth": {
"enabled": true,
"mode": "protected-resource",
"authorizationServers": ["https://auth.resource.xaa.dev"],
"requireAuth": true,
"scopesSupported": ["mcp"],
"accessTokenIssuers": ["https://auth.resource.xaa.dev"],
"jwksUri": "https://auth.resource.xaa.dev/jwks",
"resource": "http://localhost:8080/",
"resourceAudience": "http://localhost:8080/"
}
}

View file

@ -239,32 +239,55 @@ export interface ServerConfig {
*/
taskMessageQueue?: TaskMessageQueue;
/**
* OAuth 2.1 configuration for test server
* If enabled, server will act as an OAuth authorization server
* OAuth 2.1 configuration for test server.
* - **combined** (default): this server is both MCP resource and authorization server.
* - **protected-resource**: MCP resource only; metadata points at external authorization server(s).
*/
oauth?: {
/**
* Whether OAuth is enabled for this test server
*/
enabled: boolean;
/**
* OAuth authorization server issuer URL
* Used for metadata endpoints and token issuance
* If not provided, defaults to the test server's base URL
* combined — local AS + resource (existing behavior).
* protected-resource — external AS; JWT bearer validation against AS JWKS.
*/
mode?: "combined" | "protected-resource";
/**
* External authorization server URLs for protected-resource metadata
* (`authorization_servers` in RFC 9728). Required when mode is protected-resource.
*/
authorizationServers?: string[];
/**
* Protected resource identifier override for RFC 9728 metadata.
* Defaults to the MCP server base URL when omitted.
*/
resource?: string;
/**
* OAuth authorization server issuer URL (combined mode AS metadata).
* If not provided, defaults to the test server's base URL.
*/
issuerUrl?: URL;
/**
* List of scopes supported by this authorization server
* Defaults to ["mcp"] if not provided
* Allowed JWT `iss` values for access tokens (protected-resource mode).
* Defaults to authorizationServers plus issuer from AS metadata discovery.
*/
scopesSupported?: string[];
accessTokenIssuers?: string[];
/**
* If true, MCP endpoints require valid Bearer token
* Returns 401 Unauthorized if token is missing or invalid
* JWKS URI override for access-token signature verification.
* When omitted, discovered from authorization server metadata.
*/
jwksUri?: string;
/**
* When set, require JWT `aud` to match this resource identifier.
*/
resourceAudience?: string;
scopesSupported?: string[];
requireAuth?: boolean;
/**

View file

@ -8,4 +8,7 @@ export * from "./test-server-stdio.js";
export * from "./test-server-http.js";
export * from "./test-server-control.js";
export * from "./test-server-oauth.js";
export * from "./test-server-oauth-jwt.js";
export * from "./test-helpers.js";
export * from "./load-config.js";
export * from "./resolve-config.js";

View file

@ -12,6 +12,28 @@ export interface PresetRef {
params?: Record<string, unknown>;
}
export interface ConfigFileOAuth {
enabled: boolean;
mode?: "combined" | "protected-resource";
authorizationServers?: string[];
resource?: string;
issuerUrl?: string;
accessTokenIssuers?: string[];
jwksUri?: string;
resourceAudience?: string;
scopesSupported?: string[];
requireAuth?: boolean;
staticClients?: Array<{
clientId: string;
clientSecret?: string;
redirectUris?: string[];
}>;
supportDCR?: boolean;
supportCIMD?: boolean;
tokenExpirationSeconds?: number;
supportRefreshTokens?: boolean;
}
export interface ConfigFile {
serverInfo: {
name: string;
@ -38,6 +60,7 @@ export interface ConfigFile {
resourceTemplates?: number;
prompts?: number;
};
oauth?: ConfigFileOAuth;
transport: {
type: "stdio" | "streamable-http" | "sse";
port?: number;
@ -102,6 +125,48 @@ function validateConfig(
`Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`,
);
}
if (o.oauth && typeof o.oauth === "object") {
const oauth = o.oauth as Record<string, unknown>;
if (oauth.enabled !== true) {
throw new Error(
`Invalid config in ${filePath}: oauth.enabled must be true when oauth is present`,
);
}
const mode = oauth.mode;
if (
mode !== undefined &&
mode !== "combined" &&
mode !== "protected-resource"
) {
throw new Error(
`Invalid config in ${filePath}: oauth.mode must be combined or protected-resource`,
);
}
if (mode === "protected-resource") {
const servers = oauth.authorizationServers;
if (!Array.isArray(servers) || servers.length === 0) {
throw new Error(
`Invalid config in ${filePath}: oauth.authorizationServers is required when oauth.mode is protected-resource`,
);
}
for (const url of servers) {
if (typeof url !== "string" || url.trim() === "") {
throw new Error(
`Invalid config in ${filePath}: oauth.authorizationServers must be non-empty URL strings`,
);
}
}
}
if (
(transportType === "stdio") &&
oauth.enabled === true
) {
throw new Error(
`Invalid config in ${filePath}: oauth requires streamable-http or sse transport`,
);
}
}
}
/**

View file

@ -82,5 +82,13 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
port: isHttp ? transport.port : undefined,
};
if (config.oauth) {
const { issuerUrl, ...oauthRest } = config.oauth;
serverConfig.oauth = {
...oauthRest,
...(issuerUrl && { issuerUrl: new URL(issuerUrl) }),
};
}
return serverConfig;
}

View file

@ -1922,6 +1922,7 @@ export function createOAuthTestServerConfig(options: {
return {
oauth: {
enabled: true,
mode: "combined",
requireAuth: options.requireAuth ?? false,
scopesSupported: options.scopesSupported ?? ["mcp"],
staticClients: options.staticClients,
@ -1932,3 +1933,31 @@ export function createOAuthTestServerConfig(options: {
},
};
}
/**
* MCP resource server that delegates authorization to an external AS (EMA / XAA testing).
* Access tokens must be JWTs from that AS (signature + iss validated via JWKS).
*/
export function createExternalResourceOAuthTestServerConfig(options: {
authorizationServers: string[];
requireAuth?: boolean;
scopesSupported?: string[];
resource?: string;
accessTokenIssuers?: string[];
jwksUri?: string;
resourceAudience?: string;
}): Partial<ServerConfig> {
return {
oauth: {
enabled: true,
mode: "protected-resource",
authorizationServers: options.authorizationServers,
requireAuth: options.requireAuth ?? true,
scopesSupported: options.scopesSupported ?? ["mcp"],
resource: options.resource,
accessTokenIssuers: options.accessTokenIssuers,
jwksUri: options.jwksUri,
resourceAudience: options.resourceAudience,
},
};
}

View file

@ -0,0 +1,123 @@
/**
* JWT access-token validation for test MCP servers using an external authorization server.
*/
import {
createRemoteJWKSet,
customFetch,
jwtVerify,
} from "jose";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import type { ServerConfig } from "./composable-test-server.js";
type ExternalValidatorOAuthConfig = Pick<
NonNullable<ServerConfig["oauth"]>,
| "authorizationServers"
| "accessTokenIssuers"
| "jwksUri"
| "resourceAudience"
>;
function normalizeIssuer(issuer: string): string {
return issuer.replace(/\/$/, "");
}
function audienceMatches(aud: unknown, expected: string): boolean {
const normalized = expected.replace(/\/$/, "");
if (typeof aud === "string") {
return normalizeIssuer(aud) === normalized;
}
if (Array.isArray(aud)) {
return aud.some((a) => typeof a === "string" && normalizeIssuer(a) === normalized);
}
return false;
}
export class ExternalAccessTokenValidator {
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
private allowedIssuers = new Set<string>();
private initPromise: Promise<void> | null = null;
private readonly config: ExternalValidatorOAuthConfig;
private readonly fetchFn: typeof fetch;
constructor(
config: ExternalValidatorOAuthConfig,
fetchFn: typeof fetch = fetch,
) {
this.config = config;
this.fetchFn = fetchFn;
}
private async ensureReady(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.init();
}
await this.initPromise;
}
private async init(): Promise<void> {
const configuredIssuers =
this.config.accessTokenIssuers ??
this.config.authorizationServers ??
[];
if (configuredIssuers.length === 0) {
throw new Error(
"External access token validation requires authorizationServers or accessTokenIssuers",
);
}
for (const issuer of configuredIssuers) {
this.allowedIssuers.add(normalizeIssuer(issuer));
}
let jwksUri = this.config.jwksUri;
const primaryAs = this.config.authorizationServers?.[0];
if (!jwksUri && primaryAs) {
const metadata = await discoverAuthorizationServerMetadata(
new URL(primaryAs),
{ fetchFn: this.fetchFn },
);
if (metadata?.issuer) {
this.allowedIssuers.add(normalizeIssuer(metadata.issuer));
}
const discoveredJwks = metadata?.jwks_uri;
if (typeof discoveredJwks === "string") {
jwksUri = discoveredJwks;
}
}
if (!jwksUri) {
throw new Error(
"Could not resolve jwks_uri for external authorization server (set oauth.jwksUri in config)",
);
}
this.jwks = createRemoteJWKSet(new URL(jwksUri), {
[customFetch]: this.fetchFn,
});
}
async validateAccessToken(token: string): Promise<boolean> {
if (!this.jwks) {
await this.ensureReady();
}
if (!this.jwks) return false;
try {
const { payload } = await jwtVerify(token, this.jwks, {
issuer: [...this.allowedIssuers],
});
if (this.config.resourceAudience) {
const aud = payload.aud;
if (!audienceMatches(aud, this.config.resourceAudience)) {
return false;
}
}
return true;
} catch {
return false;
}
}
}

View file

@ -9,12 +9,19 @@ import crypto from "node:crypto";
import type { Request, Response } from "express";
import express from "express";
import type { ServerConfig } from "./composable-test-server.js";
import { ExternalAccessTokenValidator } from "./test-server-oauth-jwt.js";
/**
* OAuth configuration from ServerConfig
*/
export type OAuthConfig = NonNullable<ServerConfig["oauth"]>;
export function getOAuthMode(
config: OAuthConfig,
): "combined" | "protected-resource" {
return config.mode ?? "combined";
}
/**
* Set up OAuth routes on an Express application
* This adds all OAuth endpoints (authorization, token, metadata, etc.)
@ -26,18 +33,14 @@ export function setupOAuthRoutes(
app: express.Application,
config: OAuthConfig,
): void {
// OAuth metadata endpoints (RFC 8414)
setupMetadataEndpoints(app, config);
// OAuth authorization endpoint
setupAuthorizationEndpoint(app, config);
// OAuth token endpoint
setupTokenEndpoint(app, config);
// Dynamic Client Registration endpoint (if enabled)
if (config.supportDCR) {
setupDCREndpoint(app);
if (getOAuthMode(config) === "combined") {
setupAuthorizationEndpoint(app, config);
setupTokenEndpoint(app, config);
if (config.supportDCR) {
setupDCREndpoint(app);
}
}
}
@ -51,6 +54,12 @@ export function setupOAuthRoutes(
export function createBearerTokenMiddleware(
config: OAuthConfig,
): express.RequestHandler {
const mode = getOAuthMode(config);
const externalValidator =
mode === "protected-resource"
? new ExternalAccessTokenValidator(config)
: undefined;
return async (req: Request, res: Response, next: express.NextFunction) => {
if (!config.requireAuth) {
return next();
@ -77,8 +86,18 @@ export function createBearerTokenMiddleware(
const token = authHeader.substring(7); // Remove "Bearer " prefix
// Verify token (simplified for test server - in production, use proper JWT verification)
if (!isValidToken(token)) {
let valid = false;
if (mode === "protected-resource") {
try {
valid = await externalValidator!.validateAccessToken(token);
} catch {
valid = false;
}
} else {
valid = isValidToken(token);
}
if (!valid) {
// Return 401 - the SDK's transport should detect this and throw an error
res.status(401);
res.setHeader("Content-Type", "application/json");
@ -109,47 +128,59 @@ function setupMetadataEndpoints(
config: OAuthConfig,
): void {
const scopes = config.scopesSupported || ["mcp"];
const mode = getOAuthMode(config);
// OAuth Authorization Server Metadata
app.get(
"/.well-known/oauth-authorization-server",
(req: Request, res: Response) => {
// Use request's host to get actual server URL (since port is assigned dynamically)
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
const actualIssuerUrl = new URL(requestBaseUrl);
const metadata = {
issuer: actualIssuerUrl.href,
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
.href,
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
scopes_supported: scopes,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"], // PKCE support
token_endpoint_auth_methods_supported: ["client_secret_basic", "none"],
...(config.supportDCR && {
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
if (mode === "combined") {
// OAuth Authorization Server Metadata (local AS)
app.get(
"/.well-known/oauth-authorization-server",
(req: Request, res: Response) => {
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
const metadata = {
issuer: actualIssuerUrl.href.replace(/\/$/, "") + "/",
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
.href,
}),
...(config.supportCIMD && {
client_id_metadata_document_supported: true,
}),
};
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
scopes_supported: scopes,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["client_secret_basic", "none"],
...(config.supportDCR && {
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
.href,
}),
...(config.supportCIMD && {
client_id_metadata_document_supported: true,
}),
};
res.json(metadata);
},
);
res.json(metadata);
},
);
}
// OAuth Protected Resource Metadata
app.get(
"/.well-known/oauth-protected-resource",
(req: Request, res: Response) => {
// Use request's host so resource matches actual server URL (port 0 → assigned port)
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
const actualResourceUrl = new URL("/", requestBaseUrl).href;
const resourceUrl =
config.resource ?? new URL("/", requestBaseUrl).href;
const localAsUrl = (config.issuerUrl ?? new URL(requestBaseUrl)).href.replace(
/\/$/,
"",
);
const authorizationServers =
mode === "protected-resource"
? (config.authorizationServers ?? [])
: [localAsUrl];
const metadata = {
resource: actualResourceUrl,
authorization_servers: [actualResourceUrl],
resource: resourceUrl,
authorization_servers: authorizationServers.map((url) =>
url.replace(/\/$/, ""),
),
scopes_supported: scopes,
};