Skip to content

Commit 9482d93

Browse files
authored
Merge branch 'main' into main
2 parents 473dc6c + 854b55e commit 9482d93

File tree

10 files changed

+722
-15
lines changed

10 files changed

+722
-15
lines changed

Diff for: README.md

+47
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- [Low-Level Server](#low-level-server)
2222
- [Writing MCP Clients](#writing-mcp-clients)
2323
- [Server Capabilities](#server-capabilities)
24+
- [Proxy OAuth Server](#proxy-authorization-requests-upstream)
2425

2526
## Overview
2627

@@ -489,6 +490,52 @@ const result = await client.callTool({
489490
});
490491
```
491492

493+
### Proxy Authorization Requests Upstream
494+
495+
You can proxy OAuth requests to an external authorization provider:
496+
497+
```typescript
498+
import express from 'express';
499+
import { ProxyOAuthServerProvider, mcpAuthRouter } from '@modelcontextprotocol/sdk';
500+
501+
const app = express();
502+
503+
const proxyProvider = new ProxyOAuthServerProvider({
504+
endpoints: {
505+
authorizationUrl: "https://auth.external.com/oauth2/v1/authorize",
506+
tokenUrl: "https://auth.external.com/oauth2/v1/token",
507+
revocationUrl: "https://auth.external.com/oauth2/v1/revoke",
508+
},
509+
verifyAccessToken: async (token) => {
510+
return {
511+
token,
512+
clientId: "123",
513+
scopes: ["openid", "email", "profile"],
514+
}
515+
},
516+
getClient: async (client_id) => {
517+
return {
518+
client_id,
519+
redirect_uris: ["http://localhost:3000/callback"],
520+
}
521+
}
522+
})
523+
524+
app.use(mcpAuthRouter({
525+
provider: proxyProvider,
526+
issuerUrl: new URL("http://auth.external.com"),
527+
baseUrl: new URL("http://mcp.example.com"),
528+
serviceDocumentationUrl: new URL("https://docs.example.com/"),
529+
}))
530+
```
531+
532+
This setup allows you to:
533+
- Forward OAuth requests to an external provider
534+
- Add custom token validation logic
535+
- Manage client registrations
536+
- Provide custom documentation URLs
537+
- Maintain control over the OAuth flow while delegating to an external provider
538+
492539
## Documentation
493540

494541
- [Model Context Protocol documentation](https://modelcontextprotocol.io)

Diff for: src/server/auth/handlers/token.test.ts

+62
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import supertest from 'supertest';
77
import * as pkceChallenge from 'pkce-challenge';
88
import { InvalidGrantError, InvalidTokenError } from '../errors.js';
99
import { AuthInfo } from '../types.js';
10+
import { ProxyOAuthServerProvider } from '../providers/proxyProvider.js';
1011

1112
// Mock pkce-challenge
1213
jest.mock('pkce-challenge', () => ({
@@ -280,6 +281,67 @@ describe('Token Handler', () => {
280281
expect(response.body.expires_in).toBe(3600);
281282
expect(response.body.refresh_token).toBe('mock_refresh_token');
282283
});
284+
285+
it('passes through code verifier when using proxy provider', async () => {
286+
const originalFetch = global.fetch;
287+
288+
try {
289+
global.fetch = jest.fn().mockResolvedValue({
290+
ok: true,
291+
json: () => Promise.resolve({
292+
access_token: 'mock_access_token',
293+
token_type: 'bearer',
294+
expires_in: 3600,
295+
refresh_token: 'mock_refresh_token'
296+
})
297+
});
298+
299+
const proxyProvider = new ProxyOAuthServerProvider({
300+
endpoints: {
301+
authorizationUrl: 'https://example.com/authorize',
302+
tokenUrl: 'https://example.com/token'
303+
},
304+
verifyAccessToken: async (token) => ({
305+
token,
306+
clientId: 'valid-client',
307+
scopes: ['read', 'write'],
308+
expiresAt: Date.now() / 1000 + 3600
309+
}),
310+
getClient: async (clientId) => clientId === 'valid-client' ? validClient : undefined
311+
});
312+
313+
const proxyApp = express();
314+
const options: TokenHandlerOptions = { provider: proxyProvider };
315+
proxyApp.use('/token', tokenHandler(options));
316+
317+
const response = await supertest(proxyApp)
318+
.post('/token')
319+
.type('form')
320+
.send({
321+
client_id: 'valid-client',
322+
client_secret: 'valid-secret',
323+
grant_type: 'authorization_code',
324+
code: 'valid_code',
325+
code_verifier: 'any_verifier'
326+
});
327+
328+
expect(response.status).toBe(200);
329+
expect(response.body.access_token).toBe('mock_access_token');
330+
331+
expect(global.fetch).toHaveBeenCalledWith(
332+
'https://example.com/token',
333+
expect.objectContaining({
334+
method: 'POST',
335+
headers: {
336+
'Content-Type': 'application/x-www-form-urlencoded'
337+
},
338+
body: expect.stringContaining('code_verifier=any_verifier')
339+
})
340+
);
341+
} finally {
342+
global.fetch = originalFetch;
343+
}
344+
});
283345
});
284346

285347
describe('Refresh token grant', () => {

Diff for: src/server/auth/handlers/token.ts

+11-5
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,19 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
9090

9191
const { code, code_verifier } = parseResult.data;
9292

93-
// Verify PKCE challenge
94-
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
95-
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
96-
throw new InvalidGrantError("code_verifier does not match the challenge");
93+
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
94+
95+
// Perform local PKCE validation unless explicitly skipped
96+
// (e.g. to validate code_verifier in upstream server)
97+
if (!skipLocalPkceValidation) {
98+
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
99+
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
100+
throw new InvalidGrantError("code_verifier does not match the challenge");
101+
}
97102
}
98103

99-
const tokens = await provider.exchangeAuthorizationCode(client, code);
104+
// Passes the code_verifier to the provider if PKCE validation didn't occur locally
105+
const tokens = await provider.exchangeAuthorizationCode(client, code, skipLocalPkceValidation ? code_verifier : undefined);
100106
res.status(200).json(tokens);
101107
break;
102108
}

Diff for: src/server/auth/provider.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface OAuthServerProvider {
3636
/**
3737
* Exchanges an authorization code for an access token.
3838
*/
39-
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens>;
39+
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise<OAuthTokens>;
4040

4141
/**
4242
* Exchanges a refresh token for an access token.
@@ -54,4 +54,13 @@ export interface OAuthServerProvider {
5454
* If the given token is invalid or already revoked, this method should do nothing.
5555
*/
5656
revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
57+
58+
/**
59+
* Whether to skip local PKCE validation.
60+
*
61+
* If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
62+
*
63+
* NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
64+
*/
65+
skipLocalPkceValidation?: boolean;
5766
}

0 commit comments

Comments
 (0)