/** * Portal JWT verification — locks in the audience/issuer hardening shipped * in fix(auth): a token without `aud: 'portal'` + `iss: 'pn-crm'` claims * must NOT verify, even if it's signed with the correct shared secret. * * Without these claims the CRM (better-auth) and portal sessions are * structurally identical, so a portal token could be replayed against any * `verifyPortalToken` consumer (and vice versa). */ import { describe, expect, it } from 'vitest'; import { SignJWT } from 'jose'; import { createPortalToken, verifyPortalToken } from '@/lib/portal/auth'; const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET); const SESSION = { clientId: '11111111-1111-1111-1111-111111111111', portId: '22222222-2222-2222-2222-222222222222', email: 'client@example.com', }; describe('portal JWT', () => { it('round-trips a token signed with createPortalToken', async () => { const token = await createPortalToken(SESSION); const verified = await verifyPortalToken(token); expect(verified).toMatchObject(SESSION); }); it('rejects a token missing the `aud: portal` claim', async () => { // Issuer present, audience absent — exactly the shape an old (pre-fix) // portal session would have. const token = await new SignJWT(SESSION as unknown as Record) .setProtectedHeader({ alg: 'HS256' }) .setIssuer('pn-crm') .setExpirationTime('24h') .setIssuedAt() .sign(SECRET); expect(await verifyPortalToken(token)).toBeNull(); }); it('rejects a token missing the `iss: pn-crm` claim', async () => { const token = await new SignJWT(SESSION as unknown as Record) .setProtectedHeader({ alg: 'HS256' }) .setAudience('portal') .setExpirationTime('24h') .setIssuedAt() .sign(SECRET); expect(await verifyPortalToken(token)).toBeNull(); }); it('rejects a token with the wrong audience (CRM session replay shape)', async () => { // What a better-auth session token might roughly look like — same secret, // different audience. Must not verify against the portal path. const token = await new SignJWT(SESSION as unknown as Record) .setProtectedHeader({ alg: 'HS256' }) .setAudience('crm') .setIssuer('pn-crm') .setExpirationTime('24h') .setIssuedAt() .sign(SECRET); expect(await verifyPortalToken(token)).toBeNull(); }); it('rejects a token with the wrong issuer', async () => { const token = await new SignJWT(SESSION as unknown as Record) .setProtectedHeader({ alg: 'HS256' }) .setAudience('portal') .setIssuer('attacker') .setExpirationTime('24h') .setIssuedAt() .sign(SECRET); expect(await verifyPortalToken(token)).toBeNull(); }); it('rejects garbage', async () => { expect(await verifyPortalToken('not.a.jwt')).toBeNull(); expect(await verifyPortalToken('')).toBeNull(); }); });