🐛 fix: add missing jwt import in better-auth.service

This commit is contained in:
Till-JS 2026-02-01 13:26:11 +01:00
parent efb077b9ea
commit c0117b2699
4 changed files with 149 additions and 154 deletions

View file

@ -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",

View file

@ -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<string> {
const jwt = new SignJWT(payload as unknown as Record<string, unknown>)
.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<JWTCustomPayload> {
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();
});
});
});

View file

@ -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 {

View file

@ -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;