feat: Add support for separate Authorization Server / Resource server in server flow (spec: DRAFT-2025-v2) (#503)
* add support for .oauth-protected-resource metadata endpoint and www-authenticate * test and types * thread throughs scopes * have example separate AS and RS * make inmemory explicitly demo * fix type * fix types * fix client example * fixup comments * refactor metadata endpoints to cleanup * almost working w/ forwarding * separate AS/RS working * add to readme * de-async the auth server setup for happier top level flow * add test for new router * fix test * remove redundant comment * clarify comment * remove more unused endpoints * simplify router * slim down * simplify verifier, and fix router test * fix doc string
This commit is contained in:
parent
3975c1ac79
commit
ac1b80a331
2 changed files with 787 additions and 82 deletions
200
src/examples/server/demoInMemoryOAuthProvider.ts
Normal file
200
src/examples/server/demoInMemoryOAuthProvider.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
|
||||
import express, { Request, Response } from "express";
|
||||
import { AuthInfo } from 'src/server/auth/types.js';
|
||||
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
|
||||
|
||||
|
||||
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
|
||||
private clients = new Map<string, OAuthClientInformationFull>();
|
||||
|
||||
async getClient(clientId: string) {
|
||||
return this.clients.get(clientId);
|
||||
}
|
||||
|
||||
async registerClient(clientMetadata: OAuthClientInformationFull) {
|
||||
this.clients.set(clientMetadata.client_id, clientMetadata);
|
||||
return clientMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
|
||||
*
|
||||
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
|
||||
* for example:
|
||||
* - Persistent token storage
|
||||
* - Rate limiting
|
||||
*/
|
||||
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
||||
clientsStore = new DemoInMemoryClientsStore();
|
||||
private codes = new Map<string, {
|
||||
params: AuthorizationParams,
|
||||
client: OAuthClientInformationFull}>();
|
||||
private tokens = new Map<string, AuthInfo>();
|
||||
|
||||
async authorize(
|
||||
client: OAuthClientInformationFull,
|
||||
params: AuthorizationParams,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const code = randomUUID();
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
code,
|
||||
});
|
||||
if (params.state !== undefined) {
|
||||
searchParams.set('state', params.state);
|
||||
}
|
||||
|
||||
this.codes.set(code, {
|
||||
client,
|
||||
params
|
||||
});
|
||||
|
||||
const targetUrl = new URL(client.redirect_uris[0]);
|
||||
targetUrl.search = searchParams.toString();
|
||||
res.redirect(targetUrl.toString());
|
||||
}
|
||||
|
||||
async challengeForAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string
|
||||
): Promise<string> {
|
||||
|
||||
// Store the challenge with the code data
|
||||
const codeData = this.codes.get(authorizationCode);
|
||||
if (!codeData) {
|
||||
throw new Error('Invalid authorization code');
|
||||
}
|
||||
|
||||
return codeData.params.codeChallenge;
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
// Note: code verifier is checked in token.ts by default
|
||||
// it's unused here for that reason.
|
||||
_codeVerifier?: string
|
||||
): Promise<OAuthTokens> {
|
||||
const codeData = this.codes.get(authorizationCode);
|
||||
if (!codeData) {
|
||||
throw new Error('Invalid authorization code');
|
||||
}
|
||||
|
||||
if (codeData.client.client_id !== client.client_id) {
|
||||
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
|
||||
}
|
||||
|
||||
this.codes.delete(authorizationCode);
|
||||
const token = randomUUID();
|
||||
|
||||
const tokenData = {
|
||||
token,
|
||||
clientId: client.client_id,
|
||||
scopes: codeData.params.scopes || [],
|
||||
expiresAt: Date.now() + 3600000, // 1 hour
|
||||
type: 'access'
|
||||
};
|
||||
|
||||
this.tokens.set(token, tokenData);
|
||||
|
||||
return {
|
||||
access_token: token,
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
scope: (codeData.params.scopes || []).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
async exchangeRefreshToken(
|
||||
_client: OAuthClientInformationFull,
|
||||
_refreshToken: string,
|
||||
_scopes?: string[]
|
||||
): Promise<OAuthTokens> {
|
||||
throw new Error('Not implemented for example demo');
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const tokenData = this.tokens.get(token);
|
||||
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId: tokenData.clientId,
|
||||
scopes: tokenData.scopes,
|
||||
expiresAt: Math.floor(tokenData.expiresAt / 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
|
||||
// Create separate auth server app
|
||||
// NOTE: This is a separate app on a separate port to illustrate
|
||||
// how to separate an OAuth Authorization Server from a Resource
|
||||
// server in the SDK. The SDK is not intended to be provide a standalone
|
||||
// authorization server.
|
||||
const provider = new DemoInMemoryAuthProvider();
|
||||
const authApp = express();
|
||||
authApp.use(express.json());
|
||||
// For introspection requests
|
||||
authApp.use(express.urlencoded());
|
||||
|
||||
// Add OAuth routes to the auth server
|
||||
// NOTE: this will also add a protected resource metadata route,
|
||||
// but it won't be used, so leave it.
|
||||
authApp.use(mcpAuthRouter({
|
||||
provider,
|
||||
issuerUrl: authServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
}));
|
||||
|
||||
authApp.post('/introspect', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
if (!token) {
|
||||
res.status(400).json({ error: 'Token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenInfo = await provider.verifyAccessToken(token);
|
||||
res.json({
|
||||
active: true,
|
||||
client_id: tokenInfo.clientId,
|
||||
scope: tokenInfo.scopes.join(' '),
|
||||
exp: tokenInfo.expiresAt
|
||||
});
|
||||
return
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
active: false,
|
||||
error: 'Unauthorized',
|
||||
error_description: `Invalid token: ${error}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const auth_port = authServerUrl.port;
|
||||
// Start the auth server
|
||||
authApp.listen(auth_port, () => {
|
||||
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
|
||||
});
|
||||
|
||||
// Note: we could fetch this from the server, but then we end up
|
||||
// with some top level async which gets annoying.
|
||||
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
|
||||
provider,
|
||||
issuerUrl: authServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
})
|
||||
|
||||
oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href;
|
||||
|
||||
return oauthMetadata;
|
||||
}
|
||||
|
|
@ -3,8 +3,15 @@ import { randomUUID } from 'node:crypto';
|
|||
import { z } from 'zod';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
|
||||
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
|
||||
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
|
||||
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
|
||||
import { OAuthMetadata } from 'src/shared/auth.js';
|
||||
|
||||
// Check for OAuth flag
|
||||
const useOAuth = process.argv.includes('--oauth');
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const getServer = () => {
|
||||
|
|
@ -40,7 +47,7 @@ const getServer = () => {
|
|||
name: z.string().describe('Name to greet'),
|
||||
},
|
||||
{
|
||||
title: 'Multiple Greeting Tool',
|
||||
title: 'Multiple Greeting Tool',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
},
|
||||
|
|
@ -159,14 +166,79 @@ const getServer = () => {
|
|||
return server;
|
||||
};
|
||||
|
||||
const MCP_PORT = 3000;
|
||||
const AUTH_PORT = 3001;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Set up OAuth if enabled
|
||||
let authMiddleware = null;
|
||||
if (useOAuth) {
|
||||
// Create auth middleware for MCP endpoints
|
||||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl);
|
||||
|
||||
const tokenVerifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
const endpoint = oauthMetadata.introspection_endpoint;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('No token verification endpoint available in metadata');
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: token
|
||||
}).toString()
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid or expired token: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Convert the response to AuthInfo format
|
||||
return {
|
||||
token,
|
||||
clientId: data.client_id,
|
||||
scopes: data.scope ? data.scope.split(' ') : [],
|
||||
expiresAt: data.exp,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Add metadata routes to the main MCP server
|
||||
app.use(mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
resourceName: 'MCP Demo Server',
|
||||
}));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: tokenVerifier,
|
||||
requiredScopes: ['mcp:tools'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Map to store transports by session ID
|
||||
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||
|
||||
app.post('/mcp', async (req: Request, res: Response) => {
|
||||
// MCP POST endpoint with optional auth
|
||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||
console.log('Received MCP request:', req.body);
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated user:', req.auth);
|
||||
}
|
||||
try {
|
||||
// Check for existing session ID
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
|
@ -234,16 +306,27 @@ app.post('/mcp', async (req: Request, res: Response) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Set up routes with conditional auth middleware
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.post('/mcp', authMiddleware, mcpPostHandler);
|
||||
} else {
|
||||
app.post('/mcp', mcpPostHandler);
|
||||
}
|
||||
|
||||
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
|
||||
app.get('/mcp', async (req: Request, res: Response) => {
|
||||
const mcpGetHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated SSE connection from user:', req.auth);
|
||||
}
|
||||
|
||||
// Check for Last-Event-ID header for resumability
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
if (lastEventId) {
|
||||
|
|
@ -254,10 +337,17 @@ app.get('/mcp', async (req: Request, res: Response) => {
|
|||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
};
|
||||
|
||||
// Set up GET route with conditional auth middleware
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.get('/mcp', authMiddleware, mcpGetHandler);
|
||||
} else {
|
||||
app.get('/mcp', mcpGetHandler);
|
||||
}
|
||||
|
||||
// Handle DELETE requests for session termination (according to MCP spec)
|
||||
app.delete('/mcp', async (req: Request, res: Response) => {
|
||||
const mcpDeleteHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
|
|
@ -275,12 +365,17 @@ app.delete('/mcp', async (req: Request, res: Response) => {
|
|||
res.status(500).send('Error processing session termination');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
|
||||
// Set up DELETE route with conditional auth middleware
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
|
||||
} else {
|
||||
app.delete('/mcp', mcpDeleteHandler);
|
||||
}
|
||||
|
||||
app.listen(MCP_PORT, () => {
|
||||
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
|
|
|
|||
Loading…
Reference in a new issue