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:
parent
5ee347571f
commit
5de4e911e3
8 changed files with 7585 additions and 595 deletions
22
test-servers/configs/xaa-ema-http.json
Normal file
22
test-servers/configs/xaa-ema-http.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
123
test-servers/src/test-server-oauth-jwt.ts
Normal file
123
test-servers/src/test-server-oauth-jwt.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue