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
This commit is contained in:
saravanakumardb1 2026-03-20 07:30:53 -07:00
parent d10322095a
commit f61a1f0b04
8 changed files with 396 additions and 8 deletions

View File

@ -0,0 +1,36 @@
{
"name": "@bytelyst/fastify-auth",
"version": "0.1.0",
"description": "JWT auth middleware + request context for Fastify product backends",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"peerDependencies": {
"fastify": ">=5.0.0",
"jose": ">=5.0.0"
},
"dependencies": {
"@bytelyst/errors": "workspace:*"
},
"devDependencies": {
"fastify": "^5.2.1",
"jose": "^6.0.8",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"files": [
"dist"
]
}

View File

@ -0,0 +1,80 @@
/**
* 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 };
}

View File

@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import { SignJWT } from 'jose';
import { createAuthMiddleware, createRequestContext } from './index.js';
import type { JwtPayload } from './types.js';
const TEST_SECRET = 'test-jwt-secret-for-fastify-auth-package';
interface MockReq {
headers: Record<string, string | undefined>;
jwtPayload?: JwtPayload;
}
function makeReq(token?: string): MockReq {
return {
headers: {
authorization: token ? `Bearer ${token}` : undefined,
},
jwtPayload: undefined,
};
}
async function signToken(payload: Record<string, unknown>, secret = TEST_SECRET) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.sign(new TextEncoder().encode(secret));
}
describe('createAuthMiddleware', () => {
const { extractAuth, requireRole } = createAuthMiddleware({
jwtSecret: TEST_SECRET,
});
it('extracts auth payload from valid access token', async () => {
const token = await signToken({
sub: 'user-1',
email: 'test@test.com',
role: 'admin',
type: 'access',
});
const payload = await extractAuth(makeReq(token));
expect(payload.sub).toBe('user-1');
expect(payload.email).toBe('test@test.com');
expect(payload.role).toBe('admin');
});
it('throws UnauthorizedError when no Authorization header', async () => {
await expect(extractAuth(makeReq())).rejects.toThrow('Unauthorized');
});
it('throws UnauthorizedError for invalid token', async () => {
await expect(extractAuth(makeReq('bad-token'))).rejects.toThrow('Invalid or expired token');
});
it('throws UnauthorizedError for non-access token type', async () => {
const token = await signToken({ sub: 'u1', type: 'refresh' });
await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token');
});
it('throws UnauthorizedError for wrong secret', async () => {
const token = await signToken({ sub: 'u1', type: 'access' }, 'wrong');
await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token');
});
it('requireRole passes when role matches', async () => {
const token = await signToken({
sub: 'u1',
role: 'admin',
type: 'access',
});
const payload = await requireRole(makeReq(token), 'admin', 'superadmin');
expect(payload.sub).toBe('u1');
});
it('requireRole throws ForbiddenError when role does not match', async () => {
const token = await signToken({
sub: 'u1',
role: 'viewer',
type: 'access',
});
await expect(requireRole(makeReq(token), 'admin')).rejects.toThrow('Insufficient permissions');
});
it('requireRole passes with no required roles (any authenticated user)', async () => {
const token = await signToken({
sub: 'u1',
type: 'access',
});
const payload = await requireRole(makeReq(token));
expect(payload.sub).toBe('u1');
});
});
describe('createRequestContext', () => {
const { getRequestProductId, getUserId } = createRequestContext({
productId: 'testproduct',
});
function makeFastifyReq(overrides?: {
jwtPayload?: JwtPayload;
headers?: Record<string, string | undefined>;
}) {
const req = makeReq();
if (overrides?.jwtPayload !== undefined) req.jwtPayload = overrides.jwtPayload;
if (overrides?.headers) Object.assign(req.headers, overrides.headers);
return req as unknown as import('fastify').FastifyRequest;
}
it('returns product ID for valid request', () => {
expect(getRequestProductId(makeFastifyReq())).toBe('testproduct');
});
it('returns product ID when JWT productId matches', () => {
expect(
getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'testproduct' } }))
).toBe('testproduct');
});
it('throws BadRequestError when JWT productId does not match', () => {
expect(() =>
getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'wrong' } }))
).toThrow('Invalid productId');
});
it('throws BadRequestError when X-Product-Id header does not match', () => {
expect(() =>
getRequestProductId(makeFastifyReq({ headers: { 'x-product-id': 'wrong' } }))
).toThrow('Invalid productId');
});
it('getUserId returns sub from JWT payload', () => {
expect(getUserId(makeFastifyReq({ jwtPayload: { sub: 'user-42' } }))).toBe('user-42');
});
it('getUserId throws when no JWT payload', () => {
expect(() => getUserId(makeFastifyReq())).toThrow('Missing userId');
});
it('getUserId throws when JWT has no sub', () => {
expect(() => getUserId(makeFastifyReq({ jwtPayload: {} as JwtPayload }))).toThrow(
'Missing userId'
);
});
});

View File

@ -0,0 +1,8 @@
export { createAuthMiddleware } from './auth.js';
export { createRequestContext } from './request-context.js';
export type {
AuthPayload,
JwtPayload,
FastifyAuthOptions,
RequestContextOptions,
} from './types.js';

View File

@ -0,0 +1,47 @@
/**
* Configurable request context helpers for Fastify product backends.
*
* Factory function creates getRequestProductId() and getUserId() bound to
* the provided product ID, eliminating hardcoded product IDs in each repo.
*/
import type { FastifyRequest } from 'fastify';
import { BadRequestError } from '@bytelyst/errors';
import type { RequestContextOptions } from './types.js';
export function createRequestContext(opts: RequestContextOptions) {
const { productId } = opts;
/**
* Extract productId from request. Validates against this backend's product ID.
* Falls back to the configured productId since this is a product-specific backend.
*/
function getRequestProductId(req: FastifyRequest): string {
// 1. From JWT
const jwtPid = req.jwtPayload?.productId;
if (jwtPid && jwtPid !== productId) {
throw new BadRequestError(`Invalid productId: expected ${productId}, got ${jwtPid}`);
}
// 2. From header
const header = req.headers['x-product-id'];
if (typeof header === 'string' && header.length > 0 && header !== productId) {
throw new BadRequestError(`Invalid productId: expected ${productId}, got ${header}`);
}
return productId;
}
/**
* Extract userId from the JWT payload on the request.
* Throws BadRequestError if no authenticated user is found.
*/
function getUserId(req: FastifyRequest): string {
const sub = req.jwtPayload?.sub;
if (!sub) {
throw new BadRequestError('Missing userId — request must be authenticated');
}
return sub;
}
return { getRequestProductId, getUserId };
}

View File

@ -0,0 +1,44 @@
/**
* JWT payload shape expected from platform-service tokens.
* Re-exported from @bytelyst/auth for convenience.
*/
export interface AuthPayload {
sub: string;
email?: string;
role?: string;
productId?: string;
type?: string;
iat?: number;
exp?: number;
iss?: string;
}
/** JWT payload shape attached to req by the onRequest hook. */
export interface JwtPayload {
sub: string;
email?: string;
role?: string;
productId?: string;
type?: string;
}
/** Options for creating the auth middleware. */
export interface FastifyAuthOptions {
/** HS256 symmetric secret for JWT verification. */
jwtSecret: string;
/** Optional RS256 JWKS endpoint URL (tried first, falls back to HS256). */
jwksUrl?: string;
}
/** Options for creating the request context helpers. */
export interface RequestContextOptions {
/** The product ID this backend serves (e.g. 'peakpulse', 'nomgap'). */
productId: string;
}
// Augment Fastify request to include parsed JWT payload
declare module 'fastify' {
interface FastifyRequest {
jwtPayload?: JwtPayload;
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

35
pnpm-lock.yaml generated
View File

@ -191,7 +191,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
husky:
specifier: ^9.0.0
version: 9.1.7
@ -288,7 +288,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
husky:
specifier: ^9.0.0
version: 9.1.7
@ -508,6 +508,25 @@ importers:
specifier: workspace:*
version: link:../api-client
packages/fastify-auth:
dependencies:
'@bytelyst/errors':
specifier: workspace:*
version: link:../errors
devDependencies:
fastify:
specifier: ^5.2.1
version: 5.7.4
jose:
specifier: ^6.0.8
version: 6.1.3
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/fastify-core:
dependencies:
'@bytelyst/errors':
@ -17772,14 +17791,14 @@ snapshots:
msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3)
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
@ -17790,14 +17809,14 @@ snapshots:
msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3)
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@ -23576,7 +23595,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@ -23659,7 +23678,7 @@ snapshots:
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18