import { generateKeyPairSync } from 'node:crypto'; import { SignJWT, importPKCS8 } from 'jose'; import { type JWTPayload } from 'jose'; import { vi, describe, it, expect, beforeAll } from 'vitest'; import { JwtVerifier, JwtVerifierOptions, JwtKey } from './verifier.js'; type SigningMaterial = { privateKeyPem: string; jwk: JwtKey; }; const KEY_ID = 'test-key'; const createSigningMaterial = (kid = KEY_ID): SigningMaterial => { const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const jwk = { ...publicKey.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' } as SigningMaterial['jwk']; return { jwk, privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() }; }; const buildJwtVerifier = (jwk: SigningMaterial['jwk']) => { const getKeysInterceptor = vi.fn(async () => [jwk]); const options: JwtVerifierOptions = { jwksUri: 'https://example.com/.well-known/jwks.json', cache: false, getKeysInterceptor }; return { verifier: new JwtVerifier(options), getKeysInterceptor }; }; describe('JwtVerifier', () => { let signingMaterial: SigningMaterial; beforeAll(() => { signingMaterial = createSigningMaterial(); }); it('verifies a token with a matching JWKS entry', async () => { const { verifier, getKeysInterceptor } = buildJwtVerifier(signingMaterial.jwk); type TokenPayload = JWTPayload & { scope: string }; const payload: TokenPayload = { sub: 'user-123', scope: 'read:all' }; const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256'); const token = await new SignJWT(payload).setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid }).sign(key); const result = await verifier.verify(token, { algorithms: ['RS256'] }); expect(getKeysInterceptor).toHaveBeenCalledTimes(1); expect(result.sub).toBe(payload.sub); expect(result.scope).toBe(payload.scope); }); it('throws UnauthorizedError when the signature does not match the JWKS entry', async () => { const { verifier } = buildJwtVerifier(signingMaterial.jwk); const mismatchedKey = createSigningMaterial(); const invalidKey = await importPKCS8(mismatchedKey.privateKeyPem, 'RS256'); const invalidToken = await new SignJWT({ sub: 'user-456' }) .setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid }) .sign(invalidKey); await expect(verifier.verify(invalidToken, { algorithms: ['RS256'] })).rejects.toMatchObject({ status: 401, message: expect.stringContaining('signature verification failed') }); }); it('honors JwtVerifyOptions when audience and issuer match', async () => { const { verifier, getKeysInterceptor } = buildJwtVerifier(signingMaterial.jwk); type ScopedPayload = JWTPayload & { sub: string }; const audience = 'target-app'; const issuer = 'https://issuer.example.com'; const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256'); const token = await new SignJWT({ sub: 'user-789' }) .setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid }) .setAudience(audience) .setIssuer(issuer) .sign(key); const result = await verifier.verify(token, { algorithms: ['RS256'], audience, issuer }); expect(getKeysInterceptor).toHaveBeenCalledTimes(1); expect(result.sub).toBe('user-789'); expect(result.aud).toBe(audience); expect(result.iss).toBe(issuer); }); it('rejects when VerifyOptions constraints fail', async () => { const { verifier } = buildJwtVerifier(signingMaterial.jwk); const audience = 'target-app'; const issuer = 'https://issuer.example.com'; const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256'); const token = await new SignJWT({ sub: 'user-987' }) .setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid }) .setAudience(audience) .setIssuer(issuer) .sign(key); await expect( verifier.verify(token, { algorithms: ['RS256'], audience: `${audience}-mismatch`, issuer }) ).rejects.toMatchObject({ status: 401, message: expect.stringContaining('unexpected "aud" claim value') }); }); it('rejects malformed tokens before attempting JWKS lookup', async () => { const { verifier, getKeysInterceptor } = buildJwtVerifier(signingMaterial.jwk); await expect(verifier.verify('definitely-not-a-jwt')).rejects.toThrow('Invalid token specified'); expect(getKeysInterceptor).not.toHaveBeenCalled(); }); it('rejects verification when no algorithms allowlist is provided', async () => { const { verifier, getKeysInterceptor } = buildJwtVerifier(signingMaterial.jwk); const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256'); const token = await new SignJWT({ sub: 'user-noalg' }).setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid }).sign(key); await expect(verifier.verify(token)).rejects.toMatchObject({ status: 401 }); // The allowlist guard must trip before any signing key is resolved. expect(getKeysInterceptor).not.toHaveBeenCalled(); }); it('rejects a token whose algorithm is outside the pinned allowlist before any key lookup', async () => { const { verifier, getKeysInterceptor } = buildJwtVerifier(signingMaterial.jwk); const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256'); const token = await new SignJWT({ sub: 'user-rs256' }).setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid }).sign(key); // The token is validly signed, but the caller pins ES256 only. await expect(verifier.verify(token, { algorithms: ['ES256'] })).rejects.toMatchObject({ status: 401 }); expect(getKeysInterceptor).not.toHaveBeenCalled(); }); });