v2: fix simpleStreamableHttp auth example for inspector connect flow (#1427)

Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
Co-authored-by: Cliff Hall <cliff@futurescale.com>
This commit is contained in:
Konstantin Konstantinov 2026-01-28 19:11:56 +02:00 • committed by GitHub
commit b0ef89ffaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 173 additions and 101 deletions

View file

@ -26,16 +26,26 @@ export function requireBearerAuth(
): (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 ')) {
const wwwAuthenticate = resourceMetadataUrl ? `Bearer resource_metadata="${resourceMetadataUrl.toString()}"` : 'Bearer';
res.set('WWW-Authenticate', wwwAuthenticate);
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
res.status(401).json({
error: 'unauthorized',
error_description: 'Missing or invalid Authorization header'
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
return;
}
@ -52,6 +62,7 @@ export function requireBearerAuth(
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(', ')}`
@ -63,14 +74,11 @@ export function requireBearerAuth(
req.app.locals.auth = authInfo;
next();
} catch (error) {
const wwwAuthenticate = resourceMetadataUrl
? `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl.toString()}"`
: 'Bearer error="invalid_token"';
res.set('WWW-Authenticate', wwwAuthenticate);
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: error instanceof Error ? error.message : 'Invalid token'
error_description: message
});
}
};

View file

@ -11,6 +11,7 @@
import { toNodeHandler } from 'better-auth/node';
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
import cors from 'cors';
import type { Request, Response as ExpressResponse, Router } from 'express';
import express from 'express';
@ -25,6 +26,12 @@ export interface SetupAuthServerOptions {
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
*/
demoMode: boolean;
/**
* Enable verbose logging of better-auth requests/responses.
* WARNING: This may log sensitive information like tokens and cookies.
* Only use for debugging purposes.
*/
dangerousLoggingEnabled?: boolean;
}
// Store auth instance globally so it can be used for token verification
@ -79,7 +86,7 @@ async function ensureDemoUserExists(auth: DemoAuth): Promise<void> {
* @param options - Server configuration
*/
export function setupAuthServer(options: SetupAuthServerOptions): void {
const { authServerUrl, mcpServerUrl, demoMode } = options;
const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options;
// Create better-auth instance with MCP plugin
const auth = createDemoAuth({
@ -96,54 +103,68 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
const authApp = express();
// Enable CORS for all origins (demo only) - must be before other middleware
authApp.use((_req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Expose-Headers', 'WWW-Authenticate');
if (_req.method === 'OPTIONS') {
res.sendStatus(200);
return;
}
next();
});
// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself.
authApp.use(
cors({
origin: '*' // WARNING: This allows all origins to access the auth server. In production, you should restrict this to specific origins.
})
);
// Request logging middleware for OAuth endpoints
authApp.use('/api/auth', (req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`${timestamp} [Auth Request] ${req.method} ${req.url}`);
if (req.method === 'POST') {
console.log(`${timestamp} [Auth Request] Content-Type: ${req.headers['content-type']}`);
}
if (demoMode) {
// Log response when it finishes
const originalSend = res.send.bind(res);
res.send = function (body) {
console.log(`${timestamp} [Auth Response] ${res.statusCode} ${req.url}`);
if (res.statusCode >= 400 && body) {
try {
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
console.log(`${timestamp} [Auth Response] Error:`, parsed);
} catch {
// Not JSON, log as-is if short
if (typeof body === 'string' && body.length < 200) {
console.log(`${timestamp} [Auth Response] Body: ${body}`);
}
}
}
return originalSend(body);
};
}
next();
});
// Create better-auth handler
// toNodeHandler bypasses Express methods
const betterAuthHandler = toNodeHandler(auth);
// Mount better-auth handler BEFORE body parsers
// toNodeHandler reads the raw request body, so Express must not consume it first
authApp.all('/api/auth/{*splat}', toNodeHandler(auth));
if (dangerousLoggingEnabled) {
// Verbose logging mode - intercept at Node.js level to see all requests/responses
// WARNING: This may log sensitive information like tokens and cookies
authApp.all('/api/auth/{*splat}', (req, res) => {
const ts = new Date().toISOString();
console.log(`\n${'='.repeat(60)}`);
console.log(`${ts} [AUTH] ${req.method} ${req.originalUrl}`);
console.log(`${ts} [AUTH] Query:`, JSON.stringify(req.query));
console.log(`${ts} [AUTH] Headers.Cookie:`, req.headers.cookie?.slice(0, 100));
// Intercept writeHead to capture status and headers (including redirects)
const originalWriteHead = res.writeHead.bind(res);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.writeHead = function (statusCode: number, ...args: any[]) {
console.log(`${ts} [AUTH] >>> Response Status: ${statusCode}`);
// Headers can be in different positions depending on the overload
const headers = args.find(a => typeof a === 'object' && a !== null);
if (headers) {
if (headers.location || headers.Location) {
console.log(`${ts} [AUTH] >>> Location (redirect): ${headers.location || headers.Location}`);
}
console.log(`${ts} [AUTH] >>> Headers:`, JSON.stringify(headers));
}
return originalWriteHead(statusCode, ...args);
};
// Intercept write to capture response body
const originalWrite = res.write.bind(res);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.write = function (chunk: any, ...args: any[]) {
if (chunk) {
const bodyPreview = typeof chunk === 'string' ? chunk.slice(0, 500) : chunk.toString().slice(0, 500);
console.log(`${ts} [AUTH] >>> Body: ${bodyPreview}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return originalWrite(chunk, ...(args as [any]));
};
return betterAuthHandler(req, res);
});
} else {
// Normal mode - no verbose logging
authApp.all('/api/auth/{*splat}', toNodeHandler(auth));
}
// OAuth metadata endpoints using better-auth's built-in handlers
authApp.get('/.well-known/oauth-authorization-server', toNodeHandler(oAuthDiscoveryMetadata(auth)));
// Add explicit OPTIONS handler for CORS preflight
authApp.options('/.well-known/oauth-authorization-server', cors());
authApp.get('/.well-known/oauth-authorization-server', cors(), toNodeHandler(oAuthDiscoveryMetadata(auth)));
// Body parsers for non-better-auth routes (like /sign-in)
authApp.use(express.json());
@ -239,14 +260,25 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
* This is needed because MCP clients discover the auth server by first
* fetching protected resource metadata from the MCP server.
*
* Per RFC 9728 Section 3, the metadata URL includes the resource path.
* E.g., for resource http://localhost:3000/mcp, metadata is at
* http://localhost:3000/.well-known/oauth-protected-resource/mcp
*
* See: https://www.better-auth.com/docs/plugins/mcp#oauth-protected-resource-metadata
*
* @param resourcePath - The path of the MCP resource (e.g., '/mcp'). Defaults to '/mcp'.
*/
export function createProtectedResourceMetadataRouter(): Router {
export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Router {
const auth = getAuth();
const router = express.Router();
// Serve at the standard well-known path
router.get('/.well-known/oauth-protected-resource', toNodeHandler(oAuthProtectedResourceMetadata(auth)));
// Construct the metadata path per RFC 9728 Section 3
const metadataPath = `/.well-known/oauth-protected-resource${resourcePath}`;
// Enable CORS for browser-based clients to discover the auth server
// Add explicit OPTIONS handler for CORS preflight
router.options(metadataPath, cors());
router.get(metadataPath, cors(), toNodeHandler(oAuthProtectedResourceMetadata(auth)));
return router;
}