import "server-only"; import { cookies } from "next/headers"; import { RoleCode, UserStatus } from "@prisma/client"; import { prisma } from "@/lib/prisma"; export type UserRole = "super_admin" | "admin_client" | "agent"; export type AuthSession = { userId: string; fullName: string; email: string; role: UserRole; tenantId: string; tenantName: string; issuedAt: number; expiresAt: number; extraPermissions: string[]; }; export const SESSION_COOKIE = "wa_inbox_session"; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; const AUTH_SECRET = process.env.AUTH_SECRET; const SESSION_ITERATIONS = 120000; const DEFAULT_SECRET = "whatsapp-inbox-dev-secret"; const resolvedAuthSecret = AUTH_SECRET || (process.env.NODE_ENV === "production" ? undefined : DEFAULT_SECRET); if (!resolvedAuthSecret) { throw new Error("AUTH_SECRET is required in production for secure session signing."); } const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const SESSION_SECRET_FALLBACK = textEncoder.encode(resolvedAuthSecret); type RolePermissionSet = Set; function normalizePermissionToken(value: unknown) { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function collectPermissionGrants(target: RolePermissionSet, rawValue: unknown) { const source = typeof rawValue === "string" && rawValue.length > 0 ? rawValue.trim() : rawValue; if (typeof source === "string") { try { const parsed = JSON.parse(source); collectPermissionGrants(target, parsed); return; } catch { return; } } if (!source) { return; } if (Array.isArray(source)) { for (const item of source) { const token = normalizePermissionToken(item); if (!token) { continue; } target.add(token); } return; } if (typeof source !== "object" || source === null) { return; } const entries = Object.entries(source as Record); for (const [key, entry] of entries) { if (key === "global" && entry === true) { target.add("*"); continue; } if (key === "tenant" && entry === true) { target.add("tenant:read"); continue; } if (key === "inbox" && entry === true) { target.add("inbox:read"); target.add("inbox:assign"); target.add("inbox:status"); target.add("inbox:reply"); target.add("inbox:notes"); target.add("inbox:tags"); continue; } if (key === "admin" && entry === true) { target.add("admin:read"); target.add("admin:manage"); continue; } if (key === "agent" && entry === true) { target.add("agent:read"); target.add("agent:manage"); continue; } if (key === "profile_manage_self" && entry === true) { target.add("profile:manage_self"); continue; } if (typeof entry === "string") { const token = normalizePermissionToken(entry); if (!token) { continue; } target.add(token); continue; } if (entry === true) { const token = normalizePermissionToken(key); if (token) { target.add(token); } continue; } collectPermissionGrants(target, entry); } } function parseRolePermissions(rawPermissions: string | null | undefined) { const permissionSet: RolePermissionSet = new Set(); collectPermissionGrants(permissionSet, rawPermissions); return Array.from(permissionSet); } function uint8ToBase64Url(bytes: Uint8Array) { let str = ""; for (const byte of bytes) { str += String.fromCharCode(byte); } return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function base64UrlToUint8(value: string) { const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return bytes; } async function getSessionHmacKey() { return crypto.subtle.importKey( "raw", SESSION_SECRET_FALLBACK, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"] ); } function equalBytes(a: Uint8Array, b: Uint8Array) { if (a.length !== b.length) { return false; } let diff = 0; for (let i = 0; i < a.length; i += 1) { diff |= a[i] ^ b[i]; } return diff === 0; } function resolveRoleFromCode(code: RoleCode): UserRole { switch (code) { case RoleCode.SUPER_ADMIN: return "super_admin"; case RoleCode.AGENT: return "agent"; case RoleCode.ADMIN_CLIENT: default: return "admin_client"; } } export async function serializeSession(session: AuthSession) { const payload = `${session.userId}|${session.role}|${session.tenantId}|${session.expiresAt}`; const key = await getSessionHmacKey(); const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload)); return `${uint8ToBase64Url(textEncoder.encode(payload))}.${uint8ToBase64Url(new Uint8Array(signature))}`; } export async function parseSessionCookie(raw: string) { try { const [payload, signature] = raw.split("."); if (!payload || !signature) { return null; } const key = await getSessionHmacKey(); const payloadBytes = base64UrlToUint8(payload); const signatureBytes = base64UrlToUint8(signature); const valid = await crypto.subtle.verify("HMAC", key, signatureBytes, payloadBytes); if (!valid) { return null; } const decoded = textDecoder.decode(payloadBytes); const [userId, role, tenantId, exp] = decoded.split("|"); if (!userId || !role || !tenantId || !exp) { return null; } const expiresAt = Number(exp); if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) { return null; } return { userId, role: role as UserRole, tenantId, tenantName: "", fullName: "", email: "", issuedAt: Math.floor(Date.now() / 1000), expiresAt }; } catch { return null; } } export async function getSession() { const store = await cookies(); const raw = store.get(SESSION_COOKIE)?.value; if (!raw) { return null; } const parsed = await parseSessionCookie(raw); if (!parsed) { return null; } const user = await prisma.user.findUnique({ where: { id: parsed.userId }, include: { role: true, tenant: true } }); if (!user) { return { userId: parsed.userId, fullName: "User", email: "", role: parsed.role, tenantId: parsed.tenantId, tenantName: parsed.tenantId, extraPermissions: [], issuedAt: parsed.issuedAt, expiresAt: parsed.expiresAt }; } return { userId: user.id, fullName: user.fullName, email: user.email, role: resolveRoleFromCode(user.role.code), tenantId: user.tenantId, tenantName: user.tenant.companyName || user.tenant.name, extraPermissions: parseRolePermissions(user.role.permissionsJson), issuedAt: parsed.issuedAt, expiresAt: parsed.expiresAt }; } function normalizeSalt(value: string) { if (!value) { return null; } try { return base64UrlToUint8(value); } catch { return null; } } export async function hashPassword(password: string) { const salt = new Uint8Array(16); crypto.getRandomValues(salt); const passwordBits = textEncoder.encode(password); const key = await crypto.subtle.importKey("raw", passwordBits, "PBKDF2", false, ["deriveBits"]); const digest = await crypto.subtle.deriveBits( { name: "PBKDF2", hash: "SHA-256", salt, iterations: SESSION_ITERATIONS }, key, 32 * 8 ); return `pbkdf2$${SESSION_ITERATIONS}$${uint8ToBase64Url(salt)}$${uint8ToBase64Url(new Uint8Array(digest))}`; } export async function verifyPassword(password: string, hash: string) { if (!password || !hash) { return false; } const parts = hash.split("$"); if (parts.length !== 4 || parts[0] !== "pbkdf2") { return false; } const [, iterationRaw, salt, expectedDigest] = parts; if (!iterationRaw || !salt || !expectedDigest) { return false; } const iterations = Number(iterationRaw); if (!Number.isFinite(iterations) || iterations <= 0) { return false; } const normalizedSalt = normalizeSalt(salt); const normalizedHash = normalizeSalt(expectedDigest); if (!normalizedSalt || !normalizedHash) { return false; } const passwordBits = textEncoder.encode(password); const key = await crypto.subtle.importKey("raw", passwordBits, "PBKDF2", false, ["deriveBits"]); const derived = await crypto.subtle.deriveBits( { name: "PBKDF2", hash: "SHA-256", salt: normalizedSalt, iterations }, key, normalizedHash.byteLength * 8 ); return equalBytes(new Uint8Array(derived), normalizedHash); } export async function authenticateUser(email: string, password: string) { if (!email || !password) { return null; } const user = await prisma.user.findUnique({ where: { email }, include: { tenant: true, role: true } }); if (!user || user.status !== UserStatus.ACTIVE) { return null; } if (!(await verifyPassword(password, user.passwordHash))) { return null; } const now = Math.floor(Date.now() / 1000); return { userId: user.id, fullName: user.fullName, email: user.email, role: resolveRoleFromCode(user.role.code), tenantId: user.tenantId, tenantName: user.tenant.companyName || user.tenant.name, extraPermissions: parseRolePermissions(user.role.permissionsJson), issuedAt: now, expiresAt: now + SESSION_TTL_SECONDS }; } export function getDefaultPathForRole(role: UserRole) { if (role === "super_admin") { return "/super-admin"; } if (role === "agent") { return "/agent"; } return "/dashboard"; } export function canAccessPath(role: UserRole, pathname: string) { if (pathname.startsWith("/super-admin")) { return role === "super_admin"; } if (pathname.startsWith("/agent")) { return role === "agent"; } const adminPaths = [ "/dashboard", "/inbox", "/contacts", "/templates", "/campaigns", "/team", "/reports", "/settings", "/billing", "/audit-log" ]; if (adminPaths.some((path) => pathname.startsWith(path))) { return role === "admin_client"; } if (pathname.startsWith("/profile") || pathname.startsWith("/notifications") || pathname.startsWith("/search")) { return true; } return true; }