From 4ffe7569b029d057e1608768e5764f849c5665e4 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Mar 2026 11:22:04 -0700 Subject: [PATCH] fix(auth): JWKS URL-tracking singleton + expanded test coverage --- backend/src/lib/auth.test.ts | 52 ++++++++++++++++++++++++++++++------ backend/src/lib/auth.ts | 4 ++- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/backend/src/lib/auth.test.ts b/backend/src/lib/auth.test.ts index 3103020..8200122 100644 --- a/backend/src/lib/auth.test.ts +++ b/backend/src/lib/auth.test.ts @@ -41,21 +41,24 @@ afterAll(async () => { await new Promise(resolve => server.close(() => resolve())); }); +function makeHS256Token(claims: Record) { + const secret = new TextEncoder().encode(mockConfig.JWT_SECRET as string); + return new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(secret); +} + 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({ + const token = await makeHS256Token({ 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}` }, @@ -84,4 +87,37 @@ describe('JWT verification — RS256 JWKS + HS256 fallback', () => { expect(payload.sub).toBe('user-123'); expect(payload.role).toBe('user'); }); + + it('should throw UnauthorizedError when no Bearer token', async () => { + await expect(extractAuth({ headers: {} })).rejects.toThrow('Unauthorized'); + await expect( + extractAuth({ headers: { authorization: 'Basic abc' } }), + ).rejects.toThrow('Unauthorized'); + }); + + it('should reject non-access token type', async () => { + mockConfig.PLATFORM_JWKS_URL = undefined; + const token = await makeHS256Token({ sub: 'user-1', type: 'refresh' }); + + await expect( + extractAuth({ headers: { authorization: `Bearer ${token}` } }), + ).rejects.toThrow('Invalid or expired token'); + }); + + it('should enforce role via requireRole', async () => { + mockConfig.PLATFORM_JWKS_URL = undefined; + const token = await makeHS256Token({ + sub: 'user-1', + role: 'user', + type: 'access', + }); + const req = { headers: { authorization: `Bearer ${token}` } }; + + const payload = await requireRole(req, 'user'); + expect(payload.sub).toBe('user-1'); + + await expect(requireRole(req, 'admin')).rejects.toThrow( + 'Insufficient permissions', + ); + }); }); diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index d9c7ff3..3771de5 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -12,12 +12,14 @@ export type { AuthPayload }; // Lazy-init JWKS client (cached, auto-refreshed by jose) let jwks: ReturnType | null = null; +let jwksUrl: string | undefined; function getJWKS(): ReturnType | null { - if (jwks) return jwks; const url = config.PLATFORM_JWKS_URL; if (!url) return null; + if (jwks && jwksUrl === url) return jwks; jwks = createRemoteJWKSet(new URL(url)); + jwksUrl = url; return jwks; }