import uid from "uid-safe"; import dayjs from "dayjs"; import { db } from "~/database"; import type { User } from "~/user/user"; interface Session { id: string; signedId: string; userId: string; durationMs: number; expiresAt: number; } const SESSION_ID_BYTE_LENGTH = 24; const SESSION_ID_COOKIE_NAME = "session-id"; const SESSION_DURATION_MS = 30 * 60 * 1000; const findSessionQuery = db.query( "SELECT user_id, expires_at_unix FROM sessions WHERE session_id = $sessionId", ); const deleteSessionQuery = db.query( "DELETE FROM sessions WHERE session_id = $sessionId", ); const forgetAllSessionsQuery = db.query( "DELETE FROM sessions WHERE user_id = $userId", ); const saveSessionQuery = db.query( "INSERT INTO sessions(session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)", ); const extendSessionQuery = db.query( "UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id", ); async function newSessionId(): Promise { return await uid(SESSION_ID_BYTE_LENGTH); } function signSessionId(sessionId: string): string { const hasher = new Bun.CryptoHasher("sha256", Bun.env.SESSION_SECRET); hasher.update(sessionId); return `${sessionId}.${hasher.digest("base64url")}`; } async function createSessionForUser(user: User, cookies: Bun.CookieMap) { const sessionId = await newSessionId(); const signedSessionId = signSessionId(sessionId); const expiryDate = dayjs().add(30, "minutes").valueOf(); saveSessionQuery.run({ sessionId, userId: user.id, expiresAt: expiryDate, }); cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, { maxAge: SESSION_DURATION_MS, httpOnly: true, }); } async function saveSession(session: Session, cookies: Bun.CookieMap) { cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, { maxAge: SESSION_DURATION_MS, httpOnly: true, }); } function verifySession(cookie: Bun.CookieMap): Session | null { const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME); if (!signedSessionId) { return null; } const value = signedSessionId.slice(0, signedSessionId.lastIndexOf(".")); const expected = signSessionId(value); const a = Buffer.from(signedSessionId); const b = Buffer.from(expected); const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b); if (!isEqual) { return null; } const row = findSessionQuery.get({ sessionId: value }); if (!row) { return null; } const foundSession = row as { user_id: string; expires_at_unix_ms: number }; const now = dayjs().valueOf(); if (now > foundSession.expires_at_unix_ms) { deleteSessionQuery.run({ sessionId: value }); return null; } return { id: value, signedId: signedSessionId, userId: foundSession.user_id, expiresAt: foundSession.expires_at_unix_ms, durationMs: SESSION_DURATION_MS, }; } function extendSession(session: Session): Session { const newExpiryDate = dayjs().add(30, "minutes").valueOf(); extendSessionQuery.run({ sessionId: session.id, newExpiryDate, }); return { ...session, expiresAt: newExpiryDate, }; } function forgetAllSessions(user: User) { forgetAllSessionsQuery.run({ userId: user.id }); } export { newSessionId, createSessionForUser, verifySession, saveSession, extendSession, forgetAllSessions, };