Skip to content

Commit 7edc188

Browse files
committed
add/fix tests
1 parent b0ef03e commit 7edc188

File tree

2 files changed

+195
-1
lines changed

2 files changed

+195
-1
lines changed

src/client/auth.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2174,6 +2174,135 @@ describe('OAuth Authorization', () => {
21742174
expect(body.get('refresh_token')).toBe('refresh123');
21752175
});
21762176

2177+
it('uses scopes_supported from PRM when scope is not provided', async () => {
2178+
// Mock PRM with scopes_supported
2179+
mockFetch.mockImplementation(url => {
2180+
const urlString = url.toString();
2181+
2182+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2183+
return Promise.resolve({
2184+
ok: true,
2185+
status: 200,
2186+
json: async () => ({
2187+
resource: 'https://api.example.com/',
2188+
authorization_servers: ['https://auth.example.com'],
2189+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
2190+
})
2191+
});
2192+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2193+
return Promise.resolve({
2194+
ok: true,
2195+
status: 200,
2196+
json: async () => ({
2197+
issuer: 'https://auth.example.com',
2198+
authorization_endpoint: 'https://auth.example.com/authorize',
2199+
token_endpoint: 'https://auth.example.com/token',
2200+
registration_endpoint: 'https://auth.example.com/register',
2201+
response_types_supported: ['code'],
2202+
code_challenge_methods_supported: ['S256']
2203+
})
2204+
});
2205+
} else if (urlString.includes('/register')) {
2206+
return Promise.resolve({
2207+
ok: true,
2208+
status: 200,
2209+
json: async () => ({
2210+
client_id: 'test-client-id',
2211+
client_secret: 'test-client-secret',
2212+
redirect_uris: ['http://localhost:3000/callback'],
2213+
client_name: 'Test Client'
2214+
})
2215+
});
2216+
}
2217+
2218+
return Promise.resolve({ ok: false, status: 404 });
2219+
});
2220+
2221+
// Mock provider methods - no scope in clientMetadata
2222+
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
2223+
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
2224+
mockProvider.saveClientInformation = vi.fn();
2225+
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
2226+
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
2227+
2228+
// Call auth without scope parameter
2229+
const result = await auth(mockProvider, {
2230+
serverUrl: 'https://api.example.com/'
2231+
});
2232+
2233+
expect(result).toBe('REDIRECT');
2234+
2235+
// Verify the authorization URL includes the scopes from PRM
2236+
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
2237+
const authUrl: URL = redirectCall[0];
2238+
expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
2239+
});
2240+
2241+
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {
2242+
// Mock PRM with scopes_supported
2243+
mockFetch.mockImplementation(url => {
2244+
const urlString = url.toString();
2245+
2246+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2247+
return Promise.resolve({
2248+
ok: true,
2249+
status: 200,
2250+
json: async () => ({
2251+
resource: 'https://api.example.com/',
2252+
authorization_servers: ['https://auth.example.com'],
2253+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
2254+
})
2255+
});
2256+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2257+
return Promise.resolve({
2258+
ok: true,
2259+
status: 200,
2260+
json: async () => ({
2261+
issuer: 'https://auth.example.com',
2262+
authorization_endpoint: 'https://auth.example.com/authorize',
2263+
token_endpoint: 'https://auth.example.com/token',
2264+
registration_endpoint: 'https://auth.example.com/register',
2265+
response_types_supported: ['code'],
2266+
code_challenge_methods_supported: ['S256']
2267+
})
2268+
});
2269+
} else if (urlString.includes('/register')) {
2270+
return Promise.resolve({
2271+
ok: true,
2272+
status: 200,
2273+
json: async () => ({
2274+
client_id: 'test-client-id',
2275+
client_secret: 'test-client-secret',
2276+
redirect_uris: ['http://localhost:3000/callback'],
2277+
client_name: 'Test Client'
2278+
})
2279+
});
2280+
}
2281+
2282+
return Promise.resolve({ ok: false, status: 404 });
2283+
});
2284+
2285+
// Mock provider methods
2286+
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
2287+
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
2288+
mockProvider.saveClientInformation = vi.fn();
2289+
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
2290+
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
2291+
2292+
// Call auth with explicit scope parameter
2293+
const result = await auth(mockProvider, {
2294+
serverUrl: 'https://api.example.com/',
2295+
scope: 'mcp:read'
2296+
});
2297+
2298+
expect(result).toBe('REDIRECT');
2299+
2300+
// Verify the authorization URL uses the explicit scope, not scopes_supported
2301+
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
2302+
const authUrl: URL = redirectCall[0];
2303+
expect(authUrl.searchParams.get('scope')).toBe('mcp:read');
2304+
});
2305+
21772306
it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => {
21782307
// Mock PRM discovery that returns an external AS
21792308
mockFetch.mockImplementation(url => {

src/server/auth/middleware/bearerAuth.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,71 @@ describe('requireBearerAuth middleware', () => {
316316
expect(nextFunction).not.toHaveBeenCalled();
317317
});
318318

319+
describe('with requiredScopes in WWW-Authenticate header', () => {
320+
it('should include scope in WWW-Authenticate header for 401 responses when requiredScopes is provided', async () => {
321+
mockRequest.headers = {};
322+
323+
const middleware = requireBearerAuth({
324+
verifier: mockVerifier,
325+
requiredScopes: ['read', 'write']
326+
});
327+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
328+
329+
expect(mockResponse.status).toHaveBeenCalledWith(401);
330+
expect(mockResponse.set).toHaveBeenCalledWith(
331+
'WWW-Authenticate',
332+
'Bearer error="invalid_token", error_description="Missing Authorization header", scope="read write"'
333+
);
334+
expect(nextFunction).not.toHaveBeenCalled();
335+
});
336+
337+
it('should include scope in WWW-Authenticate header for 403 insufficient scope responses', async () => {
338+
const authInfo: AuthInfo = {
339+
token: 'valid-token',
340+
clientId: 'client-123',
341+
scopes: ['read']
342+
};
343+
mockVerifyAccessToken.mockResolvedValue(authInfo);
344+
345+
mockRequest.headers = {
346+
authorization: 'Bearer valid-token'
347+
};
348+
349+
const middleware = requireBearerAuth({
350+
verifier: mockVerifier,
351+
requiredScopes: ['read', 'write']
352+
});
353+
354+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
355+
356+
expect(mockResponse.status).toHaveBeenCalledWith(403);
357+
expect(mockResponse.set).toHaveBeenCalledWith(
358+
'WWW-Authenticate',
359+
'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write"'
360+
);
361+
expect(nextFunction).not.toHaveBeenCalled();
362+
});
363+
364+
it('should include both scope and resource_metadata in WWW-Authenticate header when both are provided', async () => {
365+
mockRequest.headers = {};
366+
367+
const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
368+
const middleware = requireBearerAuth({
369+
verifier: mockVerifier,
370+
requiredScopes: ['admin'],
371+
resourceMetadataUrl
372+
});
373+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
374+
375+
expect(mockResponse.status).toHaveBeenCalledWith(401);
376+
expect(mockResponse.set).toHaveBeenCalledWith(
377+
'WWW-Authenticate',
378+
`Bearer error="invalid_token", error_description="Missing Authorization header", scope="admin", resource_metadata="${resourceMetadataUrl}"`
379+
);
380+
expect(nextFunction).not.toHaveBeenCalled();
381+
});
382+
});
383+
319384
describe('with resourceMetadataUrl', () => {
320385
const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
321386

@@ -416,7 +481,7 @@ describe('requireBearerAuth middleware', () => {
416481
expect(mockResponse.status).toHaveBeenCalledWith(403);
417482
expect(mockResponse.set).toHaveBeenCalledWith(
418483
'WWW-Authenticate',
419-
`Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"`
484+
`Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write", resource_metadata="${resourceMetadataUrl}"`
420485
);
421486
expect(nextFunction).not.toHaveBeenCalled();
422487
});

0 commit comments

Comments
 (0)