/**
* mysql-mcp - TokenValidator Unit Tests
*
* Tests for JWT token validation.
*
* Note: These tests focus on the TokenValidator class interface.
* Full JWT validation testing would require mocking the jose library.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { TokenValidator, createTokenValidator } from '../TokenValidator.js';
// Mock jose library for comprehensive testing
vi.mock('jose', async () => {
const original = await vi.importActual<typeof import('jose')>('jose');
return {
...original,
createRemoteJWKSet: vi.fn(() => vi.fn()),
jwtVerify: vi.fn()
};
});
import * as jose from 'jose';
describe('TokenValidator', () => {
let validator: TokenValidator;
beforeEach(() => {
vi.clearAllMocks();
validator = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp'
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create validator with default config', () => {
expect(validator).toBeDefined();
});
it('should have invalidateCache method', () => {
expect(typeof validator.invalidateCache).toBe('function');
});
it('should have validate method', () => {
expect(typeof validator.validate).toBe('function');
});
it('should work with all configuration options', () => {
const validatorWithOptions = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp',
clockTolerance: 120,
jwksCacheTtl: 7200,
algorithms: ['RS256', 'ES256']
});
expect(validatorWithOptions).toBeDefined();
});
it('should invalidate cache without error', () => {
expect(() => validator.invalidateCache()).not.toThrow();
});
});
describe('TokenValidator Validation', () => {
let validator: TokenValidator;
const mockJwtVerify = jose.jwtVerify as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
validator = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp'
});
});
it('should validate token successfully with all claims', async () => {
mockJwtVerify.mockResolvedValue({
payload: {
sub: 'user123',
scope: 'read write admin',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
iss: 'https://auth.example.com',
aud: 'mysql-mcp',
nbf: Math.floor(Date.now() / 1000),
jti: 'unique-token-id',
client_id: 'my-client'
}
});
const result = await validator.validate('header.payload.signature');
expect(result.valid).toBe(true);
expect(result.claims?.sub).toBe('user123');
expect(result.claims?.scopes).toContain('read');
expect(result.claims?.scopes).toContain('write');
expect(result.claims?.client_id).toBe('my-client');
});
it('should handle token without optional claims', async () => {
mockJwtVerify.mockResolvedValue({
payload: {
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000)
}
});
const result = await validator.validate('header.payload.signature');
expect(result.valid).toBe(true);
expect(result.claims?.sub).toBe('');
expect(result.claims?.scopes).toEqual([]);
});
it('should return TOKEN_EXPIRED error for expired token', async () => {
const expiredError = new jose.errors.JWTExpired('Token has expired', {} as jose.JWTPayload);
mockJwtVerify.mockRejectedValue(expiredError);
const result = await validator.validate('expired.token.here');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('TOKEN_EXPIRED');
expect(result.error).toContain('expired');
});
it('should return INVALID_SIGNATURE error for bad signature', async () => {
const signatureError = new jose.errors.JWSSignatureVerificationFailed('Signature verification failed');
mockJwtVerify.mockRejectedValue(signatureError);
const result = await validator.validate('bad.signature.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_SIGNATURE');
expect(result.error).toContain('signature');
});
it('should return INVALID_CLAIMS error for claim validation failure', async () => {
const claimError = new jose.errors.JWTClaimValidationFailed('audience mismatch', {} as jose.JWTPayload, 'aud', 'mismatch');
mockJwtVerify.mockRejectedValue(claimError);
const result = await validator.validate('invalid.claims.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_CLAIMS');
expect(result.error).toContain('Claim validation failed');
});
it('should return INVALID_TOKEN for generic errors', async () => {
mockJwtVerify.mockRejectedValue(new Error('Something went wrong'));
const result = await validator.validate('some.token.here');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_TOKEN');
expect(result.error).toBe('Something went wrong');
});
it('should return INVALID_TOKEN for non-Error objects', async () => {
mockJwtVerify.mockRejectedValue('string error');
const result = await validator.validate('some.token.here');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_TOKEN');
expect(result.error).toBe('Token validation failed');
});
});
describe('TokenValidator JWKS Caching', () => {
let validator: TokenValidator;
const mockCreateRemoteJWKSet = jose.createRemoteJWKSet as ReturnType<typeof vi.fn>;
const mockJwtVerify = jose.jwtVerify as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
validator = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp',
jwksCacheTtl: 1 // 1 second for testing
});
});
it('should use cached JWKS on subsequent calls', async () => {
mockJwtVerify.mockResolvedValue({
payload: { sub: 'user', exp: 9999999999, iat: 1 }
});
await validator.validate('token1');
await validator.validate('token2');
expect(mockCreateRemoteJWKSet).toHaveBeenCalledTimes(1);
});
it('should refresh JWKS after cache expires', async () => {
mockJwtVerify.mockResolvedValue({
payload: { sub: 'user', exp: 9999999999, iat: 1 }
});
await validator.validate('token1');
// Invalidate cache manually
validator.invalidateCache();
await validator.validate('token2');
expect(mockCreateRemoteJWKSet).toHaveBeenCalledTimes(2);
});
it('should throw JwksFetchError if JWKS creation fails', async () => {
mockCreateRemoteJWKSet.mockImplementationOnce(() => {
throw new Error('Network error');
});
// Invalidate to force fetch
validator.invalidateCache();
// Should return invalid result with error message because getJWKS error is caught
const result = await validator.validate('token');
expect(result.valid).toBe(false);
expect(result.error).toContain('Failed to fetch JWKS');
});
});
describe('TokenValidator Configuration', () => {
it('should accept all configuration options', () => {
const validator = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp',
clockTolerance: 30
});
expect(validator).toBeDefined();
});
it('should work with required configuration', () => {
const validator = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'test-audience'
});
expect(validator).toBeDefined();
});
});
describe('createTokenValidator', () => {
it('should create TokenValidator instance', () => {
const validator = createTokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp'
});
expect(validator).toBeInstanceOf(TokenValidator);
});
});
describe('OAuth Security Edge Cases', () => {
let validator: TokenValidator;
const mockJwtVerify = jose.jwtVerify as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
validator = new TokenValidator({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'mysql-mcp',
clockTolerance: 30 // 30 seconds tolerance
});
});
it('should reject token with modified signature (INVALID_SIGNATURE)', async () => {
// Simulates an attacker modifying the token payload and signature
const signatureError = new jose.errors.JWSSignatureVerificationFailed(
'Signature verification failed - possible tampering'
);
mockJwtVerify.mockRejectedValue(signatureError);
const result = await validator.validate('modified.payload.badsig');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_SIGNATURE');
// Verify no sensitive data is leaked in error
expect(result.error).not.toContain('secret');
expect(result.error).not.toContain('key');
});
it('should reject token with future nbf (not before) claim', async () => {
// Token not yet valid - nbf is in the future
const claimError = new jose.errors.JWTClaimValidationFailed(
'Token is not yet valid (nbf)',
{ nbf: Math.floor(Date.now() / 1000) + 3600 } as jose.JWTPayload,
'nbf',
'check_failed'
);
mockJwtVerify.mockRejectedValue(claimError);
const result = await validator.validate('future.nbf.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_CLAIMS');
});
it('should reject severely expired token even with high clock tolerance', async () => {
// Token expired way beyond any reasonable clock tolerance
const expiredError = new jose.errors.JWTExpired(
'Token expired 24 hours ago',
{} as jose.JWTPayload
);
mockJwtVerify.mockRejectedValue(expiredError);
const result = await validator.validate('very.expired.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('TOKEN_EXPIRED');
});
it('should not leak JWKS URI in error messages', async () => {
mockJwtVerify.mockRejectedValue(new Error('Failed to fetch JWKS'));
const result = await validator.validate('any.token.here');
expect(result.valid).toBe(false);
// Ensure JWKS URI isn't exposed
expect(result.error).not.toContain('well-known');
expect(result.error).not.toContain('jwks');
});
it('should not leak issuer details in claim mismatch errors', async () => {
const claimError = new jose.errors.JWTClaimValidationFailed(
'unexpected "iss" claim value',
{ iss: 'https://attacker.com' } as jose.JWTPayload,
'iss',
'mismatch'
);
mockJwtVerify.mockRejectedValue(claimError);
const result = await validator.validate('wrong.issuer.token');
expect(result.valid).toBe(false);
// Error should be generic, not revealing expected issuer
expect(result.error).not.toContain('auth.example.com');
});
});