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));
}