● Zion Boggan
repos/Stepping App/packages/api/src/auth.ts
zionboggan.com ↗
91 lines · typescript
History for this file →
1
import {
2
  createHash,
3
  randomBytes,
4
  scryptSync,
5
  timingSafeEqual,
6
} from "node:crypto";
7
import { and, eq, gt } from "drizzle-orm";
8
import { sessions, users, type Database } from "@stepping/db";
9
import type { UserRole } from "@stepping/types";
10
 
11
const SESSION_DAYS = 30;
12
export const SESSION_COOKIE = "sid";
13
 
14
export function hashPassword(pw: string): string {
15
  const salt = randomBytes(16).toString("hex");
16
  const hash = scryptSync(pw, salt, 64).toString("hex");
17
  return `scrypt$${salt}$${hash}`;
18
}
19
 
20
export function verifyPassword(pw: string, stored: string | null | undefined): boolean {
21
  if (!stored) return false;
22
  const [scheme, salt, hash] = stored.split("$");
23
  if (scheme !== "scrypt" || !salt || !hash) return false;
24
  const test = scryptSync(pw, salt, 64);
25
  const ref = Buffer.from(hash, "hex");
26
  return test.length === ref.length && timingSafeEqual(test, ref);
27
}
28
 
29
function hashToken(token: string): string {
30
  return createHash("sha256").update(token).digest("hex");
31
}
32
 
33
export async function createSession(
34
  db: Database,
35
  userId: string,
36
): Promise<{ token: string; expiresAt: Date }> {
37
  const token = randomBytes(32).toString("hex");
38
  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);
39
  await db.insert(sessions).values({ tokenHash: hashToken(token), userId, expiresAt });
40
  return { token, expiresAt };
41
}
42
 
43
export async function verifyCredentials(
44
  db: Database,
45
  email: string,
46
  password: string,
47
): Promise<string | null> {
48
  const [u] = await db
49
    .select({ id: users.id, passwordHash: users.passwordHash })
50
    .from(users)
51
    .where(eq(users.email, email.trim().toLowerCase()));
52
  if (!u || !verifyPassword(password, u.passwordHash)) return null;
53
  return u.id;
54
}
55
 
56
export interface SessionUser {
57
  id: string;
58
  role: UserRole;
59
}
60
 
61
export async function validateSession(
62
  db: Database,
63
  token: string | undefined | null,
64
): Promise<SessionUser | null> {
65
  if (!token) return null;
66
  const [row] = await db
67
    .select({ id: users.id, role: users.role, expiresAt: sessions.expiresAt })
68
    .from(sessions)
69
    .innerJoin(users, eq(users.id, sessions.userId))
70
    .where(
71
      and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date())),
72
    );
73
  return row ? { id: row.id, role: row.role } : null;
74
}
75
 
76
export async function destroySession(
77
  db: Database,
78
  token: string | undefined | null,
79
): Promise<void> {
80
  if (!token) return;
81
  await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
82
}
83
 
84
export function readSessionCookie(cookieHeader: string | null | undefined): string | null {
85
  if (!cookieHeader) return null;
86
  for (const part of cookieHeader.split(";")) {
87
    const [k, ...v] = part.trim().split("=");
88
    if (k === SESSION_COOKIE) return decodeURIComponent(v.join("="));
89
  }
90
  return null;
91
}