From f61a1f0b04edf73a3f69a5780ef9052783434eef Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Mar 2026 07:30:53 -0700 Subject: [PATCH] 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 --- packages/fastify-auth/package.json | 36 +++++ packages/fastify-auth/src/auth.ts | 80 ++++++++++ packages/fastify-auth/src/index.test.ts | 145 +++++++++++++++++++ packages/fastify-auth/src/index.ts | 8 + packages/fastify-auth/src/request-context.ts | 47 ++++++ packages/fastify-auth/src/types.ts | 44 ++++++ packages/fastify-auth/tsconfig.json | 9 ++ pnpm-lock.yaml | 35 ++++- 8 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 packages/fastify-auth/package.json create mode 100644 packages/fastify-auth/src/auth.ts create mode 100644 packages/fastify-auth/src/index.test.ts create mode 100644 packages/fastify-auth/src/index.ts create mode 100644 packages/fastify-auth/src/request-context.ts create mode 100644 packages/fastify-auth/src/types.ts create mode 100644 packages/fastify-auth/tsconfig.json diff --git a/packages/fastify-auth/package.json b/packages/fastify-auth/package.json new file mode 100644 index 00000000..87df84cd --- /dev/null +++ b/packages/fastify-auth/package.json @@ -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" + ] +} diff --git a/packages/fastify-auth/src/auth.ts b/packages/fastify-auth/src/auth.ts new file mode 100644 index 00000000..a41db7b8 --- /dev/null +++ b/packages/fastify-auth/src/auth.ts @@ -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 | null = null; + let cachedJwksUrl: string | undefined; + + function getJWKS(): ReturnType | 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 { + 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 }; +} diff --git a/packages/fastify-auth/src/index.test.ts b/packages/fastify-auth/src/index.test.ts new file mode 100644 index 00000000..23a0f11a --- /dev/null +++ b/packages/fastify-auth/src/index.test.ts @@ -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; + jwtPayload?: JwtPayload; +} + +function makeReq(token?: string): MockReq { + return { + headers: { + authorization: token ? `Bearer ${token}` : undefined, + }, + jwtPayload: undefined, + }; +} + +async function signToken(payload: Record, 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; + }) { + 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' + ); + }); +}); diff --git a/packages/fastify-auth/src/index.ts b/packages/fastify-auth/src/index.ts new file mode 100644 index 00000000..903ec4ca --- /dev/null +++ b/packages/fastify-auth/src/index.ts @@ -0,0 +1,8 @@ +export { createAuthMiddleware } from './auth.js'; +export { createRequestContext } from './request-context.js'; +export type { + AuthPayload, + JwtPayload, + FastifyAuthOptions, + RequestContextOptions, +} from './types.js'; diff --git a/packages/fastify-auth/src/request-context.ts b/packages/fastify-auth/src/request-context.ts new file mode 100644 index 00000000..27eda5e4 --- /dev/null +++ b/packages/fastify-auth/src/request-context.ts @@ -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 }; +} diff --git a/packages/fastify-auth/src/types.ts b/packages/fastify-auth/src/types.ts new file mode 100644 index 00000000..322870e0 --- /dev/null +++ b/packages/fastify-auth/src/types.ts @@ -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; + } +} diff --git a/packages/fastify-auth/tsconfig.json b/packages/fastify-auth/tsconfig.json new file mode 100644 index 00000000..01c4d9a3 --- /dev/null +++ b/packages/fastify-auth/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db917482..a4dd6ee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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