Skip to content

Commit 5a18673

Browse files
feat(server-legacy): add expectedResource to requireBearerAuth (#2952)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent 3ab61c6 commit 5a18673

3 files changed

Lines changed: 113 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server-legacy': patch
3+
---
4+
5+
`requireBearerAuth` in `@modelcontextprotocol/server-legacy` takes the optional `expectedResource` that `@modelcontextprotocol/server` 2.3.0 and `@modelcontextprotocol/sdk` 1.32.0 added: the resource the token must be issued for (its audience), usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. The package stays frozen otherwise; this option is added so that its `requireBearerAuth` matches the 1.x middleware it is a copy of.

‎packages/server-legacy/src/auth/middleware/bearerAuth.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,28 @@ export type BearerAuthMiddlewareOptions = {
1919
* Optional resource metadata URL to include in WWW-Authenticate header.
2020
*/
2121
resourceMetadataUrl?: string;
22+
23+
/**
24+
* Optional resource the token must be issued for (its audience), usually the server's URL.
25+
* When set, the verifier has to report this value in `AuthInfo.resource` (a fragment and one trailing slash
26+
* are ignored); any other token is refused with `401 invalid_token`.
27+
*/
28+
expectedResource?: URL;
2229
};
2330

31+
// The serialized value without its fragment and without one trailing slash.
32+
function comparableResource(value: URL): string {
33+
const text = String(value);
34+
const hash = text.indexOf('#');
35+
return (hash === -1 ? text : text.slice(0, hash)).replace(/\/$/, '');
36+
}
37+
38+
// A reported resource matches when it serializes to the same string as the expected one, fragment and one trailing slash aside.
39+
function sameResource(reported: URL | undefined, expected: URL): boolean {
40+
if (!reported) return false;
41+
return comparableResource(reported) === comparableResource(expected);
42+
}
43+
2444
declare module 'express-serve-static-core' {
2545
interface Request {
2646
/**
@@ -38,7 +58,12 @@ declare module 'express-serve-static-core' {
3858
* If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header
3959
* for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec.
4060
*/
41-
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
61+
export function requireBearerAuth({
62+
verifier,
63+
requiredScopes = [],
64+
resourceMetadataUrl,
65+
expectedResource
66+
}: BearerAuthMiddlewareOptions): RequestHandler {
4267
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
4368
let header = `Bearer error="${errorCode}", error_description="${message}"`;
4469
if (requiredScopes.length > 0) {
@@ -64,6 +89,10 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad
6489

6590
const authInfo = await verifier.verifyAccessToken(token);
6691

92+
if (expectedResource !== undefined && !sameResource(authInfo.resource, expectedResource)) {
93+
throw new InvalidTokenError('Token was not issued for this resource');
94+
}
95+
6796
if (requiredScopes.length > 0) {
6897
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
6998

‎packages/server-legacy/test/auth/middleware/bearerAuth.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,4 +498,82 @@ describe('requireBearerAuth middleware', () => {
498498
expect(nextFunction).not.toHaveBeenCalled();
499499
});
500500
});
501+
502+
describe('with expectedResource', () => {
503+
const expectedResource = new URL('https://api.example.com/mcp');
504+
const notIssuedForThisResource = { error: 'invalid_token', error_description: 'Token was not issued for this resource' };
505+
506+
async function handle(reported: string | undefined, options: { expectedResource?: URL; requiredScopes?: string[] } = {}) {
507+
const authInfo: AuthInfo = {
508+
token: 'valid-token',
509+
clientId: 'client-123',
510+
scopes: ['read'],
511+
expiresAt: Date.now() / 1000 + 3600
512+
};
513+
if (reported !== undefined) authInfo.resource = new URL(reported);
514+
mockVerifyAccessToken.mockResolvedValue(authInfo);
515+
const req = { headers: { authorization: 'Bearer valid-token' } } as Request;
516+
const res = createExpressResponseMock();
517+
const next = vi.fn();
518+
await requireBearerAuth({ verifier: mockVerifier, ...options })(req, res, next);
519+
return { req, res, next, authInfo };
520+
}
521+
522+
function expectAccepted({ req, res, next, authInfo }: Awaited<ReturnType<typeof handle>>) {
523+
expect(req.auth).toEqual(authInfo);
524+
expect(next).toHaveBeenCalled();
525+
expect(res.status).not.toHaveBeenCalled();
526+
}
527+
528+
function expectUnauthorized({ req, res, next }: Awaited<ReturnType<typeof handle>>) {
529+
expect(res.status).toHaveBeenCalledWith(401);
530+
expect(res.set).toHaveBeenCalledWith(
531+
'WWW-Authenticate',
532+
expect.stringMatching(/^Bearer error="invalid_token", error_description="Token was not issued for this resource"/)
533+
);
534+
expect(res.json).toHaveBeenCalledWith(notIssuedForThisResource);
535+
expect(req.auth).toBeUndefined();
536+
expect(next).not.toHaveBeenCalled();
537+
}
538+
539+
it('should compare AuthInfo.resource only when expectedResource is set', async () => {
540+
expectAccepted(await handle('https://other.example.com/mcp'));
541+
expectUnauthorized(await handle('https://other.example.com/mcp', { expectedResource }));
542+
});
543+
544+
it('should reject a token without a reported resource only when expectedResource is set', async () => {
545+
expectAccepted(await handle(undefined));
546+
expectUnauthorized(await handle(undefined, { expectedResource }));
547+
});
548+
549+
it('should accept a token reported for expectedResource, one trailing slash aside on either value', async () => {
550+
expectAccepted(await handle('https://api.example.com/mcp', { expectedResource }));
551+
expectAccepted(await handle('https://api.example.com/mcp/', { expectedResource }));
552+
expectAccepted(await handle('https://api.example.com/mcp', { expectedResource: new URL('https://api.example.com/mcp/') }));
553+
expectAccepted(await handle('HTTPS://API.example.com:443/mcp', { expectedResource }));
554+
expectUnauthorized(await handle('https://api.example.com/mcp//', { expectedResource }));
555+
});
556+
557+
it('should ignore a fragment on either value', async () => {
558+
expectAccepted(await handle('https://api.example.com/mcp#section', { expectedResource }));
559+
expectAccepted(await handle('https://api.example.com/mcp', { expectedResource: new URL(`${expectedResource}#section`) }));
560+
expectAccepted(await handle('https://api.example.com/mcp/#section', { expectedResource }));
561+
expectUnauthorized(await handle('https://api.example.com/other#section', { expectedResource }));
562+
});
563+
564+
it.each([
565+
'https://other.example.com/mcp',
566+
'http://api.example.com/mcp',
567+
'https://api.example.com:8443/mcp',
568+
'https://api.example.com/',
569+
'https://api.example.com/mcp/tools',
570+
'https://api.example.com/mcp?tenant=a'
571+
])('should return 401 for a token reported for %s', async reported => {
572+
expectUnauthorized(await handle(reported, { expectedResource }));
573+
});
574+
575+
it('should return 401 for a token reported for another resource before checking scopes', async () => {
576+
expectUnauthorized(await handle('https://other.example.com/mcp', { expectedResource, requiredScopes: ['write'] }));
577+
});
578+
});
501579
});

0 commit comments

Comments
 (0)