import { env, waitUntil } from "cloudflare:workers"; import { deleteCookie, getCookie, getRequestHeader, setCookie } from "@tanstack/react-start/server"; import { all, first, run } from "./db"; import { randomId, randomToken, sha256Hex } from "./crypto"; import type { UserRow, Viewer } from "~/lib/types"; import { noteSignIn } from "./notify/sign-in"; const SESSION_COOKIE_BASE = "gitfor_session"; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); /** `__Host-` (Secure, Path=/, no Domain) everywhere browsers accept Secure cookies; a plain-http dev host gets neither. */ export function cookieSecure(): boolean { const url = new URL(env.PUBLIC_URL); return url.protocol === "https:" || LOOPBACK_HOSTS.has(url.hostname); } export function cookieName(base: string): string { return cookieSecure() ? `__Host-${base}` : base; } /** Attributes for hand-built Set-Cookie headers. */ export function cookieAttributes(maxAge: number): string { return `Path=/; HttpOnly;${cookieSecure() ? " Secure;" : ""} SameSite=Lax; Max-Age=${maxAge}`; } /** Absolute lifetime of a browser session; the cookie carries the same Max-Age. */ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; /** Sliding idle timeout: a session unused for this long is dead even if the absolute TTL has not passed (AUTH-A12). */ const SESSION_IDLE_MS = 14 * 24 * 60 * 60 * 1000; /** `last_seen_at` is written at most this often so ordinary browsing does not cost a D1 write per request. */ const SESSION_TOUCH_MS = 5 * 60 * 1000; const MAX_USER_AGENT = 200; export const PAT_PREFIX = "gitfor_pat"; type SessionRow = { id: string; user_id: string; ip: string | null; user_agent: string | null; created_at: number; last_seen_at: number; expires_at: number; }; 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); } type ClientInfo = { ip: string; userAgent: string }; async function issueSessionToken(userId: string, client: ClientInfo): Promise { const token = randomToken("sess"); const now = Date.now(); await run( `INSERT INTO sessions (token_hash, id, user_id, ip, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, await sha256Hex(token), randomId(), userId, client.ip, client.userAgent.slice(0, MAX_USER_AGENT), now, now, now + SESSION_TTL_SECONDS * 1000, ); return token; } export async function createSession(userId: string): Promise { const client = { ip: getRequestHeader("cf-connecting-ip") ?? "unknown", userAgent: getRequestHeader("user-agent") ?? "", }; const token = await issueSessionToken(userId, client); await noteSignIn(userId, client.ip, client.userAgent); setCookie(cookieName(SESSION_COOKIE_BASE), token, { httpOnly: true, secure: cookieSecure(), 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, request: Request): Promise { const client = { ip: request.headers.get("cf-connecting-ip") ?? "unknown", userAgent: request.headers.get("user-agent") ?? "", }; const token = await issueSessionToken(userId, client); await noteSignIn(userId, client.ip, client.userAgent); return `${cookieName(SESSION_COOKIE_BASE)}=${token}; ${cookieAttributes(SESSION_TTL_SECONDS)}`; } export async function destroySession(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); if (token) { await run("DELETE FROM sessions WHERE token_hash = ?", await sha256Hex(token)); } deleteCookie(cookieName(SESSION_COOKIE_BASE), { path: "/", secure: cookieSecure() }); if (cookieSecure()) deleteCookie(SESSION_COOKIE_BASE, { path: "/" }); } /** Opaque handle (hash) of the current browser session, for binding derived tokens to its lifetime. */ export async function currentSessionHandle(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); return token ? sha256Hex(token) : null; } /** Whether the browser session behind `handle` still exists (false once the user signs out of it). */ export async function sessionHandleIsLive(handle: string): Promise { const now = Date.now(); const row = await first<{ id: string }>( "SELECT id FROM sessions WHERE token_hash = ? AND expires_at > ? AND last_seen_at > ?", handle, now, now - SESSION_IDLE_MS, ); return row !== null; } export async function sessionViewer(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); return token ? viewerFromSessionToken(token) : null; } /** The live session row for a cookie token, or null when it is unknown, expired or idle too long. */ async function liveSession(token: string): Promise { const now = Date.now(); const row = await first( `SELECT id, user_id, ip, user_agent, created_at, last_seen_at, expires_at FROM sessions WHERE token_hash = ? AND expires_at > ? AND last_seen_at > ?`, await sha256Hex(token), now, now - SESSION_IDLE_MS, ); if (row && now - row.last_seen_at > SESSION_TOUCH_MS) { waitUntil(run("UPDATE sessions SET last_seen_at = ? WHERE id = ?", now, row.id)); } return row; } /** When the current browser session was created, for "recent sign-in" checks. */ export async function sessionCreatedAt(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); if (!token) return null; return (await liveSession(token))?.created_at ?? null; } /** The id of the session behind the current request's cookie, so a sessions list can mark "this device". */ export async function currentSessionId(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); if (!token) return null; return (await liveSession(token))?.id ?? null; } export type SessionSummary = { id: string; ip: string | null; userAgent: string | null; createdAt: number; lastSeenAt: number; current: boolean; }; export async function listSessions(userId: string): Promise { const now = Date.now(); const [rows, current] = await Promise.all([ all( `SELECT id, user_id, ip, user_agent, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? AND last_seen_at > ? ORDER BY last_seen_at DESC`, userId, now, now - SESSION_IDLE_MS, ), currentSessionId(), ]); return rows.map((row) => ({ id: row.id, ip: row.ip, userAgent: row.user_agent, createdAt: row.created_at, lastSeenAt: row.last_seen_at, current: row.id === current, })); } /** Signs out one device. Returns whether a session was found. */ export async function revokeSession(userId: string, sessionId: string): Promise { const result = await run("DELETE FROM sessions WHERE id = ? AND user_id = ?", sessionId, userId); return result.meta.changes > 0; } export async function deleteUserSessions(userId: string): Promise { await run("DELETE FROM sessions WHERE user_id = ?", userId); } export async function purgeExpiredSessions(): Promise { const now = Date.now(); await run("DELETE FROM sessions WHERE expires_at < ? OR last_seen_at < ?", now, now - SESSION_IDLE_MS); } /** True when the request carries a session cookie (current or legacy name), i.e. ambient browser authority. */ export function hasSessionCookie(request: Request): boolean { return ( cookieValue(request, cookieName(SESSION_COOKIE_BASE)) !== null || cookieValue(request, SESSION_COOKIE_BASE) !== null ); } export 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) continue; try { return decodeURIComponent(rest.join("=")); } catch { return null; } } 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, cookieName(SESSION_COOKIE_BASE)); return token ? viewerFromSessionToken(token) : null; } async function viewerFromSessionToken(token: string): Promise { const record = await liveSession(token); if (!record) return null; const user = await loadUserById(record.user_id); if (!user || (user.sessions_revoked_at !== null && record.created_at < 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") { let decoded: string; try { decoded = new TextDecoder().decode(Uint8Array.from(atob(value.trim()), (char) => char.charCodeAt(0))); } catch { return null; } const separator = decoded.indexOf(":"); return separator === -1 ? decoded : decoded.slice(separator + 1); } return null; }