Skip to content

Commit 85d79e5

Browse files
Merge branch 'main' into feature/SEP-973-icons-type-spec-tests
2 parents 5e65e64 + 1d475bb commit 85d79e5

File tree

4 files changed

+321
-9
lines changed

4 files changed

+321
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ mcpServer.registerTool(
437437
async function main() {
438438
const transport = new StdioServerTransport();
439439
await mcpServer.connect(transport);
440-
console.log("MCP server is running...");
440+
console.error("MCP server is running...");
441441
}
442442

443443
main().catch((error) => {
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { Response } from 'express';
2+
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js';
3+
import { AuthorizationParams } from '../../server/auth/provider.js';
4+
import { OAuthClientInformationFull } from '../../shared/auth.js';
5+
import { InvalidRequestError } from '../../server/auth/errors.js';
6+
7+
describe('DemoInMemoryAuthProvider', () => {
8+
let provider: DemoInMemoryAuthProvider;
9+
let mockResponse: Response & { getRedirectUrl: () => string };
10+
11+
const createMockResponse = (): Response & { getRedirectUrl: () => string } => {
12+
let capturedRedirectUrl: string | undefined;
13+
14+
const mockRedirect = jest.fn().mockImplementation((url: string | number, status?: number) => {
15+
if (typeof url === 'string') {
16+
capturedRedirectUrl = url;
17+
} else if (typeof status === 'string') {
18+
capturedRedirectUrl = status;
19+
}
20+
return mockResponse;
21+
});
22+
23+
const mockResponse = {
24+
redirect: mockRedirect,
25+
status: jest.fn().mockReturnThis(),
26+
json: jest.fn().mockReturnThis(),
27+
send: jest.fn().mockReturnThis(),
28+
getRedirectUrl: () => {
29+
if (capturedRedirectUrl === undefined) {
30+
throw new Error('No redirect URL was captured. Ensure redirect() was called first.');
31+
}
32+
return capturedRedirectUrl;
33+
},
34+
} as unknown as Response & { getRedirectUrl: () => string };
35+
36+
return mockResponse;
37+
};
38+
39+
beforeEach(() => {
40+
provider = new DemoInMemoryAuthProvider();
41+
mockResponse = createMockResponse();
42+
});
43+
44+
describe('authorize', () => {
45+
const validClient: OAuthClientInformationFull = {
46+
client_id: 'test-client',
47+
client_secret: 'test-secret',
48+
redirect_uris: [
49+
'https://example.com/callback',
50+
'https://example.com/callback2'
51+
],
52+
scope: 'test-scope'
53+
};
54+
55+
it('should redirect to the requested redirect_uri when valid', async () => {
56+
const params: AuthorizationParams = {
57+
redirectUri: 'https://example.com/callback',
58+
state: 'test-state',
59+
codeChallenge: 'test-challenge',
60+
scopes: ['test-scope']
61+
};
62+
63+
await provider.authorize(validClient, params, mockResponse);
64+
65+
expect(mockResponse.redirect).toHaveBeenCalled();
66+
expect(mockResponse.getRedirectUrl()).toBeDefined();
67+
68+
const url = new URL(mockResponse.getRedirectUrl());
69+
expect(url.origin + url.pathname).toBe('https://example.com/callback');
70+
expect(url.searchParams.get('state')).toBe('test-state');
71+
expect(url.searchParams.has('code')).toBe(true);
72+
});
73+
74+
it('should throw InvalidRequestError for unregistered redirect_uri', async () => {
75+
const params: AuthorizationParams = {
76+
redirectUri: 'https://evil.com/callback',
77+
state: 'test-state',
78+
codeChallenge: 'test-challenge',
79+
scopes: ['test-scope']
80+
};
81+
82+
await expect(
83+
provider.authorize(validClient, params, mockResponse)
84+
).rejects.toThrow(InvalidRequestError);
85+
86+
await expect(
87+
provider.authorize(validClient, params, mockResponse)
88+
).rejects.toThrow('Unregistered redirect_uri');
89+
90+
expect(mockResponse.redirect).not.toHaveBeenCalled();
91+
});
92+
93+
it('should generate unique authorization codes for multiple requests', async () => {
94+
const params1: AuthorizationParams = {
95+
redirectUri: 'https://example.com/callback',
96+
state: 'state-1',
97+
codeChallenge: 'challenge-1',
98+
scopes: ['test-scope']
99+
};
100+
101+
const params2: AuthorizationParams = {
102+
redirectUri: 'https://example.com/callback',
103+
state: 'state-2',
104+
codeChallenge: 'challenge-2',
105+
scopes: ['test-scope']
106+
};
107+
108+
await provider.authorize(validClient, params1, mockResponse);
109+
const firstRedirectUrl = mockResponse.getRedirectUrl();
110+
const firstCode = new URL(firstRedirectUrl).searchParams.get('code');
111+
112+
// Reset the mock for the second call
113+
mockResponse = createMockResponse();
114+
await provider.authorize(validClient, params2, mockResponse);
115+
const secondRedirectUrl = mockResponse.getRedirectUrl();
116+
const secondCode = new URL(secondRedirectUrl).searchParams.get('code');
117+
118+
expect(firstCode).toBeDefined();
119+
expect(secondCode).toBeDefined();
120+
expect(firstCode).not.toBe(secondCode);
121+
});
122+
123+
it('should handle params without state', async () => {
124+
const params: AuthorizationParams = {
125+
redirectUri: 'https://example.com/callback',
126+
codeChallenge: 'test-challenge',
127+
scopes: ['test-scope']
128+
};
129+
130+
await provider.authorize(validClient, params, mockResponse);
131+
132+
expect(mockResponse.redirect).toHaveBeenCalled();
133+
expect(mockResponse.getRedirectUrl()).toBeDefined();
134+
135+
const url = new URL(mockResponse.getRedirectUrl());
136+
expect(url.searchParams.has('state')).toBe(false);
137+
expect(url.searchParams.has('code')).toBe(true);
138+
});
139+
});
140+
141+
describe('challengeForAuthorizationCode', () => {
142+
const validClient: OAuthClientInformationFull = {
143+
client_id: 'test-client',
144+
client_secret: 'test-secret',
145+
redirect_uris: ['https://example.com/callback'],
146+
scope: 'test-scope'
147+
};
148+
149+
it('should return the code challenge for a valid authorization code', async () => {
150+
const params: AuthorizationParams = {
151+
redirectUri: 'https://example.com/callback',
152+
state: 'test-state',
153+
codeChallenge: 'test-challenge-value',
154+
scopes: ['test-scope']
155+
};
156+
157+
await provider.authorize(validClient, params, mockResponse);
158+
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
159+
160+
const challenge = await provider.challengeForAuthorizationCode(validClient, code);
161+
expect(challenge).toBe('test-challenge-value');
162+
});
163+
164+
it('should throw error for invalid authorization code', async () => {
165+
await expect(
166+
provider.challengeForAuthorizationCode(validClient, 'invalid-code')
167+
).rejects.toThrow('Invalid authorization code');
168+
});
169+
});
170+
171+
describe('exchangeAuthorizationCode', () => {
172+
const validClient: OAuthClientInformationFull = {
173+
client_id: 'test-client',
174+
client_secret: 'test-secret',
175+
redirect_uris: ['https://example.com/callback'],
176+
scope: 'test-scope'
177+
};
178+
179+
it('should exchange valid authorization code for tokens', async () => {
180+
const params: AuthorizationParams = {
181+
redirectUri: 'https://example.com/callback',
182+
state: 'test-state',
183+
codeChallenge: 'test-challenge',
184+
scopes: ['test-scope', 'other-scope']
185+
};
186+
187+
await provider.authorize(validClient, params, mockResponse);
188+
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
189+
190+
const tokens = await provider.exchangeAuthorizationCode(validClient, code);
191+
192+
expect(tokens).toEqual({
193+
access_token: expect.any(String),
194+
token_type: 'bearer',
195+
expires_in: 3600,
196+
scope: 'test-scope other-scope'
197+
});
198+
});
199+
200+
it('should throw error for invalid authorization code', async () => {
201+
await expect(
202+
provider.exchangeAuthorizationCode(validClient, 'invalid-code')
203+
).rejects.toThrow('Invalid authorization code');
204+
});
205+
206+
it('should throw error when client_id does not match', async () => {
207+
const params: AuthorizationParams = {
208+
redirectUri: 'https://example.com/callback',
209+
state: 'test-state',
210+
codeChallenge: 'test-challenge',
211+
scopes: ['test-scope']
212+
};
213+
214+
await provider.authorize(validClient, params, mockResponse);
215+
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
216+
217+
const differentClient: OAuthClientInformationFull = {
218+
client_id: 'different-client',
219+
client_secret: 'different-secret',
220+
redirect_uris: ['https://example.com/callback'],
221+
scope: 'test-scope'
222+
};
223+
224+
await expect(
225+
provider.exchangeAuthorizationCode(differentClient, code)
226+
).rejects.toThrow('Authorization code was not issued to this client');
227+
});
228+
229+
it('should delete authorization code after successful exchange', async () => {
230+
const params: AuthorizationParams = {
231+
redirectUri: 'https://example.com/callback',
232+
state: 'test-state',
233+
codeChallenge: 'test-challenge',
234+
scopes: ['test-scope']
235+
};
236+
237+
await provider.authorize(validClient, params, mockResponse);
238+
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
239+
240+
// First exchange should succeed
241+
await provider.exchangeAuthorizationCode(validClient, code);
242+
243+
// Second exchange should fail
244+
await expect(
245+
provider.exchangeAuthorizationCode(validClient, code)
246+
).rejects.toThrow('Invalid authorization code');
247+
});
248+
249+
it('should validate resource when validateResource is provided', async () => {
250+
const validateResource = jest.fn().mockReturnValue(false);
251+
const strictProvider = new DemoInMemoryAuthProvider(validateResource);
252+
253+
const params: AuthorizationParams = {
254+
redirectUri: 'https://example.com/callback',
255+
state: 'test-state',
256+
codeChallenge: 'test-challenge',
257+
scopes: ['test-scope'],
258+
resource: new URL('https://invalid-resource.com')
259+
};
260+
261+
await strictProvider.authorize(validClient, params, mockResponse);
262+
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
263+
264+
await expect(
265+
strictProvider.exchangeAuthorizationCode(validClient, code)
266+
).rejects.toThrow('Invalid resource: https://invalid-resource.com/');
267+
268+
expect(validateResource).toHaveBeenCalledWith(params.resource);
269+
});
270+
});
271+
272+
describe('DemoInMemoryClientsStore', () => {
273+
let store: DemoInMemoryClientsStore;
274+
275+
beforeEach(() => {
276+
store = new DemoInMemoryClientsStore();
277+
});
278+
279+
it('should register and retrieve client', async () => {
280+
const client: OAuthClientInformationFull = {
281+
client_id: 'test-client',
282+
client_secret: 'test-secret',
283+
redirect_uris: ['https://example.com/callback'],
284+
scope: 'test-scope'
285+
};
286+
287+
await store.registerClient(client);
288+
const retrieved = await store.getClient('test-client');
289+
290+
expect(retrieved).toEqual(client);
291+
});
292+
293+
it('should return undefined for non-existent client', async () => {
294+
const retrieved = await store.getClient('non-existent');
295+
expect(retrieved).toBeUndefined();
296+
});
297+
});
298+
});

src/examples/server/demoInMemoryOAuthProvider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import express, { Request, Response } from "express";
66
import { AuthInfo } from '../../server/auth/types.js';
77
import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js';
88
import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js';
9+
import { InvalidRequestError } from '../../server/auth/errors.js';
910

1011

1112
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
@@ -57,7 +58,10 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
5758
params
5859
});
5960

60-
const targetUrl = new URL(client.redirect_uris[0]);
61+
if (!client.redirect_uris.includes(params.redirectUri)) {
62+
throw new InvalidRequestError("Unregistered redirect_uri");
63+
}
64+
const targetUrl = new URL(params.redirectUri);
6165
targetUrl.search = searchParams.toString();
6266
res.redirect(targetUrl.toString());
6367
}

