From 8e2a1b37c39d93e47b5b1ed0a1dbbc27201d530b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Mar 2026 11:15:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(auth):=20RS256=20JWKS=20verification=20?= =?UTF-8?q?=E2=80=94=20NoteLett=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 1 + backend/src/lib/auth.test.ts | 87 ++++++++++++++++++++++++++++++++++++ backend/src/lib/auth.ts | 78 +++++++++++++++++++++++++++++++- backend/src/lib/config.ts | 1 + 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 backend/src/lib/auth.test.ts diff --git a/backend/.env.example b/backend/.env.example index 2d798b1..9b5c58b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,7 @@ COSMOS_ENDPOINT= COSMOS_KEY= COSMOS_DATABASE=bytelyst JWT_SECRET= +# PLATFORM_JWKS_URL=http://localhost:4003/auth/.well-known/jwks.json DB_PROVIDER=cosmos PRODUCT_ID=notelett PLATFORM_SERVICE_URL=http://localhost:4003 diff --git a/backend/src/lib/auth.test.ts b/backend/src/lib/auth.test.ts new file mode 100644 index 0000000..3103020 --- /dev/null +++ b/backend/src/lib/auth.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK } from 'jose'; +import { createServer, type Server } from 'node:http'; + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + JWT_SECRET: 'test-hs256-secret-at-least-32-chars-long', + PLATFORM_JWKS_URL: undefined as string | undefined, + } as Record, +})); + +vi.mock('./config.js', () => ({ + config: new Proxy({} as Record, { + get: (_target, prop: string) => mockConfig[prop], + }), +})); + +import { extractAuth, requireRole } from './auth.js'; + +let rsaKeys: Awaited>; +let server: Server; +let jwksPort: number; + +beforeAll(async () => { + rsaKeys = await generateKeyPair('RS256'); + const publicJwk = await exportJWK(rsaKeys.publicKey); + publicJwk.alg = 'RS256'; + publicJwk.use = 'sig'; + publicJwk.kid = 'test-key-1'; + + server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ keys: [publicJwk] })); + }); + await new Promise(resolve => server.listen(0, resolve)); + const addr = server.address(); + jwksPort = typeof addr === 'object' && addr ? addr.port : 0; +}); + +afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())); +}); + +describe('JWT verification — RS256 JWKS + HS256 fallback', () => { + it('should fall back to HS256 when JWKS unavailable', async () => { + mockConfig.PLATFORM_JWKS_URL = undefined; + + const secret = new TextEncoder().encode(mockConfig.JWT_SECRET as string); + const token = await new SignJWT({ + sub: 'user-456', + email: 'fallback@example.com', + role: 'admin', + type: 'access', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(secret); + + const payload = await extractAuth({ + headers: { authorization: `Bearer ${token}` }, + }); + expect(payload.sub).toBe('user-456'); + expect(payload.role).toBe('admin'); + }); + + it('should accept RS256 token verified via JWKS', async () => { + mockConfig.PLATFORM_JWKS_URL = `http://localhost:${jwksPort}`; + + const token = await new SignJWT({ + sub: 'user-123', + email: 'test@example.com', + role: 'user', + type: 'access', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(rsaKeys.privateKey); + + const payload = await extractAuth({ + headers: { authorization: `Bearer ${token}` }, + }); + expect(payload.sub).toBe('user-123'); + expect(payload.role).toBe('user'); + }); +}); diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index fbda6a9..d9c7ff3 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1 +1,77 @@ -export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth'; +/** + * JWT auth middleware — RS256 JWKS verification with HS256 fallback. + * Tries RS256 via platform-service JWKS endpoint first (if PLATFORM_JWKS_URL + * is configured), then falls back to HS256 symmetric verification via JWT_SECRET. + */ +import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; +import { config } from './config.js'; + +import type { AuthPayload } from '@bytelyst/auth'; +export type { AuthPayload }; + +// Lazy-init JWKS client (cached, auto-refreshed by jose) +let jwks: ReturnType | null = null; + +function getJWKS(): ReturnType | null { + if (jwks) return jwks; + const url = config.PLATFORM_JWKS_URL; + if (!url) return null; + jwks = createRemoteJWKSet(new URL(url)); + return jwks; +} + +function getHmacSecret(): Uint8Array { + return new TextEncoder().encode(config.JWT_SECRET); +} + +/** + * Extract and verify auth payload from an Authorization header. + * Tries RS256 via JWKS first, falls back to HS256. + */ +export 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. + */ +export 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; +} diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index c5e3da2..1602593 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -11,6 +11,7 @@ const envSchema = z.object({ COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required').optional(), COSMOS_DATABASE: z.string().default('bytelyst'), JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), + PLATFORM_JWKS_URL: z.string().url().optional(), DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), PRODUCT_ID: z.string().default(PRODUCT_ID), PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),