diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json index 7c1cca50c..100580660 100644 --- a/services/mana-core-auth/package.json +++ b/services/mana-core-auth/package.json @@ -43,7 +43,6 @@ "duckdb-async": "^1.1.1", "helmet": "^8.0.0", "jose": "^6.1.2", - "jsonwebtoken": "^9.0.2", "nanoid": "^5.0.9", "nodemailer": "^7.0.12", "postgres": "^3.4.5", @@ -64,7 +63,6 @@ "@types/cookie-parser": "^1.4.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", - "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.2", "@types/nodemailer": "^7.0.5", "@types/supertest": "^6.0.2", diff --git a/services/mana-core-auth/src/auth/jwt-validation.spec.ts b/services/mana-core-auth/src/auth/jwt-validation.spec.ts index 1e542af7c..5e2d03d3a 100644 --- a/services/mana-core-auth/src/auth/jwt-validation.spec.ts +++ b/services/mana-core-auth/src/auth/jwt-validation.spec.ts @@ -16,11 +16,14 @@ * 2. Organization context available via Better Auth org plugin APIs * 3. Smaller tokens = better performance * 4. Follows Better Auth's session-based design + * + * NOTE: These tests use jose library (EdDSA/HS256) as per project guidelines. + * Production uses EdDSA via Better Auth's JWKS. */ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import * as jwt from 'jsonwebtoken'; +import { SignJWT, jwtVerify, errors } from 'jose'; import { JWTCustomPayload } from './better-auth.config'; import { createMockConfigService } from '../__tests__/utils/test-helpers'; import { mockUserFactory } from '../__tests__/utils/mock-factories'; @@ -31,15 +34,55 @@ jest.mock('nanoid', () => ({ nanoid: jest.fn(() => 'mock-nanoid-123'), })); +// Helper to create JWT using jose +async function signJwt( + payload: JWTCustomPayload, + secret: Uint8Array, + options: { expiresIn?: string; issuer?: string; audience?: string; notBefore?: number } = {} +): Promise { + const jwt = new SignJWT(payload as unknown as Record) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(); + + if (options.expiresIn) { + jwt.setExpirationTime(options.expiresIn); + } + if (options.issuer) { + jwt.setIssuer(options.issuer); + } + if (options.audience) { + jwt.setAudience(options.audience); + } + if (options.notBefore !== undefined) { + jwt.setNotBefore(options.notBefore); + } + + return jwt.sign(secret); +} + +// Helper to verify JWT using jose +async function verifyJwt( + token: string, + secret: Uint8Array, + options: { issuer?: string; audience?: string } = {} +): Promise { + const { payload } = await jwtVerify(token, secret, { + algorithms: ['HS256'], + issuer: options.issuer, + audience: options.audience, + }); + return payload as unknown as JWTCustomPayload; +} + describe('JWT Token Validation (Minimal Claims)', () => { let configService: ConfigService; let mockDb: any; - let secret: string; + let secret: Uint8Array; beforeEach(async () => { // Use HS256 for testing (symmetric key) for simplicity - // In production, mana-core uses RS256 (asymmetric) - secret = 'test-secret-key-for-jwt-validation'; + // In production, mana-core uses EdDSA via Better Auth's JWKS + secret = new TextEncoder().encode('test-secret-key-for-jwt-validation-must-be-32-chars'); // Create mock database mockDb = { @@ -60,7 +103,6 @@ describe('JWT Token Validation (Minimal Claims)', () => { getDb.mockReturnValue(mockDb); configService = createMockConfigService({ - 'jwt.secret': secret, 'jwt.issuer': 'mana-core', 'jwt.audience': 'manacore', }); @@ -71,7 +113,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { }); describe('Minimal JWT Claims Structure', () => { - it('should generate token with minimal claims only', () => { + it('should generate token with minimal claims only', async () => { const user = mockUserFactory.create({ id: 'user-123', email: 'user@example.com', @@ -85,18 +127,16 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-abc-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(token, secret, { - algorithms: ['HS256'], + const decoded = await verifyJwt(token, secret, { issuer: 'mana-core', audience: 'manacore', - }) as JWTCustomPayload; + }); expect(decoded).toMatchObject({ sub: 'user-123', @@ -113,7 +153,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { expect((decoded as any).device_id).toBeUndefined(); }); - it('should include standard JWT claims (sub, iat, exp, iss, aud)', () => { + it('should include standard JWT claims (sub, iat, exp, iss, aud)', async () => { const now = Math.floor(Date.now() / 1000); const payload: JWTCustomPayload = { @@ -123,29 +163,26 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded: any = jwt.verify(token, secret, { - algorithms: ['HS256'], - }); + const decoded = await verifyJwt(token, secret); // 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'); + expect((decoded as any).iat).toBeGreaterThanOrEqual(now); + expect((decoded as any).exp).toBeGreaterThan((decoded as any).iat); + expect((decoded as any).iss).toBe('mana-core'); + expect((decoded as any).aud).toBe('manacore'); }); - it('should support different user roles', () => { + it('should support different user roles', async () => { const roles = ['user', 'admin', 'service']; - roles.forEach((role) => { + for (const role of roles) { const payload: JWTCustomPayload = { sub: `${role}-user-123`, email: `${role}@example.com`, @@ -153,24 +190,21 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: `session-${role}`, }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(token, secret, { - algorithms: ['HS256'], - }) as JWTCustomPayload; + const decoded = await verifyJwt(token, secret); expect(decoded.role).toBe(role); - }); + } }); }); describe('Token Validation - Security', () => { - it('should validate HS256 signature correctly', () => { + it('should validate HS256 signature correctly', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -178,22 +212,17 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); // Should successfully verify with correct secret - expect(() => { - jwt.verify(token, secret, { - algorithms: ['HS256'], - }); - }).not.toThrow(); + await expect(verifyJwt(token, secret)).resolves.toBeDefined(); }); - it('should reject expired tokens', () => { + it('should reject expired tokens', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -202,27 +231,19 @@ describe('JWT Token Validation (Minimal Claims)', () => { }; // Create token that expires immediately - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { 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); - }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWTExpired); }); - it('should reject tokens with wrong issuer', () => { + it('should reject tokens with wrong issuer', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -230,23 +251,21 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'wrong-issuer', // Wrong issuer audience: 'manacore', }); - expect(() => { - jwt.verify(token, secret, { - algorithms: ['HS256'], + await expect( + verifyJwt(token, secret, { issuer: 'mana-core', // Expect correct issuer audience: 'manacore', - }); - }).toThrow('jwt issuer invalid'); + }) + ).rejects.toThrow(errors.JWTClaimValidationFailed); }); - it('should reject tokens with wrong audience', () => { + it('should reject tokens with wrong audience', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -254,23 +273,21 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'wrong-audience', // Wrong audience }); - expect(() => { - jwt.verify(token, secret, { - algorithms: ['HS256'], + await expect( + verifyJwt(token, secret, { issuer: 'mana-core', audience: 'manacore', // Expect correct audience - }); - }).toThrow('jwt audience invalid'); + }) + ).rejects.toThrow(errors.JWTClaimValidationFailed); }); - it('should reject tampered tokens', () => { + it('should reject tampered tokens', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -278,8 +295,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', @@ -292,14 +308,12 @@ describe('JWT Token Validation (Minimal Claims)', () => { ); const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`; - expect(() => { - jwt.verify(tamperedToken, secret, { - algorithms: ['HS256'], - }); - }).toThrow('invalid signature'); + await expect(verifyJwt(tamperedToken, secret)).rejects.toThrow( + errors.JWSSignatureVerificationFailed + ); }); - it('should reject tokens signed with wrong secret', () => { + it('should reject tokens signed with wrong secret', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -308,24 +322,21 @@ describe('JWT Token Validation (Minimal Claims)', () => { }; // Sign with different secret - const token = jwt.sign(payload, 'wrong-secret-key', { - algorithm: 'HS256', + const wrongSecret = new TextEncoder().encode('wrong-secret-key-for-testing-wrong'); + + const token = await signJwt(payload, wrongSecret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); // Try to verify with correct secret - expect(() => { - jwt.verify(token, secret, { - algorithms: ['HS256'], - }); - }).toThrow(); + await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWSSignatureVerificationFailed); }); }); describe('Token Expiration Times', () => { - it('should use 15 minutes for access tokens', () => { + it('should use 15 minutes for access tokens', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -333,22 +344,19 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded: any = jwt.verify(token, secret, { - algorithms: ['HS256'], - }); + const decoded: any = await verifyJwt(token, secret); 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)', () => { + it('should validate token is not yet valid (nbf claim)', async () => { const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future const payload: JWTCustomPayload = { @@ -358,56 +366,40 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { 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'); + await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWTClaimValidationFailed); }); }); describe('Edge Cases', () => { - it('should handle malformed JWT gracefully', () => { + it('should handle malformed JWT gracefully', async () => { const malformedToken = 'this.is.not.a.valid.jwt'; - expect(() => { - jwt.verify(malformedToken, secret, { - algorithms: ['HS256'], - }); - }).toThrow('jwt malformed'); + await expect(verifyJwt(malformedToken, secret)).rejects.toThrow(); }); - it('should handle empty token', () => { - expect(() => { - jwt.verify('', secret, { - algorithms: ['HS256'], - }); - }).toThrow('jwt must be provided'); + it('should handle empty token', async () => { + await expect(verifyJwt('', secret)).rejects.toThrow(); }); - it('should handle token with missing required claims', () => { + it('should handle token with missing required claims', async () => { // Token with only sub (missing email, role, sid) - const minimalPayload = { sub: 'user-123' }; + const minimalPayload = { sub: 'user-123' } as unknown as JWTCustomPayload; - const token = jwt.sign(minimalPayload, secret, { - algorithm: 'HS256', + const token = await signJwt(minimalPayload, secret, { 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; + const decoded = await verifyJwt(token, secret); expect(decoded.sub).toBe('user-123'); expect(decoded.email).toBeUndefined(); @@ -417,7 +409,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { }); describe('Token Refresh Behavior', () => { - it('should issue new token with same user claims', () => { + it('should issue new token with same user claims', async () => { const originalPayload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -425,8 +417,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-original', }; - const originalToken = jwt.sign(originalPayload, secret, { - algorithm: 'HS256', + const originalToken = await signJwt(originalPayload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', @@ -438,16 +429,13 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-refreshed', // New session ID }; - const refreshedToken = jwt.sign(refreshedPayload, secret, { - algorithm: 'HS256', + const refreshedToken = await signJwt(refreshedPayload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(refreshedToken, secret, { - algorithms: ['HS256'], - }) as JWTCustomPayload; + const decoded = await verifyJwt(refreshedToken, secret); // User claims should be maintained expect(decoded.sub).toBe('user-123'); @@ -457,7 +445,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { expect(decoded.sid).toBe('session-refreshed'); }); - it('should maintain user role across refreshes', () => { + it('should maintain user role across refreshes', async () => { const adminPayload: JWTCustomPayload = { sub: 'admin-123', email: 'admin@example.com', @@ -465,16 +453,13 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(adminPayload, secret, { - algorithm: 'HS256', + const token = await signJwt(adminPayload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(token, secret, { - algorithms: ['HS256'], - }) as JWTCustomPayload; + const decoded = await verifyJwt(token, secret); // Admin role should be preserved expect(decoded.role).toBe('admin'); @@ -486,7 +471,7 @@ describe('JWT Token Validation (Minimal Claims)', () => { * 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)', () => { + it('should NOT contain organization data (fetch via API instead)', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -494,25 +479,22 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(token, secret, { - algorithms: ['HS256'], - }) as any; + const decoded = await verifyJwt(token, secret); // 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(); + expect((decoded as any).organization).toBeUndefined(); + expect((decoded as any).organizationId).toBeUndefined(); }); - it('should NOT contain credit balance (fetch via API instead)', () => { + it('should NOT contain credit balance (fetch via API instead)', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -520,25 +502,22 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(token, secret, { - algorithms: ['HS256'], - }) as any; + const decoded = await verifyJwt(token, secret); // 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(); + expect((decoded as any).credit_balance).toBeUndefined(); + expect((decoded as any).credits).toBeUndefined(); }); - it('should NOT contain customer_type (derive from session instead)', () => { + it('should NOT contain customer_type (derive from session instead)', async () => { const payload: JWTCustomPayload = { sub: 'user-123', email: 'user@example.com', @@ -546,21 +525,18 @@ describe('JWT Token Validation (Minimal Claims)', () => { sid: 'session-123', }; - const token = jwt.sign(payload, secret, { - algorithm: 'HS256', + const token = await signJwt(payload, secret, { expiresIn: '15m', issuer: 'mana-core', audience: 'manacore', }); - const decoded = jwt.verify(token, secret, { - algorithms: ['HS256'], - }) as any; + const decoded = await verifyJwt(token, secret); // Customer type should be derived from: // - B2B = session.activeOrganizationId != null // - B2C = session.activeOrganizationId == null - expect(decoded.customer_type).toBeUndefined(); + expect((decoded as any).customer_type).toBeUndefined(); }); }); }); diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index 245efb50e..2f2a98407 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -66,6 +66,7 @@ import type { BetterAuthSession, } from '../types/better-auth.types'; import { jwtVerify, createRemoteJWKSet } from 'jose'; +import * as jwt from 'jsonwebtoken'; // Re-export DTOs and result types for external use export type { diff --git a/services/mana-core-auth/test/__mocks__/jose.ts b/services/mana-core-auth/test/__mocks__/jose.ts new file mode 100644 index 000000000..269e2cf7a --- /dev/null +++ b/services/mana-core-auth/test/__mocks__/jose.ts @@ -0,0 +1,20 @@ +/** + * Jose Mock - Re-exports the real module functions + * + * We use the real jose library for JWT validation tests + * since we're testing actual JWT creation and verification. + * + * Note: We need to explicitly require and re-export because + * jest module mocking doesn't handle ESM re-exports well. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const jose = require('jose'); + +export const SignJWT = jose.SignJWT; +export const jwtVerify = jose.jwtVerify; +export const createRemoteJWKSet = jose.createRemoteJWKSet; +export const errors = jose.errors; +export const generateKeyPair = jose.generateKeyPair; +export const exportJWK = jose.exportJWK; +export const importJWK = jose.importJWK;