learning_ai_common_plat/packages/fastify-auth/src/auth.ts
saravanakumardb1 ea2cb4c0e6 fix(fastify-auth): support getter functions for jwtSecret/jwksUrl
Allows dynamic config resolution (e.g. test mocks that change config between calls).
Options can now be string | (() => string) for both jwtSecret and jwksUrl.
2026-03-20 07:38:26 -07:00

89 lines
2.8 KiB
TypeScript

/**
* 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<typeof createRemoteJWKSet> | 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<typeof createRemoteJWKSet> | 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<AuthPayload> {
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<AuthPayload> {
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 };
}