Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
1299 lines
40 KiB
TypeScript
1299 lines
40 KiB
TypeScript
/**
|
|
* OAuth Test Server Infrastructure
|
|
*
|
|
* Provides OAuth 2.1 authorization server functionality for test servers.
|
|
* Integrates with Express apps to add OAuth endpoints and Bearer token verification.
|
|
*/
|
|
|
|
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";
|
|
|
|
type OAuthRequest = Request & {
|
|
oauthToken?: string;
|
|
oauthTokenScopes?: string[];
|
|
};
|
|
|
|
/**
|
|
* OAuth configuration from ServerConfig
|
|
*/
|
|
export type OAuthConfig = NonNullable<ServerConfig["oauth"]>;
|
|
|
|
export function getOAuthMode(
|
|
config: OAuthConfig,
|
|
): "combined" | "protected-resource" {
|
|
return config.mode ?? "combined";
|
|
}
|
|
|
|
const PATH_VALIDATION_BASE = "http://config.invalid";
|
|
|
|
/**
|
|
* True for a path that resolves under its own origin — the only shape safe to
|
|
* use as both an Express route and a `resource_metadata` value.
|
|
*
|
|
* A leading-slash check is not enough: `//other-host/doc` and `/\other-host/doc`
|
|
* both re-point the origin when resolved against the request base (the URL
|
|
* parser folds a backslash into a slash for special schemes), while Express
|
|
* still registers the route locally — so the server would advertise a document
|
|
* it does not serve (Copilot). Comparing the resolved href against the literal
|
|
* also rejects anything the parser would rewrite (spaces, unescaped
|
|
* characters), which an Express route would not match either.
|
|
*
|
|
* A query or fragment is rejected for the same reason from the other
|
|
* direction: `href` preserves both, so `/doc?v=1` and `/doc#s` would pass the
|
|
* comparison above, yet Express matches on the path alone (and treats `?` as a
|
|
* pattern character) and a fragment is never sent on the wire at all — so the
|
|
* advertised URL could not reach the registered route (Copilot).
|
|
*/
|
|
export function isOriginRelativePath(value: unknown): value is string {
|
|
if (typeof value !== "string" || !value.startsWith("/")) {
|
|
return false;
|
|
}
|
|
try {
|
|
const resolved = new URL(value, PATH_VALIDATION_BASE);
|
|
return (
|
|
resolved.origin === PATH_VALIDATION_BASE &&
|
|
resolved.search === "" &&
|
|
resolved.hash === "" &&
|
|
resolved.href === `${PATH_VALIDATION_BASE}${value}`
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The configured metadata path, validated. Throws at server-setup time rather
|
|
* than serving a route that contradicts the challenge — the JSON-config path
|
|
* is validated earlier by `load-config`, so this covers a `ServerConfig`
|
|
* built programmatically.
|
|
*/
|
|
function resourceMetadataPath(config: OAuthConfig): string | undefined {
|
|
const path = config.resourceMetadataPath;
|
|
if (path === undefined) {
|
|
return undefined;
|
|
}
|
|
if (!isOriginRelativePath(path)) {
|
|
throw new Error(
|
|
`oauth.resourceMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`,
|
|
);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* The path the RFC 8414 authorization-server metadata document is served from,
|
|
* validated the same way `resourceMetadataPath` is. Defaults to the well-known
|
|
* location; `asMetadataPath` moves it (see the field's doc comment).
|
|
*/
|
|
function asMetadataPath(config: OAuthConfig): string {
|
|
const path = config.asMetadataPath;
|
|
if (path === undefined) {
|
|
return "/.well-known/oauth-authorization-server";
|
|
}
|
|
if (!isOriginRelativePath(path)) {
|
|
throw new Error(
|
|
`oauth.asMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`,
|
|
);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* The `WWW-Authenticate` challenge sent with every 401.
|
|
*
|
|
* RFC 9728 §5.1: a resource server advertises where its protected-resource
|
|
* metadata lives via the `resource_metadata` parameter. Only emitted when the
|
|
* config moves that document off the well-known path — otherwise the bare
|
|
* `Bearer` challenge keeps the existing fixtures byte-identical.
|
|
*/
|
|
function bearerChallenge(config: OAuthConfig, req: Request): string {
|
|
const path = resourceMetadataPath(config);
|
|
if (!path) {
|
|
return "Bearer";
|
|
}
|
|
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
|
return `Bearer resource_metadata="${new URL(path, requestBaseUrl).href}"`;
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth routes on an Express application
|
|
* This adds all OAuth endpoints (authorization, token, metadata, etc.)
|
|
*
|
|
* @param app - Express application
|
|
* @param config - OAuth configuration
|
|
*/
|
|
export function setupOAuthRoutes(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
setupMetadataEndpoints(app, config);
|
|
|
|
if (getOAuthMode(config) === "combined") {
|
|
setupAuthorizationEndpoint(app, config);
|
|
setupTokenEndpoint(app, config);
|
|
if (config.supportRevocation !== false) {
|
|
setupRevocationEndpoint(app, config);
|
|
}
|
|
if (config.supportDCR) {
|
|
setupDCREndpoint(app);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create Bearer token verification middleware
|
|
* Returns 401 if token is missing or invalid when requireAuth is true
|
|
*
|
|
* @param config - OAuth configuration
|
|
* @returns Express middleware function
|
|
*/
|
|
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();
|
|
}
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
// Return 401 - the SDK's transport should detect this and throw an error
|
|
// For streamable-http, the SDK checks response status and throws StreamableHTTPError with code 401
|
|
res.status(401);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader("WWW-Authenticate", bearerChallenge(config, req));
|
|
// Return a JSON-RPC error response format that the SDK will recognize
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: "Unauthorized: Missing or invalid Bearer token (401)",
|
|
},
|
|
id: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
|
|
|
let valid: boolean;
|
|
let grantedScopes: string[] = [];
|
|
if (mode === "protected-resource") {
|
|
try {
|
|
const validated =
|
|
await externalValidator!.validateAccessTokenWithScopes(token);
|
|
valid = validated.valid;
|
|
grantedScopes = validated.scopes;
|
|
} catch {
|
|
valid = false;
|
|
}
|
|
} else {
|
|
valid = isValidToken(token);
|
|
if (valid) {
|
|
grantedScopes = getAccessTokenScopes(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");
|
|
res.setHeader("WWW-Authenticate", bearerChallenge(config, req));
|
|
// Return a JSON-RPC error response format that the SDK will recognize
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: "Unauthorized: Invalid or expired token (401)",
|
|
},
|
|
id: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Attach token info to request for use in handlers
|
|
const oauthReq = req as OAuthRequest;
|
|
oauthReq.oauthToken = token;
|
|
oauthReq.oauthTokenScopes = grantedScopes;
|
|
next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth metadata endpoints (RFC 8414)
|
|
*/
|
|
function setupMetadataEndpoints(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
const scopes = config.scopesSupported || ["mcp"];
|
|
const mode = getOAuthMode(config);
|
|
|
|
if (mode === "combined") {
|
|
// OAuth Authorization Server Metadata (local AS)
|
|
app.get(asMetadataPath(config), (req: Request, res: Response) => {
|
|
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
|
|
const metadata = {
|
|
// RFC 8414 §3.3: the issuer MUST be identical to the base URL the
|
|
// well-known path was appended to — i.e. no trailing slash. SDK v2's
|
|
// client enforces this exactly (IssuerMismatchError otherwise).
|
|
issuer: actualIssuerUrl.href.replace(/\/$/, ""),
|
|
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"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "none"],
|
|
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
|
|
// clients must validate (and our e2e can exercise reject paths).
|
|
authorization_response_iss_parameter_supported: true,
|
|
...(config.supportRevocation !== false && {
|
|
// RFC 7009 (#2144). Advertised by default so the in-repo servers
|
|
// exercise the Inspector's revocation leg; set
|
|
// `oauth.supportRevocation: false` to reproduce an authorization
|
|
// server that offers none, where the Inspector must do nothing.
|
|
revocation_endpoint: new URL("/oauth/revoke", actualIssuerUrl).href,
|
|
revocation_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);
|
|
});
|
|
}
|
|
|
|
// OAuth Protected Resource Metadata. `resourceMetadataPath` moves the
|
|
// document off the well-known path entirely (rather than serving both), so
|
|
// a client that ignores the advertised `resource_metadata` URL gets a 404
|
|
// — see the field's doc comment.
|
|
app.get(
|
|
resourceMetadataPath(config) ?? "/.well-known/oauth-protected-resource",
|
|
(req: Request, res: Response) => {
|
|
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
|
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: resourceUrl,
|
|
authorization_servers: authorizationServers.map((url) =>
|
|
url.replace(/\/$/, ""),
|
|
),
|
|
scopes_supported: scopes,
|
|
};
|
|
|
|
res.json(metadata);
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth authorization endpoint.
|
|
* Shows a simple consent page so users know they reached the test authorization server.
|
|
*/
|
|
function setupAuthorizationEndpoint(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
app.get("/oauth/authorize", async (req: Request, res: Response) => {
|
|
const parsed = await parseAuthorizationRequest(req.query, config);
|
|
if (!parsed.ok) {
|
|
res.status(parsed.status).json(parsed.body);
|
|
return;
|
|
}
|
|
|
|
res.type("html").send(renderOAuthConsentPage(parsed.value));
|
|
});
|
|
|
|
app.post(
|
|
"/oauth/authorize",
|
|
express.urlencoded({ extended: true }),
|
|
async (req: Request, res: Response) => {
|
|
const parsed = await parseAuthorizationRequest(req.body, config);
|
|
if (!parsed.ok) {
|
|
res.status(parsed.status).json(parsed.body);
|
|
return;
|
|
}
|
|
|
|
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const issuer = (config.issuerUrl ?? new URL(requestBaseUrl)).href.replace(
|
|
/\/$/,
|
|
"",
|
|
);
|
|
completeAuthorizationRedirect(res, parsed.value, issuer);
|
|
},
|
|
);
|
|
}
|
|
|
|
interface AuthorizationRequestParams {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
responseType: string;
|
|
scope?: string;
|
|
state?: string;
|
|
codeChallenge?: string;
|
|
codeChallengeMethod?: string;
|
|
}
|
|
|
|
type AuthorizationRequestResult =
|
|
| { ok: true; value: AuthorizationRequestParams }
|
|
| { ok: false; status: number; body: Record<string, unknown> };
|
|
|
|
async function parseAuthorizationRequest(
|
|
input: Record<string, unknown>,
|
|
config: OAuthConfig,
|
|
): Promise<AuthorizationRequestResult> {
|
|
const client_id = input.client_id;
|
|
const redirect_uri = input.redirect_uri;
|
|
const response_type = input.response_type;
|
|
const scope = input.scope;
|
|
const state = input.state;
|
|
const code_challenge = input.code_challenge;
|
|
const code_challenge_method = input.code_challenge_method;
|
|
|
|
if (
|
|
typeof client_id !== "string" ||
|
|
typeof redirect_uri !== "string" ||
|
|
typeof response_type !== "string"
|
|
) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: {
|
|
error: "invalid_request",
|
|
error_description: "Missing required parameters",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (response_type !== "code") {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: { error: "unsupported_response_type" },
|
|
};
|
|
}
|
|
|
|
const client = await findClient(client_id, config);
|
|
if (!client) {
|
|
return { ok: false, status: 400, body: { error: "invalid_client" } };
|
|
}
|
|
|
|
if (client.redirectUris && !client.redirectUris.includes(redirect_uri)) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: {
|
|
error: "invalid_request",
|
|
error_description: "Invalid redirect_uri",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (
|
|
typeof code_challenge_method === "string" &&
|
|
code_challenge_method !== "S256"
|
|
) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: {
|
|
error: "invalid_request",
|
|
error_description: "Unsupported code_challenge_method",
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
clientId: client_id,
|
|
redirectUri: redirect_uri,
|
|
responseType: response_type,
|
|
...(typeof scope === "string" ? { scope } : {}),
|
|
...(typeof state === "string" ? { state } : {}),
|
|
...(typeof code_challenge === "string"
|
|
? { codeChallenge: code_challenge }
|
|
: {}),
|
|
...(typeof code_challenge_method === "string"
|
|
? { codeChallengeMethod: code_challenge_method }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function completeAuthorizationRedirect(
|
|
res: Response,
|
|
params: AuthorizationRequestParams,
|
|
issuer: string,
|
|
): void {
|
|
const authCode = generateAuthorizationCode();
|
|
storeAuthorizationCode(authCode, {
|
|
clientId: params.clientId,
|
|
redirectUri: params.redirectUri,
|
|
codeChallenge: params.codeChallenge,
|
|
scope: params.scope,
|
|
});
|
|
|
|
const redirectUrl = new URL(params.redirectUri);
|
|
redirectUrl.searchParams.set("code", authCode);
|
|
if (params.state) {
|
|
redirectUrl.searchParams.set("state", params.state);
|
|
}
|
|
// RFC 9207: iss must match metadata `issuer` (no trailing slash).
|
|
redirectUrl.searchParams.set("iss", issuer);
|
|
res.redirect(redirectUrl.href);
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function renderOAuthConsentPage(params: AuthorizationRequestParams): string {
|
|
const scopeList = parseScopeString(params.scope);
|
|
const scopeItems =
|
|
scopeList.length > 0
|
|
? scopeList
|
|
.map((scope) => `<li><code>${escapeHtml(scope)}</code></li>`)
|
|
.join("")
|
|
: "<li><em>No scopes requested</em></li>";
|
|
|
|
const hiddenFields = [
|
|
["client_id", params.clientId],
|
|
["redirect_uri", params.redirectUri],
|
|
["response_type", params.responseType],
|
|
...(params.scope ? [["scope", params.scope] as const] : []),
|
|
...(params.state ? [["state", params.state] as const] : []),
|
|
...(params.codeChallenge
|
|
? [["code_challenge", params.codeChallenge] as const]
|
|
: []),
|
|
...(params.codeChallengeMethod
|
|
? [["code_challenge_method", params.codeChallengeMethod] as const]
|
|
: []),
|
|
]
|
|
.map(
|
|
([name, value]) =>
|
|
`<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}" />`,
|
|
)
|
|
.join("\n");
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Authorize — MCP test server</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 32rem; line-height: 1.5; }
|
|
h1 { font-size: 1.25rem; }
|
|
code { background: #f4f4f5; padding: 0.1rem 0.25rem; border-radius: 0.25rem; }
|
|
ul { padding-left: 1.25rem; }
|
|
button { font: inherit; padding: 0.5rem 1rem; margin-right: 0.5rem; cursor: pointer; }
|
|
.primary { background: #228be6; color: white; border: 1px solid #1c7ed6; border-radius: 0.25rem; }
|
|
.muted { color: #666; font-size: 0.9rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Authorize MCP Inspector</h1>
|
|
<p class="muted">You were redirected to the <strong>local composable test authorization server</strong>.</p>
|
|
<p>Client: <code>${escapeHtml(params.clientId)}</code></p>
|
|
<p>Requested scopes:</p>
|
|
<ul>${scopeItems}</ul>
|
|
<form method="post" action="/oauth/authorize">
|
|
${hiddenFields}
|
|
<button type="submit" class="primary">Authorize</button>
|
|
</form>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth token endpoint
|
|
*/
|
|
function setupTokenEndpoint(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
app.post(
|
|
"/oauth/token",
|
|
express.urlencoded({ extended: true }),
|
|
async (req: Request, res: Response) => {
|
|
const {
|
|
grant_type,
|
|
code,
|
|
redirect_uri,
|
|
client_id: bodyClientId,
|
|
code_verifier,
|
|
refresh_token,
|
|
} = req.body;
|
|
|
|
// Extract client_id from either body (client_secret_post) or Authorization header (client_secret_basic)
|
|
let client_id = bodyClientId;
|
|
let client_secret: string | undefined;
|
|
|
|
// Check Authorization header for client_secret_basic
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
const credentials = Buffer.from(authHeader.slice(6), "base64").toString(
|
|
"utf-8",
|
|
);
|
|
const [id, secret] = credentials.split(":", 2);
|
|
client_id = id;
|
|
client_secret = secret;
|
|
}
|
|
|
|
if (grant_type === "authorization_code") {
|
|
// Authorization code flow
|
|
if (!code || !redirect_uri || !client_id) {
|
|
res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "Missing required parameters",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const authCodeData = getAuthorizationCode(code);
|
|
if (!authCodeData) {
|
|
res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Invalid or expired authorization code",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Verify client
|
|
const client = await findClient(client_id, config);
|
|
if (!client || client.clientId !== authCodeData.clientId) {
|
|
res.status(400).json({ error: "invalid_client" });
|
|
return;
|
|
}
|
|
|
|
// Verify client secret if provided (for client_secret_basic)
|
|
if (
|
|
client_secret &&
|
|
client.clientSecret &&
|
|
client.clientSecret !== client_secret
|
|
) {
|
|
res.status(400).json({ error: "invalid_client" });
|
|
return;
|
|
}
|
|
|
|
// Verify redirect_uri
|
|
if (authCodeData.redirectUri !== redirect_uri) {
|
|
res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Redirect URI mismatch",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Verify PKCE code verifier
|
|
if (authCodeData.codeChallenge) {
|
|
if (!code_verifier) {
|
|
res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "code_verifier required",
|
|
});
|
|
return;
|
|
}
|
|
// Proper PKCE verification: code_challenge should be base64url(SHA256(code_verifier))
|
|
const hash = crypto
|
|
.createHash("sha256")
|
|
.update(code_verifier)
|
|
.digest();
|
|
// Convert to base64url (replace + with -, / with _, remove padding)
|
|
const expectedChallenge = hash
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=/g, "");
|
|
if (authCodeData.codeChallenge !== expectedChallenge) {
|
|
res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Invalid code_verifier",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Generate access token
|
|
const tokenScope =
|
|
authCodeData.scope || config.scopesSupported?.[0] || "mcp";
|
|
const accessToken = generateAccessToken(tokenScope, client_id);
|
|
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
|
|
|
const response: {
|
|
access_token: string;
|
|
token_type: string;
|
|
expires_in: number;
|
|
scope: string;
|
|
refresh_token?: string;
|
|
} = {
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
expires_in: tokenExpiration,
|
|
scope: tokenScope,
|
|
};
|
|
|
|
// Add refresh token if supported
|
|
if (config.supportRefreshTokens !== false) {
|
|
const refreshToken = generateRefreshToken();
|
|
response.refresh_token = refreshToken;
|
|
storeRefreshToken(refreshToken, {
|
|
clientId: client_id,
|
|
scope: authCodeData.scope,
|
|
accessTokens: new Set([accessToken]),
|
|
});
|
|
}
|
|
|
|
res.json(response);
|
|
} else if (grant_type === "refresh_token") {
|
|
// Refresh token flow
|
|
if (!refresh_token || !client_id) {
|
|
res.status(400).json({ error: "invalid_request" });
|
|
return;
|
|
}
|
|
|
|
const refreshTokenData = getRefreshToken(refresh_token);
|
|
if (!refreshTokenData || refreshTokenData.clientId !== client_id) {
|
|
res.status(400).json({ error: "invalid_grant" });
|
|
return;
|
|
}
|
|
|
|
const tokenScope =
|
|
refreshTokenData.scope || config.scopesSupported?.[0] || "mcp";
|
|
const accessToken = generateAccessToken(tokenScope, client_id);
|
|
// Keep the grant linkage current so a later revocation of this refresh
|
|
// token also kills the access token it just minted.
|
|
refreshTokenData.accessTokens.add(accessToken);
|
|
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
|
|
|
res.json({
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
expires_in: tokenExpiration,
|
|
scope: tokenScope,
|
|
});
|
|
} else {
|
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* RFC 7009 token revocation (#2144).
|
|
*
|
|
* Deliberately faithful on the two points the Inspector depends on, both of
|
|
* which are easy to get wrong in a fixture:
|
|
*
|
|
* - **§2.2 — an unknown token is a success.** A client revoking a token the
|
|
* server has already expired must not be told it failed, so the only 400 here
|
|
* is a structurally invalid request (no `token` at all).
|
|
* - **§2.1 — revoking a refresh token also invalidates its access tokens.**
|
|
* That is why the Inspector sends one request naming the refresh token, and a
|
|
* fixture that ignored the linkage would let a regression through silently.
|
|
*
|
|
* Client authentication is **enforced**, not merely accepted. RFC 7009 §2.1
|
|
* requires it of a confidential client, and a fixture that skipped the check
|
|
* would answer 200 to a request carrying no `Authorization` header at all — at
|
|
* which point the end-to-end test claiming to prove the Inspector authenticates
|
|
* correctly proves nothing. Both RFC 6749 §2.3.1 forms are accepted (Basic and
|
|
* the request body), as is a public client identifying itself by `client_id`.
|
|
*/
|
|
function setupRevocationEndpoint(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
app.post(
|
|
"/oauth/revoke",
|
|
express.urlencoded({ extended: true }),
|
|
async (req: Request, res: Response) => {
|
|
const token: unknown = req.body?.token;
|
|
if (typeof token !== "string" || token === "") {
|
|
res.status(400).json({ error: "invalid_request" });
|
|
return;
|
|
}
|
|
|
|
const clientId = await authenticateRevocationClient(req, config);
|
|
if (clientId === null) {
|
|
res
|
|
.status(401)
|
|
.set("WWW-Authenticate", 'Basic realm="revoke"')
|
|
.json({ error: "invalid_client" });
|
|
return;
|
|
}
|
|
|
|
// §2.1: only the client the token was issued to may revoke it. A token
|
|
// belonging to someone else is left alone — and still answered 200, per
|
|
// §2.2, since the response must not tell one client whether another's
|
|
// token exists.
|
|
const refreshTokenData = refreshTokens.get(token);
|
|
if (refreshTokenData) {
|
|
if (refreshTokenData.clientId === clientId) {
|
|
for (const accessToken of refreshTokenData.accessTokens) {
|
|
forgetAccessToken(accessToken);
|
|
}
|
|
refreshTokens.delete(token);
|
|
}
|
|
} else if (accessTokenClients.get(token) === clientId) {
|
|
forgetAccessToken(token);
|
|
}
|
|
|
|
// §2.2: 200 whether or not the token was known to us.
|
|
res.status(200).end();
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Drop an access token and everything recorded about it. */
|
|
function forgetAccessToken(token: string): void {
|
|
accessTokens.delete(token);
|
|
accessTokenScopes.delete(token);
|
|
accessTokenClients.delete(token);
|
|
}
|
|
|
|
/**
|
|
* Authenticate the caller of `/oauth/revoke` (RFC 7009 §2.1) and return the
|
|
* `client_id` it authenticated as, or `null` when it did not authenticate.
|
|
*
|
|
* Credentials may arrive either way RFC 6749 §2.3.1 allows — an `Authorization:
|
|
* Basic` header or `client_id`/`client_secret` in the form body — because the
|
|
* Inspector picks between them from the metadata, and a fixture that only read
|
|
* one would silently pass a request whose credentials went to the other place.
|
|
*
|
|
* A request naming no client at all is rejected: the Inspector always sends at
|
|
* least `client_id` once it holds any client information, so an unidentified
|
|
* request means it lost track of its credentials.
|
|
*/
|
|
async function authenticateRevocationClient(
|
|
req: Request,
|
|
config: OAuthConfig,
|
|
): Promise<string | null> {
|
|
let clientId: string | undefined;
|
|
let clientSecret: string | undefined;
|
|
|
|
const authorization = req.get("authorization");
|
|
if (authorization?.startsWith("Basic ")) {
|
|
const decoded = Buffer.from(
|
|
authorization.slice("Basic ".length),
|
|
"base64",
|
|
).toString("utf8");
|
|
const separator = decoded.indexOf(":");
|
|
if (separator === -1) return null;
|
|
// RFC 6749 §2.3.1: each half is form-urlencoded before the colon, so the
|
|
// server decodes each half after splitting on it. Decoding is what makes a
|
|
// credential containing a reserved character (`:` in the id, `%` or `/` in
|
|
// the secret) survive the round trip.
|
|
//
|
|
// A malformed escape makes `decodeURIComponent` throw, which Express would
|
|
// turn into a 500 — so a bad credential would be reported as a server
|
|
// fault rather than as the `invalid_client` 401 this endpoint means.
|
|
try {
|
|
clientId = decodeURIComponent(decoded.slice(0, separator));
|
|
clientSecret = decodeURIComponent(decoded.slice(separator + 1));
|
|
} catch {
|
|
return null;
|
|
}
|
|
} else {
|
|
const bodyId: unknown = req.body?.client_id;
|
|
const bodySecret: unknown = req.body?.client_secret;
|
|
if (typeof bodyId === "string") clientId = bodyId;
|
|
if (typeof bodySecret === "string") clientSecret = bodySecret;
|
|
}
|
|
|
|
if (!clientId) return null;
|
|
const client = await findClient(clientId, config);
|
|
if (!client) return null;
|
|
// A client registered with a secret must present it; a public one must not be
|
|
// asked for one it never had.
|
|
const ok =
|
|
client.clientSecret === undefined || clientSecret === client.clientSecret;
|
|
return ok ? clientId : null;
|
|
}
|
|
|
|
/**
|
|
* Set up Dynamic Client Registration endpoint
|
|
*/
|
|
function setupDCREndpoint(app: express.Application): void {
|
|
app.post("/oauth/register", express.json(), (req: Request, res: Response) => {
|
|
const { redirect_uris, client_name, scope } = req.body;
|
|
|
|
if (
|
|
!redirect_uris ||
|
|
!Array.isArray(redirect_uris) ||
|
|
redirect_uris.length === 0
|
|
) {
|
|
res.status(400).json({ error: "invalid_client_metadata" });
|
|
return;
|
|
}
|
|
|
|
dcrRequests.push({ redirect_uris: [...redirect_uris] });
|
|
|
|
// Generate client ID and secret
|
|
const clientId = generateClientId();
|
|
const clientSecret = generateClientSecret();
|
|
|
|
// Store registered client
|
|
registerClient(clientId, {
|
|
clientSecret,
|
|
redirectUris: redirect_uris,
|
|
clientName: client_name,
|
|
scope,
|
|
});
|
|
|
|
res.status(201).json({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
redirect_uris,
|
|
...(client_name && { client_name }),
|
|
...(scope && { scope }),
|
|
});
|
|
});
|
|
}
|
|
|
|
// In-memory storage for test server (simplified - not production-ready)
|
|
interface AuthorizationCodeData {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
codeChallenge?: string;
|
|
scope?: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface RefreshTokenData {
|
|
clientId: string;
|
|
scope?: string;
|
|
/**
|
|
* Access tokens minted under the same grant. RFC 7009 §2.1 says an
|
|
* authorization server asked to revoke a refresh token SHOULD also invalidate
|
|
* the access tokens issued from it, and the Inspector relies on exactly that
|
|
* — it sends one request naming the refresh token and expects both halves to
|
|
* die. A fixture that dropped only the refresh token would let a client that
|
|
* leaves live access tokens behind pass. (#2144)
|
|
*/
|
|
accessTokens: Set<string>;
|
|
}
|
|
|
|
interface RegisteredClient {
|
|
clientSecret?: string;
|
|
redirectUris: string[];
|
|
clientName?: string;
|
|
scope?: string;
|
|
}
|
|
|
|
const authorizationCodes = new Map<string, AuthorizationCodeData>();
|
|
const accessTokens = new Set<string>();
|
|
/** Granted OAuth scope string per access token (space-separated). */
|
|
const accessTokenScopes = new Map<string, string>();
|
|
/**
|
|
* Owning `client_id` per access token. RFC 7009 §2.1 requires an authorization
|
|
* server to verify that a token being revoked was issued to the requesting
|
|
* client, and without this the fixture had no way to tell — so any registered
|
|
* client could revoke another's access token. (#2144)
|
|
*/
|
|
const accessTokenClients = new Map<string, string>();
|
|
const refreshTokens = new Map<string, RefreshTokenData>();
|
|
const registeredClients = new Map<string, RegisteredClient>();
|
|
|
|
/** Recorded DCR request bodies (redirect_uris) for tests that verify both URLs are registered. */
|
|
const dcrRequests: Array<{ redirect_uris: string[] }> = [];
|
|
|
|
/**
|
|
* Check if a string is a valid URL
|
|
*/
|
|
function isUrl(str: string): boolean {
|
|
try {
|
|
new URL(str);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch client metadata document from URL (for CIMD)
|
|
*/
|
|
async function fetchClientMetadata(metadataUrl: string): Promise<{
|
|
redirect_uris: string[];
|
|
token_endpoint_auth_method?: string;
|
|
grant_types?: string[];
|
|
response_types?: string[];
|
|
client_name?: string;
|
|
client_uri?: string;
|
|
scope?: string;
|
|
} | null> {
|
|
try {
|
|
const response = await fetch(metadataUrl);
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const metadata = await response.json();
|
|
return metadata;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function findClient(
|
|
clientId: string,
|
|
config: OAuthConfig,
|
|
): Promise<{
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
redirectUris?: string[];
|
|
} | null> {
|
|
// Check static clients first
|
|
if (config.staticClients) {
|
|
const staticClient = config.staticClients.find(
|
|
(c) => c.clientId === clientId,
|
|
);
|
|
if (staticClient) {
|
|
return {
|
|
clientId: staticClient.clientId,
|
|
clientSecret: staticClient.clientSecret,
|
|
redirectUris: staticClient.redirectUris,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check registered clients (DCR)
|
|
if (registeredClients.has(clientId)) {
|
|
const client = registeredClients.get(clientId)!;
|
|
return {
|
|
clientId,
|
|
clientSecret: client.clientSecret,
|
|
redirectUris: client.redirectUris,
|
|
};
|
|
}
|
|
|
|
// Check CIMD: if client_id is a URL and CIMD is supported, fetch metadata
|
|
if (config.supportCIMD && isUrl(clientId)) {
|
|
const metadata = await fetchClientMetadata(clientId);
|
|
if (
|
|
metadata &&
|
|
metadata.redirect_uris &&
|
|
Array.isArray(metadata.redirect_uris)
|
|
) {
|
|
// For CIMD, the client_id is the URL itself, and there's no client_secret
|
|
// (CIMD uses token_endpoint_auth_method: "none" typically)
|
|
return {
|
|
clientId, // The URL is the client_id
|
|
clientSecret: undefined, // CIMD typically doesn't use secrets
|
|
redirectUris: metadata.redirect_uris,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function generateAuthorizationCode(): string {
|
|
return `test_auth_code_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
}
|
|
|
|
function storeAuthorizationCode(
|
|
code: string,
|
|
data: Omit<AuthorizationCodeData, "expiresAt">,
|
|
): void {
|
|
authorizationCodes.set(code, {
|
|
...data,
|
|
expiresAt: Date.now() + 60000, // 1 minute expiration
|
|
});
|
|
}
|
|
|
|
function getAuthorizationCode(code: string): AuthorizationCodeData | null {
|
|
const data = authorizationCodes.get(code);
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
|
|
// Check expiration
|
|
if (Date.now() > data.expiresAt) {
|
|
authorizationCodes.delete(code);
|
|
return null;
|
|
}
|
|
|
|
// Delete after use (authorization codes are single-use)
|
|
authorizationCodes.delete(code);
|
|
return data;
|
|
}
|
|
|
|
function generateAccessToken(scope?: string, clientId?: string): string {
|
|
const token = `test_access_token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
accessTokens.add(token);
|
|
accessTokenScopes.set(token, scope?.trim() || "mcp");
|
|
if (clientId !== undefined) accessTokenClients.set(token, clientId);
|
|
return token;
|
|
}
|
|
|
|
function generateRefreshToken(): string {
|
|
return `test_refresh_token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
}
|
|
|
|
function storeRefreshToken(token: string, data: RefreshTokenData): void {
|
|
refreshTokens.set(token, data);
|
|
}
|
|
|
|
function getRefreshToken(token: string): RefreshTokenData | null {
|
|
return refreshTokens.get(token) || null;
|
|
}
|
|
|
|
function generateClientId(): string {
|
|
return `test_client_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
}
|
|
|
|
function generateClientSecret(): string {
|
|
return `test_secret_${Math.random().toString(36).substring(2, 15)}`;
|
|
}
|
|
|
|
function registerClient(clientId: string, client: RegisteredClient): void {
|
|
registeredClients.set(clientId, client);
|
|
}
|
|
|
|
function isValidToken(token: string): boolean {
|
|
// Simplified token validation for test server
|
|
// In production, verify JWT signature, expiration, etc.
|
|
return accessTokens.has(token);
|
|
}
|
|
|
|
function parseScopeString(scope: string | undefined): string[] {
|
|
if (!scope?.trim()) {
|
|
return [];
|
|
}
|
|
return scope.trim().split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
/** Test helper: mint an access token with the given granted scopes. */
|
|
export function mintTestAccessToken(scope: string): string {
|
|
return generateAccessToken(scope);
|
|
}
|
|
|
|
/** Granted scopes for a test-server access token (empty when unknown). */
|
|
export function getAccessTokenScopes(token: string): string[] {
|
|
return parseScopeString(accessTokenScopes.get(token));
|
|
}
|
|
|
|
export interface ScopeRequirementRegistry {
|
|
tools: Map<string, string[]>;
|
|
resources: Map<string, string[]>;
|
|
prompts: Map<string, string[]>;
|
|
resourceTemplates: Map<string, string[]>;
|
|
}
|
|
|
|
function resourceUriMatchesTemplate(uri: string, uriTemplate: string): boolean {
|
|
const brace = uriTemplate.indexOf("{");
|
|
const prefix = brace >= 0 ? uriTemplate.slice(0, brace) : uriTemplate;
|
|
return uri.startsWith(prefix);
|
|
}
|
|
|
|
/** Build lookup tables from merged ServerConfig capability definitions. */
|
|
export function buildScopeRequirementRegistry(
|
|
config: ServerConfig,
|
|
): ScopeRequirementRegistry {
|
|
const registry: ScopeRequirementRegistry = {
|
|
tools: new Map(),
|
|
resources: new Map(),
|
|
prompts: new Map(),
|
|
resourceTemplates: new Map(),
|
|
};
|
|
|
|
for (const tool of config.tools ?? []) {
|
|
if (tool.requiredScopes?.length) {
|
|
registry.tools.set(tool.name, tool.requiredScopes);
|
|
}
|
|
}
|
|
for (const resource of config.resources ?? []) {
|
|
if (resource.requiredScopes?.length) {
|
|
registry.resources.set(resource.uri, resource.requiredScopes);
|
|
}
|
|
}
|
|
for (const prompt of config.prompts ?? []) {
|
|
if (prompt.requiredScopes?.length) {
|
|
registry.prompts.set(prompt.name, prompt.requiredScopes);
|
|
}
|
|
}
|
|
for (const template of config.resourceTemplates ?? []) {
|
|
if (template.requiredScopes?.length) {
|
|
registry.resourceTemplates.set(
|
|
template.uriTemplate,
|
|
template.requiredScopes,
|
|
);
|
|
}
|
|
}
|
|
|
|
return registry;
|
|
}
|
|
|
|
export function scopeRequirementRegistryHasEntries(
|
|
registry: ScopeRequirementRegistry,
|
|
): boolean {
|
|
return (
|
|
registry.tools.size > 0 ||
|
|
registry.resources.size > 0 ||
|
|
registry.prompts.size > 0 ||
|
|
registry.resourceTemplates.size > 0
|
|
);
|
|
}
|
|
|
|
function parseMcpOperation(body: unknown): {
|
|
method?: string;
|
|
target?: string;
|
|
} {
|
|
if (!body || typeof body !== "object") {
|
|
return {};
|
|
}
|
|
const rpc = body as Record<string, unknown>;
|
|
const method = typeof rpc.method === "string" ? rpc.method : undefined;
|
|
const params =
|
|
rpc.params && typeof rpc.params === "object"
|
|
? (rpc.params as Record<string, unknown>)
|
|
: undefined;
|
|
|
|
if (method === "tools/call" && typeof params?.name === "string") {
|
|
return { method, target: params.name };
|
|
}
|
|
if (method === "resources/read" && typeof params?.uri === "string") {
|
|
return { method, target: params.uri };
|
|
}
|
|
if (method === "prompts/get" && typeof params?.name === "string") {
|
|
return { method, target: params.name };
|
|
}
|
|
|
|
return { method };
|
|
}
|
|
|
|
function tokenHasRequiredScopes(
|
|
granted: string[],
|
|
required: string[],
|
|
): boolean {
|
|
const grantedSet = new Set(granted);
|
|
return required.every((scope) => grantedSet.has(scope));
|
|
}
|
|
|
|
/**
|
|
* Enforce per-capability OAuth scopes after bearer validation.
|
|
* Returns 403 + insufficient_scope when the token is valid but lacks scope.
|
|
*/
|
|
export function createScopeCheckMiddleware(
|
|
registry: ScopeRequirementRegistry,
|
|
): express.RequestHandler {
|
|
return (req: Request, res: Response, next: express.NextFunction) => {
|
|
const oauthReq = req as OAuthRequest;
|
|
const token = oauthReq.oauthToken;
|
|
if (!token) {
|
|
return next();
|
|
}
|
|
|
|
const { method, target } = parseMcpOperation(req.body);
|
|
if (!method || !target) {
|
|
return next();
|
|
}
|
|
|
|
let requiredScopes: string[] | undefined;
|
|
if (method === "tools/call") {
|
|
requiredScopes = registry.tools.get(target);
|
|
} else if (method === "resources/read") {
|
|
requiredScopes = registry.resources.get(target);
|
|
if (!requiredScopes?.length) {
|
|
for (const [uriTemplate, scopes] of registry.resourceTemplates) {
|
|
if (resourceUriMatchesTemplate(target, uriTemplate)) {
|
|
requiredScopes = scopes;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (method === "prompts/get") {
|
|
requiredScopes = registry.prompts.get(target);
|
|
}
|
|
|
|
if (!requiredScopes?.length) {
|
|
return next();
|
|
}
|
|
|
|
const granted = oauthReq.oauthTokenScopes ?? getAccessTokenScopes(token);
|
|
if (tokenHasRequiredScopes(granted, requiredScopes)) {
|
|
return next();
|
|
}
|
|
|
|
const grantedSet = new Set(granted);
|
|
const missingScopes = requiredScopes.filter(
|
|
(scope) => !grantedSet.has(scope),
|
|
);
|
|
const scopeHeader = missingScopes.join(" ") || requiredScopes.join(" ");
|
|
res.status(403);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader(
|
|
"WWW-Authenticate",
|
|
`Bearer error="insufficient_scope", scope="${scopeHeader}"`,
|
|
);
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: `Forbidden: insufficient scope (403). Required: ${scopeHeader}`,
|
|
},
|
|
id: null,
|
|
});
|
|
return;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear all OAuth test data (useful for test cleanup)
|
|
*/
|
|
export function clearOAuthTestData(): void {
|
|
authorizationCodes.clear();
|
|
accessTokens.clear();
|
|
accessTokenScopes.clear();
|
|
refreshTokens.clear();
|
|
registeredClients.clear();
|
|
dcrRequests.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Returns recorded DCR request bodies (redirect_uris) for tests that verify
|
|
* redirect URI registration.
|
|
*/
|
|
export function getDCRRequests(): Array<{ redirect_uris: string[] }> {
|
|
return dcrRequests;
|
|
}
|
|
|
|
/**
|
|
* Invalidate a single access token (remove from valid set).
|
|
* Used by E2E tests to simulate expired/revoked access token while keeping
|
|
* refresh_token valid, so 401 → auth() → refresh → retry can be exercised.
|
|
*/
|
|
export function invalidateAccessToken(token: string): void {
|
|
accessTokens.delete(token);
|
|
accessTokenScopes.delete(token);
|
|
}
|