managarten/services/mana-core-auth/src/auth/jwt-validation.spec.ts
Wuesteon 9c47119535 Fix wrong type
import, make auth and chat work
2025-12-04 23:25:25 +01:00

566 lines
14 KiB
TypeScript

/**
* JWT Token Validation Tests (Minimal Claims)
*
* Tests for JWT token validation with minimal claims:
* - sub (user ID)
* - email
* - role
* - sid (session ID)
*
* ARCHITECTURE DECISION (2024-12):
* We use MINIMAL JWT claims. Organization and credit data should be fetched
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
*
* Why minimal claims?
* 1. Credit balance changes frequently - JWT would be stale
* 2. Organization context available via Better Auth org plugin APIs
* 3. Smaller tokens = better performance
* 4. Follows Better Auth's session-based design
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { JWTCustomPayload } from './better-auth.config';
import { createMockConfigService } from '../__tests__/utils/test-helpers';
import { mockUserFactory } from '../__tests__/utils/mock-factories';
// Mock external dependencies
jest.mock('../db/connection');
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
describe('JWT Token Validation (Minimal Claims)', () => {
let configService: ConfigService;
let mockDb: any;
let secret: string;
beforeEach(async () => {
// Use HS256 for testing (symmetric key) for simplicity
// In production, mana-core uses RS256 (asymmetric)
secret = 'test-secret-key-for-jwt-validation';
// Create mock database
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn(),
transaction: jest.fn(),
};
// Mock getDb
const { getDb } = require('../db/connection');
getDb.mockReturnValue(mockDb);
configService = createMockConfigService({
'jwt.secret': secret,
'jwt.issuer': 'mana-core',
'jwt.audience': 'manacore',
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Minimal JWT Claims Structure', () => {
it('should generate token with minimal claims only', () => {
const user = mockUserFactory.create({
id: 'user-123',
email: 'user@example.com',
role: 'user',
});
const payload: JWTCustomPayload = {
sub: user.id,
email: user.email,
role: user.role,
sid: 'session-abc-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'mana-core',
audience: 'manacore',
}) as JWTCustomPayload;
expect(decoded).toMatchObject({
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-abc-123',
});
// Verify NO complex claims are present
expect((decoded as any).customer_type).toBeUndefined();
expect((decoded as any).organization).toBeUndefined();
expect((decoded as any).credit_balance).toBeUndefined();
expect((decoded as any).app_id).toBeUndefined();
expect((decoded as any).device_id).toBeUndefined();
});
it('should include standard JWT claims (sub, iat, exp, iss, aud)', () => {
const now = Math.floor(Date.now() / 1000);
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded: any = jwt.verify(token, secret, {
algorithms: ['HS256'],
});
// Standard JWT claims
expect(decoded.sub).toBe('user-123');
expect(decoded.iat).toBeGreaterThanOrEqual(now);
expect(decoded.exp).toBeGreaterThan(decoded.iat);
expect(decoded.iss).toBe('mana-core');
expect(decoded.aud).toBe('manacore');
});
it('should support different user roles', () => {
const roles = ['user', 'admin', 'service'];
roles.forEach((role) => {
const payload: JWTCustomPayload = {
sub: `${role}-user-123`,
email: `${role}@example.com`,
role,
sid: `session-${role}`,
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as JWTCustomPayload;
expect(decoded.role).toBe(role);
});
});
});
describe('Token Validation - Security', () => {
it('should validate HS256 signature correctly', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Should successfully verify with correct secret
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).not.toThrow();
});
it('should reject expired tokens', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
// Create token that expires immediately
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '0s', // Expired immediately
issuer: 'mana-core',
audience: 'manacore',
});
// Wait a moment to ensure expiry
return new Promise((resolve) => {
setTimeout(() => {
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt expired');
resolve(true);
}, 100);
});
});
it('should reject tokens with wrong issuer', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'wrong-issuer', // Wrong issuer
audience: 'manacore',
});
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'mana-core', // Expect correct issuer
audience: 'manacore',
});
}).toThrow('jwt issuer invalid');
});
it('should reject tokens with wrong audience', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'wrong-audience', // Wrong audience
});
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'mana-core',
audience: 'manacore', // Expect correct audience
});
}).toThrow('jwt audience invalid');
});
it('should reject tampered tokens', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Tamper with the token - try to change role to admin
const parts = token.split('.');
const tamperedPayload = Buffer.from(JSON.stringify({ ...payload, role: 'admin' })).toString(
'base64url'
);
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
expect(() => {
jwt.verify(tamperedToken, secret, {
algorithms: ['HS256'],
});
}).toThrow('invalid signature');
});
it('should reject tokens signed with wrong secret', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
// Sign with different secret
const token = jwt.sign(payload, 'wrong-secret-key', {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Try to verify with correct secret
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).toThrow();
});
});
describe('Token Expiration Times', () => {
it('should use 15 minutes for access tokens', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded: any = jwt.verify(token, secret, {
algorithms: ['HS256'],
});
const expiryTime = decoded.exp - decoded.iat;
expect(expiryTime).toBe(15 * 60); // 15 minutes = 900 seconds
});
it('should validate token is not yet valid (nbf claim)', () => {
const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
notBefore: futureTime, // Not valid until 1 hour from now
issuer: 'mana-core',
audience: 'manacore',
});
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt not active');
});
});
describe('Edge Cases', () => {
it('should handle malformed JWT gracefully', () => {
const malformedToken = 'this.is.not.a.valid.jwt';
expect(() => {
jwt.verify(malformedToken, secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt malformed');
});
it('should handle empty token', () => {
expect(() => {
jwt.verify('', secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt must be provided');
});
it('should handle token with missing required claims', () => {
// Token with only sub (missing email, role, sid)
const minimalPayload = { sub: 'user-123' };
const token = jwt.sign(minimalPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Token is technically valid, but application should validate claims
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
expect(decoded.sub).toBe('user-123');
expect(decoded.email).toBeUndefined();
expect(decoded.role).toBeUndefined();
expect(decoded.sid).toBeUndefined();
});
});
describe('Token Refresh Behavior', () => {
it('should issue new token with same user claims', () => {
const originalPayload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-original',
};
const originalToken = jwt.sign(originalPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Refresh creates new token with new session ID
const refreshedPayload: JWTCustomPayload = {
...originalPayload,
sid: 'session-refreshed', // New session ID
};
const refreshedToken = jwt.sign(refreshedPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(refreshedToken, secret, {
algorithms: ['HS256'],
}) as JWTCustomPayload;
// User claims should be maintained
expect(decoded.sub).toBe('user-123');
expect(decoded.email).toBe('user@example.com');
expect(decoded.role).toBe('user');
// Session ID should be new
expect(decoded.sid).toBe('session-refreshed');
});
it('should maintain user role across refreshes', () => {
const adminPayload: JWTCustomPayload = {
sub: 'admin-123',
email: 'admin@example.com',
role: 'admin',
sid: 'session-123',
};
const token = jwt.sign(adminPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as JWTCustomPayload;
// Admin role should be preserved
expect(decoded.role).toBe('admin');
});
});
describe('Architecture Decision Documentation', () => {
/**
* This test documents what is NOT in the JWT by design.
* See docs/AUTHENTICATION_ARCHITECTURE.md for full explanation.
*/
it('should NOT contain organization data (fetch via API instead)', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
// Organization data should be fetched via:
// - session.activeOrganizationId (from Better Auth session)
// - GET /organization/get-active-member (for details)
expect(decoded.organization).toBeUndefined();
expect(decoded.organizationId).toBeUndefined();
});
it('should NOT contain credit balance (fetch via API instead)', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
// Credit balance should be fetched via:
// - GET /api/v1/credits/balance
// Credit balance changes too frequently to embed in JWT
expect(decoded.credit_balance).toBeUndefined();
expect(decoded.credits).toBeUndefined();
});
it('should NOT contain customer_type (derive from session instead)', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
// Customer type should be derived from:
// - B2B = session.activeOrganizationId != null
// - B2C = session.activeOrganizationId == null
expect(decoded.customer_type).toBeUndefined();
});
});
});