Skip to content

Commit ac1b80a

Browse files
authored
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
1 parent 3975c1a commit ac1b80a

File tree

10 files changed

+787
-82
lines changed

10 files changed

+787
-82
lines changed

src/examples/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03
6767

6868
```bash
6969
npx tsx src/examples/server/simpleStreamableHttp.ts
70+
71+
# To add a demo of authentication to this example, use:
72+
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth
7073
```
7174

7275
##### JSON Response Mode Server
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { randomUUID } from 'node:crypto';
2+
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
3+
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
4+
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
5+
import express, { Request, Response } from "express";
6+
import { AuthInfo } from 'src/server/auth/types.js';
7+
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
8+
9+
10+
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
11+
private clients = new Map<string, OAuthClientInformationFull>();
12+
13+
async getClient(clientId: string) {
14+
return this.clients.get(clientId);
15+
}
16+
17+
async registerClient(clientMetadata: OAuthClientInformationFull) {
18+
this.clients.set(clientMetadata.client_id, clientMetadata);
19+
return clientMetadata;
20+
}
21+
}
22+
23+
/**
24+
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
25+
*
26+
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
27+
* for example:
28+
* - Persistent token storage
29+
* - Rate limiting
30+
*/
31+
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
32+
clientsStore = new DemoInMemoryClientsStore();
33+
private codes = new Map<string, {
34+
params: AuthorizationParams,
35+
client: OAuthClientInformationFull}>();
36+
private tokens = new Map<string, AuthInfo>();
37+
38+
async authorize(
39+
client: OAuthClientInformationFull,
40+
params: AuthorizationParams,
41+
res: Response
42+
): Promise<void> {
43+
const code = randomUUID();
44+
45+
const searchParams = new URLSearchParams({
46+
code,
47+
});
48+
if (params.state !== undefined) {
49+
searchParams.set('state', params.state);
50+
}
51+
52+
this.codes.set(code, {
53+
client,
54+
params
55+
});
56+
57+
const targetUrl = new URL(client.redirect_uris[0]);
58+
targetUrl.search = searchParams.toString();
59+
res.redirect(targetUrl.toString());
60+
}
61+
62+
async challengeForAuthorizationCode(
63+
client: OAuthClientInformationFull,
64+
authorizationCode: string
65+
): Promise<string> {
66+
67+
// Store the challenge with the code data
68+
const codeData = this.codes.get(authorizationCode);
69+
if (!codeData) {
70+
throw new Error('Invalid authorization code');
71+
}
72+
73+
return codeData.params.codeChallenge;
74+
}
75+
76+
async exchangeAuthorizationCode(
77+
client: OAuthClientInformationFull,
78+
authorizationCode: string,
79+
// Note: code verifier is checked in token.ts by default
80+
// it's unused here for that reason.
81+
_codeVerifier?: string
82+
): Promise<OAuthTokens> {
83+
const codeData = this.codes.get(authorizationCode);
84+
if (!codeData) {
85+
throw new Error('Invalid authorization code');
86+
}
87+
88+
if (codeData.client.client_id !== client.client_id) {
89+
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
90+
}
91+
92+
this.codes.delete(authorizationCode);
93+
const token = randomUUID();
94+
95+
const tokenData = {
96+
token,
97+
clientId: client.client_id,
98+
scopes: codeData.params.scopes || [],
99+
expiresAt: Date.now() + 3600000, // 1 hour
100+
type: 'access'
101+
};
102+
103+
this.tokens.set(token, tokenData);
104+
105+
return {
106+
access_token: token,
107+
token_type: 'bearer',
108+
expires_in: 3600,
109+
scope: (codeData.params.scopes || []).join(' '),
110+
};
111+
}
112+
113+
async exchangeRefreshToken(
114+
_client: OAuthClientInformationFull,
115+
_refreshToken: string,
116+
_scopes?: string[]
117+
): Promise<OAuthTokens> {
118+
throw new Error('Not implemented for example demo');
119+
}
120+
121+
async verifyAccessToken(token: string): Promise<AuthInfo> {
122+
const tokenData = this.tokens.get(token);
123+
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
124+
throw new Error('Invalid or expired token');
125+
}
126+
127+
return {
128+
token,
129+
clientId: tokenData.clientId,
130+
scopes: tokenData.scopes,
131+
expiresAt: Math.floor(tokenData.expiresAt / 1000),
132+
};
133+
}
134+
}
135+
136+
137+
export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
138+
// Create separate auth server app
139+
// NOTE: This is a separate app on a separate port to illustrate
140+
// how to separate an OAuth Authorization Server from a Resource
141+
// server in the SDK. The SDK is not intended to be provide a standalone
142+
// authorization server.
143+
const provider = new DemoInMemoryAuthProvider();
144+
const authApp = express();
145+
authApp.use(express.json());
146+
// For introspection requests
147+
authApp.use(express.urlencoded());
148+
149+
// Add OAuth routes to the auth server
150+
// NOTE: this will also add a protected resource metadata route,
151+
// but it won't be used, so leave it.
152+
authApp.use(mcpAuthRouter({
153+
provider,
154+
issuerUrl: authServerUrl,
155+
scopesSupported: ['mcp:tools'],
156+
}));
157+
158+
authApp.post('/introspect', async (req: Request, res: Response) => {
159+
try {
160+
const { token } = req.body;
161+
if (!token) {
162+
res.status(400).json({ error: 'Token is required' });
163+
return;
164+
}
165+
166+
const tokenInfo = await provider.verifyAccessToken(token);
167+
res.json({
168+
active: true,
169+
client_id: tokenInfo.clientId,
170+
scope: tokenInfo.scopes.join(' '),
171+
exp: tokenInfo.expiresAt
172+
});
173+
return
174+
} catch (error) {
175+
res.status(401).json({
176+
active: false,
177+
error: 'Unauthorized',
178+
error_description: `Invalid token: ${error}`
179+
});
180+
}
181+
});
182+
183+
const auth_port = authServerUrl.port;
184+
// Start the auth server
185+
authApp.listen(auth_port, () => {
186+
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
187+
});
188+
189+
// Note: we could fetch this from the server, but then we end up
190+
// with some top level async which gets annoying.
191+
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
192+
provider,
193+
issuerUrl: authServerUrl,
194+
scopesSupported: ['mcp:tools'],
195+
})
196+
197+
oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href;
198+
199+
return oauthMetadata;
200+
}

