From 5c408eb43d855b8e873c21a2f73bad19b16a775b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 22:01:57 -0400 Subject: [PATCH] Phase 4: Create standalone authorization server - Created auth-server/index.ts with full OAuth endpoint support - Reuses EverythingAuthProvider from main server - Added custom /oauth/introspect endpoint for token validation - Includes fake upstream auth routes for user authentication - Added TypeScript configuration for auth server - Added comprehensive npm scripts for all modes: - dev:integrated, dev:separate, dev:auth-server - dev:with-separate-auth (runs both servers) - build:auth-server, build:all - Added concurrently dependency for running multiple servers - Added detailed README for auth server - Successfully serves OAuth metadata and health endpoints --- auth-server/README.md | 89 +++++++++++++++++++++++ auth-server/index.ts | 146 ++++++++++++++++++++++++++++++++++++++ auth-server/tsconfig.json | 9 +++ package-lock.json | 125 ++++++++++++++++++++++++++++++++ package.json | 14 +++- 5 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 auth-server/README.md create mode 100644 auth-server/index.ts create mode 100644 auth-server/tsconfig.json diff --git a/auth-server/README.md b/auth-server/README.md new file mode 100644 index 0000000..5e8135a --- /dev/null +++ b/auth-server/README.md @@ -0,0 +1,89 @@ +# MCP Standalone Authorization Server + +This is a demonstration OAuth 2.0 authorization server for MCP. + +## Purpose + +This server demonstrates how MCP servers can delegate authentication to a separate +authorization server (Mode 2 in our implementation). In production environments, +you would typically use established OAuth providers like: + +- Auth0 +- Okta +- Google OAuth +- GitHub OAuth +- Microsoft Azure AD + +## Architecture + +When running in separate mode, the architecture looks like: + +1. MCP Client (e.g., Inspector) discovers auth server URL from MCP server metadata +2. Client registers and authenticates directly with this auth server +3. Auth server issues tokens +4. MCP server validates tokens by calling this auth server's introspection endpoint + +## Endpoints + +- `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata +- `/oauth/authorize` - Authorization endpoint +- `/oauth/token` - Token endpoint +- `/oauth/register` - Dynamic client registration +- `/oauth/introspect` - Token introspection (for MCP server validation) +- `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only) +- `/fakeupstreamauth/callback` - Fake upstream callback (demo only) +- `/health` - Health check endpoint + +## Development + +This server shares Redis with the MCP server for development convenience. +In production, these would typically be separate. + +## Running the Auth Server + +### Standalone +```bash +# From the repository root +npm run dev:auth-server +``` + +### With MCP Server (Separate Mode) +```bash +# Start both servers together +npm run dev:with-separate-auth +``` + +## Testing + +### Health Check +```bash +curl http://localhost:3001/health +``` + +### OAuth Metadata +```bash +curl http://localhost:3001/.well-known/oauth-authorization-server +``` + +### With MCP Inspector +1. Start this auth server: `npm run dev:auth-server` +2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev` +3. Open Inspector: `npx -y @modelcontextprotocol/inspector` +4. Connect to `http://localhost:3232` +5. Auth flow will redirect to this server (port 3001) + +## Configuration + +The auth server uses the same environment variables as the main server: +- `AUTH_SERVER_PORT` - Port to run on (default: 3001) +- `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001) +- `REDIS_URL` - Redis connection (shared with MCP server) + +## Production Considerations + +In production: +- Use real OAuth providers instead of this demonstration server +- Separate Redis instances for auth and resource servers +- Enable HTTPS with proper certificates +- Implement proper rate limiting and monitoring +- Use secure client secrets and token rotation \ No newline at end of file diff --git a/auth-server/index.ts b/auth-server/index.ts new file mode 100644 index 0000000..147e0e0 --- /dev/null +++ b/auth-server/index.ts @@ -0,0 +1,146 @@ +import express from 'express'; +import cors from 'cors'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { EverythingAuthProvider } from '../src/auth/provider.js'; +import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js'; +import { redisClient } from '../src/redis.js'; +import { logger } from '../src/utils/logger.js'; +import { AUTH_SERVER_PORT, AUTH_SERVER_URL } from '../src/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}/oauth/authorize`, + token: `${AUTH_SERVER_URL}/oauth/token`, + register: `${AUTH_SERVER_URL}/oauth/register`, + introspect: `${AUTH_SERVER_URL}/oauth/introspect` + } + }); +}); + +// Create auth provider instance for reuse +const authProvider = new EverythingAuthProvider(); + +// 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 } + } +})); + +// Token introspection endpoint (RFC 7662) +app.post('/oauth/introspect', 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: authInfo.clientId, + 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('/fakeupstreamauth/authorize', cors(), handleFakeAuthorize); +app.get('/fakeupstreamauth/callback', cors(), handleFakeAuthorizeRedirect); + +// 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', (req, res) => { + // Serve from the main server's static directory + const logoPath = path.join(__dirname, '../src/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}/oauth/authorize`, + token: `${AUTH_SERVER_URL}/oauth/token`, + register: `${AUTH_SERVER_URL}/oauth/register`, + introspect: `${AUTH_SERVER_URL}/oauth/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 separate mode:'); + console.log(' 1. Keep this server running'); + console.log(' 2. In another terminal: AUTH_MODE=separate npm run dev'); + console.log(' 3. Connect Inspector to http://localhost:3232'); +}); \ No newline at end of file diff --git a/auth-server/tsconfig.json b/auth-server/tsconfig.json new file mode 100644 index 0000000..c9033fa --- /dev/null +++ b/auth-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../dist/auth-server", + "rootDir": "../" + }, + "include": ["index.ts", "../src/**/*.ts", "../shared/**/*.ts"], + "exclude": ["../dist", "../node_modules", "../**/*.test.ts"] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 25a0241..37c9d08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.0", + "concurrently": "^8.2.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.19.2", @@ -484,6 +485,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -3114,6 +3125,50 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3206,6 +3261,23 @@ "node": ">= 8" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5319,6 +5391,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6224,6 +6303,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6350,6 +6439,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6467,6 +6569,12 @@ "source-map": "^0.6.0" } }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -6651,6 +6759,16 @@ "node": ">=0.6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6743,6 +6861,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.20.5", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", diff --git a/package.json b/package.json index 793ab33..8a3956e 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,21 @@ "main": "dist/index.js", "scripts": { "start": "node dist/index.js", + "start:auth-server": "node dist/auth-server/index.js", "dev": "tsx watch --inspect src/index.ts", "dev:break": "tsx --inspect-brk watch src/index.ts", + "dev:integrated": "AUTH_MODE=integrated npm run dev", + "dev:separate": "AUTH_MODE=separate AUTH_SERVER_URL=http://localhost:3001 npm run dev", + "dev:auth-server": "AUTH_SERVER_PORT=3001 tsx watch --inspect auth-server/index.ts", + "dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"", "build": "tsc && npm run copy-static", + "build:auth-server": "tsc -p auth-server/tsconfig.json", + "build:all": "tsc && tsc -p auth-server/tsconfig.json && npm run copy-static", "copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/", - "lint": "eslint src/", - "test": "NODE_OPTIONS=--experimental-vm-modules jest" + "lint": "eslint src/ auth-server/", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:integrated": "AUTH_MODE=integrated npm test", + "test:separate": "AUTH_MODE=separate npm test" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -20,6 +29,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.0", + "concurrently": "^8.2.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.19.2",