import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' import { hmacSha256Hex } from '@sim/security/hmac' import { normalizeEmail } from '@sim/utils/string' import type { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isDev } from '@/lib/core/config/env-flags' /** * Shared authentication utilities for deployed chat endpoints. * Handles token generation, validation, and auth cookies. CORS for these * endpoints lives in proxy.ts as the single source of truth. */ function signPayload(payload: string): string { return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET) } function passwordSlot(encryptedPassword?: string | null): string { if (!encryptedPassword) return '' return sha256Hex(encryptedPassword).slice(0, 8) } function generateAuthToken( deploymentId: string, type: string, encryptedPassword?: string | null ): string { const payload = `${deploymentId}:${type}:${Date.now()}:${passwordSlot(encryptedPassword)}` const sig = signPayload(payload) return Buffer.from(`${payload}:${sig}`).toString('base64') } /** * Validates an HMAC-signed authentication token for a chat deployment. * Includes a password-derived slot so changing the deployment password immediately * invalidates existing sessions. */ export function validateAuthToken( token: string, deploymentId: string, authType: string, encryptedPassword?: string | null ): boolean { try { const decoded = Buffer.from(token, 'base64').toString() const lastColon = decoded.lastIndexOf(':') if (lastColon === -1) return false const payload = decoded.slice(0, lastColon) const sig = decoded.slice(lastColon + 1) const expectedSig = signPayload(payload) if (!safeCompare(sig, expectedSig)) { return false } const parts = payload.split(':') if (parts.length < 4) return false const [storedId, storedType, timestamp, storedPwSlot] = parts if (storedId !== deploymentId) return false // Bind the cookie to the auth type so a token minted under one mode (e.g. a // `public` share, which has an empty password slot) can't satisfy another // mode (e.g. `email` OTP) after the share's auth type is changed. if (storedType !== authType) return false const expectedPwSlot = passwordSlot(encryptedPassword) if (storedPwSlot !== expectedPwSlot) return false const createdAt = Number.parseInt(timestamp) const expireTime = 24 * 60 * 60 * 1000 if (Date.now() - createdAt > expireTime) return false return true } catch (_e) { return false } } /** The kind of deployed resource an auth cookie/token belongs to. */ export type DeploymentAuthKind = 'chat' | 'file' /** Canonical auth cookie name for a deployed resource (`{kind}_auth_{id}`). */ export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: string): string { return `${cookiePrefix}_auth_${id}` } /** * Sets an authentication cookie for a deployment */ export function setDeploymentAuthCookie( response: NextResponse, cookiePrefix: DeploymentAuthKind, deploymentId: string, authType: string, encryptedPassword?: string | null ): void { const token = generateAuthToken(deploymentId, authType, encryptedPassword) response.cookies.set({ name: deploymentAuthCookieName(cookiePrefix, deploymentId), value: token, httpOnly: true, secure: !isDev, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24, }) } /** * Checks if an email matches the allowed emails list (exact match or domain * match). Case-insensitive — email addresses are compared lowercased on both * sides, so callers don't need to normalize before calling. */ export function isEmailAllowed(email: string, allowedEmails: string[]): boolean { const normalizedEmail = normalizeEmail(email) const normalizedAllowed = allowedEmails.map(normalizeEmail) if (normalizedAllowed.includes(normalizedEmail)) { return true } const atIndex = normalizedEmail.indexOf('@') if (atIndex > 0) { const domain = normalizedEmail.substring(atIndex + 1) if (domain && normalizedAllowed.some((allowed) => allowed === `@${domain}`)) { return true } } return false }