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:
Basil Hosmer 2025-10-06 21:07:18 -04:00
commit c03a9b4e77
No known key found for this signature in database
15 changed files with 1556 additions and 15846 deletions

View 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",
};
}

View 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();
});
});
});

View 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
View 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}`;

View 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
View 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
View 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);
}
}
}
}

View 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);
});
});
});

View 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);
}

View 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;
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View 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
View 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;
}

View file

@ -0,0 +1,176 @@
import { AsyncLocalStorage } from 'async_hooks';
import { Request, Response, NextFunction } from 'express';
// Severity levels as per Google Cloud Logging
export enum LogSeverity {
DEFAULT = 'DEFAULT',
DEBUG = 'DEBUG',
INFO = 'INFO',
NOTICE = 'NOTICE',
WARNING = 'WARNING',
ERROR = 'ERROR',
CRITICAL = 'CRITICAL',
ALERT = 'ALERT',
EMERGENCY = 'EMERGENCY'
}
interface LogContext {
trace?: string;
spanId?: string;
requestId?: string;
userAgent?: string;
method?: string;
path?: string;
[key: string]: string | undefined;
}
interface StructuredLogEntry {
severity: LogSeverity;
message: string;
timestamp: string;
'logging.googleapis.com/trace'?: string;
'logging.googleapis.com/spanId'?: string;
[key: string]: unknown;
}
class StructuredLogger {
private asyncLocalStorage = new AsyncLocalStorage<LogContext>();
private projectId: string | undefined;
constructor() {
// Get project ID from environment or metadata server
this.projectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCP_PROJECT;
}
/**
* Run a function with a specific logging context
*/
runWithContext<T>(context: LogContext, fn: () => T): T {
return this.asyncLocalStorage.run(context, fn);
}
/**
* Extract trace context from Cloud Run request
*/
extractTraceContext(req: Request): LogContext {
const context: LogContext = {};
const traceHeader = req.header('X-Cloud-Trace-Context');
if (traceHeader && this.projectId) {
const [trace, spanId] = traceHeader.split('/');
context.trace = `projects/${this.projectId}/traces/${trace}`;
if (spanId) {
context.spanId = spanId.split(';')[0]; // Remove any trace flags
}
}
// Add other useful request context
context.requestId = req.header('X-Request-Id');
context.userAgent = req.header('User-Agent');
context.method = req.method;
context.path = req.path;
return context;
}
/**
* Create Express middleware for request context
*/
middleware() {
return (req: Request, res: Response, next: NextFunction) => {
const context = this.extractTraceContext(req);
this.runWithContext(context, () => {
next();
});
};
}
/**
* Log a structured message
*/
private log(severity: LogSeverity, message: string, metadata?: Record<string, unknown>) {
const context = this.asyncLocalStorage.getStore() || {};
const entry: StructuredLogEntry = {
severity,
message,
timestamp: new Date().toISOString(),
...metadata
};
// Add trace context if available
if (context.trace) {
entry['logging.googleapis.com/trace'] = context.trace;
}
if (context.spanId) {
entry['logging.googleapis.com/spanId'] = context.spanId;
}
// Add any other context fields
Object.keys(context).forEach(key => {
if (key !== 'trace' && key !== 'spanId') {
entry[`context.${key}`] = context[key];
}
});
// Output as JSON for Cloud Logging
console.log(JSON.stringify(entry));
}
// Convenience methods for different severity levels
debug(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.DEBUG, message, metadata);
}
info(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.INFO, message, metadata);
}
notice(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.NOTICE, message, metadata);
}
warning(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.WARNING, message, metadata);
}
error(message: string, error?: Error, metadata?: Record<string, unknown>) {
const errorMetadata = {
...metadata,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack
} : undefined
};
this.log(LogSeverity.ERROR, message, errorMetadata);
}
critical(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.CRITICAL, message, metadata);
}
alert(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.ALERT, message, metadata);
}
emergency(message: string, metadata?: Record<string, unknown>) {
this.log(LogSeverity.EMERGENCY, message, metadata);
}
/**
* Add additional context to the current async context
*/
addContext(context: LogContext) {
const currentContext = this.asyncLocalStorage.getStore();
if (currentContext) {
Object.assign(currentContext, context);
}
}
}
// Export singleton instance
export const logger = new StructuredLogger();
// Re-export for convenience
export type { LogContext, StructuredLogEntry };