signup / src /lib /session.ts
burtenshaw's picture
burtenshaw HF Staff
Deploy Next.js signup Space
654b283 verified
raw
history blame
4.2 kB
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));
}