diff --git a/.cursorrules b/.cursorrules index 03f5185..7a94d06 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 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) + ## Coding Guidelines - Follow existing patterns in the codebase @@ -119,4 +131,49 @@ 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 + +### Simple Binary Configuration + +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 +- **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` + +### 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 +- 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 + +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..0d1cc28 --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# Server Configuration +PORT=3000 +NODE_ENV=development +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) +# ============================================================================ +# Default: No authentication required - server runs immediately +# Enable when you need OAuth 2.1 authentication with token validation +# ENABLE_AUTH=false + +# 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 + +# 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 + +# ============================================================================ +# Common OAuth Provider Examples +# ============================================================================ + +# Auth0 Example: +# ENABLE_AUTH=true +# 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 + +# Okta Example: +# ENABLE_AUTH=true +# OAUTH_ISSUER=https://your-domain.okta.com +# OAUTH_CLIENT_ID=your-okta-client-id +# OAUTH_CLIENT_SECRET=your-okta-client-secret + +# 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/.gitignore b/.gitignore index 7335a07..f102176 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ vite.config.ts.timestamp-* # Claude Code local settings .claude/ + +# macOS metadata +.DS_Store \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index baae5c5..503a7cc 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 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) + ## Logging Best Practices - Use appropriate log levels: `error`, `warn`, `info`, `debug` @@ -108,4 +120,39 @@ 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 + +### Simple Binary Configuration + +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 +- **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` + +### 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 +- Production servers without gateway infrastructure + +### 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 804becf..6a8f446 100644 --- a/README.md +++ b/README.md @@ -12,34 +12,28 @@ This template provides: - **ESLint + Prettier** - Code quality and formatting - **Docker** - Containerization support - **Example Tool** - Simple echo tool to demonstrate MCP tool implementation +- **Optional OAuth 2.1** - Add authentication when needed with simple configuration -## Getting Started +## ⚠️ Production Storage Limitation -1. **Clone or use this template** +[!WARNING] +**Production Storage Limitation** - ```bash - git clone - cd mcp-typescript-template - ``` +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. -2. **Install dependencies** - - ```bash - npm install - ``` +**Do not use in-memory storage in production environments.** -3. **Build the project** +## Quick Start - ```bash - npm run build - ``` +Get your MCP server running immediately: -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 @@ -141,8 +135,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 @@ -184,6 +189,120 @@ server.registerTool( ); ``` +## 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: + +### Quick Setup + +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 + OAUTH_REDIRECT_URI=http://localhost:3000/callback # Optional, defaults to BASE_URL/callback + ``` + +2. **Restart the server** + ```bash + npm run dev + ``` + +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.) +- 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 + +### OAuth Provider Examples + +**Auth0:** + +```bash +ENABLE_AUTH=true +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 +``` + +**Okta:** + +```bash +ENABLE_AUTH=true +OAUTH_ISSUER=https://your-domain.okta.com +OAUTH_CLIENT_ID=your-okta-client-id +OAUTH_CLIENT_SECRET=your-okta-client-secret +``` + +**Google:** + +```bash +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 +``` + +### Making Authenticated Requests + +```bash +curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + http://localhost:3000/mcp +``` + +### 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) +- `POST /oauth/token` - Token exchange endpoint + +### 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` + +The core MCP server functionality is completely independent of the authentication layer. + ## Why Express? This template uses Express for the HTTP server, which provides: diff --git a/package-lock.json b/package-lock.json index a672ae1..11474f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,17 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", + "@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", + "express-rate-limit": "^8.0.1", + "jose": "^6.0.12", + "oauth2-server": "^3.1.1", "pino": "^9.0.0", - "pino-pretty": "^11.0.0" + "pino-pretty": "^11.0.0", + "pkce-challenge": "^5.0.0" }, "devDependencies": { "@types/node": "^20.0.0", @@ -597,6 +604,56 @@ "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/@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", @@ -1009,6 +1066,15 @@ "undici-types": "~6.21.0" } }, + "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", @@ -1522,6 +1588,30 @@ ], "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/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", @@ -1706,6 +1796,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", @@ -2361,10 +2477,13 @@ } }, "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==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.1.tgz", + "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -2891,6 +3010,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "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", @@ -2920,6 +3048,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", @@ -2965,6 +3099,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "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", @@ -3226,6 +3369,81 @@ "node": ">= 0.6" } }, + "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", @@ -3585,6 +3803,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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 9e41a6e..1fc0089 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "vite build", - "dev": "node --experimental-strip-types --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", @@ -37,9 +37,16 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", + "@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", + "express-rate-limit": "^8.0.1", + "jose": "^6.0.12", + "oauth2-server": "^3.1.1", "pino": "^9.0.0", - "pino-pretty": "^11.0.0" + "pino-pretty": "^11.0.0", + "pkce-challenge": "^5.0.0" } } diff --git a/src/auth/discovery.ts b/src/auth/discovery.ts new file mode 100644 index 0000000..ad74964 --- /dev/null +++ b/src/auth/discovery.ts @@ -0,0 +1,274 @@ +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) => { + try { + const config = getConfig(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + + const metadata = { + issuer: baseUrl, + 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"], + scopes_supported: ["read", "write", "mcp"], + token_endpoint_auth_methods_supported: ["none"], + }; + + 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 + * + * 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 metadata = { + resource: baseUrl, + authorization_servers: [baseUrl], + scopes_supported: ["read", "write", "mcp"], + bearer_methods_supported: ["header"], + resource_documentation: new URL("/docs", baseUrl).toString(), + }; + + 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.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", + }); + } + }; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..acb656a --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,43 @@ +import { OAuthTokenValidator } from "./token-validator.ts"; +import { createAuthMiddleware } from "./middleware.ts"; +import { getConfig } from "../config.ts"; +import { logger } from "../logger.ts"; + +/** + * Initialize authentication based on ENABLE_AUTH + */ +export function initializeAuth() { + const config = getConfig(); + + if (!config.ENABLE_AUTH) { + logger.info("Authentication is disabled"); + return { tokenValidator: null }; + } + + 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 }; +} + +/** + * Create authentication middleware based on configuration + */ +export function createAuthenticationMiddleware() { + const { tokenValidator } = initializeAuth(); + + if (!tokenValidator) { + return (_req: any, _res: any, next: any) => next(); + } + + return createAuthMiddleware(tokenValidator); +} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts new file mode 100644 index 0000000..971c783 --- /dev/null +++ b/src/auth/middleware.ts @@ -0,0 +1,117 @@ +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 { + userId?: string; + accessToken?: string; +} + +type TokenValidator = + | OAuthTokenValidator + | BuiltinTokenValidator + | OAuthProvider; + +/** + * Create authentication middleware that supports both gateway and built-in modes + */ +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", + error_description: "Missing or invalid authorization header", + }); + } + + const token = authHeader.substring(7); + + try { + const validation = await tokenValidator.validateToken(token); + + if (!validation.valid) { + return res.status(401).json({ + error: "invalid_token", + error_description: + validation.error || "The access token is invalid or expired", + }); + } + + req.userId = validation.userId; + req.accessToken = token; + + logger.info("Request authenticated", { userId: validation.userId }); + next(); + } catch (error) { + 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", + }); + } + }; +} + +/** + * 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..818f63c --- /dev/null +++ b/src/auth/oauth-model.ts @@ -0,0 +1,286 @@ +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 (use persistent storage in production) +// 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 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"], +}; + +// Initialize client data +clients.set(configuredClient.id, configuredClient); + +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, + }); + + // 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"]; + }, + + /** + * 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"); +} 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 +}); diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts new file mode 100644 index 0000000..1855db6 --- /dev/null +++ b/src/auth/oauth-provider.ts @@ -0,0 +1,252 @@ +import { randomBytes, createHash } from "node:crypto"; +import { logger } from "../logger.ts"; +import { OAuthTokenValidator } from "./token-validator.ts"; + +export interface OAuthConfig { + clientId: string; + clientSecret: string; + authorizationEndpoint: string; + tokenEndpoint: string; + scope: string; + redirectUri: string; +} + +export interface AccessToken { + token: string; + expiresAt: Date; + userId?: string; +} + +interface AuthorizationCodeData { + clientId: string; + redirectUri: string; + scope: string; + codeChallenge: string; + codeChallengeMethod: string; + expiresAt: Date; + externalTokens?: { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresAt: Date; + scope?: string; + }; + userId?: string; +} + +/** + * OAuth authorization server for built-in auth mode + * Acts as a full OAuth 2.1 authorization server with PKCE support + */ +export class OAuthProvider { + #config: OAuthConfig; + #tokenValidator: OAuthTokenValidator; + + // In-memory stores (use database in production) + #authorizationCodes = new Map(); + #accessTokens = new Map< + string, + { + userId: string; + scope: string; + expiresAt: Date; + 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 + } + + get tokenValidator() { + return this.#tokenValidator; + } + + /** + * 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); + } + + /** + * Exchange authorization code for access token with PKCE verification + */ + 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", { codeLength: code.length }); + return null; + } + + // Check expiration + if (codeData.expiresAt < new Date()) { + this.#authorizationCodes.delete(code); + logger.warn("Expired authorization code", { codeLength: code.length }); + return null; + } + + // 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", { codeLength: code.length }); + 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 with user info from external tokens + const userId = codeData.userId || this.generateUserId(); + this.#accessTokens.set(accessToken, { + userId, + scope: codeData.scope, + expiresAt, + externalTokens: codeData.externalTokens, + }); + + // 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, + }; + } + + /** + * 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"); + return computedChallenge === codeChallenge; + } + + /** + * 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 }; + } + + if (tokenData.expiresAt < new Date()) { + this.#accessTokens.delete(token); + return { valid: false }; + } + + return { + valid: true, + userId: tokenData.userId, + scope: tokenData.scope, + }; + } + + /** + * Generate a unique user ID + */ + private generateUserId(): string { + return `user-${randomBytes(16).toString("hex")}`; + } + + /** + * 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/oauth-server.ts b/src/auth/oauth-server.ts new file mode 100644 index 0000000..ca0ef70 --- /dev/null +++ b/src/auth/oauth-server.ts @@ -0,0 +1,204 @@ +import OAuth2Server from "oauth2-server"; +import { generateChallenge, verifyChallenge } from "pkce-challenge"; +import { randomBytes } from "node:crypto"; +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 - should be replaced with real authentication + getUser: async () => { + // Generate a unique user ID for each session + const userId = `user-${randomBytes(8).toString("hex")}`; + return { id: userId }; + }, + + // 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: true }, + 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; + } + } +} diff --git a/src/auth/routes.ts b/src/auth/routes.ts new file mode 100644 index 0000000..6bb0f01 --- /dev/null +++ b/src/auth/routes.ts @@ -0,0 +1,415 @@ +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"; + +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() { + return async (req: Request, res: Response) => { + try { + logger.debug("Authorization handler called", { + query: req.query, + url: req.url, + }); + + const config = getConfig(); + 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", + }); + } + + 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", + }); + } + + // 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 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: 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, + }); + + res.status(500).json({ + error: "server_error", + error_description: "Failed to process authorization request", + }); + } + }; +} + +/** + * OAuth callback handler - receives callback from external OAuth provider + * This completes the OAuth proxy flow by exchanging the code for tokens + */ +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) { + 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 || !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: "Authorization request has expired", + }); + } + + logger.info("OAuth callback received from external provider", { + code: typeof code === "string" ? code.substring(0, 8) + "..." : code, + 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-${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) + "...", + }); + + // 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", { + error: error instanceof Error ? error.message : error, + }); + res.status(500).json({ + error: "server_error", + error_description: "Failed to complete OAuth authorization", + }); + } + }; +} + +/** + * Exchange authorization code for tokens with external OAuth provider + */ +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, + }); + + logger.info("Exchanging authorization code with external provider", { + tokenEndpoint: tokenEndpoint.toString(), + clientId: config.OAUTH_CLIENT_ID, + }); + + const response = await fetch(tokenEndpoint.toString(), { + 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: tokenEndpoint.toString(), + 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", + }); + } + }; +} 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 new file mode 100644 index 0000000..ed43438 --- /dev/null +++ b/src/auth/token-validator.ts @@ -0,0 +1,157 @@ +import * as jose from "jose"; +import { logger } from "../logger.ts"; + +export interface TokenValidationResult { + valid: boolean; + userId?: string; + error?: string; +} + +/** + * OAuth token validator - validates JWT tokens from external OAuth providers + */ +export class OAuthTokenValidator { + #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 { + // Get JWKS from the 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, + }; + } catch (error) { + 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); + } + } + + private async introspectToken(token: string): Promise { + const introspectionUrl = new URL("/oauth/introspect", this.#issuer); + + const response = await fetch(introspectionUrl.toString(), { + 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" }; + } + } +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..0b91d66 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,165 @@ +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.ENABLE_AUTH; + 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.ENABLE_AUTH).toBe(false); + }); + + 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.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(); + + 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.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"); + }); + + // ...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 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(); + }); + }); + }); + + 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); + }); + }); +}); diff --git a/src/config.ts b/src/config.ts index 61844f9..9338c3c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,20 +2,87 @@ 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"), + + 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"), }); -export type Config = z.infer; +export type Config = z.infer & { + BASE_URL: string; +}; let config: Config; export function getConfig(): Config { if (!config) { try { - config = configSchema.parse(process.env); + 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. + For production deployments, consider setting OAUTH_AUDIENCE to your API identifier`, + ); + } + } + + config = parsed as Config; } catch (error) { console.error("❌ Invalid environment configuration:", error); process.exit(1); @@ -30,4 +97,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 06c5204..1382cca 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"; @@ -7,6 +8,18 @@ import { z } from "zod"; 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"; const getServer = () => { const config = getConfig(); @@ -37,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; @@ -50,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 }); }, }); @@ -62,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; @@ -72,40 +89,226 @@ 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; } // For GET requests without session, return server info 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, description: "TypeScript template for building MCP servers", - capabilities: ["tools"], + 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() + } + }) }); } } catch (error) { 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" + } + }); } }; -// Handle MCP requests on /mcp endpoint -app.post("/mcp", mcpHandler); +/** + * 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; + +// 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; + 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", { + discovery: [ + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource", + "/.well-known/openid_configuration" + ], + endpoints: ["/oauth/authorize", redirectPath, "/oauth/token"], + issuer: config.OAUTH_ISSUER + }); +} + +// Setup authentication middleware +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) { + authMiddleware = createAuthenticationMiddleware(); + 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); +app.post("/mcp", authMiddleware, mcpHandler); async function main() { const config = getConfig(); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 88db281..6a64649 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -5,7 +5,7 @@ describe("createTextResult", () => { // Mock data for testing const mockData = { echo: "Hello world", - timestamp: Date.now() + timestamp: Date.now(), }; it("should create a CallToolResult with correct structure", () => { @@ -38,4 +38,4 @@ describe("createTextResult", () => { expect(result.content[0].type).toBe("text"); expect(result.content[0].text).toBe("null"); }); -}); \ No newline at end of file +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 596d86f..c0a26df 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -9,7 +9,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; export function createTextResult(data: unknown): CallToolResult { // Handle undefined gracefully by converting to null const safeData = data === undefined ? null : data; - + return { content: [ { @@ -18,4 +18,4 @@ export function createTextResult(data: unknown): CallToolResult { }, ], }; -} \ No newline at end of file +}