mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 17:41:26 +02:00
566 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|