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_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}`; } const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; export const PAT_PREFIX = "gitfor_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(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): Promise { const token = await issueSessionToken(userId); 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 env.KV.delete(sessionKey(await sha256Hex(token))); } deleteCookie(cookieName(SESSION_COOKIE_BASE), { path: "/", secure: cookieSecure() }); if (cookieSecure()) deleteCookie(SESSION_COOKIE_BASE, { path: "/" }); } export async function sessionViewer(): Promise { const token = getCookie(cookieName(SESSION_COOKIE_BASE)); 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(cookieName(SESSION_COOKIE_BASE)); if (!token) return null; const record = await env.KV.get(sessionKey(await sha256Hex(token)), "json"); return record?.createdAt ?? null; } /** 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 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") { 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; }