[v2] Decouple server from express and hono - http framework-agnostic MCP server (#1326)
This commit is contained in:
parent
5ce4b5ef08
commit
f495077e08
5 changed files with 8193 additions and 15757 deletions
250
examples/shared/src/auth.ts
Normal file
250
examples/shared/src/auth.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Better Auth configuration for MCP demo servers
|
||||
*
|
||||
* DEMO ONLY - NOT FOR PRODUCTION
|
||||
*
|
||||
* This configuration uses in-memory SQLite and auto-approves all logins.
|
||||
* For production use, configure a proper database and authentication flow.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { BetterAuthOptions } from 'better-auth';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { mcp } from 'better-auth/plugins';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
// Generate a random password for the demo user (new each time the server starts)
|
||||
const DEMO_PASSWORD = randomBytes(16).toString('base64url');
|
||||
|
||||
// Create the in-memory database once (module-level singleton)
|
||||
// This avoids the type export issue and ensures the same DB is used
|
||||
let _db: InstanceType<typeof Database> | null = null;
|
||||
|
||||
function getDatabase(): InstanceType<typeof Database> {
|
||||
if (!_db) {
|
||||
_db = new Database(':memory:');
|
||||
initializeSchema(_db);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database schema for better-auth + MCP plugin.
|
||||
* This creates all required tables for the demo.
|
||||
*
|
||||
* Schema based on:
|
||||
* - https://www.better-auth.com/docs/concepts/database#core-schema
|
||||
* - https://www.better-auth.com/docs/plugins/oidc-provider#schema
|
||||
*/
|
||||
function initializeSchema(db: InstanceType<typeof Database>): void {
|
||||
// Core better-auth tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
emailVerified INTEGER NOT NULL DEFAULT 0,
|
||||
image TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expiresAt TEXT NOT NULL,
|
||||
ipAddress TEXT,
|
||||
userAgent TEXT,
|
||||
userId TEXT NOT NULL REFERENCES user(id),
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
id TEXT PRIMARY KEY,
|
||||
accountId TEXT NOT NULL,
|
||||
providerId TEXT NOT NULL,
|
||||
userId TEXT NOT NULL REFERENCES user(id),
|
||||
accessToken TEXT,
|
||||
refreshToken TEXT,
|
||||
idToken TEXT,
|
||||
accessTokenExpiresAt TEXT,
|
||||
refreshTokenExpiresAt TEXT,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expiresAt TEXT NOT NULL,
|
||||
createdAt TEXT,
|
||||
updatedAt TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// OIDC/MCP plugin tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oauthApplication (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
icon TEXT,
|
||||
metadata TEXT,
|
||||
clientId TEXT NOT NULL UNIQUE,
|
||||
clientSecret TEXT,
|
||||
redirectUrls TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
userId TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauthAccessToken (
|
||||
id TEXT PRIMARY KEY,
|
||||
accessToken TEXT NOT NULL UNIQUE,
|
||||
refreshToken TEXT UNIQUE,
|
||||
accessTokenExpiresAt TEXT NOT NULL,
|
||||
refreshTokenExpiresAt TEXT,
|
||||
clientId TEXT NOT NULL,
|
||||
userId TEXT,
|
||||
scopes TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauthRefreshToken (
|
||||
id TEXT PRIMARY KEY,
|
||||
refreshToken TEXT NOT NULL UNIQUE,
|
||||
accessTokenId TEXT NOT NULL,
|
||||
expiresAt TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauthAuthorizationCode (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
clientId TEXT NOT NULL,
|
||||
userId TEXT,
|
||||
scopes TEXT NOT NULL,
|
||||
redirectURI TEXT NOT NULL,
|
||||
codeChallenge TEXT,
|
||||
codeChallengeMethod TEXT,
|
||||
expiresAt TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauthConsent (
|
||||
id TEXT PRIMARY KEY,
|
||||
clientId TEXT NOT NULL,
|
||||
userId TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
consentGiven INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('[Auth] In-memory database schema initialized');
|
||||
console.log('[Auth] ========================================');
|
||||
console.log('[Auth] Demo user credentials (auto-login):');
|
||||
console.log(`[Auth] Email: ${DEMO_USER_CREDENTIALS.email}`);
|
||||
console.log(`[Auth] Password: ${DEMO_USER_CREDENTIALS.password}`);
|
||||
console.log('[Auth] ========================================');
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo user credentials for auto-login.
|
||||
* Password is randomly generated each time the server starts.
|
||||
* Used by authServer.ts to create and sign in the demo user.
|
||||
*/
|
||||
export const DEMO_USER_CREDENTIALS = {
|
||||
email: 'demo@example.com',
|
||||
password: DEMO_PASSWORD,
|
||||
name: 'Demo User'
|
||||
};
|
||||
|
||||
export interface CreateDemoAuthOptions {
|
||||
baseURL: string;
|
||||
resource?: string;
|
||||
loginPage?: string;
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a better-auth instance configured for MCP OAuth demo.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.baseURL - The base URL for the auth server (e.g., http://localhost:3001)
|
||||
* @param options.resource - The MCP resource server URL (for protected resource metadata)
|
||||
* @param options.loginPage - Path to login page (defaults to /sign-in)
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/plugins/mcp
|
||||
*/
|
||||
export function createDemoAuth(options: CreateDemoAuthOptions) {
|
||||
const { baseURL, resource, loginPage = '/sign-in', demoMode } = options;
|
||||
|
||||
// Use in-memory SQLite database for demo purposes
|
||||
// Note: All data is lost on restart - demo only!
|
||||
const db = getDatabase();
|
||||
|
||||
// MCP plugin configuration
|
||||
const mcpPlugin = mcp({
|
||||
loginPage,
|
||||
resource,
|
||||
oidcConfig: {
|
||||
loginPage,
|
||||
codeExpiresIn: 600, // 10 minutes
|
||||
accessTokenExpiresIn: 3600, // 1 hour
|
||||
refreshTokenExpiresIn: 604800, // 7 days
|
||||
defaultScope: 'openid',
|
||||
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
||||
allowDynamicClientRegistration: true,
|
||||
metadata: {
|
||||
scopes_supported: ['openid', 'profile', 'email', 'offline_access']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return betterAuth({
|
||||
baseURL,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
database: db as any, // Type cast to avoid exposing better-sqlite3 in exported types
|
||||
trustedOrigins: [baseURL.toString()],
|
||||
// Basic email+password for demo
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false
|
||||
},
|
||||
plugins: [mcpPlugin],
|
||||
// Enable verbose logging for demo/debugging
|
||||
logger: demoMode
|
||||
? {
|
||||
disabled: false,
|
||||
level: 'debug',
|
||||
log: (level, message, ...args) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[Auth ${level.toUpperCase()}]`;
|
||||
if (args.length > 0) {
|
||||
console.log(`${timestamp} ${prefix} ${message}`, ...args);
|
||||
} else {
|
||||
console.log(`${timestamp} ${prefix} ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
} satisfies BetterAuthOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the auth instance returned by createDemoAuth.
|
||||
* Note: Due to plugin type inference complexity, we use a generic type.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type DemoAuth = ReturnType<typeof createDemoAuth>;
|
||||
87
examples/shared/src/authMiddleware.ts
Normal file
87
examples/shared/src/authMiddleware.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* 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;
|
||||
|
||||
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.status(401).json({
|
||||
error: 'unauthorized',
|
||||
error_description: 'Missing or invalid 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.status(403).json({
|
||||
error: 'insufficient_scope',
|
||||
error_description: `Required scopes: ${requiredScopes.join(', ')}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: error instanceof Error ? error.message : 'Invalid token'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
310
examples/shared/src/authServer.ts
Normal file
310
examples/shared/src/authServer.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/**
|
||||
* Better Auth Server Setup for MCP Demo
|
||||
*
|
||||
* DEMO ONLY - NOT FOR PRODUCTION
|
||||
*
|
||||
* This creates a standalone OAuth Authorization Server using better-auth
|
||||
* that MCP clients can use to obtain access tokens.
|
||||
*
|
||||
* See: https://www.better-auth.com/docs/plugins/mcp
|
||||
*/
|
||||
|
||||
import { toNodeHandler } from 'better-auth/node';
|
||||
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
|
||||
import type { Request, Response as ExpressResponse, Router } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
import type { DemoAuth } from './auth.js';
|
||||
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.
|
||||
*/
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
// Store auth instance globally so it can be used for token verification
|
||||
let globalAuth: DemoAuth | null = null;
|
||||
let demoUserCreated = false;
|
||||
|
||||
/**
|
||||
* Gets the global auth instance (must call setupAuthServer first)
|
||||
*/
|
||||
export function getAuth(): DemoAuth {
|
||||
if (!globalAuth) {
|
||||
throw new Error('Auth not initialized. Call setupAuthServer first.');
|
||||
}
|
||||
return globalAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the demo user exists by calling signUpEmail (creates user with proper password hash)
|
||||
* Returns true if successful, false if user already exists (which is fine)
|
||||
*/
|
||||
async function ensureDemoUserExists(auth: DemoAuth): Promise<void> {
|
||||
if (demoUserCreated) return;
|
||||
|
||||
try {
|
||||
// Try to sign up the demo user
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (auth.api as any).signUpEmail({
|
||||
body: {
|
||||
email: DEMO_USER_CREDENTIALS.email,
|
||||
password: DEMO_USER_CREDENTIALS.password,
|
||||
name: DEMO_USER_CREDENTIALS.name
|
||||
}
|
||||
});
|
||||
console.log('[Auth] Demo user created via signUpEmail');
|
||||
demoUserCreated = true;
|
||||
} catch (error) {
|
||||
// User might already exist, which is fine
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('already') || message.includes('exists') || message.includes('unique')) {
|
||||
console.log('[Auth] Demo user already exists');
|
||||
demoUserCreated = true;
|
||||
} else {
|
||||
console.error('[Auth] Failed to create demo user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up and starts the OAuth Authorization Server on a separate port.
|
||||
*
|
||||
* @param options - Server configuration
|
||||
*/
|
||||
export function setupAuthServer(options: SetupAuthServerOptions): void {
|
||||
const { authServerUrl, mcpServerUrl, demoMode } = options;
|
||||
|
||||
// Create better-auth instance with MCP plugin
|
||||
const auth = createDemoAuth({
|
||||
baseURL: authServerUrl.toString().replace(/\/$/, ''),
|
||||
resource: mcpServerUrl.toString(),
|
||||
loginPage: '/sign-in',
|
||||
demoMode: demoMode
|
||||
});
|
||||
|
||||
// Store globally for token verification
|
||||
globalAuth = auth;
|
||||
|
||||
// Create Express app for auth server
|
||||
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();
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
// OAuth metadata endpoints using better-auth's built-in handlers
|
||||
authApp.get('/.well-known/oauth-authorization-server', toNodeHandler(oAuthDiscoveryMetadata(auth)));
|
||||
|
||||
// Body parsers for non-better-auth routes (like /sign-in)
|
||||
authApp.use(express.json());
|
||||
authApp.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Auto-login page that creates a real better-auth session
|
||||
// This simulates a user logging in and approving the OAuth request
|
||||
authApp.get('/sign-in', async (req: Request, res: ExpressResponse) => {
|
||||
// Get the OAuth authorization parameters from the query string
|
||||
const queryParams = new URLSearchParams(req.query as Record<string, string>);
|
||||
const redirectUri = queryParams.get('redirect_uri');
|
||||
const clientId = queryParams.get('client_id');
|
||||
|
||||
if (!redirectUri || !clientId) {
|
||||
res.status(400).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Demo Login</title></head>
|
||||
<body>
|
||||
<h1>Demo OAuth Server</h1>
|
||||
<p>Missing required OAuth parameters. This page should be accessed via OAuth flow.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure demo user exists (creates with proper password hash)
|
||||
await ensureDemoUserExists(auth);
|
||||
|
||||
// Create a session using better-auth's signIn API with asResponse to get Set-Cookie headers
|
||||
const signInResponse = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: DEMO_USER_CREDENTIALS.email,
|
||||
password: DEMO_USER_CREDENTIALS.password
|
||||
},
|
||||
asResponse: true
|
||||
});
|
||||
|
||||
console.log('[Auth] Sign-in response status:', signInResponse.status);
|
||||
|
||||
// Forward all Set-Cookie headers from better-auth's response
|
||||
const setCookieHeaders = signInResponse.headers.getSetCookie();
|
||||
console.log('[Auth] Set-Cookie headers:', setCookieHeaders);
|
||||
|
||||
for (const cookie of setCookieHeaders) {
|
||||
res.append('Set-Cookie', cookie);
|
||||
}
|
||||
|
||||
console.log(`[Auth Server] Session created, redirecting to authorize`);
|
||||
|
||||
// Redirect to the authorization endpoint
|
||||
const authorizeUrl = new URL('/api/auth/mcp/authorize', authServerUrl);
|
||||
authorizeUrl.search = queryParams.toString();
|
||||
|
||||
res.redirect(authorizeUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('[Auth Server] Failed to create session:', error);
|
||||
res.status(500).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Demo Login Error</title></head>
|
||||
<body>
|
||||
<h1>Demo OAuth Server - Error</h1>
|
||||
<p>Failed to create demo session: ${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<pre>${error instanceof Error ? error.stack : ''}</pre>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the auth server
|
||||
const authPort = parseInt(authServerUrl.port, 10);
|
||||
authApp.listen(authPort, (error?: Error) => {
|
||||
if (error) {
|
||||
console.error('Failed to start auth server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`OAuth Authorization Server listening on port ${authPort}`);
|
||||
console.log(` Authorization: ${authServerUrl}api/auth/mcp/authorize`);
|
||||
console.log(` Token: ${authServerUrl}api/auth/mcp/token`);
|
||||
console.log(` Metadata: ${authServerUrl}.well-known/oauth-authorization-server`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Express router that serves OAuth Protected Resource Metadata
|
||||
* on the MCP server using better-auth's built-in handler.
|
||||
*
|
||||
* This is needed because MCP clients discover the auth server by first
|
||||
* fetching protected resource metadata from the MCP server.
|
||||
*
|
||||
* See: https://www.better-auth.com/docs/plugins/mcp#oauth-protected-resource-metadata
|
||||
*/
|
||||
export function createProtectedResourceMetadataRouter(): 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)));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an access token using better-auth's getMcpSession.
|
||||
* This can be used by MCP servers to validate tokens.
|
||||
*/
|
||||
export async function verifyAccessToken(
|
||||
token: string,
|
||||
options?: { strictResource?: boolean; expectedResource?: URL }
|
||||
): Promise<{
|
||||
token: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
expiresAt: number;
|
||||
}> {
|
||||
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
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('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'}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
AuthInfo,
|
||||
AuthorizationParams,
|
||||
OAuthClientInformationFull,
|
||||
OAuthMetadata,
|
||||
OAuthRegisteredClientsStore,
|
||||
OAuthServerProvider,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { createOAuthMetadata, InvalidRequestError, mcpAuthRouter, resourceUrlFromServerUrl } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
|
||||
private clients = new Map<string, OAuthClientInformationFull>();
|
||||
|
||||
async getClient(clientId: string) {
|
||||
return this.clients.get(clientId);
|
||||
}
|
||||
|
||||
async registerClient(clientMetadata: OAuthClientInformationFull) {
|
||||
this.clients.set(clientMetadata.client_id, clientMetadata);
|
||||
return clientMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
|
||||
*
|
||||
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
|
||||
* for example:
|
||||
* - Persistent token storage
|
||||
* - Rate limiting
|
||||
*/
|
||||
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
||||
clientsStore = new DemoInMemoryClientsStore();
|
||||
private codes = new Map<
|
||||
string,
|
||||
{
|
||||
params: AuthorizationParams;
|
||||
client: OAuthClientInformationFull;
|
||||
}
|
||||
>();
|
||||
private tokens = new Map<string, AuthInfo>();
|
||||
|
||||
constructor(private validateResource?: (resource?: URL) => boolean) {}
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
const code = randomUUID();
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
code
|
||||
});
|
||||
if (params.state !== undefined) {
|
||||
searchParams.set('state', params.state);
|
||||
}
|
||||
|
||||
this.codes.set(code, {
|
||||
client,
|
||||
params
|
||||
});
|
||||
|
||||
// Simulate a user login
|
||||
// Set a secure HTTP-only session cookie with authorization info
|
||||
if (res.cookie) {
|
||||
const authCookieData = {
|
||||
userId: 'demo_user',
|
||||
name: 'Demo User',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
res.cookie('demo_session', JSON.stringify(authCookieData), {
|
||||
httpOnly: true,
|
||||
secure: false, // In production, this should be true
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours - for demo purposes
|
||||
path: '/' // Available to all routes
|
||||
});
|
||||
}
|
||||
|
||||
if (!client.redirect_uris.includes(params.redirectUri)) {
|
||||
throw new InvalidRequestError('Unregistered redirect_uri');
|
||||
}
|
||||
const targetUrl = new URL(params.redirectUri);
|
||||
targetUrl.search = searchParams.toString();
|
||||
res.redirect(targetUrl.toString());
|
||||
}
|
||||
|
||||
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
||||
// Store the challenge with the code data
|
||||
const codeData = this.codes.get(authorizationCode);
|
||||
if (!codeData) {
|
||||
throw new Error('Invalid authorization code');
|
||||
}
|
||||
|
||||
return codeData.params.codeChallenge;
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
// Note: code verifier is checked in token.ts by default
|
||||
// it's unused here for that reason.
|
||||
_codeVerifier?: string
|
||||
): Promise<OAuthTokens> {
|
||||
const codeData = this.codes.get(authorizationCode);
|
||||
if (!codeData) {
|
||||
throw new Error('Invalid authorization code');
|
||||
}
|
||||
|
||||
if (codeData.client.client_id !== client.client_id) {
|
||||
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
|
||||
}
|
||||
|
||||
if (this.validateResource && !this.validateResource(codeData.params.resource)) {
|
||||
throw new Error(`Invalid resource: ${codeData.params.resource}`);
|
||||
}
|
||||
|
||||
this.codes.delete(authorizationCode);
|
||||
const token = randomUUID();
|
||||
|
||||
const tokenData = {
|
||||
token,
|
||||
clientId: client.client_id,
|
||||
scopes: codeData.params.scopes || [],
|
||||
expiresAt: Date.now() + 3600000, // 1 hour
|
||||
resource: codeData.params.resource,
|
||||
type: 'access'
|
||||
};
|
||||
|
||||
this.tokens.set(token, tokenData);
|
||||
|
||||
return {
|
||||
access_token: token,
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
scope: (codeData.params.scopes || []).join(' ')
|
||||
};
|
||||
}
|
||||
|
||||
async exchangeRefreshToken(
|
||||
_client: OAuthClientInformationFull,
|
||||
_refreshToken: string,
|
||||
_scopes?: string[],
|
||||
_resource?: URL
|
||||
): Promise<OAuthTokens> {
|
||||
throw new Error('Not implemented for example demo');
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const tokenData = this.tokens.get(token);
|
||||
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId: tokenData.clientId,
|
||||
scopes: tokenData.scopes,
|
||||
expiresAt: Math.floor(tokenData.expiresAt / 1000),
|
||||
resource: tokenData.resource
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const setupAuthServer = ({
|
||||
authServerUrl,
|
||||
mcpServerUrl,
|
||||
strictResource
|
||||
}: {
|
||||
authServerUrl: URL;
|
||||
mcpServerUrl: URL;
|
||||
strictResource: boolean;
|
||||
}): OAuthMetadata => {
|
||||
// Create separate auth server app
|
||||
// NOTE: This is a separate app on a separate port to illustrate
|
||||
// how to separate an OAuth Authorization Server from a Resource
|
||||
// server in the SDK. The SDK is not intended to be provide a standalone
|
||||
// authorization server.
|
||||
|
||||
const validateResource = strictResource
|
||||
? (resource?: URL) => {
|
||||
if (!resource) return false;
|
||||
const expectedResource = resourceUrlFromServerUrl(mcpServerUrl);
|
||||
return resource.toString() === expectedResource.toString();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const provider = new DemoInMemoryAuthProvider(validateResource);
|
||||
const authApp = express();
|
||||
authApp.use(express.json());
|
||||
// For introspection requests
|
||||
authApp.use(express.urlencoded());
|
||||
|
||||
// Add OAuth routes to the auth server
|
||||
// NOTE: this will also add a protected resource metadata route,
|
||||
// but it won't be used, so leave it.
|
||||
authApp.use(
|
||||
mcpAuthRouter({
|
||||
provider,
|
||||
issuerUrl: authServerUrl,
|
||||
scopesSupported: ['mcp:tools']
|
||||
})
|
||||
);
|
||||
|
||||
authApp.post('/introspect', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
if (!token) {
|
||||
res.status(400).json({ error: 'Token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenInfo = await provider.verifyAccessToken(token);
|
||||
res.json({
|
||||
active: true,
|
||||
client_id: tokenInfo.clientId,
|
||||
scope: tokenInfo.scopes.join(' '),
|
||||
exp: tokenInfo.expiresAt,
|
||||
aud: tokenInfo.resource
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
active: false,
|
||||
error: 'Unauthorized',
|
||||
error_description: `Invalid token: ${error}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const auth_port = authServerUrl.port;
|
||||
// Start the auth server
|
||||
authApp.listen(auth_port, error => {
|
||||
if (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
|
||||
});
|
||||
|
||||
// Note: we could fetch this from the server, but then we end up
|
||||
// with some top level async which gets annoying.
|
||||
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
|
||||
provider,
|
||||
issuerUrl: authServerUrl,
|
||||
scopesSupported: ['mcp:tools']
|
||||
});
|
||||
|
||||
oauthMetadata.introspection_endpoint = new URL('/introspect', authServerUrl).href;
|
||||
|
||||
return oauthMetadata;
|
||||
};
|
||||
|
|
@ -1 +1,11 @@
|
|||
export * from './demoInMemoryOAuthProvider.js';
|
||||
// Auth configuration
|
||||
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
|
||||
export type { SetupAuthServerOptions } from './authServer.js';
|
||||
export { createProtectedResourceMetadataRouter, getAuth, setupAuthServer, verifyAccessToken } from './authServer.js';
|
||||
|
|
|
|||
Loading…
Reference in a new issue