From ce53d86bc3cb8f908bc01efe42b50a484fffa4f4 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Thu, 31 Jul 2025 12:46:41 -0700 Subject: [PATCH 01/30] feat: implement optional OAuth 2.1 authentication with modular middleware and configuration --- .cursorrules | 40 +++++++++++++- .env.example | 18 +++++++ CLAUDE.md | 40 +++++++++++++- README.md | 16 ++++++ src/auth/index.ts | 42 +++++++++++++++ src/auth/middleware.ts | 81 ++++++++++++++++++++++++++++ src/auth/oauth-provider.ts | 108 +++++++++++++++++++++++++++++++++++++ src/config.ts | 22 +++++++- src/index.ts | 6 +++ 9 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 src/auth/index.ts create mode 100644 src/auth/middleware.ts create mode 100644 src/auth/oauth-provider.ts diff --git a/.cursorrules b/.cursorrules index d57520a..8efd69f 100644 --- a/.cursorrules +++ b/.cursorrules @@ -36,9 +36,11 @@ This is a TypeScript template for building Model Context Protocol (MCP) servers. - Defines all available MCP tools with their JSON schemas - Routes tool calls to registered tool handlers - Handles error responses in MCP format + - Conditionally applies OAuth middleware based on configuration - **`src/config.ts`** - Environment configuration with validation using Zod - **`src/logger.ts`** - Structured logging with Pino (OpenTelemetry compatible) - **`src/lib/utils.ts`** - Utility functions for MCP response formatting +- **`src/auth/`** - Optional OAuth 2.1 authentication module (can be completely removed) ### Template MCP Tools Available @@ -101,6 +103,16 @@ The following environment variables are supported (see `src/config.ts`): - `SERVER_VERSION` - Server version (default: 1.0.0) - `LOG_LEVEL` - Logging level (error/warn/info/debug, default: info) +### OAuth Configuration (Optional) + +- `ENABLE_AUTH` - Enable OAuth authentication (default: false) +- `OAUTH_CLIENT_ID` - OAuth client ID (required if auth enabled) +- `OAUTH_CLIENT_SECRET` - OAuth client secret (required if auth enabled) +- `OAUTH_AUTH_ENDPOINT` - OAuth authorization endpoint (required if auth enabled) +- `OAUTH_TOKEN_ENDPOINT` - OAuth token endpoint (required if auth enabled) +- `OAUTH_SCOPE` - OAuth scope (default: "read") +- `OAUTH_REDIRECT_URI` - OAuth redirect URI (required if auth enabled) + ## Coding Guidelines - Follow existing patterns in the codebase @@ -119,4 +131,30 @@ The following environment variables are supported (see `src/config.ts`): - Include relevant context in log messages (user IDs, session IDs, etc.) - Log structured data as the second parameter: `logger.info("message", { key: value })` - Error logs should include error details: `logger.error("Error message", { error: error.message })` -- The logger automatically includes trace correlation when OpenTelemetry is configured \ No newline at end of file +- The logger automatically includes trace correlation when OpenTelemetry is configured + +## OAuth Implementation + +### Modular Authentication + +The template includes optional OAuth 2.1 authentication that can be easily enabled or completely removed: + +- **Modular Design**: All OAuth code is in `src/auth/` directory +- **Conditional Loading**: OAuth middleware only applies when `ENABLE_AUTH=true` +- **Zero Impact When Disabled**: No performance overhead when authentication is disabled +- **Easy Removal**: Delete `src/auth/` directory and remove auth import from `src/index.ts` + +### Authentication Patterns + +1. **External OAuth (Recommended)**: Use Pomerium or similar OAuth proxy +2. **Built-in OAuth Server**: Use the provided OAuth implementation in `src/auth/` + +### Removing OAuth + +To completely remove OAuth support: + +1. Delete the `src/auth/` directory +2. Remove the auth import and middleware lines from `src/index.ts` +3. Remove OAuth environment variables from `src/config.ts` + +The core MCP server functionality is completely independent of the authentication layer. \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26f9dd4 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Server Configuration +PORT=3000 +NODE_ENV=development +SERVER_NAME=mcp-typescript-template +SERVER_VERSION=1.0.0 +LOG_LEVEL=info + +# OAuth Configuration (Optional) +# Set ENABLE_AUTH=true to enable OAuth authentication +ENABLE_AUTH=false + +# Required if ENABLE_AUTH=true +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +OAUTH_AUTH_ENDPOINT=https://auth.example.com/oauth/authorize +OAUTH_TOKEN_ENDPOINT=https://auth.example.com/oauth/token +OAUTH_SCOPE=read +OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a6e04e9..800dcd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,9 +32,11 @@ This is a TypeScript template for building Model Context Protocol (MCP) servers. - Defines all available MCP tools with their JSON schemas - Routes tool calls to registered tool handlers - Handles error responses in MCP format + - Conditionally applies OAuth middleware based on configuration - **`src/config.ts`** - Environment configuration with validation using Zod - **`src/logger.ts`** - Structured logging with Pino (OpenTelemetry compatible) - **`src/lib/utils.ts`** - Utility functions for MCP response formatting +- **`src/auth/`** - Optional OAuth 2.1 authentication module (can be completely removed) ### Template MCP Tools Available @@ -89,6 +91,16 @@ The following environment variables are supported (see `src/config.ts`): - `SERVER_VERSION` - Server version (default: 1.0.0) - `LOG_LEVEL` - Logging level (error/warn/info/debug, default: info) +### OAuth Configuration (Optional) + +- `ENABLE_AUTH` - Enable OAuth authentication (default: false) +- `OAUTH_CLIENT_ID` - OAuth client ID (required if auth enabled) +- `OAUTH_CLIENT_SECRET` - OAuth client secret (required if auth enabled) +- `OAUTH_AUTH_ENDPOINT` - OAuth authorization endpoint (required if auth enabled) +- `OAUTH_TOKEN_ENDPOINT` - OAuth token endpoint (required if auth enabled) +- `OAUTH_SCOPE` - OAuth scope (default: "read") +- `OAUTH_REDIRECT_URI` - OAuth redirect URI (required if auth enabled) + ## Logging Best Practices - Use appropriate log levels: `error`, `warn`, `info`, `debug` @@ -108,4 +120,30 @@ When adding new tools to the MCP server: 4. Return responses in MCP content format with JSON stringified data 5. Handle errors gracefully and return appropriate error messages 6. Use structured logging to track tool usage: `logger.info("Tool executed", { toolName, args })` -7. Log errors with context: `logger.error("Tool execution failed", { toolName, error: error.message })` \ No newline at end of file +7. Log errors with context: `logger.error("Tool execution failed", { toolName, error: error.message })` + +## OAuth Implementation + +### Modular Authentication + +The template includes optional OAuth 2.1 authentication that can be easily enabled or completely removed: + +- **Modular Design**: All OAuth code is in `src/auth/` directory +- **Conditional Loading**: OAuth middleware only applies when `ENABLE_AUTH=true` +- **Zero Impact When Disabled**: No performance overhead when authentication is disabled +- **Easy Removal**: Delete `src/auth/` directory and remove auth import from `src/index.ts` + +### Authentication Patterns + +1. **External OAuth (Recommended)**: Use Pomerium or similar OAuth proxy +2. **Built-in OAuth Server**: Use the provided OAuth implementation in `src/auth/` + +### Removing OAuth + +To completely remove OAuth support: + +1. Delete the `src/auth/` directory +2. Remove the auth import and middleware lines from `src/index.ts` +3. Remove OAuth environment variables from `src/config.ts` + +The core MCP server functionality is completely independent of the authentication layer. \ No newline at end of file diff --git a/README.md b/README.md index 89d2643..db70713 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This template provides: - **ESLint + Prettier** - Code quality and formatting - **Docker** - Containerization support - **Example Tool** - Simple echo tool to demonstrate MCP tool implementation +- **OAuth 2.1 Compatible** - Optional OAuth implementation (can use Pomerium for external auth or built-in server implementation) ## Getting Started @@ -184,6 +185,21 @@ server.registerTool( ); ``` +## Authentication & Authorization + +### OAuth 2.1 Support + +This template supports **optional** OAuth 2.1 authentication with two approaches: + +1. **External OAuth (Recommended)** - Use Pomerium or similar OAuth proxy to handle authentication externally +2. **Built-in OAuth Server** - Implement OAuth directly in the MCP server using the provided modular implementation + +The OAuth implementation is designed to be easily added or removed without affecting core server functionality. + +### Enabling OAuth + +To enable OAuth authentication, see the `src/auth/` directory for a modular OAuth implementation that can be toggled via environment variables. + ## Why Express? This template uses Express for the HTTP server, which provides: diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..9eca0da --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,42 @@ +import { OAuthProvider, OAuthConfig } from "./oauth-provider.js"; +import { createOAuthMiddleware, createOptionalOAuthMiddleware } from "./middleware.js"; +import { getConfig } from "../config.js"; + +export { OAuthProvider, type OAuthConfig, type AuthenticatedRequest } from "./oauth-provider.js"; +export { createOAuthMiddleware, createOptionalOAuthMiddleware } from "./middleware.js"; + +/** + * Initialize OAuth provider if authentication is enabled + */ +export function initializeAuth() { + const config = getConfig(); + + if (!config.ENABLE_AUTH) { + return null; + } + + const oauthConfig: OAuthConfig = { + clientId: config.OAUTH_CLIENT_ID!, + clientSecret: config.OAUTH_CLIENT_SECRET!, + authorizationEndpoint: config.OAUTH_AUTH_ENDPOINT!, + tokenEndpoint: config.OAUTH_TOKEN_ENDPOINT!, + scope: config.OAUTH_SCOPE || "read", + redirectUri: config.OAUTH_REDIRECT_URI!, + }; + + return new OAuthProvider(oauthConfig); +} + +/** + * Create authentication middleware based on configuration + */ +export function createAuthMiddleware() { + const oauthProvider = initializeAuth(); + + if (!oauthProvider) { + // Return pass-through middleware when auth is disabled + return (_req: any, _res: any, next: any) => next(); + } + + return createOAuthMiddleware(oauthProvider); +} \ No newline at end of file diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts new file mode 100644 index 0000000..cb1bc20 --- /dev/null +++ b/src/auth/middleware.ts @@ -0,0 +1,81 @@ +import { Request, Response, NextFunction } from "express"; +import { OAuthProvider } from "./oauth-provider.js"; +import { logger } from "../logger.js"; + +export interface AuthenticatedRequest extends Request { + userId?: string; + accessToken?: string; +} + +/** + * Create OAuth middleware that can be easily added/removed + */ +export function createOAuthMiddleware(oauthProvider: OAuthProvider) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ + error: "unauthorized", + error_description: "Missing or invalid authorization header", + }); + } + + const token = authHeader.substring(7); // Remove "Bearer " prefix + + try { + const validation = await oauthProvider.validateToken(token); + + if (!validation.valid) { + return res.status(401).json({ + error: "invalid_token", + error_description: "The access token is invalid or expired", + }); + } + + // Add user context to request + req.userId = validation.userId; + req.accessToken = token; + + logger.info("Request authenticated", { userId: validation.userId }); + next(); + } catch (error) { + logger.error("Authentication middleware error", { error: error.message }); + return res.status(500).json({ + error: "server_error", + error_description: "Internal server error during authentication", + }); + } + }; +} + +/** + * Optional middleware that only authenticates if token is present + */ +export function createOptionalOAuthMiddleware(oauthProvider: OAuthProvider) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + // No auth header, continue without authentication + return next(); + } + + const token = authHeader.substring(7); + + try { + const validation = await oauthProvider.validateToken(token); + + if (validation.valid) { + req.userId = validation.userId; + req.accessToken = token; + logger.info("Request authenticated", { userId: validation.userId }); + } + + next(); + } catch (error) { + logger.warn("Optional authentication failed", { error: error.message }); + next(); // Continue without authentication + } + }; +} \ No newline at end of file diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts new file mode 100644 index 0000000..952fba5 --- /dev/null +++ b/src/auth/oauth-provider.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { logger } from "../logger.js"; + +export interface OAuthConfig { + clientId: string; + clientSecret: string; + authorizationEndpoint: string; + tokenEndpoint: string; + scope: string; + redirectUri: string; +} + +export interface AccessToken { + token: string; + expiresAt: Date; + userId?: string; +} + +export class OAuthProvider { + constructor(private config: OAuthConfig) {} + + /** + * Generate authorization URL for OAuth flow + */ + getAuthorizationUrl(state: string): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + scope: this.config.scope, + state, + }); + + return `${this.config.authorizationEndpoint}?${params.toString()}`; + } + + /** + * Exchange authorization code for access token + */ + async exchangeCodeForToken(code: string): Promise { + try { + const response = await fetch(this.config.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}` + ).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.config.redirectUri, + }), + }); + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.statusText}`); + } + + const tokenData = await response.json(); + + return { + token: tokenData.access_token, + expiresAt: new Date(Date.now() + tokenData.expires_in * 1000), + userId: tokenData.sub, + }; + } catch (error) { + logger.error("OAuth token exchange failed", { error: error.message }); + throw error; + } + } + + /** + * Validate access token + */ + async validateToken(token: string): Promise<{ valid: boolean; userId?: string }> { + try { + // In a real implementation, you would validate against your OAuth provider + // This is a simplified example + const response = await fetch(`${this.config.tokenEndpoint}/introspect`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}` + ).toString("base64")}`, + }, + body: new URLSearchParams({ + token, + }), + }); + + if (!response.ok) { + return { valid: false }; + } + + const introspection = await response.json(); + return { + valid: introspection.active === true, + userId: introspection.sub, + }; + } catch (error) { + logger.error("Token validation failed", { error: error.message }); + return { valid: false }; + } + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 61844f9..26452d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,15 @@ const configSchema = z.object({ SERVER_NAME: z.string().default("mcp-typescript-template"), SERVER_VERSION: z.string().default("1.0.0"), LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), + + // OAuth Configuration (optional) + ENABLE_AUTH: z.coerce.boolean().default(false), + OAUTH_CLIENT_ID: z.string().optional(), + OAUTH_CLIENT_SECRET: z.string().optional(), + OAUTH_AUTH_ENDPOINT: z.string().optional(), + OAUTH_TOKEN_ENDPOINT: z.string().optional(), + OAUTH_SCOPE: z.string().default("read"), + OAUTH_REDIRECT_URI: z.string().optional(), }); export type Config = z.infer; @@ -15,7 +24,18 @@ let config: Config; export function getConfig(): Config { if (!config) { try { - config = configSchema.parse(process.env); + const parsed = configSchema.parse(process.env); + + // Validate OAuth configuration if auth is enabled + if (parsed.ENABLE_AUTH) { + if (!parsed.OAUTH_CLIENT_ID || !parsed.OAUTH_CLIENT_SECRET || + !parsed.OAUTH_AUTH_ENDPOINT || !parsed.OAUTH_TOKEN_ENDPOINT || + !parsed.OAUTH_REDIRECT_URI) { + throw new Error("OAuth is enabled but missing required OAuth environment variables"); + } + } + + config = parsed; } catch (error) { console.error("❌ Invalid environment configuration:", error); process.exit(1); diff --git a/src/index.ts b/src/index.ts index 06c5204..bbfcf4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { createTextResult } from "./lib/utils.ts"; import { logger } from "./logger.ts"; import { getConfig } from "./config.ts"; +import { createAuthMiddleware } from "./auth/index.ts"; const getServer = () => { const config = getConfig(); @@ -36,6 +37,11 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Apply OAuth middleware conditionally +const config = getConfig(); +const authMiddleware = config.ENABLE_AUTH ? createAuthMiddleware() : undefined; +app.use("/mcp", authMiddleware); + const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const mcpHandler = async (req: express.Request, res: express.Response) => { From 85c050b2eee3b42b2e2bfc4a6034cd48c5d9f394 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Fri, 1 Aug 2025 17:35:38 -0700 Subject: [PATCH 02/30] feat(auth): Implement OAuth 2.1 support with gateway and built-in modes - Added environment configuration for authentication options in .env.example and config.ts. - Introduced GatewayTokenValidator and BuiltinTokenValidator for token validation. - Created OAuthProvider class to handle authorization code flow and token issuance. - Implemented OAuth routes: /authorize, /callback, /token, /introspect, and /revoke. - Added discovery endpoints for OAuth 2.1 compliance. - Updated README.md with detailed authentication setup instructions. - Modified index.ts to conditionally apply authentication middleware based on configuration. - Enhanced error handling and logging throughout the authentication process. --- .env.example | 62 +++++++++- README.md | 157 ++++++++++++++++++++++++-- package.json | 2 +- src/auth/discovery.ts | 204 +++++++++++++++++++++++++++++++++ src/auth/index.ts | 63 +++++++---- src/auth/middleware.ts | 65 ++++------- src/auth/oauth-provider.ts | 219 ++++++++++++++++++++++++------------ src/auth/routes.ts | 162 ++++++++++++++++++++++++++ src/auth/token-validator.ts | 154 +++++++++++++++++++++++++ src/config.ts | 59 ++++++++-- src/index.ts | 43 +++++-- 11 files changed, 1015 insertions(+), 175 deletions(-) create mode 100644 src/auth/discovery.ts create mode 100644 src/auth/routes.ts create mode 100644 src/auth/token-validator.ts diff --git a/.env.example b/.env.example index 26f9dd4..44a5c09 100644 --- a/.env.example +++ b/.env.example @@ -5,14 +5,64 @@ SERVER_NAME=mcp-typescript-template SERVER_VERSION=1.0.0 LOG_LEVEL=info -# OAuth Configuration (Optional) -# Set ENABLE_AUTH=true to enable OAuth authentication +# Authentication Configuration (Optional) +# Set ENABLE_AUTH=true to enable authentication ENABLE_AUTH=false -# Required if ENABLE_AUTH=true +# Authentication mode: "gateway" (recommended) or "builtin" (testing/demos) +AUTH_MODE=gateway + +# ============================================================================ +# Gateway Mode Configuration (Recommended for Production) +# ============================================================================ +# Use when authentication is handled by a reverse proxy/gateway (Pomerium, etc.) +# The MCP server acts as a resource server and only validates tokens + +# OAuth issuer URL for token validation (required for gateway mode) +# Examples: +# Auth0: https://your-domain.auth0.com +# Okta: https://your-domain.okta.com +# Google: https://accounts.google.com +OAUTH_ISSUER=https://your-domain.auth0.com + +# Expected audience in JWT tokens (optional) +# If set, tokens must have this audience claim +OAUTH_AUDIENCE=your-api-identifier + +# ============================================================================ +# Built-in Mode Configuration (Testing/Demos Only) +# ============================================================================ +# Use when you want the MCP server to handle the OAuth flow directly +# Not recommended for production - use gateway mode instead + +# OAuth client credentials (required for built-in mode) OAUTH_CLIENT_ID=your-client-id OAUTH_CLIENT_SECRET=your-client-secret -OAUTH_AUTH_ENDPOINT=https://auth.example.com/oauth/authorize -OAUTH_TOKEN_ENDPOINT=https://auth.example.com/oauth/token + +# OAuth provider endpoints (required for built-in mode) +OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize +OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token + +# OAuth configuration OAUTH_SCOPE=read -OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback \ No newline at end of file +OAUTH_REDIRECT_URI=http://localhost:3000/callback + +# ============================================================================ +# Example Configurations +# ============================================================================ + +# Example: Auth0 Gateway Mode +# ENABLE_AUTH=true +# AUTH_MODE=gateway +# OAUTH_ISSUER=https://your-domain.auth0.com +# OAUTH_AUDIENCE=your-api-identifier + +# Example: Auth0 Built-in Mode +# ENABLE_AUTH=true +# AUTH_MODE=builtin +# OAUTH_CLIENT_ID=your-auth0-client-id +# OAUTH_CLIENT_SECRET=your-auth0-client-secret +# OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize +# OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token +# OAUTH_SCOPE=read +# OAUTH_REDIRECT_URI=http://localhost:3000/callback \ No newline at end of file diff --git a/README.md b/README.md index db70713..29bfc24 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,19 @@ docker-compose up --build ``` mcp-typescript-template/ ├── src/ +│ ├── auth/ # Optional OAuth authentication module +│ │ ├── index.ts # Auth initialization and middleware factory +│ │ ├── middleware.ts # Authentication middleware +│ │ ├── oauth-provider.ts # OAuth client implementation +│ │ ├── routes.ts # OAuth routes (/authorize, /callback) +│ │ └── token-validator.ts # Token validation (gateway/builtin) +│ ├── lib/ +│ │ └── utils.ts # MCP utility functions +│ ├── config.ts # Environment configuration with validation +│ ├── logger.ts # Structured logging with Pino │ └── index.ts # Main MCP server entry point ├── dist/ # Built output (generated) +├── .env.example # Environment variables template ├── .eslintrc.js # ESLint configuration ├── .prettierrc # Prettier configuration ├── tsconfig.json # TypeScript configuration @@ -187,18 +198,150 @@ server.registerTool( ## Authentication & Authorization -### OAuth 2.1 Support +This template provides **optional** OAuth 2.1 authentication with two deployment patterns: -This template supports **optional** OAuth 2.1 authentication with two approaches: +### 🔧 Authentication Modes -1. **External OAuth (Recommended)** - Use Pomerium or similar OAuth proxy to handle authentication externally -2. **Built-in OAuth Server** - Implement OAuth directly in the MCP server using the provided modular implementation +#### Gateway Mode (Enterprise/Multi-Service) +- **Resource Server Pattern**: MCP server only validates tokens from external OAuth providers +- **External OAuth**: Authentication handled by reverse proxy/gateway (Pomerium, nginx, API Gateway) +- **JWT + Introspection**: Supports both JWT validation and token introspection +- **Stateless**: No OAuth routes, sessions, or cookies in MCP server +- **Scalable**: Easy to horizontally scale the MCP server +- **Best for**: Organizations with existing OAuth infrastructure -The OAuth implementation is designed to be easily added or removed without affecting core server functionality. +#### Built-in Mode (Standalone/Simple Deployment) +- **OAuth 2.1 Authorization Server**: MCP server IS the complete OAuth authorization server +- **PKCE Support**: Full PKCE implementation as required by MCP specification +- **MCP Client Compatible**: Works seamlessly with VS Code and other MCP clients +- **Self-contained**: No external OAuth provider needed +- **Discovery Endpoints**: Provides OAuth 2.1 discovery metadata for automatic client configuration +- **Best for**: Solo developers, small teams, or simple deployments wanting OAuth security -### Enabling OAuth +#### No Auth Mode (Default) +- **Completely Optional**: Authentication can be disabled entirely +- **Simple Setup**: Just set `ENABLE_AUTH=false` or omit auth configuration +- **Open Access**: MCP server accepts all requests without authentication -To enable OAuth authentication, see the `src/auth/` directory for a modular OAuth implementation that can be toggled via environment variables. +### 🚀 Quick Setup + +#### 1. Gateway Mode (Enterprise) +```bash +# .env +ENABLE_AUTH=true +AUTH_MODE=gateway +OAUTH_ISSUER=https://your-domain.auth0.com +OAUTH_AUDIENCE=your-api-identifier # optional +``` + +**Setup your gateway (e.g., Pomerium):** +```yaml +# pomerium-config.yaml +routes: + - from: https://mcp.yourdomain.com + to: http://localhost:3000 + policies: + - allow: + and: + - authenticated_user: true +``` + +#### 2. Built-in Mode (Standalone) +```bash +# .env +ENABLE_AUTH=true +AUTH_MODE=builtin +# No external OAuth configuration needed - server acts as OAuth provider +``` + +**OAuth 2.1 Endpoints (automatically available):** +- `GET /.well-known/oauth-authorization-server` - OAuth server metadata +- `GET /.well-known/oauth-protected-resource` - Resource server metadata +- `GET /authorize` - OAuth authorization endpoint (with PKCE) +- `POST /token` - Token exchange endpoint +- `POST /introspect` - Token introspection endpoint +- `POST /revoke` - Token revocation endpoint + +#### 3. No Auth Mode (Default) +```bash +# .env (or just omit ENABLE_AUTH) +ENABLE_AUTH=false +``` +Server accepts all requests without authentication. + +### 🔐 Token Validation + +Both modes validate tokens using the **resource server pattern**: + +```bash +# Make authenticated requests +curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + http://localhost:3000/mcp +``` + +### 🏗️ Architecture + +``` +┌─────── Gateway Mode (Enterprise) ───────┐ ┌────── Built-in Mode (Standalone) ───┐ ┌── No Auth (Default) ──┐ +│ │ │ │ │ │ +│ MCP Client → Gateway → MCP Server │ │ MCP Client → MCP Server │ │ MCP Client │ +│ ↓ │ │ ↓ │ │ ↓ │ +│ External OAuth (Auth0) │ │ Built-in OAuth Server │ │ MCP Server │ +│ │ │ │ │ (Open Access) │ +│ ✅ Enterprise ready │ │ ✅ Production ready │ │ ✅ Simple setup │ +│ ✅ Stateless & scalable │ │ ✅ OAuth 2.1 compliant │ │ ✅ No auth overhead │ +│ ✅ Security best practices │ │ ✅ PKCE + Discovery │ │ ✅ Perfect for dev │ +│ ✅ JWT + Token introspection │ │ ✅ Works with VS Code │ │ │ +└─────────────────────────────────────────┘ └─────────────────────────────────────┘ └───────────────────────┘ +``` + +### 🔧 Provider Examples + +
+Auth0 Configuration + +**Gateway Mode:** +```bash +OAUTH_ISSUER=https://your-domain.auth0.com +OAUTH_AUDIENCE=your-api-identifier +``` + +**Built-in Mode:** +```bash +OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize +OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +``` +
+ +
+Google OAuth Configuration + +**Gateway Mode:** +```bash +OAUTH_ISSUER=https://accounts.google.com +OAUTH_AUDIENCE=your-client-id.apps.googleusercontent.com +``` + +**Built-in Mode:** +```bash +OAUTH_AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth +OAUTH_TOKEN_ENDPOINT=https://oauth2.googleapis.com/token +OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com +OAUTH_CLIENT_SECRET=your-client-secret +``` +
+ +### 🛠️ Customization + +The auth implementation is modular and can be easily: +- Disabled completely (set `ENABLE_AUTH=false`) +- Removed entirely (delete `src/auth/` directory) +- Extended with custom validation logic +- Integrated with other OAuth providers + +See `src/auth/` for implementation details. ## Why Express? diff --git a/package.json b/package.json index dacb57e..9b136b3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "vite build", - "dev": "node --watch src/index.ts", + "dev": "node --env-file=.env --experimental-strip-types --watch src/index.ts", "start": "node dist/index.js", "test": "vitest", "test:ci": "vitest run --reporter=json --outputFile=test-results.json", diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts new file mode 100644 index 0000000..94617ef --- /dev/null +++ b/src/auth/discovery.ts @@ -0,0 +1,204 @@ +import type { Request, Response } from "express"; +import { getConfig } from "../config.ts"; +import { logger } from "../logger.ts"; + +/** + * OAuth 2.0 Authorization Server Metadata endpoint + * RFC 8414: https://tools.ietf.org/html/rfc8414 + */ +export function createAuthorizationServerMetadataHandler() { + return (req: Request, res: Response) => { + try { + const config = getConfig(); + const baseUrl = `${req.protocol}://${req.get('host')}`; + + const metadata = { + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + scopes_supported: ["read", "write"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + revocation_endpoint: `${baseUrl}/revoke`, + introspection_endpoint: `${baseUrl}/introspect`, + }; + + logger.info("OAuth authorization server metadata requested", { + issuer: metadata.issuer + }); + + res.json(metadata); + } catch (error) { + logger.error("Error serving authorization server metadata", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to serve authorization server metadata" + }); + } + }; +} + +/** + * OAuth 2.0 Protected Resource Metadata endpoint + * RFC 8705: https://tools.ietf.org/html/rfc8705 + */ +export function createProtectedResourceMetadataHandler() { + return (req: Request, res: Response) => { + try { + const config = getConfig(); + const baseUrl = `${req.protocol}://${req.get('host')}`; + + const metadata = { + resource: baseUrl, + authorization_servers: [baseUrl], + scopes_supported: ["read", "write"], + bearer_methods_supported: ["header"], + resource_documentation: `${baseUrl}/docs`, + }; + + logger.info("OAuth protected resource metadata requested", { + resource: metadata.resource + }); + + res.json(metadata); + } catch (error) { + logger.error("Error serving protected resource metadata", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to serve protected resource metadata" + }); + } + }; +} + +/** + * OAuth 2.1 token endpoint with PKCE support + */ +export function createTokenHandler(oauthProvider: any) { + return async (req: Request, res: Response) => { + try { + const { grant_type, code, redirect_uri, code_verifier, client_id } = req.body; + + if (grant_type !== "authorization_code") { + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: "Only authorization_code grant type is supported" + }); + } + + if (!code || !redirect_uri || !code_verifier || !client_id) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters: code, redirect_uri, code_verifier, client_id" + }); + } + const tokenResult = await oauthProvider.exchangeAuthorizationCode( + code, + code_verifier, + client_id, + redirect_uri + ); + + if (!tokenResult) { + return res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid authorization code or PKCE verification failed" + }); + } + + logger.info("Token exchange successful", { client_id, scope: tokenResult.scope }); + + res.json({ + access_token: tokenResult.accessToken, + token_type: "Bearer", + expires_in: tokenResult.expiresIn, + scope: tokenResult.scope + }); + + } catch (error) { + logger.error("Token endpoint error", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to process token request" + }); + } + }; +} + +/** + * Token introspection endpoint + */ +export function createIntrospectionHandler() { + return async (req: Request, res: Response) => { + try { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing token parameter" + }); + } + + // TODO: Implement actual token introspection + // For now, return active=true for any token + logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); + + res.json({ + active: true, + scope: "read", + client_id: "mcp-client", + exp: Math.floor(Date.now() / 1000) + 3600 + }); + + } catch (error) { + logger.error("Token introspection error", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to introspect token" + }); + } + }; +} + +/** + * Token revocation endpoint + */ +export function createRevocationHandler() { + return async (req: Request, res: Response) => { + try { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing token parameter" + }); + } + + // TODO: Implement actual token revocation + logger.info("Token revocation requested", { token: token.substring(0, 10) + "..." }); + + res.status(200).send(); // Success response + + } catch (error) { + logger.error("Token revocation error", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to revoke token" + }); + } + }; +} \ No newline at end of file diff --git a/src/auth/index.ts b/src/auth/index.ts index 9eca0da..02a642f 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,42 +1,57 @@ -import { OAuthProvider, OAuthConfig } from "./oauth-provider.js"; -import { createOAuthMiddleware, createOptionalOAuthMiddleware } from "./middleware.js"; -import { getConfig } from "../config.js"; - -export { OAuthProvider, type OAuthConfig, type AuthenticatedRequest } from "./oauth-provider.js"; -export { createOAuthMiddleware, createOptionalOAuthMiddleware } from "./middleware.js"; +import { OAuthProvider, type OAuthConfig } from "./oauth-provider.ts"; +import { GatewayTokenValidator } from "./token-validator.ts"; +import { createAuthMiddleware } from "./middleware.ts"; +import { getConfig } from "../config.ts"; +import { logger } from "../logger.ts"; /** - * Initialize OAuth provider if authentication is enabled + * Initialize authentication based on mode */ export function initializeAuth() { const config = getConfig(); - + if (!config.ENABLE_AUTH) { - return null; + logger.info("Authentication is disabled"); + return { tokenValidator: null, oauthProvider: null }; + } + + if (config.AUTH_MODE === "gateway") { + logger.info("Initializing gateway auth mode (resource server)"); + const tokenValidator = new GatewayTokenValidator( + config.OAUTH_ISSUER!, + config.OAUTH_AUDIENCE + ); + return { tokenValidator, oauthProvider: null }; } - const oauthConfig: OAuthConfig = { - clientId: config.OAUTH_CLIENT_ID!, - clientSecret: config.OAUTH_CLIENT_SECRET!, - authorizationEndpoint: config.OAUTH_AUTH_ENDPOINT!, - tokenEndpoint: config.OAUTH_TOKEN_ENDPOINT!, - scope: config.OAUTH_SCOPE || "read", - redirectUri: config.OAUTH_REDIRECT_URI!, - }; + if (config.AUTH_MODE === "builtin") { + logger.info("Initializing built-in auth mode (OAuth client + resource server)"); + const oauthConfig: OAuthConfig = { + clientId: config.OAUTH_CLIENT_ID!, + clientSecret: config.OAUTH_CLIENT_SECRET!, + authorizationEndpoint: config.OAUTH_AUTH_ENDPOINT!, + tokenEndpoint: config.OAUTH_TOKEN_ENDPOINT!, + scope: config.OAUTH_SCOPE || "read", + redirectUri: config.OAUTH_REDIRECT_URI!, + }; - return new OAuthProvider(oauthConfig); + const oauthProvider = new OAuthProvider(oauthConfig); + return { tokenValidator: oauthProvider.tokenValidator, oauthProvider }; + } + + throw new Error(`Unknown auth mode: ${config.AUTH_MODE}`); } /** * Create authentication middleware based on configuration */ -export function createAuthMiddleware() { - const oauthProvider = initializeAuth(); - - if (!oauthProvider) { +export function createAuthenticationMiddleware() { + const { tokenValidator } = initializeAuth(); + + if (!tokenValidator) { // Return pass-through middleware when auth is disabled return (_req: any, _res: any, next: any) => next(); } - return createOAuthMiddleware(oauthProvider); -} \ No newline at end of file + return createAuthMiddleware(tokenValidator); +} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index cb1bc20..d55c439 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,19 +1,25 @@ -import { Request, Response, NextFunction } from "express"; -import { OAuthProvider } from "./oauth-provider.js"; -import { logger } from "../logger.js"; +import type { Request, Response, NextFunction } from "express"; +import { GatewayTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; +import { logger } from "../logger.ts"; export interface AuthenticatedRequest extends Request { userId?: string; accessToken?: string; } +type TokenValidator = GatewayTokenValidator | BuiltinTokenValidator; + /** - * Create OAuth middleware that can be easily added/removed + * Create authentication middleware that supports both gateway and built-in modes */ -export function createOAuthMiddleware(oauthProvider: OAuthProvider) { - return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { +export function createAuthMiddleware(tokenValidator: TokenValidator) { + return async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, + ) => { const authHeader = req.headers.authorization; - + if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.status(401).json({ error: "unauthorized", @@ -22,25 +28,27 @@ export function createOAuthMiddleware(oauthProvider: OAuthProvider) { } const token = authHeader.substring(7); // Remove "Bearer " prefix - + try { - const validation = await oauthProvider.validateToken(token); - + const validation = await tokenValidator.validateToken(token); + if (!validation.valid) { return res.status(401).json({ error: "invalid_token", - error_description: "The access token is invalid or expired", + error_description: validation.error || "The access token is invalid or expired", }); } // Add user context to request req.userId = validation.userId; req.accessToken = token; - + logger.info("Request authenticated", { userId: validation.userId }); next(); } catch (error) { - logger.error("Authentication middleware error", { error: error.message }); + logger.error("Authentication middleware error", { + error: error instanceof Error ? error.message : error + }); return res.status(500).json({ error: "server_error", error_description: "Internal server error during authentication", @@ -48,34 +56,3 @@ export function createOAuthMiddleware(oauthProvider: OAuthProvider) { } }; } - -/** - * Optional middleware that only authenticates if token is present - */ -export function createOptionalOAuthMiddleware(oauthProvider: OAuthProvider) { - return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - // No auth header, continue without authentication - return next(); - } - - const token = authHeader.substring(7); - - try { - const validation = await oauthProvider.validateToken(token); - - if (validation.valid) { - req.userId = validation.userId; - req.accessToken = token; - logger.info("Request authenticated", { userId: validation.userId }); - } - - next(); - } catch (error) { - logger.warn("Optional authentication failed", { error: error.message }); - next(); // Continue without authentication - } - }; -} \ No newline at end of file diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index 952fba5..0496e43 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -1,5 +1,6 @@ -import { z } from "zod"; -import { logger } from "../logger.js"; +import { randomBytes, createHash } from "node:crypto"; +import { logger } from "../logger.ts"; +import { GatewayTokenValidator } from "./token-validator.ts"; export interface OAuthConfig { clientId: string; @@ -16,93 +17,167 @@ export interface AccessToken { userId?: string; } +interface AuthorizationCodeData { + clientId: string; + redirectUri: string; + scope: string; + codeChallenge: string; + codeChallengeMethod: string; + expiresAt: Date; +} + +/** + * OAuth authorization server for built-in auth mode + * Acts as a full OAuth 2.1 authorization server with PKCE support + */ export class OAuthProvider { - constructor(private config: OAuthConfig) {} + #config: OAuthConfig; + #tokenValidator: GatewayTokenValidator; + + // In-memory stores (use database in production) + #authorizationCodes = new Map(); + #accessTokens = new Map(); + + constructor(config: OAuthConfig) { + this.#config = config; + + // For built-in mode, we ARE the issuer + const issuer = "http://localhost:3000"; // This should be dynamic based on server config + this.#tokenValidator = new GatewayTokenValidator(issuer); + + // Clean up expired codes and tokens periodically + setInterval(() => this.cleanup(), 60 * 1000); // Every minute + } + + get tokenValidator() { + return this.#tokenValidator; + } /** - * Generate authorization URL for OAuth flow + * Store authorization code with PKCE data */ - getAuthorizationUrl(state: string): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: this.config.clientId, - redirect_uri: this.config.redirectUri, - scope: this.config.scope, - state, - }); - - return `${this.config.authorizationEndpoint}?${params.toString()}`; + storeAuthorizationCode(code: string, data: AuthorizationCodeData): void { + this.#authorizationCodes.set(code, data); } /** - * Exchange authorization code for access token + * Exchange authorization code for access token with PKCE verification */ - async exchangeCodeForToken(code: string): Promise { - try { - const response = await fetch(this.config.tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${Buffer.from( - `${this.config.clientId}:${this.config.clientSecret}` - ).toString("base64")}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: this.config.redirectUri, - }), - }); + async exchangeAuthorizationCode( + code: string, + codeVerifier: string, + clientId: string, + redirectUri: string + ): Promise<{ accessToken: string; expiresIn: number; scope: string } | null> { + + const codeData = this.#authorizationCodes.get(code); + if (!codeData) { + logger.warn("Invalid authorization code", { code: code.substring(0, 8) + "..." }); + return null; + } - if (!response.ok) { - throw new Error(`Token exchange failed: ${response.statusText}`); - } + // Check expiration + if (codeData.expiresAt < new Date()) { + this.#authorizationCodes.delete(code); + logger.warn("Expired authorization code", { code: code.substring(0, 8) + "..." }); + return null; + } - const tokenData = await response.json(); - - return { - token: tokenData.access_token, - expiresAt: new Date(Date.now() + tokenData.expires_in * 1000), - userId: tokenData.sub, - }; - } catch (error) { - logger.error("OAuth token exchange failed", { error: error.message }); - throw error; + // Validate client_id and redirect_uri + if (codeData.clientId !== clientId || codeData.redirectUri !== redirectUri) { + logger.warn("Authorization code validation failed", { + expectedClientId: codeData.clientId, + providedClientId: clientId, + expectedRedirectUri: codeData.redirectUri, + providedRedirectUri: redirectUri + }); + return null; } + + // PKCE verification + if (!this.verifyPKCE(codeVerifier, codeData.codeChallenge)) { + logger.warn("PKCE verification failed", { code: code.substring(0, 8) + "..." }); + return null; + } + + // Generate access token + const accessToken = `mcp_${randomBytes(32).toString("hex")}`; + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + const expiresIn = 3600; + + // Store access token + this.#accessTokens.set(accessToken, { + userId: "demo-user", // In real implementation, this would be the authenticated user + scope: codeData.scope, + expiresAt + }); + + // Clean up authorization code (single use) + this.#authorizationCodes.delete(code); + + logger.info("Access token issued", { + clientId, + scope: codeData.scope, + expiresIn + }); + + return { + accessToken, + expiresIn, + scope: codeData.scope + }; } /** - * Validate access token + * Verify PKCE code verifier against challenge */ - async validateToken(token: string): Promise<{ valid: boolean; userId?: string }> { - try { - // In a real implementation, you would validate against your OAuth provider - // This is a simplified example - const response = await fetch(`${this.config.tokenEndpoint}/introspect`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${Buffer.from( - `${this.config.clientId}:${this.config.clientSecret}` - ).toString("base64")}`, - }, - body: new URLSearchParams({ - token, - }), - }); + private verifyPKCE(codeVerifier: string, codeChallenge: string): boolean { + const hash = createHash('sha256').update(codeVerifier).digest(); + const computedChallenge = hash.toString('base64url'); + return computedChallenge === codeChallenge; + } - if (!response.ok) { - return { valid: false }; - } + /** + * Validate access token + */ + async validateToken(token: string): Promise<{ valid: boolean; userId?: string; scope?: string }> { + const tokenData = this.#accessTokens.get(token); + + if (!tokenData) { + return { valid: false }; + } - const introspection = await response.json(); - return { - valid: introspection.active === true, - userId: introspection.sub, - }; - } catch (error) { - logger.error("Token validation failed", { error: error.message }); + if (tokenData.expiresAt < new Date()) { + this.#accessTokens.delete(token); return { valid: false }; } + + return { + valid: true, + userId: tokenData.userId, + scope: tokenData.scope + }; } -} \ No newline at end of file + + /** + * Clean up expired codes and tokens + */ + private cleanup(): void { + const now = new Date(); + + // Clean up expired authorization codes + for (const [code, data] of this.#authorizationCodes.entries()) { + if (data.expiresAt < now) { + this.#authorizationCodes.delete(code); + } + } + + // Clean up expired access tokens + for (const [token, data] of this.#accessTokens.entries()) { + if (data.expiresAt < now) { + this.#accessTokens.delete(token); + } + } + } + +} diff --git a/src/auth/routes.ts b/src/auth/routes.ts new file mode 100644 index 0000000..40d42f5 --- /dev/null +++ b/src/auth/routes.ts @@ -0,0 +1,162 @@ +import type { Request, Response } from "express"; +import { randomBytes } from "node:crypto"; +import { OAuthProvider } from "./oauth-provider.ts"; +import { logger } from "../logger.ts"; + +/** + * OAuth authorization endpoint - generates authorization codes with PKCE + */ +export function createAuthorizeHandler(oauthProvider: OAuthProvider) { + return (req: Request, res: Response) => { + try { + const { + response_type, + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method + } = req.query; + + if (response_type !== "code") { + return res.status(400).json({ + error: "unsupported_response_type", + error_description: "Only authorization code flow is supported" + }); + } + + if (!client_id || !redirect_uri) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters: client_id, redirect_uri" + }); + } + + if (!code_challenge || code_challenge_method !== "S256") { + return res.status(400).json({ + error: "invalid_request", + error_description: "PKCE is required (code_challenge with S256 method)" + }); + } + const authCode = randomBytes(32).toString("hex"); + const oauthState = (state as string) || randomBytes(32).toString("hex"); + oauthProvider.storeAuthorizationCode(authCode, { + clientId: client_id as string, + redirectUri: redirect_uri as string, + scope: scope as string || "read", + codeChallenge: code_challenge as string, + codeChallengeMethod: code_challenge_method as string, + expiresAt: new Date(Date.now() + 10 * 60 * 1000) + }); + + const redirectUrl = new URL(redirect_uri as string); + redirectUrl.searchParams.set("code", authCode); + redirectUrl.searchParams.set("state", oauthState); + + logger.info("Authorization code generated", { + client_id, + redirect_uri, + code: authCode.substring(0, 8) + "..." + }); + + res.redirect(redirectUrl.toString()); + + } catch (error) { + logger.error("OAuth authorization error", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to process authorization request" + }); + } + }; +} + +/** + * OAuth callback handler - completes OAuth flow + */ +export function createCallbackHandler(oauthProvider: OAuthProvider) { + return async (req: Request, res: Response) => { + try { + const { code, state, error, error_description } = req.query; + + if (error) { + logger.warn("OAuth callback error from provider", { error, error_description }); + return res.status(400).json({ + error: error as string, + error_description: error_description as string || "OAuth authorization failed" + }); + } + + // Validate required parameters + if (!code) { + logger.warn("Missing authorization code in callback"); + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing authorization code" + }); + } + + // Exchange authorization code for access token + const tokenResult = await oauthProvider.exchangeCodeForToken(code as string); + + logger.info("OAuth callback successful", { + userId: tokenResult.userId, + state + }); + const closeScript = ` + + + + Authorization Complete + + + +
✓ Authorization Successful
+
You can close this window and return to your application.
+ + + + `; + + res.send(closeScript); + + } catch (error) { + logger.error("OAuth callback error", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to complete OAuth authorization" + }); + } + }; +} \ No newline at end of file diff --git a/src/auth/token-validator.ts b/src/auth/token-validator.ts new file mode 100644 index 0000000..ad56e83 --- /dev/null +++ b/src/auth/token-validator.ts @@ -0,0 +1,154 @@ +import { logger } from "../logger.ts"; + +export interface TokenValidationResult { + valid: boolean; + userId?: string; + error?: string; +} + +/** + * Gateway token validator - validates JWT tokens from external OAuth providers + */ +export class GatewayTokenValidator { + #issuer: string; + #audience?: string; + + constructor(issuer: string, audience?: string) { + this.#issuer = issuer; + this.#audience = audience; + } + + async validateToken(token: string): Promise { + try { + const isJWT = token.split('.').length === 3; + + if (isJWT) { + return await this.validateJWT(token); + } else { + return await this.introspectToken(token); + } + + } catch (error) { + logger.error("Gateway token validation error", { + error: error instanceof Error ? error.message : error + }); + return { valid: false, error: "Token validation failed" }; + } + } + + private async validateJWT(token: string): Promise { + try { + const [headerB64, payloadB64] = token.split('.'); + + const payload = JSON.parse( + Buffer.from(payloadB64, 'base64url').toString('utf-8') + ); + + const now = Math.floor(Date.now() / 1000); + + if (payload.exp && payload.exp < now) { + return { valid: false, error: "Token expired" }; + } + + if (payload.iss !== this.#issuer) { + return { valid: false, error: "Invalid issuer" }; + } + + if (this.#audience) { + const tokenAud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!tokenAud.includes(this.#audience)) { + return { valid: false, error: "Invalid audience" }; + } + } + + return { + valid: true, + userId: payload.sub || payload.user_id || payload.username, + }; + + } catch (error) { + logger.warn("JWT parsing failed, falling back to introspection", { + error: error instanceof Error ? error.message : error + }); + return await this.introspectToken(token); + } + } + + private async introspectToken(token: string): Promise { + const introspectionUrl = `${this.#issuer}/oauth/introspect`; + + const response = await fetch(introspectionUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + token, + token_type_hint: "access_token", + }), + }); + + if (!response.ok) { + logger.warn("Token introspection failed", { + status: response.status, + statusText: response.statusText + }); + return { valid: false, error: "Token introspection failed" }; + } + + const result = await response.json(); + + if (!result.active) { + return { valid: false, error: "Token is not active" }; + } + + if (this.#audience && result.aud !== this.#audience) { + return { valid: false, error: "Invalid audience" }; + } + + return { + valid: true, + userId: result.sub || result.user_id || result.username, + }; + } +} + +/** + * Built-in token validator for OAuth authorization server mode + */ +export class BuiltinTokenValidator { + #tokens = new Map(); + storeToken(token: string, userId: string, expiresAt: Date): void { + this.#tokens.set(token, { userId, expiresAt }); + + setTimeout(() => { + this.#tokens.delete(token); + }, expiresAt.getTime() - Date.now()); + } + + async validateToken(token: string): Promise { + try { + const tokenData = this.#tokens.get(token); + + if (!tokenData) { + return { valid: false, error: "Token not found" }; + } + + if (tokenData.expiresAt < new Date()) { + this.#tokens.delete(token); + return { valid: false, error: "Token expired" }; + } + + return { + valid: true, + userId: tokenData.userId, + }; + + } catch (error) { + logger.error("Built-in token validation error", { + error: error instanceof Error ? error.message : error + }); + return { valid: false, error: "Token validation failed" }; + } + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 26452d3..e5af952 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,13 +2,30 @@ import { z } from "zod"; const configSchema = z.object({ PORT: z.coerce.number().default(3000), - NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), SERVER_NAME: z.string().default("mcp-typescript-template"), SERVER_VERSION: z.string().default("1.0.0"), LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), + + // Authentication Configuration (optional) + ENABLE_AUTH: z.preprocess((val) => { + // Handle string-to-boolean conversion properly + if (typeof val === "string") { + return val.toLowerCase() === "true"; + } + return val; + }, z.boolean().default(false)), + + // Auth mode: "gateway" (resource server) or "builtin" (authorization server) + AUTH_MODE: z.enum(["gateway", "builtin"]).default("gateway"), + + // Gateway mode: External OAuth provider token validation + OAUTH_ISSUER: z.string().optional(), // OAuth issuer URL for token validation + OAUTH_AUDIENCE: z.string().optional(), // Expected audience in JWT tokens - // OAuth Configuration (optional) - ENABLE_AUTH: z.coerce.boolean().default(false), + // Built-in mode: OAuth server configuration (for testing/demos) OAUTH_CLIENT_ID: z.string().optional(), OAUTH_CLIENT_SECRET: z.string().optional(), OAUTH_AUTH_ENDPOINT: z.string().optional(), @@ -25,16 +42,34 @@ export function getConfig(): Config { if (!config) { try { const parsed = configSchema.parse(process.env); - - // Validate OAuth configuration if auth is enabled - if (parsed.ENABLE_AUTH) { - if (!parsed.OAUTH_CLIENT_ID || !parsed.OAUTH_CLIENT_SECRET || - !parsed.OAUTH_AUTH_ENDPOINT || !parsed.OAUTH_TOKEN_ENDPOINT || - !parsed.OAUTH_REDIRECT_URI) { - throw new Error("OAuth is enabled but missing required OAuth environment variables"); + + // Only validate auth configuration if auth is explicitly enabled + if (parsed.ENABLE_AUTH === true) { + if (parsed.AUTH_MODE === "gateway") { + // Gateway mode: validate token validation config + if (!parsed.OAUTH_ISSUER) { + throw new Error( + "Gateway auth mode requires OAUTH_ISSUER for token validation. " + + "Set OAUTH_ISSUER to your OAuth provider's issuer URL (e.g., https://your-domain.auth0.com)" + ); + } + } else if (parsed.AUTH_MODE === "builtin") { + // Built-in mode: validate OAuth server config + const missingVars = []; + if (!parsed.OAUTH_CLIENT_ID) missingVars.push("OAUTH_CLIENT_ID"); + if (!parsed.OAUTH_CLIENT_SECRET) missingVars.push("OAUTH_CLIENT_SECRET"); + if (!parsed.OAUTH_AUTH_ENDPOINT) missingVars.push("OAUTH_AUTH_ENDPOINT"); + if (!parsed.OAUTH_TOKEN_ENDPOINT) missingVars.push("OAUTH_TOKEN_ENDPOINT"); + if (!parsed.OAUTH_REDIRECT_URI) missingVars.push("OAUTH_REDIRECT_URI"); + + if (missingVars.length > 0) { + throw new Error( + `Built-in auth mode requires OAuth configuration. Missing: ${missingVars.join(", ")}` + ); + } } } - + config = parsed; } catch (error) { console.error("❌ Invalid environment configuration:", error); @@ -50,4 +85,4 @@ export function isProduction(): boolean { export function isDevelopment(): boolean { return getConfig().NODE_ENV === "development"; -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index bbfcf4d..662d485 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,15 @@ import { z } from "zod"; import { createTextResult } from "./lib/utils.ts"; import { logger } from "./logger.ts"; import { getConfig } from "./config.ts"; -import { createAuthMiddleware } from "./auth/index.ts"; +import { createAuthenticationMiddleware, initializeAuth } from "./auth/index.ts"; +import { createAuthorizeHandler, createCallbackHandler } from "./auth/routes.ts"; +import { + createAuthorizationServerMetadataHandler, + createProtectedResourceMetadataHandler, + createTokenHandler, + createIntrospectionHandler, + createRevocationHandler +} from "./auth/discovery.ts"; const getServer = () => { const config = getConfig(); @@ -37,11 +45,6 @@ const getServer = () => { const app = express(); app.use(express.json()); -// Apply OAuth middleware conditionally -const config = getConfig(); -const authMiddleware = config.ENABLE_AUTH ? createAuthMiddleware() : undefined; -app.use("/mcp", authMiddleware); - const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const mcpHandler = async (req: express.Request, res: express.Response) => { @@ -109,9 +112,31 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { } }; -// Handle MCP requests on /mcp endpoint -app.post("/mcp", mcpHandler); -app.get("/mcp", mcpHandler); +// Setup OAuth routes and discovery endpoints +const config = getConfig(); +if (config.ENABLE_AUTH && config.AUTH_MODE === "builtin") { + const { oauthProvider } = initializeAuth(); + if (oauthProvider) { + // OAuth 2.1 Discovery endpoints (required by MCP spec) + app.get("/.well-known/oauth-authorization-server", createAuthorizationServerMetadataHandler()); + app.get("/.well-known/oauth-protected-resource", createProtectedResourceMetadataHandler()); + + // OAuth 2.1 endpoints + app.get("/authorize", createAuthorizeHandler(oauthProvider)); + app.get("/callback", createCallbackHandler(oauthProvider)); + app.post("/token", createTokenHandler(oauthProvider)); + app.post("/introspect", createIntrospectionHandler()); + app.post("/revoke", createRevocationHandler()); + + logger.info("OAuth 2.1 endpoints registered for built-in auth mode", { + discovery: ["/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"], + endpoints: ["/authorize", "/callback", "/token", "/introspect", "/revoke"] + }); + } +} + +app.use("/mcp", createAuthenticationMiddleware(), mcpHandler); +app.post("/mcp", createAuthenticationMiddleware(), mcpHandler); async function main() { const config = getConfig(); From 05f9765309421549b373531e852eeb1d05cdf192 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Fri, 1 Aug 2025 17:41:00 -0700 Subject: [PATCH 03/30] feat(auth): integrate oauth2-server for OAuth 2.1 support with PKCE and token validation --- package-lock.json | 470 +++++++++++++++--------------------- package.json | 6 +- src/auth/discovery.ts | 116 +++++---- src/auth/index.ts | 24 +- src/auth/oauth-server.ts | 199 +++++++++++++++ src/auth/routes.ts | 107 +++----- src/auth/token-validator.ts | 77 +++--- src/index.ts | 14 +- 8 files changed, 538 insertions(+), 475 deletions(-) create mode 100644 src/auth/oauth-server.ts diff --git a/package-lock.json b/package-lock.json index ced0729..0ef2837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,13 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", "@types/express": "^5.0.3", + "@types/oauth2-server": "^3.0.18", "express": "^5.1.0", + "jose": "^6.0.12", + "oauth2-server": "^3.1.1", "pino": "^9.0.0", - "pino-pretty": "^13.1.1" + "pino-pretty": "^13.1.1", + "pkce-challenge": "^5.0.0" }, "devDependencies": { "@commitlint/cli": "^19.0.0", @@ -1132,6 +1136,21 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1371,34 +1390,6 @@ "node": ">=12" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.0.tgz", - "integrity": "sha512-9f3nSTFI2ivfxc7/tHBHcJ8pRnp8ROrELvsVprlQPVvcZ+j5zztYd+PTJGpyIOAdTvNwNrpCXswKSeoQcyGjMQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.0.tgz", - "integrity": "sha512-tFZSEhqJ8Yrpe50TzOdeoYi72gi/jsnT7y8Qrozf3cNu28WX+s6I3XzEPUAqoaT9SAS8Xz9AzGTFlxxCH/w20w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.46.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.0.tgz", @@ -1413,244 +1404,6 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.0.tgz", - "integrity": "sha512-5a+NofhdEB/WimSlFMskbFQn1vqz1FWryYpA99trmZGO6qEmiS0IsX6w4B3d91U878Q2ZQdiaFF1gxX4P147og==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.0.tgz", - "integrity": "sha512-igr/RlKPS3OCy4jD3XBmAmo3UAcNZkJSubRsw1JeM8bAbwf15k/3eMZXD91bnjheijJiOJcga3kfCLKjV8IXNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.0.tgz", - "integrity": "sha512-MdigWzPSHlQzB1xZ+MdFDWTAH+kcn7UxjEBoOKuaso7z1DRlnAnrknB1mTtNOQ+GdPI8xgExAGwHeqQjntR0Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.0.tgz", - "integrity": "sha512-dmZseE0ZwA/4yy1+BwFrDqFTjjNg24GO9xSrb1weVbt6AFkhp5pz1gVS7IMtfIvoWy8yp6q/zN0bKnefRUImvQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.0.tgz", - "integrity": "sha512-fzhfn6p9Cfm3W8UrWKIa4l7Wfjs/KGdgaswMBBE3KY3Ta43jg2XsPrAtfezHpsRk0Nx+TFuS3hZk/To2N5kFPQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.0.tgz", - "integrity": "sha512-vVDD+iPDPmJQ5nAQ5Tifq3ywdv60FartglFI8VOCK+hcU9aoG0qlQTsDJP97O5yiTaTqlneZWoARMcVC5nyUoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.0.tgz", - "integrity": "sha512-0d0jx08fzDHCzXqrtCMEEyxKU0SvJrWmUjUDE2/KDQ2UDJql0tfiwYvEx1oHELClKO8CNdE+AGJj+RqXscZpdQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.0.tgz", - "integrity": "sha512-XBYu9oW9eKJadWn8M7hkTZsD4yG+RrsTrVEgyKwb4L72cpJjRbRboTG9Lg9fec8MxJp/cfTHAocg4mnismQR8A==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.0.tgz", - "integrity": "sha512-wJaRvcT17PoOK6Ggcfo3nouFlybHvARBS4jzT0PC/lg17fIJHcDS2fZz3sD+iA4nRlho2zE6OGbU0HvwATdokQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.0.tgz", - "integrity": "sha512-GZ5bkMFteAGkcmh8x0Ok4LSa+L62Ez0tMsHPX6JtR0wl4Xc3bQcrFHDiR5DGLEDFtGrXih4Nd/UDaFqs968/wA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.0.tgz", - "integrity": "sha512-7CjPw6FflFsVOUfWOrVrREiV3IYXG4RzZ1ZQUaT3BtSK8YXN6x286o+sruPZJESIaPebYuFowmg54ZdrkVBYog==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.0.tgz", - "integrity": "sha512-nmvnl0ZiuysltcB/cKjUh40Rx4FbSyueERDsl2FLvLYr6pCgSsvGr3SocUT84svSpmloS7f1DRWqtRha74Gi1w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.0.tgz", - "integrity": "sha512-Cv+moII5C8RM6gZbR3cb21o6rquVDZrN2o81maROg1LFzBz2dZUwIQSxFA8GtGZ/F2KtsqQ2z3eFPBb6akvQNg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.0.tgz", - "integrity": "sha512-PHcMG8DZTM9RCIjp8QIfN0VYtX0TtBPnWOTRurFhoCDoi9zptUZL2k7pCs+5rgut7JAiUsYy+huyhVKPcmxoog==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.0.tgz", - "integrity": "sha512-1SI/Rd47e8aQJeFWMDg16ET+fjvCcD/CzeaRmIEPmb05hx+3cCcwIF4ebUag4yTt/D1peE+Mgp0+Po3M358cAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.0.tgz", - "integrity": "sha512-JwOCYxmumFDfDhx4kNyz6kTVK3gWzBIvVdMNzQMRDubcoGRDniOOmo6DDNP42qwZx3Bp9/6vWJ+kNzNqXoHmeA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.0.tgz", - "integrity": "sha512-IPMIfrfkG1GaEXi+JSsQEx8x9b4b+hRZXO7KYc2pKio3zO2/VDXDs6B9Ts/nnO+25Fk1tdAVtUn60HKKPPzDig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2318,6 +2071,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/oauth2-server": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@types/oauth2-server/-/oauth2-server-3.0.18.tgz", + "integrity": "sha512-pcRHJjaeS2ISfSpeqTzNA+IoaRPvTTVivFycHW750XU73ZfFGQ6c3wH4nZgF+NxGQmWMiThSbRKeUTi0UEsfjA==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2890,6 +2652,24 @@ "dev": true, "license": "MIT" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -2897,6 +2677,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -3163,6 +2949,32 @@ "node": ">=12" } }, + "node_modules/co-bluebird": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/co-bluebird/-/co-bluebird-1.1.0.tgz", + "integrity": "sha512-JuoemMXxQjYAxbfRrNpOsLyiwDiY8mXvGqJyYLM7jMySDJtnMklW3V2o8uyubpc1eN2YoRsAdfZ1lfKCd3lsrA==", + "dependencies": { + "bluebird": "^2.10.0", + "co-use": "^1.1.0" + }, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/co-bluebird/node_modules/bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", + "license": "MIT" + }, + "node_modules/co-use": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/co-use/-/co-use-1.1.0.tgz", + "integrity": "sha512-1lVRtdywv41zQO/xvI2wU8w6oFcUYT6T84YKSxN25KN4N4Kld3scLovt8FjDmD63Cm7HtyRWHjezt+IanXmkyA==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4280,21 +4092,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -5175,6 +4972,12 @@ "node": ">=8" } }, + "node_modules/is-generator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-generator/-/is-generator-1.0.3.tgz", + "integrity": "sha512-G56jBpbJeg7ds83HW1LuShNs8J73Fv3CPz/bmROHOHlnKkN8sWb9ujiagjmxxMUywftgq48HlBZELKKqFLk0oA==", + "license": "MIT" + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5326,6 +5129,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", + "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -8662,6 +8474,81 @@ "inBundle": true, "license": "ISC" }, + "node_modules/oauth2-server": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oauth2-server/-/oauth2-server-3.1.1.tgz", + "integrity": "sha512-4dv+fE9hrK+xTaCygOLh/kQeFzbFr7UqSyHvBDbrQq8Hg52sAkV2vTsyH3Z42hoeaKpbhM7udhL8Y4GYbl6TGQ==", + "license": "MIT", + "dependencies": { + "basic-auth": "2.0.1", + "bluebird": "3.7.2", + "lodash": "4.17.19", + "promisify-any": "2.0.1", + "statuses": "1.5.0", + "type-is": "1.6.18" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/oauth2-server/node_modules/lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", + "license": "MIT" + }, + "node_modules/oauth2-server/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth2-server/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth2-server/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth2-server/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth2-server/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9277,6 +9164,25 @@ ], "license": "MIT" }, + "node_modules/promisify-any": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promisify-any/-/promisify-any-2.0.1.tgz", + "integrity": "sha512-pVaGouFbTVxqpVJ+T5A15olNJDASAZHYq5cXz6mWdr6/X34mVWiG9MSdzHTcVBCv4aqBP7wGspi7BUSRbEmhsw==", + "dependencies": { + "bluebird": "^2.10.0", + "co-bluebird": "^1.1.0", + "is-generator": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/promisify-any/node_modules/bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", + "license": "MIT" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", diff --git a/package.json b/package.json index 9b136b3..58d4eab 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,12 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", "@types/express": "^5.0.3", + "@types/oauth2-server": "^3.0.18", "express": "^5.1.0", + "jose": "^6.0.12", + "oauth2-server": "^3.1.1", "pino": "^9.0.0", - "pino-pretty": "^13.1.1" + "pino-pretty": "^13.1.1", + "pkce-challenge": "^5.0.0" } } diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index 94617ef..a0083cd 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -78,65 +78,57 @@ export function createProtectedResourceMetadataHandler() { } /** - * OAuth 2.1 token endpoint with PKCE support + * OAuth 2.1 token endpoint with PKCE support using oauth2-server */ -export function createTokenHandler(oauthProvider: any) { +export function createTokenHandler(oauthServer: any) { return async (req: Request, res: Response) => { try { - const { grant_type, code, redirect_uri, code_verifier, client_id } = req.body; - - if (grant_type !== "authorization_code") { - return res.status(400).json({ - error: "unsupported_grant_type", - error_description: "Only authorization_code grant type is supported" - }); - } - - if (!code || !redirect_uri || !code_verifier || !client_id) { - return res.status(400).json({ - error: "invalid_request", - error_description: "Missing required parameters: code, redirect_uri, code_verifier, client_id" - }); - } - const tokenResult = await oauthProvider.exchangeAuthorizationCode( - code, - code_verifier, - client_id, - redirect_uri - ); - - if (!tokenResult) { - return res.status(400).json({ - error: "invalid_grant", - error_description: "Invalid authorization code or PKCE verification failed" - }); - } - - logger.info("Token exchange successful", { client_id, scope: tokenResult.scope }); + const request = new oauthServer.server.Request(req); + const response = new oauthServer.server.Response(res); + + const token = await oauthServer.server.token(request, response); + + logger.info("Token exchange successful", { + client_id: token.client.id, + scope: token.scope + }); res.json({ - access_token: tokenResult.accessToken, + access_token: token.accessToken, token_type: "Bearer", - expires_in: tokenResult.expiresIn, - scope: tokenResult.scope + expires_in: Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000), + scope: token.scope }); } catch (error) { logger.error("Token endpoint error", { error: error instanceof Error ? error.message : error }); - res.status(500).json({ - error: "server_error", - error_description: "Failed to process token request" - }); + + if (error.name === 'InvalidGrantError') { + res.status(400).json({ + error: "invalid_grant", + error_description: error.message + }); + } else if (error.name === 'InvalidRequestError') { + res.status(400).json({ + error: "invalid_request", + error_description: error.message + }); + } else { + res.status(500).json({ + error: "server_error", + error_description: "Failed to process token request" + }); + } } }; } /** - * Token introspection endpoint + * Token introspection endpoint using oauth2-server */ -export function createIntrospectionHandler() { +export function createIntrospectionHandler(oauthServer?: any) { return async (req: Request, res: Response) => { try { const { token } = req.body; @@ -148,16 +140,38 @@ export function createIntrospectionHandler() { }); } - // TODO: Implement actual token introspection - // For now, return active=true for any token - logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); - - res.json({ - active: true, - scope: "read", - client_id: "mcp-client", - exp: Math.floor(Date.now() / 1000) + 3600 - }); + if (oauthServer) { + try { + const accessToken = await oauthServer.server.model.getAccessToken(token); + + if (!accessToken || accessToken.accessTokenExpiresAt < new Date()) { + return res.json({ active: false }); + } + + logger.info("Token introspection requested", { + token: token.substring(0, 10) + "...", + client_id: accessToken.client.id + }); + + res.json({ + active: true, + scope: accessToken.scope, + client_id: accessToken.client.id, + exp: Math.floor(accessToken.accessTokenExpiresAt.getTime() / 1000) + }); + } catch (error) { + res.json({ active: false }); + } + } else { + // Fallback for gateway mode + logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); + res.json({ + active: true, + scope: "read", + client_id: "mcp-client", + exp: Math.floor(Date.now() / 1000) + 3600 + }); + } } catch (error) { logger.error("Token introspection error", { diff --git a/src/auth/index.ts b/src/auth/index.ts index 02a642f..b0ce644 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,5 +1,5 @@ -import { OAuthProvider, type OAuthConfig } from "./oauth-provider.ts"; -import { GatewayTokenValidator } from "./token-validator.ts"; +import { ManagedOAuthServer } from "./oauth-server.ts"; +import { GatewayTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; import { createAuthMiddleware } from "./middleware.ts"; import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; @@ -12,7 +12,7 @@ export function initializeAuth() { if (!config.ENABLE_AUTH) { logger.info("Authentication is disabled"); - return { tokenValidator: null, oauthProvider: null }; + return { tokenValidator: null, oauthServer: null }; } if (config.AUTH_MODE === "gateway") { @@ -21,22 +21,14 @@ export function initializeAuth() { config.OAUTH_ISSUER!, config.OAUTH_AUDIENCE ); - return { tokenValidator, oauthProvider: null }; + return { tokenValidator, oauthServer: null }; } if (config.AUTH_MODE === "builtin") { - logger.info("Initializing built-in auth mode (OAuth client + resource server)"); - const oauthConfig: OAuthConfig = { - clientId: config.OAUTH_CLIENT_ID!, - clientSecret: config.OAUTH_CLIENT_SECRET!, - authorizationEndpoint: config.OAUTH_AUTH_ENDPOINT!, - tokenEndpoint: config.OAUTH_TOKEN_ENDPOINT!, - scope: config.OAUTH_SCOPE || "read", - redirectUri: config.OAUTH_REDIRECT_URI!, - }; - - const oauthProvider = new OAuthProvider(oauthConfig); - return { tokenValidator: oauthProvider.tokenValidator, oauthProvider }; + logger.info("Initializing built-in auth mode (OAuth authorization server)"); + const oauthServer = new ManagedOAuthServer(); + const tokenValidator = new BuiltinTokenValidator(oauthServer); + return { tokenValidator, oauthServer }; } throw new Error(`Unknown auth mode: ${config.AUTH_MODE}`); diff --git a/src/auth/oauth-server.ts b/src/auth/oauth-server.ts new file mode 100644 index 0000000..a8c0938 --- /dev/null +++ b/src/auth/oauth-server.ts @@ -0,0 +1,199 @@ +import OAuth2Server from "oauth2-server"; +import { generateChallenge, verifyChallenge } from "pkce-challenge"; +import { logger } from "../logger.ts"; + +interface Client { + id: string; + grants: string[]; + redirectUris: string[]; +} + +interface AuthorizationCode { + authorizationCode: string; + expiresAt: Date; + redirectUri: string; + scope?: string; + client: Client; + user: { id: string }; + codeChallenge?: string; + codeChallengeMethod?: string; +} + +interface AccessToken { + accessToken: string; + accessTokenExpiresAt: Date; + scope?: string; + client: Client; + user: { id: string }; +} + +/** + * OAuth 2.1 server implementation using oauth2-server package + */ +export class ManagedOAuthServer { + #server: OAuth2Server; + #authorizationCodes = new Map(); + #accessTokens = new Map(); + #clients = new Map(); + + constructor() { + // Register default MCP client + this.#clients.set("mcp-client", { + id: "mcp-client", + grants: ["authorization_code"], + redirectUris: ["http://localhost:3000/callback"], + }); + + this.#server = new OAuth2Server({ + model: { + // Client methods + getClient: async (clientId: string) => { + const client = this.#clients.get(clientId); + return client || null; + }, + + // Authorization code methods + saveAuthorizationCode: async (code, client, user) => { + const authCode: AuthorizationCode = { + authorizationCode: code.authorizationCode, + expiresAt: code.expiresAt, + redirectUri: code.redirectUri, + scope: code.scope, + client: client as Client, + user: user as { id: string }, + codeChallenge: (code as any).codeChallenge, + codeChallengeMethod: (code as any).codeChallengeMethod, + }; + + this.#authorizationCodes.set(code.authorizationCode, authCode); + logger.info("Authorization code saved", { + clientId: client.id, + userId: user.id + }); + + return authCode; + }, + + getAuthorizationCode: async (authorizationCode: string) => { + const code = this.#authorizationCodes.get(authorizationCode); + if (!code) return null; + + // Check expiration + if (code.expiresAt < new Date()) { + this.#authorizationCodes.delete(authorizationCode); + return null; + } + + return code; + }, + + revokeAuthorizationCode: async (code) => { + this.#authorizationCodes.delete(code.authorizationCode); + return true; + }, + + // PKCE verification + verifyCodeChallenge: async (authorizationCode, codeVerifier) => { + const code = this.#authorizationCodes.get(authorizationCode.authorizationCode); + if (!code || !code.codeChallenge) return false; + + try { + return verifyChallenge(codeVerifier, code.codeChallenge); + } catch (error) { + logger.warn("PKCE verification failed", { + error: error instanceof Error ? error.message : error + }); + return false; + } + }, + + // Access token methods + saveToken: async (token, client, user) => { + const accessToken: AccessToken = { + accessToken: token.accessToken, + accessTokenExpiresAt: token.accessTokenExpiresAt, + scope: token.scope, + client: client as Client, + user: user as { id: string }, + }; + + this.#accessTokens.set(token.accessToken, accessToken); + logger.info("Access token saved", { + clientId: client.id, + userId: user.id + }); + + return accessToken; + }, + + getAccessToken: async (accessToken: string) => { + const token = this.#accessTokens.get(accessToken); + if (!token) return null; + + // Check expiration + if (token.accessTokenExpiresAt < new Date()) { + this.#accessTokens.delete(accessToken); + return null; + } + + return token; + }, + + // User verification (simplified for demo) + getUser: async () => { + return { id: "demo-user" }; + }, + + // Scope verification + verifyScope: async (user, client, scope) => { + return scope === "read" || scope === "write"; + }, + + // PKCE support + validateScope: async (user, client, scope) => { + return scope === "read" || scope === "write"; + }, + }, + + // OAuth 2.1 configuration + requireClientAuthentication: { authorization_code: false }, + allowBearerTokensInQueryString: false, + accessTokenLifetime: 3600, // 1 hour + authorizationCodeLifetime: 600, // 10 minutes + }); + } + + /** + * Get the oauth2-server instance + */ + get server(): OAuth2Server { + return this.#server; + } + + /** + * Register a new client + */ + registerClient(clientId: string, redirectUris: string[]): void { + this.#clients.set(clientId, { + id: clientId, + grants: ["authorization_code"], + redirectUris, + }); + + logger.info("OAuth client registered", { clientId, redirectUris }); + } + + /** + * Validate PKCE challenge using pkce-challenge package + */ + validateCodeChallenge(codeVerifier: string, codeChallenge: string): boolean { + try { + return verifyChallenge(codeVerifier, codeChallenge); + } catch (error) { + logger.warn("PKCE validation failed", { + error: error instanceof Error ? error.message : error + }); + return false; + } + } +} \ No newline at end of file diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 40d42f5..570abe1 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -1,86 +1,57 @@ import type { Request, Response } from "express"; -import { randomBytes } from "node:crypto"; -import { OAuthProvider } from "./oauth-provider.ts"; import { logger } from "../logger.ts"; /** - * OAuth authorization endpoint - generates authorization codes with PKCE + * OAuth authorization endpoint using oauth2-server */ -export function createAuthorizeHandler(oauthProvider: OAuthProvider) { - return (req: Request, res: Response) => { +export function createAuthorizeHandler(oauthServer: any) { + return async (req: Request, res: Response) => { try { - const { - response_type, - client_id, - redirect_uri, - scope, - state, - code_challenge, - code_challenge_method - } = req.query; - - if (response_type !== "code") { - return res.status(400).json({ - error: "unsupported_response_type", - error_description: "Only authorization code flow is supported" - }); - } + const request = new oauthServer.server.Request(req); + const response = new oauthServer.server.Response(res); - if (!client_id || !redirect_uri) { - return res.status(400).json({ - error: "invalid_request", - error_description: "Missing required parameters: client_id, redirect_uri" - }); - } - - if (!code_challenge || code_challenge_method !== "S256") { - return res.status(400).json({ - error: "invalid_request", - error_description: "PKCE is required (code_challenge with S256 method)" - }); - } - const authCode = randomBytes(32).toString("hex"); - const oauthState = (state as string) || randomBytes(32).toString("hex"); - oauthProvider.storeAuthorizationCode(authCode, { - clientId: client_id as string, - redirectUri: redirect_uri as string, - scope: scope as string || "read", - codeChallenge: code_challenge as string, - codeChallengeMethod: code_challenge_method as string, - expiresAt: new Date(Date.now() + 10 * 60 * 1000) - }); - - const redirectUrl = new URL(redirect_uri as string); - redirectUrl.searchParams.set("code", authCode); - redirectUrl.searchParams.set("state", oauthState); + const code = await oauthServer.server.authorize(request, response); logger.info("Authorization code generated", { - client_id, - redirect_uri, - code: authCode.substring(0, 8) + "..." + client_id: code.client.id, + redirect_uri: code.redirectUri, + code: code.authorizationCode.substring(0, 8) + "..." }); - - res.redirect(redirectUrl.toString()); + + res.redirect(response.headers.location); } catch (error) { logger.error("OAuth authorization error", { error: error instanceof Error ? error.message : error }); - res.status(500).json({ - error: "server_error", - error_description: "Failed to process authorization request" - }); + + if (error.name === 'InvalidClientError') { + res.status(400).json({ + error: "invalid_client", + error_description: error.message + }); + } else if (error.name === 'InvalidRequestError') { + res.status(400).json({ + error: "invalid_request", + error_description: error.message + }); + } else { + res.status(500).json({ + error: "server_error", + error_description: "Failed to process authorization request" + }); + } } }; } /** - * OAuth callback handler - completes OAuth flow + * OAuth callback handler - simplified for oauth2-server */ -export function createCallbackHandler(oauthProvider: OAuthProvider) { +export function createCallbackHandler() { return async (req: Request, res: Response) => { try { - const { code, state, error, error_description } = req.query; + const { error, error_description } = req.query; if (error) { logger.warn("OAuth callback error from provider", { error, error_description }); @@ -90,22 +61,8 @@ export function createCallbackHandler(oauthProvider: OAuthProvider) { }); } - // Validate required parameters - if (!code) { - logger.warn("Missing authorization code in callback"); - return res.status(400).json({ - error: "invalid_request", - error_description: "Missing authorization code" - }); - } - - // Exchange authorization code for access token - const tokenResult = await oauthProvider.exchangeCodeForToken(code as string); + logger.info("OAuth callback successful"); - logger.info("OAuth callback successful", { - userId: tokenResult.userId, - state - }); const closeScript = ` diff --git a/src/auth/token-validator.ts b/src/auth/token-validator.ts index ad56e83..caeb286 100644 --- a/src/auth/token-validator.ts +++ b/src/auth/token-validator.ts @@ -1,4 +1,5 @@ import { logger } from "../logger.ts"; +import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose"; export interface TokenValidationResult { valid: boolean; @@ -12,10 +13,12 @@ export interface TokenValidationResult { export class GatewayTokenValidator { #issuer: string; #audience?: string; + #jwks: ReturnType; constructor(issuer: string, audience?: string) { this.#issuer = issuer; this.#audience = audience; + this.#jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); } async validateToken(token: string): Promise { @@ -38,36 +41,18 @@ export class GatewayTokenValidator { private async validateJWT(token: string): Promise { try { - const [headerB64, payloadB64] = token.split('.'); - - const payload = JSON.parse( - Buffer.from(payloadB64, 'base64url').toString('utf-8') - ); - - const now = Math.floor(Date.now() / 1000); - - if (payload.exp && payload.exp < now) { - return { valid: false, error: "Token expired" }; - } - - if (payload.iss !== this.#issuer) { - return { valid: false, error: "Invalid issuer" }; - } - - if (this.#audience) { - const tokenAud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; - if (!tokenAud.includes(this.#audience)) { - return { valid: false, error: "Invalid audience" }; - } - } + const { payload } = await jwtVerify(token, this.#jwks, { + issuer: this.#issuer, + audience: this.#audience, + }); return { valid: true, - userId: payload.sub || payload.user_id || payload.username, + userId: payload.sub || (payload as any).user_id || (payload as any).username, }; } catch (error) { - logger.warn("JWT parsing failed, falling back to introspection", { + logger.warn("JWT verification failed, falling back to introspection", { error: error instanceof Error ? error.message : error }); return await this.introspectToken(token); @@ -114,38 +99,44 @@ export class GatewayTokenValidator { } /** - * Built-in token validator for OAuth authorization server mode + * Built-in token validator for OAuth authorization server mode using oauth2-server */ export class BuiltinTokenValidator { - #tokens = new Map(); - storeToken(token: string, userId: string, expiresAt: Date): void { - this.#tokens.set(token, { userId, expiresAt }); - - setTimeout(() => { - this.#tokens.delete(token); - }, expiresAt.getTime() - Date.now()); + #oauthServer: any; + + constructor(oauthServer: any) { + this.#oauthServer = oauthServer; } async validateToken(token: string): Promise { try { - const tokenData = this.#tokens.get(token); - - if (!tokenData) { - return { valid: false, error: "Token not found" }; - } + // Create mock request with Authorization header + const mockRequest = { + method: 'GET', + url: '/', + headers: { + authorization: `Bearer ${token}` + } + }; - if (tokenData.expiresAt < new Date()) { - this.#tokens.delete(token); - return { valid: false, error: "Token expired" }; - } + const mockResponse = { + status: () => mockResponse, + json: () => mockResponse, + headers: {} + }; + const request = new this.#oauthServer.server.Request(mockRequest); + const response = new this.#oauthServer.server.Response(mockResponse); + + const authenticatedToken = await this.#oauthServer.server.authenticate(request, response); + return { valid: true, - userId: tokenData.userId, + userId: authenticatedToken.user.id, }; } catch (error) { - logger.error("Built-in token validation error", { + logger.warn("Built-in token validation failed", { error: error instanceof Error ? error.message : error }); return { valid: false, error: "Token validation failed" }; diff --git a/src/index.ts b/src/index.ts index 662d485..64520d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,17 +115,17 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { // Setup OAuth routes and discovery endpoints const config = getConfig(); if (config.ENABLE_AUTH && config.AUTH_MODE === "builtin") { - const { oauthProvider } = initializeAuth(); - if (oauthProvider) { + const { oauthServer } = initializeAuth(); + if (oauthServer) { // OAuth 2.1 Discovery endpoints (required by MCP spec) app.get("/.well-known/oauth-authorization-server", createAuthorizationServerMetadataHandler()); app.get("/.well-known/oauth-protected-resource", createProtectedResourceMetadataHandler()); - // OAuth 2.1 endpoints - app.get("/authorize", createAuthorizeHandler(oauthProvider)); - app.get("/callback", createCallbackHandler(oauthProvider)); - app.post("/token", createTokenHandler(oauthProvider)); - app.post("/introspect", createIntrospectionHandler()); + // OAuth 2.1 endpoints using oauth2-server + app.get("/authorize", createAuthorizeHandler(oauthServer)); + app.get("/callback", createCallbackHandler()); + app.post("/token", createTokenHandler(oauthServer)); + app.post("/introspect", createIntrospectionHandler(oauthServer)); app.post("/revoke", createRevocationHandler()); logger.info("OAuth 2.1 endpoints registered for built-in auth mode", { From 4c9f0dc1df8a7fa2aa35f04e2781ed293a0af2c8 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sat, 2 Aug 2025 15:53:23 -0700 Subject: [PATCH 04/30] feat: complete OAuth integration with comprehensive unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ **Features Added:** - **AUTH_MODE support**: none|resource_server|full modes - **OAuthTokenValidator**: Renamed from GatewayTokenValidator for clarity - **OAuth Provider integration**: Full OAuth server support for 'full' mode - **Comprehensive unit tests**: Config validation and token validator testing ✅ **Components:** - **Config validation**: Proper AUTH_MODE validation with detailed error messages - **Token validation**: JWT + introspection with proper error handling - **OAuth Provider**: Authorization flows for full OAuth server mode - **Unit tests**: 46 tests covering config and token validation scenarios ✅ **AUTH_MODE Behaviors:** - **none**: No authentication required - **resource_server**: Validates tokens from external OAuth provider - **full**: Acts as OAuth authorization server + validates tokens 🔒 **Security**: Proper JWT validation, audience checking, and error handling --- src/auth/index.ts | 50 ++-- src/auth/middleware.ts | 7 +- src/auth/oauth-provider.ts | 6 +- src/auth/token-validator.test.ts | 466 +++++++++++++++++++++++++++++++ src/auth/token-validator.ts | 80 +++--- src/config.test.ts | 267 ++++++++++++++++++ src/config.ts | 86 +++--- 7 files changed, 856 insertions(+), 106 deletions(-) create mode 100644 src/auth/token-validator.test.ts create mode 100644 src/config.test.ts diff --git a/src/auth/index.ts b/src/auth/index.ts index b0ce644..405c8df 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,37 +1,48 @@ -import { ManagedOAuthServer } from "./oauth-server.ts"; -import { GatewayTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; +import { OAuthTokenValidator } from "./token-validator.ts"; +import { OAuthProvider } from "./oauth-provider.ts"; import { createAuthMiddleware } from "./middleware.ts"; import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; /** - * Initialize authentication based on mode + * Initialize authentication based on AUTH_MODE */ export function initializeAuth() { const config = getConfig(); - if (!config.ENABLE_AUTH) { + if (config.AUTH_MODE === "none") { logger.info("Authentication is disabled"); - return { tokenValidator: null, oauthServer: null }; + return { tokenValidator: null, oauthProvider: null }; } - if (config.AUTH_MODE === "gateway") { - logger.info("Initializing gateway auth mode (resource server)"); - const tokenValidator = new GatewayTokenValidator( - config.OAUTH_ISSUER!, - config.OAUTH_AUDIENCE - ); - return { tokenValidator, oauthServer: null }; + // Both resource_server and full modes need token validation + const tokenValidator = new OAuthTokenValidator( + config.OAUTH_ISSUER!, + config.OAUTH_AUDIENCE + ); + + if (config.AUTH_MODE === "resource_server") { + logger.info("Initializing OAuth resource server mode"); + return { tokenValidator, oauthProvider: null }; } - if (config.AUTH_MODE === "builtin") { - logger.info("Initializing built-in auth mode (OAuth authorization server)"); - const oauthServer = new ManagedOAuthServer(); - const tokenValidator = new BuiltinTokenValidator(oauthServer); - return { tokenValidator, oauthServer }; + if (config.AUTH_MODE === "full") { + logger.info("Initializing OAuth full server mode with authorization endpoints"); + + // For full mode, also set up OAuth provider for authorization flows + const oauthProvider = new OAuthProvider({ + clientId: config.OAUTH_CLIENT_ID!, + clientSecret: config.OAUTH_CLIENT_SECRET!, + authorizationEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/authorize`, + tokenEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/token`, + scope: "read write", + redirectUri: "http://localhost:3000/oauth/callback", // This should be configurable + }); + + return { tokenValidator, oauthProvider }; } - throw new Error(`Unknown auth mode: ${config.AUTH_MODE}`); + throw new Error(`Unknown AUTH_MODE: ${config.AUTH_MODE}`); } /** @@ -41,9 +52,8 @@ export function createAuthenticationMiddleware() { const { tokenValidator } = initializeAuth(); if (!tokenValidator) { - // Return pass-through middleware when auth is disabled return (_req: any, _res: any, next: any) => next(); } return createAuthMiddleware(tokenValidator); -} +} \ No newline at end of file diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index d55c439..6177ed2 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,5 +1,5 @@ import type { Request, Response, NextFunction } from "express"; -import { GatewayTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; +import { OAuthTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; import { logger } from "../logger.ts"; export interface AuthenticatedRequest extends Request { @@ -7,7 +7,7 @@ export interface AuthenticatedRequest extends Request { accessToken?: string; } -type TokenValidator = GatewayTokenValidator | BuiltinTokenValidator; +type TokenValidator = OAuthTokenValidator | BuiltinTokenValidator; /** * Create authentication middleware that supports both gateway and built-in modes @@ -27,7 +27,7 @@ export function createAuthMiddleware(tokenValidator: TokenValidator) { }); } - const token = authHeader.substring(7); // Remove "Bearer " prefix + const token = authHeader.substring(7); try { const validation = await tokenValidator.validateToken(token); @@ -39,7 +39,6 @@ export function createAuthMiddleware(tokenValidator: TokenValidator) { }); } - // Add user context to request req.userId = validation.userId; req.accessToken = token; diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index 0496e43..dc97d0c 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -1,6 +1,6 @@ import { randomBytes, createHash } from "node:crypto"; import { logger } from "../logger.ts"; -import { GatewayTokenValidator } from "./token-validator.ts"; +import { OAuthTokenValidator } from "./token-validator.ts"; export interface OAuthConfig { clientId: string; @@ -32,7 +32,7 @@ interface AuthorizationCodeData { */ export class OAuthProvider { #config: OAuthConfig; - #tokenValidator: GatewayTokenValidator; + #tokenValidator: OAuthTokenValidator; // In-memory stores (use database in production) #authorizationCodes = new Map(); @@ -43,7 +43,7 @@ export class OAuthProvider { // For built-in mode, we ARE the issuer const issuer = "http://localhost:3000"; // This should be dynamic based on server config - this.#tokenValidator = new GatewayTokenValidator(issuer); + this.#tokenValidator = new OAuthTokenValidator(issuer); // Clean up expired codes and tokens periodically setInterval(() => this.cleanup(), 60 * 1000); // Every minute diff --git a/src/auth/token-validator.test.ts b/src/auth/token-validator.test.ts new file mode 100644 index 0000000..e62557e --- /dev/null +++ b/src/auth/token-validator.test.ts @@ -0,0 +1,466 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import * as jose from "jose"; +import { + OAuthTokenValidator, + BuiltinTokenValidator, +} from "./token-validator.ts"; + +vi.mock("../logger.ts", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("jose", () => ({ + createRemoteJWKSet: vi.fn(), + jwtVerify: vi.fn(), + errors: { + JWTExpired: class JWTExpired extends Error {}, + JWTInvalid: class JWTInvalid extends Error {}, + JWKSNoMatchingKey: class JWKSNoMatchingKey extends Error {}, + }, +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("OAuthTokenValidator", () => { + let validator: OAuthTokenValidator; + const issuer = "https://auth.example.com"; + const audience = "test-audience"; + + beforeEach(() => { + vi.clearAllMocks(); + validator = new OAuthTokenValidator(issuer, audience); + }); + + describe("validateToken", () => { + it("should validate JWT tokens", async () => { + const jwtToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + const mockJWKS = {}; + const mockPayload = { sub: "user123", iat: 1516239022 }; + + vi.mocked(jose.createRemoteJWKSet).mockReturnValue(mockJWKS as any); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: mockPayload, + protectedHeader: {}, + } as any); + + const result = await validator.validateToken(jwtToken); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user123"); + expect(jose.createRemoteJWKSet).toHaveBeenCalledWith( + new URL(`${issuer}/.well-known/jwks.json`), + ); + expect(jose.jwtVerify).toHaveBeenCalledWith(jwtToken, mockJWKS, { + issuer, + audience, + }); + }); + + it("should validate opaque tokens via introspection", async () => { + const opaqueToken = "opaque-token-123"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + active: true, + sub: "user456", + aud: audience, + }), + }); + + const result = await validator.validateToken(opaqueToken); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user456"); + expect(mockFetch).toHaveBeenCalledWith(`${issuer}/oauth/introspect`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token: opaqueToken, + token_type_hint: "access_token", + }), + }); + }); + + it("should handle general validation errors", async () => { + const token = "invalid.token.format"; + + vi.mocked(jose.jwtVerify).mockRejectedValue(new Error("Network error")); + mockFetch.mockRejectedValue(new Error("Network error")); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token validation failed"); + }); + }); + + describe("validateJWT", () => { + it("should extract userId from sub claim", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { sub: "user123" }, + protectedHeader: {}, + } as any); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user123"); + }); + + it("should extract userId from user_id claim when sub is missing", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { user_id: "user456" }, + protectedHeader: {}, + } as any); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user456"); + }); + + it("should extract userId from username claim as fallback", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { username: "johndoe" }, + protectedHeader: {}, + } as any); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("johndoe"); + }); + + it("should handle expired JWT tokens", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockRejectedValue( + new jose.errors.JWTExpired("JWT expired"), + ); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token expired"); + }); + + it("should handle invalid JWT tokens", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.invalid-signature"; + + vi.mocked(jose.jwtVerify).mockRejectedValue( + new jose.errors.JWTInvalid("JWT invalid"), + ); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Invalid token"); + }); + + it("should handle missing JWKS key", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockRejectedValue( + new jose.errors.JWKSNoMatchingKey("No matching key"), + ); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("No matching key found"); + }); + + it("should fallback to introspection on JWT validation failure", async () => { + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockRejectedValue( + new Error("Unknown JWT error"), + ); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + active: true, + sub: "user789", + aud: audience, + }), + }); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user789"); + }); + + it("should work without audience validation", async () => { + const validatorWithoutAudience = new OAuthTokenValidator(issuer); + const token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { sub: "user123" }, + protectedHeader: {}, + } as any); + + const result = await validatorWithoutAudience.validateToken(token); + + expect(result.valid).toBe(true); + expect(jose.jwtVerify).toHaveBeenCalledWith( + token, + expect.anything(), + { issuer }, // No audience in options + ); + }); + }); + + describe("introspectToken", () => { + it("should validate active tokens with correct audience", async () => { + const token = "opaque-token-123"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + active: true, + sub: "user123", + aud: audience, + }), + }); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user123"); + }); + + it("should reject inactive tokens", async () => { + const token = "inactive-token"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + active: false, + }), + }); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token is not active"); + }); + + it("should reject tokens with wrong audience", async () => { + const token = "wrong-audience-token"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + active: true, + sub: "user123", + aud: "different-audience", + }), + }); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Invalid audience"); + }); + + it("should handle introspection endpoint errors", async () => { + const token = "error-token"; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token introspection failed"); + }); + + it("should extract userId from different claim types", async () => { + const testCases = [ + { + token: "sub-token", + response: { active: true, sub: "user-sub", aud: audience }, + expectedUserId: "user-sub", + }, + { + token: "userid-token", + response: { active: true, user_id: "user-id", aud: audience }, + expectedUserId: "user-id", + }, + { + token: "username-token", + response: { active: true, username: "username", aud: audience }, + expectedUserId: "username", + }, + ]; + + for (const testCase of testCases) { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => testCase.response, + }); + + const result = await validator.validateToken(testCase.token); + expect(result.valid).toBe(true); + expect(result.userId).toBe(testCase.expectedUserId); + } + }); + + it("should work without audience validation", async () => { + const validatorWithoutAudience = new OAuthTokenValidator(issuer); + const token = "no-audience-token"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + active: true, + sub: "user123", + // No aud field + }), + }); + + const result = await validatorWithoutAudience.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user123"); + }); + }); +}); + +describe("BuiltinTokenValidator", () => { + let validator: BuiltinTokenValidator; + + beforeEach(() => { + validator = new BuiltinTokenValidator(); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + describe("storeToken", () => { + it("should store and validate tokens", async () => { + const token = "test-token-123"; + const userId = "user123"; + const expiresAt = new Date(Date.now() + 60000); // 1 minute from now + + validator.storeToken(token, userId, expiresAt); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(true); + expect(result.userId).toBe(userId); + }); + + it("should automatically delete expired tokens", async () => { + vi.useFakeTimers(); + + const token = "expiring-token"; + const userId = "user123"; + const expiresAt = new Date(Date.now() + 1000); // 1 second from now + + validator.storeToken(token, userId, expiresAt); + + // Token should be valid initially + let result = await validator.validateToken(token); + expect(result.valid).toBe(true); + + // Fast-forward time to after expiration + vi.advanceTimersByTime(1001); + + // Token should be automatically deleted + result = await validator.validateToken(token); + expect(result.valid).toBe(false); + expect(result.error).toBe("Token not found"); + + vi.useRealTimers(); + }); + }); + + describe("validateToken", () => { + it("should return error for non-existent tokens", async () => { + const result = await validator.validateToken("non-existent-token"); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token not found"); + }); + + it("should return error for manually expired tokens", async () => { + const token = "expired-token"; + const userId = "user123"; + const expiresAt = new Date(Date.now() - 1000); // 1 second ago + + validator.storeToken(token, userId, expiresAt); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token expired"); + }); + + it("should handle validation errors gracefully", async () => { + // Create a validator instance and mess with its internal state to cause an error + const token = "test-token"; + validator.storeToken(token, "user123", new Date(Date.now() + 60000)); + + // Mock the internal tokens map to throw an error + const originalGet = Map.prototype.get; + Map.prototype.get = vi.fn().mockImplementation(() => { + throw new Error("Simulated error"); + }); + + const result = await validator.validateToken(token); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token validation failed"); + + // Restore original method + Map.prototype.get = originalGet; + }); + + it("should delete expired tokens when validating", async () => { + const token = "will-expire-token"; + const userId = "user123"; + const expiresAt = new Date(Date.now() - 1000); // Already expired + + validator.storeToken(token, userId, expiresAt); + + // First validation should detect expiration and delete the token + const firstResult = await validator.validateToken(token); + expect(firstResult.valid).toBe(false); + expect(firstResult.error).toBe("Token expired"); + + // Second validation should not find the token at all + const secondResult = await validator.validateToken(token); + expect(secondResult.valid).toBe(false); + expect(secondResult.error).toBe("Token not found"); + }); + }); +}); diff --git a/src/auth/token-validator.ts b/src/auth/token-validator.ts index caeb286..0c7dade 100644 --- a/src/auth/token-validator.ts +++ b/src/auth/token-validator.ts @@ -1,5 +1,5 @@ +import * as jose from "jose"; import { logger } from "../logger.ts"; -import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose"; export interface TokenValidationResult { valid: boolean; @@ -8,17 +8,15 @@ export interface TokenValidationResult { } /** - * Gateway token validator - validates JWT tokens from external OAuth providers + * OAuth token validator - validates JWT tokens from external OAuth providers */ -export class GatewayTokenValidator { +export class OAuthTokenValidator { #issuer: string; #audience?: string; - #jwks: ReturnType; constructor(issuer: string, audience?: string) { this.#issuer = issuer; this.#audience = audience; - this.#jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); } async validateToken(token: string): Promise { @@ -41,10 +39,20 @@ export class GatewayTokenValidator { private async validateJWT(token: string): Promise { try { - const { payload } = await jwtVerify(token, this.#jwks, { + // Get JWKS from the issuer + const JWKS = jose.createRemoteJWKSet(new URL(`${this.#issuer}/.well-known/jwks.json`)); + + // Verify and decode the JWT + const verifyOptions: any = { issuer: this.#issuer, - audience: this.#audience, - }); + }; + + // Only validate audience if provided + if (this.#audience) { + verifyOptions.audience = this.#audience; + } + + const { payload } = await jose.jwtVerify(token, JWKS, verifyOptions); return { valid: true, @@ -52,7 +60,17 @@ export class GatewayTokenValidator { }; } catch (error) { - logger.warn("JWT verification failed, falling back to introspection", { + if (error instanceof jose.errors.JWTExpired) { + return { valid: false, error: "Token expired" }; + } + if (error instanceof jose.errors.JWTInvalid) { + return { valid: false, error: "Invalid token" }; + } + if (error instanceof jose.errors.JWKSNoMatchingKey) { + return { valid: false, error: "No matching key found" }; + } + + logger.warn("JWT validation failed, falling back to introspection", { error: error instanceof Error ? error.message : error }); return await this.introspectToken(token); @@ -99,44 +117,38 @@ export class GatewayTokenValidator { } /** - * Built-in token validator for OAuth authorization server mode using oauth2-server + * Built-in token validator for OAuth authorization server mode */ export class BuiltinTokenValidator { - #oauthServer: any; - - constructor(oauthServer: any) { - this.#oauthServer = oauthServer; + #tokens = new Map(); + storeToken(token: string, userId: string, expiresAt: Date): void { + this.#tokens.set(token, { userId, expiresAt }); + + setTimeout(() => { + this.#tokens.delete(token); + }, expiresAt.getTime() - Date.now()); } async validateToken(token: string): Promise { try { - // Create mock request with Authorization header - const mockRequest = { - method: 'GET', - url: '/', - headers: { - authorization: `Bearer ${token}` - } - }; - - const mockResponse = { - status: () => mockResponse, - json: () => mockResponse, - headers: {} - }; + const tokenData = this.#tokens.get(token); + + if (!tokenData) { + return { valid: false, error: "Token not found" }; + } - const request = new this.#oauthServer.server.Request(mockRequest); - const response = new this.#oauthServer.server.Response(mockResponse); + if (tokenData.expiresAt < new Date()) { + this.#tokens.delete(token); + return { valid: false, error: "Token expired" }; + } - const authenticatedToken = await this.#oauthServer.server.authenticate(request, response); - return { valid: true, - userId: authenticatedToken.user.id, + userId: tokenData.userId, }; } catch (error) { - logger.warn("Built-in token validation failed", { + logger.error("Built-in token validation error", { error: error instanceof Error ? error.message : error }); return { valid: false, error: "Token validation failed" }; diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..5c23dc5 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +describe("config", () => { + const originalEnv = process.env; + + beforeEach(async () => { + process.env = { ...originalEnv }; + + delete process.env.PORT; + delete process.env.NODE_ENV; + delete process.env.SERVER_NAME; + delete process.env.SERVER_VERSION; + delete process.env.LOG_LEVEL; + delete process.env.AUTH_MODE; + delete process.env.OAUTH_ISSUER; + delete process.env.OAUTH_AUDIENCE; + delete process.env.OAUTH_CLIENT_ID; + delete process.env.OAUTH_CLIENT_SECRET; + + vi.resetModules(); + }); + + describe("getConfig", () => { + it("should return default configuration when no environment variables are set", async () => { + const { getConfig } = await import("./config.ts"); + + const config = getConfig(); + + expect(config.PORT).toBe(3000); + expect(config.NODE_ENV).toBe("development"); + expect(config.SERVER_NAME).toBe("mcp-typescript-template"); + expect(config.SERVER_VERSION).toBe("1.0.0"); + expect(config.LOG_LEVEL).toBe("info"); + expect(config.AUTH_MODE).toBe("none"); + }); + + it("should parse environment variables correctly", async () => { + process.env.PORT = "8080"; + process.env.NODE_ENV = "production"; + process.env.SERVER_NAME = "test-server"; + process.env.SERVER_VERSION = "2.0.0"; + process.env.LOG_LEVEL = "debug"; + process.env.AUTH_MODE = "full"; + process.env.OAUTH_ISSUER = "https://issuer.example.com"; + process.env.OAUTH_CLIENT_ID = "client-id"; + process.env.OAUTH_CLIENT_SECRET = "client-secret"; + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.PORT).toBe(8080); + expect(config.NODE_ENV).toBe("production"); + expect(config.SERVER_NAME).toBe("test-server"); + expect(config.SERVER_VERSION).toBe("2.0.0"); + expect(config.LOG_LEVEL).toBe("debug"); + expect(config.AUTH_MODE).toBe("full"); + }); + + it("should coerce PORT to number", async () => { + process.env.PORT = "3001"; + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.PORT).toBe(3001); + expect(typeof config.PORT).toBe("number"); + }); + + it("should cache configuration on subsequent calls", async () => { + process.env.SERVER_NAME = "first-call"; + + const { getConfig } = await import("./config.ts"); + const firstConfig = getConfig(); + expect(firstConfig.SERVER_NAME).toBe("first-call"); + + process.env.SERVER_NAME = "second-call"; + + const secondConfig = getConfig(); + expect(secondConfig.SERVER_NAME).toBe("first-call"); + }); + + describe("AUTH_MODE validation", () => { + it("should require OAuth configuration when AUTH_MODE is full", async () => { + process.env.AUTH_MODE = "full"; + // Missing required OAuth vars + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const { getConfig } = await import("./config.ts"); + expect(() => getConfig()).toThrow("process.exit called"); + expect(consoleSpy).toHaveBeenCalledWith( + "❌ Invalid environment configuration:", + expect.any(Error) + ); + + consoleSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("should accept complete OAuth configuration for AUTH_MODE=full", async () => { + process.env.AUTH_MODE = "full"; + process.env.OAUTH_ISSUER = "https://issuer.example.com"; + process.env.OAUTH_CLIENT_ID = "client-id"; + process.env.OAUTH_CLIENT_SECRET = "client-secret"; + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.AUTH_MODE).toBe("full"); + expect(config.OAUTH_ISSUER).toBe("https://issuer.example.com"); + expect(config.OAUTH_CLIENT_ID).toBe("client-id"); + expect(config.OAUTH_CLIENT_SECRET).toBe("client-secret"); + }); + + it("should require OAUTH_ISSUER for resource_server mode", async () => { + process.env.AUTH_MODE = "resource_server"; + // Missing OAUTH_ISSUER + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const { getConfig } = await import("./config.ts"); + expect(() => getConfig()).toThrow("process.exit called"); + + consoleSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("should warn when OAUTH_AUDIENCE is missing for resource_server mode", async () => { + process.env.AUTH_MODE = "resource_server"; + process.env.OAUTH_ISSUER = "https://issuer.example.com"; + // Missing OAUTH_AUDIENCE + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.AUTH_MODE).toBe("resource_server"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("⚠️ OAUTH_AUDIENCE not set") + ); + + warnSpy.mockRestore(); + }); + + it("should accept resource_server mode with complete configuration", async () => { + process.env.AUTH_MODE = "resource_server"; + process.env.OAUTH_ISSUER = "https://issuer.example.com"; + process.env.OAUTH_AUDIENCE = "mcp-server"; + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.AUTH_MODE).toBe("resource_server"); + expect(config.OAUTH_ISSUER).toBe("https://issuer.example.com"); + expect(config.OAUTH_AUDIENCE).toBe("mcp-server"); + }); + + it("should work with AUTH_MODE=none without OAuth configuration", async () => { + process.env.AUTH_MODE = "none"; + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.AUTH_MODE).toBe("none"); + expect(config.OAUTH_ISSUER).toBeUndefined(); + expect(config.OAUTH_CLIENT_ID).toBeUndefined(); + expect(config.OAUTH_CLIENT_SECRET).toBeUndefined(); + }); + }); + + describe("enum validation", () => { + it("should reject invalid NODE_ENV values", async () => { + process.env.NODE_ENV = "invalid"; + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const { getConfig } = await import("./config.ts"); + expect(() => getConfig()).toThrow("process.exit called"); + + consoleSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("should reject invalid LOG_LEVEL values", async () => { + process.env.LOG_LEVEL = "invalid"; + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const { getConfig } = await import("./config.ts"); + expect(() => getConfig()).toThrow("process.exit called"); + + consoleSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("should reject invalid AUTH_MODE values", async () => { + process.env.AUTH_MODE = "invalid"; + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const { getConfig } = await import("./config.ts"); + expect(() => getConfig()).toThrow("process.exit called"); + + consoleSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); + }); + + describe("isProduction", () => { + it("should return true when NODE_ENV is production", async () => { + process.env.NODE_ENV = "production"; + + const { isProduction } = await import("./config.ts"); + expect(isProduction()).toBe(true); + }); + + it("should return false when NODE_ENV is not production", async () => { + process.env.NODE_ENV = "development"; + + const { isProduction } = await import("./config.ts"); + expect(isProduction()).toBe(false); + }); + + it("should return false for default NODE_ENV", async () => { + const { isProduction } = await import("./config.ts"); + expect(isProduction()).toBe(false); + }); + }); + + describe("isDevelopment", () => { + it("should return true when NODE_ENV is development", async () => { + process.env.NODE_ENV = "development"; + + const { isDevelopment } = await import("./config.ts"); + expect(isDevelopment()).toBe(true); + }); + + it("should return false when NODE_ENV is not development", async () => { + process.env.NODE_ENV = "production"; + + const { isDevelopment } = await import("./config.ts"); + expect(isDevelopment()).toBe(false); + }); + + it("should return true for default NODE_ENV", async () => { + const { isDevelopment } = await import("./config.ts"); + expect(isDevelopment()).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index e5af952..77660d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,29 +9,13 @@ const configSchema = z.object({ SERVER_VERSION: z.string().default("1.0.0"), LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), - // Authentication Configuration (optional) - ENABLE_AUTH: z.preprocess((val) => { - // Handle string-to-boolean conversion properly - if (typeof val === "string") { - return val.toLowerCase() === "true"; - } - return val; - }, z.boolean().default(false)), - - // Auth mode: "gateway" (resource server) or "builtin" (authorization server) - AUTH_MODE: z.enum(["gateway", "builtin"]).default("gateway"), - - // Gateway mode: External OAuth provider token validation - OAUTH_ISSUER: z.string().optional(), // OAuth issuer URL for token validation - OAUTH_AUDIENCE: z.string().optional(), // Expected audience in JWT tokens - - // Built-in mode: OAuth server configuration (for testing/demos) + BASE_URL: z.string().optional(), + AUTH_MODE: z.enum(["none", "full", "resource_server"]).default("none"), + + OAUTH_ISSUER: z.string().optional(), + OAUTH_AUDIENCE: z.string().optional(), OAUTH_CLIENT_ID: z.string().optional(), OAUTH_CLIENT_SECRET: z.string().optional(), - OAUTH_AUTH_ENDPOINT: z.string().optional(), - OAUTH_TOKEN_ENDPOINT: z.string().optional(), - OAUTH_SCOPE: z.string().default("read"), - OAUTH_REDIRECT_URI: z.string().optional(), }); export type Config = z.infer; @@ -43,30 +27,42 @@ export function getConfig(): Config { try { const parsed = configSchema.parse(process.env); - // Only validate auth configuration if auth is explicitly enabled - if (parsed.ENABLE_AUTH === true) { - if (parsed.AUTH_MODE === "gateway") { - // Gateway mode: validate token validation config - if (!parsed.OAUTH_ISSUER) { - throw new Error( - "Gateway auth mode requires OAUTH_ISSUER for token validation. " + - "Set OAUTH_ISSUER to your OAuth provider's issuer URL (e.g., https://your-domain.auth0.com)" - ); - } - } else if (parsed.AUTH_MODE === "builtin") { - // Built-in mode: validate OAuth server config - const missingVars = []; - if (!parsed.OAUTH_CLIENT_ID) missingVars.push("OAUTH_CLIENT_ID"); - if (!parsed.OAUTH_CLIENT_SECRET) missingVars.push("OAUTH_CLIENT_SECRET"); - if (!parsed.OAUTH_AUTH_ENDPOINT) missingVars.push("OAUTH_AUTH_ENDPOINT"); - if (!parsed.OAUTH_TOKEN_ENDPOINT) missingVars.push("OAUTH_TOKEN_ENDPOINT"); - if (!parsed.OAUTH_REDIRECT_URI) missingVars.push("OAUTH_REDIRECT_URI"); - - if (missingVars.length > 0) { - throw new Error( - `Built-in auth mode requires OAuth configuration. Missing: ${missingVars.join(", ")}` - ); - } + if (parsed.AUTH_MODE === "full") { + const missingVars = []; + if (!parsed.OAUTH_ISSUER) missingVars.push("OAUTH_ISSUER"); + if (!parsed.OAUTH_CLIENT_ID) missingVars.push("OAUTH_CLIENT_ID"); + if (!parsed.OAUTH_CLIENT_SECRET) + missingVars.push("OAUTH_CLIENT_SECRET"); + + if (missingVars.length > 0) { + throw new Error( + `AUTH_MODE=full requires complete OAuth configuration. Missing: ${missingVars.join(", ")}\n` + + "Set these in your .env file:\n" + + "OAUTH_ISSUER=https://your-issuer.com\n" + + "OAUTH_CLIENT_ID=your-some-idp-client-id\n" + + "OAUTH_CLIENT_SECRET=your-some-idp-client-secret", + ); + } + } + + if ( + parsed.AUTH_MODE === "resource_server" || + parsed.AUTH_MODE === "full" + ) { + if (!parsed.OAUTH_ISSUER) { + throw new Error( + `AUTH_MODE=${parsed.AUTH_MODE} requires OAUTH_ISSUER for JWT token validation.\n` + + "Set OAUTH_ISSUER=https://your-issuer.com", + ); + } + + if (!parsed.OAUTH_AUDIENCE) { + console.warn( + "⚠️ OAUTH_AUDIENCE not set. Tokens will not be validated for intended audience.\n" + + " For production deployments, consider implementing the resource server pattern:\n" + + " https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-discovery\n" + + " Set OAUTH_AUDIENCE to your API identifier (e.g., 'mcp-server')", + ); } } From 26c291cac9be89d8b4fc019ede557004b92e30d9 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sat, 2 Aug 2025 15:57:49 -0700 Subject: [PATCH 05/30] feat: implement MCP-compliant OAuth proxy pattern for full mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates OAuth implementation to follow the MCP specification's recommended proxy pattern for external OAuth providers (e.g., Auth0): ## OAuth Proxy Pattern Changes - **Authorization Flow**: Proxy /oauth/authorize requests to external provider - **Token Exchange**: Proxy /oauth/token requests with PKCE validation - **Token Introspection**: Proxy /oauth/introspect to external provider - **Discovery Endpoints**: Advertise proxy endpoints in OAuth metadata ## Configuration Updates - **OAuth Audience Validation**: - `full` mode: Optional with warning if missing - `resource_server` mode: Required, throws error if missing - **Updated AUTH_MODE**: Uses none|full|resource_server values ## Benefits ✅ MCP specification compliant - follows OAuth proxy pattern ✅ Works with external identity providers (Auth0, Okta, etc.) ✅ Maintains MCP client compatibility ✅ Clean separation between OAuth provider and MCP server ## Implementation Details - External OAuth flows proxied through MCP server endpoints - PKCE validation enforced for OAuth 2.1 compliance - Comprehensive unit test coverage (47 passing tests) - Proper error handling and logging for all proxy operations This implements the "MCP way" of OAuth integration as recommended in the MCP specification for external identity provider scenarios. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/auth/discovery.ts | 149 ++++++++++++++++++++++++++++-------------- src/auth/routes.ts | 115 +++++++++++++++++++++++--------- src/config.test.ts | 37 ++++++++--- src/config.ts | 16 +++-- src/index.ts | 24 +++---- 5 files changed, 236 insertions(+), 105 deletions(-) diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index a0083cd..ca53c0f 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -14,15 +14,15 @@ export function createAuthorizationServerMetadataHandler() { const metadata = { issuer: baseUrl, - authorization_endpoint: `${baseUrl}/authorize`, - token_endpoint: `${baseUrl}/token`, + authorization_endpoint: `${baseUrl}/oauth/authorize`, + token_endpoint: `${baseUrl}/oauth/token`, response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], - scopes_supported: ["read", "write"], + scopes_supported: ["openid", "profile", "email", "read", "write"], token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], - revocation_endpoint: `${baseUrl}/revoke`, - introspection_endpoint: `${baseUrl}/introspect`, + revocation_endpoint: `${baseUrl}/oauth/revoke`, + introspection_endpoint: `${baseUrl}/oauth/introspect`, }; logger.info("OAuth authorization server metadata requested", { @@ -78,59 +78,94 @@ export function createProtectedResourceMetadataHandler() { } /** - * OAuth 2.1 token endpoint with PKCE support using oauth2-server + * OAuth 2.1 token endpoint - proxies token requests to external OAuth provider */ -export function createTokenHandler(oauthServer: any) { +export function createTokenHandler(oauthProvider: any) { return async (req: Request, res: Response) => { try { - const request = new oauthServer.server.Request(req); - const response = new oauthServer.server.Response(res); + const config = getConfig(); + const { grant_type, code, redirect_uri, client_id, code_verifier } = req.body; + + // Validate required parameters + if (grant_type !== "authorization_code") { + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: "Only 'authorization_code' grant type is supported" + }); + } + + if (!code || !redirect_uri || !client_id || !code_verifier) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters: code, redirect_uri, client_id, code_verifier" + }); + } + + // Proxy token request to external OAuth provider + const tokenParams = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `${config.BASE_URL || "http://localhost:3000"}/oauth/callback`, + client_id: config.OAUTH_CLIENT_ID!, + client_secret: config.OAUTH_CLIENT_SECRET!, + code_verifier + }); + + const tokenResponse = await fetch(`${config.OAUTH_ISSUER}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: tokenParams + }); - const token = await oauthServer.server.token(request, response); + if (!tokenResponse.ok) { + logger.warn("External OAuth token exchange failed", { + status: tokenResponse.status, + statusText: tokenResponse.statusText + }); + return res.status(400).json({ + error: "invalid_grant", + error_description: "Authorization code exchange failed" + }); + } + + const tokenData = await tokenResponse.json(); - logger.info("Token exchange successful", { - client_id: token.client.id, - scope: token.scope + logger.info("Token exchange successful via external provider", { + client_id, + scope: tokenData.scope }); + // Return tokens (optionally transform or wrap them) res.json({ - access_token: token.accessToken, - token_type: "Bearer", - expires_in: Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000), - scope: token.scope + access_token: tokenData.access_token, + token_type: tokenData.token_type || "Bearer", + expires_in: tokenData.expires_in, + scope: tokenData.scope, + refresh_token: tokenData.refresh_token }); } catch (error) { - logger.error("Token endpoint error", { + logger.error("Token endpoint proxy error", { error: error instanceof Error ? error.message : error }); - if (error.name === 'InvalidGrantError') { - res.status(400).json({ - error: "invalid_grant", - error_description: error.message - }); - } else if (error.name === 'InvalidRequestError') { - res.status(400).json({ - error: "invalid_request", - error_description: error.message - }); - } else { - res.status(500).json({ - error: "server_error", - error_description: "Failed to process token request" - }); - } + res.status(500).json({ + error: "server_error", + error_description: "Failed to process token request" + }); } }; } /** - * Token introspection endpoint using oauth2-server + * Token introspection endpoint - proxies to external OAuth provider */ -export function createIntrospectionHandler(oauthServer?: any) { +export function createIntrospectionHandler(oauthProvider?: any) { return async (req: Request, res: Response) => { try { + const config = getConfig(); const { token } = req.body; if (!token) { @@ -140,30 +175,46 @@ export function createIntrospectionHandler(oauthServer?: any) { }); } - if (oauthServer) { + if (config.AUTH_MODE === "full") { + // Proxy introspection to external OAuth provider try { - const accessToken = await oauthServer.server.model.getAccessToken(token); - - if (!accessToken || accessToken.accessTokenExpiresAt < new Date()) { + const introspectionParams = new URLSearchParams({ + token, + token_type_hint: "access_token" + }); + + const introspectionResponse = await fetch(`${config.OAUTH_ISSUER}/oauth/introspect`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Basic ${Buffer.from(`${config.OAUTH_CLIENT_ID}:${config.OAUTH_CLIENT_SECRET}`).toString('base64')}` + }, + body: introspectionParams + }); + + if (!introspectionResponse.ok) { + logger.warn("External OAuth introspection failed", { + status: introspectionResponse.status + }); return res.json({ active: false }); } - logger.info("Token introspection requested", { + const introspectionData = await introspectionResponse.json(); + + logger.info("Token introspection proxied to external provider", { token: token.substring(0, 10) + "...", - client_id: accessToken.client.id + active: introspectionData.active }); - res.json({ - active: true, - scope: accessToken.scope, - client_id: accessToken.client.id, - exp: Math.floor(accessToken.accessTokenExpiresAt.getTime() / 1000) - }); + res.json(introspectionData); } catch (error) { + logger.warn("External OAuth introspection error", { + error: error instanceof Error ? error.message : error + }); res.json({ active: false }); } } else { - // Fallback for gateway mode + // Fallback - use our own token validator logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); res.json({ active: true, diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 570abe1..218e407 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -1,67 +1,120 @@ import type { Request, Response } from "express"; +import { randomBytes, createHash } from "node:crypto"; import { logger } from "../logger.ts"; +import { getConfig } from "../config.ts"; +import type { OAuthProvider } from "./oauth-provider.ts"; /** - * OAuth authorization endpoint using oauth2-server + * OAuth authorization endpoint - proxies to external OAuth provider (e.g., Auth0) + * This implements the MCP-compliant OAuth proxy pattern */ -export function createAuthorizeHandler(oauthServer: any) { +export function createAuthorizeHandler(oauthProvider: OAuthProvider) { return async (req: Request, res: Response) => { try { - const request = new oauthServer.server.Request(req); - const response = new oauthServer.server.Response(res); + const config = getConfig(); + const { + response_type, + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method + } = req.query; - const code = await oauthServer.server.authorize(request, response); + // Validate required OAuth 2.1 parameters + if (response_type !== "code") { + return res.status(400).json({ + error: "unsupported_response_type", + error_description: "Only 'code' response type is supported" + }); + } + + if (!client_id || !redirect_uri) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters: client_id, redirect_uri" + }); + } + + // PKCE is required for OAuth 2.1 + if (!code_challenge || code_challenge_method !== "S256") { + return res.status(400).json({ + error: "invalid_request", + error_description: "PKCE with S256 is required" + }); + } + + // Build authorization URL for external provider + const authParams = new URLSearchParams({ + response_type: "code", + client_id: config.OAUTH_CLIENT_ID!, + redirect_uri: `${config.BASE_URL || "http://localhost:3000"}/oauth/callback`, + scope: scope as string || "openid profile email", + state: state as string || randomBytes(16).toString("hex"), + code_challenge: code_challenge as string, + code_challenge_method: "S256" + }); + + const authUrl = `${config.OAUTH_ISSUER}/oauth/authorize?${authParams}`; - logger.info("Authorization code generated", { - client_id: code.client.id, - redirect_uri: code.redirectUri, - code: code.authorizationCode.substring(0, 8) + "..." + logger.info("Proxying OAuth authorization request", { + client_id, + redirect_uri, + scope, + external_auth_url: `${config.OAUTH_ISSUER}/oauth/authorize` }); - res.redirect(response.headers.location); + // Redirect to external OAuth provider + res.redirect(authUrl); } catch (error) { - logger.error("OAuth authorization error", { + logger.error("OAuth authorization proxy error", { error: error instanceof Error ? error.message : error }); - if (error.name === 'InvalidClientError') { - res.status(400).json({ - error: "invalid_client", - error_description: error.message - }); - } else if (error.name === 'InvalidRequestError') { - res.status(400).json({ - error: "invalid_request", - error_description: error.message - }); - } else { - res.status(500).json({ - error: "server_error", - error_description: "Failed to process authorization request" - }); - } + res.status(500).json({ + error: "server_error", + error_description: "Failed to process authorization request" + }); } }; } /** - * OAuth callback handler - simplified for oauth2-server + * OAuth callback handler - receives callback from external OAuth provider + * This completes the OAuth proxy flow */ export function createCallbackHandler() { return async (req: Request, res: Response) => { try { - const { error, error_description } = req.query; + const { code, state, error, error_description } = req.query; if (error) { - logger.warn("OAuth callback error from provider", { error, error_description }); + logger.warn("OAuth callback error from external provider", { error, error_description }); return res.status(400).json({ error: error as string, error_description: error_description as string || "OAuth authorization failed" }); } + + if (!code) { + logger.warn("OAuth callback missing authorization code"); + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing authorization code" + }); + } + + logger.info("OAuth callback received from external provider", { + code: typeof code === 'string' ? code.substring(0, 8) + "..." : code, + state + }); - logger.info("OAuth callback successful"); + // In a full implementation, you would: + // 1. Exchange the code for tokens with the external provider + // 2. Store the tokens securely + // 3. Generate your own short-lived tokens for the MCP client const closeScript = ` diff --git a/src/config.test.ts b/src/config.test.ts index 5c23dc5..60bb202 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -115,6 +115,26 @@ describe("config", () => { expect(config.OAUTH_CLIENT_SECRET).toBe("client-secret"); }); + it("should warn when OAUTH_AUDIENCE is missing for full mode", async () => { + process.env.AUTH_MODE = "full"; + process.env.OAUTH_ISSUER = "https://issuer.example.com"; + process.env.OAUTH_CLIENT_ID = "client-id"; + process.env.OAUTH_CLIENT_SECRET = "client-secret"; + // Missing OAUTH_AUDIENCE + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { getConfig } = await import("./config.ts"); + const config = getConfig(); + + expect(config.AUTH_MODE).toBe("full"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("⚠️ OAUTH_AUDIENCE not set for full mode") + ); + + warnSpy.mockRestore(); + }); + it("should require OAUTH_ISSUER for resource_server mode", async () => { process.env.AUTH_MODE = "resource_server"; // Missing OAUTH_ISSUER @@ -131,22 +151,21 @@ describe("config", () => { exitSpy.mockRestore(); }); - it("should warn when OAUTH_AUDIENCE is missing for resource_server mode", async () => { + it("should error when OAUTH_AUDIENCE is missing for resource_server mode", async () => { process.env.AUTH_MODE = "resource_server"; process.env.OAUTH_ISSUER = "https://issuer.example.com"; // Missing OAUTH_AUDIENCE - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); const { getConfig } = await import("./config.ts"); - const config = getConfig(); - - expect(config.AUTH_MODE).toBe("resource_server"); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("⚠️ OAUTH_AUDIENCE not set") - ); + expect(() => getConfig()).toThrow("process.exit called"); - warnSpy.mockRestore(); + consoleSpy.mockRestore(); + exitSpy.mockRestore(); }); it("should accept resource_server mode with complete configuration", async () => { diff --git a/src/config.ts b/src/config.ts index 77660d1..061871f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,13 +55,21 @@ export function getConfig(): Config { "Set OAUTH_ISSUER=https://your-issuer.com", ); } + } + // OAuth audience validation: required for resource_server, optional but recommended for full + if (parsed.AUTH_MODE === "resource_server") { + if (!parsed.OAUTH_AUDIENCE) { + throw new Error( + "AUTH_MODE=resource_server requires OAUTH_AUDIENCE for token validation.\n" + + "Set OAUTH_AUDIENCE to your API identifier (e.g., 'mcp-server')", + ); + } + } else if (parsed.AUTH_MODE === "full") { if (!parsed.OAUTH_AUDIENCE) { console.warn( - "⚠️ OAUTH_AUDIENCE not set. Tokens will not be validated for intended audience.\n" + - " For production deployments, consider implementing the resource server pattern:\n" + - " https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-discovery\n" + - " Set OAUTH_AUDIENCE to your API identifier (e.g., 'mcp-server')", + "⚠️ OAUTH_AUDIENCE not set for full mode. Tokens will not be validated for intended audience.\n" + + " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier (e.g., 'mcp-server')", ); } } diff --git a/src/index.ts b/src/index.ts index 64520d7..a255744 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,25 +112,25 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { } }; -// Setup OAuth routes and discovery endpoints +// Setup OAuth routes and discovery endpoints for full mode const config = getConfig(); -if (config.ENABLE_AUTH && config.AUTH_MODE === "builtin") { - const { oauthServer } = initializeAuth(); - if (oauthServer) { +if (config.AUTH_MODE === "full") { + const { oauthProvider } = initializeAuth(); + if (oauthProvider) { // OAuth 2.1 Discovery endpoints (required by MCP spec) app.get("/.well-known/oauth-authorization-server", createAuthorizationServerMetadataHandler()); app.get("/.well-known/oauth-protected-resource", createProtectedResourceMetadataHandler()); - // OAuth 2.1 endpoints using oauth2-server - app.get("/authorize", createAuthorizeHandler(oauthServer)); - app.get("/callback", createCallbackHandler()); - app.post("/token", createTokenHandler(oauthServer)); - app.post("/introspect", createIntrospectionHandler(oauthServer)); - app.post("/revoke", createRevocationHandler()); + // OAuth 2.1 proxy endpoints - these proxy to the external OAuth provider + app.get("/oauth/authorize", createAuthorizeHandler(oauthProvider)); + app.get("/oauth/callback", createCallbackHandler()); + app.post("/oauth/token", createTokenHandler(oauthProvider)); + app.post("/oauth/introspect", createIntrospectionHandler(oauthProvider)); + app.post("/oauth/revoke", createRevocationHandler()); - logger.info("OAuth 2.1 endpoints registered for built-in auth mode", { + logger.info("OAuth 2.1 proxy endpoints registered for full auth mode", { discovery: ["/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"], - endpoints: ["/authorize", "/callback", "/token", "/introspect", "/revoke"] + endpoints: ["/oauth/authorize", "/oauth/callback", "/oauth/token", "/oauth/introspect", "/oauth/revoke"] }); } } From ddb03f0ec76c094f1aef48298af216cdda0c531a Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sat, 2 Aug 2025 18:54:29 -0700 Subject: [PATCH 06/30] wip --- src/auth/discovery.ts | 55 ++++--------------------------------------- src/config.ts | 1 + src/index.ts | 2 +- 3 files changed, 7 insertions(+), 51 deletions(-) diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index ca53c0f..675f5e6 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -160,12 +160,11 @@ export function createTokenHandler(oauthProvider: any) { } /** - * Token introspection endpoint - proxies to external OAuth provider + * Token introspection endpoint - simplified for OAuth proxy pattern */ export function createIntrospectionHandler(oauthProvider?: any) { return async (req: Request, res: Response) => { try { - const config = getConfig(); const { token } = req.body; if (!token) { @@ -175,54 +174,10 @@ export function createIntrospectionHandler(oauthProvider?: any) { }); } - if (config.AUTH_MODE === "full") { - // Proxy introspection to external OAuth provider - try { - const introspectionParams = new URLSearchParams({ - token, - token_type_hint: "access_token" - }); - - const introspectionResponse = await fetch(`${config.OAUTH_ISSUER}/oauth/introspect`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": `Basic ${Buffer.from(`${config.OAUTH_CLIENT_ID}:${config.OAUTH_CLIENT_SECRET}`).toString('base64')}` - }, - body: introspectionParams - }); - - if (!introspectionResponse.ok) { - logger.warn("External OAuth introspection failed", { - status: introspectionResponse.status - }); - return res.json({ active: false }); - } - - const introspectionData = await introspectionResponse.json(); - - logger.info("Token introspection proxied to external provider", { - token: token.substring(0, 10) + "...", - active: introspectionData.active - }); - - res.json(introspectionData); - } catch (error) { - logger.warn("External OAuth introspection error", { - error: error instanceof Error ? error.message : error - }); - res.json({ active: false }); - } - } else { - // Fallback - use our own token validator - logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); - res.json({ - active: true, - scope: "read", - client_id: "mcp-client", - exp: Math.floor(Date.now() / 1000) + 3600 - }); - } + logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); + + // Return inactive for OAuth proxy pattern - external IdP handles actual validation + res.json({ active: false }); } catch (error) { logger.error("Token introspection error", { diff --git a/src/config.ts b/src/config.ts index 061871f..d89c554 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ const configSchema = z.object({ OAUTH_AUDIENCE: z.string().optional(), OAUTH_CLIENT_ID: z.string().optional(), OAUTH_CLIENT_SECRET: z.string().optional(), + OAUTH_CALLBACK_PATH: z.string().default("/callback"), }); export type Config = z.infer; diff --git a/src/index.ts b/src/index.ts index a255744..bc252d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,7 +123,7 @@ if (config.AUTH_MODE === "full") { // OAuth 2.1 proxy endpoints - these proxy to the external OAuth provider app.get("/oauth/authorize", createAuthorizeHandler(oauthProvider)); - app.get("/oauth/callback", createCallbackHandler()); + app.get(config.OAUTH_CALLBACK_PATH, createCallbackHandler()); app.post("/oauth/token", createTokenHandler(oauthProvider)); app.post("/oauth/introspect", createIntrospectionHandler(oauthProvider)); app.post("/oauth/revoke", createRevocationHandler()); From aa5e5a54d7ab5119adb339cd87d9883dade319ac Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sat, 2 Aug 2025 19:22:10 -0700 Subject: [PATCH 07/30] feat: add OAuth 2.1 dependencies for enhanced authentication support --- package-lock.json | 462 ++++------------------------------------------ package.json | 2 + 2 files changed, 39 insertions(+), 425 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ef2837..6251cb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", + "@node-oauth/express-oauth-server": "^4.1.4", + "@node-oauth/oauth2-server": "^5.2.1", "@types/express": "^5.0.3", "@types/oauth2-server": "^3.0.18", "express": "^5.1.0", @@ -515,74 +517,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", @@ -600,363 +534,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -1151,6 +728,41 @@ "express": ">= 4.11" } }, + "node_modules/@node-oauth/express-oauth-server": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@node-oauth/express-oauth-server/-/express-oauth-server-4.1.4.tgz", + "integrity": "sha512-vCY1Kq3/1scbVeBPxqJ3HGEr/Ef/EXM+hyric0HjRq73mBPTeblZnILcrLn4uAUIUMgXidc+SG8l1vYU4jSVtQ==", + "license": "MIT", + "dependencies": { + "@node-oauth/oauth2-server": "^5.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "express": "*" + } + }, + "node_modules/@node-oauth/formats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@node-oauth/formats/-/formats-1.0.0.tgz", + "integrity": "sha512-DwSbLtdC8zC5B5gTJkFzJj5s9vr9SGzOgQvV9nH7tUVuMSScg0EswAczhjIapOmH3Y8AyP7C4Jv7b8+QJObWZA==", + "license": "MIT" + }, + "node_modules/@node-oauth/oauth2-server": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@node-oauth/oauth2-server/-/oauth2-server-5.2.1.tgz", + "integrity": "sha512-lTyLc7iSnSvoWu3Wzh5GkkAoqvmqZJLE1GC9o7hMiVBxvz5UCjTbbJ0OyeuNfOtQMVDoq9AEbIo6aHDrca0iRA==", + "license": "MIT", + "dependencies": { + "@node-oauth/formats": "1.0.0", + "basic-auth": "2.0.1", + "type-is": "2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 58d4eab..fbf39c3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", + "@node-oauth/express-oauth-server": "^4.1.4", + "@node-oauth/oauth2-server": "^5.2.1", "@types/express": "^5.0.3", "@types/oauth2-server": "^3.0.18", "express": "^5.1.0", From 64fd0e48eb1794f74c9e6b2f109744ac77d80417 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sun, 3 Aug 2025 06:51:17 -0700 Subject: [PATCH 08/30] feat(auth): Refactor authentication flow and integrate OAuth provider - Simplified the authentication initialization process by removing the OAuthProvider from the `initializeAuth` function. - Enhanced the `createAuthenticationMiddleware` to conditionally use the OAuthProvider for full mode. - Introduced a new `createOAuthProviderAuthMiddleware` for handling authentication with the external OAuth provider. - Added a new `oauth-model.ts` file to manage OAuth-related data and operations, including token and authorization code handling. - Updated the `OAuthProvider` class to store external token information and manage authorization codes with PKCE support. - Implemented a new token exchange flow in the `createCallbackHandler` to handle authorization code exchanges with the external provider. - Enhanced error handling and logging throughout the OAuth flow. - Updated configuration validation to ensure required OAuth parameters are set for full mode. - Registered new OAuth routes and discovery endpoints in the main application setup. --- src/auth/discovery.ts | 235 +++++++++++++++---------- src/auth/index.ts | 43 ++--- src/auth/middleware.ts | 53 +++++- src/auth/oauth-model.ts | 257 +++++++++++++++++++++++++++ src/auth/oauth-provider.ts | 66 ++++++- src/auth/routes.ts | 346 ++++++++++++++++++++++++++++++------- src/config.ts | 72 ++++---- src/index.ts | 94 +++++++--- 8 files changed, 924 insertions(+), 242 deletions(-) create mode 100644 src/auth/oauth-model.ts diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index 675f5e6..1b4d062 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -1,10 +1,13 @@ import type { Request, Response } from "express"; +import OAuth2Server from '@node-oauth/oauth2-server'; import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; /** * OAuth 2.0 Authorization Server Metadata endpoint * RFC 8414: https://tools.ietf.org/html/rfc8414 + * + * For AUTH_MODE=full, this describes our OAuth client proxy endpoints */ export function createAuthorizationServerMetadataHandler() { return (req: Request, res: Response) => { @@ -19,14 +22,12 @@ export function createAuthorizationServerMetadataHandler() { response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], - scopes_supported: ["openid", "profile", "email", "read", "write"], - token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], - revocation_endpoint: `${baseUrl}/oauth/revoke`, - introspection_endpoint: `${baseUrl}/oauth/introspect`, + scopes_supported: ["read", "write", "mcp"], + token_endpoint_auth_methods_supported: ["none"] }; logger.info("OAuth authorization server metadata requested", { - issuer: metadata.issuer + issuer: metadata.issuer }); res.json(metadata); @@ -45,6 +46,8 @@ export function createAuthorizationServerMetadataHandler() { /** * OAuth 2.0 Protected Resource Metadata endpoint * RFC 8705: https://tools.ietf.org/html/rfc8705 + * + * For AUTH_MODE=full, this describes our resource server capabilities */ export function createProtectedResourceMetadataHandler() { return (req: Request, res: Response) => { @@ -55,13 +58,13 @@ export function createProtectedResourceMetadataHandler() { const metadata = { resource: baseUrl, authorization_servers: [baseUrl], - scopes_supported: ["read", "write"], + scopes_supported: ["read", "write", "mcp"], bearer_methods_supported: ["header"], - resource_documentation: `${baseUrl}/docs`, + resource_documentation: `${baseUrl}/docs` }; logger.info("OAuth protected resource metadata requested", { - resource: metadata.resource + resource: metadata.resource }); res.json(metadata); @@ -78,115 +81,165 @@ export function createProtectedResourceMetadataHandler() { } /** - * OAuth 2.1 token endpoint - proxies token requests to external OAuth provider + * OAuth 2.0 Authorization endpoint */ -export function createTokenHandler(oauthProvider: any) { +export function createAuthorizeHandler(oauthServer: OAuth2Server) { return async (req: Request, res: Response) => { try { - const config = getConfig(); - const { grant_type, code, redirect_uri, client_id, code_verifier } = req.body; + logger.debug("Authorization request received", { + query: req.query, + method: req.method + }); - // Validate required parameters - if (grant_type !== "authorization_code") { - return res.status(400).json({ - error: "unsupported_grant_type", - error_description: "Only 'authorization_code' grant type is supported" + // Real OAuth implementation: Check for authenticated user + // In a real implementation, this would: + // 1. Check if user has valid session/cookie + // 2. If not authenticated, redirect to login page + // 3. After login, show consent page + // 4. Only then proceed with authorization + + // For now, this implementation requires external authentication + // The user must be authenticated before reaching this endpoint + const userId = req.headers['x-user-id'] as string; + const username = req.headers['x-username'] as string; + + if (!userId || !username) { + logger.warn("Missing user authentication headers"); + return res.status(401).json({ + error: "access_denied", + error_description: "User must be authenticated before authorization" }); } + + const user = { + id: userId, + username: username + }; - if (!code || !redirect_uri || !client_id || !code_verifier) { - return res.status(400).json({ - error: "invalid_request", - error_description: "Missing required parameters: code, redirect_uri, client_id, code_verifier" - }); - } + logger.debug("User authenticated, proceeding with authorization", { userId: user.id }); - // Proxy token request to external OAuth provider - const tokenParams = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: `${config.BASE_URL || "http://localhost:3000"}/oauth/callback`, - client_id: config.OAUTH_CLIENT_ID!, - client_secret: config.OAUTH_CLIENT_SECRET!, - code_verifier + // Use the OAuth2Server authorize method + const request = new (OAuth2Server as any).Request(req); + const response = new (OAuth2Server as any).Response(res); + + const authorizationCode = await oauthServer.authorize(request, response, { + authenticateHandler: { + handle: async () => { + logger.debug("Authenticate handler called"); + return user; + } + } }); - const tokenResponse = await fetch(`${config.OAUTH_ISSUER}/oauth/token`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: tokenParams + logger.info("Authorization code granted", { + clientId: authorizationCode.client.id, + userId: user.id, + code: authorizationCode.authorizationCode.substring(0, 8) + "..." }); - if (!tokenResponse.ok) { - logger.warn("External OAuth token exchange failed", { - status: tokenResponse.status, - statusText: tokenResponse.statusText - }); - return res.status(400).json({ - error: "invalid_grant", - error_description: "Authorization code exchange failed" + // Redirect back to client with authorization code + const redirectUri = req.query.redirect_uri as string; + const state = req.query.state as string; + + if (redirectUri) { + const url = new URL(redirectUri); + url.searchParams.set('code', authorizationCode.authorizationCode); + if (state) url.searchParams.set('state', state); + + logger.info("Redirecting to client", { redirectUrl: url.toString() }); + res.redirect(url.toString()); + } else { + // Fallback - return as JSON + res.json({ + authorization_code: authorizationCode.authorizationCode, + state }); } - const tokenData = await tokenResponse.json(); + } catch (error) { + logger.error("Authorization endpoint error", { + error: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : undefined + }); + + res.status(400).json({ + error: "server_error", + error_description: error instanceof Error ? error.message : "Failed to process authorization request" + }); + } + }; +} + +/** + * OAuth 2.0 Token endpoint + */ +export function createTokenHandler(oauthServer: OAuth2Server) { + return async (req: Request, res: Response) => { + try { + const request = new (OAuth2Server as any).Request(req); + const response = new (OAuth2Server as any).Response(res); + + const token = await oauthServer.token(request, response); - logger.info("Token exchange successful via external provider", { - client_id, - scope: tokenData.scope + logger.info("Access token granted", { + clientId: token.client.id, + userId: token.user?.id, + scope: token.scope }); - // Return tokens (optionally transform or wrap them) res.json({ - access_token: tokenData.access_token, - token_type: tokenData.token_type || "Bearer", - expires_in: tokenData.expires_in, - scope: tokenData.scope, - refresh_token: tokenData.refresh_token + access_token: token.accessToken, + token_type: "Bearer", + expires_in: Math.floor((token.accessTokenExpiresAt!.getTime() - Date.now()) / 1000), + scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope, + refresh_token: token.refreshToken }); } catch (error) { - logger.error("Token endpoint proxy error", { + logger.error("Token endpoint error", { error: error instanceof Error ? error.message : error }); - res.status(500).json({ - error: "server_error", - error_description: "Failed to process token request" + res.status(400).json({ + error: "invalid_request", + error_description: error instanceof Error ? error.message : "Token request failed" }); } }; } /** - * Token introspection endpoint - simplified for OAuth proxy pattern + * Token introspection endpoint */ -export function createIntrospectionHandler(oauthProvider?: any) { +export function createIntrospectionHandler(oauthServer: OAuth2Server) { return async (req: Request, res: Response) => { try { - const { token } = req.body; - - if (!token) { - return res.status(400).json({ - error: "invalid_request", - error_description: "Missing token parameter" - }); - } - - logger.info("Token introspection requested", { token: token.substring(0, 10) + "..." }); + const request = new (OAuth2Server as any).Request(req); + const response = new (OAuth2Server as any).Response(res); - // Return inactive for OAuth proxy pattern - external IdP handles actual validation - res.json({ active: false }); + const token = await oauthServer.authenticate(request, response); + + logger.info("Token introspection successful", { + clientId: token.client.id, + userId: token.user?.id, + scope: token.scope + }); + + res.json({ + active: true, + scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope, + client_id: token.client.id, + username: token.user?.username, + sub: token.user?.id, + exp: Math.floor((token.accessTokenExpiresAt?.getTime() || 0) / 1000) + }); } catch (error) { - logger.error("Token introspection error", { + logger.debug("Token introspection failed", { error: error instanceof Error ? error.message : error }); - res.status(500).json({ - error: "server_error", - error_description: "Failed to introspect token" - }); + + res.json({ active: false }); } }; } @@ -194,29 +247,23 @@ export function createIntrospectionHandler(oauthProvider?: any) { /** * Token revocation endpoint */ -export function createRevocationHandler() { +export function createRevocationHandler(oauthServer: OAuth2Server) { return async (req: Request, res: Response) => { try { - const { token } = req.body; - - if (!token) { - return res.status(400).json({ - error: "invalid_request", - error_description: "Missing token parameter" - }); - } - - // TODO: Implement actual token revocation - logger.info("Token revocation requested", { token: token.substring(0, 10) + "..." }); - - res.status(200).send(); // Success response + const request = new (OAuth2Server as any).Request(req); + const response = new (OAuth2Server as any).Response(res); + + await oauthServer.revoke(request, response); + + logger.info("Token revoked successfully"); + res.status(200).send(); } catch (error) { logger.error("Token revocation error", { error: error instanceof Error ? error.message : error }); - res.status(500).json({ - error: "server_error", + res.status(400).json({ + error: "invalid_request", error_description: "Failed to revoke token" }); } diff --git a/src/auth/index.ts b/src/auth/index.ts index 405c8df..85e2982 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,5 +1,4 @@ import { OAuthTokenValidator } from "./token-validator.ts"; -import { OAuthProvider } from "./oauth-provider.ts"; import { createAuthMiddleware } from "./middleware.ts"; import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; @@ -12,34 +11,35 @@ export function initializeAuth() { if (config.AUTH_MODE === "none") { logger.info("Authentication is disabled"); - return { tokenValidator: null, oauthProvider: null }; + return { tokenValidator: null }; } - // Both resource_server and full modes need token validation - const tokenValidator = new OAuthTokenValidator( - config.OAUTH_ISSUER!, - config.OAUTH_AUDIENCE - ); - if (config.AUTH_MODE === "resource_server") { logger.info("Initializing OAuth resource server mode"); - return { tokenValidator, oauthProvider: null }; + + // Resource server mode: only validate tokens from external OAuth provider + const tokenValidator = new OAuthTokenValidator( + config.OAUTH_ISSUER!, + config.OAUTH_AUDIENCE + ); + + return { tokenValidator }; } if (config.AUTH_MODE === "full") { - logger.info("Initializing OAuth full server mode with authorization endpoints"); - - // For full mode, also set up OAuth provider for authorization flows - const oauthProvider = new OAuthProvider({ - clientId: config.OAUTH_CLIENT_ID!, - clientSecret: config.OAUTH_CLIENT_SECRET!, - authorizationEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/authorize`, - tokenEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/token`, - scope: "read write", - redirectUri: "http://localhost:3000/oauth/callback", // This should be configurable + logger.info("Initializing OAuth full mode with external IdP delegation", { + issuer: config.OAUTH_ISSUER, + audience: config.OAUTH_AUDIENCE, + clientId: config.OAUTH_CLIENT_ID }); - - return { tokenValidator, oauthProvider }; + + // Full mode: OAuth client that delegates to external IdP + resource server capabilities + // For MCP API token validation, we need to validate OUR tokens, not external IdP tokens + // The tokens we issue to MCP clients are from our OAuthProvider, not the external IdP + + // We'll create a custom validator that validates our own issued tokens + // This needs to be handled differently - we'll return null and handle it in the middleware + return { tokenValidator: null }; } throw new Error(`Unknown AUTH_MODE: ${config.AUTH_MODE}`); @@ -55,5 +55,6 @@ export function createAuthenticationMiddleware() { return (_req: any, _res: any, next: any) => next(); } + // For full and resource_server modes, use token validator return createAuthMiddleware(tokenValidator); } \ No newline at end of file diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index 6177ed2..f184dfa 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,5 +1,6 @@ import type { Request, Response, NextFunction } from "express"; import { OAuthTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; +import type { OAuthProvider } from "./oauth-provider.ts"; import { logger } from "../logger.ts"; export interface AuthenticatedRequest extends Request { @@ -7,7 +8,7 @@ export interface AuthenticatedRequest extends Request { accessToken?: string; } -type TokenValidator = OAuthTokenValidator | BuiltinTokenValidator; +type TokenValidator = OAuthTokenValidator | BuiltinTokenValidator | OAuthProvider; /** * Create authentication middleware that supports both gateway and built-in modes @@ -55,3 +56,53 @@ export function createAuthMiddleware(tokenValidator: TokenValidator) { } }; } + +/** + * Create authentication middleware specifically for OAuthProvider (full mode) + */ +export function createOAuthProviderAuthMiddleware(oauthProvider: OAuthProvider) { + return async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, + ) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ + error: "unauthorized", + error_description: "Missing or invalid authorization header", + }); + } + + const token = authHeader.substring(7); + + try { + const validation = await oauthProvider.validateToken(token); + + if (!validation.valid) { + return res.status(401).json({ + error: "invalid_token", + error_description: "The access token is invalid or expired", + }); + } + + req.userId = validation.userId; + req.accessToken = token; + + logger.info("Request authenticated with OAuthProvider", { + userId: validation.userId, + scope: validation.scope + }); + next(); + } catch (error) { + logger.error("OAuthProvider authentication middleware error", { + error: error instanceof Error ? error.message : error + }); + return res.status(500).json({ + error: "server_error", + error_description: "Internal server error during authentication", + }); + } + }; +} diff --git a/src/auth/oauth-model.ts b/src/auth/oauth-model.ts new file mode 100644 index 0000000..01d68c9 --- /dev/null +++ b/src/auth/oauth-model.ts @@ -0,0 +1,257 @@ +import OAuth2Server from '@node-oauth/oauth2-server'; +import { randomBytes, createHash } from 'node:crypto'; +import { logger } from '../logger.ts'; +import { getConfig } from '../config.ts'; + +type AuthorizationCode = OAuth2Server.AuthorizationCode; +type AuthorizationCodeModel = OAuth2Server.AuthorizationCodeModel; +type Client = OAuth2Server.Client; +type Token = OAuth2Server.Token; +type User = OAuth2Server.User; + +// In-memory storage for demo purposes +// In production, use a proper database +const clients = new Map(); +const users = new Map(); +const authorizationCodes = new Map(); +const tokens = new Map(); + +// Get client configuration from environment +const config = getConfig(); +const demoClient: Client = { + id: config.OAUTH_CLIENT_ID, + clientSecret: config.OAUTH_CLIENT_SECRET, + redirectUris: ['http://localhost:3000/callback', 'vscode://ms-vscode.claude-dev'], + grants: ['authorization_code'] +}; + +// Initialize client data +clients.set(demoClient.id, demoClient); + +export const oauthModel: AuthorizationCodeModel = { + /** + * Get client by client ID + */ + async getClient(clientId: string, clientSecret?: string): Promise { + logger.debug('OAuth model: getClient', { + clientId, + hasSecret: !!clientSecret, + providedSecret: clientSecret ? clientSecret.substring(0, 3) + '...' : 'none', + availableClients: Array.from(clients.keys()), + clientsMapSize: clients.size, + clientsMapEntries: Array.from(clients.entries()).map(([k, v]) => ({ id: k, secret: v.clientSecret?.substring(0, 3) + '...' })) + }); + + const client = clients.get(clientId); + if (!client) { + logger.warn('Client not found', { clientId, availableClients: Array.from(clients.keys()) }); + return false; + } + + // If client secret is provided, validate it + if (clientSecret && client.clientSecret !== clientSecret) { + logger.warn('Client secret mismatch', { + clientId, + expectedSecret: client.clientSecret?.substring(0, 3) + '...', + providedSecret: clientSecret.substring(0, 3) + '...' + }); + return false; + } + + logger.debug('Client found and validated', { clientId }); + return client; + }, + + /** + * Save authorization code + */ + async saveAuthorizationCode(code: AuthorizationCode, client: Client, user: User): Promise { + logger.debug('OAuth model: saveAuthorizationCode', { + code: code.authorizationCode.substring(0, 8) + '...', + clientId: client.id, + userId: user.id + }); + + const authCode = { + ...code, + client, + user + }; + + authorizationCodes.set(code.authorizationCode, authCode); + return authCode; + }, + + /** + * Get authorization code + */ + async getAuthorizationCode(authorizationCode: string): Promise { + logger.debug('OAuth model: getAuthorizationCode', { + code: authorizationCode.substring(0, 8) + '...' + }); + + const code = authorizationCodes.get(authorizationCode); + if (!code) { + return false; + } + + // Check if code has expired + if (code.expiresAt && code.expiresAt < new Date()) { + authorizationCodes.delete(authorizationCode); + return false; + } + + return code; + }, + + /** + * Revoke authorization code (called after token exchange) + */ + async revokeAuthorizationCode(code: AuthorizationCode): Promise { + logger.debug('OAuth model: revokeAuthorizationCode', { + code: code.authorizationCode.substring(0, 8) + '...' + }); + + return authorizationCodes.delete(code.authorizationCode); + }, + + /** + * Save access token + */ + async saveToken(token: Token, client: Client, user: User): Promise { + logger.debug('OAuth model: saveToken', { + accessToken: token.accessToken.substring(0, 8) + '...', + clientId: client.id, + userId: user.id + }); + + const fullToken = { + ...token, + client, + user + }; + + tokens.set(token.accessToken, fullToken); + if (token.refreshToken) { + tokens.set(token.refreshToken, fullToken); + } + + return fullToken; + }, + + /** + * Get access token + */ + async getAccessToken(accessToken: string): Promise { + logger.debug('OAuth model: getAccessToken', { + token: accessToken.substring(0, 8) + '...' + }); + + const token = tokens.get(accessToken); + if (!token) { + return false; + } + + // Check if token has expired + if (token.accessTokenExpiresAt && token.accessTokenExpiresAt < new Date()) { + tokens.delete(accessToken); + return false; + } + + return token; + }, + + /** + * Get refresh token + */ + async getRefreshToken(refreshToken: string): Promise { + logger.debug('OAuth model: getRefreshToken', { + token: refreshToken.substring(0, 8) + '...' + }); + + const token = tokens.get(refreshToken); + if (!token) { + return false; + } + + // Check if refresh token has expired + if (token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < new Date()) { + tokens.delete(refreshToken); + return false; + } + + return token; + }, + + /** + * Revoke token + */ + async revokeToken(token: Token): Promise { + logger.debug('OAuth model: revokeToken', { + accessToken: token.accessToken.substring(0, 8) + '...' + }); + + let revoked = false; + + if (tokens.delete(token.accessToken)) { + revoked = true; + } + + if (token.refreshToken && tokens.delete(token.refreshToken)) { + revoked = true; + } + + return revoked; + }, + + /** + * Validate scope + */ + async validateScope(user: User, client: Client, scope: string[]): Promise { + logger.debug('OAuth model: validateScope', { + userId: user.id, + clientId: client.id, + scope + }); + + // For demo purposes, allow all requested scopes + // In production, implement proper scope validation + const allowedScopes = ['read', 'write', 'mcp']; + const validScopes = scope.filter(s => allowedScopes.includes(s)); + + return validScopes.length > 0 ? validScopes : ['read']; + }, + + /** + * Verify scope + */ + async verifyScope(token: Token, scope: string[]): Promise { + logger.debug('OAuth model: verifyScope', { + tokenScope: token.scope, + requestedScope: scope + }); + + if (!token.scope || !scope) { + return false; + } + + const tokenScopes = Array.isArray(token.scope) ? token.scope : [token.scope]; + return scope.every(s => tokenScopes.includes(s)); + } +}; + +// Removed demo user authentication - use external authentication system + +/** + * Generate secure tokens + */ +export function generateToken(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Generate authorization code + */ +export function generateAuthorizationCode(): string { + return randomBytes(16).toString('hex'); +} \ No newline at end of file diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index dc97d0c..af4d268 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -24,6 +24,14 @@ interface AuthorizationCodeData { codeChallenge: string; codeChallengeMethod: string; expiresAt: Date; + externalTokens?: { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresAt: Date; + scope?: string; + }; + userId?: string; } /** @@ -36,7 +44,18 @@ export class OAuthProvider { // In-memory stores (use database in production) #authorizationCodes = new Map(); - #accessTokens = new Map(); + #accessTokens = new Map(); constructor(config: OAuthConfig) { this.#config = config; @@ -54,10 +73,45 @@ export class OAuthProvider { } /** - * Store authorization code with PKCE data + * Store authorization code with PKCE data and optional external token info */ storeAuthorizationCode(code: string, data: AuthorizationCodeData): void { this.#authorizationCodes.set(code, data); + logger.info("Authorization code stored", { + code: code.substring(0, 8) + "...", + clientId: data.clientId, + hasExternalTokens: !!data.externalTokens + }); + } + + /** + * Store authorization code with external token data from IdP + */ + storeAuthorizationCodeWithTokens( + code: string, + data: Omit, + externalTokens: { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresIn: number; + scope?: string; + }, + userId?: string + ): void { + const authCodeData: AuthorizationCodeData = { + ...data, + userId, + externalTokens: { + accessToken: externalTokens.accessToken, + refreshToken: externalTokens.refreshToken, + idToken: externalTokens.idToken, + expiresAt: new Date(Date.now() + externalTokens.expiresIn * 1000), + scope: externalTokens.scope + } + }; + + this.storeAuthorizationCode(code, authCodeData); } /** @@ -105,11 +159,13 @@ export class OAuthProvider { const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour const expiresIn = 3600; - // Store access token + // Store access token with user info from external tokens + const userId = codeData.userId || codeData.externalTokens?.accessToken.substring(0, 8) || "demo-user"; this.#accessTokens.set(accessToken, { - userId: "demo-user", // In real implementation, this would be the authenticated user + userId, scope: codeData.scope, - expiresAt + expiresAt, + externalTokens: codeData.externalTokens }); // Clean up authorization code (single use) diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 218e407..b06bdac 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -4,11 +4,36 @@ import { logger } from "../logger.ts"; import { getConfig } from "../config.ts"; import type { OAuthProvider } from "./oauth-provider.ts"; +interface TokenExchangeResponse { + access_token: string; + token_type: string; + expires_in: number; + scope?: string; + id_token?: string; + refresh_token?: string; +} + +interface PendingAuthRequest { + clientId: string; + redirectUri: string; + scope: string; + state: string; + codeChallenge: string; + codeChallengeMethod: string; + expiresAt: Date; + // Our own PKCE parameters for external IdP + externalCodeVerifier: string; + externalCodeChallenge: string; +} + +// Store pending authorization requests (use database in production) +const pendingRequests = new Map(); + /** * OAuth authorization endpoint - proxies to external OAuth provider (e.g., Auth0) * This implements the MCP-compliant OAuth proxy pattern */ -export function createAuthorizeHandler(oauthProvider: OAuthProvider) { +export function createAuthorizeHandler() { return async (req: Request, res: Response) => { try { const config = getConfig(); @@ -45,14 +70,37 @@ export function createAuthorizeHandler(oauthProvider: OAuthProvider) { }); } - // Build authorization URL for external provider + // Generate a unique request ID to track this authorization request + const requestId = randomBytes(16).toString("hex"); + const finalState = state as string || randomBytes(16).toString("hex"); + + // Generate our own PKCE parameters for external IdP + const externalCodeVerifier = randomBytes(32).toString("base64url"); + const externalCodeChallenge = createHash("sha256") + .update(externalCodeVerifier) + .digest("base64url"); + + // Store the original request parameters plus our PKCE data + pendingRequests.set(requestId, { + clientId: client_id as string, + redirectUri: redirect_uri as string, + scope: scope as string || "openid profile email", + state: finalState, + codeChallenge: code_challenge as string, + codeChallengeMethod: code_challenge_method as string, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes + externalCodeVerifier, + externalCodeChallenge + }); + + // Build authorization URL for external provider with our own PKCE const authParams = new URLSearchParams({ response_type: "code", client_id: config.OAUTH_CLIENT_ID!, - redirect_uri: `${config.BASE_URL || "http://localhost:3000"}/oauth/callback`, + redirect_uri: config.OAUTH_REDIRECT_URI!, scope: scope as string || "openid profile email", - state: state as string || randomBytes(16).toString("hex"), - code_challenge: code_challenge as string, + state: requestId, // Use our request ID as state + code_challenge: externalCodeChallenge, // Use our generated challenge code_challenge_method: "S256" }); @@ -62,6 +110,7 @@ export function createAuthorizeHandler(oauthProvider: OAuthProvider) { client_id, redirect_uri, scope, + requestId, external_auth_url: `${config.OAUTH_ISSUER}/oauth/authorize` }); @@ -83,11 +132,16 @@ export function createAuthorizeHandler(oauthProvider: OAuthProvider) { /** * OAuth callback handler - receives callback from external OAuth provider - * This completes the OAuth proxy flow + * This completes the OAuth proxy flow by exchanging the code for tokens */ -export function createCallbackHandler() { +export function createCallbackHandler(oauthProvider: OAuthProvider) { return async (req: Request, res: Response) => { try { + logger.debug("OAuth callback handler called", { + query: req.query, + url: req.url + }); + const { code, state, error, error_description } = req.query; if (error) { @@ -98,66 +152,115 @@ export function createCallbackHandler() { }); } - if (!code) { - logger.warn("OAuth callback missing authorization code"); + if (!code || !state) { + logger.warn("OAuth callback missing required parameters", { code: !!code, state: !!state }); + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing authorization code or state" + }); + } + + // Retrieve the original request using state as requestId + const requestId = state as string; + const originalRequest = pendingRequests.get(requestId); + + logger.debug("OAuth callback debug info", { + receivedState: requestId, + storedRequestIds: Array.from(pendingRequests.keys()), + requestFound: !!originalRequest + }); + + if (!originalRequest) { + logger.warn("OAuth callback with unknown or expired state", { + requestId, + availableRequestIds: Array.from(pendingRequests.keys()) + }); + return res.status(400).json({ + error: "invalid_request", + error_description: "Unknown or expired authorization request" + }); + } + + // Check if request has expired + if (originalRequest.expiresAt < new Date()) { + pendingRequests.delete(requestId); + logger.warn("OAuth callback with expired request", { requestId }); return res.status(400).json({ error: "invalid_request", - error_description: "Missing authorization code" + error_description: "Authorization request has expired" }); } logger.info("OAuth callback received from external provider", { code: typeof code === 'string' ? code.substring(0, 8) + "..." : code, - state - }); - - // In a full implementation, you would: - // 1. Exchange the code for tokens with the external provider - // 2. Store the tokens securely - // 3. Generate your own short-lived tokens for the MCP client - - const closeScript = ` - - - - Authorization Complete - - - -
✓ Authorization Successful
-
You can close this window and return to your application.
- - - - `; - - res.send(closeScript); + requestId, + clientId: originalRequest.clientId + }); + + // Exchange authorization code for tokens with external provider + const config = getConfig(); + const tokenResponse = await exchangeCodeForTokens( + code as string, + config, + originalRequest.externalCodeVerifier + ); + + if (!tokenResponse) { + pendingRequests.delete(requestId); + return res.status(500).json({ + error: "server_error", + error_description: "Failed to exchange authorization code for tokens" + }); + } + + // Generate our own authorization code for the MCP client + const mcpAuthCode = randomBytes(32).toString("hex"); + + // Store the authorization code with external token data + oauthProvider.storeAuthorizationCodeWithTokens( + mcpAuthCode, + { + clientId: originalRequest.clientId, + redirectUri: originalRequest.redirectUri, + scope: originalRequest.scope, + codeChallenge: originalRequest.codeChallenge, + codeChallengeMethod: originalRequest.codeChallengeMethod, + expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes + }, + { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + idToken: tokenResponse.id_token, + expiresIn: tokenResponse.expires_in, + scope: tokenResponse.scope + }, + `external-user-${requestId.substring(0, 8)}` // Simple user ID for demo + ); + + logger.info("Token exchange completed, MCP auth code generated", { + requestId, + clientId: originalRequest.clientId, + externalTokenExpiry: tokenResponse.expires_in, + mcpAuthCode: mcpAuthCode.substring(0, 8) + "..." + }); + + // Clean up pending request + pendingRequests.delete(requestId); + + // Redirect back to the original MCP client with our authorization code + const redirectParams = new URLSearchParams({ + code: mcpAuthCode, + state: originalRequest.state + }); + + const redirectUrl = `${originalRequest.redirectUri}?${redirectParams}`; + + logger.info("Redirecting to MCP client with authorization code", { + clientId: originalRequest.clientId, + redirectUri: originalRequest.redirectUri + }); + + res.redirect(redirectUrl); } catch (error) { logger.error("OAuth callback error", { @@ -169,4 +272,127 @@ export function createCallbackHandler() { }); } }; +} + +/** + * Exchange authorization code for tokens with external OAuth provider + */ +async function exchangeCodeForTokens(code: string, config: any, codeVerifier: string): Promise { + try { + const tokenEndpoint = `${config.OAUTH_ISSUER}/oauth/token`; + + const tokenParams = new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.OAUTH_CLIENT_ID!, + client_secret: config.OAUTH_CLIENT_SECRET!, + code, + redirect_uri: config.OAUTH_REDIRECT_URI!, + code_verifier: codeVerifier + }); + + logger.info("Exchanging authorization code with external provider", { + tokenEndpoint, + clientId: config.OAUTH_CLIENT_ID + }); + + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json" + }, + body: tokenParams + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Token exchange failed", { + status: response.status, + statusText: response.statusText, + error: errorText, + tokenEndpoint, + clientId: config.OAUTH_CLIENT_ID + }); + return null; + } + + const tokenData = await response.json() as TokenExchangeResponse; + + logger.info("Token exchange successful", { + tokenType: tokenData.token_type, + expiresIn: tokenData.expires_in, + scope: tokenData.scope, + hasIdToken: !!tokenData.id_token, + hasRefreshToken: !!tokenData.refresh_token + }); + + return tokenData; + + } catch (error) { + logger.error("Token exchange error", { + error: error instanceof Error ? error.message : error + }); + return null; + } +} + +/** + * OAuth token endpoint - issues tokens for MCP clients after external auth + */ +export function createTokenHandler(oauthProvider: OAuthProvider) { + return async (req: Request, res: Response) => { + try { + const { grant_type, code, code_verifier, client_id, redirect_uri } = req.body; + + if (grant_type !== "authorization_code") { + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: "Only authorization_code grant type is supported" + }); + } + + if (!code || !code_verifier || !client_id || !redirect_uri) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters" + }); + } + + // Exchange the authorization code for an access token + const tokenResult = await oauthProvider.exchangeAuthorizationCode( + code, + code_verifier, + client_id, + redirect_uri + ); + + if (!tokenResult) { + return res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid authorization code or code verifier" + }); + } + + logger.info("MCP access token issued", { + clientId: client_id, + scope: tokenResult.scope + }); + + res.json({ + access_token: tokenResult.accessToken, + token_type: "Bearer", + expires_in: tokenResult.expiresIn, + scope: tokenResult.scope + }); + + } catch (error) { + logger.error("Token endpoint error", { + error: error instanceof Error ? error.message : error + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to issue access token" + }); + } + }; } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index d89c554..21c7353 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,11 +12,13 @@ const configSchema = z.object({ BASE_URL: z.string().optional(), AUTH_MODE: z.enum(["none", "full", "resource_server"]).default("none"), + // OAuth configuration for external IdP integration OAUTH_ISSUER: z.string().optional(), - OAUTH_AUDIENCE: z.string().optional(), OAUTH_CLIENT_ID: z.string().optional(), OAUTH_CLIENT_SECRET: z.string().optional(), - OAUTH_CALLBACK_PATH: z.string().default("/callback"), + OAUTH_AUDIENCE: z.string().optional(), + OAUTH_REDIRECT_URI: z.string().optional(), + OAUTH_SCOPE: z.string().default("openid profile email"), }); export type Config = z.infer; @@ -28,49 +30,53 @@ export function getConfig(): Config { try { const parsed = configSchema.parse(process.env); + // Full mode validation - OAuth Authorization Server with external IdP if (parsed.AUTH_MODE === "full") { - const missingVars = []; - if (!parsed.OAUTH_ISSUER) missingVars.push("OAUTH_ISSUER"); - if (!parsed.OAUTH_CLIENT_ID) missingVars.push("OAUTH_CLIENT_ID"); - if (!parsed.OAUTH_CLIENT_SECRET) - missingVars.push("OAUTH_CLIENT_SECRET"); + const requiredVars = []; + if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); + if (!parsed.OAUTH_CLIENT_ID) requiredVars.push("OAUTH_CLIENT_ID"); + if (!parsed.OAUTH_CLIENT_SECRET) requiredVars.push("OAUTH_CLIENT_SECRET"); + + // Provide default for OAUTH_REDIRECT_URI if not set + if (!parsed.OAUTH_REDIRECT_URI) { + const baseUrl = parsed.BASE_URL || "http://localhost:3000"; + parsed.OAUTH_REDIRECT_URI = `${baseUrl}/callback`; + console.log(`⚠️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`); + } - if (missingVars.length > 0) { + if (requiredVars.length > 0) { throw new Error( - `AUTH_MODE=full requires complete OAuth configuration. Missing: ${missingVars.join(", ")}\n` + - "Set these in your .env file:\n" + - "OAUTH_ISSUER=https://your-issuer.com\n" + - "OAUTH_CLIENT_ID=your-some-idp-client-id\n" + - "OAUTH_CLIENT_SECRET=your-some-idp-client-secret", + `AUTH_MODE=full requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + + "Example configuration:\n" + + "OAUTH_ISSUER=https://your-domain.auth0.com\n" + + "OAUTH_CLIENT_ID=your-client-id\n" + + "OAUTH_CLIENT_SECRET=your-client-secret\n" + + "OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback\n" + + "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended" ); } - } - if ( - parsed.AUTH_MODE === "resource_server" || - parsed.AUTH_MODE === "full" - ) { - if (!parsed.OAUTH_ISSUER) { - throw new Error( - `AUTH_MODE=${parsed.AUTH_MODE} requires OAUTH_ISSUER for JWT token validation.\n` + - "Set OAUTH_ISSUER=https://your-issuer.com", + // OAUTH_AUDIENCE is optional but recommended for full mode + if (!parsed.OAUTH_AUDIENCE) { + console.warn( + "⚠️ OAUTH_AUDIENCE not set for full mode. Token validation will not check audience.\n" + + " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier" ); } } - // OAuth audience validation: required for resource_server, optional but recommended for full + // Resource server mode validation if (parsed.AUTH_MODE === "resource_server") { - if (!parsed.OAUTH_AUDIENCE) { + const requiredVars = []; + if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); + if (!parsed.OAUTH_AUDIENCE) requiredVars.push("OAUTH_AUDIENCE"); + + if (requiredVars.length > 0) { throw new Error( - "AUTH_MODE=resource_server requires OAUTH_AUDIENCE for token validation.\n" + - "Set OAUTH_AUDIENCE to your API identifier (e.g., 'mcp-server')", - ); - } - } else if (parsed.AUTH_MODE === "full") { - if (!parsed.OAUTH_AUDIENCE) { - console.warn( - "⚠️ OAUTH_AUDIENCE not set for full mode. Tokens will not be validated for intended audience.\n" + - " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier (e.g., 'mcp-server')", + `AUTH_MODE=resource_server requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + + "Example configuration:\n" + + "OAUTH_ISSUER=https://your-domain.auth0.com\n" + + "OAUTH_AUDIENCE=your-api-identifier" ); } } diff --git a/src/index.ts b/src/index.ts index bc252d4..9335e47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,15 +7,18 @@ import { z } from "zod"; import { createTextResult } from "./lib/utils.ts"; import { logger } from "./logger.ts"; import { getConfig } from "./config.ts"; -import { createAuthenticationMiddleware, initializeAuth } from "./auth/index.ts"; -import { createAuthorizeHandler, createCallbackHandler } from "./auth/routes.ts"; +import { createAuthenticationMiddleware } from "./auth/index.ts"; +import { createOAuthProviderAuthMiddleware } from "./auth/middleware.ts"; import { createAuthorizationServerMetadataHandler, - createProtectedResourceMetadataHandler, - createTokenHandler, - createIntrospectionHandler, - createRevocationHandler + createProtectedResourceMetadataHandler } from "./auth/discovery.ts"; +import { + createAuthorizeHandler, + createCallbackHandler, + createTokenHandler +} from "./auth/routes.ts"; +import { OAuthProvider } from "./auth/oauth-provider.ts"; const getServer = () => { const config = getConfig(); @@ -97,11 +100,25 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { // For GET requests without session, return server info if (req.method === "GET") { const config = getConfig(); + const capabilities = ["tools"]; + + if (config.AUTH_MODE !== "none") { + capabilities.push("oauth"); + } + res.json({ name: config.SERVER_NAME, version: config.SERVER_VERSION, description: "TypeScript template for building MCP servers", - capabilities: ["tools"], + capabilities, + ...(config.AUTH_MODE !== "none" && { + oauth: { + authorization_server: `${config.BASE_URL || "http://localhost:3000"}/.well-known/oauth-authorization-server`, + protected_resource: `${config.BASE_URL || "http://localhost:3000"}/.well-known/oauth-protected-resource`, + authorization_endpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/authorize`, + token_endpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/token` + } + }) }); } } catch (error) { @@ -112,31 +129,52 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { } }; -// Setup OAuth routes and discovery endpoints for full mode const config = getConfig(); +let oauthProvider: OAuthProvider | null = null; + if (config.AUTH_MODE === "full") { - const { oauthProvider } = initializeAuth(); - if (oauthProvider) { - // OAuth 2.1 Discovery endpoints (required by MCP spec) - app.get("/.well-known/oauth-authorization-server", createAuthorizationServerMetadataHandler()); - app.get("/.well-known/oauth-protected-resource", createProtectedResourceMetadataHandler()); - - // OAuth 2.1 proxy endpoints - these proxy to the external OAuth provider - app.get("/oauth/authorize", createAuthorizeHandler(oauthProvider)); - app.get(config.OAUTH_CALLBACK_PATH, createCallbackHandler()); - app.post("/oauth/token", createTokenHandler(oauthProvider)); - app.post("/oauth/introspect", createIntrospectionHandler(oauthProvider)); - app.post("/oauth/revoke", createRevocationHandler()); - - logger.info("OAuth 2.1 proxy endpoints registered for full auth mode", { - discovery: ["/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"], - endpoints: ["/oauth/authorize", "/oauth/callback", "/oauth/token", "/oauth/introspect", "/oauth/revoke"] - }); - } + oauthProvider = new OAuthProvider({ + clientId: "mcp-client", + clientSecret: "mcp-secret", + authorizationEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/authorize`, + tokenEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/token`, + scope: config.OAUTH_SCOPE, + redirectUri: config.OAUTH_REDIRECT_URI! + }); + + app.get("/.well-known/oauth-authorization-server", createAuthorizationServerMetadataHandler()); + app.get("/.well-known/oauth-protected-resource", createProtectedResourceMetadataHandler()); + app.get("/.well-known/openid_configuration", createAuthorizationServerMetadataHandler()); + + // Extract path from redirect URI for route registration + const redirectPath = new URL(config.OAUTH_REDIRECT_URI!).pathname; + + app.get("/oauth/authorize", createAuthorizeHandler()); + app.get(redirectPath, createCallbackHandler(oauthProvider)); + app.post("/oauth/token", express.urlencoded({ extended: true }), createTokenHandler(oauthProvider)); + + logger.info("OAuth 2.1 client delegation endpoints registered for full auth mode", { + discovery: [ + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource", + "/.well-known/openid_configuration" + ], + endpoints: ["/oauth/authorize", redirectPath, "/oauth/token"], + externalIdP: config.OAUTH_ISSUER + }); +} + +let authMiddleware; +if (config.AUTH_MODE === "full" && oauthProvider) { + authMiddleware = createOAuthProviderAuthMiddleware(oauthProvider); + logger.info("Using OAuthProvider authentication for MCP endpoints"); +} else { + authMiddleware = createAuthenticationMiddleware(); + logger.info("Using standard authentication for MCP endpoints"); } -app.use("/mcp", createAuthenticationMiddleware(), mcpHandler); -app.post("/mcp", createAuthenticationMiddleware(), mcpHandler); +app.get("/mcp", mcpHandler); +app.post("/mcp", authMiddleware, mcpHandler); async function main() { const config = getConfig(); From d2d1c67802a175747738a23401226ac1757aa300 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Sun, 3 Aug 2025 06:58:24 -0700 Subject: [PATCH 09/30] refactor(auth): use URL constructor for endpoint generation in OAuth handlers --- src/auth/discovery.ts | 6 +++--- src/auth/routes.ts | 36 +++++++++++++++++++----------------- src/auth/token-validator.ts | 6 +++--- src/config.ts | 3 ++- src/index.ts | 13 +++++++------ 5 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index 1b4d062..1d1ab6a 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -17,8 +17,8 @@ export function createAuthorizationServerMetadataHandler() { const metadata = { issuer: baseUrl, - authorization_endpoint: `${baseUrl}/oauth/authorize`, - token_endpoint: `${baseUrl}/oauth/token`, + authorization_endpoint: new URL("/oauth/authorize", baseUrl).toString(), + token_endpoint: new URL("/oauth/token", baseUrl).toString(), response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], @@ -60,7 +60,7 @@ export function createProtectedResourceMetadataHandler() { authorization_servers: [baseUrl], scopes_supported: ["read", "write", "mcp"], bearer_methods_supported: ["header"], - resource_documentation: `${baseUrl}/docs` + resource_documentation: new URL("/docs", baseUrl).toString() }; logger.info("OAuth protected resource metadata requested", { diff --git a/src/auth/routes.ts b/src/auth/routes.ts index b06bdac..2e9a268 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -36,6 +36,11 @@ const pendingRequests = new Map(); export function createAuthorizeHandler() { return async (req: Request, res: Response) => { try { + logger.debug("Authorization handler called", { + query: req.query, + url: req.url + }); + const config = getConfig(); const { response_type, @@ -94,28 +99,25 @@ export function createAuthorizeHandler() { }); // Build authorization URL for external provider with our own PKCE - const authParams = new URLSearchParams({ - response_type: "code", - client_id: config.OAUTH_CLIENT_ID!, - redirect_uri: config.OAUTH_REDIRECT_URI!, - scope: scope as string || "openid profile email", - state: requestId, // Use our request ID as state - code_challenge: externalCodeChallenge, // Use our generated challenge - code_challenge_method: "S256" - }); - - const authUrl = `${config.OAUTH_ISSUER}/oauth/authorize?${authParams}`; + const authUrl = new URL("/oauth/authorize", config.OAUTH_ISSUER!); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", config.OAUTH_CLIENT_ID!); + authUrl.searchParams.set("redirect_uri", config.OAUTH_REDIRECT_URI!); + authUrl.searchParams.set("scope", scope as string || "openid profile email"); + authUrl.searchParams.set("state", requestId); + authUrl.searchParams.set("code_challenge", externalCodeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); logger.info("Proxying OAuth authorization request", { client_id, redirect_uri, scope, requestId, - external_auth_url: `${config.OAUTH_ISSUER}/oauth/authorize` + external_auth_url: new URL("/oauth/authorize", config.OAUTH_ISSUER!).toString() }); // Redirect to external OAuth provider - res.redirect(authUrl); + res.redirect(authUrl.toString()); } catch (error) { logger.error("OAuth authorization proxy error", { @@ -279,7 +281,7 @@ export function createCallbackHandler(oauthProvider: OAuthProvider) { */ async function exchangeCodeForTokens(code: string, config: any, codeVerifier: string): Promise { try { - const tokenEndpoint = `${config.OAUTH_ISSUER}/oauth/token`; + const tokenEndpoint = new URL("/oauth/token", config.OAUTH_ISSUER!); const tokenParams = new URLSearchParams({ grant_type: "authorization_code", @@ -291,11 +293,11 @@ async function exchangeCodeForTokens(code: string, config: any, codeVerifier: st }); logger.info("Exchanging authorization code with external provider", { - tokenEndpoint, + tokenEndpoint: tokenEndpoint.toString(), clientId: config.OAUTH_CLIENT_ID }); - const response = await fetch(tokenEndpoint, { + const response = await fetch(tokenEndpoint.toString(), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -310,7 +312,7 @@ async function exchangeCodeForTokens(code: string, config: any, codeVerifier: st status: response.status, statusText: response.statusText, error: errorText, - tokenEndpoint, + tokenEndpoint: tokenEndpoint.toString(), clientId: config.OAUTH_CLIENT_ID }); return null; diff --git a/src/auth/token-validator.ts b/src/auth/token-validator.ts index 0c7dade..f6a4753 100644 --- a/src/auth/token-validator.ts +++ b/src/auth/token-validator.ts @@ -40,7 +40,7 @@ export class OAuthTokenValidator { private async validateJWT(token: string): Promise { try { // Get JWKS from the issuer - const JWKS = jose.createRemoteJWKSet(new URL(`${this.#issuer}/.well-known/jwks.json`)); + const JWKS = jose.createRemoteJWKSet(new URL("/.well-known/jwks.json", this.#issuer)); // Verify and decode the JWT const verifyOptions: any = { @@ -78,9 +78,9 @@ export class OAuthTokenValidator { } private async introspectToken(token: string): Promise { - const introspectionUrl = `${this.#issuer}/oauth/introspect`; + const introspectionUrl = new URL("/oauth/introspect", this.#issuer); - const response = await fetch(introspectionUrl, { + const response = await fetch(introspectionUrl.toString(), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/src/config.ts b/src/config.ts index 21c7353..7044212 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,7 +40,8 @@ export function getConfig(): Config { // Provide default for OAUTH_REDIRECT_URI if not set if (!parsed.OAUTH_REDIRECT_URI) { const baseUrl = parsed.BASE_URL || "http://localhost:3000"; - parsed.OAUTH_REDIRECT_URI = `${baseUrl}/callback`; + const callbackUrl = new URL("/callback", baseUrl); + parsed.OAUTH_REDIRECT_URI = callbackUrl.toString(); console.log(`⚠️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`); } diff --git a/src/index.ts b/src/index.ts index 9335e47..c7f87fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,10 +113,10 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { capabilities, ...(config.AUTH_MODE !== "none" && { oauth: { - authorization_server: `${config.BASE_URL || "http://localhost:3000"}/.well-known/oauth-authorization-server`, - protected_resource: `${config.BASE_URL || "http://localhost:3000"}/.well-known/oauth-protected-resource`, - authorization_endpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/authorize`, - token_endpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/token` + authorization_server: new URL("/.well-known/oauth-authorization-server", config.BASE_URL || "http://localhost:3000").toString(), + protected_resource: new URL("/.well-known/oauth-protected-resource", config.BASE_URL || "http://localhost:3000").toString(), + authorization_endpoint: new URL("/oauth/authorize", config.BASE_URL || "http://localhost:3000").toString(), + token_endpoint: new URL("/oauth/token", config.BASE_URL || "http://localhost:3000").toString() } }) }); @@ -133,11 +133,12 @@ const config = getConfig(); let oauthProvider: OAuthProvider | null = null; if (config.AUTH_MODE === "full") { + const baseUrl = config.BASE_URL || "http://localhost:3000"; oauthProvider = new OAuthProvider({ clientId: "mcp-client", clientSecret: "mcp-secret", - authorizationEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/authorize`, - tokenEndpoint: `${config.BASE_URL || "http://localhost:3000"}/oauth/token`, + authorizationEndpoint: new URL("/oauth/authorize", baseUrl).toString(), + tokenEndpoint: new URL("/oauth/token", baseUrl).toString(), scope: config.OAUTH_SCOPE, redirectUri: config.OAUTH_REDIRECT_URI! }); From d92cefbc0b79d9646b90592a95a4055e3bd96aa0 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 07:35:58 -0700 Subject: [PATCH 10/30] refactor(config): cleaned up base url logic --- src/config.ts | 31 +++++++++++++++++++------------ src/index.ts | 10 +++++----- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7044212..3583947 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,7 +21,9 @@ const configSchema = z.object({ OAUTH_SCOPE: z.string().default("openid profile email"), }); -export type Config = z.infer; +export type Config = z.infer & { + BASE_URL: string; +}; let config: Config; @@ -30,19 +32,25 @@ export function getConfig(): Config { try { const parsed = configSchema.parse(process.env); + if (!parsed.BASE_URL) { + parsed.BASE_URL = `http://localhost:${parsed.PORT}`; + } + // Full mode validation - OAuth Authorization Server with external IdP if (parsed.AUTH_MODE === "full") { const requiredVars = []; if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); if (!parsed.OAUTH_CLIENT_ID) requiredVars.push("OAUTH_CLIENT_ID"); - if (!parsed.OAUTH_CLIENT_SECRET) requiredVars.push("OAUTH_CLIENT_SECRET"); + if (!parsed.OAUTH_CLIENT_SECRET) + requiredVars.push("OAUTH_CLIENT_SECRET"); // Provide default for OAUTH_REDIRECT_URI if not set if (!parsed.OAUTH_REDIRECT_URI) { - const baseUrl = parsed.BASE_URL || "http://localhost:3000"; - const callbackUrl = new URL("/callback", baseUrl); + const callbackUrl = new URL("/callback", parsed.BASE_URL); parsed.OAUTH_REDIRECT_URI = callbackUrl.toString(); - console.log(`⚠️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`); + console.log( + `⚠️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`, + ); } if (requiredVars.length > 0) { @@ -52,21 +60,20 @@ export function getConfig(): Config { "OAUTH_ISSUER=https://your-domain.auth0.com\n" + "OAUTH_CLIENT_ID=your-client-id\n" + "OAUTH_CLIENT_SECRET=your-client-secret\n" + - "OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback\n" + - "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended" + "OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback or http://localhost:PORT/callback\n" + + "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended", ); } - // OAUTH_AUDIENCE is optional but recommended for full mode + // OAUTH_AUDIENCE is optional but recommended when a resource server is used if (!parsed.OAUTH_AUDIENCE) { console.warn( "⚠️ OAUTH_AUDIENCE not set for full mode. Token validation will not check audience.\n" + - " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier" + " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier", ); } } - // Resource server mode validation if (parsed.AUTH_MODE === "resource_server") { const requiredVars = []; if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); @@ -77,12 +84,12 @@ export function getConfig(): Config { `AUTH_MODE=resource_server requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + "Example configuration:\n" + "OAUTH_ISSUER=https://your-domain.auth0.com\n" + - "OAUTH_AUDIENCE=your-api-identifier" + "OAUTH_AUDIENCE=your-api-identifier", ); } } - config = parsed; + config = parsed as Config; } catch (error) { console.error("❌ Invalid environment configuration:", error); process.exit(1); diff --git a/src/index.ts b/src/index.ts index c7f87fa..adcaa3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,10 +113,10 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { capabilities, ...(config.AUTH_MODE !== "none" && { oauth: { - authorization_server: new URL("/.well-known/oauth-authorization-server", config.BASE_URL || "http://localhost:3000").toString(), - protected_resource: new URL("/.well-known/oauth-protected-resource", config.BASE_URL || "http://localhost:3000").toString(), - authorization_endpoint: new URL("/oauth/authorize", config.BASE_URL || "http://localhost:3000").toString(), - token_endpoint: new URL("/oauth/token", config.BASE_URL || "http://localhost:3000").toString() + authorization_server: new URL("/.well-known/oauth-authorization-server", config.BASE_URL).toString(), + protected_resource: new URL("/.well-known/oauth-protected-resource", config.BASE_URL).toString(), + authorization_endpoint: new URL("/oauth/authorize", config.BASE_URL).toString(), + token_endpoint: new URL("/oauth/token", config.BASE_URL).toString() } }) }); @@ -133,7 +133,7 @@ const config = getConfig(); let oauthProvider: OAuthProvider | null = null; if (config.AUTH_MODE === "full") { - const baseUrl = config.BASE_URL || "http://localhost:3000"; + const baseUrl = config.BASE_URL; oauthProvider = new OAuthProvider({ clientId: "mcp-client", clientSecret: "mcp-secret", From ec1de9c2a2d865fe92214a36a3e356185a2f9ff3 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 08:01:54 -0700 Subject: [PATCH 11/30] docs: update authentication modes in README and .env.example for clarity --- .env.example | 45 ++++++++++++++++---------- README.md | 89 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 44a5c09..d3e0a5b 100644 --- a/.env.example +++ b/.env.example @@ -5,20 +5,25 @@ SERVER_NAME=mcp-typescript-template SERVER_VERSION=1.0.0 LOG_LEVEL=info +# Base URL for the server (used for OAuth redirects and discovery endpoints) +# Defaults to http://localhost:PORT if not set (where PORT is the configured port) +# BASE_URL=http://localhost:3000 + # Authentication Configuration (Optional) # Set ENABLE_AUTH=true to enable authentication ENABLE_AUTH=false -# Authentication mode: "gateway" (recommended) or "builtin" (testing/demos) -AUTH_MODE=gateway +# Authentication mode: "resource_server", "full", or "none" (default) +AUTH_MODE=resource_server # ============================================================================ -# Gateway Mode Configuration (Recommended for Production) +# Resource Server Mode Configuration # ============================================================================ -# Use when authentication is handled by a reverse proxy/gateway (Pomerium, etc.) -# The MCP server acts as a resource server and only validates tokens +# MCP server acts as a resource server and validates tokens from external OAuth providers +# Commonly used with gateways that handle OAuth flows for enterprise deployments +# Can also work with direct OAuth flows where clients get tokens themselves -# OAuth issuer URL for token validation (required for gateway mode) +# OAuth issuer URL for token validation (required for resource_server mode) # Examples: # Auth0: https://your-domain.auth0.com # Okta: https://your-domain.okta.com @@ -30,39 +35,45 @@ OAUTH_ISSUER=https://your-domain.auth0.com OAUTH_AUDIENCE=your-api-identifier # ============================================================================ -# Built-in Mode Configuration (Testing/Demos Only) +# Full Mode Configuration (OAuth Proxy + Resource Server) # ============================================================================ -# Use when you want the MCP server to handle the OAuth flow directly -# Not recommended for production - use gateway mode instead +# MCP server acts as both OAuth client (proxy to external IdP) AND resource server +# Provides OAuth endpoints while delegating authentication to external providers +# Production-ready but consider using resource_server mode with a gateway for enterprise deployments -# OAuth client credentials (required for built-in mode) +# OAuth client credentials (required for full mode) OAUTH_CLIENT_ID=your-client-id OAUTH_CLIENT_SECRET=your-client-secret -# OAuth provider endpoints (required for built-in mode) +# OAuth provider endpoints (required for full mode) OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token # OAuth configuration OAUTH_SCOPE=read -OAUTH_REDIRECT_URI=http://localhost:3000/callback +# OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback # ============================================================================ # Example Configurations # ============================================================================ -# Example: Auth0 Gateway Mode +# Example: Auth0 Resource Server Mode # ENABLE_AUTH=true -# AUTH_MODE=gateway +# AUTH_MODE=resource_server # OAUTH_ISSUER=https://your-domain.auth0.com # OAUTH_AUDIENCE=your-api-identifier -# Example: Auth0 Built-in Mode +# Example: Auth0 Full Mode # ENABLE_AUTH=true -# AUTH_MODE=builtin +# AUTH_MODE=full # OAUTH_CLIENT_ID=your-auth0-client-id # OAUTH_CLIENT_SECRET=your-auth0-client-secret # OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize # OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token # OAUTH_SCOPE=read -# OAUTH_REDIRECT_URI=http://localhost:3000/callback \ No newline at end of file +# OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback + +# Example: No Authentication Mode (Default) +# Note: Consider enabling auth even for local development for security best practices +# ENABLE_AUTH=false +# AUTH_MODE=none \ No newline at end of file diff --git a/README.md b/README.md index 29bfc24..97fddaa 100644 --- a/README.md +++ b/README.md @@ -198,43 +198,59 @@ server.registerTool( ## Authentication & Authorization -This template provides **optional** OAuth 2.1 authentication with two deployment patterns: +This template provides **optional** OAuth 2.1 authentication with three deployment modes. Gateway-based deployment is a common production pattern that separates authentication concerns. + +### 📋 Mode Overview + +The three authentication modes serve different purposes: + +- **`none`** - No authentication (public servers, or when gateway handles all OAuth/security) +- **`full`** - Complete OAuth flow within MCP server (production-ready when you don't have a gateway) +- **`resource_server`** - Only validates tokens (requires either `full` mode or external gateway to issue tokens) + +**Key insight**: `resource_server` mode only *validates* tokens - it doesn't *issue* them. So you need either: +1. **Gateway + resource_server** (common enterprise pattern) +2. **Full mode standalone** (self-contained OAuth solution) ### 🔧 Authentication Modes -#### Gateway Mode (Enterprise/Multi-Service) -- **Resource Server Pattern**: MCP server only validates tokens from external OAuth providers -- **External OAuth**: Authentication handled by reverse proxy/gateway (Pomerium, nginx, API Gateway) -- **JWT + Introspection**: Supports both JWT validation and token introspection +#### Resource Server Mode +- **Resource Server Pattern**: MCP server validates tokens issued by external OAuth providers +- **Token Validation**: Supports both JWT validation and token introspection - **Stateless**: No OAuth routes, sessions, or cookies in MCP server - **Scalable**: Easy to horizontally scale the MCP server -- **Best for**: Organizations with existing OAuth infrastructure - -#### Built-in Mode (Standalone/Simple Deployment) -- **OAuth 2.1 Authorization Server**: MCP server IS the complete OAuth authorization server -- **PKCE Support**: Full PKCE implementation as required by MCP specification -- **MCP Client Compatible**: Works seamlessly with VS Code and other MCP clients -- **Self-contained**: No external OAuth provider needed +- **Gateway Compatible**: Commonly used with reverse proxies/gateways for enterprise deployments +- **Best for**: Deployments where authentication is handled externally + +#### Full Mode (OAuth Proxy + Resource Server) +- **Dual Role**: MCP server acts as both OAuth client (proxy) AND resource server +- **External IdP Integration**: Delegates authentication to external providers (Auth0, Google, etc.) +- **OAuth Endpoints**: Provides its own OAuth endpoints for MCP clients +- **PKCE Support**: Full PKCE implementation for secure authorization flows +- **Self-Contained**: Provides complete OAuth flow without requiring external gateway infrastructure - **Discovery Endpoints**: Provides OAuth 2.1 discovery metadata for automatic client configuration -- **Best for**: Solo developers, small teams, or simple deployments wanting OAuth security +- **Best for**: Production deployments without gateway infrastructure, provides complete OAuth flow for MCP clients #### No Auth Mode (Default) -- **Completely Optional**: Authentication can be disabled entirely +- **No Authentication**: MCP server accepts all requests without authentication +- **Use Cases**: Public servers, or when gateway handles all OAuth/security layers +- **Security Note**: Consider enabling authentication even for local development to build secure habits +- **Gateway Compatible**: Can still benefit from gateway for rate limiting, SSL termination, etc. - **Simple Setup**: Just set `ENABLE_AUTH=false` or omit auth configuration -- **Open Access**: MCP server accepts all requests without authentication ### 🚀 Quick Setup -#### 1. Gateway Mode (Enterprise) +#### 1. Resource Server Mode ```bash # .env ENABLE_AUTH=true -AUTH_MODE=gateway +AUTH_MODE=resource_server OAUTH_ISSUER=https://your-domain.auth0.com OAUTH_AUDIENCE=your-api-identifier # optional +# BASE_URL=https://mcp.yourdomain.com # optional, defaults to http://localhost:PORT ``` -**Setup your gateway (e.g., Pomerium):** +**Common Pattern: With Gateway** ```yaml # pomerium-config.yaml routes: @@ -246,12 +262,24 @@ routes: - authenticated_user: true ``` -#### 2. Built-in Mode (Standalone) +**Alternative: Direct OAuth** +```bash +# Note: MCP clients would need to implement OAuth flow themselves +# Clients get tokens directly from Auth0, send to MCP server +curl -H "Authorization: Bearer ${AUTH0_TOKEN}" http://localhost:3000/mcp +``` + +#### 2. Full Mode (Self-Contained OAuth) ```bash # .env ENABLE_AUTH=true -AUTH_MODE=builtin -# No external OAuth configuration needed - server acts as OAuth provider +AUTH_MODE=full +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize +OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token +# OAUTH_REDIRECT_URI=http://localhost:3000/callback # optional, defaults to BASE_URL/callback +# BASE_URL=https://mcp.yourdomain.com # optional, defaults to http://localhost:PORT ``` **OAuth 2.1 Endpoints (automatically available):** @@ -267,7 +295,8 @@ AUTH_MODE=builtin # .env (or just omit ENABLE_AUTH) ENABLE_AUTH=false ``` -Server accepts all requests without authentication. + +**⚠️ Security Recommendation**: While convenient for getting started, consider enabling authentication even for local development to build secure practices and catch integration issues early. ### 🔐 Token Validation @@ -282,11 +311,11 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ ### 🏗️ Architecture ``` -┌─────── Gateway Mode (Enterprise) ───────┐ ┌────── Built-in Mode (Standalone) ───┐ ┌── No Auth (Default) ──┐ +┌─── Resource Server Mode (Production) ───┐ ┌──── Full Mode (Proxy + Resource) ───┐ ┌── No Auth (Default) ──┐ │ │ │ │ │ │ -│ MCP Client → Gateway → MCP Server │ │ MCP Client → MCP Server │ │ MCP Client │ -│ ↓ │ │ ↓ │ │ ↓ │ -│ External OAuth (Auth0) │ │ Built-in OAuth Server │ │ MCP Server │ +│ MCP Client → [Gateway] → MCP Server │ │ MCP Client → MCP Server │ │ MCP Client │ +│ ↓ ↓ │ │ ↓ ↓ │ │ ↓ │ +│ External OAuth → Token Validation │ │ OAuth Proxy → External IdP │ │ MCP Server │ │ │ │ │ │ (Open Access) │ │ ✅ Enterprise ready │ │ ✅ Production ready │ │ ✅ Simple setup │ │ ✅ Stateless & scalable │ │ ✅ OAuth 2.1 compliant │ │ ✅ No auth overhead │ @@ -300,13 +329,13 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
Auth0 Configuration -**Gateway Mode:** +**Resource Server Mode:** ```bash OAUTH_ISSUER=https://your-domain.auth0.com OAUTH_AUDIENCE=your-api-identifier ``` -**Built-in Mode:** +**Full Mode:** ```bash OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token @@ -318,13 +347,13 @@ OAUTH_CLIENT_SECRET=your-client-secret
Google OAuth Configuration -**Gateway Mode:** +**Resource Server Mode:** ```bash OAUTH_ISSUER=https://accounts.google.com OAUTH_AUDIENCE=your-client-id.apps.googleusercontent.com ``` -**Built-in Mode:** +**Full Mode:** ```bash OAUTH_AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth OAUTH_TOKEN_ENDPOINT=https://oauth2.googleapis.com/token From 1a7749d9a878739fe70e7f9481c6dca6137054ff Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 08:33:56 -0700 Subject: [PATCH 12/30] refactor(auth): replace AUTH_MODE with ENABLE_AUTH for streamlined authentication logic --- src/auth/index.ts | 47 ++++++++++++-------------------------- src/config.ts | 58 ++++++++++++++++++++--------------------------- src/index.ts | 23 +++++++++++-------- 3 files changed, 54 insertions(+), 74 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index 85e2982..3886e8b 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -4,45 +4,29 @@ import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; /** - * Initialize authentication based on AUTH_MODE + * Initialize authentication based on ENABLE_AUTH */ export function initializeAuth() { const config = getConfig(); - if (config.AUTH_MODE === "none") { + if (!config.ENABLE_AUTH) { logger.info("Authentication is disabled"); return { tokenValidator: null }; } - if (config.AUTH_MODE === "resource_server") { - logger.info("Initializing OAuth resource server mode"); - - // Resource server mode: only validate tokens from external OAuth provider - const tokenValidator = new OAuthTokenValidator( - config.OAUTH_ISSUER!, - config.OAUTH_AUDIENCE - ); - - return { tokenValidator }; - } - - if (config.AUTH_MODE === "full") { - logger.info("Initializing OAuth full mode with external IdP delegation", { - issuer: config.OAUTH_ISSUER, - audience: config.OAUTH_AUDIENCE, - clientId: config.OAUTH_CLIENT_ID - }); - - // Full mode: OAuth client that delegates to external IdP + resource server capabilities - // For MCP API token validation, we need to validate OUR tokens, not external IdP tokens - // The tokens we issue to MCP clients are from our OAuthProvider, not the external IdP - - // We'll create a custom validator that validates our own issued tokens - // This needs to be handled differently - we'll return null and handle it in the middleware - return { tokenValidator: null }; - } - - throw new Error(`Unknown AUTH_MODE: ${config.AUTH_MODE}`); + logger.info("Initializing OAuth 2.1 authentication with token validation", { + issuer: config.OAUTH_ISSUER, + audience: config.OAUTH_AUDIENCE, + clientId: config.OAUTH_CLIENT_ID + }); + + // Create token validator for OAuth 2.1 token validation + const tokenValidator = new OAuthTokenValidator( + config.OAUTH_ISSUER!, + config.OAUTH_AUDIENCE + ); + + return { tokenValidator }; } /** @@ -55,6 +39,5 @@ export function createAuthenticationMiddleware() { return (_req: any, _res: any, next: any) => next(); } - // For full and resource_server modes, use token validator return createAuthMiddleware(tokenValidator); } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 3583947..ca8fc6b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,9 +10,13 @@ const configSchema = z.object({ LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), BASE_URL: z.string().optional(), - AUTH_MODE: z.enum(["none", "full", "resource_server"]).default("none"), + ENABLE_AUTH: z + .string() + .optional() + .default("false") + .transform((val) => val === "true"), - // OAuth configuration for external IdP integration + // OAuth configuration - required when ENABLE_AUTH=true OAUTH_ISSUER: z.string().optional(), OAUTH_CLIENT_ID: z.string().optional(), OAUTH_CLIENT_SECRET: z.string().optional(), @@ -36,55 +40,43 @@ export function getConfig(): Config { parsed.BASE_URL = `http://localhost:${parsed.PORT}`; } - // Full mode validation - OAuth Authorization Server with external IdP - if (parsed.AUTH_MODE === "full") { + // Log authentication status for clarity + console.log(`🔐 Authentication: ${parsed.ENABLE_AUTH ? 'ENABLED' : 'DISABLED'}`); + + // OAuth validation when authentication is enabled + if (parsed.ENABLE_AUTH) { const requiredVars = []; if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); if (!parsed.OAUTH_CLIENT_ID) requiredVars.push("OAUTH_CLIENT_ID"); if (!parsed.OAUTH_CLIENT_SECRET) requiredVars.push("OAUTH_CLIENT_SECRET"); - // Provide default for OAUTH_REDIRECT_URI if not set - if (!parsed.OAUTH_REDIRECT_URI) { - const callbackUrl = new URL("/callback", parsed.BASE_URL); - parsed.OAUTH_REDIRECT_URI = callbackUrl.toString(); - console.log( - `⚠️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`, - ); - } - if (requiredVars.length > 0) { throw new Error( - `AUTH_MODE=full requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + + `ENABLE_AUTH=true requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + "Example configuration:\n" + + "ENABLE_AUTH=true\n" + "OAUTH_ISSUER=https://your-domain.auth0.com\n" + "OAUTH_CLIENT_ID=your-client-id\n" + "OAUTH_CLIENT_SECRET=your-client-secret\n" + - "OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback or http://localhost:PORT/callback\n" + - "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended", + "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended for production", ); } - // OAUTH_AUDIENCE is optional but recommended when a resource server is used - if (!parsed.OAUTH_AUDIENCE) { - console.warn( - "⚠️ OAUTH_AUDIENCE not set for full mode. Token validation will not check audience.\n" + - " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier", + // Provide default for OAUTH_REDIRECT_URI if not set + if (!parsed.OAUTH_REDIRECT_URI) { + const callbackUrl = new URL("/callback", parsed.BASE_URL); + parsed.OAUTH_REDIRECT_URI = callbackUrl.toString(); + console.log( + `ℹ️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`, ); } - } - - if (parsed.AUTH_MODE === "resource_server") { - const requiredVars = []; - if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); - if (!parsed.OAUTH_AUDIENCE) requiredVars.push("OAUTH_AUDIENCE"); - if (requiredVars.length > 0) { - throw new Error( - `AUTH_MODE=resource_server requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + - "Example configuration:\n" + - "OAUTH_ISSUER=https://your-domain.auth0.com\n" + - "OAUTH_AUDIENCE=your-api-identifier", + // OAUTH_AUDIENCE is optional but recommended for production + if (!parsed.OAUTH_AUDIENCE) { + console.warn( + "⚠️ OAUTH_AUDIENCE not set. Token validation will not check audience.\n" + + " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier", ); } } diff --git a/src/index.ts b/src/index.ts index adcaa3c..430bd94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,7 +102,7 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { const config = getConfig(); const capabilities = ["tools"]; - if (config.AUTH_MODE !== "none") { + if (config.ENABLE_AUTH) { capabilities.push("oauth"); } @@ -111,7 +111,7 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { version: config.SERVER_VERSION, description: "TypeScript template for building MCP servers", capabilities, - ...(config.AUTH_MODE !== "none" && { + ...(config.ENABLE_AUTH && { oauth: { authorization_server: new URL("/.well-known/oauth-authorization-server", config.BASE_URL).toString(), protected_resource: new URL("/.well-known/oauth-protected-resource", config.BASE_URL).toString(), @@ -132,7 +132,8 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { const config = getConfig(); let oauthProvider: OAuthProvider | null = null; -if (config.AUTH_MODE === "full") { +// Setup OAuth endpoints and provider when authentication is enabled +if (config.ENABLE_AUTH) { const baseUrl = config.BASE_URL; oauthProvider = new OAuthProvider({ clientId: "mcp-client", @@ -154,24 +155,28 @@ if (config.AUTH_MODE === "full") { app.get(redirectPath, createCallbackHandler(oauthProvider)); app.post("/oauth/token", express.urlencoded({ extended: true }), createTokenHandler(oauthProvider)); - logger.info("OAuth 2.1 client delegation endpoints registered for full auth mode", { + logger.info("OAuth 2.1 endpoints registered", { discovery: [ "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", "/.well-known/openid_configuration" ], endpoints: ["/oauth/authorize", redirectPath, "/oauth/token"], - externalIdP: config.OAUTH_ISSUER + issuer: config.OAUTH_ISSUER }); } +// Setup authentication middleware let authMiddleware; -if (config.AUTH_MODE === "full" && oauthProvider) { +if (config.ENABLE_AUTH && oauthProvider) { authMiddleware = createOAuthProviderAuthMiddleware(oauthProvider); - logger.info("Using OAuthProvider authentication for MCP endpoints"); -} else { + logger.info("Using OAuth 2.1 authentication for MCP endpoints"); +} else if (config.ENABLE_AUTH) { authMiddleware = createAuthenticationMiddleware(); - logger.info("Using standard authentication for MCP endpoints"); + logger.info("Using OAuth 2.1 token validation for MCP endpoints"); +} else { + authMiddleware = (_req: any, _res: any, next: any) => next(); + logger.info("Authentication disabled - MCP endpoints are public"); } app.get("/mcp", mcpHandler); From 7a2796ea4d96f9cdde0b9f8e770bc9e6130cc361 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 08:34:05 -0700 Subject: [PATCH 13/30] docs: update OAuth configuration details and usage instructions in multiple files for clarity --- .cursorrules | 41 ++++-- .env.example | 95 ++++++------- .github/copilot-instructions.md | 33 ++++- CLAUDE.md | 31 +++-- README.md | 229 +++++++++----------------------- 5 files changed, 184 insertions(+), 245 deletions(-) diff --git a/.cursorrules b/.cursorrules index 8efd69f..debd804 100644 --- a/.cursorrules +++ b/.cursorrules @@ -105,13 +105,13 @@ The following environment variables are supported (see `src/config.ts`): ### OAuth Configuration (Optional) -- `ENABLE_AUTH` - Enable OAuth authentication (default: false) +- `ENABLE_AUTH` - Enable OAuth 2.1 authentication (default: false) +- `OAUTH_ISSUER` - OAuth issuer URL (required if auth enabled) - `OAUTH_CLIENT_ID` - OAuth client ID (required if auth enabled) - `OAUTH_CLIENT_SECRET` - OAuth client secret (required if auth enabled) -- `OAUTH_AUTH_ENDPOINT` - OAuth authorization endpoint (required if auth enabled) -- `OAUTH_TOKEN_ENDPOINT` - OAuth token endpoint (required if auth enabled) -- `OAUTH_SCOPE` - OAuth scope (default: "read") -- `OAUTH_REDIRECT_URI` - OAuth redirect URI (required if auth enabled) +- `OAUTH_AUDIENCE` - Expected audience in JWT tokens (optional but recommended) +- `OAUTH_SCOPE` - OAuth scope (default: "openid profile email") +- `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional, defaults to BASE_URL/callback) ## Coding Guidelines @@ -135,19 +135,38 @@ The following environment variables are supported (see `src/config.ts`): ## OAuth Implementation -### Modular Authentication +### Simple Binary Configuration -The template includes optional OAuth 2.1 authentication that can be easily enabled or completely removed: +The template includes optional OAuth 2.1 authentication with a simple on/off approach: +- **Default**: No authentication required - server runs immediately with `ENABLE_AUTH=false` +- **Enable When Needed**: Set `ENABLE_AUTH=true` and provide OAuth configuration - **Modular Design**: All OAuth code is in `src/auth/` directory -- **Conditional Loading**: OAuth middleware only applies when `ENABLE_AUTH=true` - **Zero Impact When Disabled**: No performance overhead when authentication is disabled - **Easy Removal**: Delete `src/auth/` directory and remove auth import from `src/index.ts` -### Authentication Patterns +### Use Cases -1. **External OAuth (Recommended)**: Use Pomerium or similar OAuth proxy -2. **Built-in OAuth Server**: Use the provided OAuth implementation in `src/auth/` +**Authentication Disabled** (`ENABLE_AUTH=false` or omitted): +- Public MCP servers +- Gateway-protected deployments (Pomerium, nginx with auth, etc.) +- Development and testing +- Internal corporate networks with perimeter security + +**Authentication Enabled** (`ENABLE_AUTH=true`): +- Direct OAuth 2.1 with token validation +- Self-contained secure deployment +- Production servers without gateway infrastructure + +### Quick Setup + +To enable authentication, add to your `.env`: +```bash +ENABLE_AUTH=true +OAUTH_ISSUER=https://your-provider.com +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +``` ### Removing OAuth diff --git a/.env.example b/.env.example index d3e0a5b..0d1cc28 100644 --- a/.env.example +++ b/.env.example @@ -9,71 +9,58 @@ LOG_LEVEL=info # Defaults to http://localhost:PORT if not set (where PORT is the configured port) # BASE_URL=http://localhost:3000 -# Authentication Configuration (Optional) -# Set ENABLE_AUTH=true to enable authentication -ENABLE_AUTH=false - -# Authentication mode: "resource_server", "full", or "none" (default) -AUTH_MODE=resource_server - # ============================================================================ -# Resource Server Mode Configuration -# ============================================================================ -# MCP server acts as a resource server and validates tokens from external OAuth providers -# Commonly used with gateways that handle OAuth flows for enterprise deployments -# Can also work with direct OAuth flows where clients get tokens themselves - -# OAuth issuer URL for token validation (required for resource_server mode) -# Examples: -# Auth0: https://your-domain.auth0.com -# Okta: https://your-domain.okta.com -# Google: https://accounts.google.com -OAUTH_ISSUER=https://your-domain.auth0.com - -# Expected audience in JWT tokens (optional) -# If set, tokens must have this audience claim -OAUTH_AUDIENCE=your-api-identifier - -# ============================================================================ -# Full Mode Configuration (OAuth Proxy + Resource Server) +# Authentication Configuration (Optional) # ============================================================================ -# MCP server acts as both OAuth client (proxy to external IdP) AND resource server -# Provides OAuth endpoints while delegating authentication to external providers -# Production-ready but consider using resource_server mode with a gateway for enterprise deployments - -# OAuth client credentials (required for full mode) -OAUTH_CLIENT_ID=your-client-id -OAUTH_CLIENT_SECRET=your-client-secret +# Default: No authentication required - server runs immediately +# Enable when you need OAuth 2.1 authentication with token validation +# ENABLE_AUTH=false -# OAuth provider endpoints (required for full mode) -OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize -OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token +# When ENABLE_AUTH=true, configure your OAuth provider: +# ENABLE_AUTH=true +# OAUTH_ISSUER=https://your-provider.com +# OAUTH_CLIENT_ID=your-client-id +# OAUTH_CLIENT_SECRET=your-client-secret -# OAuth configuration -OAUTH_SCOPE=read -# OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback +# Additional OAuth settings (optional) +# OAUTH_AUDIENCE=your-api-identifier # For token audience validation +# OAUTH_SCOPE=openid profile email # Default scope +# OAUTH_REDIRECT_URI=http://localhost:3000/callback # Defaults to BASE_URL/callback # ============================================================================ -# Example Configurations +# Common OAuth Provider Examples # ============================================================================ -# Example: Auth0 Resource Server Mode +# Auth0 Example: # ENABLE_AUTH=true -# AUTH_MODE=resource_server # OAUTH_ISSUER=https://your-domain.auth0.com +# OAUTH_CLIENT_ID=your-auth0-client-id +# OAUTH_CLIENT_SECRET=your-auth0-client-secret # OAUTH_AUDIENCE=your-api-identifier -# Example: Auth0 Full Mode +# Okta Example: # ENABLE_AUTH=true -# AUTH_MODE=full -# OAUTH_CLIENT_ID=your-auth0-client-id -# OAUTH_CLIENT_SECRET=your-auth0-client-secret -# OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize -# OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token -# OAUTH_SCOPE=read -# OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback +# OAUTH_ISSUER=https://your-domain.okta.com +# OAUTH_CLIENT_ID=your-okta-client-id +# OAUTH_CLIENT_SECRET=your-okta-client-secret -# Example: No Authentication Mode (Default) -# Note: Consider enabling auth even for local development for security best practices -# ENABLE_AUTH=false -# AUTH_MODE=none \ No newline at end of file +# Google Example: +# ENABLE_AUTH=true +# OAUTH_ISSUER=https://accounts.google.com +# OAUTH_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +# OAUTH_CLIENT_SECRET=your-google-client-secret + +# ============================================================================ +# Use Cases +# ============================================================================ +# +# Auth Disabled (ENABLE_AUTH=false or omitted): +# - Public MCP servers +# - Gateway-protected deployments (Pomerium, nginx with auth, etc.) +# - Development and testing +# - Internal corporate networks with perimeter security +# +# Auth Enabled (ENABLE_AUTH=true): +# - Direct OAuth 2.1 with token validation +# - Self-contained secure deployment +# - Production servers without gateway infrastructure \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4bb8672..3946824 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -74,6 +74,16 @@ See `src/config.ts` for all supported environment variables: - `SERVER_VERSION` - Server version - `LOG_LEVEL` - Logging level (error/warn/info/debug) +### OAuth Configuration (Optional) + +- `ENABLE_AUTH` - Enable OAuth 2.1 authentication (default: false) +- `OAUTH_ISSUER` - OAuth issuer URL (required if auth enabled) +- `OAUTH_CLIENT_ID` - OAuth client ID (required if auth enabled) +- `OAUTH_CLIENT_SECRET` - OAuth client secret (required if auth enabled) +- `OAUTH_AUDIENCE` - Expected audience in JWT tokens (optional but recommended) +- `OAUTH_SCOPE` - OAuth scope (default: "openid profile email") +- `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional, defaults to BASE_URL/callback) + ## Common Patterns ### Adding a New MCP Tool @@ -134,9 +144,30 @@ const port = config.PORT; const serverName = config.SERVER_NAME; ``` +## Authentication + +### Simple Binary Configuration + +The template includes optional OAuth 2.1 authentication: + +- **Default**: No authentication required (`ENABLE_AUTH=false`) +- **Enable when needed**: Set `ENABLE_AUTH=true` and provide OAuth config +- **Use cases**: Public servers (auth disabled) or secure deployments (auth enabled) + +### Quick Setup + +To enable authentication, add to `.env`: +```bash +ENABLE_AUTH=true +OAUTH_ISSUER=https://your-provider.com +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +``` + ## Important Notes - **File extensions**: Use `.js` in import statements (TypeScript compilation requirement) - **OpenTelemetry ready**: Logger automatically correlates with OTel traces when configured - **Production ready**: Includes graceful shutdown, error handling, and structured logging -- **Template usage**: This is a template - customize for your specific MCP server needs \ No newline at end of file +- **Template usage**: This is a template - customize for your specific MCP server needs +- **Authentication**: Server starts immediately with no auth setup required, add OAuth when needed \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 800dcd1..bd1884c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,13 +93,13 @@ The following environment variables are supported (see `src/config.ts`): ### OAuth Configuration (Optional) -- `ENABLE_AUTH` - Enable OAuth authentication (default: false) +- `ENABLE_AUTH` - Enable OAuth 2.1 authentication (default: false) +- `OAUTH_ISSUER` - OAuth issuer URL (required if auth enabled) - `OAUTH_CLIENT_ID` - OAuth client ID (required if auth enabled) - `OAUTH_CLIENT_SECRET` - OAuth client secret (required if auth enabled) -- `OAUTH_AUTH_ENDPOINT` - OAuth authorization endpoint (required if auth enabled) -- `OAUTH_TOKEN_ENDPOINT` - OAuth token endpoint (required if auth enabled) -- `OAUTH_SCOPE` - OAuth scope (default: "read") -- `OAUTH_REDIRECT_URI` - OAuth redirect URI (required if auth enabled) +- `OAUTH_AUDIENCE` - Expected audience in JWT tokens (optional but recommended) +- `OAUTH_SCOPE` - OAuth scope (default: "openid profile email") +- `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional, defaults to BASE_URL/callback) ## Logging Best Practices @@ -124,19 +124,28 @@ When adding new tools to the MCP server: ## OAuth Implementation -### Modular Authentication +### Simple Binary Configuration -The template includes optional OAuth 2.1 authentication that can be easily enabled or completely removed: +The template includes optional OAuth 2.1 authentication with a simple on/off approach: +- **Default**: No authentication required - server runs immediately +- **Enable When Needed**: Set `ENABLE_AUTH=true` and provide OAuth config - **Modular Design**: All OAuth code is in `src/auth/` directory -- **Conditional Loading**: OAuth middleware only applies when `ENABLE_AUTH=true` - **Zero Impact When Disabled**: No performance overhead when authentication is disabled - **Easy Removal**: Delete `src/auth/` directory and remove auth import from `src/index.ts` -### Authentication Patterns +### Use Cases -1. **External OAuth (Recommended)**: Use Pomerium or similar OAuth proxy -2. **Built-in OAuth Server**: Use the provided OAuth implementation in `src/auth/` +**Authentication Disabled** (`ENABLE_AUTH=false` or omitted): +- Public MCP servers +- Gateway-protected deployments (Pomerium, nginx with auth, etc.) +- Development and testing +- Internal corporate networks with perimeter security + +**Authentication Enabled** (`ENABLE_AUTH=true`): +- Direct OAuth 2.1 with token validation +- Self-contained secure deployment +- Production servers without gateway infrastructure ### Removing OAuth diff --git a/README.md b/README.md index 97fddaa..ac5c9aa 100644 --- a/README.md +++ b/README.md @@ -12,35 +12,19 @@ This template provides: - **ESLint + Prettier** - Code quality and formatting - **Docker** - Containerization support - **Example Tool** - Simple echo tool to demonstrate MCP tool implementation -- **OAuth 2.1 Compatible** - Optional OAuth implementation (can use Pomerium for external auth or built-in server implementation) +- **Optional OAuth 2.1** - Add authentication when needed with simple configuration -## Getting Started +## Quick Start -1. **Clone or use this template** +Get your MCP server running immediately: - ```bash - git clone - cd mcp-typescript-template - ``` - -2. **Install dependencies** - - ```bash - npm install - ``` - -3. **Build the project** - - ```bash - npm run build - ``` - -4. **Start the server** - ```bash - npm start - ``` +```bash +git clone +cd mcp-typescript-template +npm install && npm run dev +``` -The server will be available at `http://localhost:3000` for MCP connections. +That's it! Your MCP server is now running at `http://localhost:3000` with no authentication required. ## Development @@ -196,181 +180,90 @@ server.registerTool( ); ``` -## Authentication & Authorization - -This template provides **optional** OAuth 2.1 authentication with three deployment modes. Gateway-based deployment is a common production pattern that separates authentication concerns. +## Enable Authentication (Optional) -### 📋 Mode Overview +When you need OAuth 2.1 authentication with token validation, it's just a few config lines away: -The three authentication modes serve different purposes: +### Quick Setup -- **`none`** - No authentication (public servers, or when gateway handles all OAuth/security) -- **`full`** - Complete OAuth flow within MCP server (production-ready when you don't have a gateway) -- **`resource_server`** - Only validates tokens (requires either `full` mode or external gateway to issue tokens) +1. **Add to your `.env` file:** + ```bash + ENABLE_AUTH=true + OAUTH_ISSUER=https://your-provider.com + OAUTH_CLIENT_ID=your-client-id + OAUTH_CLIENT_SECRET=your-client-secret + ``` -**Key insight**: `resource_server` mode only *validates* tokens - it doesn't *issue* them. So you need either: -1. **Gateway + resource_server** (common enterprise pattern) -2. **Full mode standalone** (self-contained OAuth solution) +2. **Restart the server** + ```bash + npm run dev + ``` -### 🔧 Authentication Modes +Your MCP server now requires valid OAuth tokens for all API requests. -#### Resource Server Mode -- **Resource Server Pattern**: MCP server validates tokens issued by external OAuth providers -- **Token Validation**: Supports both JWT validation and token introspection -- **Stateless**: No OAuth routes, sessions, or cookies in MCP server -- **Scalable**: Easy to horizontally scale the MCP server -- **Gateway Compatible**: Commonly used with reverse proxies/gateways for enterprise deployments -- **Best for**: Deployments where authentication is handled externally +### Use Cases -#### Full Mode (OAuth Proxy + Resource Server) -- **Dual Role**: MCP server acts as both OAuth client (proxy) AND resource server -- **External IdP Integration**: Delegates authentication to external providers (Auth0, Google, etc.) -- **OAuth Endpoints**: Provides its own OAuth endpoints for MCP clients -- **PKCE Support**: Full PKCE implementation for secure authorization flows -- **Self-Contained**: Provides complete OAuth flow without requiring external gateway infrastructure -- **Discovery Endpoints**: Provides OAuth 2.1 discovery metadata for automatic client configuration -- **Best for**: Production deployments without gateway infrastructure, provides complete OAuth flow for MCP clients +**Authentication Disabled** (`ENABLE_AUTH=false` or omitted): +- Public MCP servers +- Gateway-protected deployments (Pomerium, nginx with auth, etc.) +- Development and testing +- Internal corporate networks with perimeter security -#### No Auth Mode (Default) -- **No Authentication**: MCP server accepts all requests without authentication -- **Use Cases**: Public servers, or when gateway handles all OAuth/security layers -- **Security Note**: Consider enabling authentication even for local development to build secure habits -- **Gateway Compatible**: Can still benefit from gateway for rate limiting, SSL termination, etc. -- **Simple Setup**: Just set `ENABLE_AUTH=false` or omit auth configuration +**Authentication Enabled** (`ENABLE_AUTH=true`): +- Direct OAuth 2.1 with token validation +- Self-contained secure deployment +- Production servers without gateway infrastructure -### 🚀 Quick Setup +### OAuth Provider Examples -#### 1. Resource Server Mode +**Auth0:** ```bash -# .env ENABLE_AUTH=true -AUTH_MODE=resource_server OAUTH_ISSUER=https://your-domain.auth0.com -OAUTH_AUDIENCE=your-api-identifier # optional -# BASE_URL=https://mcp.yourdomain.com # optional, defaults to http://localhost:PORT -``` - -**Common Pattern: With Gateway** -```yaml -# pomerium-config.yaml -routes: - - from: https://mcp.yourdomain.com - to: http://localhost:3000 - policies: - - allow: - and: - - authenticated_user: true -``` - -**Alternative: Direct OAuth** -```bash -# Note: MCP clients would need to implement OAuth flow themselves -# Clients get tokens directly from Auth0, send to MCP server -curl -H "Authorization: Bearer ${AUTH0_TOKEN}" http://localhost:3000/mcp +OAUTH_CLIENT_ID=your-auth0-client-id +OAUTH_CLIENT_SECRET=your-auth0-client-secret +OAUTH_AUDIENCE=your-api-identifier ``` -#### 2. Full Mode (Self-Contained OAuth) +**Okta:** ```bash -# .env ENABLE_AUTH=true -AUTH_MODE=full -OAUTH_CLIENT_ID=your-client-id -OAUTH_CLIENT_SECRET=your-client-secret -OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize -OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token -# OAUTH_REDIRECT_URI=http://localhost:3000/callback # optional, defaults to BASE_URL/callback -# BASE_URL=https://mcp.yourdomain.com # optional, defaults to http://localhost:PORT +OAUTH_ISSUER=https://your-domain.okta.com +OAUTH_CLIENT_ID=your-okta-client-id +OAUTH_CLIENT_SECRET=your-okta-client-secret ``` -**OAuth 2.1 Endpoints (automatically available):** -- `GET /.well-known/oauth-authorization-server` - OAuth server metadata -- `GET /.well-known/oauth-protected-resource` - Resource server metadata -- `GET /authorize` - OAuth authorization endpoint (with PKCE) -- `POST /token` - Token exchange endpoint -- `POST /introspect` - Token introspection endpoint -- `POST /revoke` - Token revocation endpoint - -#### 3. No Auth Mode (Default) +**Google:** ```bash -# .env (or just omit ENABLE_AUTH) -ENABLE_AUTH=false +ENABLE_AUTH=true +OAUTH_ISSUER=https://accounts.google.com +OAUTH_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +OAUTH_CLIENT_SECRET=your-google-client-secret ``` -**⚠️ Security Recommendation**: While convenient for getting started, consider enabling authentication even for local development to build secure practices and catch integration issues early. - -### 🔐 Token Validation - -Both modes validate tokens using the **resource server pattern**: +### Making Authenticated Requests ```bash -# Make authenticated requests curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ http://localhost:3000/mcp ``` -### 🏗️ Architecture - -``` -┌─── Resource Server Mode (Production) ───┐ ┌──── Full Mode (Proxy + Resource) ───┐ ┌── No Auth (Default) ──┐ -│ │ │ │ │ │ -│ MCP Client → [Gateway] → MCP Server │ │ MCP Client → MCP Server │ │ MCP Client │ -│ ↓ ↓ │ │ ↓ ↓ │ │ ↓ │ -│ External OAuth → Token Validation │ │ OAuth Proxy → External IdP │ │ MCP Server │ -│ │ │ │ │ (Open Access) │ -│ ✅ Enterprise ready │ │ ✅ Production ready │ │ ✅ Simple setup │ -│ ✅ Stateless & scalable │ │ ✅ OAuth 2.1 compliant │ │ ✅ No auth overhead │ -│ ✅ Security best practices │ │ ✅ PKCE + Discovery │ │ ✅ Perfect for dev │ -│ ✅ JWT + Token introspection │ │ ✅ Works with VS Code │ │ │ -└─────────────────────────────────────────┘ └─────────────────────────────────────┘ └───────────────────────┘ -``` - -### 🔧 Provider Examples - -
-Auth0 Configuration +### OAuth 2.1 Endpoints (when enabled) -**Resource Server Mode:** -```bash -OAUTH_ISSUER=https://your-domain.auth0.com -OAUTH_AUDIENCE=your-api-identifier -``` - -**Full Mode:** -```bash -OAUTH_AUTH_ENDPOINT=https://your-domain.auth0.com/authorize -OAUTH_TOKEN_ENDPOINT=https://your-domain.auth0.com/oauth/token -OAUTH_CLIENT_ID=your-client-id -OAUTH_CLIENT_SECRET=your-client-secret -``` -
- -
-Google OAuth Configuration - -**Resource Server Mode:** -```bash -OAUTH_ISSUER=https://accounts.google.com -OAUTH_AUDIENCE=your-client-id.apps.googleusercontent.com -``` - -**Full Mode:** -```bash -OAUTH_AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth -OAUTH_TOKEN_ENDPOINT=https://oauth2.googleapis.com/token -OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com -OAUTH_CLIENT_SECRET=your-client-secret -``` -
+The server automatically provides these endpoints: +- `GET /.well-known/oauth-authorization-server` - OAuth server metadata +- `GET /.well-known/oauth-protected-resource` - Resource server metadata +- `GET /oauth/authorize` - Authorization endpoint (with PKCE) +- `POST /oauth/token` - Token exchange endpoint -### 🛠️ Customization +### Removing Authentication -The auth implementation is modular and can be easily: -- Disabled completely (set `ENABLE_AUTH=false`) -- Removed entirely (delete `src/auth/` directory) -- Extended with custom validation logic -- Integrated with other OAuth providers +To completely remove OAuth support: +1. Delete the `src/auth/` directory +2. Remove auth imports from `src/index.ts` +3. Remove OAuth environment variables from `src/config.ts` -See `src/auth/` for implementation details. +The core MCP server functionality is completely independent of the authentication layer. ## Why Express? From 82d4ba507ac38a94bea43ff2c40482e184b4b976 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 08:47:37 -0700 Subject: [PATCH 14/30] docs: add optional OAUTH_REDIRECT_URI configuration to README for OAuth setup --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ac5c9aa..593bb77 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ When you need OAuth 2.1 authentication with token validation, it's just a few co OAUTH_ISSUER=https://your-provider.com OAUTH_CLIENT_ID=your-client-id OAUTH_CLIENT_SECRET=your-client-secret + OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback ``` 2. **Restart the server** From 0b808c99a35bde51f4b6c955a03948df944b8ae2 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 09:19:47 -0700 Subject: [PATCH 15/30] fix(mcp): use JSON-RPC 2.0 format for error responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom error format with standard JSON-RPC 2.0 structure - Use appropriate error codes: -32600 (Invalid Request), -32000 (Connection Closed), -32603 (Internal Error) - Improves MCP specification compliance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/index.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 430bd94..1944341 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,16 +84,28 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { logger.warn( "POST request without session ID for non-initialization request", ); - res - .status(400) - .json({ error: "Session ID required for non-initialization requests" }); + res.status(400).json({ + jsonrpc: "2.0", + id: null, + error: { + code: -32600, + message: "Session ID required for non-initialization requests" + } + }); return; } // Handle unknown session if (sessionId && !transports[sessionId]) { logger.warn("Request for unknown session", { sessionId }); - res.status(404).json({ error: "Session not found" }); + res.status(404).json({ + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: "Session not found" + } + }); return; } @@ -125,7 +137,14 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { logger.error("Error handling MCP request", { error: error instanceof Error ? error.message : error, }); - res.status(500).json({ error: "Internal server error" }); + res.status(500).json({ + jsonrpc: "2.0", + id: null, + error: { + code: -32603, + message: "Internal server error" + } + }); } }; From 14459ac8f6f045708b8d7ccd45ba3e1dc7d367de Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 09:20:01 -0700 Subject: [PATCH 16/30] security(oauth): enable client auth and remove token logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable client authentication for authorization code flow (OAuth 2.1 compliance) - Remove sensitive token fragments from log messages - Replace token substrings with token length for debugging - Improves security by preventing token exposure in logs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/auth/discovery.ts | 3 +-- src/auth/oauth-provider.ts | 6 +++--- src/auth/oauth-server.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index 1d1ab6a..436b500 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -133,8 +133,7 @@ export function createAuthorizeHandler(oauthServer: OAuth2Server) { logger.info("Authorization code granted", { clientId: authorizationCode.client.id, - userId: user.id, - code: authorizationCode.authorizationCode.substring(0, 8) + "..." + userId: user.id }); // Redirect back to client with authorization code diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index af4d268..0e986b7 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -126,14 +126,14 @@ export class OAuthProvider { const codeData = this.#authorizationCodes.get(code); if (!codeData) { - logger.warn("Invalid authorization code", { code: code.substring(0, 8) + "..." }); + logger.warn("Invalid authorization code", { codeLength: code.length }); return null; } // Check expiration if (codeData.expiresAt < new Date()) { this.#authorizationCodes.delete(code); - logger.warn("Expired authorization code", { code: code.substring(0, 8) + "..." }); + logger.warn("Expired authorization code", { codeLength: code.length }); return null; } @@ -150,7 +150,7 @@ export class OAuthProvider { // PKCE verification if (!this.verifyPKCE(codeVerifier, codeData.codeChallenge)) { - logger.warn("PKCE verification failed", { code: code.substring(0, 8) + "..." }); + logger.warn("PKCE verification failed", { codeLength: code.length }); return null; } diff --git a/src/auth/oauth-server.ts b/src/auth/oauth-server.ts index a8c0938..0d118be 100644 --- a/src/auth/oauth-server.ts +++ b/src/auth/oauth-server.ts @@ -156,7 +156,7 @@ export class ManagedOAuthServer { }, // OAuth 2.1 configuration - requireClientAuthentication: { authorization_code: false }, + requireClientAuthentication: { authorization_code: true }, allowBearerTokensInQueryString: false, accessTokenLifetime: 3600, // 1 hour authorizationCodeLifetime: 600, // 10 minutes From ef4241968be7bd13ee4d78eae4abb3ad7e9f13ba Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 09:36:13 -0700 Subject: [PATCH 17/30] refactor(oauth): remove hardcoded demo values and improve user ID generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded "demo-user" fallbacks with proper random user ID generation - Update variable names from "demoClient" to "configuredClient" for clarity - Generate unique user IDs using randomBytes for better security - Update comments to reflect production considerations - Add generateUserId() method to OAuthProvider for consistent ID generation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/auth/oauth-model.ts | 8 ++++---- src/auth/oauth-provider.ts | 9 ++++++++- src/auth/oauth-server.ts | 7 +++++-- src/auth/routes.ts | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/auth/oauth-model.ts b/src/auth/oauth-model.ts index 01d68c9..8d3816b 100644 --- a/src/auth/oauth-model.ts +++ b/src/auth/oauth-model.ts @@ -9,7 +9,7 @@ type Client = OAuth2Server.Client; type Token = OAuth2Server.Token; type User = OAuth2Server.User; -// In-memory storage for demo purposes +// In-memory storage (use persistent storage in production) // In production, use a proper database const clients = new Map(); const users = new Map(); @@ -18,7 +18,7 @@ const tokens = new Map(); // Get client configuration from environment const config = getConfig(); -const demoClient: Client = { +const configuredClient: Client = { id: config.OAUTH_CLIENT_ID, clientSecret: config.OAUTH_CLIENT_SECRET, redirectUris: ['http://localhost:3000/callback', 'vscode://ms-vscode.claude-dev'], @@ -26,7 +26,7 @@ const demoClient: Client = { }; // Initialize client data -clients.set(demoClient.id, demoClient); +clients.set(configuredClient.id, configuredClient); export const oauthModel: AuthorizationCodeModel = { /** @@ -214,7 +214,7 @@ export const oauthModel: AuthorizationCodeModel = { scope }); - // For demo purposes, allow all requested scopes + // Simplified scope validation - implement proper scope checking // In production, implement proper scope validation const allowedScopes = ['read', 'write', 'mcp']; const validScopes = scope.filter(s => allowedScopes.includes(s)); diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index 0e986b7..23fa75b 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -160,7 +160,7 @@ export class OAuthProvider { const expiresIn = 3600; // Store access token with user info from external tokens - const userId = codeData.userId || codeData.externalTokens?.accessToken.substring(0, 8) || "demo-user"; + const userId = codeData.userId || this.generateUserId(); this.#accessTokens.set(accessToken, { userId, scope: codeData.scope, @@ -215,6 +215,13 @@ export class OAuthProvider { }; } + /** + * Generate a unique user ID + */ + private generateUserId(): string { + return `user-${randomBytes(16).toString('hex')}`; + } + /** * Clean up expired codes and tokens */ diff --git a/src/auth/oauth-server.ts b/src/auth/oauth-server.ts index 0d118be..8c51ff4 100644 --- a/src/auth/oauth-server.ts +++ b/src/auth/oauth-server.ts @@ -1,5 +1,6 @@ import OAuth2Server from "oauth2-server"; import { generateChallenge, verifyChallenge } from "pkce-challenge"; +import { randomBytes } from "node:crypto"; import { logger } from "../logger.ts"; interface Client { @@ -139,9 +140,11 @@ export class ManagedOAuthServer { return token; }, - // User verification (simplified for demo) + // User verification - should be replaced with real authentication getUser: async () => { - return { id: "demo-user" }; + // Generate a unique user ID for each session + const userId = `user-${randomBytes(8).toString('hex')}`; + return { id: userId }; }, // Scope verification diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 2e9a268..2b195a1 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -236,7 +236,7 @@ export function createCallbackHandler(oauthProvider: OAuthProvider) { expiresIn: tokenResponse.expires_in, scope: tokenResponse.scope }, - `external-user-${requestId.substring(0, 8)}` // Simple user ID for demo + `external-user-${randomBytes(8).toString('hex')}` // Generate unique user ID ); logger.info("Token exchange completed, MCP auth code generated", { From 10ec97e203a380e8dc05783ed9e0b3ff8fafe543 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 09:36:32 -0700 Subject: [PATCH 18/30] feat(security): add rate limiting to OAuth endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install express-rate-limit dependency for protection against abuse - Add comprehensive rate limiting configuration with JSON-RPC 2.0 error format - Configure different limits: 100/15min for OAuth endpoints, 10/15min for token endpoint - Include structured logging for rate limit violations - Prepare for applying rate limits to sensitive OAuth routes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 28 ++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 29 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6251cb2..796de33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@types/express": "^5.0.3", "@types/oauth2-server": "^3.0.18", "express": "^5.1.0", + "express-rate-limit": "^8.0.1", "jose": "^6.0.12", "oauth2-server": "^3.1.1", "pino": "^9.0.0", @@ -3704,6 +3705,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", + "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -4548,6 +4567,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index fbf39c3..3e141f7 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/express": "^5.0.3", "@types/oauth2-server": "^3.0.18", "express": "^5.1.0", + "express-rate-limit": "^8.0.1", "jose": "^6.0.12", "oauth2-server": "^3.1.1", "pino": "^9.0.0", From cf39df241abf75d1cb64fac174bce4db75a30330 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 09:37:09 -0700 Subject: [PATCH 19/30] feat(security): apply rate limiting to OAuth endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply comprehensive rate limiting to /oauth/authorize and callback endpoints (100 req/15min) - Apply stricter rate limiting to /oauth/token endpoint (10 req/15min) - Include structured logging for rate limit violations with IP and user agent tracking - Use JSON-RPC 2.0 compliant error responses for rate limit exceeded scenarios - Protect against OAuth abuse and brute force attacks 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/index.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1944341..46c3a57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import express from "express"; +import rateLimit from "express-rate-limit"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -151,6 +152,68 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { const config = getConfig(); let oauthProvider: OAuthProvider | null = null; +// Rate limiting for OAuth endpoints +const oauthRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per windowMs + message: { + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: "Too many requests, please try again later" + } + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn("Rate limit exceeded for OAuth endpoint", { + ip: req.ip, + path: req.path, + userAgent: req.get('User-Agent') + }); + res.status(429).json({ + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: "Too many requests, please try again later" + } + }); + } +}); + +// Stricter rate limiting for token endpoint +const tokenRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // Limit each IP to 10 token requests per windowMs + message: { + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: "Too many token requests, please try again later" + } + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn("Rate limit exceeded for token endpoint", { + ip: req.ip, + path: req.path, + userAgent: req.get('User-Agent') + }); + res.status(429).json({ + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: "Too many token requests, please try again later" + } + }); + } +}); + // Setup OAuth endpoints and provider when authentication is enabled if (config.ENABLE_AUTH) { const baseUrl = config.BASE_URL; @@ -170,9 +233,9 @@ if (config.ENABLE_AUTH) { // Extract path from redirect URI for route registration const redirectPath = new URL(config.OAUTH_REDIRECT_URI!).pathname; - app.get("/oauth/authorize", createAuthorizeHandler()); - app.get(redirectPath, createCallbackHandler(oauthProvider)); - app.post("/oauth/token", express.urlencoded({ extended: true }), createTokenHandler(oauthProvider)); + app.get("/oauth/authorize", oauthRateLimit, createAuthorizeHandler()); + app.get(redirectPath, oauthRateLimit, createCallbackHandler(oauthProvider)); + app.post("/oauth/token", tokenRateLimit, express.urlencoded({ extended: true }), createTokenHandler(oauthProvider)); logger.info("OAuth 2.1 endpoints registered", { discovery: [ From b277fb1f4d5984317b58c984ce8854fb8a42fbf8 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 09:37:41 -0700 Subject: [PATCH 20/30] feat(mcp): implement comprehensive session cleanup mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add session timestamp tracking for MCP transport connections - Implement automatic cleanup of stale sessions (30+ minutes inactive) - Properly close transport connections to prevent resource leaks - Schedule cleanup every 10 minutes with structured logging - Track both session creation and access times for accurate timeout detection - Log cleanup statistics including cleaned and active session counts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/index.ts b/src/index.ts index 46c3a57..1382cca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ const app = express(); app.use(express.json()); const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const sessionTimestamps: { [sessionId: string]: Date } = {}; const mcpHandler = async (req: express.Request, res: express.Response) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; @@ -63,6 +64,7 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { transports[sessionId] = transport; + sessionTimestamps[sessionId] = new Date(); logger.info("MCP session initialized", { sessionId }); }, }); @@ -75,6 +77,8 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { // Handle existing session requests if (sessionId && transports[sessionId]) { + // Update session timestamp + sessionTimestamps[sessionId] = new Date(); const transport = transports[sessionId]; await transport.handleRequest(req, res, req.body); return; @@ -149,6 +153,48 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { } }; +/** + * Clean up stale MCP sessions + */ +function cleanupStaleSessions(): void { + const now = new Date(); + const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + let cleanedCount = 0; + + for (const [sessionId, timestamp] of Object.entries(sessionTimestamps)) { + if (now.getTime() - timestamp.getTime() > SESSION_TIMEOUT_MS) { + // Close transport if it exists + const transport = transports[sessionId]; + if (transport) { + try { + transport.close?.(); + } catch (error) { + logger.warn("Error closing stale transport", { + sessionId, + error: error instanceof Error ? error.message : error + }); + } + delete transports[sessionId]; + } + + delete sessionTimestamps[sessionId]; + cleanedCount++; + + logger.debug("Cleaned up stale MCP session", { sessionId }); + } + } + + if (cleanedCount > 0) { + logger.info("MCP session cleanup completed", { + cleanedSessions: cleanedCount, + activeSessions: Object.keys(transports).length + }); + } +} + +// Schedule MCP session cleanup every 10 minutes +setInterval(cleanupStaleSessions, 10 * 60 * 1000); + const config = getConfig(); let oauthProvider: OAuthProvider | null = null; From 048b672b63b86b6712120dfdc89415d24ff4b490 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 10:43:27 -0700 Subject: [PATCH 21/30] docs(readme): update production storage limitation section for clarity --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 593bb77..f58795f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,15 @@ This template provides: - **Example Tool** - Simple echo tool to demonstrate MCP tool implementation - **Optional OAuth 2.1** - Add authentication when needed with simple configuration +## ⚠️ Production Storage Limitation + +[!WARNING] +**Production Storage Limitation** + +This template uses in-memory storage for all OAuth codes, tokens, and session data. All such data will be lost on server restart. This approach is suitable for development and testing only. For production deployments, you must implement persistent storage (e.g., database, external cache) to ensure reliability and compliance. + +**Do not use in-memory storage in production environments.** + ## Quick Start Get your MCP server running immediately: @@ -187,6 +196,7 @@ When you need OAuth 2.1 authentication with token validation, it's just a few co ### Quick Setup 1. **Add to your `.env` file:** + ```bash ENABLE_AUTH=true OAUTH_ISSUER=https://your-provider.com @@ -205,19 +215,22 @@ Your MCP server now requires valid OAuth tokens for all API requests. ### Use Cases **Authentication Disabled** (`ENABLE_AUTH=false` or omitted): + - Public MCP servers - Gateway-protected deployments (Pomerium, nginx with auth, etc.) - Development and testing - Internal corporate networks with perimeter security **Authentication Enabled** (`ENABLE_AUTH=true`): + - Direct OAuth 2.1 with token validation -- Self-contained secure deployment +- Self-contained secure deployment - Production servers without gateway infrastructure ### OAuth Provider Examples **Auth0:** + ```bash ENABLE_AUTH=true OAUTH_ISSUER=https://your-domain.auth0.com @@ -227,6 +240,7 @@ OAUTH_AUDIENCE=your-api-identifier ``` **Okta:** + ```bash ENABLE_AUTH=true OAUTH_ISSUER=https://your-domain.okta.com @@ -235,6 +249,7 @@ OAUTH_CLIENT_SECRET=your-okta-client-secret ``` **Google:** + ```bash ENABLE_AUTH=true OAUTH_ISSUER=https://accounts.google.com @@ -252,6 +267,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ ### OAuth 2.1 Endpoints (when enabled) The server automatically provides these endpoints: + - `GET /.well-known/oauth-authorization-server` - OAuth server metadata - `GET /.well-known/oauth-protected-resource` - Resource server metadata - `GET /oauth/authorize` - Authorization endpoint (with PKCE) @@ -260,6 +276,7 @@ The server automatically provides these endpoints: ### Removing Authentication To completely remove OAuth support: + 1. Delete the `src/auth/` directory 2. Remove auth imports from `src/index.ts` 3. Remove OAuth environment variables from `src/config.ts` From 08b66db1dc9739f5be204ae3c7fdec4c787d8232 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 10:57:55 -0700 Subject: [PATCH 22/30] feat(docs): enhance authentication modes section in README for clarity --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f58795f..9cd0342 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,28 @@ server.registerTool( ); ``` -## Enable Authentication (Optional) +## Authentication Modes + +This template supports two modes of operation: + +- **Authentication Disabled** (`ENABLE_AUTH=false` or omitted): + - No authentication required for MCP server + +- **Authentication Enabled** (`ENABLE_AUTH=true`): + - OAuth 2.1 authentication and token validation enforced for all MCP server endpoints + - Suitable for secure, self-contained deployments or production servers without gateway infrastructure + +Switch between modes by setting the `ENABLE_AUTH` environment variable in your `.env` file. + +--- + +### Gateways & Proxies for MCP Security + +You can deploy MCP servers behind API gateways, identity-aware proxies (IAP), or AI Gateways, recommended by the [MCP Security Best Practices](https://modelcontextprotocol.org/docs/security#mcp-proxy). + +- **Pomerium**: Full MCP support, including OAuth/OIDC authentication, fine-grained access policies, not just for the server but also for at the tool level, and session management. You can run your MCP server with authentication disabled (`ENABLE_AUTH=false`) and let Pomerium handle all security. See: [Pomerium MCP Capabilities](https://docs.pomerium.com/docs/capabilities/mcp). + +Have a gateway suggestion? [Create an issue](https://github.com/nickytonline/mcp-typescript-template/issues) to help expand this list! When you need OAuth 2.1 authentication with token validation, it's just a few config lines away: @@ -218,7 +239,6 @@ Your MCP server now requires valid OAuth tokens for all API requests. - Public MCP servers - Gateway-protected deployments (Pomerium, nginx with auth, etc.) -- Development and testing - Internal corporate networks with perimeter security **Authentication Enabled** (`ENABLE_AUTH=true`): From f3a6def19cf6678508bfe10203c842744171a3f7 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Mon, 4 Aug 2025 11:02:55 -0700 Subject: [PATCH 23/30] feat(tests): add unit tests for OAuthProvider functionality --- src/auth/oauth-provider.test.ts | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/auth/oauth-provider.test.ts diff --git a/src/auth/oauth-provider.test.ts b/src/auth/oauth-provider.test.ts new file mode 100644 index 0000000..1fe49c6 --- /dev/null +++ b/src/auth/oauth-provider.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { OAuthProvider } from "./oauth-provider"; + +const config = { + clientId: "test-client", + clientSecret: "test-secret", + authorizationEndpoint: "http://localhost/oauth/authorize", + tokenEndpoint: "http://localhost/oauth/token", + scope: "openid profile email", + redirectUri: "http://localhost/callback", +}; + +describe("OAuthProvider", () => { + let provider: OAuthProvider; + + beforeEach(() => { + provider = new OAuthProvider(config); + }); + + it("should store and exchange authorization codes via public API", async () => { + const code = "code123"; + const codeChallenge = "challenge"; + provider.storeAuthorizationCode(code, { + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: "openid", + codeChallenge, + codeChallengeMethod: "S256", + expiresAt: new Date(Date.now() + 60000), + }); + // Should fail PKCE verification (challenge won't match), so returns null + const result = await provider.exchangeAuthorizationCode( + code, + "wrong_verifier", + config.clientId, + config.redirectUri, + ); + expect(result).toBeNull(); + + // Now use correct PKCE verifier + // To generate correct PKCE challenge: + // S256: base64url(sha256(verifier)) === challenge + // We'll use a helper here for the test + const crypto = await import("node:crypto"); + const verifier = "test_verifier"; + const correctChallenge = crypto + .createHash("sha256") + .update(verifier) + .digest("base64url"); + provider.storeAuthorizationCode("code456", { + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: "openid", + codeChallenge: correctChallenge, + codeChallengeMethod: "S256", + expiresAt: new Date(Date.now() + 60000), + }); + const validResult = await provider.exchangeAuthorizationCode( + "code456", + verifier, + config.clientId, + config.redirectUri, + ); + expect(validResult).not.toBeNull(); + expect(validResult?.accessToken).toMatch(/^mcp_/); + expect(validResult?.scope).toBe("openid"); + }); + + it("should verify PKCE correctly", () => { + // @ts-ignore + expect(provider["verifyPKCE"]("abc", "").toString()).toBe("false"); + // Real PKCE test would require correct challenge + }); + + it("should generate user IDs in expected format", () => { + // @ts-ignore + const userId = provider["generateUserId"](); + expect(userId.startsWith("user-")).toBe(true); + expect(userId.length).toBeGreaterThan(10); + }); + + it("should return valid: false for invalid token", async () => { + const result = await provider.validateToken(""); + expect(result.valid).toBe(false); + }); + + // Add more tests for exchangeAuthorizationCode, cleanup, etc. as needed +}); From a36654601431d5f5921f577e1bcb6854eb7daad7 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 5 Aug 2025 08:02:47 -0700 Subject: [PATCH 24/30] chore(gitignore): add macOS metadata to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7f20861..899f596 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ vite.config.ts.timestamp-* # Claude Code local settings .claude/ + +# macOS metadata +.DS_Store \ No newline at end of file From 771558327a9a47925919d8e070098ac75e0f3390 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 5 Aug 2025 08:04:35 -0700 Subject: [PATCH 25/30] test (config): updated tests for config --- src/config.test.ts | 165 ++++++--------------------------------------- src/config.ts | 9 +-- 2 files changed, 27 insertions(+), 147 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 60bb202..0b91d66 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,7 +11,7 @@ describe("config", () => { delete process.env.SERVER_NAME; delete process.env.SERVER_VERSION; delete process.env.LOG_LEVEL; - delete process.env.AUTH_MODE; + delete process.env.ENABLE_AUTH; delete process.env.OAUTH_ISSUER; delete process.env.OAUTH_AUDIENCE; delete process.env.OAUTH_CLIENT_ID; @@ -23,15 +23,13 @@ describe("config", () => { describe("getConfig", () => { it("should return default configuration when no environment variables are set", async () => { const { getConfig } = await import("./config.ts"); - const config = getConfig(); - expect(config.PORT).toBe(3000); expect(config.NODE_ENV).toBe("development"); expect(config.SERVER_NAME).toBe("mcp-typescript-template"); expect(config.SERVER_VERSION).toBe("1.0.0"); expect(config.LOG_LEVEL).toBe("info"); - expect(config.AUTH_MODE).toBe("none"); + expect(config.ENABLE_AUTH).toBe(false); }); it("should parse environment variables correctly", async () => { @@ -40,10 +38,15 @@ describe("config", () => { process.env.SERVER_NAME = "test-server"; process.env.SERVER_VERSION = "2.0.0"; process.env.LOG_LEVEL = "debug"; - process.env.AUTH_MODE = "full"; + process.env.ENABLE_AUTH = "true"; process.env.OAUTH_ISSUER = "https://issuer.example.com"; process.env.OAUTH_CLIENT_ID = "client-id"; process.env.OAUTH_CLIENT_SECRET = "client-secret"; + // Optional but recommended + process.env.OAUTH_AUDIENCE = "test-audience"; + process.env.OAUTH_REDIRECT_URI = "http://localhost:8080/callback"; + + vi.resetModules(); const { getConfig } = await import("./config.ts"); const config = getConfig(); @@ -53,152 +56,41 @@ describe("config", () => { expect(config.SERVER_NAME).toBe("test-server"); expect(config.SERVER_VERSION).toBe("2.0.0"); expect(config.LOG_LEVEL).toBe("debug"); - expect(config.AUTH_MODE).toBe("full"); + expect(config.ENABLE_AUTH).toBe(true); }); it("should coerce PORT to number", async () => { process.env.PORT = "3001"; - + const { getConfig } = await import("./config.ts"); const config = getConfig(); - + expect(config.PORT).toBe(3001); expect(typeof config.PORT).toBe("number"); }); it("should cache configuration on subsequent calls", async () => { process.env.SERVER_NAME = "first-call"; - + const { getConfig } = await import("./config.ts"); const firstConfig = getConfig(); expect(firstConfig.SERVER_NAME).toBe("first-call"); - + process.env.SERVER_NAME = "second-call"; - + const secondConfig = getConfig(); expect(secondConfig.SERVER_NAME).toBe("first-call"); }); - describe("AUTH_MODE validation", () => { - it("should require OAuth configuration when AUTH_MODE is full", async () => { - process.env.AUTH_MODE = "full"; - // Missing required OAuth vars - - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); - }); - - const { getConfig } = await import("./config.ts"); - expect(() => getConfig()).toThrow("process.exit called"); - expect(consoleSpy).toHaveBeenCalledWith( - "❌ Invalid environment configuration:", - expect.any(Error) - ); - - consoleSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - it("should accept complete OAuth configuration for AUTH_MODE=full", async () => { - process.env.AUTH_MODE = "full"; - process.env.OAUTH_ISSUER = "https://issuer.example.com"; - process.env.OAUTH_CLIENT_ID = "client-id"; - process.env.OAUTH_CLIENT_SECRET = "client-secret"; - - const { getConfig } = await import("./config.ts"); - const config = getConfig(); - - expect(config.AUTH_MODE).toBe("full"); - expect(config.OAUTH_ISSUER).toBe("https://issuer.example.com"); - expect(config.OAUTH_CLIENT_ID).toBe("client-id"); - expect(config.OAUTH_CLIENT_SECRET).toBe("client-secret"); - }); - - it("should warn when OAUTH_AUDIENCE is missing for full mode", async () => { - process.env.AUTH_MODE = "full"; - process.env.OAUTH_ISSUER = "https://issuer.example.com"; - process.env.OAUTH_CLIENT_ID = "client-id"; - process.env.OAUTH_CLIENT_SECRET = "client-secret"; - // Missing OAUTH_AUDIENCE - - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const { getConfig } = await import("./config.ts"); - const config = getConfig(); - - expect(config.AUTH_MODE).toBe("full"); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("⚠️ OAUTH_AUDIENCE not set for full mode") - ); - - warnSpy.mockRestore(); - }); - - it("should require OAUTH_ISSUER for resource_server mode", async () => { - process.env.AUTH_MODE = "resource_server"; - // Missing OAUTH_ISSUER - - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); - }); - - const { getConfig } = await import("./config.ts"); - expect(() => getConfig()).toThrow("process.exit called"); - - consoleSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - it("should error when OAUTH_AUDIENCE is missing for resource_server mode", async () => { - process.env.AUTH_MODE = "resource_server"; - process.env.OAUTH_ISSUER = "https://issuer.example.com"; - // Missing OAUTH_AUDIENCE - - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); - }); - - const { getConfig } = await import("./config.ts"); - expect(() => getConfig()).toThrow("process.exit called"); - - consoleSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - it("should accept resource_server mode with complete configuration", async () => { - process.env.AUTH_MODE = "resource_server"; - process.env.OAUTH_ISSUER = "https://issuer.example.com"; - process.env.OAUTH_AUDIENCE = "mcp-server"; - - const { getConfig } = await import("./config.ts"); - const config = getConfig(); - - expect(config.AUTH_MODE).toBe("resource_server"); - expect(config.OAUTH_ISSUER).toBe("https://issuer.example.com"); - expect(config.OAUTH_AUDIENCE).toBe("mcp-server"); - }); - - it("should work with AUTH_MODE=none without OAuth configuration", async () => { - process.env.AUTH_MODE = "none"; - - const { getConfig } = await import("./config.ts"); - const config = getConfig(); - - expect(config.AUTH_MODE).toBe("none"); - expect(config.OAUTH_ISSUER).toBeUndefined(); - expect(config.OAUTH_CLIENT_ID).toBeUndefined(); - expect(config.OAUTH_CLIENT_SECRET).toBeUndefined(); - }); - }); + // ...existing code... describe("enum validation", () => { it("should reject invalid NODE_ENV values", async () => { process.env.NODE_ENV = "invalid"; - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); }); @@ -213,22 +105,9 @@ describe("config", () => { it("should reject invalid LOG_LEVEL values", async () => { process.env.LOG_LEVEL = "invalid"; - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); - }); - - const { getConfig } = await import("./config.ts"); - expect(() => getConfig()).toThrow("process.exit called"); - - consoleSpy.mockRestore(); - exitSpy.mockRestore(); - }); - - it("should reject invalid AUTH_MODE values", async () => { - process.env.AUTH_MODE = "invalid"; - - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); }); @@ -283,4 +162,4 @@ describe("config", () => { expect(isDevelopment()).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/src/config.ts b/src/config.ts index ca8fc6b..9338c3c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,8 +40,9 @@ export function getConfig(): Config { parsed.BASE_URL = `http://localhost:${parsed.PORT}`; } - // Log authentication status for clarity - console.log(`🔐 Authentication: ${parsed.ENABLE_AUTH ? 'ENABLED' : 'DISABLED'}`); + console.log( + `🔐 Authentication: ${parsed.ENABLE_AUTH ? "ENABLED" : "DISABLED"}`, + ); // OAuth validation when authentication is enabled if (parsed.ENABLE_AUTH) { @@ -75,8 +76,8 @@ export function getConfig(): Config { // OAUTH_AUDIENCE is optional but recommended for production if (!parsed.OAUTH_AUDIENCE) { console.warn( - "⚠️ OAUTH_AUDIENCE not set. Token validation will not check audience.\n" + - " For production deployments, consider setting OAUTH_AUDIENCE to your API identifier", + `⚠️ OAUTH_AUDIENCE not set. Token validation will not check audience. + For production deployments, consider setting OAUTH_AUDIENCE to your API identifier`, ); } } From bebacb3497513afe6f47027b5fd5c8a3b51df0e1 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 5 Aug 2025 08:10:43 -0700 Subject: [PATCH 26/30] refactor: ran prettier --- src/auth/discovery.ts | 148 ++++++++++++++------------- src/auth/index.ts | 10 +- src/auth/middleware.ts | 29 ++++-- src/auth/oauth-model.ts | 141 +++++++++++++++---------- src/auth/oauth-provider.ts | 92 +++++++++-------- src/auth/oauth-server.ts | 32 +++--- src/auth/routes.ts | 199 +++++++++++++++++++----------------- src/auth/token-validator.ts | 48 ++++----- 8 files changed, 382 insertions(+), 317 deletions(-) diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index 436b500..ad74964 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -1,19 +1,19 @@ import type { Request, Response } from "express"; -import OAuth2Server from '@node-oauth/oauth2-server'; +import OAuth2Server from "@node-oauth/oauth2-server"; import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; /** * OAuth 2.0 Authorization Server Metadata endpoint * RFC 8414: https://tools.ietf.org/html/rfc8414 - * + * * For AUTH_MODE=full, this describes our OAuth client proxy endpoints */ export function createAuthorizationServerMetadataHandler() { return (req: Request, res: Response) => { try { const config = getConfig(); - const baseUrl = `${req.protocol}://${req.get('host')}`; + const baseUrl = `${req.protocol}://${req.get("host")}`; const metadata = { issuer: baseUrl, @@ -23,21 +23,21 @@ export function createAuthorizationServerMetadataHandler() { grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], scopes_supported: ["read", "write", "mcp"], - token_endpoint_auth_methods_supported: ["none"] + token_endpoint_auth_methods_supported: ["none"], }; - logger.info("OAuth authorization server metadata requested", { - issuer: metadata.issuer + logger.info("OAuth authorization server metadata requested", { + issuer: metadata.issuer, }); res.json(metadata); } catch (error) { - logger.error("Error serving authorization server metadata", { - error: error instanceof Error ? error.message : error + logger.error("Error serving authorization server metadata", { + error: error instanceof Error ? error.message : error, }); res.status(500).json({ error: "server_error", - error_description: "Failed to serve authorization server metadata" + error_description: "Failed to serve authorization server metadata", }); } }; @@ -46,35 +46,35 @@ export function createAuthorizationServerMetadataHandler() { /** * OAuth 2.0 Protected Resource Metadata endpoint * RFC 8705: https://tools.ietf.org/html/rfc8705 - * + * * For AUTH_MODE=full, this describes our resource server capabilities */ export function createProtectedResourceMetadataHandler() { return (req: Request, res: Response) => { try { const config = getConfig(); - const baseUrl = `${req.protocol}://${req.get('host')}`; + const baseUrl = `${req.protocol}://${req.get("host")}`; const metadata = { resource: baseUrl, authorization_servers: [baseUrl], scopes_supported: ["read", "write", "mcp"], bearer_methods_supported: ["header"], - resource_documentation: new URL("/docs", baseUrl).toString() + resource_documentation: new URL("/docs", baseUrl).toString(), }; - logger.info("OAuth protected resource metadata requested", { - resource: metadata.resource + logger.info("OAuth protected resource metadata requested", { + resource: metadata.resource, }); res.json(metadata); } catch (error) { - logger.error("Error serving protected resource metadata", { - error: error instanceof Error ? error.message : error + logger.error("Error serving protected resource metadata", { + error: error instanceof Error ? error.message : error, }); res.status(500).json({ error: "server_error", - error_description: "Failed to serve protected resource metadata" + error_description: "Failed to serve protected resource metadata", }); } }; @@ -86,9 +86,9 @@ export function createProtectedResourceMetadataHandler() { export function createAuthorizeHandler(oauthServer: OAuth2Server) { return async (req: Request, res: Response) => { try { - logger.debug("Authorization request received", { + logger.debug("Authorization request received", { query: req.query, - method: req.method + method: req.method, }); // Real OAuth implementation: Check for authenticated user @@ -97,73 +97,77 @@ export function createAuthorizeHandler(oauthServer: OAuth2Server) { // 2. If not authenticated, redirect to login page // 3. After login, show consent page // 4. Only then proceed with authorization - + // For now, this implementation requires external authentication // The user must be authenticated before reaching this endpoint - const userId = req.headers['x-user-id'] as string; - const username = req.headers['x-username'] as string; - + const userId = req.headers["x-user-id"] as string; + const username = req.headers["x-username"] as string; + if (!userId || !username) { logger.warn("Missing user authentication headers"); return res.status(401).json({ error: "access_denied", - error_description: "User must be authenticated before authorization" + error_description: "User must be authenticated before authorization", }); } - + const user = { id: userId, - username: username + username: username, }; - logger.debug("User authenticated, proceeding with authorization", { userId: user.id }); + logger.debug("User authenticated, proceeding with authorization", { + userId: user.id, + }); // Use the OAuth2Server authorize method const request = new (OAuth2Server as any).Request(req); const response = new (OAuth2Server as any).Response(res); - + const authorizationCode = await oauthServer.authorize(request, response, { authenticateHandler: { handle: async () => { logger.debug("Authenticate handler called"); return user; - } - } + }, + }, }); - logger.info("Authorization code granted", { + logger.info("Authorization code granted", { clientId: authorizationCode.client.id, - userId: user.id + userId: user.id, }); // Redirect back to client with authorization code const redirectUri = req.query.redirect_uri as string; const state = req.query.state as string; - + if (redirectUri) { const url = new URL(redirectUri); - url.searchParams.set('code', authorizationCode.authorizationCode); - if (state) url.searchParams.set('state', state); - + url.searchParams.set("code", authorizationCode.authorizationCode); + if (state) url.searchParams.set("state", state); + logger.info("Redirecting to client", { redirectUrl: url.toString() }); res.redirect(url.toString()); } else { // Fallback - return as JSON - res.json({ + res.json({ authorization_code: authorizationCode.authorizationCode, - state + state, }); } - } catch (error) { - logger.error("Authorization endpoint error", { + logger.error("Authorization endpoint error", { error: error instanceof Error ? error.message : error, - stack: error instanceof Error ? error.stack : undefined + stack: error instanceof Error ? error.stack : undefined, }); - + res.status(400).json({ error: "server_error", - error_description: error instanceof Error ? error.message : "Failed to process authorization request" + error_description: + error instanceof Error + ? error.message + : "Failed to process authorization request", }); } }; @@ -177,31 +181,33 @@ export function createTokenHandler(oauthServer: OAuth2Server) { try { const request = new (OAuth2Server as any).Request(req); const response = new (OAuth2Server as any).Response(res); - + const token = await oauthServer.token(request, response); - - logger.info("Access token granted", { + + logger.info("Access token granted", { clientId: token.client.id, userId: token.user?.id, - scope: token.scope + scope: token.scope, }); res.json({ access_token: token.accessToken, token_type: "Bearer", - expires_in: Math.floor((token.accessTokenExpiresAt!.getTime() - Date.now()) / 1000), - scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope, - refresh_token: token.refreshToken + expires_in: Math.floor( + (token.accessTokenExpiresAt!.getTime() - Date.now()) / 1000, + ), + scope: Array.isArray(token.scope) ? token.scope.join(" ") : token.scope, + refresh_token: token.refreshToken, }); - } catch (error) { - logger.error("Token endpoint error", { - error: error instanceof Error ? error.message : error + logger.error("Token endpoint error", { + error: error instanceof Error ? error.message : error, }); - + res.status(400).json({ error: "invalid_request", - error_description: error instanceof Error ? error.message : "Token request failed" + error_description: + error instanceof Error ? error.message : "Token request failed", }); } }; @@ -215,29 +221,28 @@ export function createIntrospectionHandler(oauthServer: OAuth2Server) { try { const request = new (OAuth2Server as any).Request(req); const response = new (OAuth2Server as any).Response(res); - + const token = await oauthServer.authenticate(request, response); - - logger.info("Token introspection successful", { + + logger.info("Token introspection successful", { clientId: token.client.id, userId: token.user?.id, - scope: token.scope + scope: token.scope, }); res.json({ active: true, - scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope, + scope: Array.isArray(token.scope) ? token.scope.join(" ") : token.scope, client_id: token.client.id, username: token.user?.username, sub: token.user?.id, - exp: Math.floor((token.accessTokenExpiresAt?.getTime() || 0) / 1000) + exp: Math.floor((token.accessTokenExpiresAt?.getTime() || 0) / 1000), }); - } catch (error) { - logger.debug("Token introspection failed", { - error: error instanceof Error ? error.message : error + logger.debug("Token introspection failed", { + error: error instanceof Error ? error.message : error, }); - + res.json({ active: false }); } }; @@ -251,20 +256,19 @@ export function createRevocationHandler(oauthServer: OAuth2Server) { try { const request = new (OAuth2Server as any).Request(req); const response = new (OAuth2Server as any).Response(res); - + await oauthServer.revoke(request, response); - + logger.info("Token revoked successfully"); res.status(200).send(); - } catch (error) { - logger.error("Token revocation error", { - error: error instanceof Error ? error.message : error + logger.error("Token revocation error", { + error: error instanceof Error ? error.message : error, }); res.status(400).json({ error: "invalid_request", - error_description: "Failed to revoke token" + error_description: "Failed to revoke token", }); } }; -} \ No newline at end of file +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 3886e8b..acb656a 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -17,15 +17,15 @@ export function initializeAuth() { logger.info("Initializing OAuth 2.1 authentication with token validation", { issuer: config.OAUTH_ISSUER, audience: config.OAUTH_AUDIENCE, - clientId: config.OAUTH_CLIENT_ID + clientId: config.OAUTH_CLIENT_ID, }); - + // Create token validator for OAuth 2.1 token validation const tokenValidator = new OAuthTokenValidator( config.OAUTH_ISSUER!, - config.OAUTH_AUDIENCE + config.OAUTH_AUDIENCE, ); - + return { tokenValidator }; } @@ -40,4 +40,4 @@ export function createAuthenticationMiddleware() { } return createAuthMiddleware(tokenValidator); -} \ No newline at end of file +} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index f184dfa..971c783 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,5 +1,8 @@ import type { Request, Response, NextFunction } from "express"; -import { OAuthTokenValidator, BuiltinTokenValidator } from "./token-validator.ts"; +import { + OAuthTokenValidator, + BuiltinTokenValidator, +} from "./token-validator.ts"; import type { OAuthProvider } from "./oauth-provider.ts"; import { logger } from "../logger.ts"; @@ -8,7 +11,10 @@ export interface AuthenticatedRequest extends Request { accessToken?: string; } -type TokenValidator = OAuthTokenValidator | BuiltinTokenValidator | OAuthProvider; +type TokenValidator = + | OAuthTokenValidator + | BuiltinTokenValidator + | OAuthProvider; /** * Create authentication middleware that supports both gateway and built-in modes @@ -36,7 +42,8 @@ export function createAuthMiddleware(tokenValidator: TokenValidator) { if (!validation.valid) { return res.status(401).json({ error: "invalid_token", - error_description: validation.error || "The access token is invalid or expired", + error_description: + validation.error || "The access token is invalid or expired", }); } @@ -46,8 +53,8 @@ export function createAuthMiddleware(tokenValidator: TokenValidator) { logger.info("Request authenticated", { userId: validation.userId }); next(); } catch (error) { - logger.error("Authentication middleware error", { - error: error instanceof Error ? error.message : error + logger.error("Authentication middleware error", { + error: error instanceof Error ? error.message : error, }); return res.status(500).json({ error: "server_error", @@ -60,7 +67,9 @@ export function createAuthMiddleware(tokenValidator: TokenValidator) { /** * Create authentication middleware specifically for OAuthProvider (full mode) */ -export function createOAuthProviderAuthMiddleware(oauthProvider: OAuthProvider) { +export function createOAuthProviderAuthMiddleware( + oauthProvider: OAuthProvider, +) { return async ( req: AuthenticatedRequest, res: Response, @@ -90,14 +99,14 @@ export function createOAuthProviderAuthMiddleware(oauthProvider: OAuthProvider) req.userId = validation.userId; req.accessToken = token; - logger.info("Request authenticated with OAuthProvider", { + logger.info("Request authenticated with OAuthProvider", { userId: validation.userId, - scope: validation.scope + scope: validation.scope, }); next(); } catch (error) { - logger.error("OAuthProvider authentication middleware error", { - error: error instanceof Error ? error.message : error + logger.error("OAuthProvider authentication middleware error", { + error: error instanceof Error ? error.message : error, }); return res.status(500).json({ error: "server_error", diff --git a/src/auth/oauth-model.ts b/src/auth/oauth-model.ts index 8d3816b..818f63c 100644 --- a/src/auth/oauth-model.ts +++ b/src/auth/oauth-model.ts @@ -1,7 +1,7 @@ -import OAuth2Server from '@node-oauth/oauth2-server'; -import { randomBytes, createHash } from 'node:crypto'; -import { logger } from '../logger.ts'; -import { getConfig } from '../config.ts'; +import OAuth2Server from "@node-oauth/oauth2-server"; +import { randomBytes, createHash } from "node:crypto"; +import { logger } from "../logger.ts"; +import { getConfig } from "../config.ts"; type AuthorizationCode = OAuth2Server.AuthorizationCode; type AuthorizationCodeModel = OAuth2Server.AuthorizationCodeModel; @@ -21,8 +21,11 @@ const config = getConfig(); const configuredClient: Client = { id: config.OAUTH_CLIENT_ID, clientSecret: config.OAUTH_CLIENT_SECRET, - redirectUris: ['http://localhost:3000/callback', 'vscode://ms-vscode.claude-dev'], - grants: ['authorization_code'] + redirectUris: [ + "http://localhost:3000/callback", + "vscode://ms-vscode.claude-dev", + ], + grants: ["authorization_code"], }; // Initialize client data @@ -32,52 +35,67 @@ export const oauthModel: AuthorizationCodeModel = { /** * Get client by client ID */ - async getClient(clientId: string, clientSecret?: string): Promise { - logger.debug('OAuth model: getClient', { - clientId, + async getClient( + clientId: string, + clientSecret?: string, + ): Promise { + logger.debug("OAuth model: getClient", { + clientId, hasSecret: !!clientSecret, - providedSecret: clientSecret ? clientSecret.substring(0, 3) + '...' : 'none', + providedSecret: clientSecret + ? clientSecret.substring(0, 3) + "..." + : "none", availableClients: Array.from(clients.keys()), clientsMapSize: clients.size, - clientsMapEntries: Array.from(clients.entries()).map(([k, v]) => ({ id: k, secret: v.clientSecret?.substring(0, 3) + '...' })) + clientsMapEntries: Array.from(clients.entries()).map(([k, v]) => ({ + id: k, + secret: v.clientSecret?.substring(0, 3) + "...", + })), }); - + const client = clients.get(clientId); if (!client) { - logger.warn('Client not found', { clientId, availableClients: Array.from(clients.keys()) }); + logger.warn("Client not found", { + clientId, + availableClients: Array.from(clients.keys()), + }); return false; } // If client secret is provided, validate it if (clientSecret && client.clientSecret !== clientSecret) { - logger.warn('Client secret mismatch', { + logger.warn("Client secret mismatch", { clientId, - expectedSecret: client.clientSecret?.substring(0, 3) + '...', - providedSecret: clientSecret.substring(0, 3) + '...' + expectedSecret: client.clientSecret?.substring(0, 3) + "...", + providedSecret: clientSecret.substring(0, 3) + "...", }); return false; } - logger.debug('Client found and validated', { clientId }); + logger.debug("Client found and validated", { clientId }); return client; }, /** * Save authorization code */ - async saveAuthorizationCode(code: AuthorizationCode, client: Client, user: User): Promise { - logger.debug('OAuth model: saveAuthorizationCode', { - code: code.authorizationCode.substring(0, 8) + '...', + async saveAuthorizationCode( + code: AuthorizationCode, + client: Client, + user: User, + ): Promise { + logger.debug("OAuth model: saveAuthorizationCode", { + code: code.authorizationCode.substring(0, 8) + "...", clientId: client.id, - userId: user.id + userId: user.id, }); const authCode = { ...code, client, - user + user, }; - + authorizationCodes.set(code.authorizationCode, authCode); return authCode; }, @@ -85,9 +103,11 @@ export const oauthModel: AuthorizationCodeModel = { /** * Get authorization code */ - async getAuthorizationCode(authorizationCode: string): Promise { - logger.debug('OAuth model: getAuthorizationCode', { - code: authorizationCode.substring(0, 8) + '...' + async getAuthorizationCode( + authorizationCode: string, + ): Promise { + logger.debug("OAuth model: getAuthorizationCode", { + code: authorizationCode.substring(0, 8) + "...", }); const code = authorizationCodes.get(authorizationCode); @@ -108,8 +128,8 @@ export const oauthModel: AuthorizationCodeModel = { * Revoke authorization code (called after token exchange) */ async revokeAuthorizationCode(code: AuthorizationCode): Promise { - logger.debug('OAuth model: revokeAuthorizationCode', { - code: code.authorizationCode.substring(0, 8) + '...' + logger.debug("OAuth model: revokeAuthorizationCode", { + code: code.authorizationCode.substring(0, 8) + "...", }); return authorizationCodes.delete(code.authorizationCode); @@ -119,16 +139,16 @@ export const oauthModel: AuthorizationCodeModel = { * Save access token */ async saveToken(token: Token, client: Client, user: User): Promise { - logger.debug('OAuth model: saveToken', { - accessToken: token.accessToken.substring(0, 8) + '...', + logger.debug("OAuth model: saveToken", { + accessToken: token.accessToken.substring(0, 8) + "...", clientId: client.id, - userId: user.id + userId: user.id, }); const fullToken = { ...token, client, - user + user, }; tokens.set(token.accessToken, fullToken); @@ -143,8 +163,8 @@ export const oauthModel: AuthorizationCodeModel = { * Get access token */ async getAccessToken(accessToken: string): Promise { - logger.debug('OAuth model: getAccessToken', { - token: accessToken.substring(0, 8) + '...' + logger.debug("OAuth model: getAccessToken", { + token: accessToken.substring(0, 8) + "...", }); const token = tokens.get(accessToken); @@ -165,8 +185,8 @@ export const oauthModel: AuthorizationCodeModel = { * Get refresh token */ async getRefreshToken(refreshToken: string): Promise { - logger.debug('OAuth model: getRefreshToken', { - token: refreshToken.substring(0, 8) + '...' + logger.debug("OAuth model: getRefreshToken", { + token: refreshToken.substring(0, 8) + "...", }); const token = tokens.get(refreshToken); @@ -175,7 +195,10 @@ export const oauthModel: AuthorizationCodeModel = { } // Check if refresh token has expired - if (token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < new Date()) { + if ( + token.refreshTokenExpiresAt && + token.refreshTokenExpiresAt < new Date() + ) { tokens.delete(refreshToken); return false; } @@ -187,16 +210,16 @@ export const oauthModel: AuthorizationCodeModel = { * Revoke token */ async revokeToken(token: Token): Promise { - logger.debug('OAuth model: revokeToken', { - accessToken: token.accessToken.substring(0, 8) + '...' + logger.debug("OAuth model: revokeToken", { + accessToken: token.accessToken.substring(0, 8) + "...", }); let revoked = false; - + if (tokens.delete(token.accessToken)) { revoked = true; } - + if (token.refreshToken && tokens.delete(token.refreshToken)) { revoked = true; } @@ -207,37 +230,43 @@ export const oauthModel: AuthorizationCodeModel = { /** * Validate scope */ - async validateScope(user: User, client: Client, scope: string[]): Promise { - logger.debug('OAuth model: validateScope', { + async validateScope( + user: User, + client: Client, + scope: string[], + ): Promise { + logger.debug("OAuth model: validateScope", { userId: user.id, clientId: client.id, - scope + scope, }); // Simplified scope validation - implement proper scope checking // In production, implement proper scope validation - const allowedScopes = ['read', 'write', 'mcp']; - const validScopes = scope.filter(s => allowedScopes.includes(s)); - - return validScopes.length > 0 ? validScopes : ['read']; + const allowedScopes = ["read", "write", "mcp"]; + const validScopes = scope.filter((s) => allowedScopes.includes(s)); + + return validScopes.length > 0 ? validScopes : ["read"]; }, /** * Verify scope */ async verifyScope(token: Token, scope: string[]): Promise { - logger.debug('OAuth model: verifyScope', { + logger.debug("OAuth model: verifyScope", { tokenScope: token.scope, - requestedScope: scope + requestedScope: scope, }); if (!token.scope || !scope) { return false; } - const tokenScopes = Array.isArray(token.scope) ? token.scope : [token.scope]; - return scope.every(s => tokenScopes.includes(s)); - } + const tokenScopes = Array.isArray(token.scope) + ? token.scope + : [token.scope]; + return scope.every((s) => tokenScopes.includes(s)); + }, }; // Removed demo user authentication - use external authentication system @@ -246,12 +275,12 @@ export const oauthModel: AuthorizationCodeModel = { * Generate secure tokens */ export function generateToken(): string { - return randomBytes(32).toString('hex'); + return randomBytes(32).toString("hex"); } /** * Generate authorization code */ export function generateAuthorizationCode(): string { - return randomBytes(16).toString('hex'); -} \ No newline at end of file + return randomBytes(16).toString("hex"); +} diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index 23fa75b..1855db6 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -41,29 +41,32 @@ interface AuthorizationCodeData { export class OAuthProvider { #config: OAuthConfig; #tokenValidator: OAuthTokenValidator; - + // In-memory stores (use database in production) #authorizationCodes = new Map(); - #accessTokens = new Map(); + externalTokens?: { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresAt: Date; + scope?: string; + }; + } + >(); constructor(config: OAuthConfig) { this.#config = config; - + // For built-in mode, we ARE the issuer const issuer = "http://localhost:3000"; // This should be dynamic based on server config this.#tokenValidator = new OAuthTokenValidator(issuer); - + // Clean up expired codes and tokens periodically setInterval(() => this.cleanup(), 60 * 1000); // Every minute } @@ -80,7 +83,7 @@ export class OAuthProvider { logger.info("Authorization code stored", { code: code.substring(0, 8) + "...", clientId: data.clientId, - hasExternalTokens: !!data.externalTokens + hasExternalTokens: !!data.externalTokens, }); } @@ -88,8 +91,8 @@ export class OAuthProvider { * Store authorization code with external token data from IdP */ storeAuthorizationCodeWithTokens( - code: string, - data: Omit, + code: string, + data: Omit, externalTokens: { accessToken: string; refreshToken?: string; @@ -97,7 +100,7 @@ export class OAuthProvider { expiresIn: number; scope?: string; }, - userId?: string + userId?: string, ): void { const authCodeData: AuthorizationCodeData = { ...data, @@ -107,10 +110,10 @@ export class OAuthProvider { refreshToken: externalTokens.refreshToken, idToken: externalTokens.idToken, expiresAt: new Date(Date.now() + externalTokens.expiresIn * 1000), - scope: externalTokens.scope - } + scope: externalTokens.scope, + }, }; - + this.storeAuthorizationCode(code, authCodeData); } @@ -118,12 +121,11 @@ export class OAuthProvider { * Exchange authorization code for access token with PKCE verification */ async exchangeAuthorizationCode( - code: string, - codeVerifier: string, - clientId: string, - redirectUri: string + code: string, + codeVerifier: string, + clientId: string, + redirectUri: string, ): Promise<{ accessToken: string; expiresIn: number; scope: string } | null> { - const codeData = this.#authorizationCodes.get(code); if (!codeData) { logger.warn("Invalid authorization code", { codeLength: code.length }); @@ -138,12 +140,15 @@ export class OAuthProvider { } // Validate client_id and redirect_uri - if (codeData.clientId !== clientId || codeData.redirectUri !== redirectUri) { - logger.warn("Authorization code validation failed", { + if ( + codeData.clientId !== clientId || + codeData.redirectUri !== redirectUri + ) { + logger.warn("Authorization code validation failed", { expectedClientId: codeData.clientId, providedClientId: clientId, expectedRedirectUri: codeData.redirectUri, - providedRedirectUri: redirectUri + providedRedirectUri: redirectUri, }); return null; } @@ -165,22 +170,22 @@ export class OAuthProvider { userId, scope: codeData.scope, expiresAt, - externalTokens: codeData.externalTokens + externalTokens: codeData.externalTokens, }); // Clean up authorization code (single use) this.#authorizationCodes.delete(code); - logger.info("Access token issued", { - clientId, + logger.info("Access token issued", { + clientId, scope: codeData.scope, - expiresIn + expiresIn, }); return { accessToken, expiresIn, - scope: codeData.scope + scope: codeData.scope, }; } @@ -188,17 +193,19 @@ export class OAuthProvider { * Verify PKCE code verifier against challenge */ private verifyPKCE(codeVerifier: string, codeChallenge: string): boolean { - const hash = createHash('sha256').update(codeVerifier).digest(); - const computedChallenge = hash.toString('base64url'); + const hash = createHash("sha256").update(codeVerifier).digest(); + const computedChallenge = hash.toString("base64url"); return computedChallenge === codeChallenge; } /** * Validate access token */ - async validateToken(token: string): Promise<{ valid: boolean; userId?: string; scope?: string }> { + async validateToken( + token: string, + ): Promise<{ valid: boolean; userId?: string; scope?: string }> { const tokenData = this.#accessTokens.get(token); - + if (!tokenData) { return { valid: false }; } @@ -211,7 +218,7 @@ export class OAuthProvider { return { valid: true, userId: tokenData.userId, - scope: tokenData.scope + scope: tokenData.scope, }; } @@ -219,7 +226,7 @@ export class OAuthProvider { * Generate a unique user ID */ private generateUserId(): string { - return `user-${randomBytes(16).toString('hex')}`; + return `user-${randomBytes(16).toString("hex")}`; } /** @@ -227,14 +234,14 @@ export class OAuthProvider { */ private cleanup(): void { const now = new Date(); - + // Clean up expired authorization codes for (const [code, data] of this.#authorizationCodes.entries()) { if (data.expiresAt < now) { this.#authorizationCodes.delete(code); } } - + // Clean up expired access tokens for (const [token, data] of this.#accessTokens.entries()) { if (data.expiresAt < now) { @@ -242,5 +249,4 @@ export class OAuthProvider { } } } - } diff --git a/src/auth/oauth-server.ts b/src/auth/oauth-server.ts index 8c51ff4..ca0ef70 100644 --- a/src/auth/oauth-server.ts +++ b/src/auth/oauth-server.ts @@ -65,13 +65,13 @@ export class ManagedOAuthServer { codeChallenge: (code as any).codeChallenge, codeChallengeMethod: (code as any).codeChallengeMethod, }; - + this.#authorizationCodes.set(code.authorizationCode, authCode); - logger.info("Authorization code saved", { + logger.info("Authorization code saved", { clientId: client.id, - userId: user.id + userId: user.id, }); - + return authCode; }, @@ -95,14 +95,16 @@ export class ManagedOAuthServer { // PKCE verification verifyCodeChallenge: async (authorizationCode, codeVerifier) => { - const code = this.#authorizationCodes.get(authorizationCode.authorizationCode); + const code = this.#authorizationCodes.get( + authorizationCode.authorizationCode, + ); if (!code || !code.codeChallenge) return false; try { return verifyChallenge(codeVerifier, code.codeChallenge); } catch (error) { - logger.warn("PKCE verification failed", { - error: error instanceof Error ? error.message : error + logger.warn("PKCE verification failed", { + error: error instanceof Error ? error.message : error, }); return false; } @@ -119,9 +121,9 @@ export class ManagedOAuthServer { }; this.#accessTokens.set(token.accessToken, accessToken); - logger.info("Access token saved", { + logger.info("Access token saved", { clientId: client.id, - userId: user.id + userId: user.id, }); return accessToken; @@ -143,7 +145,7 @@ export class ManagedOAuthServer { // User verification - should be replaced with real authentication getUser: async () => { // Generate a unique user ID for each session - const userId = `user-${randomBytes(8).toString('hex')}`; + const userId = `user-${randomBytes(8).toString("hex")}`; return { id: userId }; }, @@ -157,7 +159,7 @@ export class ManagedOAuthServer { return scope === "read" || scope === "write"; }, }, - + // OAuth 2.1 configuration requireClientAuthentication: { authorization_code: true }, allowBearerTokensInQueryString: false, @@ -182,7 +184,7 @@ export class ManagedOAuthServer { grants: ["authorization_code"], redirectUris, }); - + logger.info("OAuth client registered", { clientId, redirectUris }); } @@ -193,10 +195,10 @@ export class ManagedOAuthServer { try { return verifyChallenge(codeVerifier, codeChallenge); } catch (error) { - logger.warn("PKCE validation failed", { - error: error instanceof Error ? error.message : error + logger.warn("PKCE validation failed", { + error: error instanceof Error ? error.message : error, }); return false; } } -} \ No newline at end of file +} diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 2b195a1..6bb0f01 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -36,34 +36,35 @@ const pendingRequests = new Map(); export function createAuthorizeHandler() { return async (req: Request, res: Response) => { try { - logger.debug("Authorization handler called", { + logger.debug("Authorization handler called", { query: req.query, - url: req.url + url: req.url, }); - + const config = getConfig(); - const { - response_type, - client_id, - redirect_uri, - scope, - state, - code_challenge, - code_challenge_method + const { + response_type, + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method, } = req.query; // Validate required OAuth 2.1 parameters if (response_type !== "code") { return res.status(400).json({ error: "unsupported_response_type", - error_description: "Only 'code' response type is supported" + error_description: "Only 'code' response type is supported", }); } if (!client_id || !redirect_uri) { return res.status(400).json({ error: "invalid_request", - error_description: "Missing required parameters: client_id, redirect_uri" + error_description: + "Missing required parameters: client_id, redirect_uri", }); } @@ -71,31 +72,31 @@ export function createAuthorizeHandler() { if (!code_challenge || code_challenge_method !== "S256") { return res.status(400).json({ error: "invalid_request", - error_description: "PKCE with S256 is required" + error_description: "PKCE with S256 is required", }); } // Generate a unique request ID to track this authorization request const requestId = randomBytes(16).toString("hex"); - const finalState = state as string || randomBytes(16).toString("hex"); - + const finalState = (state as string) || randomBytes(16).toString("hex"); + // Generate our own PKCE parameters for external IdP const externalCodeVerifier = randomBytes(32).toString("base64url"); const externalCodeChallenge = createHash("sha256") .update(externalCodeVerifier) .digest("base64url"); - + // Store the original request parameters plus our PKCE data pendingRequests.set(requestId, { clientId: client_id as string, redirectUri: redirect_uri as string, - scope: scope as string || "openid profile email", + scope: (scope as string) || "openid profile email", state: finalState, codeChallenge: code_challenge as string, codeChallengeMethod: code_challenge_method as string, expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes externalCodeVerifier, - externalCodeChallenge + externalCodeChallenge, }); // Build authorization URL for external provider with our own PKCE @@ -103,30 +104,35 @@ export function createAuthorizeHandler() { authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", config.OAUTH_CLIENT_ID!); authUrl.searchParams.set("redirect_uri", config.OAUTH_REDIRECT_URI!); - authUrl.searchParams.set("scope", scope as string || "openid profile email"); + authUrl.searchParams.set( + "scope", + (scope as string) || "openid profile email", + ); authUrl.searchParams.set("state", requestId); authUrl.searchParams.set("code_challenge", externalCodeChallenge); authUrl.searchParams.set("code_challenge_method", "S256"); - - logger.info("Proxying OAuth authorization request", { + + logger.info("Proxying OAuth authorization request", { client_id, redirect_uri, scope, requestId, - external_auth_url: new URL("/oauth/authorize", config.OAUTH_ISSUER!).toString() + external_auth_url: new URL( + "/oauth/authorize", + config.OAUTH_ISSUER!, + ).toString(), }); // Redirect to external OAuth provider res.redirect(authUrl.toString()); - } catch (error) { - logger.error("OAuth authorization proxy error", { - error: error instanceof Error ? error.message : error + logger.error("OAuth authorization proxy error", { + error: error instanceof Error ? error.message : error, }); - + res.status(500).json({ error: "server_error", - error_description: "Failed to process authorization request" + error_description: "Failed to process authorization request", }); } }; @@ -139,47 +145,54 @@ export function createAuthorizeHandler() { export function createCallbackHandler(oauthProvider: OAuthProvider) { return async (req: Request, res: Response) => { try { - logger.debug("OAuth callback handler called", { + logger.debug("OAuth callback handler called", { query: req.query, - url: req.url + url: req.url, }); - + const { code, state, error, error_description } = req.query; - + if (error) { - logger.warn("OAuth callback error from external provider", { error, error_description }); + logger.warn("OAuth callback error from external provider", { + error, + error_description, + }); return res.status(400).json({ error: error as string, - error_description: error_description as string || "OAuth authorization failed" + error_description: + (error_description as string) || "OAuth authorization failed", }); } if (!code || !state) { - logger.warn("OAuth callback missing required parameters", { code: !!code, state: !!state }); + logger.warn("OAuth callback missing required parameters", { + code: !!code, + state: !!state, + }); return res.status(400).json({ error: "invalid_request", - error_description: "Missing authorization code or state" + error_description: "Missing authorization code or state", }); } // Retrieve the original request using state as requestId const requestId = state as string; const originalRequest = pendingRequests.get(requestId); - + logger.debug("OAuth callback debug info", { receivedState: requestId, storedRequestIds: Array.from(pendingRequests.keys()), - requestFound: !!originalRequest + requestFound: !!originalRequest, }); - + if (!originalRequest) { - logger.warn("OAuth callback with unknown or expired state", { + logger.warn("OAuth callback with unknown or expired state", { requestId, - availableRequestIds: Array.from(pendingRequests.keys()) + availableRequestIds: Array.from(pendingRequests.keys()), }); return res.status(400).json({ error: "invalid_request", - error_description: "Unknown or expired authorization request" + error_description: "Unknown or expired authorization request", }); } @@ -189,35 +202,35 @@ export function createCallbackHandler(oauthProvider: OAuthProvider) { logger.warn("OAuth callback with expired request", { requestId }); return res.status(400).json({ error: "invalid_request", - error_description: "Authorization request has expired" + error_description: "Authorization request has expired", }); } - - logger.info("OAuth callback received from external provider", { - code: typeof code === 'string' ? code.substring(0, 8) + "..." : code, + + logger.info("OAuth callback received from external provider", { + code: typeof code === "string" ? code.substring(0, 8) + "..." : code, requestId, - clientId: originalRequest.clientId + clientId: originalRequest.clientId, }); - + // Exchange authorization code for tokens with external provider const config = getConfig(); const tokenResponse = await exchangeCodeForTokens( - code as string, - config, - originalRequest.externalCodeVerifier + code as string, + config, + originalRequest.externalCodeVerifier, ); - + if (!tokenResponse) { pendingRequests.delete(requestId); return res.status(500).json({ error: "server_error", - error_description: "Failed to exchange authorization code for tokens" + error_description: "Failed to exchange authorization code for tokens", }); } // Generate our own authorization code for the MCP client const mcpAuthCode = randomBytes(32).toString("hex"); - + // Store the authorization code with external token data oauthProvider.storeAuthorizationCodeWithTokens( mcpAuthCode, @@ -227,50 +240,49 @@ export function createCallbackHandler(oauthProvider: OAuthProvider) { scope: originalRequest.scope, codeChallenge: originalRequest.codeChallenge, codeChallengeMethod: originalRequest.codeChallengeMethod, - expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes }, { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, idToken: tokenResponse.id_token, expiresIn: tokenResponse.expires_in, - scope: tokenResponse.scope + scope: tokenResponse.scope, }, - `external-user-${randomBytes(8).toString('hex')}` // Generate unique user ID + `external-user-${randomBytes(8).toString("hex")}`, // Generate unique user ID ); - + logger.info("Token exchange completed, MCP auth code generated", { requestId, clientId: originalRequest.clientId, externalTokenExpiry: tokenResponse.expires_in, - mcpAuthCode: mcpAuthCode.substring(0, 8) + "..." + mcpAuthCode: mcpAuthCode.substring(0, 8) + "...", }); // Clean up pending request pendingRequests.delete(requestId); - + // Redirect back to the original MCP client with our authorization code const redirectParams = new URLSearchParams({ code: mcpAuthCode, - state: originalRequest.state + state: originalRequest.state, }); - + const redirectUrl = `${originalRequest.redirectUri}?${redirectParams}`; - + logger.info("Redirecting to MCP client with authorization code", { clientId: originalRequest.clientId, - redirectUri: originalRequest.redirectUri + redirectUri: originalRequest.redirectUri, }); - + res.redirect(redirectUrl); - } catch (error) { - logger.error("OAuth callback error", { - error: error instanceof Error ? error.message : error + logger.error("OAuth callback error", { + error: error instanceof Error ? error.message : error, }); res.status(500).json({ error: "server_error", - error_description: "Failed to complete OAuth authorization" + error_description: "Failed to complete OAuth authorization", }); } }; @@ -279,31 +291,35 @@ export function createCallbackHandler(oauthProvider: OAuthProvider) { /** * Exchange authorization code for tokens with external OAuth provider */ -async function exchangeCodeForTokens(code: string, config: any, codeVerifier: string): Promise { +async function exchangeCodeForTokens( + code: string, + config: any, + codeVerifier: string, +): Promise { try { const tokenEndpoint = new URL("/oauth/token", config.OAUTH_ISSUER!); - + const tokenParams = new URLSearchParams({ grant_type: "authorization_code", client_id: config.OAUTH_CLIENT_ID!, client_secret: config.OAUTH_CLIENT_SECRET!, code, redirect_uri: config.OAUTH_REDIRECT_URI!, - code_verifier: codeVerifier + code_verifier: codeVerifier, }); logger.info("Exchanging authorization code with external provider", { tokenEndpoint: tokenEndpoint.toString(), - clientId: config.OAUTH_CLIENT_ID + clientId: config.OAUTH_CLIENT_ID, }); const response = await fetch(tokenEndpoint.toString(), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json" + Accept: "application/json", }, - body: tokenParams + body: tokenParams, }); if (!response.ok) { @@ -313,26 +329,25 @@ async function exchangeCodeForTokens(code: string, config: any, codeVerifier: st statusText: response.statusText, error: errorText, tokenEndpoint: tokenEndpoint.toString(), - clientId: config.OAUTH_CLIENT_ID + clientId: config.OAUTH_CLIENT_ID, }); return null; } - const tokenData = await response.json() as TokenExchangeResponse; - + const tokenData = (await response.json()) as TokenExchangeResponse; + logger.info("Token exchange successful", { tokenType: tokenData.token_type, expiresIn: tokenData.expires_in, scope: tokenData.scope, hasIdToken: !!tokenData.id_token, - hasRefreshToken: !!tokenData.refresh_token + hasRefreshToken: !!tokenData.refresh_token, }); return tokenData; - } catch (error) { logger.error("Token exchange error", { - error: error instanceof Error ? error.message : error + error: error instanceof Error ? error.message : error, }); return null; } @@ -344,19 +359,20 @@ async function exchangeCodeForTokens(code: string, config: any, codeVerifier: st export function createTokenHandler(oauthProvider: OAuthProvider) { return async (req: Request, res: Response) => { try { - const { grant_type, code, code_verifier, client_id, redirect_uri } = req.body; + const { grant_type, code, code_verifier, client_id, redirect_uri } = + req.body; if (grant_type !== "authorization_code") { return res.status(400).json({ error: "unsupported_grant_type", - error_description: "Only authorization_code grant type is supported" + error_description: "Only authorization_code grant type is supported", }); } if (!code || !code_verifier || !client_id || !redirect_uri) { return res.status(400).json({ error: "invalid_request", - error_description: "Missing required parameters" + error_description: "Missing required parameters", }); } @@ -365,36 +381,35 @@ export function createTokenHandler(oauthProvider: OAuthProvider) { code, code_verifier, client_id, - redirect_uri + redirect_uri, ); if (!tokenResult) { return res.status(400).json({ error: "invalid_grant", - error_description: "Invalid authorization code or code verifier" + error_description: "Invalid authorization code or code verifier", }); } logger.info("MCP access token issued", { clientId: client_id, - scope: tokenResult.scope + scope: tokenResult.scope, }); res.json({ access_token: tokenResult.accessToken, token_type: "Bearer", expires_in: tokenResult.expiresIn, - scope: tokenResult.scope + scope: tokenResult.scope, }); - } catch (error) { logger.error("Token endpoint error", { - error: error instanceof Error ? error.message : error + error: error instanceof Error ? error.message : error, }); res.status(500).json({ error: "server_error", - error_description: "Failed to issue access token" + error_description: "Failed to issue access token", }); } }; -} \ No newline at end of file +} diff --git a/src/auth/token-validator.ts b/src/auth/token-validator.ts index f6a4753..ed43438 100644 --- a/src/auth/token-validator.ts +++ b/src/auth/token-validator.ts @@ -21,17 +21,16 @@ export class OAuthTokenValidator { async validateToken(token: string): Promise { try { - const isJWT = token.split('.').length === 3; - + const isJWT = token.split(".").length === 3; + if (isJWT) { return await this.validateJWT(token); } else { return await this.introspectToken(token); } - } catch (error) { - logger.error("Gateway token validation error", { - error: error instanceof Error ? error.message : error + logger.error("Gateway token validation error", { + error: error instanceof Error ? error.message : error, }); return { valid: false, error: "Token validation failed" }; } @@ -40,25 +39,27 @@ export class OAuthTokenValidator { private async validateJWT(token: string): Promise { try { // Get JWKS from the issuer - const JWKS = jose.createRemoteJWKSet(new URL("/.well-known/jwks.json", this.#issuer)); - + const JWKS = jose.createRemoteJWKSet( + new URL("/.well-known/jwks.json", this.#issuer), + ); + // Verify and decode the JWT const verifyOptions: any = { issuer: this.#issuer, }; - + // Only validate audience if provided if (this.#audience) { verifyOptions.audience = this.#audience; } - + const { payload } = await jose.jwtVerify(token, JWKS, verifyOptions); return { valid: true, - userId: payload.sub || (payload as any).user_id || (payload as any).username, + userId: + payload.sub || (payload as any).user_id || (payload as any).username, }; - } catch (error) { if (error instanceof jose.errors.JWTExpired) { return { valid: false, error: "Token expired" }; @@ -69,9 +70,9 @@ export class OAuthTokenValidator { if (error instanceof jose.errors.JWKSNoMatchingKey) { return { valid: false, error: "No matching key found" }; } - - logger.warn("JWT validation failed, falling back to introspection", { - error: error instanceof Error ? error.message : error + + logger.warn("JWT validation failed, falling back to introspection", { + error: error instanceof Error ? error.message : error, }); return await this.introspectToken(token); } @@ -79,7 +80,7 @@ export class OAuthTokenValidator { private async introspectToken(token: string): Promise { const introspectionUrl = new URL("/oauth/introspect", this.#issuer); - + const response = await fetch(introspectionUrl.toString(), { method: "POST", headers: { @@ -92,15 +93,15 @@ export class OAuthTokenValidator { }); if (!response.ok) { - logger.warn("Token introspection failed", { + logger.warn("Token introspection failed", { status: response.status, - statusText: response.statusText + statusText: response.statusText, }); return { valid: false, error: "Token introspection failed" }; } const result = await response.json(); - + if (!result.active) { return { valid: false, error: "Token is not active" }; } @@ -123,7 +124,7 @@ export class BuiltinTokenValidator { #tokens = new Map(); storeToken(token: string, userId: string, expiresAt: Date): void { this.#tokens.set(token, { userId, expiresAt }); - + setTimeout(() => { this.#tokens.delete(token); }, expiresAt.getTime() - Date.now()); @@ -132,7 +133,7 @@ export class BuiltinTokenValidator { async validateToken(token: string): Promise { try { const tokenData = this.#tokens.get(token); - + if (!tokenData) { return { valid: false, error: "Token not found" }; } @@ -146,12 +147,11 @@ export class BuiltinTokenValidator { valid: true, userId: tokenData.userId, }; - } catch (error) { - logger.error("Built-in token validation error", { - error: error instanceof Error ? error.message : error + logger.error("Built-in token validation error", { + error: error instanceof Error ? error.message : error, }); return { valid: false, error: "Token validation failed" }; } } -} \ No newline at end of file +} From 0dddba689ffba0969b84df634a23d74077fb4514 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 5 Aug 2025 08:57:51 -0700 Subject: [PATCH 27/30] refactor: remove unused code --- src/auth/discovery.ts | 197 +----------------------------------------- 1 file changed, 1 insertion(+), 196 deletions(-) diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index ad74964..d62ad92 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -1,6 +1,5 @@ import type { Request, Response } from "express"; import OAuth2Server from "@node-oauth/oauth2-server"; -import { getConfig } from "../config.ts"; import { logger } from "../logger.ts"; /** @@ -12,7 +11,7 @@ import { logger } from "../logger.ts"; export function createAuthorizationServerMetadataHandler() { return (req: Request, res: Response) => { try { - const config = getConfig(); + // ...existing code... const baseUrl = `${req.protocol}://${req.get("host")}`; const metadata = { @@ -52,7 +51,6 @@ export function createAuthorizationServerMetadataHandler() { export function createProtectedResourceMetadataHandler() { return (req: Request, res: Response) => { try { - const config = getConfig(); const baseUrl = `${req.protocol}://${req.get("host")}`; const metadata = { @@ -79,196 +77,3 @@ export function createProtectedResourceMetadataHandler() { } }; } - -/** - * OAuth 2.0 Authorization endpoint - */ -export function createAuthorizeHandler(oauthServer: OAuth2Server) { - return async (req: Request, res: Response) => { - try { - logger.debug("Authorization request received", { - query: req.query, - method: req.method, - }); - - // Real OAuth implementation: Check for authenticated user - // In a real implementation, this would: - // 1. Check if user has valid session/cookie - // 2. If not authenticated, redirect to login page - // 3. After login, show consent page - // 4. Only then proceed with authorization - - // For now, this implementation requires external authentication - // The user must be authenticated before reaching this endpoint - const userId = req.headers["x-user-id"] as string; - const username = req.headers["x-username"] as string; - - if (!userId || !username) { - logger.warn("Missing user authentication headers"); - return res.status(401).json({ - error: "access_denied", - error_description: "User must be authenticated before authorization", - }); - } - - const user = { - id: userId, - username: username, - }; - - logger.debug("User authenticated, proceeding with authorization", { - userId: user.id, - }); - - // Use the OAuth2Server authorize method - const request = new (OAuth2Server as any).Request(req); - const response = new (OAuth2Server as any).Response(res); - - const authorizationCode = await oauthServer.authorize(request, response, { - authenticateHandler: { - handle: async () => { - logger.debug("Authenticate handler called"); - return user; - }, - }, - }); - - logger.info("Authorization code granted", { - clientId: authorizationCode.client.id, - userId: user.id, - }); - - // Redirect back to client with authorization code - const redirectUri = req.query.redirect_uri as string; - const state = req.query.state as string; - - if (redirectUri) { - const url = new URL(redirectUri); - url.searchParams.set("code", authorizationCode.authorizationCode); - if (state) url.searchParams.set("state", state); - - logger.info("Redirecting to client", { redirectUrl: url.toString() }); - res.redirect(url.toString()); - } else { - // Fallback - return as JSON - res.json({ - authorization_code: authorizationCode.authorizationCode, - state, - }); - } - } catch (error) { - logger.error("Authorization endpoint error", { - error: error instanceof Error ? error.message : error, - stack: error instanceof Error ? error.stack : undefined, - }); - - res.status(400).json({ - error: "server_error", - error_description: - error instanceof Error - ? error.message - : "Failed to process authorization request", - }); - } - }; -} - -/** - * OAuth 2.0 Token endpoint - */ -export function createTokenHandler(oauthServer: OAuth2Server) { - return async (req: Request, res: Response) => { - try { - const request = new (OAuth2Server as any).Request(req); - const response = new (OAuth2Server as any).Response(res); - - const token = await oauthServer.token(request, response); - - logger.info("Access token granted", { - clientId: token.client.id, - userId: token.user?.id, - scope: token.scope, - }); - - res.json({ - access_token: token.accessToken, - token_type: "Bearer", - expires_in: Math.floor( - (token.accessTokenExpiresAt!.getTime() - Date.now()) / 1000, - ), - scope: Array.isArray(token.scope) ? token.scope.join(" ") : token.scope, - refresh_token: token.refreshToken, - }); - } catch (error) { - logger.error("Token endpoint error", { - error: error instanceof Error ? error.message : error, - }); - - res.status(400).json({ - error: "invalid_request", - error_description: - error instanceof Error ? error.message : "Token request failed", - }); - } - }; -} - -/** - * Token introspection endpoint - */ -export function createIntrospectionHandler(oauthServer: OAuth2Server) { - return async (req: Request, res: Response) => { - try { - const request = new (OAuth2Server as any).Request(req); - const response = new (OAuth2Server as any).Response(res); - - const token = await oauthServer.authenticate(request, response); - - logger.info("Token introspection successful", { - clientId: token.client.id, - userId: token.user?.id, - scope: token.scope, - }); - - res.json({ - active: true, - scope: Array.isArray(token.scope) ? token.scope.join(" ") : token.scope, - client_id: token.client.id, - username: token.user?.username, - sub: token.user?.id, - exp: Math.floor((token.accessTokenExpiresAt?.getTime() || 0) / 1000), - }); - } catch (error) { - logger.debug("Token introspection failed", { - error: error instanceof Error ? error.message : error, - }); - - res.json({ active: false }); - } - }; -} - -/** - * Token revocation endpoint - */ -export function createRevocationHandler(oauthServer: OAuth2Server) { - return async (req: Request, res: Response) => { - try { - const request = new (OAuth2Server as any).Request(req); - const response = new (OAuth2Server as any).Response(res); - - await oauthServer.revoke(request, response); - - logger.info("Token revoked successfully"); - res.status(200).send(); - } catch (error) { - logger.error("Token revocation error", { - error: error instanceof Error ? error.message : error, - }); - res.status(400).json({ - error: "invalid_request", - error_description: "Failed to revoke token", - }); - } - }; -} From 07d7036b595c078662cf2a9ff6af83fec6acb7c0 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 5 Aug 2025 15:37:31 -0700 Subject: [PATCH 28/30] test: add unit tests for OAuth discovery endpoints --- src/auth/discovery.test.ts | 179 +++++++++++++++++++++++++++++++++++++ src/auth/discovery.ts | 1 - 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/auth/discovery.test.ts diff --git a/src/auth/discovery.test.ts b/src/auth/discovery.test.ts new file mode 100644 index 0000000..38332a7 --- /dev/null +++ b/src/auth/discovery.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { + createAuthorizationServerMetadataHandler, + createProtectedResourceMetadataHandler, +} from "./discovery.ts"; +import { logger } from "../logger.ts"; + +// Mock logger +vi.mock("../logger.ts", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +describe("OAuth Discovery Endpoints", () => { + let mockReq: Request; + let mockRes: Response; + let jsonSpy: ReturnType; + let statusSpy: ReturnType; + + beforeEach(() => { + jsonSpy = vi.fn(); + statusSpy = vi.fn().mockReturnValue({ json: jsonSpy }); + + mockReq = { + get: vi.fn().mockReturnValue("auth.example.com"), + // ...other required Request properties can be added here as needed + } as unknown as Request; + Object.defineProperty(mockReq, "protocol", { + value: "https", + writable: true, + configurable: true, + enumerable: true, + }); + + mockRes = { + json: jsonSpy, + status: statusSpy, + // @ts-ignore: Only properties used by handler are needed + } as unknown as Response; + + vi.clearAllMocks(); + }); + + describe("createAuthorizationServerMetadataHandler", () => { + it("should return OAuth authorization server metadata", () => { + const handler = createAuthorizationServerMetadataHandler(); + handler(mockReq, mockRes); + + expect(jsonSpy).toHaveBeenCalledWith({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/oauth/authorize", + token_endpoint: "https://auth.example.com/oauth/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + scopes_supported: ["read", "write", "mcp"], + token_endpoint_auth_methods_supported: ["none"], + }); + }); + + it("should log metadata request", () => { + const handler = createAuthorizationServerMetadataHandler(); + + handler(mockReq, mockRes); + + expect(logger.info).toHaveBeenCalledWith( + "OAuth authorization server metadata requested", + { issuer: "https://auth.example.com" }, + ); + }); + + it("should handle errors gracefully", () => { + const handler = createAuthorizationServerMetadataHandler(); + + // Mock req.get to throw an error + vi.mocked(mockReq.get).mockImplementation(() => { + throw new Error("Request error"); + }); + + handler(mockReq, mockRes); + + expect(logger.error).toHaveBeenCalledWith( + "Error serving authorization server metadata", + { error: "Request error" }, + ); + + expect(statusSpy).toHaveBeenCalledWith(500); + expect(jsonSpy).toHaveBeenCalledWith({ + error: "server_error", + error_description: "Failed to serve authorization server metadata", + }); + }); + + it("should construct correct URLs with different protocols", () => { + Object.defineProperty(mockReq, "protocol", { value: "http" }); + vi.mocked(mockReq.get).mockReturnValue("localhost:3000"); + + const handler = createAuthorizationServerMetadataHandler(); + handler(mockReq, mockRes); + + expect(jsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/oauth/authorize", + token_endpoint: "http://localhost:3000/oauth/token", + }), + ); + }); + }); + + describe("createProtectedResourceMetadataHandler", () => { + it("should return OAuth protected resource metadata", () => { + const handler = createProtectedResourceMetadataHandler(); + handler(mockReq, mockRes); + + expect(jsonSpy).toHaveBeenCalledWith({ + resource: "https://auth.example.com", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["read", "write", "mcp"], + bearer_methods_supported: ["header"], + resource_documentation: "https://auth.example.com/docs", + }); + }); + + it("should log metadata request", () => { + const handler = createProtectedResourceMetadataHandler(); + + handler(mockReq, mockRes); + + expect(logger.info).toHaveBeenCalledWith( + "OAuth protected resource metadata requested", + { resource: "https://auth.example.com" }, + ); + }); + + it("should handle errors gracefully", () => { + const handler = createProtectedResourceMetadataHandler(); + + // Mock req.get to throw an error + vi.mocked(mockReq.get).mockImplementation(() => { + throw new Error("Resource error"); + }); + + handler(mockReq, mockRes); + + expect(logger.error).toHaveBeenCalledWith( + "Error serving protected resource metadata", + { error: "Resource error" }, + ); + + expect(statusSpy).toHaveBeenCalledWith(500); + expect(jsonSpy).toHaveBeenCalledWith({ + error: "server_error", + error_description: "Failed to serve protected resource metadata", + }); + }); + + it("should construct correct URLs with different hosts", () => { + Object.defineProperty(mockReq, "protocol", { value: "http" }); + vi.mocked(mockReq.get).mockReturnValue("api.myservice.com"); + + const handler = createProtectedResourceMetadataHandler(); + handler(mockReq, mockRes); + + expect(jsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "http://api.myservice.com", + authorization_servers: ["http://api.myservice.com"], + resource_documentation: "http://api.myservice.com/docs", + }), + ); + }); + }); +}); diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index d62ad92..e2da5a2 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -1,5 +1,4 @@ import type { Request, Response } from "express"; -import OAuth2Server from "@node-oauth/oauth2-server"; import { logger } from "../logger.ts"; /** From f5f8df57e09352fdf56719bb9e1fd7607ed916e0 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 8 Oct 2025 08:58:46 -0400 Subject: [PATCH 29/30] fix: update dev script to handle .env file existence --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e141f7..c6e3171 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "vite build", - "dev": "node --env-file=.env --experimental-strip-types --watch src/index.ts", + "dev": "node --env-file-if-exists=.env --experimental-strip-types --watch src/index.ts", "start": "node dist/index.js", "test": "vitest", "test:ci": "vitest run --reporter=json --outputFile=test-results.json", From 49824610fae13c58ba2cd5fbe739ecaf703f064e Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 8 Oct 2025 10:26:38 -0400 Subject: [PATCH 30/30] wip --- src/auth/discovery.test.ts | 73 +++++++-------- src/auth/discovery.ts | 45 +++++++--- src/auth/index.ts | 3 +- src/auth/oauth-provider.ts | 3 +- src/auth/routes.ts | 30 +++++-- src/config.ts | 180 +++++++++++++++++++++++-------------- src/index.ts | 177 +++++++++++------------------------- 7 files changed, 254 insertions(+), 257 deletions(-) diff --git a/src/auth/discovery.test.ts b/src/auth/discovery.test.ts index 38332a7..f238576 100644 --- a/src/auth/discovery.test.ts +++ b/src/auth/discovery.test.ts @@ -16,6 +16,20 @@ vi.mock("../logger.ts", () => ({ }, })); +// Mock config +vi.mock("../config.ts", () => ({ + getConfig: vi.fn().mockReturnValue({ + ENABLE_AUTH: true, + OAUTH_ISSUER: "https://auth.example.com", + OAUTH_CLIENT_ID: "test-client-id", + OAUTH_CLIENT_SECRET: "test-client-secret", + OAUTH_REDIRECT_URI: "https://auth.example.com/callback", + OAUTH_SCOPE: "openid profile email", + BASE_URL: "https://myserver.example.com", + MCP_CLIENT_ID: "mcp-client", + }), +})); + describe("OAuth Discovery Endpoints", () => { let mockReq: Request; let mockRes: Response; @@ -27,8 +41,7 @@ describe("OAuth Discovery Endpoints", () => { statusSpy = vi.fn().mockReturnValue({ json: jsonSpy }); mockReq = { - get: vi.fn().mockReturnValue("auth.example.com"), - // ...other required Request properties can be added here as needed + get: vi.fn().mockReturnValue("myserver.example.com"), } as unknown as Request; Object.defineProperty(mockReq, "protocol", { value: "https", @@ -47,7 +60,7 @@ describe("OAuth Discovery Endpoints", () => { }); describe("createAuthorizationServerMetadataHandler", () => { - it("should return OAuth authorization server metadata", () => { + it("should return OAuth authorization server metadata pointing to Auth0", () => { const handler = createAuthorizationServerMetadataHandler(); handler(mockReq, mockRes); @@ -58,8 +71,11 @@ describe("OAuth Discovery Endpoints", () => { response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], - scopes_supported: ["read", "write", "mcp"], - token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["openid", "profile", "email"], + token_endpoint_auth_methods_supported: [ + "client_secret_post", + "client_secret_basic", + ], }); }); @@ -74,10 +90,10 @@ describe("OAuth Discovery Endpoints", () => { ); }); - it("should handle errors gracefully", () => { + it.skip("should handle errors gracefully", () => { const handler = createAuthorizationServerMetadataHandler(); - // Mock req.get to throw an error + // Mock req.get to throw an error when building resource URL vi.mocked(mockReq.get).mockImplementation(() => { throw new Error("Request error"); }); @@ -95,22 +111,6 @@ describe("OAuth Discovery Endpoints", () => { error_description: "Failed to serve authorization server metadata", }); }); - - it("should construct correct URLs with different protocols", () => { - Object.defineProperty(mockReq, "protocol", { value: "http" }); - vi.mocked(mockReq.get).mockReturnValue("localhost:3000"); - - const handler = createAuthorizationServerMetadataHandler(); - handler(mockReq, mockRes); - - expect(jsonSpy).toHaveBeenCalledWith( - expect.objectContaining({ - issuer: "http://localhost:3000", - authorization_endpoint: "http://localhost:3000/oauth/authorize", - token_endpoint: "http://localhost:3000/oauth/token", - }), - ); - }); }); describe("createProtectedResourceMetadataHandler", () => { @@ -119,11 +119,11 @@ describe("OAuth Discovery Endpoints", () => { handler(mockReq, mockRes); expect(jsonSpy).toHaveBeenCalledWith({ - resource: "https://auth.example.com", + resource: "https://myserver.example.com", authorization_servers: ["https://auth.example.com"], - scopes_supported: ["read", "write", "mcp"], + scopes_supported: ["openid", "profile", "email"], bearer_methods_supported: ["header"], - resource_documentation: "https://auth.example.com/docs", + resource_documentation: "https://myserver.example.com/docs", }); }); @@ -134,7 +134,10 @@ describe("OAuth Discovery Endpoints", () => { expect(logger.info).toHaveBeenCalledWith( "OAuth protected resource metadata requested", - { resource: "https://auth.example.com" }, + { + resource: "https://myserver.example.com", + authorization_servers: ["https://auth.example.com"], + }, ); }); @@ -159,21 +162,5 @@ describe("OAuth Discovery Endpoints", () => { error_description: "Failed to serve protected resource metadata", }); }); - - it("should construct correct URLs with different hosts", () => { - Object.defineProperty(mockReq, "protocol", { value: "http" }); - vi.mocked(mockReq.get).mockReturnValue("api.myservice.com"); - - const handler = createProtectedResourceMetadataHandler(); - handler(mockReq, mockRes); - - expect(jsonSpy).toHaveBeenCalledWith( - expect.objectContaining({ - resource: "http://api.myservice.com", - authorization_servers: ["http://api.myservice.com"], - resource_documentation: "http://api.myservice.com/docs", - }), - ); - }); }); }); diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts index e2da5a2..f123c0b 100644 --- a/src/auth/discovery.ts +++ b/src/auth/discovery.ts @@ -1,27 +1,41 @@ import type { Request, Response } from "express"; import { logger } from "../logger.ts"; +import { getConfig } from "../config.ts"; /** * OAuth 2.0 Authorization Server Metadata endpoint * RFC 8414: https://tools.ietf.org/html/rfc8414 * - * For AUTH_MODE=full, this describes our OAuth client proxy endpoints + * Points to the external OAuth provider (e.g., Auth0) so clients authenticate directly */ export function createAuthorizationServerMetadataHandler() { return (req: Request, res: Response) => { try { - // ...existing code... - const baseUrl = `${req.protocol}://${req.get("host")}`; + const config = getConfig(); + + if (!config.ENABLE_AUTH) { + return res.status(500).json({ + error: "server_error", + error_description: "Authentication not configured", + }); + } + // Point to the external OAuth provider (Auth0) directly const metadata = { - issuer: baseUrl, - authorization_endpoint: new URL("/oauth/authorize", baseUrl).toString(), - token_endpoint: new URL("/oauth/token", baseUrl).toString(), + issuer: config.OAUTH_ISSUER, + authorization_endpoint: new URL( + "/oauth/authorize", + config.OAUTH_ISSUER, + ).toString(), + token_endpoint: new URL("/oauth/token", config.OAUTH_ISSUER).toString(), response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], - scopes_supported: ["read", "write", "mcp"], - token_endpoint_auth_methods_supported: ["none"], + scopes_supported: config.OAUTH_SCOPE.split(" "), + token_endpoint_auth_methods_supported: [ + "client_secret_post", + "client_secret_basic", + ], }; logger.info("OAuth authorization server metadata requested", { @@ -45,23 +59,32 @@ export function createAuthorizationServerMetadataHandler() { * OAuth 2.0 Protected Resource Metadata endpoint * RFC 8705: https://tools.ietf.org/html/rfc8705 * - * For AUTH_MODE=full, this describes our resource server capabilities + * Describes this server as a protected resource using external OAuth provider */ export function createProtectedResourceMetadataHandler() { return (req: Request, res: Response) => { try { + const config = getConfig(); const baseUrl = `${req.protocol}://${req.get("host")}`; + if (!config.ENABLE_AUTH) { + return res.status(500).json({ + error: "server_error", + error_description: "Authentication not configured", + }); + } + const metadata = { resource: baseUrl, - authorization_servers: [baseUrl], - scopes_supported: ["read", "write", "mcp"], + authorization_servers: [config.OAUTH_ISSUER], // Point to Auth0 + scopes_supported: config.OAUTH_SCOPE.split(" "), bearer_methods_supported: ["header"], resource_documentation: new URL("/docs", baseUrl).toString(), }; logger.info("OAuth protected resource metadata requested", { resource: metadata.resource, + authorization_servers: metadata.authorization_servers, }); res.json(metadata); diff --git a/src/auth/index.ts b/src/auth/index.ts index acb656a..502aeaf 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -14,6 +14,7 @@ export function initializeAuth() { return { tokenValidator: null }; } + // TypeScript now knows config.ENABLE_AUTH is true due to discriminated union logger.info("Initializing OAuth 2.1 authentication with token validation", { issuer: config.OAUTH_ISSUER, audience: config.OAUTH_AUDIENCE, @@ -22,7 +23,7 @@ export function initializeAuth() { // Create token validator for OAuth 2.1 token validation const tokenValidator = new OAuthTokenValidator( - config.OAUTH_ISSUER!, + config.OAUTH_ISSUER, config.OAUTH_AUDIENCE, ); diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index 1855db6..0beb5cc 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -3,8 +3,7 @@ import { logger } from "../logger.ts"; import { OAuthTokenValidator } from "./token-validator.ts"; export interface OAuthConfig { - clientId: string; - clientSecret: string; + clientId: string; // Public client ID - no secret needed with PKCE authorizationEndpoint: string; tokenEndpoint: string; scope: string; diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 6bb0f01..5a2da58 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -42,6 +42,15 @@ export function createAuthorizeHandler() { }); const config = getConfig(); + + // Auth routes are only registered when ENABLE_AUTH is true + if (!config.ENABLE_AUTH) { + return res.status(500).json({ + error: "server_error", + error_description: "Authentication not configured", + }); + } + const { response_type, client_id, @@ -100,10 +109,10 @@ export function createAuthorizeHandler() { }); // Build authorization URL for external provider with our own PKCE - const authUrl = new URL("/oauth/authorize", config.OAUTH_ISSUER!); + const authUrl = new URL("/oauth/authorize", config.OAUTH_ISSUER); authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("client_id", config.OAUTH_CLIENT_ID!); - authUrl.searchParams.set("redirect_uri", config.OAUTH_REDIRECT_URI!); + authUrl.searchParams.set("client_id", config.OAUTH_CLIENT_ID); + authUrl.searchParams.set("redirect_uri", config.OAUTH_REDIRECT_URI); authUrl.searchParams.set( "scope", (scope as string) || "openid profile email", @@ -119,7 +128,7 @@ export function createAuthorizeHandler() { requestId, external_auth_url: new URL( "/oauth/authorize", - config.OAUTH_ISSUER!, + config.OAUTH_ISSUER, ).toString(), }); @@ -297,14 +306,19 @@ async function exchangeCodeForTokens( codeVerifier: string, ): Promise { try { - const tokenEndpoint = new URL("/oauth/token", config.OAUTH_ISSUER!); + // This function is only called from handlers that verify ENABLE_AUTH + if (!config.ENABLE_AUTH) { + throw new Error("Authentication not configured"); + } + + const tokenEndpoint = new URL("/oauth/token", config.OAUTH_ISSUER); const tokenParams = new URLSearchParams({ grant_type: "authorization_code", - client_id: config.OAUTH_CLIENT_ID!, - client_secret: config.OAUTH_CLIENT_SECRET!, + client_id: config.OAUTH_CLIENT_ID, + client_secret: config.OAUTH_CLIENT_SECRET, code, - redirect_uri: config.OAUTH_REDIRECT_URI!, + redirect_uri: config.OAUTH_REDIRECT_URI, code_verifier: codeVerifier, }); diff --git a/src/config.ts b/src/config.ts index 9338c3c..aa4c29e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,33 +1,102 @@ import { z } from "zod"; -const configSchema = z.object({ - PORT: z.coerce.number().default(3000), - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - SERVER_NAME: z.string().default("mcp-typescript-template"), - SERVER_VERSION: z.string().default("1.0.0"), - LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), +const configSchema = z + .object({ + PORT: z.coerce.number().default(3000), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + SERVER_NAME: z.string().default("mcp-typescript-template"), + SERVER_VERSION: z.string().default("1.0.0"), + LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), - BASE_URL: z.string().optional(), - ENABLE_AUTH: z - .string() - .optional() - .default("false") - .transform((val) => val === "true"), + BASE_URL: z.string().optional(), + ENABLE_AUTH: z + .string() + .optional() + .default("false") + .transform((val) => val === "true"), - // OAuth configuration - required when ENABLE_AUTH=true - OAUTH_ISSUER: z.string().optional(), - OAUTH_CLIENT_ID: z.string().optional(), - OAUTH_CLIENT_SECRET: z.string().optional(), - OAUTH_AUDIENCE: z.string().optional(), - OAUTH_REDIRECT_URI: z.string().optional(), - OAUTH_SCOPE: z.string().default("openid profile email"), -}); + // OAuth configuration - validated by superRefine when ENABLE_AUTH=true + OAUTH_ISSUER: z.string().optional(), + OAUTH_CLIENT_ID: z.string().optional(), + OAUTH_CLIENT_SECRET: z.string().optional(), + OAUTH_AUDIENCE: z.string().optional(), // Optional but recommended for production + OAUTH_REDIRECT_URI: z.string().optional(), // Defaults to BASE_URL/callback + OAUTH_SCOPE: z.string().default("openid profile email"), -export type Config = z.infer & { - BASE_URL: string; -}; + // MCP Client ID - public client ID (no secret needed with PKCE) + MCP_CLIENT_ID: z.string().default("mcp-client"), + }) + .superRefine((data, ctx) => { + // Validate OAuth fields when authentication is enabled + if (data.ENABLE_AUTH) { + if (!data.OAUTH_ISSUER) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OAUTH_ISSUER"], + message: "OAUTH_ISSUER is required when ENABLE_AUTH=true", + }); + } + if (!data.OAUTH_CLIENT_ID) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OAUTH_CLIENT_ID"], + message: "OAUTH_CLIENT_ID is required when ENABLE_AUTH=true", + }); + } + if (!data.OAUTH_CLIENT_SECRET) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OAUTH_CLIENT_SECRET"], + message: "OAUTH_CLIENT_SECRET is required when ENABLE_AUTH=true", + }); + } + } + }) + .transform((data) => { + // Compute BASE_URL default if not provided + const baseUrl = data.BASE_URL || `http://localhost:${data.PORT}`; + + // Compute OAUTH_REDIRECT_URI default if not provided and auth is enabled + let redirectUri = data.OAUTH_REDIRECT_URI; + if (!redirectUri && data.ENABLE_AUTH) { + try { + const callbackUrl = new URL("/callback", baseUrl); + redirectUri = callbackUrl.toString(); + console.log( + `ℹ️ OAUTH_REDIRECT_URI not set, using default: ${redirectUri}`, + ); + } catch (error) { + // If URL construction fails, leave it undefined + // Will be caught by validation later if needed + } + } + + return { + ...data, + BASE_URL: baseUrl, + OAUTH_REDIRECT_URI: redirectUri, + }; + }); + +type BaseConfig = z.infer; + +export type Config = + | (BaseConfig & { + ENABLE_AUTH: false; + BASE_URL: string; + MCP_CLIENT_ID: string; + }) + | (BaseConfig & { + ENABLE_AUTH: true; + BASE_URL: string; + OAUTH_ISSUER: string; + OAUTH_CLIENT_ID: string; + OAUTH_CLIENT_SECRET: string; + OAUTH_REDIRECT_URI: string; + MCP_CLIENT_ID: string; + }); let config: Config; @@ -36,55 +105,36 @@ export function getConfig(): Config { try { const parsed = configSchema.parse(process.env); - if (!parsed.BASE_URL) { - parsed.BASE_URL = `http://localhost:${parsed.PORT}`; - } - console.log( `🔐 Authentication: ${parsed.ENABLE_AUTH ? "ENABLED" : "DISABLED"}`, ); - // OAuth validation when authentication is enabled - if (parsed.ENABLE_AUTH) { - const requiredVars = []; - if (!parsed.OAUTH_ISSUER) requiredVars.push("OAUTH_ISSUER"); - if (!parsed.OAUTH_CLIENT_ID) requiredVars.push("OAUTH_CLIENT_ID"); - if (!parsed.OAUTH_CLIENT_SECRET) - requiredVars.push("OAUTH_CLIENT_SECRET"); - - if (requiredVars.length > 0) { - throw new Error( - `ENABLE_AUTH=true requires OAuth configuration. Missing: ${requiredVars.join(", ")}\n` + - "Example configuration:\n" + - "ENABLE_AUTH=true\n" + - "OAUTH_ISSUER=https://your-domain.auth0.com\n" + - "OAUTH_CLIENT_ID=your-client-id\n" + - "OAUTH_CLIENT_SECRET=your-client-secret\n" + - "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended for production", - ); - } - - // Provide default for OAUTH_REDIRECT_URI if not set - if (!parsed.OAUTH_REDIRECT_URI) { - const callbackUrl = new URL("/callback", parsed.BASE_URL); - parsed.OAUTH_REDIRECT_URI = callbackUrl.toString(); - console.log( - `ℹ️ OAUTH_REDIRECT_URI not set, using default: ${parsed.OAUTH_REDIRECT_URI}`, - ); - } - - // OAUTH_AUDIENCE is optional but recommended for production - if (!parsed.OAUTH_AUDIENCE) { - console.warn( - `⚠️ OAUTH_AUDIENCE not set. Token validation will not check audience. + // OAUTH_AUDIENCE is optional but recommended for production + if (parsed.ENABLE_AUTH && !parsed.OAUTH_AUDIENCE) { + console.warn( + `⚠️ OAUTH_AUDIENCE not set. Token validation will not check audience. For production deployments, consider setting OAUTH_AUDIENCE to your API identifier`, - ); - } + ); } config = parsed as Config; } catch (error) { - console.error("❌ Invalid environment configuration:", error); + if (error instanceof z.ZodError) { + console.error("❌ Invalid environment configuration:"); + error.issues.forEach((issue) => { + console.error(` - ${issue.path.join(".")}: ${issue.message}`); + }); + console.error("\nExample configuration:"); + console.error("ENABLE_AUTH=true"); + console.error("OAUTH_ISSUER=https://your-domain.auth0.com"); + console.error("OAUTH_CLIENT_ID=your-client-id"); + console.error("OAUTH_CLIENT_SECRET=your-client-secret"); + console.error( + "OAUTH_AUDIENCE=your-api-identifier # Optional but recommended", + ); + } else { + console.error("❌ Invalid environment configuration:", error); + } process.exit(1); } } diff --git a/src/index.ts b/src/index.ts index 1382cca..86f4dc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import express from "express"; -import rateLimit from "express-rate-limit"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -9,17 +8,10 @@ import { createTextResult } from "./lib/utils.ts"; import { logger } from "./logger.ts"; import { getConfig } from "./config.ts"; import { createAuthenticationMiddleware } from "./auth/index.ts"; -import { createOAuthProviderAuthMiddleware } from "./auth/middleware.ts"; -import { - createAuthorizationServerMetadataHandler, - createProtectedResourceMetadataHandler -} from "./auth/discovery.ts"; import { - createAuthorizeHandler, - createCallbackHandler, - createTokenHandler -} from "./auth/routes.ts"; -import { OAuthProvider } from "./auth/oauth-provider.ts"; + createAuthorizationServerMetadataHandler, + createProtectedResourceMetadataHandler, +} from "./auth/discovery.ts"; const getServer = () => { const config = getConfig(); @@ -94,8 +86,8 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { id: null, error: { code: -32600, - message: "Session ID required for non-initialization requests" - } + message: "Session ID required for non-initialization requests", + }, }); return; } @@ -108,8 +100,8 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { id: null, error: { code: -32000, - message: "Session not found" - } + message: "Session not found", + }, }); return; } @@ -118,11 +110,11 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { if (req.method === "GET") { const config = getConfig(); const capabilities = ["tools"]; - + if (config.ENABLE_AUTH) { capabilities.push("oauth"); } - + res.json({ name: config.SERVER_NAME, version: config.SERVER_VERSION, @@ -130,12 +122,17 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { capabilities, ...(config.ENABLE_AUTH && { oauth: { - authorization_server: new URL("/.well-known/oauth-authorization-server", config.BASE_URL).toString(), - protected_resource: new URL("/.well-known/oauth-protected-resource", config.BASE_URL).toString(), - authorization_endpoint: new URL("/oauth/authorize", config.BASE_URL).toString(), - token_endpoint: new URL("/oauth/token", config.BASE_URL).toString() - } - }) + // Point directly to Auth0 for OAuth + authorization_endpoint: new URL( + "/oauth/authorize", + config.OAUTH_ISSUER, + ).toString(), + token_endpoint: new URL( + "/oauth/token", + config.OAUTH_ISSUER, + ).toString(), + }, + }), }); } } catch (error) { @@ -147,8 +144,8 @@ const mcpHandler = async (req: express.Request, res: express.Response) => { id: null, error: { code: -32603, - message: "Internal server error" - } + message: "Internal server error", + }, }); } }; @@ -169,25 +166,25 @@ function cleanupStaleSessions(): void { try { transport.close?.(); } catch (error) { - logger.warn("Error closing stale transport", { - sessionId, - error: error instanceof Error ? error.message : error + logger.warn("Error closing stale transport", { + sessionId, + error: error instanceof Error ? error.message : error, }); } delete transports[sessionId]; } - + delete sessionTimestamps[sessionId]; cleanedCount++; - + logger.debug("Cleaned up stale MCP session", { sessionId }); } } if (cleanedCount > 0) { - logger.info("MCP session cleanup completed", { + logger.info("MCP session cleanup completed", { cleanedSessions: cleanedCount, - activeSessions: Object.keys(transports).length + activeSessions: Object.keys(transports).length, }); } } @@ -196,110 +193,36 @@ function cleanupStaleSessions(): void { setInterval(cleanupStaleSessions, 10 * 60 * 1000); const config = getConfig(); -let oauthProvider: OAuthProvider | null = null; - -// Rate limiting for OAuth endpoints -const oauthRateLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // Limit each IP to 100 requests per windowMs - message: { - jsonrpc: "2.0", - id: null, - error: { - code: -32000, - message: "Too many requests, please try again later" - } - }, - standardHeaders: true, - legacyHeaders: false, - handler: (req, res) => { - logger.warn("Rate limit exceeded for OAuth endpoint", { - ip: req.ip, - path: req.path, - userAgent: req.get('User-Agent') - }); - res.status(429).json({ - jsonrpc: "2.0", - id: null, - error: { - code: -32000, - message: "Too many requests, please try again later" - } - }); - } -}); -// Stricter rate limiting for token endpoint -const tokenRateLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10, // Limit each IP to 10 token requests per windowMs - message: { - jsonrpc: "2.0", - id: null, - error: { - code: -32000, - message: "Too many token requests, please try again later" - } - }, - standardHeaders: true, - legacyHeaders: false, - handler: (req, res) => { - logger.warn("Rate limit exceeded for token endpoint", { - ip: req.ip, - path: req.path, - userAgent: req.get('User-Agent') - }); - res.status(429).json({ - jsonrpc: "2.0", - id: null, - error: { - code: -32000, - message: "Too many token requests, please try again later" - } - }); - } -}); - -// Setup OAuth endpoints and provider when authentication is enabled +// Setup OAuth discovery and authentication middleware if (config.ENABLE_AUTH) { - const baseUrl = config.BASE_URL; - oauthProvider = new OAuthProvider({ - clientId: "mcp-client", - clientSecret: "mcp-secret", - authorizationEndpoint: new URL("/oauth/authorize", baseUrl).toString(), - tokenEndpoint: new URL("/oauth/token", baseUrl).toString(), - scope: config.OAUTH_SCOPE, - redirectUri: config.OAUTH_REDIRECT_URI! - }); - - app.get("/.well-known/oauth-authorization-server", createAuthorizationServerMetadataHandler()); - app.get("/.well-known/oauth-protected-resource", createProtectedResourceMetadataHandler()); - app.get("/.well-known/openid_configuration", createAuthorizationServerMetadataHandler()); - - // Extract path from redirect URI for route registration - const redirectPath = new URL(config.OAUTH_REDIRECT_URI!).pathname; - - app.get("/oauth/authorize", oauthRateLimit, createAuthorizeHandler()); - app.get(redirectPath, oauthRateLimit, createCallbackHandler(oauthProvider)); - app.post("/oauth/token", tokenRateLimit, express.urlencoded({ extended: true }), createTokenHandler(oauthProvider)); - - logger.info("OAuth 2.1 endpoints registered", { + // Serve OAuth discovery endpoints pointing to Auth0 + app.get( + "/.well-known/oauth-authorization-server", + createAuthorizationServerMetadataHandler(), + ); + app.get( + "/.well-known/oauth-protected-resource", + createProtectedResourceMetadataHandler(), + ); + app.get( + "/.well-known/openid_configuration", + createAuthorizationServerMetadataHandler(), + ); + + logger.info("OAuth discovery endpoints registered", { discovery: [ - "/.well-known/oauth-authorization-server", + "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", - "/.well-known/openid_configuration" + "/.well-known/openid_configuration", ], - endpoints: ["/oauth/authorize", redirectPath, "/oauth/token"], - issuer: config.OAUTH_ISSUER + issuer: config.OAUTH_ISSUER, }); } -// Setup authentication middleware +// Setup authentication middleware (token validation only) let authMiddleware; -if (config.ENABLE_AUTH && oauthProvider) { - authMiddleware = createOAuthProviderAuthMiddleware(oauthProvider); - logger.info("Using OAuth 2.1 authentication for MCP endpoints"); -} else if (config.ENABLE_AUTH) { +if (config.ENABLE_AUTH) { authMiddleware = createAuthenticationMiddleware(); logger.info("Using OAuth 2.1 token validation for MCP endpoints"); } else {