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 2e7fe3bf8..daa11d1f4 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 @@ -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 { 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('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('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, }); diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts index 264f6b93a..e23353eff 100644 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts +++ b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts @@ -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; -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() ); diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts index 04618b9c5..541a1fb7a 100644 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts +++ b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts @@ -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 | null = null; + private jwks: ReturnType | 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('PORT') || 3001; - const jwksUrl = new URL(`http://localhost:${port}/api/v1/auth/jwks`); - this.jwks = createRemoteJWKSet(jwksUrl); + const databaseUrl = this.configService.get('database.url') || ''; + this.jwks = createCachedLocalJWKSet(databaseUrl); } // IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts) diff --git a/services/mana-core-auth/src/common/guards/local-jwks-cache.ts b/services/mana-core-auth/src/common/guards/local-jwks-cache.ts new file mode 100644 index 000000000..0006aaf4a --- /dev/null +++ b/services/mana-core-auth/src/common/guards/local-jwks-cache.ts @@ -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; + 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 { + 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 +> { + 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 { + 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; +} diff --git a/services/mana-core-auth/src/common/guards/optional-auth.guard.ts b/services/mana-core-auth/src/common/guards/optional-auth.guard.ts index 4b557f647..3542a5bfa 100644 --- a/services/mana-core-auth/src/common/guards/optional-auth.guard.ts +++ b/services/mana-core-auth/src/common/guards/optional-auth.guard.ts @@ -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 | null = null; + private jwks: ReturnType | 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('BASE_URL') || 'http://localhost:3001'; - const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl); - this.jwks = createRemoteJWKSet(jwksUrl); + const databaseUrl = this.configService.get('database.url') || ''; + this.jwks = createCachedLocalJWKSet(databaseUrl); } // IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)