mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 06:41:08 +02:00
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:
parent
d0802362d7
commit
5b5849eaa4
5 changed files with 158 additions and 38 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
113
services/mana-core-auth/src/common/guards/local-jwks-cache.ts
Normal file
113
services/mana-core-auth/src/common/guards/local-jwks-cache.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue