|
1 | | -import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; |
| 1 | +import { |
| 2 | + InvalidGrantError, |
| 3 | + InvalidTokenError, |
| 4 | +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; |
2 | 5 | import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; |
3 | 6 | import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; |
| 7 | +import { SignJWT } from 'jose'; |
4 | 8 | import { describe, expect, it, vi } from 'vitest'; |
5 | 9 | import type { TokenStore } from '../storage/token-store.js'; |
6 | 10 | import type { RedisClientStore } from './client-store.js'; |
7 | 11 | import type { FreeeOAuthProviderDeps } from './oauth-provider.js'; |
8 | 12 | import { FreeeOAuthProvider } from './oauth-provider.js'; |
9 | 13 | import type { AuthCodeData, OAuthStateStore, RefreshTokenData } from './oauth-store.js'; |
| 14 | +import { RequestRecorder, withRequestRecorder } from './request-context.js'; |
10 | 15 |
|
11 | 16 | const TEST_SECRET = 'test-jwt-secret-long-enough-for-hmac-signing'; |
12 | 17 | const TEST_ISSUER = 'https://mcp.example.com'; |
@@ -287,10 +292,91 @@ describe('FreeeOAuthProvider', () => { |
287 | 292 | expect(authInfo.extra?.tokenStore).toBe(tokenStore); |
288 | 293 | }); |
289 | 294 |
|
290 | | - it('rejects invalid JWT', async () => { |
| 295 | + it('rejects a malformed (non-JWT) token as InvalidTokenError', async () => { |
291 | 296 | const { provider } = createProvider(); |
292 | 297 |
|
293 | | - await expect(provider.verifyAccessToken('invalid-jwt')).rejects.toThrow(); |
| 298 | + await expect(provider.verifyAccessToken('invalid-jwt')).rejects.toThrow(InvalidTokenError); |
| 299 | + }); |
| 300 | + |
| 301 | + it('maps jose JWTExpired to InvalidTokenError("Token has expired")', async () => { |
| 302 | + const { provider } = createProvider(); |
| 303 | + const pastTime = Math.floor(Date.now() / 1000) - 7200; |
| 304 | + const expiredToken = await new SignJWT({ scope: 'mcp:read', client_id: 'test-client-id' }) |
| 305 | + .setProtectedHeader({ alg: 'HS256' }) |
| 306 | + .setSubject('user-1') |
| 307 | + .setIssuer(TEST_ISSUER) |
| 308 | + .setIssuedAt(pastTime) |
| 309 | + .setExpirationTime(pastTime + 3600) |
| 310 | + .sign(new TextEncoder().encode(TEST_SECRET)); |
| 311 | + |
| 312 | + await expect(provider.verifyAccessToken(expiredToken)).rejects.toThrow( |
| 313 | + new InvalidTokenError('Token has expired'), |
| 314 | + ); |
| 315 | + }); |
| 316 | + |
| 317 | + it('maps jose JWSSignatureVerificationFailed to InvalidTokenError', async () => { |
| 318 | + const { provider } = createProvider(); |
| 319 | + const foreignToken = await new SignJWT({ scope: 'mcp:read', client_id: 'test-client-id' }) |
| 320 | + .setProtectedHeader({ alg: 'HS256' }) |
| 321 | + .setSubject('user-1') |
| 322 | + .setIssuer(TEST_ISSUER) |
| 323 | + .setIssuedAt() |
| 324 | + .setExpirationTime('1h') |
| 325 | + .sign(new TextEncoder().encode('different-jwt-secret-long-enough-for-hmac-signing')); |
| 326 | + |
| 327 | + await expect(provider.verifyAccessToken(foreignToken)).rejects.toThrow( |
| 328 | + new InvalidTokenError('Invalid token signature'), |
| 329 | + ); |
| 330 | + }); |
| 331 | + |
| 332 | + it('maps jose JWTClaimValidationFailed (wrong issuer) to InvalidTokenError', async () => { |
| 333 | + const { provider } = createProvider(); |
| 334 | + const wrongIssuerToken = await new SignJWT({ scope: 'mcp:read', client_id: 'test-client-id' }) |
| 335 | + .setProtectedHeader({ alg: 'HS256' }) |
| 336 | + .setSubject('user-1') |
| 337 | + .setIssuer('https://wrong.example.com') |
| 338 | + .setIssuedAt() |
| 339 | + .setExpirationTime('1h') |
| 340 | + .sign(new TextEncoder().encode(TEST_SECRET)); |
| 341 | + |
| 342 | + await expect(provider.verifyAccessToken(wrongIssuerToken)).rejects.toThrowError( |
| 343 | + InvalidTokenError, |
| 344 | + ); |
| 345 | + }); |
| 346 | + |
| 347 | + it('records auth error to the current RequestRecorder on expired token', async () => { |
| 348 | + const { provider } = createProvider(); |
| 349 | + const pastTime = Math.floor(Date.now() / 1000) - 7200; |
| 350 | + const expiredToken = await new SignJWT({ scope: 'mcp:read', client_id: 'test-client-id' }) |
| 351 | + .setProtectedHeader({ alg: 'HS256' }) |
| 352 | + .setSubject('user-1') |
| 353 | + .setIssuer(TEST_ISSUER) |
| 354 | + .setIssuedAt(pastTime) |
| 355 | + .setExpirationTime(pastTime + 3600) |
| 356 | + .sign(new TextEncoder().encode(TEST_SECRET)); |
| 357 | + |
| 358 | + const recorder = new RequestRecorder({ |
| 359 | + request_id: 'req-1', |
| 360 | + source_ip: '127.0.0.1', |
| 361 | + method: 'POST', |
| 362 | + path: '/mcp', |
| 363 | + }); |
| 364 | + |
| 365 | + await withRequestRecorder(recorder, async () => { |
| 366 | + await expect(provider.verifyAccessToken(expiredToken)).rejects.toThrow(InvalidTokenError); |
| 367 | + }); |
| 368 | + |
| 369 | + const payload = recorder.buildPayload({ status: 401, duration_ms: 1 }); |
| 370 | + expect(payload.errors).toHaveLength(1); |
| 371 | + expect(payload.errors[0]).toMatchObject({ |
| 372 | + source: 'auth', |
| 373 | + status_code: 401, |
| 374 | + error_type: 'invalid_token', |
| 375 | + }); |
| 376 | + expect(payload.errors[0]?.chain[0]).toMatchObject({ |
| 377 | + name: 'JWTExpired', |
| 378 | + message: 'Token has expired', |
| 379 | + }); |
294 | 380 | }); |
295 | 381 | }); |
296 | 382 |
|
|
0 commit comments