import { SignJWT, jwtVerify } from 'jose'; import { cookies } from 'next/headers'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { portalUsers } from '@/lib/db/schema/portal'; const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET); export const PORTAL_COOKIE = 'portal_session'; // BREAKING CHANGE (intentional): tokens issued before this change lack aud/iss // and will be rejected by verifyPortalToken. Portal tokens are 24h-lived so // existing sessions will be invalidated on deploy. Users simply re-login. const PORTAL_AUD = 'portal'; const PORTAL_ISS = 'pn-crm'; export interface PortalSession { clientId: string; portId: string; email: string; /** Portal user id — needed by verifyPortalToken to fetch * passwordChangedAt for the iat-vs-watermark check. Mirrors what * `portalUsers.id` resolves to. */ portalUserId: string; } export async function createPortalToken(session: PortalSession): Promise { return new SignJWT(session as unknown as Record) .setProtectedHeader({ alg: 'HS256' }) .setAudience(PORTAL_AUD) .setIssuer(PORTAL_ISS) .setExpirationTime('24h') .setIssuedAt() .sign(PORTAL_SECRET); } export async function verifyPortalToken(token: string): Promise { try { const { payload } = await jwtVerify(token, PORTAL_SECRET, { audience: PORTAL_AUD, issuer: PORTAL_ISS, }); const session = payload as unknown as PortalSession & { iat?: number }; // auth-flow-auditor C1 (portal half): reject tokens issued before // the user's last password change so a stolen cookie stops working // after the legitimate owner does the forgot-password dance. The // portalUserId claim is required for the lookup; tokens issued by // the pre-C1 codepath lack it and are rejected on that grounds // alone (forces re-login post-deploy, 24h max delay). if (!session.portalUserId || !session.iat) return null; const user = await db.query.portalUsers.findFirst({ where: eq(portalUsers.id, session.portalUserId), columns: { passwordChangedAt: true, isActive: true }, }); if (!user || !user.isActive) return null; const iatSeconds = session.iat; const watermarkSeconds = Math.floor(user.passwordChangedAt.getTime() / 1000); if (iatSeconds < watermarkSeconds) return null; return session; } catch { return null; } } export async function getPortalSession(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(PORTAL_COOKIE)?.value; if (!token) return null; return verifyPortalToken(token); }