src/server/auth/router.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export type AuthRouterOptions = {
4141
*/
4242
resourceName?: string;
4343

44+
/**
45+
* The URL of the protected resource (RS) whose metadata we advertise.
46+
* If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS).
47+
*/
48+
resourceServerUrl?: URL;
49+
4450
// Individual options per route
4551
authorizationOptions?: Omit<AuthorizationHandlerOptions, "provider">;
4652
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, "clientsStore">;
@@ -130,8 +136,8 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
130136

131137
router.use(mcpAuthMetadataRouter({
132138
oauthMetadata,
133-
// This router is used for AS+RS combo's, so the issuer is also the resource server
134-
resourceServerUrl: new URL(oauthMetadata.issuer),
139+
// Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat)
140+
resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer),
135141
serviceDocumentationUrl: options.serviceDocumentationUrl,
136142
scopesSupported: options.scopesSupported,
137143
resourceName: options.resourceName
@@ -185,7 +191,7 @@ export type AuthMetadataOptions = {
185191
resourceName?: string;
186192
}
187193

188-
export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
194+
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router {
189195
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
190196

191197
const router = express.Router();
@@ -202,9 +208,11 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
202208
resource_documentation: options.serviceDocumentationUrl?.href,
203209
};
204210

205-
router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata));
211+
// Serve PRM at the path-specific URL per RFC 9728
212+
const rsPath = new URL(options.resourceServerUrl.href).pathname;
213+
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));
206214

207-
// Always add this for backwards compatibility
215+
// Always add this for OAuth Authorization Server metadata per RFC 8414
208216
router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata));
209217

210218
return router;
@@ -219,8 +227,10 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
219227
*
220228
* @example
221229
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
222-
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource'
230+
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
223231
*/
224232
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
225-
return new URL('/.well-known/oauth-protected-resource', serverUrl).href;
233+
const u = new URL(serverUrl.href);
234+
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
235+
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
226236
}

0 commit comments

Comments
 (0)