import { env } from "cloudflare:workers"; import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server"; import { first } from "./db"; import { randomToken, sha256Hex } from "./crypto"; import type { UserRow, Viewer } from "~/lib/types"; const SESSION_COOKIE = "stig_session"; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; export const PAT_PREFIX = "stig_pat"; type SessionRecord = { userId: string; createdAt: number }; function sessionKey(tokenHash: string): string { return `session:${tokenHash}`; } export function toViewer(user: UserRow): Viewer { return { id: user.id, name: user.name, displayName: user.display_name, email: user.email, isAdmin: user.is_admin === 1, }; } const USER_SELECT = `SELECT a.*, u.email, u.password_hash, u.github_id, u.is_admin, u.email_verified_at, u.totp_enabled_at, u.sessions_revoked_at FROM accounts a JOIN users u ON u.account_id = a.id`; export async function loadUserById(id: string): Promise { return first(`${USER_SELECT} WHERE a.id = ?`, id); } export async function loadUserByLogin(login: string): Promise { return first(`${USER_SELECT} WHERE a.name = ? OR u.email = ?`, login, login); } async function issueSessionToken(userId: string): Promise { const token = randomToken("sess"); const record: SessionRecord = { userId, createdAt: Date.now() }; await env.KV.put(sessionKey(await sha256Hex(token)), JSON.stringify(record), { expirationTtl: SESSION_TTL_SECONDS, }); return token; } export async function createSession(userId: string): Promise { const token = await issueSessionToken(userId); setCookie(SESSION_COOKIE, token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: SESSION_TTL_SECONDS, }); } /** For raw handlers that build their own Response: returns a Set-Cookie header value for a new session. */ export async function createSessionCookie(userId: string): Promise { const token = await issueSessionToken(userId); return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}`; } export async function destroySession(): Promise { const token = getCookie(SESSION_COOKIE); if (token) { await env.KV.delete(sessionKey(await sha256Hex(token))); } deleteCookie(SESSION_COOKIE, { path: "/" }); } export async function sessionViewer(): Promise { const token = getCookie(SESSION_COOKIE); return token ? viewerFromSessionToken(token) : null; } /** When the current browser session was created, for "recent sign-in" checks. */ export async function sessionCreatedAt(): Promise { const token = getCookie(SESSION_COOKIE); if (!token) return null; const record = await env.KV.get(sessionKey(await sha256Hex(token)), "json"); return record?.createdAt ?? null; } function cookieValue(request: Request, name: string): string | null { const header = request.headers.get("cookie"); if (!header) return null; for (const part of header.split(";")) { const [key, ...rest] = part.trim().split("="); if (key === name) return decodeURIComponent(rest.join("=")); } return null; } /** Session lookup for raw Worker handlers that run outside the TanStack request context. */ export async function viewerFromSessionCookie(request: Request): Promise { const token = cookieValue(request, SESSION_COOKIE); return token ? viewerFromSessionToken(token) : null; } async function viewerFromSessionToken(token: string): Promise { const record = await env.KV.get(sessionKey(await sha256Hex(token)), "json"); if (!record) return null; const user = await loadUserById(record.userId); if (!user || (user.sessions_revoked_at !== null && record.createdAt < user.sessions_revoked_at)) return null; return toViewer(user); } export function credentialFromRequest(request: Request): string | null { const header = request.headers.get("authorization"); if (!header) return null; const [scheme, value] = header.split(" ", 2); if (!value) return null; if (scheme.toLowerCase() === "bearer" || scheme.toLowerCase() === "token") return value.trim(); if (scheme.toLowerCase() === "basic") { const decoded = atob(value.trim()); const separator = decoded.indexOf(":"); return separator === -1 ? decoded : decoded.slice(separator + 1); } return null; }