perf(auth): cache JWKS locally instead of HTTP self-call

Replace createRemoteJWKSet (HTTP to localhost) with local DB-backed
JWKS cache. Keys are read from auth.jwks table and cached in memory
with 5-minute TTL. Eliminates HTTP roundtrip per token validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 20:26:16 +01:00
parent d0802362d7
commit 5b5849eaa4
5 changed files with 158 additions and 38 deletions

View file

@ -64,8 +64,9 @@ import type {
BetterAuthUser,
BetterAuthSession,
} from '../types/better-auth.types';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { jwtVerify } from 'jose';
import * as jwt from 'jsonwebtoken';
import { createCachedLocalJWKSet } from '../../common/guards/local-jwks-cache';
// Re-export DTOs and result types for external use
export type {
@ -1136,15 +1137,8 @@ export class BetterAuthService {
*/
async validateToken(token: string): Promise<ValidateTokenResult> {
try {
// Decode to check the algorithm
const decoded = jwt.decode(token, { complete: true });
// Use our JWKS endpoint via localhost (self-referencing avoids external URL issues in Docker)
const port = this.configService.get<number>('PORT') || 3001;
const jwksUrl = new URL(`http://localhost:${port}/api/v1/auth/jwks`);
// Create JWKS fetcher
const JWKS = createRemoteJWKSet(jwksUrl);
// Use local JWKS cache (reads from DB, no self-referential HTTP requests)
const localJWKS = createCachedLocalJWKSet(this.databaseUrl);
// IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)
// Signing uses: issuer = BASE_URL, audience = JWT_AUDIENCE || 'manacore'
@ -1152,8 +1146,8 @@ export class BetterAuthService {
const issuer = baseUrl; // Better Auth uses BASE_URL as issuer for OIDC compatibility
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
// Verify using jose library with Better Auth's JWKS
const { payload } = await jwtVerify(token, JWKS, {
// Verify using jose library with locally cached JWKS keys
const { payload } = await jwtVerify(token, localJWKS, {
issuer,
audience,
});

View file

@ -3,7 +3,7 @@
*
* Tests JWT authentication guard functionality:
* - Token extraction from Authorization header
* - JWT verification using JWKS (EdDSA keys)
* - JWT verification using locally cached JWKS (EdDSA keys)
* - Error handling for invalid/expired tokens
* - User attachment to request object
*/
@ -17,15 +17,21 @@ import { LoggerService } from '../logger';
import { createMockConfigService, httpMockHelpers } from '../../__tests__/utils/test-helpers';
import { mockTokenFactory } from '../../__tests__/utils/mock-factories';
import { silentError } from '../../__tests__/utils/silent-error.decorator';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { jwtVerify } from 'jose';
import { createCachedLocalJWKSet } from './local-jwks-cache';
// Mock jose (auto-mocked via jest.config.js moduleNameMapper)
jest.mock('jose');
// Setup mock for createRemoteJWKSet to return a defined JWKS function
// Mock the local JWKS cache
jest.mock('./local-jwks-cache');
// Setup mock for createCachedLocalJWKSet to return a defined JWKS function
const mockJWKS = jest.fn();
const mockCreateRemoteJWKSet = createRemoteJWKSet as jest.MockedFunction<typeof createRemoteJWKSet>;
mockCreateRemoteJWKSet.mockReturnValue(mockJWKS as any);
const mockCreateLocalJWKSet = createCachedLocalJWKSet as jest.MockedFunction<
typeof createCachedLocalJWKSet
>;
mockCreateLocalJWKSet.mockReturnValue(mockJWKS as any);
// Mock LoggerService
const createMockLoggerService = (): LoggerService =>
@ -48,8 +54,8 @@ describe('JwtAuthGuard', () => {
// Reset mocks
jest.clearAllMocks();
// Ensure createRemoteJWKSet returns a defined value after clearing
mockCreateRemoteJWKSet.mockReturnValue(mockJWKS as any);
// Ensure createCachedLocalJWKSet returns a defined value after clearing
mockCreateLocalJWKSet.mockReturnValue(mockJWKS as any);
const module: TestingModule = await Test.createTestingModule({
providers: [
@ -60,6 +66,7 @@ describe('JwtAuthGuard', () => {
BASE_URL: 'http://localhost:3001',
'jwt.issuer': 'manacore',
'jwt.audience': 'manacore',
'database.url': 'postgresql://localhost:5432/test',
}),
},
{
@ -343,7 +350,7 @@ describe('JwtAuthGuard', () => {
});
describe('Configuration', () => {
it('should use BASE_URL from config for JWKS endpoint', async () => {
it('should use local JWKS cache for key resolution', async () => {
const mockRequest = httpMockHelpers.createMockRequest({
headers: {
authorization: 'Bearer valid-jwt-token',
@ -362,7 +369,10 @@ describe('JwtAuthGuard', () => {
await guard.canActivate(mockContext as any);
// JWKS should be created with correct URL (verified via createRemoteJWKSet call)
// Should use createCachedLocalJWKSet instead of createRemoteJWKSet
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith(
expect.any(String) // database URL
);
expect(mockJwtVerify).toHaveBeenCalled();
});
@ -372,6 +382,7 @@ describe('JwtAuthGuard', () => {
createMockConfigService({
'jwt.issuer': 'manacore',
'jwt.audience': 'manacore',
'database.url': 'postgresql://localhost:5432/test',
}),
createMockLoggerService()
);
@ -404,6 +415,7 @@ describe('JwtAuthGuard', () => {
createMockConfigService({
'jwt.issuer': 'custom-issuer',
'jwt.audience': 'custom-audience',
'database.url': 'postgresql://localhost:5432/test',
}),
createMockLoggerService()
);

View file

@ -5,18 +5,20 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { jwtVerify } from 'jose';
import { LoggerService } from '../logger';
import { createCachedLocalJWKSet } from './local-jwks-cache';
/**
* JWT Auth Guard using JWKS (Better Auth compatible)
* JWT Auth Guard using local JWKS cache (Better Auth compatible)
*
* Uses jose library with JWKS endpoint for EdDSA token verification.
* This is the correct approach for Better Auth which uses EdDSA keys.
* Uses jose library with locally cached JWKS keys for EdDSA token verification.
* Keys are read directly from the database instead of making HTTP requests
* to the service's own JWKS endpoint.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
private jwks: ReturnType<typeof createCachedLocalJWKSet> | null = null;
private readonly logger: LoggerService;
constructor(
@ -35,11 +37,10 @@ export class JwtAuthGuard implements CanActivate {
}
try {
// Lazy initialize JWKS via localhost (self-referencing avoids external URL issues in Docker)
// Lazy initialize local JWKS (reads from DB, cached in memory)
if (!this.jwks) {
const port = this.configService.get<number>('PORT') || 3001;
const jwksUrl = new URL(`http://localhost:${port}/api/v1/auth/jwks`);
this.jwks = createRemoteJWKSet(jwksUrl);
const databaseUrl = this.configService.get<string>('database.url') || '';
this.jwks = createCachedLocalJWKSet(databaseUrl);
}
// IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)

View file

@ -0,0 +1,113 @@
/**
* Local JWKS Cache
*
* Provides in-memory cached JWKS keys for JWT verification without
* making HTTP requests. Since the auth service IS the JWKS provider,
* it should read keys directly from the database instead of fetching
* from its own HTTP endpoint.
*
* Uses jose's built-in createLocalJWKSet() for key resolution,
* wrapping it with a database-backed cache layer.
*/
import { createLocalJWKSet as joseCreateLocalJWKSet } from 'jose';
import type { JWK, JSONWebKeySet, JWSHeaderParameters, FlattenedJWSInput, CryptoKey } from 'jose';
import { getDb } from '../../db/connection';
import { jwks } from '../../db/schema/auth.schema';
interface JwksCache {
resolver: (
protectedHeader?: JWSHeaderParameters,
token?: FlattenedJWSInput
) => Promise<CryptoKey>;
expiresAt: number;
}
/** Cache TTL in milliseconds (5 minutes) */
const CACHE_TTL_MS = 5 * 60 * 1000;
/** Module-level cache shared across all consumers within this process */
let cache: JwksCache | null = null;
/**
* Load JWKS keys from the database and return as a JSONWebKeySet.
*/
async function loadJwksFromDb(databaseUrl: string): Promise<JSONWebKeySet> {
const db = getDb(databaseUrl);
const rows = await db.select().from(jwks);
const keys: JWK[] = [];
for (const row of rows) {
try {
const jwk: JWK = JSON.parse(row.publicKey);
// Ensure the kid is set (use the row ID if the JWK doesn't have one)
if (!jwk.kid) {
jwk.kid = row.id;
}
keys.push(jwk);
} catch {
// Skip malformed keys
}
}
return { keys };
}
/**
* Get or refresh the cached JWKS resolver.
*/
async function getCachedResolver(
databaseUrl: string
): Promise<
(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput) => Promise<CryptoKey>
> {
const now = Date.now();
if (cache && cache.expiresAt > now) {
return cache.resolver;
}
const jwksData = await loadJwksFromDb(databaseUrl);
if (jwksData.keys.length === 0) {
throw new Error('No JWKS keys available in database');
}
const resolver = joseCreateLocalJWKSet(jwksData);
cache = {
resolver,
expiresAt: now + CACHE_TTL_MS,
};
return resolver;
}
/**
* Create a jose-compatible key getter function that reads JWKS from
* the local database with in-memory caching.
*
* This replaces createRemoteJWKSet() for the auth service itself,
* avoiding self-referential HTTP requests.
*
* @param databaseUrl - PostgreSQL connection URL
* @returns A function compatible with jose's jwtVerify second argument
*/
export function createCachedLocalJWKSet(
databaseUrl: string
): (protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput) => Promise<CryptoKey> {
return async (protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput) => {
const resolver = await getCachedResolver(databaseUrl);
return resolver(protectedHeader, token);
};
}
/**
* Clear the JWKS cache. Useful for testing or when keys are rotated.
*/
export function clearJwksCache(): void {
cache = null;
}

View file

@ -1,17 +1,18 @@
import { Injectable } from '@nestjs/common';
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { jwtVerify } from 'jose';
import { createCachedLocalJWKSet } from './local-jwks-cache';
/**
* Optional authentication guard using JWKS (Better Auth compatible)
* Optional authentication guard using locally cached JWKS (Better Auth compatible)
*
* Attaches user to request if valid token is present, but doesn't require it.
* Uses jose library with JWKS endpoint for EdDSA token verification.
* Uses jose library with locally cached JWKS keys for EdDSA token verification.
*/
@Injectable()
export class OptionalAuthGuard implements CanActivate {
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
private jwks: ReturnType<typeof createCachedLocalJWKSet> | null = null;
constructor(private configService: ConfigService) {}
@ -26,11 +27,10 @@ export class OptionalAuthGuard implements CanActivate {
}
try {
// Lazy initialize JWKS
// Lazy initialize local JWKS (reads from DB, cached in memory)
if (!this.jwks) {
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
this.jwks = createRemoteJWKSet(jwksUrl);
const databaseUrl = this.configService.get<string>('database.url') || '';
this.jwks = createCachedLocalJWKSet(databaseUrl);
}
// IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)