import "server-only"; import { createHash, randomBytes } from "node:crypto"; import { Prisma } from "@prisma/client"; import { AuthTokenType } from "@prisma/client"; import { prisma } from "@/lib/prisma"; const AUTH_TOKEN_SECRET = process.env.AUTH_SECRET; const FALLBACK_AUTH_TOKEN_SECRET = "whatsapp-inbox-dev-secret"; const RESOLVED_SECRET = AUTH_TOKEN_SECRET || (process.env.NODE_ENV === "production" ? undefined : FALLBACK_AUTH_TOKEN_SECRET); if (!RESOLVED_SECRET) { throw new Error("AUTH_SECRET is required in production for secure token generation."); } const textEncoder = new TextEncoder(); const tokenSecretBytes = textEncoder.encode(RESOLVED_SECRET); const RESET_DEFAULT_TTL_MINUTES = 60; const INVITE_DEFAULT_TTL_MINUTES = 72 * 60; type HashInput = { token: string; }; type TokenResult = { id: string; userId: string; tenantId: string; tokenType: AuthTokenType; tokenHash: string; expiresAt: Date; consumedAt: Date | null; createdAt: Date; metadataJson: string | null; }; type CreateAuthTokenInput = { userId: string; tenantId: string; tokenType: AuthTokenType; ttlMinutes?: number; createdByUserId?: string | null; metadata?: unknown; }; type ResolveResult = | { valid: true; token: TokenResult; } | { valid: false; reason: "missing" | "expired" | "consumed"; }; function normalizeTokenMetadata(metadata: unknown) { if (metadata === undefined) { return null; } try { return JSON.stringify(metadata); } catch { return null; } } function serializePayload(input: HashInput) { const bytes = new TextEncoder().encode(`${input.token}`); const payload = new Uint8Array(tokenSecretBytes.length + bytes.length); payload.set(tokenSecretBytes, 0); payload.set(bytes, tokenSecretBytes.length); return payload; } function hashToken(token: string) { if (!token) { return ""; } const digest = createHash("sha256") .update(serializePayload({ token })) .digest(); return digest.toString("base64url"); } function newToken() { return randomBytes(32).toString("base64url"); } function resolveTokenTypeTTL(type: AuthTokenType) { if (type === AuthTokenType.INVITE_ACCEPTANCE) { return INVITE_DEFAULT_TTL_MINUTES; } return RESET_DEFAULT_TTL_MINUTES; } export function makeResetUrl(rawToken: string) { const baseUrl = (process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/+$/, ""); return `${baseUrl}/reset-password?token=${encodeURIComponent(rawToken)}`; } export function makeInviteUrl(rawToken: string) { const baseUrl = (process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/+$/, ""); return `${baseUrl}/invite/${encodeURIComponent(rawToken)}`; } export async function createAuthToken(input: CreateAuthTokenInput) { const ttl = resolveTokenTypeTTL(input.tokenType); const rawToken = newToken(); const tokenHash = hashToken(rawToken); const now = new Date(); const expiresAt = new Date(now.getTime() + ttl * 60 * 1000); await prisma.authToken.deleteMany({ where: { userId: input.userId, tokenType: input.tokenType, consumedAt: null } }); const token = await prisma.authToken.create({ data: { userId: input.userId, tenantId: input.tenantId, tokenType: input.tokenType, tokenHash, expiresAt, createdByUser: input.createdByUserId, metadataJson: normalizeTokenMetadata(input.metadata) } }); return { rawToken, tokenId: token.id, expiresAt: token.expiresAt, ttlMinutes: ttl }; } export async function consumeAuthToken(raw: string, tokenType: AuthTokenType): Promise { if (!raw) { return { valid: false, reason: "missing" }; } const tokenHash = hashToken(raw.trim()); const now = new Date(); const token = await prisma.authToken.findFirst({ where: { tokenType, tokenHash, consumedAt: null }, select: { id: true, userId: true, tenantId: true, tokenType: true, tokenHash: true, expiresAt: true, consumedAt: true, createdAt: true, metadataJson: true } satisfies Prisma.AuthTokenSelect }); if (!token) { return { valid: false, reason: "missing" }; } if (token.expiresAt <= now) { return { valid: false, reason: "expired" }; } if (token.consumedAt) { return { valid: false, reason: "consumed" }; } return { valid: true, token }; } export async function markAuthTokenConsumed(tokenId: string, now: Date) { await prisma.authToken.update({ where: { id: tokenId }, data: { consumedAt: now } }); } export async function revokeUserAuthTokens(userId: string, tokenType: AuthTokenType) { await prisma.authToken.deleteMany({ where: { userId, tokenType } }); }