Remove embedded OAuth implementation and flatten directory structure
Simplified the repository to have a single OAuth pattern with separate auth and MCP servers at the top level. Preserved embedded OAuth documentation as an alternative pattern.
This commit is contained in:
parent
c637d39f8d
commit
c03a9b4e77
89 changed files with 1556 additions and 15846 deletions
594
README.md
594
README.md
|
|
@ -1,35 +1,24 @@
|
|||
# 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 Feature Reference 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?
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This repository contains **two complete, standalone implementations** to help you learn different MCP deployment patterns:
|
||||
|
||||
```
|
||||
embedded-oauth/ # Complete example: MCP server with integrated OAuth
|
||||
└── src/ # All code in one place, ready to run
|
||||
|
||||
external-oauth/
|
||||
├── auth-server/ # Standalone OAuth authorization server
|
||||
│ └── src/
|
||||
└── mcp-server/ # MCP server that delegates to external auth
|
||||
└── src/
|
||||
```
|
||||
|
||||
Both modes are complete, independently buildable implementations with full test coverage. For more information on the difference between embedded and external OAuth, see [Authentication Modes](#authentication-modes).
|
||||
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
|
||||
|
||||
|
|
@ -48,9 +37,7 @@ npm install # Install dependencies for all workspaces
|
|||
|
||||
# 3. Start services
|
||||
docker compose up -d # Start Redis
|
||||
npm run dev:embedded # Start embedded-oauth server
|
||||
# OR
|
||||
npm run dev:external # Start both auth-server and mcp-server
|
||||
npm run dev # Start both auth-server and mcp-server
|
||||
|
||||
# 4. Test with Inspector
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
|
@ -59,19 +46,146 @@ npx -y @modelcontextprotocol/inspector
|
|||
|
||||
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
|
||||
└── src/ # Authorization endpoints and token management
|
||||
|
||||
mcp-server/ # MCP resource server
|
||||
└── 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 existing OAuth providers (Auth0, Okta, etc.) or deploy your own authorization server.
|
||||
|
||||
## 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-patterns.md](docs/oauth-patterns.md) for alternative architectures.
|
||||
|
||||
## 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)
|
||||
|
|
@ -96,10 +210,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
|
||||
|
||||
|
|
@ -138,25 +252,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 # Installs dependencies for all workspaces (embedded-oauth, auth-server, mcp-server)
|
||||
npm install # Installs dependencies for all workspaces
|
||||
```
|
||||
|
||||
### Step 3: Choose Your Mode
|
||||
The repository includes two complete, standalone examples:
|
||||
|
||||
**Option A: Embedded OAuth (Simpler)**
|
||||
```bash
|
||||
cd embedded-oauth
|
||||
# Everything is configured by default in .env
|
||||
```
|
||||
|
||||
**Option B: External OAuth (Production-like)**
|
||||
```bash
|
||||
cd external-oauth/auth-server # Auth server configuration in .env
|
||||
cd external-oauth/mcp-server # MCP server configuration in .env
|
||||
```
|
||||
|
||||
### Step 4: Start Redis
|
||||
### Step 3: Start Redis
|
||||
```bash
|
||||
# Ensure Docker/OrbStack is running first!
|
||||
docker compose up -d
|
||||
|
|
@ -165,33 +264,21 @@ docker compose up -d
|
|||
docker compose ps
|
||||
```
|
||||
|
||||
### Step 5: Start and Verify
|
||||
|
||||
**Embedded OAuth:**
|
||||
### Step 4: Start the Servers
|
||||
```bash
|
||||
npm run dev:embedded
|
||||
# Server starts on http://localhost:3232
|
||||
```
|
||||
# Start both servers concurrently
|
||||
npm run dev
|
||||
|
||||
**External OAuth:**
|
||||
```bash
|
||||
npm run dev:external
|
||||
# Auth server starts on http://localhost:3001
|
||||
# MCP server starts 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
|
||||
|
||||
Each mode has its own `.env` file pre-configured:
|
||||
Each server has its own `.env` file:
|
||||
|
||||
**Embedded OAuth** (`embedded-oauth/.env`):
|
||||
```bash
|
||||
BASE_URI=http://localhost:3232 # MCP server URL
|
||||
PORT=3232 # MCP server port
|
||||
REDIS_URL=redis://localhost:6379 # Redis connection
|
||||
```
|
||||
|
||||
**External OAuth Auth Server** (`external-oauth/auth-server/.env`):
|
||||
**Auth Server** (`auth-server/.env`):
|
||||
```bash
|
||||
AUTH_SERVER_URL=http://localhost:3001 # Auth server URL
|
||||
AUTH_SERVER_PORT=3001 # Auth server port
|
||||
|
|
@ -199,7 +286,7 @@ BASE_URI=http://localhost:3232 # MCP server URL (for redirects)
|
|||
REDIS_URL=redis://localhost:6379 # Redis connection
|
||||
```
|
||||
|
||||
**External OAuth MCP Server** (`external-oauth/mcp-server/.env`):
|
||||
**MCP Server** (`mcp-server/.env`):
|
||||
```bash
|
||||
BASE_URI=http://localhost:3232 # MCP server URL
|
||||
PORT=3232 # MCP server port
|
||||
|
|
@ -207,158 +294,12 @@ AUTH_SERVER_URL=http://localhost:3001 # External auth server URL
|
|||
REDIS_URL=redis://localhost:6379 # Redis connection
|
||||
```
|
||||
|
||||
## Authentication Modes
|
||||
## Architecture
|
||||
|
||||
Per the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization), the authorization server may be hosted with the resource server or as a separate entity. This repository demonstrates both patterns.
|
||||
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.
|
||||
|
||||
### Embedded OAuth
|
||||
**Location**: `embedded-oauth/`
|
||||
### System Architecture
|
||||
|
||||
OAuth authorization server hosted with the MCP resource server. Demonstrates self-hosted OAuth with upstream identity provider delegation (enterprise pattern where you control OAuth but delegate user authentication to corporate SSO/LDAP).
|
||||
|
||||
```bash
|
||||
npm run dev:embedded # Single server on :3232
|
||||
```
|
||||
|
||||
See [embedded-oauth/README.md](embedded-oauth/README.md) for details.
|
||||
|
||||
### External OAuth
|
||||
**Location**: `external-oauth/`
|
||||
|
||||
MCP resource server using a completely separate OAuth authorization server. Demonstrates the OAuth-as-a-Service pattern (modern SaaS pattern using Auth0, Okta, or similar providers).
|
||||
|
||||
```bash
|
||||
npm run dev:external # Auth server on :3001, MCP server on :3232
|
||||
```
|
||||
|
||||
See [external-oauth/README.md](external-oauth/README.md) for architecture and implementation details.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start servers
|
||||
npm run dev:embedded # Embedded OAuth
|
||||
npm run dev:external # External OAuth (both servers)
|
||||
npm run dev:auth-server # External OAuth - auth server only
|
||||
npm run dev:mcp-server # External OAuth - MCP server only
|
||||
```
|
||||
|
||||
### Build & Production
|
||||
```bash
|
||||
npm run build # Build all workspaces
|
||||
npm run build:embedded # Build embedded OAuth only
|
||||
npm run build:external # Build external OAuth only
|
||||
|
||||
npm run start:embedded # Run embedded OAuth
|
||||
npm run start:external # Run external OAuth (both servers)
|
||||
```
|
||||
|
||||
### Testing & Quality
|
||||
```bash
|
||||
npm test # All unit tests (189 total)
|
||||
npm run lint # Lint all code
|
||||
npm run typecheck # Typecheck all code
|
||||
|
||||
npm run test:e2e # Run all e2e tests
|
||||
npm run test:e2e:embedded # E2E test for embedded OAuth
|
||||
npm run test:e2e:external # E2E test for external OAuth
|
||||
```
|
||||
|
||||
### 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 Embedded OAuth
|
||||
```bash
|
||||
# 1. Start the server (Redis must already be running)
|
||||
npm run dev:embedded
|
||||
|
||||
# 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 External OAuth
|
||||
```bash
|
||||
# 1. Start both servers (Redis must already be running)
|
||||
npm run dev:external
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
- **Unit Tests**: Component testing (189 tests across all workspaces)
|
||||
- **Integration Tests**: Transport and Redis integration
|
||||
- **E2E Tests**: Complete OAuth + MCP feature verification
|
||||
|
||||
### End-to-End Testing
|
||||
|
||||
E2E scripts in `scripts/` verify complete OAuth flows and MCP features:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:embedded # Test embedded OAuth
|
||||
npm run test:e2e:external # Test external OAuth
|
||||
npm run test:e2e # Run both
|
||||
```
|
||||
|
||||
Scripts automatically manage server lifecycle, test OAuth 2.0 + PKCE flow, and verify all MCP features.
|
||||
|
||||
## 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"**
|
||||
- Each mode directory has a pre-configured `.env` file
|
||||
- embedded-oauth/.env, external-oauth/auth-server/.env, external-oauth/mcp-server/.env
|
||||
|
||||
**"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 server logs for error messages
|
||||
- Ensure Redis is running: `docker compose ps`
|
||||
- Verify .env configuration in the mode directory you're running
|
||||
|
||||
## Architecture & Technical Details
|
||||
|
||||
### Authentication Architecture
|
||||
|
||||
#### Embedded OAuth
|
||||
```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
|
||||
```
|
||||
|
||||
#### External OAuth
|
||||
```mermaid
|
||||
graph TD
|
||||
Client["MCP Client<br/>(Inspector)"]
|
||||
|
|
@ -371,65 +312,179 @@ graph TD
|
|||
MCP <-->|"Token validation<br/>(introspect)"| Auth
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
For alternative patterns like embedded OAuth, see [docs/oauth-patterns.md](docs/oauth-patterns.md).
|
||||
|
||||
### OAuth Flow
|
||||
|
||||
Both modes implement OAuth 2.1 with PKCE. For detailed flow analysis including data storage, TTLs, and mode-specific differences, see [docs/oauth-flow.md](docs/oauth-flow.md).
|
||||
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).
|
||||
|
||||
### Understanding the Mock Identity Provider
|
||||
|
||||
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
|
||||
```
|
||||
├── embedded-oauth/ # Complete integrated auth example
|
||||
├── auth-server/ # OAuth authorization server
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Main entry point (MCP + OAuth)
|
||||
│ │ ├── index.ts # Auth server entry point
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── provider.ts # FeatureReferenceAuthProvider
|
||||
│ │ │ └── auth-core.ts # Token generation, PKCE
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── mcp.ts # MCP server implementation
|
||||
│ │ │ ├── auth.ts # Auth service wrappers
|
||||
│ │ │ ├── redis-auth.ts # Redis auth operations
|
||||
│ │ │ └── redisTransport.ts # Redis-backed transport
|
||||
│ │ ├── handlers/ # SHTTP, SSE, mock-upstream-idp handlers
|
||||
│ │ ├── utils/logger.ts # Structured logging
|
||||
│ │ └── ...
|
||||
│ │ ├── services/ # Auth services, redis-auth
|
||||
│ │ ├── handlers/ # Mock upstream auth
|
||||
│ │ └── utils/logger.ts # Structured logging
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env
|
||||
│
|
||||
├── external-oauth/
|
||||
│ ├── auth-server/ # Standalone OAuth server
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── index.ts # Auth server entry point
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ │ ├── provider.ts # FeatureReferenceAuthProvider
|
||||
│ │ │ │ └── auth-core.ts
|
||||
│ │ │ ├── services/ # Auth services, redis-auth
|
||||
│ │ │ ├── handlers/ # Mock upstream auth
|
||||
│ │ │ └── ...
|
||||
│ │ ├── package.json
|
||||
│ │ └── .env
|
||||
│ │
|
||||
│ └── mcp-server/ # MCP server with external auth
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # MCP server entry point
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── external-verifier.ts # Token introspection
|
||||
│ │ │ └── auth-core.ts
|
||||
│ │ ├── services/ # MCP server, redisTransport
|
||||
│ │ ├── handlers/ # SHTTP, SSE handlers
|
||||
│ │ └── ...
|
||||
│ ├── package.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/ # End-to-end testing scripts
|
||||
│ ├── test-integrated-e2e.sh # OAuth + feature verification (integrated)
|
||||
│ └── test-separate-e2e.sh # OAuth + feature verification (separate)
|
||||
├── 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
|
||||
├── package.json # Root workspace configuration
|
||||
└── docker-compose.yml # Redis service
|
||||
│ ├── endpoints.md # API endpoint reference
|
||||
│ ├── oauth-flow.md # OAuth flow documentation
|
||||
│ ├── oauth-patterns.md # Alternative OAuth patterns
|
||||
│ └── 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"
|
||||
- **Cause**: Incorrect URL or missing path
|
||||
- **Solution**:
|
||||
- Use full URL: `http://localhost:3232/mcp` (not just `:3232`)
|
||||
- Include the `/mcp` path for MCP endpoints
|
||||
- Ensure you have a valid Bearer token in the Authorization header
|
||||
|
||||
### "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 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:
|
||||
|
|
@ -465,7 +520,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
|
||||
|
||||
|
|
@ -476,8 +531,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
|
||||
|
|
@ -487,15 +540,16 @@ Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2
|
|||
|
||||
## API Reference
|
||||
|
||||
For a complete listing of all endpoints provided by each server configuration, including OAuth authorization endpoints, MCP resource endpoints, and demo identity provider endpoints, see [docs/endpoints.md](docs/endpoints.md).
|
||||
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
|
||||
|
|
@ -587,7 +641,7 @@ Built by the Model Context Protocol team as a reference implementation for the M
|
|||
|
||||
### 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) (used in separate mode)
|
||||
- [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/)
|
||||
|
|
|
|||
124
auth-server/README.md
Normal file
124
auth-server/README.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Auth Server - Demo OAuth 2.0 Provider
|
||||
|
||||
Demonstration OAuth 2.0 authorization server representing commercial providers (Auth0, Okta, Google OAuth).
|
||||
|
||||
## Purpose
|
||||
|
||||
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, replace this server with a commercial OAuth provider.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# From this directory:
|
||||
npm run dev
|
||||
|
||||
# Server starts on http://localhost:3001
|
||||
```
|
||||
|
||||
## 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
|
||||
# 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
|
||||
|
||||
```bash
|
||||
npm test # 37 unit tests
|
||||
npm run lint # Lint code
|
||||
npm run typecheck # Type checking
|
||||
npm run build # Build to dist/
|
||||
```
|
||||
|
||||
## Production Replacement
|
||||
|
||||
Commercial OAuth providers supporting RFC 7662 introspection:
|
||||
- Auth0, Okta, Azure AD/Microsoft Entra
|
||||
- AWS Cognito, Google Identity Platform
|
||||
- GitHub OAuth
|
||||
|
||||
The MCP server integrates with any RFC 7662-compliant provider.
|
||||
|
||||
## Redis Data
|
||||
|
||||
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)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Main README](../README.md) - Complete project documentation
|
||||
- [MCP Server README](../mcp-server/README.md) - How MCP server uses these tokens
|
||||
- [OAuth Patterns](../docs/oauth-patterns.md) - OAuth architecture patterns
|
||||
- [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 separate from 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 |
188
auth-server/src/static/styles.css
Normal file
188
auth-server/src/static/styles.css
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #000000;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #000000;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.25rem;
|
||||
color: #666666;
|
||||
margin-bottom: 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border: 2px solid #000000;
|
||||
padding: 1.5rem;
|
||||
background: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.endpoints {
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #000000;
|
||||
padding: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.endpoints h2 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #ffffff;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #000000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.method {
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.method.get { color: #0066cc; }
|
||||
.method.post { color: #009900; }
|
||||
.method.delete { color: #cc0000; }
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 2rem;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid #000000;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.link-button.secondary {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.link-button.secondary:hover {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #000000;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
92
auth-server/src/types.ts
Normal file
92
auth-server/src/types.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
/**
|
||||
* Represents a pending OAuth authorization that hasn't been exchanged for tokens yet.
|
||||
* Stored in Redis with the authorization code as the key.
|
||||
*/
|
||||
export interface PendingAuthorization {
|
||||
/** The redirect URI where the client expects to receive the authorization code */
|
||||
redirectUri: string;
|
||||
/** PKCE code challenge - a derived value from the code verifier */
|
||||
codeChallenge: string;
|
||||
/** Method used to derive the code challenge (currently only S256 supported) */
|
||||
codeChallengeMethod: string;
|
||||
/** The OAuth client ID that initiated the authorization */
|
||||
clientId: string;
|
||||
/** Optional state parameter for CSRF protection */
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the exchange of an authorization code for an MCP access token.
|
||||
* Used to prevent replay attacks by tracking if a code has been used.
|
||||
*/
|
||||
export interface TokenExchange {
|
||||
/** The MCP access token that was issued for this authorization code */
|
||||
mcpAccessToken: string;
|
||||
/** Whether this authorization code has already been exchanged for tokens */
|
||||
alreadyUsed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents mock upstream identity provider tokens for demonstration purposes.
|
||||
* In production, this would contain real upstream provider tokens.
|
||||
*/
|
||||
export interface MockUpstreamInstallation {
|
||||
/** Access token from the mock upstream identity provider */
|
||||
mockUpstreamAccessToken: string;
|
||||
/** Refresh token from the mock upstream identity provider */
|
||||
mockUpstreamRefreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete installation object stored in Redis, containing both
|
||||
* upstream provider information and MCP-specific tokens.
|
||||
* This object is encrypted using the MCP access token as the key.
|
||||
*/
|
||||
export interface McpInstallation {
|
||||
/** Information from the upstream authentication provider */
|
||||
mockUpstreamInstallation: MockUpstreamInstallation;
|
||||
/** MCP OAuth tokens issued to the client */
|
||||
mcpTokens: OAuthTokens;
|
||||
/** The OAuth client ID associated with this installation */
|
||||
clientId: string;
|
||||
/** Unix timestamp (seconds) when the tokens were issued */
|
||||
issuedAt: number;
|
||||
/** Unique identifier for the user (not the OAuth client) */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Token Introspection Response
|
||||
* Based on RFC 7662: https://tools.ietf.org/html/rfc7662
|
||||
* Used when validating tokens with an external authorization server.
|
||||
*/
|
||||
export interface TokenIntrospectionResponse {
|
||||
/** Whether the token is currently active */
|
||||
active: boolean;
|
||||
/** Space-separated list of scopes associated with the token */
|
||||
scope?: string;
|
||||
/** Client identifier for the OAuth client that requested the token */
|
||||
client_id?: string;
|
||||
/** Human-readable identifier for the resource owner */
|
||||
username?: string;
|
||||
/** Type of the token (e.g., "Bearer") */
|
||||
token_type?: string;
|
||||
/** Expiration time as seconds since Unix epoch */
|
||||
exp?: number;
|
||||
/** Time at which the token was issued as seconds since Unix epoch */
|
||||
iat?: number;
|
||||
/** Time before which the token is not valid as seconds since Unix epoch */
|
||||
nbf?: number;
|
||||
/** Subject identifier for the resource owner */
|
||||
sub?: string;
|
||||
/** Intended audience for the token */
|
||||
aud?: string | string[];
|
||||
/** Issuer of the token */
|
||||
iss?: string;
|
||||
/** Unique identifier for the token */
|
||||
jti?: string;
|
||||
/** Custom field for our implementation to store user ID */
|
||||
userId?: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue