Files
whatsapp-inbox-platform/lib/auth.ts
Wira Basalamah c84ce90fcf
Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
fix: fallback to signed session payload when DB user row is missing
2026-04-21 13:39:05 +07:00

448 lines
10 KiB
TypeScript

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<string>;
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<string, unknown>);
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<string>();
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;
}