/** * Configurable JWT auth middleware — RS256 JWKS verification with HS256 fallback. * * Factory function creates extractAuth() and requireRole() bound to the * provided config, eliminating the need for each product backend to maintain * its own copy. */ import { jwtVerify, createRemoteJWKSet } from 'jose'; import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; import type { AuthPayload, FastifyAuthOptions } from './types.js'; export function createAuthMiddleware(opts: FastifyAuthOptions) { // Lazy-init JWKS client (cached, auto-refreshed by jose) let jwks: ReturnType | null = null; let cachedJwksUrl: string | undefined; function resolveJwksUrl(): string | undefined { return typeof opts.jwksUrl === 'function' ? opts.jwksUrl() : opts.jwksUrl; } function resolveJwtSecret(): string { return typeof opts.jwtSecret === 'function' ? opts.jwtSecret() : opts.jwtSecret; } function getJWKS(): ReturnType | null { const url = resolveJwksUrl(); if (!url) return null; if (jwks && cachedJwksUrl === url) return jwks; jwks = createRemoteJWKSet(new URL(url)); cachedJwksUrl = url; return jwks; } function getHmacSecret(): Uint8Array { return new TextEncoder().encode(resolveJwtSecret()); } /** * Extract and verify auth payload from an Authorization header. * Tries RS256 via JWKS first, falls back to HS256. */ async function extractAuth(req: { headers: { authorization?: string } }): Promise { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) { throw new UnauthorizedError(); } const token = auth.slice(7); // Try RS256 via JWKS first const remoteJWKS = getJWKS(); if (remoteJWKS) { try { const { payload } = await jwtVerify(token, remoteJWKS); const p = payload as unknown as AuthPayload; if (p.type !== 'access') throw new Error('Not an access token'); return p; } catch { // Fall through to HS256 } } // Fall back to HS256 (existing behavior) try { const { payload } = await jwtVerify(token, getHmacSecret()); const p = payload as unknown as AuthPayload; if (p.type !== 'access') throw new Error('Not an access token'); return p; } catch { throw new UnauthorizedError('Invalid or expired token'); } } /** * Require specific roles. Extracts auth first, then checks role. */ async function requireRole( req: { headers: { authorization?: string } }, ...roles: string[] ): Promise { const payload = await extractAuth(req); if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { throw new ForbiddenError('Insufficient permissions'); } return payload; } return { extractAuth, requireRole }; }