-
Notifications
You must be signed in to change notification settings - Fork 831
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
Merged
Merged
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
d634d5a
add support for .oauth-protected-resource metadata endpoint and www-a…
pcarleton 055d95d
test and types
pcarleton 2270239
thread throughs scopes
pcarleton 374580f
have example separate AS and RS
pcarleton 2cc5a8c
make inmemory explicitly demo
pcarleton 34ada58
fix type
pcarleton bbafc85
fix types
pcarleton b4e8dcd
fix client example
pcarleton bb9f560
fixup comments
pcarleton 1f0c45f
refactor metadata endpoints to cleanup
pcarleton fb1c3b7
almost working w/ forwarding
pcarleton 689d9b3
separate AS/RS working
pcarleton cef35c9
add to readme
pcarleton 6374d54
de-async the auth server setup for happier top level flow
pcarleton ba6f995
add test for new router
pcarleton 544547b
fix test
pcarleton 75a12ae
remove redundant comment
pcarleton 43dceff
clarify comment
pcarleton f76bb96
remove more unused endpoints
pcarleton d608390
simplify router
pcarleton 8acd5ed
slim down
pcarleton e55037e
simplify verifier, and fix router test
pcarleton 9e7f72d
fix doc string
pcarleton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'); | ||
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 +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'); | ||
} | ||
} | ||
}); | ||
}; | ||
|
||
// 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 | ||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.