Skip to content

Commit 9841a6c

Browse files
Use redirect Uri passed in in demoInMemoryOAuthProvider (#931)
1 parent c342dac commit 9841a6c

File tree

2 files changed

+303
-1
lines changed

2 files changed

+303
-1
lines changed
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
}

0 commit comments

Comments
 (0)