import { env, waitUntil } from "cloudflare:workers"; import { deleteCookie, getCookie, getRequestHeader, setCookie } from "@tanstack/react-start/server"; import { randomId, sha256Hex, toHex } from "./crypto"; import { accountDb } from "./scoped-db"; import { forgetLocator, locate, registerLocator } from "./scoped-db/locators"; import { accountToken, embeddedAccountId } from "./account-scope"; import type { UserRow, Viewer } from "~/lib/types"; import { noteSignIn } from "./notify/sign-in"; import { emailConfigured } from "./email"; import { accountDisableState, assertAccountActive, disabledNotice } from "./account-disable"; import type { DisabledNotice } from "~/lib/account-disable"; import { loadActiveUserCached } from "./user-row"; /** Sliding idle timeout (AUTH-A12); expired and idle sessions are deleted by the account's housekeeping alarm. */ import { SESSION_IDLE_MS } from "./account-state/housekeeping"; 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; /** `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, emailVerified: user.email_verified_at !== null || user.github_id !== null || !emailConfigured(), }; } export { loadActiveUserById, loadUserById, loadUserByLogin } from "./user-row"; type ClientInfo = { ip: string; userAgent: string }; async function issueSessionToken(userId: string, client: ClientInfo): Promise { await assertAccountActive(userId); const token = accountToken("sess", userId, toHex(crypto.getRandomValues(new Uint8Array(20)))); const tokenHash = await sha256Hex(token); const now = Date.now(); await accountDb(userId).run( `INSERT INTO sessions (token_hash, id, user_id, ip, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, tokenHash, randomId(), userId, client.ip, client.userAgent.slice(0, MAX_USER_AGENT), now, now, now + SESSION_TTL_SECONDS * 1000, ); await registerLocator("session", tokenHash, userId); 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) { const tokenHash = await sha256Hex(token); const userId = await sessionOwner(token, tokenHash); if (userId) await accountDb(userId).run("DELETE FROM sessions WHERE token_hash = ?", tokenHash); await forgetLocator("session", tokenHash); } 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)); if (!token) return null; const tokenHash = await sha256Hex(token); const userId = await sessionOwner(token, tokenHash); return userId ? `${userId}.${tokenHash}` : tokenHash; } /** The account a session token belongs to: embedded in new tokens, located by hash for older ones. */ async function sessionOwner(token: string, tokenHash: string): Promise { return embeddedAccountId(token, "sess") ?? (await locate("session", tokenHash)); } /** 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 dot = handle.indexOf("."); const tokenHash = dot === -1 ? handle : handle.slice(dot + 1); const userId = dot === -1 ? await locate("session", handle) : handle.slice(0, dot); if (!userId) return false; const row = await accountDb(userId).first<{ id: string }>( "SELECT id FROM sessions WHERE token_hash = ? AND expires_at > ? AND last_seen_at > ?", tokenHash, 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; } /** Why the current cookie's account is disabled, so the UI can show the notice instead of signing it in. */ export async function disabledSessionNotice(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); const record = token ? await liveSession(token) : null; const state = record ? await accountDisableState(record.user_id) : null; return state ? disabledNotice(state.reason) : 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 tokenHash = await sha256Hex(token); const userId = await sessionOwner(token, tokenHash); if (!userId) return null; const db = accountDb(userId); const row = await db.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 > ?`, tokenHash, now, now - SESSION_IDLE_MS, ); if (row && now - row.last_seen_at > SESSION_TOUCH_MS) { waitUntil(db.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([ accountDb(userId).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 db = accountDb(userId); const row = await db.first<{ token_hash: string }>( "SELECT token_hash FROM sessions WHERE id = ? AND user_id = ?", sessionId, userId, ); if (!row) return false; await db.run("DELETE FROM sessions WHERE id = ? AND user_id = ?", sessionId, userId); await forgetLocator("session", row.token_hash); return true; } export async function deleteUserSessions(userId: string): Promise { const db = accountDb(userId); const rows = await db.all<{ token_hash: string }>("SELECT token_hash FROM sessions WHERE user_id = ?", userId); await db.run("DELETE FROM sessions WHERE user_id = ?", userId); await Promise.all(rows.map((row) => forgetLocator("session", row.token_hash))); } /** 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 loadActiveUserCached(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; }