368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
/**
|
|
* @jest-environment node
|
|
* Comprehensive tests for auth-manager.js
|
|
* Tests JWT generation/validation, API key management, and security boundaries
|
|
*/
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const crypto = require('crypto');
|
|
const AuthManager = require('../auth-manager');
|
|
const credentialManager = require('../credential-manager');
|
|
|
|
// Mock credential manager
|
|
jest.mock('../credential-manager');
|
|
jest.mock('../logger-utils', () => ({
|
|
safeLog: jest.fn()
|
|
}));
|
|
|
|
describe('AuthManager', () => {
|
|
let authManager;
|
|
|
|
beforeEach(() => {
|
|
authManager = new AuthManager();
|
|
jest.clearAllMocks();
|
|
credentialManager.save.mockResolvedValue(true);
|
|
credentialManager.get.mockResolvedValue(null);
|
|
credentialManager.delete.mockResolvedValue(true);
|
|
credentialManager.list.mockResolvedValue([]);
|
|
});
|
|
|
|
describe('JWT Generation', () => {
|
|
test('should generate valid JWT token', async () => {
|
|
const payload = { sub: 'user123', role: 'admin' };
|
|
const token = await authManager.generateJWT(payload);
|
|
|
|
expect(token).toBeDefined();
|
|
expect(typeof token).toBe('string');
|
|
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
|
|
});
|
|
|
|
test('should include required claims in JWT', async () => {
|
|
const payload = { sub: 'user123', role: 'admin' };
|
|
const token = await authManager.generateJWT(payload);
|
|
const decoded = jwt.decode(token);
|
|
|
|
expect(decoded.sub).toBe('user123');
|
|
expect(decoded.role).toBe('admin');
|
|
expect(decoded.iat).toBeDefined();
|
|
expect(decoded.exp).toBeDefined();
|
|
expect(decoded.scope).toEqual(['read', 'write']); // default scopes
|
|
});
|
|
|
|
test('should respect custom expiration time', async () => {
|
|
const payload = { sub: 'user123' };
|
|
const token = await authManager.generateJWT(payload, '1h');
|
|
const decoded = jwt.decode(token);
|
|
|
|
const expectedExp = decoded.iat + 3600; // 1 hour = 3600 seconds
|
|
expect(decoded.exp).toBeCloseTo(expectedExp, -1); // Allow 1 sec tolerance
|
|
});
|
|
|
|
test('should include custom scopes', async () => {
|
|
const payload = { sub: 'user123', scope: ['read'] };
|
|
const token = await authManager.generateJWT(payload);
|
|
const decoded = jwt.decode(token);
|
|
|
|
expect(decoded.scope).toEqual(['read']);
|
|
});
|
|
|
|
test('should reject JWT generation without sub claim', async () => {
|
|
const payload = { role: 'admin' }; // Missing sub
|
|
await expect(authManager.generateJWT(payload))
|
|
.rejects.toThrow('JWT payload must include "sub"');
|
|
});
|
|
|
|
test('should reject JWT generation with null payload', async () => {
|
|
await expect(authManager.generateJWT(null))
|
|
.rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('JWT Verification', () => {
|
|
test('should verify valid JWT token', async () => {
|
|
const payload = { sub: 'user123', role: 'admin' };
|
|
const token = await authManager.generateJWT(payload);
|
|
const verified = await authManager.verifyJWT(token);
|
|
|
|
expect(verified).toBeDefined();
|
|
expect(verified.userId).toBe('user123');
|
|
expect(verified.scope).toEqual(['read', 'write']);
|
|
expect(verified.iat).toBeDefined();
|
|
expect(verified.exp).toBeDefined();
|
|
});
|
|
|
|
test('should reject invalid JWT token', async () => {
|
|
const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature';
|
|
const verified = await authManager.verifyJWT(invalidToken);
|
|
|
|
expect(verified).toBeNull();
|
|
});
|
|
|
|
test('should reject malformed JWT token', async () => {
|
|
const verified = await authManager.verifyJWT('not-a-jwt-token');
|
|
expect(verified).toBeNull();
|
|
});
|
|
|
|
test('should reject expired JWT token', async () => {
|
|
const payload = { sub: 'user123' };
|
|
const token = await authManager.generateJWT(payload, '-1s'); // Already expired
|
|
|
|
// Wait a tiny bit to ensure expiration
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
const verified = await authManager.verifyJWT(token);
|
|
expect(verified).toBeNull();
|
|
});
|
|
|
|
test('should reject JWT with wrong signature', async () => {
|
|
const payload = { sub: 'user123' };
|
|
const wrongSecret = 'wrong-secret-key';
|
|
const token = jwt.sign(payload, wrongSecret);
|
|
|
|
const verified = await authManager.verifyJWT(token);
|
|
expect(verified).toBeNull();
|
|
});
|
|
|
|
test('should handle empty token gracefully', async () => {
|
|
const verified = await authManager.verifyJWT('');
|
|
expect(verified).toBeNull();
|
|
});
|
|
|
|
test('should handle null token gracefully', async () => {
|
|
const verified = await authManager.verifyJWT(null);
|
|
expect(verified).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('API Key Generation', () => {
|
|
test('should generate valid API key', async () => {
|
|
const result = await authManager.generateAPIKey('test-key');
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.key).toMatch(/^dk_[a-f0-9]{32}_[a-f0-9]{64}$/);
|
|
expect(result.id).toMatch(/^[a-f0-9]{32}$/);
|
|
expect(result.name).toBe('test-key');
|
|
expect(result.scopes).toEqual(['read', 'write']);
|
|
expect(result.createdAt).toBeDefined();
|
|
});
|
|
|
|
test('should generate unique API keys', async () => {
|
|
const key1 = await authManager.generateAPIKey('key1');
|
|
const key2 = await authManager.generateAPIKey('key2');
|
|
|
|
expect(key1.key).not.toBe(key2.key);
|
|
expect(key1.id).not.toBe(key2.id);
|
|
});
|
|
|
|
test('should accept custom scopes', async () => {
|
|
const result = await authManager.generateAPIKey('readonly-key', ['read']);
|
|
|
|
expect(result.scopes).toEqual(['read']);
|
|
});
|
|
|
|
test('should store API key in credential manager', async () => {
|
|
await authManager.generateAPIKey('test-key');
|
|
|
|
expect(credentialManager.save).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^auth\.apikey\./),
|
|
expect.objectContaining({
|
|
keySecret: expect.any(String)
|
|
})
|
|
);
|
|
});
|
|
|
|
test('should store API key metadata', async () => {
|
|
await authManager.generateAPIKey('test-key', ['read']);
|
|
|
|
expect(credentialManager.save).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^auth\.metadata\./),
|
|
expect.objectContaining({
|
|
name: 'test-key',
|
|
scopes: ['read'],
|
|
createdAt: expect.any(String)
|
|
})
|
|
);
|
|
});
|
|
|
|
test('should reject API key generation without name', async () => {
|
|
await expect(authManager.generateAPIKey(''))
|
|
.rejects.toThrow('API key name is required');
|
|
|
|
await expect(authManager.generateAPIKey(null))
|
|
.rejects.toThrow('API key name is required');
|
|
|
|
await expect(authManager.generateAPIKey(undefined))
|
|
.rejects.toThrow('API key name is required');
|
|
});
|
|
|
|
test('should reject non-string name', async () => {
|
|
await expect(authManager.generateAPIKey(123))
|
|
.rejects.toThrow('API key name is required');
|
|
|
|
await expect(authManager.generateAPIKey({}))
|
|
.rejects.toThrow('API key name is required');
|
|
});
|
|
});
|
|
|
|
describe('API Key Validation', () => {
|
|
test('should validate correct API key', async () => {
|
|
const { key, id } = await authManager.generateAPIKey('test-key');
|
|
|
|
// Mock credential manager to return the stored key
|
|
credentialManager.get.mockResolvedValueOnce({
|
|
keySecret: key.split('_')[2]
|
|
});
|
|
credentialManager.get.mockResolvedValueOnce({
|
|
name: 'test-key',
|
|
scopes: ['read', 'write'],
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
|
|
const validated = await authManager.validateAPIKey(key);
|
|
|
|
expect(validated).toBeDefined();
|
|
expect(validated.valid).toBe(true);
|
|
expect(validated.keyId).toBe(id);
|
|
expect(validated.scopes).toEqual(['read', 'write']);
|
|
});
|
|
|
|
test('should reject malformed API key', async () => {
|
|
const validated = await authManager.validateAPIKey('not-an-api-key');
|
|
|
|
expect(validated.valid).toBe(false);
|
|
});
|
|
|
|
test('should reject API key with wrong prefix', async () => {
|
|
const validated = await authManager.validateAPIKey('sk_abc123_def456');
|
|
|
|
expect(validated.valid).toBe(false);
|
|
});
|
|
|
|
test('should reject non-existent API key', async () => {
|
|
const fakeKey = 'dk_' + crypto.randomBytes(16).toString('hex') + '_' + crypto.randomBytes(32).toString('hex');
|
|
credentialManager.get.mockResolvedValue(null); // Key doesn't exist
|
|
|
|
const validated = await authManager.validateAPIKey(fakeKey);
|
|
|
|
expect(validated.valid).toBe(false);
|
|
});
|
|
|
|
test('should reject revoked API key', async () => {
|
|
const { key } = await authManager.generateAPIKey('test-key');
|
|
|
|
credentialManager.get.mockResolvedValueOnce({
|
|
keySecret: key.split('_')[2],
|
|
revoked: true // Key is revoked
|
|
});
|
|
|
|
const validated = await authManager.validateAPIKey(key);
|
|
|
|
expect(validated.valid).toBe(false);
|
|
});
|
|
|
|
test('should handle null key gracefully', async () => {
|
|
const validated = await authManager.validateAPIKey(null);
|
|
|
|
expect(validated.valid).toBe(false);
|
|
});
|
|
|
|
test('should handle empty key gracefully', async () => {
|
|
const validated = await authManager.validateAPIKey('');
|
|
|
|
expect(validated.valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('API Key Revocation', () => {
|
|
test('should revoke API key', async () => {
|
|
const { id } = await authManager.generateAPIKey('test-key');
|
|
|
|
credentialManager.get.mockResolvedValue({
|
|
keySecret: 'test-secret'
|
|
});
|
|
|
|
const revoked = await authManager.revokeAPIKey(id);
|
|
|
|
expect(revoked).toBe(true);
|
|
expect(credentialManager.save).toHaveBeenCalledWith(
|
|
`auth.apikey.${id}`,
|
|
expect.objectContaining({
|
|
revoked: true,
|
|
revokedAt: expect.any(String)
|
|
})
|
|
);
|
|
});
|
|
|
|
test('should reject revoking non-existent key', async () => {
|
|
credentialManager.get.mockResolvedValue(null);
|
|
|
|
await expect(authManager.revokeAPIKey('nonexistent'))
|
|
.rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('API Key Listing', () => {
|
|
test('should list all API keys with metadata', async () => {
|
|
credentialManager.list.mockResolvedValue([
|
|
'auth.metadata.key1',
|
|
'auth.metadata.key2'
|
|
]);
|
|
|
|
credentialManager.get.mockResolvedValueOnce({
|
|
name: 'Key 1',
|
|
scopes: ['read'],
|
|
createdAt: '2026-01-01T00:00:00Z'
|
|
});
|
|
|
|
credentialManager.get.mockResolvedValueOnce({
|
|
name: 'Key 2',
|
|
scopes: ['read', 'write'],
|
|
createdAt: '2026-01-02T00:00:00Z'
|
|
});
|
|
|
|
const keys = await authManager.listAPIKeys();
|
|
|
|
expect(keys).toHaveLength(2);
|
|
expect(keys[0].name).toBe('Key 1');
|
|
expect(keys[1].name).toBe('Key 2');
|
|
});
|
|
|
|
test('should return empty array when no keys exist', async () => {
|
|
credentialManager.list.mockResolvedValue([]);
|
|
|
|
const keys = await authManager.listAPIKeys();
|
|
|
|
expect(keys).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Security Boundaries', () => {
|
|
test('should not log sensitive token data', async () => {
|
|
const payload = { sub: 'user123' };
|
|
const token = await authManager.generateJWT(payload);
|
|
|
|
// Logger should never be called with the actual token
|
|
const { safeLog } = require('../logger-utils');
|
|
const calls = safeLog.mock.calls.flat();
|
|
expect(calls.some(arg => String(arg).includes(token))).toBe(false);
|
|
});
|
|
|
|
test('should not log API key secrets', async () => {
|
|
const { key } = await authManager.generateAPIKey('test-key');
|
|
|
|
const { safeLog } = require('../logger-utils');
|
|
const calls = safeLog.mock.calls.flat();
|
|
expect(calls.some(arg => String(arg).includes(key))).toBe(false);
|
|
});
|
|
|
|
test('should generate cryptographically secure API keys', async () => {
|
|
const key1 = await authManager.generateAPIKey('key1');
|
|
const key2 = await authManager.generateAPIKey('key2');
|
|
|
|
// Keys should be unrelated (not sequential)
|
|
expect(parseInt(key1.id, 16)).not.toBe(parseInt(key2.id, 16) + 1);
|
|
expect(parseInt(key1.id, 16)).not.toBe(parseInt(key2.id, 16) - 1);
|
|
});
|
|
});
|
|
});
|