Skip to main content
Glama
jwt-service.test.ts21.7 kB
import { JWTService, JWTPayload, TokenPair } from '../../../src/auth/jwt-service'; import { MockFactory, TestDataGenerator } from '../../utils/test-helpers'; import { fixtures } from '../../utils/fixtures'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; // Mock dependencies jest.mock('../../../src/config/config', () => ({ config: { env: 'test', jwt: { secret: 'test-jwt-secret-must-be-at-least-32-characters-long', accessExpiresIn: '15m', refreshExpiresIn: '7d', issuer: 'secure-mcp-server', audience: 'secure-mcp-client', }, }, })); jest.mock('../../../src/database/redis', () => ({ redis: { get: jest.fn(), set: jest.fn(), setex: jest.fn(), del: jest.fn(), incr: jest.fn(), expire: jest.fn(), ttl: jest.fn(), sadd: jest.fn(), srem: jest.fn(), smembers: jest.fn(), hgetall: jest.fn(), hget: jest.fn(), hset: jest.fn(), keys: jest.fn(), scard: jest.fn(), scan: jest.fn(), memory: jest.fn(), exists: jest.fn(), ping: jest.fn(() => Promise.resolve('PONG')), disconnect: jest.fn(() => Promise.resolve()), pipeline: jest.fn(() => ({ setex: jest.fn().mockReturnThis(), sadd: jest.fn().mockReturnThis(), srem: jest.fn().mockReturnThis(), del: jest.fn().mockReturnThis(), expire: jest.fn().mockReturnThis(), ttl: jest.fn().mockReturnThis(), exec: jest.fn().mockResolvedValue([]), })), }, })); jest.mock('../../../src/security/vault', () => ({ vault: { read: jest.fn(), write: jest.fn(), health: jest.fn(() => Promise.resolve({ status: 'ok' })), delete: jest.fn(), }, })); jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), child: jest.fn(() => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), })), }, })); describe('JWTService', () => { let jwtService: JWTService; let mockRedis: any; let mockVault: any; beforeEach(() => { // Use fake timers to avoid timeouts from setInterval jest.useFakeTimers(); jwtService = new JWTService(); mockRedis = require('../../../src/database/redis').redis; mockVault = require('../../../src/security/vault').vault; // Reset mocks jest.clearAllMocks(); }); afterEach(() => { // Clean up fake timers jest.useRealTimers(); }); describe('initialization', () => { it('should initialize successfully in development environment', async () => { await jwtService.initialize(); expect(mockVault.read).not.toHaveBeenCalled(); }); it('should load secrets from Vault in production environment', async () => { const originalEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const mockSecrets = { data: { access_secret: 'vault-access-secret-must-be-at-least-32-chars', refresh_secret: 'vault-refresh-secret-must-be-at-least-32-chars', }, }; mockVault.read.mockResolvedValue(mockSecrets); await jwtService.initialize(); expect(mockVault.read).toHaveBeenCalledWith('auth/jwt'); process.env.NODE_ENV = originalEnv; }); it('should handle Vault errors gracefully', async () => { const originalEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; mockVault.read.mockRejectedValue(new Error('Vault unavailable')); await expect(jwtService.initialize()).rejects.toThrow('Vault unavailable'); process.env.NODE_ENV = originalEnv; }); }); describe('token generation', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should generate valid token pair', async () => { const userPayload = { userId: 'user-123', email: 'test@example.com', roles: ['user'], permissions: ['basic'], sessionId: 'session-123', mfaVerified: false, }; const tokenPair: TokenPair = await jwtService.generateTokenPair(userPayload); expect(tokenPair.accessToken).toBeValidJWT(); expect(tokenPair.refreshToken).toBeValidJWT(); expect(tokenPair.expiresIn).toBeGreaterThan(0); expect(tokenPair.refreshExpiresIn).toBeGreaterThan(tokenPair.expiresIn); // Verify Redis calls for token family and active token storage expect(mockRedis.setex).toHaveBeenCalledTimes(2); expect(mockRedis.setex).toHaveBeenCalledWith( expect.stringMatching(/^token_family:/), expect.any(Number), expect.stringContaining('user-123') ); expect(mockRedis.setex).toHaveBeenCalledWith( expect.stringMatching(/^active_token:/), expect.any(Number), 'valid' ); }); it('should generate tokens with unique JTIs', async () => { const userPayload = TestDataGenerator.generateUser(); const tokenPair1 = await jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-1', mfaVerified: false, }); const tokenPair2 = await jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-2', mfaVerified: false, }); const payload1 = jwt.decode(tokenPair1.accessToken) as JWTPayload; const payload2 = jwt.decode(tokenPair2.accessToken) as JWTPayload; expect(payload1.jti).not.toBe(payload2.jti); expect(payload1.sessionId).not.toBe(payload2.sessionId); }); it('should include all required claims in access token', async () => { const userPayload = { userId: 'user-123', email: 'test@example.com', roles: ['user', 'admin'], permissions: ['read', 'write', 'admin'], sessionId: 'session-123', mfaVerified: true, }; const tokenPair = await jwtService.generateTokenPair(userPayload); const payload = jwt.decode(tokenPair.accessToken) as JWTPayload; expect(payload.sub).toBe('user-123'); expect(payload.email).toBe('test@example.com'); expect(payload.roles).toEqual(['user', 'admin']); expect(payload.permissions).toEqual(['read', 'write', 'admin']); expect(payload.sessionId).toBe('session-123'); expect(payload.mfaVerified).toBe(true); expect(payload.iss).toBe('secure-mcp-server'); expect(payload.aud).toBe('secure-mcp-client'); expect(payload.jti).toBeDefined(); expect(payload.iat).toBeDefined(); expect(payload.exp).toBeDefined(); }); }); describe('token verification', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should verify valid access token', async () => { const userPayload = TestDataGenerator.generateJWTPayload(); const tokenPair = await jwtService.generateTokenPair({ userId: userPayload.sub, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: userPayload.sessionId, mfaVerified: userPayload.mfaVerified, }); mockRedis.get.mockResolvedValue('valid'); const payload = await jwtService.verifyAccessToken(tokenPair.accessToken); expect(payload.sub).toBe(userPayload.sub); expect(payload.email).toBe(userPayload.email); expect(mockRedis.get).toHaveBeenCalledWith(expect.stringMatching(/^active_token:/)); }); it('should reject revoked access token', async () => { const userPayload = TestDataGenerator.generateJWTPayload(); const tokenPair = await jwtService.generateTokenPair({ userId: userPayload.sub, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: userPayload.sessionId, mfaVerified: userPayload.mfaVerified, }); mockRedis.get.mockResolvedValue(null); // Token not found = revoked await expect(jwtService.verifyAccessToken(tokenPair.accessToken)) .rejects.toThrow('Invalid or expired access token'); }); it('should reject expired access token', async () => { const expiredPayload = { ...TestDataGenerator.generateJWTPayload(), exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago }; const expiredToken = jwt.sign(expiredPayload, 'test-jwt-secret-must-be-at-least-32-characters-long'); await expect(jwtService.verifyAccessToken(expiredToken)) .rejects.toThrow('Invalid or expired access token'); }); it('should reject token with invalid signature', async () => { const payload = TestDataGenerator.generateJWTPayload(); const invalidToken = jwt.sign(payload, 'wrong-secret'); await expect(jwtService.verifyAccessToken(invalidToken)) .rejects.toThrow('Invalid or expired access token'); }); it('should reject token with wrong issuer', async () => { const payload = { ...TestDataGenerator.generateJWTPayload(), iss: 'wrong-issuer', }; const token = jwt.sign(payload, 'test-jwt-secret-must-be-at-least-32-characters-long'); await expect(jwtService.verifyAccessToken(token)) .rejects.toThrow('Invalid or expired access token'); }); it('should reject token with wrong audience', async () => { const payload = { ...TestDataGenerator.generateJWTPayload(), aud: 'wrong-audience', }; const token = jwt.sign(payload, 'test-jwt-secret-must-be-at-least-32-characters-long'); await expect(jwtService.verifyAccessToken(token)) .rejects.toThrow('Invalid or expired access token'); }); }); describe('refresh token verification', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should verify valid refresh token', async () => { const userPayload = TestDataGenerator.generateUser(); const tokenPair = await jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-123', mfaVerified: false, }); // Mock token family exists mockRedis.get.mockResolvedValue(JSON.stringify({ userId: userPayload.id, sessionId: 'session-123', createdAt: new Date().toISOString(), })); const payload = await jwtService.verifyRefreshToken(tokenPair.refreshToken); expect(payload.sub).toBe(userPayload.id); expect(payload.sessionId).toBe('session-123'); }); it('should reject refresh token with non-existent token family', async () => { const userPayload = TestDataGenerator.generateUser(); const tokenPair = await jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-123', mfaVerified: false, }); mockRedis.get.mockResolvedValue(null); // Token family not found await expect(jwtService.verifyRefreshToken(tokenPair.refreshToken)) .rejects.toThrow('Invalid or expired refresh token'); }); }); describe('token refresh', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should refresh access token using valid refresh token', async () => { const userPayload = TestDataGenerator.generateUser(); const originalTokenPair = await jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-123', mfaVerified: false, }); // Mock token family and user data mockRedis.get .mockResolvedValueOnce(JSON.stringify({ userId: userPayload.id, sessionId: 'session-123', createdAt: new Date().toISOString(), })) .mockResolvedValueOnce(JSON.stringify({ email: userPayload.email, roles: JSON.stringify(userPayload.roles), permissions: JSON.stringify(userPayload.permissions), mfaVerified: 'false', })); const newTokenPair = await jwtService.refreshAccessToken(originalTokenPair.refreshToken); expect(newTokenPair.accessToken).toBeValidJWT(); expect(newTokenPair.refreshToken).toBeValidJWT(); expect(newTokenPair.accessToken).not.toBe(originalTokenPair.accessToken); // Verify old refresh token JTI is deleted expect(mockRedis.del).toHaveBeenCalledWith(expect.stringMatching(/^active_token:/)); }); it('should handle refresh token with missing user data', async () => { const userPayload = TestDataGenerator.generateUser(); const tokenPair = await jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-123', mfaVerified: false, }); mockRedis.get .mockResolvedValueOnce(JSON.stringify({ userId: userPayload.id, sessionId: 'session-123', createdAt: new Date().toISOString(), })) .mockResolvedValueOnce('{}'); // Empty user data const newTokenPair = await jwtService.refreshAccessToken(tokenPair.refreshToken); expect(newTokenPair.accessToken).toBeValidJWT(); // Should use default values for missing user data const payload = jwt.decode(newTokenPair.accessToken) as JWTPayload; expect(payload.email).toBe(''); expect(payload.roles).toEqual([]); expect(payload.permissions).toEqual([]); expect(payload.mfaVerified).toBe(false); }); }); describe('token revocation', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should revoke token by JTI', async () => { const jti = crypto.randomUUID(); await jwtService.revokeToken(jti); expect(mockRedis.del).toHaveBeenCalledWith(`active_token:${jti}`); }); it('should revoke all tokens for a session', async () => { const sessionId = 'session-123'; const mockTokenKeys = [ 'active_token:jti1', 'active_token:jti2', 'active_token:jti3', ]; const mockFamilyKeys = [ 'token_family:family1', 'token_family:family2', ]; mockRedis.keys .mockResolvedValueOnce(mockTokenKeys) .mockResolvedValueOnce(mockFamilyKeys); mockRedis.get .mockResolvedValue('valid') // For token keys .mockResolvedValueOnce(JSON.stringify({ sessionId })) .mockResolvedValueOnce(JSON.stringify({ sessionId: 'other-session' })); await jwtService.revokeSession(sessionId); expect(mockRedis.keys).toHaveBeenCalledWith('active_token:*'); expect(mockRedis.keys).toHaveBeenCalledWith('token_family:*'); expect(mockRedis.del).toHaveBeenCalledTimes(4); // 3 tokens + 1 matching family }); it('should revoke all tokens for a user', async () => { const userId = 'user-123'; const mockFamilyKeys = [ 'token_family:family1', 'token_family:family2', 'token_family:family3', ]; const mockTokenKeys = [ 'active_token:jti1', 'active_token:jti2', ]; mockRedis.keys .mockResolvedValueOnce(mockFamilyKeys) .mockResolvedValueOnce(mockTokenKeys); mockRedis.get .mockResolvedValueOnce(JSON.stringify({ userId })) .mockResolvedValueOnce(JSON.stringify({ userId: 'other-user' })) .mockResolvedValueOnce(JSON.stringify({ userId })); await jwtService.revokeAllUserTokens(userId); expect(mockRedis.del).toHaveBeenCalledTimes(4); // 2 matching families + 2 tokens }); }); describe('token utility methods', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should extract token from valid Authorization header', () => { const authHeader = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; const token = jwtService.extractTokenFromHeader(authHeader); expect(token).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'); }); it('should return null for invalid Authorization header format', () => { expect(jwtService.extractTokenFromHeader('InvalidHeader')).toBeNull(); expect(jwtService.extractTokenFromHeader('Basic dXNlcjpwYXNz')).toBeNull(); expect(jwtService.extractTokenFromHeader('')).toBeNull(); }); it('should validate JWT token format', () => { const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; const invalidTokens = [ 'invalid-token', 'one.two', // Missing third part 'one.two.three.four', // Too many parts '', // Empty string ]; expect(jwtService.isValidTokenFormat(validToken)).toBe(true); invalidTokens.forEach(token => { expect(jwtService.isValidTokenFormat(token)).toBe(false); }); }); it('should get token expiry time', () => { const payload = { exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now }; const token = jwt.sign(payload, 'secret'); const expiry = jwtService.getTokenExpiry(token); expect(expiry).toBeInstanceOf(Date); expect(expiry!.getTime()).toBeCloseTo(payload.exp * 1000, -3); // Within 1 second }); it('should return null for token without expiry', () => { const payload = { sub: 'user' }; // No exp claim const token = jwt.sign(payload, 'secret'); const expiry = jwtService.getTokenExpiry(token); expect(expiry).toBeNull(); }); it('should check if token is expired', () => { const expiredPayload = { exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago }; const validPayload = { exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now }; const expiredToken = jwt.sign(expiredPayload, 'secret'); const validToken = jwt.sign(validPayload, 'secret'); expect(jwtService.isTokenExpired(expiredToken)).toBe(true); expect(jwtService.isTokenExpired(validToken)).toBe(false); }); it('should consider malformed token as expired', () => { expect(jwtService.isTokenExpired('invalid-token')).toBe(true); expect(jwtService.isTokenExpired('')).toBe(true); }); }); describe('security considerations', () => { beforeEach(async () => { await jwtService.initialize(); }); it('should generate cryptographically secure JTIs', async () => { const userPayload = TestDataGenerator.generateUser(); const tokenPairs = await Promise.all( Array.from({ length: 10 }, () => jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-123', mfaVerified: false, }) ) ); const jtis = tokenPairs.map(pair => { const payload = jwt.decode(pair.accessToken) as JWTPayload; return payload.jti; }); // All JTIs should be unique const uniqueJtis = new Set(jtis); expect(uniqueJtis.size).toBe(jtis.length); // All JTIs should be valid UUIDs jtis.forEach(jti => { expect(jti).toBeValidUUID(); }); }); it('should handle Redis failures gracefully during token operations', async () => { const userPayload = TestDataGenerator.generateUser(); mockRedis.setex.mockRejectedValue(new Error('Redis connection failed')); await expect(jwtService.generateTokenPair({ userId: userPayload.id, email: userPayload.email, roles: userPayload.roles, permissions: userPayload.permissions, sessionId: 'session-123', mfaVerified: false, })).rejects.toThrow('Redis connection failed'); }); it('should not expose sensitive information in error messages', async () => { const malformedToken = 'definitely.not.jwt'; try { await jwtService.verifyAccessToken(malformedToken); fail('Should have thrown an error'); } catch (error) { expect(error.message).toBe('Invalid or expired access token'); expect(error.message).not.toContain(malformedToken); expect(error.message).not.toContain('secret'); } }); it('should properly clean up expired tokens during operations', async () => { const sessionId = 'session-123'; // Mock scenario where some tokens are already expired in Redis mockRedis.keys.mockResolvedValue([ 'token_family:expired-family', 'token_family:valid-family', ]); mockRedis.get .mockResolvedValueOnce(null) // Expired family returns null .mockResolvedValueOnce(JSON.stringify({ sessionId })); // Valid family await jwtService.revokeSession(sessionId); // Should handle null values gracefully expect(mockRedis.del).toHaveBeenCalledWith('token_family:valid-family'); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/perfecxion-ai/secure-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server