- createAuthMiddleware(): RS256 JWKS + HS256 fallback (parameterized) - createRequestContext(): productId validation + getUserId() - Fastify request type augmentation for jwtPayload - 15 tests passing
81 lines
2.6 KiB
TypeScript
81 lines
2.6 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 getJWKS(): ReturnType<typeof createRemoteJWKSet> | null {
|
|
const url = opts.jwksUrl;
|
|
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(opts.jwtSecret);
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|