/** * 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). * * The post-Wave-11 `verifyPortalToken` also does a DB lookup for the * portal user's password-change watermark (auth-flow-auditor C1). Mock * `@/lib/db` so these tests stay unit-pure and don't need a seeded * portal_users row. */ import { describe, expect, it, vi } from 'vitest'; import { SignJWT } from 'jose'; const PORTAL_USER_ID = '33333333-3333-3333-3333-333333333333'; vi.mock('@/lib/db', () => ({ db: { query: { portalUsers: { findFirst: vi.fn(async () => ({ // Watermark in the past so iat (≈ now) is later. passwordChangedAt: new Date(Date.now() - 60_000), isActive: true, })), }, }, }, })); const { createPortalToken, verifyPortalToken } = await import('@/lib/portal/auth'); const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET); const SESSION = { portalUserId: PORTAL_USER_ID, 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(); }); });