/**
* postgres-mcp - Token Validator Tests
*
* Tests for JWT token validation with JWKS support.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TokenValidator, createTokenValidator } from '../TokenValidator.js';
// Mock jose module
vi.mock('jose', () => ({
jwtVerify: vi.fn(),
createRemoteJWKSet: vi.fn(() => {
return () => Promise.resolve({ type: 'remote' });
}),
errors: {
JWTExpired: class JWTExpired extends Error {
constructor(message = 'jwt expired', _claim?: string, _reason?: string) {
super(message);
this.name = 'JWTExpired';
}
},
JWSSignatureVerificationFailed: class JWSSignatureVerificationFailed extends Error {
constructor(message = 'signature verification failed', _cause?: Error) {
super(message);
this.name = 'JWSSignatureVerificationFailed';
}
},
JWTClaimValidationFailed: class JWTClaimValidationFailed extends Error {
constructor(message = 'claim validation failed', _claim?: string, _reason?: string) {
super(message);
this.name = 'JWTClaimValidationFailed';
}
}
}
}));
// Import after mock
const jose = await import('jose');
describe('TokenValidator', () => {
const defaultConfig = {
jwksUri: 'http://localhost:8080/realms/postgres-mcp/protocol/openid-connect/certs',
issuer: 'http://localhost:8080/realms/postgres-mcp',
audience: 'postgres-mcp'
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default config values', () => {
const validator = new TokenValidator(defaultConfig);
expect(validator).toBeInstanceOf(TokenValidator);
});
it('should accept custom config values', () => {
const validator = new TokenValidator({
...defaultConfig,
clockTolerance: 120,
jwksCacheTtl: 7200,
algorithms: ['RS256']
});
expect(validator).toBeInstanceOf(TokenValidator);
});
});
describe('validate', () => {
it('should return valid result for valid token', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValueOnce({
payload: {
sub: 'user123',
scope: 'read write',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
iss: 'http://localhost:8080/realms/postgres-mcp',
aud: 'postgres-mcp'
},
protectedHeader: { alg: 'RS256' }
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('valid.jwt.token');
expect(result.valid).toBe(true);
expect(result.claims?.sub).toBe('user123');
expect(result.claims?.scopes).toEqual(['read', 'write']);
});
it('should handle tokens without scope claim', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValueOnce({
payload: {
sub: 'user123',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000)
},
protectedHeader: { alg: 'RS256' }
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('token.without.scope');
expect(result.valid).toBe(true);
expect(result.claims?.scopes).toEqual([]);
});
it('should return TOKEN_EXPIRED error for expired tokens', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValueOnce(new jose.errors.JWTExpired('jwt expired', { exp: 0 }, 'exp'));
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('expired.jwt.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('TOKEN_EXPIRED');
});
it('should return INVALID_SIGNATURE for bad signatures', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValueOnce(
new jose.errors.JWSSignatureVerificationFailed('signature verification failed')
);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('bad.sig.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_SIGNATURE');
});
it('should return INVALID_CLAIMS for claim validation failures', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValueOnce(
new jose.errors.JWTClaimValidationFailed('claim validation failed', { aud: 'wrong' }, 'aud')
);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('wrong.claims.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_CLAIMS');
});
it('should return INVALID_TOKEN for generic errors', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValueOnce(new Error('Unknown error'));
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('malformed.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_TOKEN');
});
it('should extract client_id from payload', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValueOnce({
payload: {
sub: 'user123',
scope: 'read',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
client_id: 'my-client-app'
},
protectedHeader: { alg: 'RS256' }
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('token.with.clientid');
expect(result.claims?.client_id).toBe('my-client-app');
});
});
describe('invalidateCache', () => {
it('should clear the JWKS cache', () => {
const validator = new TokenValidator(defaultConfig);
// This should not throw
expect(() => validator.invalidateCache()).not.toThrow();
});
});
describe('createTokenValidator factory', () => {
it('should create a TokenValidator instance', () => {
const validator = createTokenValidator(defaultConfig);
expect(validator).toBeInstanceOf(TokenValidator);
});
});
});
// =============================================================================
// Phase 4: TokenValidator Branch Coverage
// =============================================================================
describe('TokenValidator (Branch Coverage)', () => {
const defaultConfig = {
jwksUri: 'http://localhost:8080/realms/postgres-mcp/protocol/openid-connect/certs',
issuer: 'http://localhost:8080/realms/postgres-mcp',
audience: 'postgres-mcp'
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should use cached JWKS on second validation (line 81 cache hit)', async () => {
// First validation - creates cache
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {
sub: 'user1',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000)
},
protectedHeader: { alg: 'RS256' }
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const validator = new TokenValidator(defaultConfig);
// First call creates the cache
await validator.validate('first.jwt.token');
// Second call should hit the cache (line 81)
await validator.validate('second.jwt.token');
// createRemoteJWKSet should only be called once (cached on second call)
expect(jose.createRemoteJWKSet).toHaveBeenCalledTimes(1);
});
it('should refresh JWKS cache after TTL expires (lines 80-87)', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {
sub: 'user1',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000)
},
protectedHeader: { alg: 'RS256' }
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
// Create validator with very short TTL
const validator = new TokenValidator({
...defaultConfig,
jwksCacheTtl: 0 // 0 seconds - always expires
});
await validator.validate('token1');
await validator.validate('token2');
// With 0 TTL, cache always expires, so should create new JWKS set each time
expect(jose.createRemoteJWKSet).toHaveBeenCalledTimes(2);
});
it('should handle non-Error exception in handleValidationError (line 129)', async () => {
// Reject with a non-Error value (string)
vi.mocked(jose.jwtVerify).mockRejectedValueOnce('string error' as unknown);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('weird.error.token');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe('INVALID_TOKEN');
expect(result.error).toBe('Token validation failed'); // fallback message
});
it('should handle tokens with array scope claim', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValueOnce({
payload: {
sub: 'user123',
scope: 'admin write read', // space-separated scopes
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000)
},
protectedHeader: { alg: 'RS256' }
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const validator = new TokenValidator(defaultConfig);
const result = await validator.validate('token.with.scopes');
expect(result.claims?.scopes).toContain('admin');
expect(result.claims?.scopes).toContain('write');
expect(result.claims?.scopes).toContain('read');
});
});