-
Notifications
You must be signed in to change notification settings - Fork 924
feat: Add support for separate Authorization Server / Resource server in server flow (spec: DRAFT-2025-v2) #503
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
d634d5a
055d95d
2270239
374580f
2cc5a8c
34ada58
bbafc85
b4e8dcd
bb9f560
1f0c45f
fb1c3b7
689d9b3
cef35c9
6374d54
ba6f995
544547b
75a12ae
43dceff
f76bb96
d608390
8acd5ed
e55037e
9e7f72d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { randomUUID } from 'node:crypto'; | ||
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; | ||
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; | ||
import { OAuthClientInformationFull, OAuthTokens } from 'src/shared/auth.js'; | ||
import { Response } from "express"; | ||
import { AuthInfo } from 'src/server/auth/types.js'; | ||
|
||
|
||
/** | ||
* Simple in-memory implementation of OAuth clients store for demo purposes. | ||
* In production, this should be backed by a persistent database. | ||
*/ | ||
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; | ||
} | ||
} | ||
|
||
/** | ||
* Simple in-memory implementation of OAuth server provider for demo purposes. | ||
* Do not use this in production. | ||
*/ | ||
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, | ||
_codeVerifier?: string | ||
ihrpr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
): 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}`); | ||
} | ||
|
||
// Remove the used code | ||
this.codes.delete(authorizationCode); | ||
|
||
// Generate access token | ||
const token = randomUUID(); | ||
|
||
const tokenData = { | ||
token, | ||
clientId: client.client_id, | ||
scopes: codeData.params.scopes || [], | ||
expiresAt: Date.now() + 3600000, // 1 hour | ||
type: 'access' | ||
}; | ||
|
||
// Store the token | ||
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), | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,8 +3,14 @@ import { randomUUID } from 'node:crypto'; | |
import { z } from 'zod'; | ||
import { McpServer } from '../../server/mcp.js'; | ||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; | ||
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } 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 { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; | ||
|
||
// Check for OAuth flag | ||
const useOAuth = process.argv.includes('--oauth'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth adding to README in src/examples/README.md as I just copy-pasted command and forgot to add it 🙈 |
||
|
||
// Create an MCP server with implementation details | ||
const getServer = () => { | ||
|
@@ -40,7 +46,7 @@ const getServer = () => { | |
name: z.string().describe('Name to greet'), | ||
}, | ||
{ | ||
title: 'Multiple Greeting Tool', | ||
title: 'Multiple Greeting Tool', | ||
readOnlyHint: true, | ||
openWorldHint: false | ||
}, | ||
|
@@ -159,14 +165,69 @@ 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) { | ||
const provider = new DemoInMemoryAuthProvider(); | ||
|
||
// Create auth middleware for MCP endpoints | ||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); | ||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); | ||
|
||
// Create separate auth server app | ||
const authApp = express(); | ||
authApp.use(express.json()); | ||
|
||
// Add OAuth routes to the auth server | ||
authApp.use(mcpAuthRouter({ | ||
provider, | ||
issuerUrl: authServerUrl, | ||
scopesSupported: ['mcp:tools'], | ||
// This endpoint is set up on the Authorization server, but really shouldn't be. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe add a link to the spec and explanation this is only for demo There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yea let me see about re-working this... we should make it easier to provide just the backwards-compat endpoint |
||
protectedResourceOptions: { | ||
serverUrl: mcpServerUrl, | ||
resourceName: 'MCP Demo Server', | ||
}, | ||
})); | ||
|
||
// Start the auth server | ||
authApp.listen(AUTH_PORT, () => { | ||
console.log(`OAuth Authorization Server listening on port ${AUTH_PORT}`); | ||
}); | ||
|
||
// Add both resource metadata and oauth server metadata (for backwards compatiblity) to the main MCP server | ||
app.use(mcpAuthRouter({ | ||
provider, | ||
issuerUrl: authServerUrl, | ||
scopesSupported: ['mcp:tools'], | ||
protectedResourceOptions: { | ||
serverUrl: mcpServerUrl, | ||
resourceName: 'MCP Demo Server', | ||
}, | ||
})); | ||
|
||
authMiddleware = requireBearerAuth({ | ||
provider, | ||
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 +295,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 +326,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 +354,17 @@ app.delete('/mcp', async (req: Request, res: Response) => { | |
res.status(500).send('Error processing session termination'); | ||
} | ||
} | ||
}); | ||
}; | ||
|
||
// Set up DELETE route with conditional auth middleware | ||
if (useOAuth && authMiddleware) { | ||
app.delete('/mcp', authMiddleware, mcpDeleteHandler); | ||
|
||
} else { | ||
app.delete('/mcp', mcpDeleteHandler); | ||
} | ||
|
||
// Start the server | ||
const PORT = 3000; | ||
app.listen(PORT, () => { | ||
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); | ||
app.listen(MCP_PORT, () => { | ||
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); | ||
}); | ||
|
||
// Handle server shutdown | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe emphasising a bit more this is a demo
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sg, although this does support PKCE (see below) and separate AS / RS. This is intended to be a standalone AS.