src/examples/server/simpleStreamableHttp.ts

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ import { randomUUID } from 'node:crypto';
33
import { z } from 'zod';
44
import { McpServer } from '../../server/mcp.js';
55
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
6+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
7+
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
68
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
79
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
10+
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
11+
import { OAuthMetadata } from 'src/shared/auth.js';
12+
13+
// Check for OAuth flag
14+
const useOAuth = process.argv.includes('--oauth');
815

916
// Create an MCP server with implementation details
1017
const getServer = () => {
@@ -40,7 +47,7 @@ const getServer = () => {
4047
name: z.string().describe('Name to greet'),
4148
},
4249
{
43-
title: 'Multiple Greeting Tool',
50+
title: 'Multiple Greeting Tool',
4451
readOnlyHint: true,
4552
openWorldHint: false
4653
},
@@ -159,14 +166,79 @@ const getServer = () => {
159166
return server;
160167
};
161168

169+
const MCP_PORT = 3000;
170+
const AUTH_PORT = 3001;
171+
162172
const app = express();
163173
app.use(express.json());
164174

175+
// Set up OAuth if enabled
176+
let authMiddleware = null;
177+
if (useOAuth) {
178+
// Create auth middleware for MCP endpoints
179+
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
180+
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
181+
182+
const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl);
183+
184+
const tokenVerifier = {
185+
verifyAccessToken: async (token: string) => {
186+
const endpoint = oauthMetadata.introspection_endpoint;
187+
188+
if (!endpoint) {
189+
throw new Error('No token verification endpoint available in metadata');
190+
}
191+
192+
const response = await fetch(endpoint, {
193+
method: 'POST',
194+
headers: {
195+
'Content-Type': 'application/x-www-form-urlencoded',
196+
},
197+
body: new URLSearchParams({
198+
token: token
199+
}).toString()
200+
});
201+
202+
203+
if (!response.ok) {
204+
throw new Error(`Invalid or expired token: ${await response.text()}`);
205+
}
206+
207+
const data = await response.json();
208+
209+
// Convert the response to AuthInfo format
210+
return {
211+
token,
212+
clientId: data.client_id,
213+
scopes: data.scope ? data.scope.split(' ') : [],
214+
expiresAt: data.exp,
215+
};
216+
}
217+
}
218+
// Add metadata routes to the main MCP server
219+
app.use(mcpAuthMetadataRouter({
220+
oauthMetadata,
221+
resourceServerUrl: mcpServerUrl,
222+
scopesSupported: ['mcp:tools'],
223+
resourceName: 'MCP Demo Server',
224+
}));
225+
226+
authMiddleware = requireBearerAuth({
227+
verifier: tokenVerifier,
228+
requiredScopes: ['mcp:tools'],
229+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
230+
});
231+
}
232+
165233
// Map to store transports by session ID
166234
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
167235

168-
app.post('/mcp', async (req: Request, res: Response) => {
236+
// MCP POST endpoint with optional auth
237+
const mcpPostHandler = async (req: Request, res: Response) => {
169238
console.log('Received MCP request:', req.body);
239+
if (useOAuth && req.auth) {
240+
console.log('Authenticated user:', req.auth);
241+
}
170242
try {
171243
// Check for existing session ID
172244
const sessionId = req.headers['mcp-session-id'] as string | undefined;
@@ -234,16 +306,27 @@ app.post('/mcp', async (req: Request, res: Response) => {
234306
});
235307
}
236308
}
237-
});
309+
};
310+
311+
// Set up routes with conditional auth middleware
312+
if (useOAuth && authMiddleware) {
313+
app.post('/mcp', authMiddleware, mcpPostHandler);
314+
} else {
315+
app.post('/mcp', mcpPostHandler);
316+
}
238317

239318
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
240-
app.get('/mcp', async (req: Request, res: Response) => {
319+
const mcpGetHandler = async (req: Request, res: Response) => {
241320
const sessionId = req.headers['mcp-session-id'] as string | undefined;
242321
if (!sessionId || !transports[sessionId]) {
243322
res.status(400).send('Invalid or missing session ID');
244323
return;
245324
}
246325

326+
if (useOAuth && req.auth) {
327+
console.log('Authenticated SSE connection from user:', req.auth);
328+
}
329+
247330
// Check for Last-Event-ID header for resumability
248331
const lastEventId = req.headers['last-event-id'] as string | undefined;
249332
if (lastEventId) {
@@ -254,10 +337,17 @@ app.get('/mcp', async (req: Request, res: Response) => {
254337

255338
const transport = transports[sessionId];
256339
await transport.handleRequest(req, res);
257-
});
340+
};
341+
342+
// Set up GET route with conditional auth middleware
343+
if (useOAuth && authMiddleware) {
344+
app.get('/mcp', authMiddleware, mcpGetHandler);
345+
} else {
346+
app.get('/mcp', mcpGetHandler);
347+
}
258348

259349
// Handle DELETE requests for session termination (according to MCP spec)
260-
app.delete('/mcp', async (req: Request, res: Response) => {
350+
const mcpDeleteHandler = async (req: Request, res: Response) => {
261351
const sessionId = req.headers['mcp-session-id'] as string | undefined;
262352
if (!sessionId || !transports[sessionId]) {
263353
res.status(400).send('Invalid or missing session ID');
@@ -275,12 +365,17 @@ app.delete('/mcp', async (req: Request, res: Response) => {
275365
res.status(500).send('Error processing session termination');
276366
}
277367
}
278-
});
368+
};
369+
370+
// Set up DELETE route with conditional auth middleware
371+
if (useOAuth && authMiddleware) {
372+
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
373+
} else {
374+
app.delete('/mcp', mcpDeleteHandler);
375+
}
279376

280-
// Start the server
281-
const PORT = 3000;
282-
app.listen(PORT, () => {
283-
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
377+
app.listen(MCP_PORT, () => {
378+
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
284379
});
285380

286381
// Handle server shutdown

0 commit comments

Comments
 (0)