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