feat(express): restore Resource-Server auth glue (requireBearerAuth, mcpAuthMetadataRouter) (#1907)

This commit is contained in:
Felix Weinberger 2026-04-28 15:18:33 +01:00 • committed by GitHub
commit 7cccc2aca8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 693 additions and 184 deletions

View file

@ -1,95 +0,0 @@
/**
* Auth Middleware for MCP Demo Servers
*
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
*
* This provides bearer auth middleware for MCP servers.
*/
import type { NextFunction, Request, Response } from 'express';
import { verifyAccessToken } from './authServer.js';
export interface RequireBearerAuthOptions {
requiredScopes?: string[];
resourceMetadataUrl?: URL;
strictResource?: boolean;
expectedResource?: URL;
}
/**
* Express middleware that requires a valid Bearer token.
* Sets `req.app.locals.auth` on success.
*/
export function requireBearerAuth(
options: RequireBearerAuthOptions = {}
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
const { requiredScopes = [], resourceMetadataUrl, strictResource = false, expectedResource } = options;
// Build WWW-Authenticate header matching v1.x format
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
let header = `Bearer error="${errorCode}", error_description="${message}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl.toString()}"`;
}
return header;
};
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
res.status(401).json({
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
return;
}
const token = authHeader.slice(7); // Remove 'Bearer ' prefix
try {
const authInfo = await verifyAccessToken(token, {
strictResource,
expectedResource
});
// Check required scopes
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
res.set('WWW-Authenticate', buildWwwAuthHeader('insufficient_scope', `Required scopes: ${requiredScopes.join(', ')}`));
res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${requiredScopes.join(', ')}`
});
return;
}
}
req.app.locals.auth = authInfo;
next();
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid token';
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message));
res.status(401).json({
error: 'invalid_token',
error_description: message
});
}
};
}
/**
* Helper to get the protected resource metadata URL from a server URL.
*/
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): URL {
const metadataUrl = new URL(serverUrl);
// Insert well-known between host and path per RFC 9728 Section 3
metadataUrl.pathname = `/.well-known/oauth-protected-resource${serverUrl.pathname}`;
return metadataUrl;
}

View file

@ -9,6 +9,9 @@
* See: https://www.better-auth.com/docs/plugins/mcp
*/
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import type { AuthInfo } from '@modelcontextprotocol/server';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import { toNodeHandler } from 'better-auth/node';
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
import cors from 'cors';
@ -21,7 +24,6 @@ import { createDemoAuth, DEMO_USER_CREDENTIALS } from './auth.js';
export interface SetupAuthServerOptions {
authServerUrl: URL;
mcpServerUrl: URL;
strictResource?: boolean;
/**
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
*/
@ -284,60 +286,29 @@ export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Ro
}
/**
* Verifies an access token using better-auth's getMcpSession.
* This can be used by MCP servers to validate tokens.
* Demo {@link OAuthTokenVerifier} backed by better-auth's `getMcpSession`.
* Pass this to `requireBearerAuth({ verifier: demoTokenVerifier, ... })` from
* `@modelcontextprotocol/express` to validate Bearer tokens against the demo
* Authorization Server started by `setupAuthServer`.
*/
export async function verifyAccessToken(
token: string,
options?: { strictResource?: boolean; expectedResource?: URL }
): Promise<{
token: string;
clientId: string;
scopes: string[];
expiresAt: number;
}> {
const auth = getAuth();
export const demoTokenVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token: string): Promise<AuthInfo> {
const auth = getAuth();
try {
// Create a mock request with the Authorization header
const headers = new Headers();
headers.set('Authorization', `Bearer ${token}`);
// Use better-auth's getMcpSession API
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await (auth.api as any).getMcpSession({
headers
});
const session = await (auth.api as any).getMcpSession({ headers });
if (!session) {
throw new Error('Invalid token');
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid token');
}
// OAuthAccessToken has:
// - accessToken, refreshToken: string
// - accessTokenExpiresAt, refreshTokenExpiresAt: Date
// - clientId, userId: string
// - scopes: string (space-separated)
const scopes = typeof session.scopes === 'string' ? session.scopes.split(' ') : ['openid'];
const expiresAt = session.accessTokenExpiresAt
? Math.floor(new Date(session.accessTokenExpiresAt).getTime() / 1000)
: Math.floor(Date.now() / 1000) + 3600;
// Note: better-auth's OAuthAccessToken doesn't have a resource field
// Resource validation would need to be done at a different layer
if (options?.strictResource && options.expectedResource) {
// For now, we skip resource validation as it's not in the session
// In production, you'd store and validate this separately
console.warn('[Auth] Resource validation requested but not available in better-auth session');
}
return {
token,
clientId: session.clientId,
scopes,
expiresAt
};
} catch (error) {
throw new Error(`Token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
return { token, clientId: session.clientId, scopes, expiresAt };
}
}
};

View file

@ -2,10 +2,6 @@
export type { CreateDemoAuthOptions, DemoAuth } from './auth.js';
export { createDemoAuth } from './auth.js';
// Auth middleware
export type { RequireBearerAuthOptions } from './authMiddleware.js';
export { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from './authMiddleware.js';
// Auth server setup
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
export type { SetupAuthServerOptions } from './authServer.js';
export { createProtectedResourceMetadataRouter, getAuth, setupAuthServer, verifyAccessToken } from './authServer.js';
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js';

View file

@ -8,6 +8,7 @@
"paths": {
"*": ["./*"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
"@modelcontextprotocol/core": [
"./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts"