learning_ai_common_plat/packages/fastify-auth/src/auth.ts
saravanakumardb1 f61a1f0b04 feat(fastify-auth): create @bytelyst/fastify-auth package with JWT auth + request context
- createAuthMiddleware(): RS256 JWKS + HS256 fallback (parameterized)
- createRequestContext(): productId validation + getUserId()
- Fastify request type augmentation for jwtPayload
- 15 tests passing
2026-03-20 07:30:53 -07:00

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 };
}