Merge pull request #13 from modelcontextprotocol/basil/refactor
refactor, improve docs
This commit is contained in:
commit
afb8d6a381
66 changed files with 18057 additions and 3529 deletions
|
|
@ -1,4 +0,0 @@
|
|||
AUTH_MODE=integrated
|
||||
BASE_URI=http://localhost:3232
|
||||
PORT=3232
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
AUTH_MODE=separate
|
||||
BASE_URI=http://localhost:3232
|
||||
PORT=3232
|
||||
REDIS_URL=redis://localhost:6379
|
||||
AUTH_SERVER_URL=http://localhost:3001
|
||||
AUTH_SERVER_PORT=3001
|
||||
761
README.md
761
README.md
|
|
@ -1,18 +1,24 @@
|
|||
# MCP Everything Server
|
||||
# MCP Feature Reference Server
|
||||
|
||||
_Note: these docs were AI generated based on a claude code transcript, and then edited manually for accuracy_
|
||||
A production-ready example of a Model Context Protocol (MCP) server with OAuth 2.0 authentication, demonstrating best practices for secure, scalable MCP deployments.
|
||||
|
||||
A comprehensive example implementation of a scalable Model Context Protocol (MCP) server that demonstrates all MCP functionality with full authentication support and horizontal scalability.
|
||||
## What is this?
|
||||
|
||||
## Overview
|
||||
This repository provides a complete implementation of an MCP server that:
|
||||
- Shows how to add authentication to MCP servers using OAuth 2.0
|
||||
- Demonstrates all MCP protocol features (tools, resources, prompts)
|
||||
- Uses a scalable architecture suitable for production deployments
|
||||
- Serves as a learning resource and starting template for your own MCP servers
|
||||
|
||||
The Everything Server is an open-source reference implementation that showcases:
|
||||
- **Complete [MCP Protocol](https://modelcontextprotocol.io/specification) Support**: All MCP features including tools, resources, prompts, sampling, completions, and logging
|
||||
- **Multiple [Transport Methods](https://modelcontextprotocol.io/docs/concepts/transports)**: Streamable HTTP (SHTTP) and Server-Sent Events (SSE)
|
||||
- **Dual Authentication Modes**: Integrated and separate authorization server support
|
||||
- **Horizontal Scalability**: Redis-backed session management for multi-instance deployments
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io) is an open standard that enables seamless integration between AI applications and external data sources, tools, and services.
|
||||
|
||||
This server serves as both primarily as a learning resource, and an example implementation of a scalable remote MCP server.
|
||||
## Why OAuth for MCP?
|
||||
|
||||
MCP servers often handle sensitive operations and data. This implementation shows how to:
|
||||
- Authenticate users before granting access to MCP resources
|
||||
- Integrate with existing identity providers (Auth0, Okta, Google)
|
||||
- Implement user session isolation in multi-tenant environments
|
||||
- Scale horizontally while maintaining security
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -27,33 +33,167 @@ orbctl start # macOS: Start OrbStack daemon
|
|||
# 2. Setup
|
||||
git clone https://github.com/modelcontextprotocol/example-remote-server.git
|
||||
cd example-remote-server
|
||||
npm install
|
||||
cp .env.integrated .env # Configure for integrated mode (see Authentication Modes for details)
|
||||
npm install # Install dependencies for all workspaces
|
||||
|
||||
# 3. Start services
|
||||
docker compose up -d # Start Redis
|
||||
npm run dev # Start server
|
||||
npm run dev # Start both auth-server and mcp-server
|
||||
|
||||
# 4. Test with Inspector
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
||||
# Connect to http://localhost:3232/mcp
|
||||
# (Make sure to include the http:// prefix!)
|
||||
```
|
||||
|
||||
For detailed instructions, see [Installation](#installation).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This repository demonstrates a production-ready MCP deployment pattern with separate authorization and resource servers:
|
||||
|
||||
```
|
||||
auth-server/ # OAuth 2.0 authorization server (demo only - replace in production)
|
||||
└── src/ # Authorization endpoints and token management
|
||||
|
||||
mcp-server/ # MCP resource server (customize tools/resources/prompts)
|
||||
└── src/ # MCP protocol implementation with external auth
|
||||
|
||||
scripts/ # Testing and deployment scripts
|
||||
docs/ # Architecture and API documentation
|
||||
examples/ # Example code and usage patterns
|
||||
```
|
||||
|
||||
The architecture separates authentication concerns from MCP functionality, allowing you to integrate with commercial OAuth providers (Auth0, Okta, etc.).
|
||||
|
||||
## Customizing for Your Use Case
|
||||
|
||||
This is a reference implementation with demo tools, resources, and prompts. To adapt it for production:
|
||||
- **Replace MCP features:** See [Customization Guide](docs/customization-guide.md) for replacing demo functionality with your own
|
||||
- **Integrate OAuth provider:** See [OAuth Architecture Patterns](docs/oauth-architecture-patterns.md) for production authentication setup
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Making Your First MCP Request
|
||||
|
||||
Once the servers are running, here's how to interact with them:
|
||||
|
||||
#### 1. Register an OAuth Client
|
||||
|
||||
```bash
|
||||
# Register your application with the auth server
|
||||
curl -X POST http://localhost:3001/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "my-mcp-client",
|
||||
"redirect_uris": ["http://localhost:3000/callback"]
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "client_id": "abc123...",
|
||||
# "client_secret": "xyz789...",
|
||||
# "client_name": "my-mcp-client"
|
||||
# }
|
||||
```
|
||||
|
||||
#### 2. Get an Access Token
|
||||
|
||||
For testing, use the MCP Inspector which handles the OAuth flow automatically. For programmatic access, implement the OAuth 2.0 authorization code flow (see [docs/oauth-flow.md](docs/oauth-flow.md)).
|
||||
|
||||
#### 3. Initialize MCP Session
|
||||
|
||||
```bash
|
||||
# Initialize a new MCP session
|
||||
curl -X POST http://localhost:3232/mcp \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "my-client",
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
# Response includes session ID for subsequent requests
|
||||
```
|
||||
|
||||
#### 4. Use MCP Features
|
||||
|
||||
```bash
|
||||
# List available tools
|
||||
curl -X POST http://localhost:3232/mcp \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "2",
|
||||
"method": "tools/list"
|
||||
}'
|
||||
|
||||
# Call a tool (echo example)
|
||||
curl -X POST http://localhost:3232/mcp \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "3",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo",
|
||||
"arguments": {
|
||||
"message": "Hello, MCP!"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
For more examples, see the [examples/](examples/) directory.
|
||||
|
||||
## Understanding the Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Client as Your App
|
||||
participant Auth as Auth Server (:3001)
|
||||
participant MCP as MCP Server (:3232)
|
||||
|
||||
User->>Client: Wants to use MCP
|
||||
Client->>Auth: 1. Register & OAuth flow
|
||||
Auth->>User: 2. Login page
|
||||
User->>Auth: 3. Authenticate
|
||||
Auth->>Client: 4. Access token
|
||||
Client->>MCP: 5. MCP request + token
|
||||
MCP->>Auth: 6. Validate token
|
||||
Auth->>MCP: 7. Token valid ✓
|
||||
MCP->>Client: 8. MCP response
|
||||
Client->>User: 9. Results
|
||||
```
|
||||
|
||||
The auth server is separate so you can easily replace it with Auth0, Okta, or any OAuth provider. See [docs/oauth-architecture-patterns.md](docs/oauth-architecture-patterns.md) for integration guidance.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Authentication Modes](#authentication-modes)
|
||||
- [Architecture](#architecture)
|
||||
- [Development](#development)
|
||||
- [Testing with MCP Inspector](#testing-with-mcp-inspector)
|
||||
- [Automated End-to-End Testing](#automated-end-to-end-testing)
|
||||
- [Interactive Testing](#interactive-testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Architecture & Technical Details](#architecture--technical-details)
|
||||
- [Common Issues & Solutions](#common-issues--solutions)
|
||||
- [Technical Details](#technical-details)
|
||||
- [API Reference](#api-reference)
|
||||
- [Security](#security)
|
||||
- [Monitoring & Debugging](#monitoring--debugging)
|
||||
|
|
@ -78,10 +218,10 @@ For detailed instructions, see [Installation](#installation).
|
|||
- **Horizontal Scaling**: Any instance can handle any request
|
||||
|
||||
### Authentication & Security
|
||||
- **Dual Mode Support**: Run with integrated or separate authorization server
|
||||
- **[OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization)**: Complete authorization flow with PKCE support
|
||||
- **External Auth Ready**: Demonstrates integration with external OAuth providers
|
||||
- **Session Ownership**: User isolation and access control
|
||||
- **Separate Auth Server**: Demonstrates integration with external OAuth providers
|
||||
- **Token Introspection**: Validates tokens via [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662)
|
||||
- **Session Ownership**: User isolation and access control
|
||||
- **Security Headers**: CSP, HSTS, X-Frame-Options, and more
|
||||
- **Bearer Token Auth**: Middleware for protected endpoints
|
||||
|
||||
|
|
@ -120,19 +260,10 @@ sudo apt-get install redis-server && sudo systemctl start redis
|
|||
```bash
|
||||
git clone https://github.com/modelcontextprotocol/example-remote-server.git
|
||||
cd example-remote-server
|
||||
npm install
|
||||
npm install # Installs dependencies for all workspaces
|
||||
```
|
||||
|
||||
### Step 3: Configure Environment
|
||||
```bash
|
||||
# Use integrated mode (default, simpler setup)
|
||||
cp .env.integrated .env
|
||||
|
||||
# OR use separate mode (for testing external auth)
|
||||
cp .env.separate .env
|
||||
```
|
||||
|
||||
### Step 4: Start Redis
|
||||
### Step 3: Start Redis
|
||||
```bash
|
||||
# Ensure Docker/OrbStack is running first!
|
||||
docker compose up -d
|
||||
|
|
@ -141,263 +272,42 @@ docker compose up -d
|
|||
docker compose ps
|
||||
```
|
||||
|
||||
### Step 5: Verify Installation
|
||||
### Step 4: Start the Servers
|
||||
```bash
|
||||
# Run the development server
|
||||
# Start both servers concurrently
|
||||
npm run dev
|
||||
|
||||
# Server should start on http://localhost:3232
|
||||
# Or start them individually:
|
||||
npm run dev:auth-server # Auth server on http://localhost:3001
|
||||
npm run dev:mcp-server # MCP server on http://localhost:3232
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (`.env` file):
|
||||
Each server has its own `.env` file:
|
||||
|
||||
**Auth Server** (`auth-server/.env`):
|
||||
```bash
|
||||
# Server Configuration
|
||||
PORT=3232 # MCP server port
|
||||
BASE_URI=http://localhost:3232 # Base URI for OAuth redirects
|
||||
AUTH_SERVER_URL=http://localhost:3001 # Auth server URL
|
||||
AUTH_SERVER_PORT=3001 # Auth server port
|
||||
BASE_URI=http://localhost:3232 # MCP server URL (for redirects)
|
||||
REDIS_URL=redis://localhost:6379 # Redis connection
|
||||
```
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379 # Redis connection URL
|
||||
|
||||
# Authentication Mode (integrated | separate)
|
||||
AUTH_MODE=integrated # Default: integrated mode
|
||||
|
||||
# Separate Mode Configuration (only used when AUTH_MODE=separate)
|
||||
**MCP Server** (`mcp-server/.env`):
|
||||
```bash
|
||||
BASE_URI=http://localhost:3232 # MCP server URL
|
||||
PORT=3232 # MCP server port
|
||||
AUTH_SERVER_URL=http://localhost:3001 # External auth server URL
|
||||
AUTH_SERVER_PORT=3001 # Auth server port (for standalone server)
|
||||
REDIS_URL=redis://localhost:6379 # Redis connection
|
||||
```
|
||||
|
||||
**Pre-configured environment files:**
|
||||
- `.env.integrated` - Configuration for integrated mode
|
||||
- `.env.separate` - Configuration for separate mode
|
||||
## Architecture
|
||||
|
||||
```bash
|
||||
# Use integrated mode
|
||||
cp .env.integrated .env
|
||||
Per the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization), this implementation demonstrates a production-ready pattern with separate authorization and resource servers.
|
||||
|
||||
# Use separate mode
|
||||
cp .env.separate .env
|
||||
```
|
||||
### System Architecture
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
The Everything Server supports two authentication modes to demonstrate different MCP deployment patterns:
|
||||
|
||||
### Integrated Mode (Default)
|
||||
The MCP server acts as its own OAuth 2.0 authorization server. This configuration is simpler to deploy but requires the MCP server to host its own authorization logic. The implementation provided here is for demonstration purposes only.
|
||||
|
||||
```bash
|
||||
npm run dev:integrated
|
||||
```
|
||||
|
||||
### Separate Mode
|
||||
The MCP server delegates authentication to a standalone authorization server. This demonstrates how MCP servers can integrate with existing OAuth infrastructure. See [auth-server/README.md](auth-server/README.md) for more details about the standalone auth server.
|
||||
|
||||
```bash
|
||||
# Start both the auth server and MCP server
|
||||
npm run dev:with-separate-auth
|
||||
|
||||
# Or run them separately:
|
||||
# Terminal 1: Start the authorization server
|
||||
npm run dev:auth-server
|
||||
|
||||
# Terminal 2: Start the MCP server in separate mode
|
||||
npm run dev:separate
|
||||
```
|
||||
|
||||
In production, the separate authorization server would typically be replaced with:
|
||||
- Corporate SSO (Auth0, Okta)
|
||||
- Cloud providers (AWS Cognito, Azure AD)
|
||||
- Social providers (Google, GitHub)
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
If you've completed installation, you're ready to develop:
|
||||
|
||||
```bash
|
||||
# Integrated mode (MCP server handles auth)
|
||||
npm run dev:integrated
|
||||
|
||||
# Separate mode (external auth server)
|
||||
npm run dev:with-separate-auth
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
```bash
|
||||
# Start development server with hot reload
|
||||
npm run dev
|
||||
|
||||
# Start in integrated mode (MCP server as OAuth server)
|
||||
npm run dev:integrated
|
||||
|
||||
# Start in separate mode (external auth server)
|
||||
npm run dev:separate
|
||||
|
||||
# Start standalone authorization server
|
||||
npm run dev:auth-server
|
||||
|
||||
# Start both auth server and MCP server in separate mode
|
||||
npm run dev:with-separate-auth
|
||||
|
||||
# Start development server with debugging
|
||||
npm run dev:break
|
||||
```
|
||||
|
||||
#### Build & Production
|
||||
```bash
|
||||
# Build TypeScript to JavaScript (builds both servers)
|
||||
npm run build
|
||||
|
||||
# Run production server
|
||||
npm start
|
||||
|
||||
# Run production auth server
|
||||
npm run start:auth-server
|
||||
```
|
||||
|
||||
#### Testing & Quality
|
||||
```bash
|
||||
# Run linting
|
||||
npm run lint
|
||||
|
||||
# Run unit tests
|
||||
npm test
|
||||
|
||||
# Run end-to-end tests (automated server management)
|
||||
npm run test:e2e:integrated # Test integrated mode OAuth + features
|
||||
npm run test:e2e:separate # Test separate mode OAuth + features
|
||||
```
|
||||
|
||||
### Testing with MCP Inspector
|
||||
|
||||
The MCP Inspector is a web-based tool for testing MCP servers.
|
||||
|
||||
#### Prerequisites
|
||||
1. Ensure Docker/OrbStack is running
|
||||
2. Ensure Redis is running: `docker compose ps`
|
||||
3. Ensure environment is configured: Check `.env` file exists
|
||||
|
||||
#### Test Integrated Mode
|
||||
```bash
|
||||
# 1. Start the server (Redis must already be running)
|
||||
npm run dev:integrated
|
||||
|
||||
# 2. Launch MCP Inspector in a new terminal
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
||||
# 3. Connect to: http://localhost:3232/mcp
|
||||
# 4. Navigate to Auth tab and complete OAuth flow
|
||||
```
|
||||
|
||||
#### Test Separate Mode
|
||||
```bash
|
||||
# 1. Start both servers (Redis must already be running)
|
||||
npm run dev:with-separate-auth
|
||||
|
||||
# 2. Launch MCP Inspector in a new terminal
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
||||
# 3. Connect to: http://localhost:3232/mcp
|
||||
# 4. Auth flow will redirect to :3001 for authentication
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run specific test suites
|
||||
npm test -- --testNamePattern="User Session Isolation"
|
||||
npm test -- --testNamePattern="session ownership"
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
- **Unit Tests**: Individual component testing
|
||||
- **Integration Tests**: Transport and Redis integration
|
||||
- **Auth Tests**: OAuth flow and session ownership
|
||||
- **Multi-user Tests**: User isolation and access control
|
||||
|
||||
### Automated End-to-End Testing
|
||||
|
||||
The `scripts/` directory contains automated test scripts that verify the complete OAuth flow and all MCP features:
|
||||
|
||||
#### Scripts
|
||||
- **`test-integrated-e2e.sh`** - Tests integrated mode (MCP server as OAuth server)
|
||||
- **`test-separate-e2e.sh`** - Tests separate mode (external auth server)
|
||||
|
||||
#### What the scripts test:
|
||||
- Complete OAuth 2.0 + PKCE flow from client registration to token usage
|
||||
- All MCP features: tools (7), resources (100 with pagination), prompts (3)
|
||||
- Session management and proper error handling
|
||||
- README claim verification
|
||||
|
||||
#### Usage
|
||||
```bash
|
||||
# Recommended: Automated testing (handles server lifecycle)
|
||||
npm run test:e2e:integrated # Tests integrated mode
|
||||
npm run test:e2e:separate # Tests separate mode
|
||||
|
||||
# Advanced: Manual script execution (requires manual server setup)
|
||||
./scripts/test-integrated-e2e.sh
|
||||
./scripts/test-separate-e2e.sh
|
||||
```
|
||||
|
||||
The npm scripts automatically start required servers, run tests, and clean up. Manual scripts require you to start Redis and servers first.
|
||||
|
||||
### Interactive Testing
|
||||
Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Cannot connect to Docker daemon"**
|
||||
- Ensure Docker Desktop or OrbStack daemon is running
|
||||
- macOS with OrbStack: `orbctl start` (verify with `orbctl status`)
|
||||
- Windows/Linux/macOS with Docker Desktop: Start Docker Desktop application
|
||||
|
||||
**"Redis connection refused"**
|
||||
- Check Redis is running: `docker compose ps`
|
||||
- If not running: `docker compose up -d`
|
||||
- Ensure Docker/OrbStack is started first
|
||||
|
||||
**"Missing .env file"**
|
||||
- Run `cp .env.integrated .env` for default setup
|
||||
- Or `cp .env.separate .env` for separate auth mode
|
||||
|
||||
**"Port already in use"**
|
||||
- Check for existing processes: `lsof -i :3232` or `lsof -i :3001`
|
||||
- Kill existing processes or change PORT in .env
|
||||
|
||||
**"npm install fails"**
|
||||
- Ensure Node.js >= 16 is installed: `node --version`
|
||||
- Clear npm cache: `npm cache clean --force`
|
||||
- Delete node_modules and package-lock.json, then retry
|
||||
|
||||
**"Authentication flow fails"**
|
||||
- Check the server logs for error messages
|
||||
- Ensure Redis is running and accessible
|
||||
- Verify .env configuration matches your setup mode
|
||||
|
||||
## Architecture & Technical Details
|
||||
|
||||
### Authentication Architecture
|
||||
|
||||
#### Integrated Mode
|
||||
```mermaid
|
||||
graph TD
|
||||
Client["MCP Client<br/>(Inspector)"]
|
||||
MCP["MCP Server<br/>(port 3232)<br/>• OAuth Server<br/>• Resource Server"]
|
||||
|
||||
Client <-->|"OAuth flow & MCP resources"| MCP
|
||||
```
|
||||
|
||||
#### Separate Mode
|
||||
```mermaid
|
||||
graph TD
|
||||
Client["MCP Client<br/>(Inspector)"]
|
||||
|
|
@ -410,99 +320,197 @@ graph TD
|
|||
MCP <-->|"Token validation<br/>(introspect)"| Auth
|
||||
```
|
||||
|
||||
### OAuth 2.0 + PKCE Flow Analysis
|
||||
This architecture pattern:
|
||||
- **Separates concerns**: Auth server handles OAuth, MCP server handles protocol
|
||||
- **Enables integration**: Can replace auth server with Auth0, Okta, etc.
|
||||
- **Scales independently**: Auth and MCP servers can scale based on their load
|
||||
- **Follows standards**: Uses OAuth 2.0 and token introspection (RFC 7662)
|
||||
|
||||
The server implements a complete OAuth 2.0 authorization code flow with PKCE. Here's how each step maps to data storage and expiry:
|
||||
For alternative patterns like embedded OAuth, see [docs/oauth-architecture-patterns.md](docs/oauth-architecture-patterns.md).
|
||||
|
||||
**1. Client Registration** (app setup - happens once)
|
||||
```
|
||||
App → Auth Server: "I want to use OAuth, here's my info"
|
||||
Auth Server → App: "OK, your client_id is XYZ, client_secret is ABC"
|
||||
```
|
||||
- **Storage**: Client credentials for future OAuth flows
|
||||
- **Expiry**: 30 days (long-lived app credentials)
|
||||
### OAuth Flow
|
||||
|
||||
**2. Authorization Request** (starts each OAuth flow)
|
||||
```
|
||||
User → App: "I want to connect to MCP server"
|
||||
App → Auth Server: "User wants access, here's my PKCE challenge"
|
||||
Auth Server: Stores pending authorization, shows auth page
|
||||
```
|
||||
- **Storage**: `PENDING_AUTHORIZATION` - temporary state during flow
|
||||
- **Expiry**: 10 minutes (short-lived temporary state)
|
||||
The implementation uses OAuth 2.1 with PKCE. For detailed flow analysis including data storage, TTLs, and implementation details, see [docs/oauth-flow.md](docs/oauth-flow.md).
|
||||
|
||||
**3. Authorization Code Exchange** (completes OAuth flow)
|
||||
```
|
||||
User → Auth Server: "I approve this app"
|
||||
Auth Server → App: "Here's your authorization code"
|
||||
App → Auth Server: "Exchange code + PKCE verifier for tokens"
|
||||
Auth Server → App: "Here are your access/refresh tokens"
|
||||
```
|
||||
- **Storage**: `TOKEN_EXCHANGE` - prevents replay attacks
|
||||
- **Expiry**: 10 minutes (single-use, consumed immediately)
|
||||
### Understanding the Mock Identity Provider
|
||||
|
||||
**4. Token Storage** (long-term user session)
|
||||
```
|
||||
Auth Server: Issues access_token + refresh_token
|
||||
Server: Stores user installation with tokens
|
||||
```
|
||||
- **Storage**: `UPSTREAM_INSTALLATION` - the actual user session
|
||||
- **Expiry**: 7 days (balances security vs usability)
|
||||
|
||||
**5. Token Refresh** (extends user session)
|
||||
```
|
||||
App → Auth Server: "My access token expired, here's my refresh token"
|
||||
Auth Server → App: "Here's a new access token"
|
||||
```
|
||||
- **Storage**: `REFRESH_TOKEN` - mapping for token rotation
|
||||
- **Expiry**: 7 days (matches installation lifetime)
|
||||
|
||||
#### Data Lifecycle Hierarchy
|
||||
|
||||
**Timeline (shortest to longest expiry):**
|
||||
1. **OAuth flow state** (10 minutes) - very temporary
|
||||
2. **User sessions** (7 days) - medium-term
|
||||
3. **Client credentials** (30 days) - long-term
|
||||
|
||||
This creates a logical hierarchy where each layer outlives the layers it supports.
|
||||
The `/mock-upstream-idp` endpoints simulate what a real identity provider (Google, GitHub, corporate SSO) would do. In production, users would be redirected to their actual identity provider for login. This mock implementation helps you test the complete flow locally without external dependencies.
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
├── src/ # MCP server code
|
||||
│ ├── index.ts # Express app setup and routes
|
||||
│ ├── config.ts # Configuration management
|
||||
│ ├── redis.ts # Redis client setup
|
||||
│ ├── auth/
|
||||
│ │ ├── provider.ts # OAuth auth provider implementation
|
||||
│ │ └── external-verifier.ts # External token verification
|
||||
│ ├── handlers/
|
||||
│ │ ├── shttp.ts # Streamable HTTP handler
|
||||
│ │ ├── sse.ts # SSE transport handler
|
||||
│ │ ├── fakeauth.ts # Fake upstream auth handler
|
||||
│ │ └── common.ts # Shared middleware
|
||||
│ ├── services/
|
||||
│ │ ├── mcp.ts # MCP server implementation
|
||||
│ │ ├── auth.ts # Auth service wrappers
|
||||
│ │ └── redisTransport.ts # Redis-backed transport
|
||||
│ └── utils/
|
||||
│ └── logger.ts # Structured logging
|
||||
├── auth-server/ # Standalone authorization server
|
||||
│ ├── index.ts # Auth server main entry point
|
||||
│ ├── README.md # Auth server documentation
|
||||
│ └── tsconfig.json # TypeScript configuration
|
||||
├── shared/ # Shared between both servers
|
||||
│ ├── auth-core.ts # Core auth logic
|
||||
│ ├── redis-auth.ts # Redis auth operations
|
||||
│ └── types.ts # Shared type definitions
|
||||
├── scripts/ # End-to-end testing scripts
|
||||
│ ├── test-integrated-e2e.sh # OAuth + feature verification (integrated)
|
||||
│ └── test-separate-e2e.sh # OAuth + feature verification (separate)
|
||||
├── auth-server/ # OAuth authorization server
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Auth server entry point
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── provider.ts # FeatureReferenceAuthProvider
|
||||
│ │ │ └── auth-core.ts # Token generation, PKCE
|
||||
│ │ ├── services/ # Auth services, redis-auth
|
||||
│ │ ├── handlers/ # Mock upstream auth
|
||||
│ │ └── utils/logger.ts # Structured logging
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env
|
||||
│
|
||||
├── mcp-server/ # MCP resource server
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # MCP server entry point
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── external-verifier.ts # Token introspection
|
||||
│ │ │ └── auth-core.ts
|
||||
│ │ ├── services/ # MCP server, redisTransport
|
||||
│ │ ├── handlers/ # SHTTP, SSE handlers
|
||||
│ │ └── utils/logger.ts
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env
|
||||
│
|
||||
├── scripts/ # Testing and deployment
|
||||
│ └── test-e2e.sh # End-to-end OAuth + MCP verification
|
||||
├── docs/
|
||||
│ ├── streamable-http-design.md # SHTTP implementation details
|
||||
│ └── user-id-system.md # Authentication flow documentation
|
||||
└── dist/ # Compiled JavaScript output
|
||||
│ ├── customization-guide.md # How to adapt this for your use case
|
||||
│ ├── endpoints.md # API endpoint reference
|
||||
│ ├── oauth-flow.md # OAuth flow documentation
|
||||
│ ├── oauth-architecture-patterns.md # OAuth integration guidance
|
||||
│ └── session-ownership.md # Session management details
|
||||
├── examples/ # Example code and usage patterns
|
||||
│ ├── client.js # Node.js client example
|
||||
│ └── curl-examples.sh # Shell script with curl examples
|
||||
├── package.json # Root workspace configuration
|
||||
└── docker-compose.yml # Redis service
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start servers
|
||||
npm run dev # Both servers concurrently
|
||||
npm run dev:auth-server # Auth server only
|
||||
npm run dev:mcp-server # MCP server only
|
||||
```
|
||||
|
||||
### Build & Production
|
||||
```bash
|
||||
npm run build # Build all workspaces
|
||||
npm run start # Run both servers
|
||||
npm run start:auth-server # Run auth server only
|
||||
npm run start:mcp-server # Run MCP server only
|
||||
```
|
||||
|
||||
### Testing & Quality
|
||||
```bash
|
||||
npm test # All unit tests
|
||||
npm run lint # Lint all code
|
||||
npm run typecheck # Typecheck all code
|
||||
npm run test:e2e # End-to-end test
|
||||
```
|
||||
|
||||
### Testing with MCP Inspector
|
||||
|
||||
The MCP Inspector is a web-based tool for testing MCP servers.
|
||||
|
||||
#### Prerequisites
|
||||
1. Ensure Docker/OrbStack is running
|
||||
2. Ensure Redis is running: `docker compose ps`
|
||||
|
||||
#### Test the Server
|
||||
```bash
|
||||
# 1. Start both servers (Redis must already be running)
|
||||
npm run dev
|
||||
|
||||
# 2. Launch MCP Inspector in a new terminal
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
||||
# 3. Connect to: http://localhost:3232/mcp
|
||||
# 4. Auth flow will redirect to :3001 for authentication
|
||||
# 5. Complete OAuth flow in the Auth tab
|
||||
# 6. Test MCP features (tools, resources, prompts, etc.)
|
||||
```
|
||||
|
||||
### Automated End-to-End Testing
|
||||
|
||||
The E2E script in `scripts/test-e2e.sh` verifies the complete OAuth flow and all MCP features:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
The script:
|
||||
- Automatically manages server lifecycle
|
||||
- Tests OAuth 2.0 + PKCE flow
|
||||
- Verifies all MCP features (tools, resources, prompts)
|
||||
- Validates token introspection between servers
|
||||
- Tests session management and ownership
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### "Token validation failed"
|
||||
- **Cause**: Token expired or servers not communicating
|
||||
- **Solution**:
|
||||
- Ensure both servers are running (`npm run dev`)
|
||||
- Check that Redis is running (`docker compose ps`)
|
||||
- Verify the token hasn't expired (tokens last 7 days)
|
||||
|
||||
### "Cannot connect to MCP server" or "Connection Error - Check if your MCP server is running"
|
||||
- **Cause**: Incorrect URL format or servers not running
|
||||
- **Solution**:
|
||||
- Use the full URL with http:// prefix: `http://localhost:3232/mcp`
|
||||
- Ensure **both** auth and MCP servers are running (`npm run dev`)
|
||||
|
||||
### "Wrong endpoint for SSE transport" or SSE connection fails to `/mcp`
|
||||
- **Cause**: Using SSE transport with Streamable HTTP endpoint
|
||||
- **Solution**:
|
||||
- For **SSE transport** (legacy): Use `http://localhost:3232/sse`
|
||||
- For **Streamable HTTP** (recommended): Use `http://localhost:3232/mcp`
|
||||
- In MCP Inspector: Match transport type with the correct endpoint
|
||||
|
||||
### "Cannot connect to Docker daemon"
|
||||
- **Cause**: Docker/OrbStack not running
|
||||
- **Solution**:
|
||||
- macOS with OrbStack: `orbctl start` (verify with `orbctl status`)
|
||||
- Windows/Linux/macOS with Docker Desktop: Start Docker Desktop application
|
||||
|
||||
### "Redis connection refused"
|
||||
- **Cause**: Redis container not running
|
||||
- **Solution**:
|
||||
- Check Redis is running: `docker compose ps`
|
||||
- If not running: `docker compose up -d`
|
||||
- Ensure Docker/OrbStack is started first
|
||||
|
||||
### "Port already in use"
|
||||
- **Cause**: Another process using port 3001 or 3232
|
||||
- **Solution**:
|
||||
- Check for existing processes: `lsof -i :3232` or `lsof -i :3001`
|
||||
- Kill existing processes or change PORT in .env files
|
||||
|
||||
### Understanding the Mock IDP
|
||||
- The mock IDP simulates user login for testing
|
||||
- In production, this would be replaced by:
|
||||
- Your company's SSO system (LDAP, Active Directory)
|
||||
- Auth0/Okta login page
|
||||
- Google/GitHub OAuth
|
||||
- The mock IDP creates random user IDs for testing multi-user scenarios
|
||||
|
||||
### "Authentication service unavailable" (HTTP 503)
|
||||
- **Cause**: Auth server is not running or not reachable
|
||||
- **What happens**: MCP server runs in degraded mode
|
||||
- Splash page accessible with warning banner
|
||||
- Health endpoint shows degraded status: `curl http://localhost:3232/health`
|
||||
- Protected MCP endpoints return 503 with helpful error message
|
||||
- **Solution**:
|
||||
- Start the auth server: `npm run dev` (starts both servers)
|
||||
- Or start manually: `cd auth-server && npm run dev`
|
||||
- Restart the MCP server to retry connection
|
||||
|
||||
### "Authentication flow fails"
|
||||
- **Cause**: Misconfiguration or servers not communicating
|
||||
- **Solution**:
|
||||
- Check server logs for error messages
|
||||
- Ensure Redis is running: `docker compose ps`
|
||||
- Verify .env configuration in both server directories
|
||||
- Check that both servers are running
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Scalability Architecture
|
||||
|
||||
The server is designed for horizontal scaling using Redis as the backbone:
|
||||
|
|
@ -538,7 +546,7 @@ auth:exch:{authCode} # Token exchanges
|
|||
auth:refresh:{refreshToken} # Refresh tokens
|
||||
```
|
||||
|
||||
Note: The `auth:` prefix ensures complete namespace isolation between auth and MCP functions in both integrated and separate modes.
|
||||
Note: The `auth:` prefix ensures complete namespace isolation between auth and MCP functions.
|
||||
|
||||
### Transport Methods
|
||||
|
||||
|
|
@ -549,8 +557,6 @@ Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basi
|
|||
- Efficient message buffering
|
||||
- Automatic reconnection support
|
||||
|
||||
See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details.
|
||||
|
||||
#### Server-Sent Events (Legacy)
|
||||
Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE:
|
||||
- Separate endpoints for SSE streams and messages
|
||||
|
|
@ -560,31 +566,16 @@ Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2
|
|||
|
||||
## API Reference
|
||||
|
||||
### MCP Endpoints
|
||||
- `GET/POST/DELETE /mcp` - Streamable HTTP transport endpoint
|
||||
- `POST`: Initialize sessions or send messages
|
||||
- `GET`: Establish SSE streams
|
||||
- `DELETE`: Terminate sessions
|
||||
- `GET /sse` - Legacy SSE transport endpoint
|
||||
- `POST /message` - Legacy message endpoint for SSE transport
|
||||
|
||||
### Authentication Endpoints (Integrated Mode Only)
|
||||
- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page
|
||||
- `GET /fakeupstreamauth/callback` - OAuth redirect handler
|
||||
- OAuth 2.0 endpoints provided by MCP SDK auth router
|
||||
|
||||
### Headers
|
||||
- `Mcp-Session-Id`: Session identifier for Streamable HTTP
|
||||
- `Authorization: Bearer <token>`: OAuth access token
|
||||
- Standard MCP headers as per protocol specification
|
||||
For a complete listing of all endpoints provided by each server, including OAuth authorization endpoints, MCP resource endpoints, and demo identity provider endpoints, see [docs/endpoints.md](docs/endpoints.md).
|
||||
|
||||
## Security
|
||||
|
||||
### Implemented Security Measures
|
||||
- **Authentication**: [OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) with bearer tokens
|
||||
- **Token Validation**: Introspection via [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662)
|
||||
- **Authorization**: User-based session ownership
|
||||
- **Session Isolation**: Users can only access their own sessions
|
||||
- **Security Headers**:
|
||||
- **Security Headers**:
|
||||
- Content Security Policy (CSP)
|
||||
- Strict Transport Security (HSTS)
|
||||
- X-Frame-Options
|
||||
|
|
@ -660,9 +651,11 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||
|
||||
Built by the Model Context Protocol team as a reference implementation for the MCP ecosystem.
|
||||
|
||||
## Links
|
||||
## References
|
||||
|
||||
### MCP Documentation
|
||||
- [Model Context Protocol Documentation](https://modelcontextprotocol.io)
|
||||
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
|
||||
- [MCP Specification](https://modelcontextprotocol.io/specification)
|
||||
- [MCP Concepts](https://modelcontextprotocol.io/docs/concepts)
|
||||
- [Tools](https://modelcontextprotocol.io/docs/concepts/tools)
|
||||
|
|
@ -671,4 +664,16 @@ Built by the Model Context Protocol team as a reference implementation for the M
|
|||
- [Sampling](https://modelcontextprotocol.io/docs/concepts/sampling)
|
||||
- [Transports](https://modelcontextprotocol.io/docs/concepts/transports)
|
||||
- [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
|
||||
- [Example Servers](https://github.com/modelcontextprotocol/servers)
|
||||
|
||||
### OAuth 2.0 / 2.1 Resources
|
||||
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
|
||||
- [OAuth 2.1 Draft](https://oauth.net/2.1/) - Modern security best practices
|
||||
- [OAuth.net: End User Authentication](https://oauth.net/articles/authentication/)
|
||||
- [The Resource Server - OAuth 2.0 Simplified](https://www.oauth.com/oauth2-servers/the-resource-server/)
|
||||
|
||||
### Architecture Patterns
|
||||
This implementation demonstrates patterns discussed in:
|
||||
- [Stack Overflow: Separating Resource and Authorization Servers](https://stackoverflow.com/questions/16228193/oauth-2-separating-resource-server-and-authorization-server)
|
||||
- [OAuth 2.0 Overview - Curity](https://curity.io/resources/learn/oauth-overview/)
|
||||
- Microsoft identity platform documentation on [OAuth 2.0 flows](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)
|
||||
|
|
@ -1,94 +1,123 @@
|
|||
# MCP Standalone Authorization Server
|
||||
# Auth Server - Demo OAuth 2.0 Provider
|
||||
|
||||
This is a demonstration OAuth 2.0 authorization server for MCP's separate authentication mode.
|
||||
Demonstration OAuth 2.0 authorization server representing commercial providers (Auth0, Okta, Google OAuth).
|
||||
|
||||
## Purpose
|
||||
|
||||
This server demonstrates how MCP servers can delegate authentication to a separate authorization server. See the main [README Authentication Modes](../README.md#authentication-modes) section for a complete overview of integrated vs separate modes.
|
||||
Complete OAuth 2.1 server implementing:
|
||||
- Client registration (Dynamic Client Registration)
|
||||
- Authorization flow with PKCE
|
||||
- Token issuance and refresh
|
||||
- Token introspection (RFC 7662)
|
||||
- User authentication (via demo upstream IDP)
|
||||
|
||||
In production environments, you would typically use established OAuth providers like:
|
||||
- Auth0, Okta
|
||||
- Google OAuth, GitHub OAuth
|
||||
- Microsoft Azure AD, AWS Cognito
|
||||
In production, replace this server with a commercial OAuth provider.
|
||||
|
||||
## Architecture
|
||||
## Quick Start
|
||||
|
||||
For detailed architecture information and OAuth flow analysis, see:
|
||||
- [Authentication Modes](../README.md#authentication-modes) - Overview and comparison
|
||||
- [OAuth 2.0 + PKCE Flow Analysis](../README.md#oauth-20--pkce-flow-analysis) - Step-by-step flow breakdown
|
||||
- [Authentication Architecture](../README.md#authentication-architecture) - Visual diagrams
|
||||
|
||||
This auth server specifically implements the "Auth Server" component in the separate mode architecture diagram.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata
|
||||
- `/authorize` - Authorization endpoint
|
||||
- `/token` - Token endpoint
|
||||
- `/register` - Dynamic client registration
|
||||
- `/introspect` - Token introspection (for MCP server validation)
|
||||
- `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only)
|
||||
- `/fakeupstreamauth/callback` - Fake upstream callback (demo only)
|
||||
- `/health` - Health check endpoint
|
||||
|
||||
## Development
|
||||
|
||||
This server shares Redis with the MCP server for development convenience.
|
||||
In production, these would typically be separate.
|
||||
|
||||
## Running the Auth Server
|
||||
|
||||
### Standalone
|
||||
```bash
|
||||
# From the repository root
|
||||
npm run dev:auth-server
|
||||
# From this directory:
|
||||
npm run dev
|
||||
|
||||
# Server starts on http://localhost:3001
|
||||
```
|
||||
|
||||
### With MCP Server (Separate Mode)
|
||||
## Flow
|
||||
|
||||
```
|
||||
MCP Client ──OAuth──> Auth Server ──tokens──> MCP Client ──MCP requests──> MCP Server
|
||||
│
|
||||
└──validate──> Auth Server /introspect
|
||||
```
|
||||
|
||||
1. MCP Client initiates OAuth flow with this server
|
||||
2. Server handles authorization and user authentication
|
||||
3. Server issues access tokens
|
||||
4. Client uses tokens to access MCP server
|
||||
5. MCP server validates tokens by calling this server's `/introspect` endpoint
|
||||
|
||||
## Key Components
|
||||
|
||||
### `src/index.ts`
|
||||
Main entry point. Sets up:
|
||||
- OAuth endpoints via SDK's `mcpAuthRouter`
|
||||
- Token introspection endpoint (`POST /introspect`)
|
||||
- Mock upstream auth UI for demo
|
||||
- Rate limiting and CORS
|
||||
|
||||
### `src/auth/provider.ts`
|
||||
**FeatureReferenceAuthProvider** - Implements OAuth server logic:
|
||||
- Client registration storage
|
||||
- Authorization code generation
|
||||
- PKCE challenge validation
|
||||
- Token issuance and refresh
|
||||
- Token verification
|
||||
|
||||
### `src/services/`
|
||||
- **auth.ts**: Auth service wrappers
|
||||
- **redis-auth.ts**: Redis operations for OAuth data
|
||||
|
||||
### `src/handlers/mock-upstream-idp.ts`
|
||||
Mock upstream identity provider. Simulates user authentication that would be handled by corporate SSO or social login in production OAuth providers.
|
||||
|
||||
## OAuth Endpoints
|
||||
|
||||
```bash
|
||||
# Start both servers together
|
||||
npm run dev:with-separate-auth
|
||||
# Discovery
|
||||
GET /.well-known/oauth-authorization-server
|
||||
|
||||
# Client registration
|
||||
POST /register
|
||||
|
||||
# Authorization
|
||||
GET /authorize?client_id=...&redirect_uri=...&code_challenge=...
|
||||
|
||||
# Token exchange
|
||||
POST /token
|
||||
|
||||
# Token introspection (called by MCP server)
|
||||
POST /introspect
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables in `.env`:
|
||||
```bash
|
||||
AUTH_SERVER_URL=http://localhost:3001
|
||||
AUTH_SERVER_PORT=3001
|
||||
BASE_URI=http://localhost:3232 # MCP server URL
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
npm test # 37 unit tests
|
||||
npm run lint # Lint code
|
||||
npm run typecheck # Type checking
|
||||
npm run build # Build to dist/
|
||||
```
|
||||
|
||||
### OAuth Metadata
|
||||
```bash
|
||||
curl http://localhost:3001/.well-known/oauth-authorization-server
|
||||
```
|
||||
## Production Usage
|
||||
|
||||
### With MCP Inspector
|
||||
See the main [Testing with MCP Inspector](../README.md#testing-with-mcp-inspector) section for complete testing instructions for both modes.
|
||||
This demo server should be replaced with a commercial OAuth provider in production.
|
||||
|
||||
**Quick test for this auth server:**
|
||||
1. Start this auth server: `npm run dev:auth-server`
|
||||
2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev`
|
||||
3. Follow the separate mode testing steps in the main README
|
||||
See [OAuth Architecture Patterns](../docs/oauth-architecture-patterns.md#using-a-commercial-auth-provider) for detailed integration guidance.
|
||||
|
||||
## Configuration
|
||||
**Supported providers:** Auth0, Okta, Azure AD, AWS Cognito, Google, GitHub, and any RFC 7662-compliant OAuth provider.
|
||||
|
||||
The auth server uses the same configuration system as the main server. See [Configuration](../README.md#configuration) in the main README for complete environment variable documentation.
|
||||
## Redis Data
|
||||
|
||||
**Auth server specific variables:**
|
||||
- `AUTH_SERVER_PORT` - Port to run on (default: 3001)
|
||||
- `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001)
|
||||
- `REDIS_URL` - Redis connection (shared with MCP server)
|
||||
This server stores in Redis:
|
||||
- `auth:client:{clientId}` - OAuth client registrations (30 day TTL)
|
||||
- `auth:pending:{code}` - Pending authorizations (10 min TTL)
|
||||
- `auth:installation:{token}` - Active installations (7 day TTL)
|
||||
- `auth:refresh:{refreshToken}` - Refresh token mappings (7 day TTL)
|
||||
- `auth:exch:{code}` - Token exchanges (10 min TTL)
|
||||
|
||||
## Production Considerations
|
||||
## Related Documentation
|
||||
|
||||
**This server is for demonstration only.** In production, use established OAuth providers.
|
||||
|
||||
For comprehensive security and deployment guidance, see:
|
||||
- [Security](../README.md#security) - Security measures and best practices
|
||||
- [Configuration](../README.md#configuration) - Environment setup
|
||||
- [Monitoring & Debugging](../README.md#monitoring--debugging) - Operational guidance
|
||||
|
||||
**Production replacement options:**
|
||||
- Corporate SSO (Auth0, Okta)
|
||||
- Cloud providers (AWS Cognito, Azure AD)
|
||||
- Social providers (Google OAuth, GitHub OAuth)
|
||||
- [Main README](../README.md) - Complete project documentation
|
||||
- [MCP Server README](../mcp-server/README.md) - How MCP server uses these tokens
|
||||
- [OAuth Architecture Patterns](../docs/oauth-architecture-patterns.md) - OAuth integration options
|
||||
- [OAuth Flow](../docs/oauth-flow.md) - Detailed OAuth flow analysis
|
||||
|
|
|
|||
19
auth-server/eslint.config.mjs
Normal file
19
auth-server/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error",
|
||||
{ "argsIgnorePattern": "^_" }
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
19
auth-server/jest.config.js
Normal file
19
auth-server/jest.config.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
export default {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/scratch/'],
|
||||
injectGlobals: true,
|
||||
};
|
||||
7141
auth-server/package-lock.json
generated
Normal file
7141
auth-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
auth-server/package.json
Normal file
43
auth-server/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "mcp-server-feature-reference-external-auth-server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP Feature Reference Server - Standalone Authorization Server",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch --inspect src/index.ts",
|
||||
"dev:break": "tsx --inspect-brk watch src/index.ts",
|
||||
"build": "tsc && npm run copy-static",
|
||||
"copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"@redis/client": "^1.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"raw-body": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/content-type": "^1.1.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.0.2"
|
||||
}
|
||||
}
|
||||
87
auth-server/src/auth/auth-core.ts
Normal file
87
auth-server/src/auth/auth-core.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import crypto from "crypto";
|
||||
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
/**
|
||||
* Generates a PKCE code challenge from a verifier string.
|
||||
* Uses S256 method as specified in RFC 7636.
|
||||
* @param verifier The code verifier string
|
||||
* @returns Base64url-encoded SHA256 hash of the verifier
|
||||
*/
|
||||
export function generatePKCEChallenge(verifier: string): string {
|
||||
const buffer = Buffer.from(verifier);
|
||||
const hash = crypto.createHash("sha256").update(buffer);
|
||||
return hash.digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random token.
|
||||
* @returns 64-character hexadecimal string
|
||||
*/
|
||||
export function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA256 hash of input data.
|
||||
* @param data The string to hash
|
||||
* @returns Hexadecimal representation of the hash
|
||||
*/
|
||||
export function sha256(data: string): string {
|
||||
return crypto.createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string using AES-256-CBC encryption.
|
||||
* @param text The plaintext to encrypt
|
||||
* @param key The encryption key (64 hex characters)
|
||||
* @returns Encrypted string in format "iv:ciphertext"
|
||||
*/
|
||||
export function encryptString({ text, key }: { text: string; key: string }): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
|
||||
let encrypted = cipher.update(text, "utf-8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
return `${iv.toString("hex")}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a string encrypted with encryptString.
|
||||
* @param encryptedText The encrypted string in format "iv:ciphertext"
|
||||
* @param key The encryption key (64 hex characters)
|
||||
* @returns Decrypted plaintext
|
||||
*/
|
||||
export function decryptString({
|
||||
encryptedText,
|
||||
key,
|
||||
}: {
|
||||
encryptedText: string;
|
||||
key: string;
|
||||
}): string {
|
||||
const [ivHex, encrypted] = encryptedText.split(":");
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf-8");
|
||||
decrypted += decipher.final("utf-8");
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access token expiry time in seconds (1 hour)
|
||||
*/
|
||||
export const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60;
|
||||
|
||||
/**
|
||||
* Generates a complete set of MCP OAuth tokens.
|
||||
* @returns OAuth tokens with access token, refresh token, and expiry
|
||||
*/
|
||||
export function generateMcpTokens(): OAuthTokens {
|
||||
const mcpAccessToken = generateToken();
|
||||
const mcpRefreshToken = generateToken();
|
||||
|
||||
return {
|
||||
access_token: mcpAccessToken,
|
||||
refresh_token: mcpRefreshToken,
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SEC,
|
||||
token_type: "Bearer",
|
||||
};
|
||||
}
|
||||
412
auth-server/src/auth/provider.test.ts
Normal file
412
auth-server/src/auth/provider.test.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { jest, describe, beforeEach, it, expect } from '@jest/globals';
|
||||
import { Response } from "express";
|
||||
import { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
||||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
||||
import { MockRedisClient, setRedisClient } from "../redis.js";
|
||||
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
|
||||
import { FeatureReferenceAuthProvider, FeatureReferenceOAuthClientsStore } from "./provider.js";
|
||||
import * as authService from "../services/auth.js";
|
||||
|
||||
// Helper function to create sample client
|
||||
function createTestClient(): OAuthClientInformationFull {
|
||||
return {
|
||||
client_id: "test-client-id",
|
||||
client_name: "Test Client",
|
||||
client_uri: "https://example.com",
|
||||
redirect_uris: ["https://example.com/callback"]
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create a mock Response object
|
||||
function createMockResponse() {
|
||||
const res = {
|
||||
redirect: jest.fn().mockReturnThis(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis()
|
||||
};
|
||||
return res as unknown as jest.Mocked<Response>;
|
||||
}
|
||||
|
||||
|
||||
function getMockAuthValues() {
|
||||
const client = createTestClient();
|
||||
// Use properly generated tokens for encryption
|
||||
const accessToken = authService.generateToken();
|
||||
const newTokens: OAuthTokens = {
|
||||
access_token: authService.generateToken(),
|
||||
refresh_token: authService.generateToken(),
|
||||
token_type: "bearer",
|
||||
expires_in: 3600,
|
||||
};
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
refresh_token: authService.generateToken(),
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: client.client_id,
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
};
|
||||
|
||||
return {
|
||||
client,
|
||||
accessToken,
|
||||
newTokens,
|
||||
mcpInstallation,
|
||||
}
|
||||
}
|
||||
|
||||
describe("FeatureReferenceOAuthClientsStore", () => {
|
||||
let clientsStore: FeatureReferenceOAuthClientsStore;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
jest.resetAllMocks();
|
||||
clientsStore = new FeatureReferenceOAuthClientsStore();
|
||||
});
|
||||
|
||||
describe("getClient", () => {
|
||||
it("returns undefined for non-existent client", async () => {
|
||||
const result = await clientsStore.getClient("non-existent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns client information for existing client", async () => {
|
||||
const client = createTestClient();
|
||||
// First save the client
|
||||
await clientsStore.registerClient(client);
|
||||
|
||||
// Then retrieve it
|
||||
const result = await clientsStore.getClient(client.client_id);
|
||||
|
||||
expect(result).toEqual(client);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerClient", () => {
|
||||
it("saves and returns client information", async () => {
|
||||
const client = createTestClient();
|
||||
|
||||
const result = await clientsStore.registerClient(client);
|
||||
|
||||
expect(result).toEqual(client);
|
||||
|
||||
// Verify it was saved
|
||||
const retrieved = await clientsStore.getClient(client.client_id);
|
||||
expect(retrieved).toEqual(client);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeatureReferenceAuthProvider", () => {
|
||||
let provider: FeatureReferenceAuthProvider;
|
||||
let mockRedis: MockRedisClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
|
||||
provider = new FeatureReferenceAuthProvider();
|
||||
});
|
||||
|
||||
describe("authorize", () => {
|
||||
it("saves pending authorization and sends HTML response", async () => {
|
||||
const client = createTestClient();
|
||||
// Use a type assertion to make TypeScript ignore the mismatch
|
||||
const params = {
|
||||
redirectUri: "https://example.com/callback",
|
||||
codeChallenge: "test-challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
} as unknown as AuthorizationParams;
|
||||
const res = createMockResponse();
|
||||
|
||||
await provider.authorize(client, params, res);
|
||||
|
||||
// Verify HTML sent with redirect
|
||||
expect(res.send).toHaveBeenCalled();
|
||||
const sentHtml = (res.send as jest.Mock).mock.calls[0][0];
|
||||
expect(sentHtml).toContain('MCP Server Authorization');
|
||||
expect(sentHtml).toContain('Authorization Required');
|
||||
expect(sentHtml).toContain('mock-upstream-idp/authorize?redirect_uri=/mock-upstream-idp/callback&state=');
|
||||
});
|
||||
});
|
||||
|
||||
describe("challengeForAuthorizationCode", () => {
|
||||
it("returns code challenge for valid authorization code", async () => {
|
||||
const client = createTestClient();
|
||||
const pendingAuth: PendingAuthorization = {
|
||||
redirectUri: "https://example.com/callback",
|
||||
codeChallenge: "test-challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
clientId: client.client_id,
|
||||
};
|
||||
|
||||
// First save the pending authorization
|
||||
const authCode = authService.generateToken();
|
||||
await authService.savePendingAuthorization(authCode, pendingAuth);
|
||||
|
||||
const result = await provider.challengeForAuthorizationCode(client, authCode);
|
||||
|
||||
expect(result).toBe("test-challenge");
|
||||
});
|
||||
|
||||
it("throws error for non-existent authorization code", async () => {
|
||||
const client = createTestClient();
|
||||
|
||||
await expect(provider.challengeForAuthorizationCode(client, "test-code"))
|
||||
.rejects
|
||||
.toThrow("Authorization code not found");
|
||||
});
|
||||
|
||||
it("throws error when client ID doesn't match", async () => {
|
||||
const client = createTestClient();
|
||||
const pendingAuth: PendingAuthorization = {
|
||||
redirectUri: "https://example.com/callback",
|
||||
codeChallenge: "test-challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
clientId: "different-client-id",
|
||||
};
|
||||
|
||||
// Save pending auth with different client ID
|
||||
const authCode = authService.generateToken();
|
||||
await authService.savePendingAuthorization(authCode, pendingAuth);
|
||||
|
||||
await expect(provider.challengeForAuthorizationCode(client, authCode))
|
||||
.rejects
|
||||
.toThrow("Authorization code does not match client");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeAuthorizationCode", () => {
|
||||
it("returns tokens when exchange is successful", async () => {
|
||||
const client = createTestClient();
|
||||
const { mcpInstallation } = getMockAuthValues();
|
||||
|
||||
// Setup: save token exchange and installation
|
||||
const authCode = authService.generateToken();
|
||||
const tokenExchange: TokenExchange = {
|
||||
mcpAccessToken: mcpInstallation.mcpTokens.access_token,
|
||||
alreadyUsed: false,
|
||||
};
|
||||
await authService.saveTokenExchange(authCode, tokenExchange);
|
||||
await authService.saveMcpInstallation(mcpInstallation.mcpTokens.access_token, mcpInstallation);
|
||||
|
||||
const result = await provider.exchangeAuthorizationCode(client, authCode);
|
||||
|
||||
expect(result).toEqual({
|
||||
access_token: mcpInstallation.mcpTokens.access_token,
|
||||
expires_in: mcpInstallation.mcpTokens.expires_in,
|
||||
refresh_token: mcpInstallation.mcpTokens.refresh_token,
|
||||
token_type: "Bearer",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error for invalid authorization code", async () => {
|
||||
const client = createTestClient();
|
||||
|
||||
await expect(provider.exchangeAuthorizationCode(client, "test-code"))
|
||||
.rejects
|
||||
.toThrow("Invalid authorization code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeRefreshToken", () => {
|
||||
it("returns new tokens when refresh token is valid", async () => {
|
||||
const {
|
||||
client,
|
||||
accessToken,
|
||||
mcpInstallation,
|
||||
} = getMockAuthValues();
|
||||
|
||||
// Setup: save refresh token and installation
|
||||
const refreshToken = authService.generateToken();
|
||||
await authService.saveRefreshToken(refreshToken, accessToken);
|
||||
await authService.saveMcpInstallation(accessToken, mcpInstallation);
|
||||
|
||||
const result = await provider.exchangeRefreshToken(client, refreshToken);
|
||||
|
||||
// Should return new tokens
|
||||
expect(result).toHaveProperty('access_token');
|
||||
expect(result).toHaveProperty('refresh_token');
|
||||
expect(result).toHaveProperty('expires_in', 3600);
|
||||
expect(result).toHaveProperty('token_type', 'Bearer');
|
||||
});
|
||||
|
||||
it("throws error for invalid refresh token", async () => {
|
||||
const client = createTestClient();
|
||||
|
||||
await expect(provider.exchangeRefreshToken(client, "test-refresh-token"))
|
||||
.rejects
|
||||
.toThrow("Invalid refresh token");
|
||||
});
|
||||
|
||||
it("throws error for refresh token with no installation", async () => {
|
||||
const client = createTestClient();
|
||||
const refreshToken = authService.generateToken();
|
||||
const accessToken = authService.generateToken();
|
||||
|
||||
// Only save refresh token, not the installation
|
||||
await authService.saveRefreshToken(refreshToken, accessToken);
|
||||
|
||||
await expect(provider.exchangeRefreshToken(client, refreshToken))
|
||||
.rejects
|
||||
.toThrow("Invalid refresh token");
|
||||
});
|
||||
|
||||
it("throws error when client ID doesn't match", async () => {
|
||||
const client = createTestClient();
|
||||
const accessToken = authService.generateToken();
|
||||
const refreshToken = authService.generateToken();
|
||||
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: "different-client-id",
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
};
|
||||
|
||||
await authService.saveRefreshToken(refreshToken, accessToken);
|
||||
await authService.saveMcpInstallation(accessToken, mcpInstallation);
|
||||
|
||||
await expect(provider.exchangeRefreshToken(client, refreshToken))
|
||||
.rejects
|
||||
.toThrow("Invalid client");
|
||||
});
|
||||
|
||||
it("works correctly even when the refresh token has expired from Redis", async () => {
|
||||
const client = createTestClient();
|
||||
|
||||
// Simulate the refresh token not being found in Redis (expired)
|
||||
await expect(provider.exchangeRefreshToken(client, "expired-refresh-token"))
|
||||
.rejects
|
||||
.toThrow("Invalid refresh token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyAccessToken", () => {
|
||||
it("returns auth info for valid token", async () => {
|
||||
const accessToken = authService.generateToken();
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: "client-id",
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
};
|
||||
|
||||
await authService.saveMcpInstallation(accessToken, mcpInstallation);
|
||||
|
||||
const result = await provider.verifyAccessToken(accessToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: accessToken,
|
||||
clientId: mcpInstallation.clientId,
|
||||
scopes: ['mcp'],
|
||||
expiresAt: mcpInstallation.mcpTokens.expires_in! + mcpInstallation.issuedAt,
|
||||
extra: {
|
||||
userId: "test-user-id"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error for invalid token", async () => {
|
||||
await expect(provider.verifyAccessToken("invalid-token"))
|
||||
.rejects
|
||||
.toThrow("Invalid access token");
|
||||
});
|
||||
|
||||
it("throws InvalidTokenError for expired token", async () => {
|
||||
const accessToken = authService.generateToken();
|
||||
const oneDayInSeconds = 24 * 60 * 60;
|
||||
const twoDaysAgoInSeconds = Math.floor(Date.now() / 1000) - (2 * oneDayInSeconds);
|
||||
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: oneDayInSeconds,
|
||||
},
|
||||
clientId: "client-id",
|
||||
issuedAt: twoDaysAgoInSeconds, // 2 days ago, with 1-day expiry
|
||||
userId: "test-user-id",
|
||||
};
|
||||
|
||||
await authService.saveMcpInstallation(accessToken, mcpInstallation);
|
||||
|
||||
await expect(provider.verifyAccessToken(accessToken))
|
||||
.rejects
|
||||
.toThrow(InvalidTokenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeToken", () => {
|
||||
it("revokes the installation when given an access token", async () => {
|
||||
const client = createTestClient();
|
||||
const accessToken = authService.generateToken();
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: client.client_id,
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
};
|
||||
|
||||
// Save the installation
|
||||
await authService.saveMcpInstallation(accessToken, mcpInstallation);
|
||||
|
||||
// Verify it exists
|
||||
const saved = await authService.readMcpInstallation(accessToken);
|
||||
expect(saved).toBeTruthy();
|
||||
|
||||
// Revoke it
|
||||
const request: OAuthTokenRevocationRequest = {
|
||||
token: accessToken,
|
||||
token_type_hint: "access_token"
|
||||
};
|
||||
|
||||
await provider.revokeToken(client, request);
|
||||
|
||||
// Verify it was revoked
|
||||
const revoked = await authService.readMcpInstallation(accessToken);
|
||||
expect(revoked).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
378
auth-server/src/auth/provider.ts
Normal file
378
auth-server/src/auth/provider.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { Response } from 'express';
|
||||
import { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
|
||||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import {
|
||||
exchangeToken,
|
||||
generateToken,
|
||||
getClientRegistration,
|
||||
readPendingAuthorization,
|
||||
readMcpInstallation,
|
||||
revokeMcpInstallation,
|
||||
saveClientRegistration,
|
||||
savePendingAuthorization,
|
||||
readRefreshToken,
|
||||
generateMcpTokens,
|
||||
saveMcpInstallation,
|
||||
saveRefreshToken,
|
||||
} from '../services/auth.js';
|
||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Implementation of the OAuthRegisteredClientsStore interface using the existing client registration system
|
||||
*/
|
||||
export class FeatureReferenceOAuthClientsStore implements OAuthRegisteredClientsStore {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
const registration = await getClientRegistration(clientId);
|
||||
if (!registration) {
|
||||
return undefined;
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
|
||||
await saveClientRegistration(client.client_id, client);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the OAuthServerProvider interface for upstream authentication
|
||||
*/
|
||||
export class FeatureReferenceAuthProvider implements OAuthServerProvider {
|
||||
private _clientsStore: FeatureReferenceOAuthClientsStore;
|
||||
|
||||
constructor() {
|
||||
this._clientsStore = new FeatureReferenceOAuthClientsStore();
|
||||
}
|
||||
|
||||
get clientsStore(): OAuthRegisteredClientsStore {
|
||||
return this._clientsStore;
|
||||
}
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
|
||||
// Client is validated by the MCP sdk.
|
||||
|
||||
// Generate authorization code
|
||||
const authorizationCode = generateToken();
|
||||
|
||||
// Save the pending authorization with code challenge and state
|
||||
await savePendingAuthorization(authorizationCode, {
|
||||
redirectUri: params.redirectUri,
|
||||
codeChallenge: params.codeChallenge,
|
||||
codeChallengeMethod: 'S256', // Currently only support S256
|
||||
clientId: client.client_id,
|
||||
state: params.state,
|
||||
});
|
||||
|
||||
logger.debug('Saved pending authorization', {
|
||||
authorizationCode: authorizationCode.substring(0, 8) + '...',
|
||||
clientId: client.client_id,
|
||||
state: params.state?.substring(0, 8) + '...'
|
||||
});
|
||||
|
||||
// TODO: should we use a different key, other than the authorization code, to store the pending authorization?
|
||||
|
||||
// You can redirect to another page, or you can send an html response directly
|
||||
// res.redirect(new URL(`mock-upstream-idp/authorize?metadata=${authorizationCode}`, BASE_URI).href);
|
||||
|
||||
// Set permissive CSP for styling
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
"base-uri 'self'"
|
||||
].join('; '));
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Server Authorization</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(255, 255, 255, 0.1);
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.mcp-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #000000;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #4a5568;
|
||||
font-size: 18px;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.client-info h3 {
|
||||
color: #2d3748;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.client-id {
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.auth-flow-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
border-left: 4px solid #000000;
|
||||
}
|
||||
|
||||
.auth-flow-info h3 {
|
||||
color: #2d3748;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-flow-info p {
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
padding: 18px 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #333333;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.branding {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="logo-container">
|
||||
<img src="/mcp-logo.png" alt="MCP Logo" class="logo">
|
||||
<div class="mcp-title">MCP</div>
|
||||
</div>
|
||||
|
||||
<h1>Authorization Required</h1>
|
||||
<p class="subtitle">This client wants to connect to your MCP server</p>
|
||||
|
||||
<div class="client-info">
|
||||
<h3>Client Application</h3>
|
||||
<div class="client-id">${client.client_id}</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-flow-info">
|
||||
<h3>What happens next?</h3>
|
||||
<p>You'll be redirected to authenticate with the upstream provider. Once verified, you'll be granted access to this MCP server's resources.</p>
|
||||
</div>
|
||||
|
||||
<a href="/mock-upstream-idp/authorize?redirect_uri=/mock-upstream-idp/callback&state=${authorizationCode}" class="btn-primary">
|
||||
Continue to Authentication
|
||||
</a>
|
||||
|
||||
<div class="branding">
|
||||
Model Context Protocol (MCP) Server
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
||||
const pendingAuth = await readPendingAuthorization(authorizationCode);
|
||||
if (!pendingAuth) {
|
||||
throw new Error('Authorization code not found');
|
||||
}
|
||||
|
||||
if (pendingAuth.clientId !== client.client_id) {
|
||||
throw new Error('Authorization code does not match client');
|
||||
}
|
||||
|
||||
return pendingAuth.codeChallenge;
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
|
||||
const tokenData = await exchangeToken(authorizationCode);
|
||||
if (!tokenData) {
|
||||
throw new Error('Invalid authorization code');
|
||||
}
|
||||
|
||||
// Get the MCP installation to retrieve the full token data including refresh token
|
||||
const mcpInstallation = await readMcpInstallation(tokenData.mcpAccessToken);
|
||||
if (!mcpInstallation) {
|
||||
throw new Error('Failed to retrieve MCP installation');
|
||||
}
|
||||
|
||||
// Return the full token data including refresh token
|
||||
return {
|
||||
access_token: mcpInstallation.mcpTokens.access_token,
|
||||
refresh_token: mcpInstallation.mcpTokens.refresh_token,
|
||||
expires_in: mcpInstallation.mcpTokens.expires_in,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
}
|
||||
|
||||
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, _scopes?: string[]): Promise<OAuthTokens> {
|
||||
const accessToken = await readRefreshToken(refreshToken);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Invalid refresh token');
|
||||
}
|
||||
|
||||
const mcpInstallation = await readMcpInstallation(accessToken);
|
||||
|
||||
if (!mcpInstallation) {
|
||||
throw new Error('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Check the client_id
|
||||
if (mcpInstallation.clientId !== client.client_id) {
|
||||
throw new Error('Invalid client');
|
||||
}
|
||||
|
||||
const newTokens = generateMcpTokens();
|
||||
|
||||
if (newTokens.refresh_token) {
|
||||
await saveRefreshToken(newTokens.refresh_token, newTokens.access_token);
|
||||
}
|
||||
|
||||
// Update the installation with the new tokens
|
||||
await saveMcpInstallation(newTokens.access_token, {
|
||||
...mcpInstallation,
|
||||
mcpTokens: newTokens,
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: mcpInstallation.userId, // Preserve the user ID
|
||||
});
|
||||
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const installation = await readMcpInstallation(token);
|
||||
if (!installation) {
|
||||
throw new InvalidTokenError("Invalid access token");
|
||||
}
|
||||
|
||||
const expiresAt = (
|
||||
installation.mcpTokens.expires_in
|
||||
? installation.mcpTokens.expires_in + installation.issuedAt
|
||||
: undefined
|
||||
);
|
||||
|
||||
// This can be removed once in the SDK
|
||||
// Check if the token is expired
|
||||
if (!!expiresAt && expiresAt < Date.now() / 1000) {
|
||||
throw new InvalidTokenError("Token has expired");
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId: installation.clientId,
|
||||
scopes: ['mcp'],
|
||||
expiresAt,
|
||||
extra: {
|
||||
userId: installation.userId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
await revokeMcpInstallation(request.token);
|
||||
}
|
||||
}
|
||||
33
auth-server/src/config.ts
Normal file
33
auth-server/src/config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Port for the MCP server to listen on
|
||||
*/
|
||||
export const PORT = Number(process.env.PORT) || 3232;
|
||||
|
||||
/**
|
||||
* Base URI for the MCP server. Used for OAuth callbacks and metadata.
|
||||
* Should match the port if specified separately.
|
||||
*/
|
||||
export const BASE_URI = process.env.BASE_URI || `http://localhost:${PORT}`;
|
||||
|
||||
// Validate PORT and BASE_URI consistency
|
||||
const baseUrl = new URL(BASE_URI);
|
||||
if (baseUrl.port && parseInt(baseUrl.port) !== PORT) {
|
||||
console.warn(`Warning: BASE_URI port (${baseUrl.port}) doesn't match PORT (${PORT})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis connection URL
|
||||
*/
|
||||
export const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
|
||||
/**
|
||||
* Port for this authorization server
|
||||
*/
|
||||
export const AUTH_SERVER_PORT = parseInt(process.env.AUTH_SERVER_PORT || '3001');
|
||||
|
||||
/**
|
||||
* URL where this authorization server is hosted
|
||||
*/
|
||||
export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || `http://localhost:${AUTH_SERVER_PORT}`;
|
||||
382
auth-server/src/handlers/mock-upstream-idp.ts
Normal file
382
auth-server/src/handlers/mock-upstream-idp.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
import { Request, Response } from "express";
|
||||
import { generateMcpTokens, readPendingAuthorization, saveMcpInstallation, saveRefreshToken, saveTokenExchange } from "../services/auth.js";
|
||||
import { McpInstallation } from "../types.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK UPSTREAM IDENTITY PROVIDER - FOR DEMONSTRATION ONLY
|
||||
* ============================================================================
|
||||
*
|
||||
* This file simulates what happens when an OAuth server delegates user
|
||||
* authentication to an external identity provider. In production, this would be:
|
||||
*
|
||||
* - Google OAuth (accounts.google.com)
|
||||
* - GitHub OAuth (github.com/login)
|
||||
* - Corporate SSO (SAML, LDAP, Active Directory)
|
||||
* - Auth0/Okta user database
|
||||
*
|
||||
* The mock implementation:
|
||||
* - Shows a user selection UI
|
||||
* - Generates random user IDs for testing
|
||||
* - Simulates the redirect flow back to the OAuth server
|
||||
*
|
||||
* In production, users would see their actual identity provider's login page
|
||||
* (Google's login, GitHub's login, corporate SSO portal, etc.)
|
||||
*
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mock authorization endpoint - simulates external IDP login page
|
||||
* In production, this would be replaced by redirecting to:
|
||||
* - https://accounts.google.com/oauth/authorize (Google)
|
||||
* - https://github.com/login/oauth/authorize (GitHub)
|
||||
* - https://login.microsoftonline.com (Azure AD)
|
||||
* - Your corporate SSO login page
|
||||
*/
|
||||
export async function handleMockUpstreamAuthorize(req: Request, res: Response) {
|
||||
// get the redirect_uri and state from the query params
|
||||
const { redirect_uri, state } = req.query;
|
||||
|
||||
// Set a more permissive CSP for auth pages to allow inline styles and scripts
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles for auth page styling
|
||||
"script-src 'self' 'unsafe-inline'", // Allow inline scripts for auth page functionality
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
"base-uri 'self'"
|
||||
].join('; '));
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Upstream Provider Authentication</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 16px;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #1a202c;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
background: #f7fafc;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-section h3 {
|
||||
color: #2d3748;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-id-display {
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 16px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #4299e1, #3182ce);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
padding: 16px 32px;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #3182ce, #2c5282);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin-top: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-text strong {
|
||||
color: #4a5568;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="logo">🔒</div>
|
||||
<h1>Upstream Authentication</h1>
|
||||
<p class="subtitle">Please verify your identity with the upstream provider</p>
|
||||
|
||||
<div class="user-section">
|
||||
<h3>Your User Identity</h3>
|
||||
<div class="user-id-display" id="userIdDisplay">Loading...</div>
|
||||
<div class="user-actions">
|
||||
<button class="btn btn-secondary" onclick="generateNewUserId()">Generate New ID</button>
|
||||
<button class="btn btn-secondary" onclick="editUserId()">Edit ID</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="authorize()">
|
||||
Complete Authentication
|
||||
</button>
|
||||
|
||||
<div class="help-text">
|
||||
<strong>Testing Multiple Users:</strong> Open this page in different browser windows or incognito tabs to simulate different users. Each will have their own unique User ID and separate MCP sessions.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Generate UUID v4
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create user ID
|
||||
function getUserId() {
|
||||
let userId = localStorage.getItem('mcpUserId');
|
||||
if (!userId) {
|
||||
userId = generateUUID();
|
||||
localStorage.setItem('mcpUserId', userId);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
// Update the display
|
||||
function updateDisplay() {
|
||||
const userId = getUserId();
|
||||
document.getElementById('userIdDisplay').textContent = userId;
|
||||
}
|
||||
|
||||
// Generate new user ID
|
||||
function generateNewUserId() {
|
||||
const newId = generateUUID();
|
||||
localStorage.setItem('mcpUserId', newId);
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
// Edit user ID
|
||||
function editUserId() {
|
||||
const currentId = getUserId();
|
||||
const newId = prompt('Enter new User ID:', currentId);
|
||||
if (newId && newId.trim()) {
|
||||
localStorage.setItem('mcpUserId', newId.trim());
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize with current user ID
|
||||
function authorize() {
|
||||
const userId = getUserId();
|
||||
// Handle relative URLs by making them absolute
|
||||
const redirectUri = '${redirect_uri}';
|
||||
const baseUrl = redirectUri.startsWith('http') ? redirectUri : window.location.origin + redirectUri;
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('state', '${state}');
|
||||
url.searchParams.set('code', 'mock-auth-code');
|
||||
url.searchParams.set('userId', userId);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
updateDisplay();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
// This is the callback URL that the upstream auth server will redirect to after authorization
|
||||
export async function handleMockUpstreamCallback(req: Request, res: Response) {
|
||||
const {
|
||||
// The state returned from the upstream auth server is actually the authorization code
|
||||
state: mcpAuthorizationCode,
|
||||
code: upstreamAuthorizationCode,
|
||||
userId, // User ID from the authorization flow
|
||||
} = req.query;
|
||||
|
||||
logger.debug('Mock upstream IDP callback received', {
|
||||
mcpAuthorizationCode: typeof mcpAuthorizationCode === 'string' ? mcpAuthorizationCode.substring(0, 8) + '...' : mcpAuthorizationCode,
|
||||
upstreamAuthorizationCode: typeof upstreamAuthorizationCode === 'string' ? upstreamAuthorizationCode.substring(0, 8) + '...' : upstreamAuthorizationCode,
|
||||
userId
|
||||
});
|
||||
|
||||
// This is where you'd exchange the upstreamAuthorizationCode for access/refresh tokens
|
||||
// In this case, we're just going to fake it
|
||||
const upstreamTokens = await mockUpstreamTokenExchange(upstreamAuthorizationCode as string);
|
||||
|
||||
// Validate that it's a string
|
||||
if (typeof mcpAuthorizationCode !== "string") {
|
||||
throw new Error("Invalid authorization code");
|
||||
}
|
||||
|
||||
const pendingAuth = await readPendingAuthorization(mcpAuthorizationCode);
|
||||
logger.debug('Reading pending authorization', {
|
||||
mcpAuthorizationCode: mcpAuthorizationCode.substring(0, 8) + '...',
|
||||
found: !!pendingAuth
|
||||
});
|
||||
|
||||
if (!pendingAuth) {
|
||||
throw new Error("No matching authorization found");
|
||||
}
|
||||
|
||||
logger.debug('Generating MCP tokens');
|
||||
const mcpTokens = generateMcpTokens();
|
||||
logger.debug('MCP tokens generated', {
|
||||
hasAccessToken: !!mcpTokens.access_token,
|
||||
hasRefreshToken: !!mcpTokens.refresh_token
|
||||
});
|
||||
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: upstreamTokens.access_token,
|
||||
mockUpstreamRefreshToken: upstreamTokens.refresh_token,
|
||||
},
|
||||
mcpTokens,
|
||||
clientId: pendingAuth.clientId,
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: (userId as string) || 'anonymous-user', // Include user ID from auth flow
|
||||
}
|
||||
|
||||
logger.debug('Saving MCP installation');
|
||||
// Store the upstream authorization data
|
||||
await saveMcpInstallation(mcpTokens.access_token, mcpInstallation);
|
||||
logger.debug('MCP installation saved');
|
||||
|
||||
// Store the refresh token -> access token mapping
|
||||
if (mcpTokens.refresh_token) {
|
||||
logger.debug('Saving refresh token mapping');
|
||||
await saveRefreshToken(mcpTokens.refresh_token, mcpTokens.access_token);
|
||||
logger.debug('Refresh token mapping saved');
|
||||
}
|
||||
|
||||
logger.debug('Saving token exchange data');
|
||||
// Store the token exchange data
|
||||
await saveTokenExchange(mcpAuthorizationCode, {
|
||||
mcpAccessToken: mcpTokens.access_token,
|
||||
alreadyUsed: false,
|
||||
});
|
||||
logger.debug('Token exchange data saved');
|
||||
|
||||
// Redirect back to the original application with the authorization code and state
|
||||
const redirectUrl = pendingAuth.state ?
|
||||
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}&state=${pendingAuth.state}` :
|
||||
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}`;
|
||||
|
||||
logger.debug('Redirecting to callback', {
|
||||
redirectUrl,
|
||||
hasState: !!pendingAuth.state
|
||||
});
|
||||
res.redirect(redirectUrl);
|
||||
logger.debug('Redirect completed');
|
||||
};
|
||||
|
||||
function mockUpstreamTokenExchange(
|
||||
authorizationCode: string,
|
||||
): Promise<{ access_token: string; refresh_token: string }> {
|
||||
// just return the authorization code with a suffix
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
access_token: `${authorizationCode}-exchanged-for-access-token`,
|
||||
refresh_token: `${authorizationCode}-exchanged-for-refresh-token`,
|
||||
});
|
||||
});
|
||||
}
|
||||
165
auth-server/src/index.ts
Normal file
165
auth-server/src/index.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { FeatureReferenceAuthProvider } from './auth/provider.js';
|
||||
import { handleMockUpstreamAuthorize, handleMockUpstreamCallback } from './handlers/mock-upstream-idp.js';
|
||||
import { redisClient } from './redis.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import { AUTH_SERVER_PORT, AUTH_SERVER_URL, BASE_URI } from './config.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
console.log('=====================================');
|
||||
console.log('MCP Demonstration Authorization Server');
|
||||
console.log('=====================================');
|
||||
console.log('This standalone server demonstrates OAuth 2.0');
|
||||
console.log('authorization for the MCP resource server');
|
||||
console.log('');
|
||||
console.log('This is for demonstration purposes only.');
|
||||
console.log('In production, you would use a real OAuth provider');
|
||||
console.log('like Auth0, Okta, Google, GitHub, etc.');
|
||||
console.log('=====================================');
|
||||
|
||||
// CORS for Inspector and MCP server
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(logger.middleware());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
mode: 'authorization-server',
|
||||
endpoints: {
|
||||
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
|
||||
authorize: `${AUTH_SERVER_URL}/authorize`,
|
||||
token: `${AUTH_SERVER_URL}/token`,
|
||||
register: `${AUTH_SERVER_URL}/register`,
|
||||
introspect: `${AUTH_SERVER_URL}/introspect`
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create auth provider instance for reuse
|
||||
const authProvider = new FeatureReferenceAuthProvider();
|
||||
|
||||
// OAuth endpoints via SDK's mcpAuthRouter
|
||||
app.use(mcpAuthRouter({
|
||||
provider: authProvider,
|
||||
issuerUrl: new URL(AUTH_SERVER_URL),
|
||||
tokenOptions: {
|
||||
rateLimit: { windowMs: 5000, limit: 100 }
|
||||
},
|
||||
clientRegistrationOptions: {
|
||||
rateLimit: { windowMs: 60000, limit: 10 }
|
||||
}
|
||||
}));
|
||||
|
||||
// Rate limiting for custom endpoints
|
||||
const introspectRateLimit = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
limit: 100, // 100 introspections per 5 minutes
|
||||
message: { error: 'too_many_requests', error_description: 'Token introspection rate limit exceeded' }
|
||||
});
|
||||
|
||||
const mockUpstreamIdpRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 20, // 20 auth attempts per minute
|
||||
message: { error: 'too_many_requests', error_description: 'Authentication rate limit exceeded' }
|
||||
});
|
||||
|
||||
const staticFileRateLimit = rateLimit({
|
||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||
limit: 25, // 25 requests per 10 minutes for static files
|
||||
message: { error: 'too_many_requests', error_description: 'Static file rate limit exceeded' }
|
||||
});
|
||||
|
||||
// Token introspection endpoint (RFC 7662)
|
||||
app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: false }), async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'Missing token parameter' });
|
||||
}
|
||||
|
||||
// Verify the token using the auth provider
|
||||
const authInfo = await authProvider.verifyAccessToken(token);
|
||||
|
||||
// Return RFC 7662 compliant response
|
||||
res.json({
|
||||
active: true,
|
||||
client_id: authInfo.clientId,
|
||||
scope: authInfo.scopes.join(' '),
|
||||
exp: authInfo.expiresAt,
|
||||
sub: authInfo.extra?.userId || 'unknown',
|
||||
userId: authInfo.extra?.userId, // Custom field for our implementation
|
||||
username: authInfo.extra?.username,
|
||||
iss: AUTH_SERVER_URL,
|
||||
aud: BASE_URI, // The resource server URL this token is intended for
|
||||
token_type: 'Bearer'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.debug('Token introspection failed', { error: (error as Error).message });
|
||||
|
||||
// Return inactive token response (don't leak error details)
|
||||
res.json({
|
||||
active: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fake upstream auth endpoints (for user authentication simulation)
|
||||
app.get('/mock-upstream-idp/authorize', mockUpstreamIdpRateLimit, cors(), handleMockUpstreamAuthorize);
|
||||
app.get('/mock-upstream-idp/callback', mockUpstreamIdpRateLimit, cors(), handleMockUpstreamCallback);
|
||||
|
||||
// Static assets (for auth page styling)
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
app.get('/mcp-logo.png', staticFileRateLimit, (req, res) => {
|
||||
const logoPath = path.join(__dirname, 'static/mcp.png');
|
||||
res.sendFile(logoPath);
|
||||
});
|
||||
|
||||
// Connect to Redis (shared with MCP server in dev)
|
||||
try {
|
||||
await redisClient.connect();
|
||||
logger.info('Connected to Redis', { url: redisClient.options?.url });
|
||||
} catch (error) {
|
||||
logger.error('Could not connect to Redis', error as Error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(AUTH_SERVER_PORT, () => {
|
||||
logger.info('Authorization server started', {
|
||||
port: AUTH_SERVER_PORT,
|
||||
url: AUTH_SERVER_URL,
|
||||
endpoints: {
|
||||
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
|
||||
authorize: `${AUTH_SERVER_URL}/authorize`,
|
||||
token: `${AUTH_SERVER_URL}/token`,
|
||||
register: `${AUTH_SERVER_URL}/register`,
|
||||
introspect: `${AUTH_SERVER_URL}/introspect`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('🚀 Auth server ready! Test with:');
|
||||
console.log(` curl ${AUTH_SERVER_URL}/health`);
|
||||
console.log(` curl ${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`);
|
||||
console.log('');
|
||||
console.log('💡 To test with MCP server:');
|
||||
console.log(' 1. Keep this server running');
|
||||
console.log(' 2. In another terminal: cd ../mcp-server && npm run dev');
|
||||
console.log(' 3. Connect Inspector to http://localhost:3232/mcp');
|
||||
});
|
||||
289
auth-server/src/redis.ts
Normal file
289
auth-server/src/redis.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { createClient, SetOptions } from "@redis/client";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import { REDIS_URL } from "./config.js";
|
||||
|
||||
/**
|
||||
* Describes the Redis primitives we use in this application, to be able to mock
|
||||
* them in tests (so we don't need to actually hit Redis).
|
||||
*/
|
||||
export interface RedisClient {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: SetOptions): Promise<string | null>;
|
||||
getDel(key: string): Promise<string | null>;
|
||||
del(key: string): Promise<number>;
|
||||
expire(key: string, seconds: number): Promise<boolean>;
|
||||
lpush(key: string, ...values: string[]): Promise<number>;
|
||||
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
||||
connect(): Promise<void>;
|
||||
on(event: string, callback: (error: Error) => void): void;
|
||||
options?: { url: string };
|
||||
exists(key: string): Promise<boolean>;
|
||||
numsub(key: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Creates a pub/sub subscription. Returns a cleanup function to unsubscribe.
|
||||
* Handles Redis client duplication and error handling internally.
|
||||
*/
|
||||
createSubscription(
|
||||
channel: string,
|
||||
onMessage: (message: string) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<() => Promise<void>>;
|
||||
|
||||
/**
|
||||
* Publishes a message to a channel.
|
||||
*/
|
||||
publish(channel: string, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class RedisClientImpl implements RedisClient {
|
||||
private redis = createClient({
|
||||
url: REDIS_URL,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
socket: {
|
||||
tls: process.env.REDIS_TLS === "1",
|
||||
ca: process.env.REDIS_TLS_CA,
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.redis.on("error", (error) =>
|
||||
logger.error("Redis client error", error as Error),
|
||||
);
|
||||
}
|
||||
|
||||
async numsub(key: string): Promise<number> {
|
||||
const subs = await this.redis.pubSubNumSub(key);
|
||||
return subs[key] || 0;
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return await this.redis.get(key);
|
||||
}
|
||||
|
||||
async getDel(key: string): Promise<string | null> {
|
||||
return await this.redis.getDel(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, options?: SetOptions): Promise<string | null> {
|
||||
return await this.redis.set(
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
return await this.redis.del(key);
|
||||
}
|
||||
|
||||
async expire(key: string, seconds: number): Promise<boolean> {
|
||||
return await this.redis.expire(key, seconds);
|
||||
}
|
||||
|
||||
async lpush(key: string, ...values: string[]): Promise<number> {
|
||||
return await this.redis.lPush(key, values);
|
||||
}
|
||||
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return await this.redis.lRange(key, start, stop);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.redis.connect();
|
||||
}
|
||||
|
||||
on(event: string, callback: (error: Error) => void): void {
|
||||
this.redis.on(event, callback);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return { url: REDIS_URL };
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
channel: string,
|
||||
onMessage: (message: string) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
const subscriber = this.redis.duplicate();
|
||||
subscriber.on("error", (error) => {
|
||||
onError(error);
|
||||
});
|
||||
|
||||
await subscriber.connect();
|
||||
|
||||
await subscriber.subscribe(channel, (message) => {
|
||||
onMessage(message);
|
||||
});
|
||||
|
||||
return async () => {
|
||||
await subscriber.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
await this.redis.publish(channel, message);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.redis.exists(key);
|
||||
return result > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a mutable reference that can be swapped in tests
|
||||
export let redisClient: RedisClient = new RedisClientImpl();
|
||||
|
||||
// Function to replace the Redis client (used in tests)
|
||||
export function setRedisClient(client: RedisClient) {
|
||||
redisClient = client;
|
||||
}
|
||||
|
||||
export class MockRedisClient implements RedisClient {
|
||||
options = { url: "redis://localhost:6379" };
|
||||
private store = new Map<string, string>();
|
||||
private lists = new Map<string, string[]>();
|
||||
public subscribers = new Map<string, ((message: string) => void)[]>(); // Public for testing access
|
||||
private errorCallbacks = new Map<string, ((error: Error) => void)[]>();
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
async getDel(key: string): Promise<string | null> {
|
||||
const value = this.store.get(key) ?? null;
|
||||
this.store.delete(key);
|
||||
return value;
|
||||
}
|
||||
|
||||
async set(key: string, value: string, options?: SetOptions): Promise<string | null> {
|
||||
let oldValue: string | null = null;
|
||||
if (options?.GET) {
|
||||
oldValue = this.store.get(key) ?? null;
|
||||
}
|
||||
this.store.set(key, value);
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
let deleted = 0;
|
||||
if (this.store.has(key)) {
|
||||
this.store.delete(key);
|
||||
deleted++;
|
||||
}
|
||||
if (this.lists.has(key)) {
|
||||
this.lists.delete(key);
|
||||
deleted++;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async expire(key: string, _seconds: number): Promise<boolean> {
|
||||
// Mock implementation - just return true if key exists
|
||||
return this.store.has(key) || this.lists.has(key);
|
||||
}
|
||||
|
||||
async lpush(key: string, ...values: string[]): Promise<number> {
|
||||
const list = this.lists.get(key) || [];
|
||||
list.unshift(...values);
|
||||
this.lists.set(key, list);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
const list = this.lists.get(key) || [];
|
||||
if (stop === -1) {
|
||||
return list.slice(start);
|
||||
}
|
||||
return list.slice(start, stop + 1);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// No-op in mock
|
||||
}
|
||||
|
||||
on(event: string, callback: (error: Error) => void): void {
|
||||
if (event === "error") {
|
||||
const callbacks = this.errorCallbacks.get("global") ?? [];
|
||||
callbacks.push(callback);
|
||||
this.errorCallbacks.set("global", callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
channel: string,
|
||||
onMessage: (message: string) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
const callbacks = this.subscribers.get(channel) ?? [];
|
||||
callbacks.push(onMessage);
|
||||
this.subscribers.set(channel, callbacks);
|
||||
|
||||
const errorCallbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
errorCallbacks.push(onError);
|
||||
this.errorCallbacks.set(channel, errorCallbacks);
|
||||
|
||||
return async () => {
|
||||
const callbacks = this.subscribers.get(channel) ?? [];
|
||||
const index = callbacks.indexOf(onMessage);
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
this.subscribers.set(channel, callbacks);
|
||||
|
||||
if (onError) {
|
||||
const errorCallbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
const errorIndex = errorCallbacks.indexOf(onError);
|
||||
if (errorIndex !== -1) {
|
||||
errorCallbacks.splice(errorIndex, 1);
|
||||
}
|
||||
this.errorCallbacks.set(channel, errorCallbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
const callbacks = this.subscribers.get(channel) ?? [];
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (error) {
|
||||
const errorCallbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
for (const errorCallback of errorCallbacks) {
|
||||
errorCallback(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
return this.store.has(key) || this.lists.has(key);
|
||||
}
|
||||
|
||||
async numsub(key: string): Promise<number> {
|
||||
return (this.subscribers.get(key) || []).length;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
this.lists.clear();
|
||||
this.subscribers.clear();
|
||||
this.errorCallbacks.clear();
|
||||
}
|
||||
|
||||
// Helper method for tests to simulate Redis errors
|
||||
simulateError(error: Error, channel?: string): void {
|
||||
if (channel) {
|
||||
const callbacks = this.errorCallbacks.get(channel) ?? [];
|
||||
for (const callback of callbacks) {
|
||||
callback(error);
|
||||
}
|
||||
} else {
|
||||
const callbacks = this.errorCallbacks.get("global") ?? [];
|
||||
for (const callback of callbacks) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
292
auth-server/src/services/auth.test.ts
Normal file
292
auth-server/src/services/auth.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { jest } from '@jest/globals';
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
decryptString,
|
||||
exchangeToken,
|
||||
generateMcpTokens,
|
||||
generatePKCEChallenge,
|
||||
generateToken,
|
||||
getClientRegistration,
|
||||
readMcpInstallation,
|
||||
readPendingAuthorization,
|
||||
readRefreshToken,
|
||||
revokeMcpInstallation,
|
||||
saveClientRegistration,
|
||||
saveMcpInstallation,
|
||||
savePendingAuthorization,
|
||||
saveRefreshToken,
|
||||
saveTokenExchange,
|
||||
} from "./auth.js";
|
||||
import { MockRedisClient, setRedisClient } from "../redis.js";
|
||||
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
|
||||
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
|
||||
describe("auth utils", () => {
|
||||
let mockRedis: MockRedisClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = new MockRedisClient();
|
||||
setRedisClient(mockRedis);
|
||||
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("generateToken", () => {
|
||||
it("generates a 64-character hex string", () => {
|
||||
const token = generateToken();
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("generates unique tokens", () => {
|
||||
const token1 = generateToken();
|
||||
const token2 = generateToken();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMcpTokens", () => {
|
||||
it("generates valid token structure", () => {
|
||||
const tokens = generateMcpTokens();
|
||||
|
||||
expect(tokens.access_token).toBeDefined();
|
||||
expect(tokens.access_token).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
expect(tokens.refresh_token).toBeDefined();
|
||||
expect(tokens.refresh_token).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
expect(tokens.token_type).toBe("Bearer");
|
||||
expect(tokens.expires_in).toBeDefined();
|
||||
});
|
||||
|
||||
it("generates unique tokens", () => {
|
||||
const tokens1 = generateMcpTokens();
|
||||
const tokens2 = generateMcpTokens();
|
||||
|
||||
expect(tokens1.access_token).not.toBe(tokens2.access_token);
|
||||
expect(tokens1.refresh_token).not.toBe(tokens2.refresh_token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client registration", () => {
|
||||
it("saves and retrieves client registration", async () => {
|
||||
const clientId = "test-client-id";
|
||||
const registration: OAuthClientInformationFull = {
|
||||
client_id: clientId,
|
||||
client_name: "Test Client",
|
||||
client_uri: "https://example.com",
|
||||
redirect_uris: ["https://example.com/callback"]
|
||||
};
|
||||
|
||||
await saveClientRegistration(clientId, registration);
|
||||
const retrieved = await getClientRegistration(clientId);
|
||||
|
||||
expect(retrieved).toEqual(registration);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent client", async () => {
|
||||
const result = await getClientRegistration("non-existent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending authorization", () => {
|
||||
it("saves and retrieves pending authorization", async () => {
|
||||
const authCode = generateToken();
|
||||
const pendingAuth: PendingAuthorization = {
|
||||
redirectUri: "https://example.com/callback",
|
||||
codeChallenge: "test-challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
clientId: "test-client-id"
|
||||
};
|
||||
|
||||
await savePendingAuthorization(authCode, pendingAuth);
|
||||
const retrieved = await readPendingAuthorization(authCode);
|
||||
|
||||
expect(retrieved).toEqual(pendingAuth);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent code", async () => {
|
||||
const result = await readPendingAuthorization("non-existent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("token exchange", () => {
|
||||
it("saves and retrieves token exchange data", async () => {
|
||||
const authCode = generateToken();
|
||||
const tokenExchange: TokenExchange = {
|
||||
mcpAccessToken: generateToken(),
|
||||
alreadyUsed: false
|
||||
};
|
||||
|
||||
// For this test, we'll directly manipulate Redis to check the token
|
||||
// instead of using exchangeToken which changes the value
|
||||
await saveTokenExchange(authCode, tokenExchange);
|
||||
|
||||
// Get the key used by saveTokenExchange (now with auth: prefix)
|
||||
const key = "auth:exch:" + crypto.createHash("sha256").update(authCode).digest("hex");
|
||||
|
||||
// Get the encrypted data
|
||||
const encryptedData = await mockRedis.get(key);
|
||||
expect(encryptedData).not.toBeNull();
|
||||
|
||||
// Decrypt it manually to verify
|
||||
const decoded = JSON.parse(decryptString({
|
||||
encryptedText: encryptedData!,
|
||||
key: authCode
|
||||
}));
|
||||
|
||||
expect(decoded).toEqual(tokenExchange);
|
||||
});
|
||||
|
||||
it("prevents duplicate token exchange", async () => {
|
||||
const authCode = generateToken();
|
||||
const tokenExchange: TokenExchange = {
|
||||
mcpAccessToken: generateToken(),
|
||||
alreadyUsed: false
|
||||
};
|
||||
|
||||
await saveTokenExchange(authCode, tokenExchange);
|
||||
|
||||
// First exchange succeeds
|
||||
const first = await exchangeToken(authCode);
|
||||
expect(first).toBeDefined();
|
||||
|
||||
// Mock console.error to suppress expected error message
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Second exchange throws
|
||||
await expect(exchangeToken(authCode)).rejects.toThrow(
|
||||
"Duplicate use of authorization code detected"
|
||||
);
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent code", async () => {
|
||||
const result = await exchangeToken("non-existent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP installation", () => {
|
||||
it("returns undefined for missing tokens", async () => {
|
||||
const accessToken = generateToken();
|
||||
const result = await readMcpInstallation(accessToken);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves and retrieves installation data", async () => {
|
||||
const accessToken = generateToken();
|
||||
|
||||
const mcpInstallation: McpInstallation = {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: "client-id",
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
await saveMcpInstallation(accessToken, mcpInstallation);
|
||||
|
||||
const result = await readMcpInstallation(accessToken);
|
||||
expect(result).toEqual(mcpInstallation);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh token", () => {
|
||||
it("saves and retrieves refresh token mapping", async () => {
|
||||
const refreshToken = generateToken();
|
||||
const accessToken = generateToken();
|
||||
|
||||
await saveRefreshToken(refreshToken, accessToken);
|
||||
const retrieved = await readRefreshToken(refreshToken);
|
||||
|
||||
expect(retrieved).toBe(accessToken);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent refresh token", async () => {
|
||||
const result = await readRefreshToken("non-existent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeMcpInstallation", () => {
|
||||
it("revokes token for valid installation", async () => {
|
||||
// For this test, we'll simply test if the WebClient is called with our mocked token
|
||||
|
||||
// Create mock installation
|
||||
const accessToken = generateToken();
|
||||
|
||||
// Save it to Redis with actual function
|
||||
await saveMcpInstallation(accessToken, {
|
||||
mockUpstreamInstallation: {
|
||||
mockUpstreamAccessToken: "fake-upstream-access-token",
|
||||
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
|
||||
},
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: "client-id",
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
});
|
||||
|
||||
const getDel = jest.spyOn(mockRedis, 'getDel').mockImplementationOnce(() => {
|
||||
// Need to return encrypted data for successful decryption in the revoke function
|
||||
// Create encrypted data using our access token
|
||||
const mcpInstallation = {
|
||||
mcpTokens: {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
},
|
||||
clientId: "client-id",
|
||||
issuedAt: Date.now() / 1000,
|
||||
userId: "test-user-id",
|
||||
};
|
||||
const value = JSON.stringify(mcpInstallation);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(accessToken, "hex"), iv);
|
||||
let encrypted = cipher.update(value, "utf-8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
return Promise.resolve(`${iv.toString("hex")}:${encrypted}`);
|
||||
});
|
||||
|
||||
await revokeMcpInstallation(accessToken);
|
||||
|
||||
// Should have called getDel with the correct key (now auth:installation:)
|
||||
expect(getDel).toHaveBeenCalledWith(expect.stringContaining("auth:installation:"));
|
||||
|
||||
});
|
||||
|
||||
it("handles non-existent installation without error", async () => {
|
||||
await expect(revokeMcpInstallation("non-existent")).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePKCEChallenge", () => {
|
||||
it("generates base64url-encoded SHA256 hash", () => {
|
||||
const verifier = "test_verifier";
|
||||
const challenge = generatePKCEChallenge(verifier);
|
||||
expect(challenge).toBe("0Ku4rR8EgR1w3HyHLBCxVLtPsAAks5HOlpmTEt0XhVA");
|
||||
});
|
||||
|
||||
it("generates different challenges for different verifiers", () => {
|
||||
const challenge1 = generatePKCEChallenge("verifier1");
|
||||
const challenge2 = generatePKCEChallenge("verifier2");
|
||||
expect(challenge1).not.toBe(challenge2);
|
||||
});
|
||||
});
|
||||
});
|
||||
86
auth-server/src/services/auth.ts
Normal file
86
auth-server/src/services/auth.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { redisClient } from "../redis.js";
|
||||
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
|
||||
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// Re-export from auth-core module
|
||||
export {
|
||||
generatePKCEChallenge,
|
||||
generateToken,
|
||||
decryptString,
|
||||
generateMcpTokens
|
||||
} from "../auth/auth-core.js";
|
||||
|
||||
import * as sharedRedisAuth from "./redis-auth.js";
|
||||
|
||||
// Wrapper functions that pass redisClient to shared module functions
|
||||
|
||||
export async function saveClientRegistration(
|
||||
clientId: string,
|
||||
registration: OAuthClientInformationFull,
|
||||
) {
|
||||
return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration);
|
||||
}
|
||||
|
||||
export async function getClientRegistration(
|
||||
clientId: string,
|
||||
): Promise<OAuthClientInformationFull | undefined> {
|
||||
return sharedRedisAuth.getClientRegistration(redisClient, clientId);
|
||||
}
|
||||
|
||||
export async function savePendingAuthorization(
|
||||
authorizationCode: string,
|
||||
pendingAuthorization: PendingAuthorization,
|
||||
) {
|
||||
return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization);
|
||||
}
|
||||
|
||||
export async function readPendingAuthorization(
|
||||
authorizationCode: string,
|
||||
): Promise<PendingAuthorization | undefined> {
|
||||
return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode);
|
||||
}
|
||||
|
||||
export async function saveMcpInstallation(
|
||||
mcpAccessToken: string,
|
||||
installation: McpInstallation,
|
||||
) {
|
||||
return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation);
|
||||
}
|
||||
|
||||
export async function readMcpInstallation(
|
||||
mcpAccessToken: string,
|
||||
): Promise<McpInstallation | undefined> {
|
||||
return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken);
|
||||
}
|
||||
|
||||
export async function saveRefreshToken(
|
||||
refreshToken: string,
|
||||
mcpAccessToken: string,
|
||||
) {
|
||||
return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken);
|
||||
}
|
||||
|
||||
export async function readRefreshToken(
|
||||
refreshToken: string,
|
||||
): Promise<string | undefined> {
|
||||
return sharedRedisAuth.readRefreshToken(redisClient, refreshToken);
|
||||
}
|
||||
|
||||
export async function revokeMcpInstallation(
|
||||
mcpAccessToken: string,
|
||||
): Promise<void> {
|
||||
return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken);
|
||||
}
|
||||
|
||||
export async function saveTokenExchange(
|
||||
authorizationCode: string,
|
||||
tokenExchange: TokenExchange,
|
||||
) {
|
||||
return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange);
|
||||
}
|
||||
|
||||
export async function exchangeToken(
|
||||
authorizationCode: string,
|
||||
): Promise<TokenExchange | undefined> {
|
||||
return sharedRedisAuth.exchangeToken(redisClient, authorizationCode);
|
||||
}
|
||||
285
auth-server/src/services/redis-auth.ts
Normal file
285
auth-server/src/services/redis-auth.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { SetOptions } from "@redis/client";
|
||||
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { RedisClient } from "../redis.js";
|
||||
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
|
||||
import { sha256, encryptString, decryptString } from "../auth/auth-core.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Redis key prefixes for different data types
|
||||
* All auth-related keys use "auth:" prefix to avoid collision with MCP session keys
|
||||
*/
|
||||
export const REDIS_KEY_PREFIXES = {
|
||||
CLIENT_REGISTRATION: "auth:client:",
|
||||
PENDING_AUTHORIZATION: "auth:pending:",
|
||||
MCP_AUTHORIZATION: "auth:installation:", // Changed from "mcp:" to avoid collision
|
||||
TOKEN_EXCHANGE: "auth:exch:",
|
||||
REFRESH_TOKEN: "auth:refresh:",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Redis key expiry times in seconds
|
||||
*/
|
||||
export const REDIS_EXPIRY_TIMES = {
|
||||
CLIENT_REGISTRATION: 30 * 24 * 60 * 60, // 30 days - client app credentials
|
||||
PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization
|
||||
TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token
|
||||
UPSTREAM_INSTALLATION: 7 * 24 * 60 * 60, // 7 days - MCP access token -> UpstreamInstallation
|
||||
REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Saves encrypted data to Redis with optional expiry.
|
||||
*/
|
||||
async function saveEncrypted<T>(
|
||||
redisClient: RedisClient,
|
||||
{
|
||||
prefix,
|
||||
key,
|
||||
data,
|
||||
options,
|
||||
}: {
|
||||
prefix: string;
|
||||
key: string;
|
||||
data: T;
|
||||
options?: SetOptions;
|
||||
}
|
||||
): Promise<string | null> {
|
||||
const value = encryptString({
|
||||
text: JSON.stringify(data),
|
||||
key: key,
|
||||
});
|
||||
|
||||
return await redisClient.set(prefix + sha256(key), value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and decrypts data from Redis.
|
||||
*/
|
||||
async function readEncrypted<T>(
|
||||
redisClient: RedisClient,
|
||||
{
|
||||
prefix,
|
||||
key,
|
||||
del = false,
|
||||
}: {
|
||||
prefix: string;
|
||||
key: string;
|
||||
del?: boolean;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
const data = del
|
||||
? await redisClient.getDel(prefix + sha256(key))
|
||||
: await redisClient.get(prefix + sha256(key));
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const decoded = decryptString({
|
||||
encryptedText: data,
|
||||
key: key,
|
||||
});
|
||||
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a client registration to Redis.
|
||||
*/
|
||||
export async function saveClientRegistration(
|
||||
redisClient: RedisClient,
|
||||
clientId: string,
|
||||
registration: OAuthClientInformationFull
|
||||
): Promise<void> {
|
||||
await redisClient.set(
|
||||
REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId,
|
||||
JSON.stringify(registration),
|
||||
{ EX: REDIS_EXPIRY_TIMES.CLIENT_REGISTRATION }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a client registration from Redis.
|
||||
*/
|
||||
export async function getClientRegistration(
|
||||
redisClient: RedisClient,
|
||||
clientId: string
|
||||
): Promise<OAuthClientInformationFull | undefined> {
|
||||
const data = await redisClient.get(REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a pending authorization to Redis.
|
||||
*/
|
||||
export async function savePendingAuthorization(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string,
|
||||
pendingAuthorization: PendingAuthorization
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
|
||||
key: authorizationCode,
|
||||
data: pendingAuthorization,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.PENDING_AUTHORIZATION },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a pending authorization from Redis.
|
||||
*/
|
||||
export async function readPendingAuthorization(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string
|
||||
): Promise<PendingAuthorization | undefined> {
|
||||
return readEncrypted<PendingAuthorization>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
|
||||
key: authorizationCode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an MCP installation to Redis.
|
||||
*/
|
||||
export async function saveMcpInstallation(
|
||||
redisClient: RedisClient,
|
||||
mcpAccessToken: string,
|
||||
installation: McpInstallation
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
|
||||
key: mcpAccessToken,
|
||||
data: installation,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.UPSTREAM_INSTALLATION },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an MCP installation from Redis.
|
||||
*/
|
||||
export async function readMcpInstallation(
|
||||
redisClient: RedisClient,
|
||||
mcpAccessToken: string
|
||||
): Promise<McpInstallation | undefined> {
|
||||
return readEncrypted<McpInstallation>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
|
||||
key: mcpAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a refresh token to an MCP access token.
|
||||
*/
|
||||
export async function saveRefreshToken(
|
||||
redisClient: RedisClient,
|
||||
refreshToken: string,
|
||||
mcpAccessToken: string
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
|
||||
key: refreshToken,
|
||||
data: mcpAccessToken,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.REFRESH_TOKEN },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the access token associated with a refresh token.
|
||||
*/
|
||||
export async function readRefreshToken(
|
||||
redisClient: RedisClient,
|
||||
refreshToken: string
|
||||
): Promise<string | undefined> {
|
||||
return readEncrypted<string>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
|
||||
key: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an MCP installation.
|
||||
*/
|
||||
export async function revokeMcpInstallation(
|
||||
redisClient: RedisClient,
|
||||
mcpAccessToken: string
|
||||
): Promise<void> {
|
||||
const installation = await readEncrypted<McpInstallation>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
|
||||
key: mcpAccessToken,
|
||||
del: true,
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
return;
|
||||
}
|
||||
// In production, would revoke upstream tokens here
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a token exchange record.
|
||||
*/
|
||||
export async function saveTokenExchange(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string,
|
||||
tokenExchange: TokenExchange
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
|
||||
key: authorizationCode,
|
||||
data: tokenExchange,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.TOKEN_EXCHANGE },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a temporary authorization code for an MCP access token.
|
||||
* Will only succeed the first time to prevent replay attacks.
|
||||
*/
|
||||
export async function exchangeToken(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string
|
||||
): Promise<TokenExchange | undefined> {
|
||||
const data = await redisClient.get(
|
||||
REDIS_KEY_PREFIXES.TOKEN_EXCHANGE + sha256(authorizationCode)
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const decoded = decryptString({
|
||||
encryptedText: data,
|
||||
key: authorizationCode,
|
||||
});
|
||||
|
||||
const tokenExchange: TokenExchange = JSON.parse(decoded);
|
||||
if (tokenExchange.alreadyUsed) {
|
||||
logger.error('Duplicate use of authorization code detected; revoking tokens', undefined, {
|
||||
authorizationCode: authorizationCode.substring(0, 8) + '...'
|
||||
});
|
||||
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
|
||||
throw new Error("Duplicate use of authorization code detected; tokens revoked");
|
||||
}
|
||||
|
||||
const rereadData = await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
|
||||
key: authorizationCode,
|
||||
data: { ...tokenExchange, alreadyUsed: true },
|
||||
options: { KEEPTTL: true, GET: true },
|
||||
});
|
||||
|
||||
if (rereadData !== data) {
|
||||
// Data concurrently changed while we were updating it. This necessarily means a duplicate use.
|
||||
logger.error('Duplicate use of authorization code detected (concurrent update); revoking tokens', undefined, {
|
||||
authorizationCode: authorizationCode.substring(0, 8) + '...'
|
||||
});
|
||||
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
|
||||
throw new Error("Duplicate use of authorization code detected; tokens revoked");
|
||||
}
|
||||
|
||||
return tokenExchange;
|
||||
}
|
||||
97
auth-server/src/static/index.html
Normal file
97
auth-server/src/static/index.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Example Server</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<img src="/mcp-logo.png" alt="MCP Logo" class="logo">
|
||||
<h1>MCP Example Server</h1>
|
||||
</header>
|
||||
|
||||
<p class="tagline">
|
||||
A comprehensive reference implementation of the Model Context Protocol (MCP) server
|
||||
demonstrating all protocol features with full authentication support and horizontal scalability.
|
||||
</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<h3>Complete MCP Support</h3>
|
||||
<p>All MCP features including tools, resources, prompts, sampling, completions, and logging with full protocol compliance.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Multiple Transports</h3>
|
||||
<p>Streamable HTTP (SHTTP) and Server-Sent Events (SSE) transports for flexible client integration.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>OAuth 2.0 Authentication</h3>
|
||||
<p>Complete OAuth flow with PKCE support and a built-in fake provider for testing and development.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Horizontal Scalability</h3>
|
||||
<p>Redis-backed session management enables multi-instance deployments with automatic load distribution.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>7 Demo Tools</h3>
|
||||
<p>Echo, add, long-running operations, LLM sampling, image handling, annotations, and resource references.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>100+ Resources</h3>
|
||||
<p>Example resources with pagination, templates, subscriptions, and real-time update notifications.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoints">
|
||||
<h2>API Endpoints</h2>
|
||||
<div class="endpoint-list">
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<span>/mcp - Initialize sessions or send messages (Streamable HTTP)</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span>/mcp - Establish SSE streams (Streamable HTTP)</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method delete">DELETE</span>
|
||||
<span>/mcp - Terminate sessions (Streamable HTTP)</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span>/sse - Legacy SSE transport endpoint</span>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<span>/message - Legacy message endpoint for SSE transport</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="https://github.com/modelcontextprotocol/example-remote-server" class="link-button">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub Repository
|
||||
</a>
|
||||
<a href="https://modelcontextprotocol.io" class="link-button secondary">
|
||||
MCP Documentation
|
||||
</a>
|
||||
<a href="https://modelcontextprotocol.io/specification/2025-06-18" class="link-button secondary">
|
||||
Protocol Specification
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Built by the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> team
|
||||
as a reference implementation for the MCP ecosystem.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
BIN
auth-server/src/static/mcp.png
Normal file
BIN
auth-server/src/static/mcp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue