| 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<typeof signupSessionSchema>; |
|
|
| 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<SignupSession | null> { |
| 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)); |
| } |
|
|