File size: 4,199 Bytes
654b283 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | 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));
}
|