import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { cookies } from "next/headers"; import type { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; export const SESSION_COOKIE_NAME = "hlh_signup_session"; export const OAUTH_STATE_COOKIE_NAME = "hlh_signup_oauth_state"; export const OAUTH_VERIFIER_COOKIE_NAME = "hlh_signup_oauth_verifier"; const OAUTH_COOKIE_MAX_AGE_SECONDS = 60 * 10; const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const signupSessionSchema = z.object({ id: z.string().min(1), username: z.string().min(1), name: z.string().min(1), email: z.string().email(), emailVerified: z.boolean(), avatarUrl: z.string().url(), profileUrl: z.string().url(), website: z.string().url().nullable(), isPro: z.boolean(), registrationComplete: z.boolean().default(false), }); export type SignupSession = z.infer; function getSessionSecret(): string { const secret = process.env.SESSION_SECRET?.trim(); if (!secret) { throw new Error("Missing SESSION_SECRET."); } return secret; } function getCookieOptions(maxAge?: number) { return { httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production", path: "/", ...(typeof maxAge === "number" ? { maxAge } : {}), }; } function encodeJsonPayload(value: unknown): string { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } function decodeJsonPayload(value: string): unknown { return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); } function signPayload(payload: string): string { return createHmac("sha256", getSessionSecret()).update(payload).digest("base64url"); } function verifyPayload(payload: string, signature: string): boolean { const expected = Buffer.from(signPayload(payload), "utf8"); const actual = Buffer.from(signature, "utf8"); if (expected.length !== actual.length) { return false; } return timingSafeEqual(expected, actual); } export function createOpaqueToken(bytes = 32): string { return randomBytes(bytes).toString("base64url"); } export function createPkcePair(): { verifier: string; challenge: string } { const verifier = createOpaqueToken(48); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } export function parseSessionCookie(value?: string | null): SignupSession | null { if (!value) { return null; } const [payload, signature] = value.split("."); if (!payload || !signature || !verifyPayload(payload, signature)) { return null; } try { return signupSessionSchema.parse(decodeJsonPayload(payload)); } catch { return null; } } export async function getSessionFromCookies(): Promise { const cookieStore = await cookies(); return parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value); } export function setSessionCookie(response: NextResponse, session: SignupSession) { const payload = encodeJsonPayload(session); const signedValue = `${payload}.${signPayload(payload)}`; response.cookies.set(SESSION_COOKIE_NAME, signedValue, getCookieOptions(SESSION_COOKIE_MAX_AGE_SECONDS)); } export function clearSessionCookie(response: NextResponse) { response.cookies.set(SESSION_COOKIE_NAME, "", getCookieOptions(0)); } export function setOAuthCookies(response: NextResponse, state: string, verifier: string) { response.cookies.set(OAUTH_STATE_COOKIE_NAME, state, getCookieOptions(OAUTH_COOKIE_MAX_AGE_SECONDS)); response.cookies.set(OAUTH_VERIFIER_COOKIE_NAME, verifier, getCookieOptions(OAUTH_COOKIE_MAX_AGE_SECONDS)); } export function readOAuthCookies(request: NextRequest): { state: string | null; verifier: string | null; } { return { state: request.cookies.get(OAUTH_STATE_COOKIE_NAME)?.value ?? null, verifier: request.cookies.get(OAUTH_VERIFIER_COOKIE_NAME)?.value ?? null, }; } export function clearOAuthCookies(response: NextResponse) { response.cookies.set(OAUTH_STATE_COOKIE_NAME, "", getCookieOptions(0)); response.cookies.set(OAUTH_VERIFIER_COOKIE_NAME, "", getCookieOptions(0)); }