| 1 | // Dashboard authorization. Identity comes from id.reasonix.io (the shared |
| 2 | // account service); this worker only maps a signed-in identity to a per-dashboard |
| 3 | // role via the `access` table. |
| 4 | import type { Env } from "./env"; |
| 5 | import { esc } from "./shell"; |
| 6 | |
| 7 | export type Role = "pending" | "viewer" | "admin"; |
| 8 | |
| 9 | export interface User { |
| 10 | id: number; |
| 11 | email: string; |
| 12 | role: Role; |
| 13 | created_at: string; |
| 14 | approved_at: string | null; |
| 15 | } |
| 16 | |
| 17 | const RANK: Record<Role, number> = { pending: 0, viewer: 1, admin: 2 }; |
| 18 | |
| 19 | export function atLeast(role: Role, min: Role): boolean { |
| 20 | return RANK[role] >= RANK[min]; |
| 21 | } |
| 22 | |
| 23 | // The id.reasonix.io session cookie is scoped to `.reasonix.io`, so the browser |
| 24 | // sends it here too; we hand it back as a Bearer token to resolve the identity. |
| 25 | const SHARED_COOKIE = "rxid"; |
| 26 | |
| 27 | function idOrigin(env: Env): string { |
| 28 | return (env.ID_ORIGIN ?? "https://id.reasonix.io").replace(/\/$/, ""); |
| 29 | } |
| 30 | |
| 31 | function appOrigin(env: Env): string { |
| 32 | return (env.APP_ORIGIN ?? "https://reasonix.io").replace(/\/$/, ""); |
| 33 | } |
| 34 | |
| 35 | export function getCookie(request: Request, name: string): string | null { |
| 36 | const header = request.headers.get("cookie") ?? ""; |
| 37 | for (const part of header.split(";")) { |
| 38 | const [k, ...v] = part.trim().split("="); |
| 39 | if (k === name) return v.join("="); |
| 40 | } |
| 41 | return null; |
| 42 | } |
| 43 | |
| 44 | // Where an unauthenticated visitor is sent to sign in: the shared login page, |
| 45 | // with a same-site `next` back to the page they wanted. |
| 46 | export function loginUrl(env: Env, request: Request): string { |
| 47 | const here = new URL(request.url); |
| 48 | const next = `${here.origin}${here.pathname}`; |
| 49 | return `${appOrigin(env)}/login/?next=${encodeURIComponent(next)}`; |
| 50 | } |
| 51 | |
| 52 | interface Identity { |
| 53 | email: string; |
| 54 | emailVerified: boolean; |
| 55 | } |
| 56 | |
| 57 | // Short-lived cache of a resolved session so repeat dashboard loads skip the |
| 58 | // cross-worker /me round-trip. Keyed by the token hash, never the raw token. |
| 59 | const IDENTITY_TTL_SECONDS = 60; |
| 60 | |
| 61 | async function identityCacheKey(token: string): Promise<Request> { |
| 62 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); |
| 63 | const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| 64 | return new Request(`https://id-cache.reasonix.io/${hex}`); |
| 65 | } |
| 66 | |
| 67 | async function resolveIdentity(request: Request, env: Env): Promise<Identity | null> { |
| 68 | const token = getCookie(request, SHARED_COOKIE); |
| 69 | if (!token) return null; |
| 70 | const cacheKey = await identityCacheKey(token); |
| 71 | const cached = await caches.default.match(cacheKey); |
| 72 | if (cached) return cached.json<Identity>().catch(() => null); |
| 73 | |
| 74 | const res = await fetch(`${idOrigin(env)}/me`, { headers: { authorization: `Bearer ${token}` } }); |
| 75 | if (!res.ok) return null; |
| 76 | const data = await res.json<{ user?: { email?: string; emailVerified?: boolean } }>().catch(() => null); |
| 77 | const email = data?.user?.email?.toLowerCase(); |
| 78 | if (!email) return null; |
| 79 | const identity: Identity = { email, emailVerified: data?.user?.emailVerified === true }; |
| 80 | await caches.default.put( |
| 81 | cacheKey, |
| 82 | new Response(JSON.stringify(identity), { |
| 83 | headers: { "content-type": "application/json", "cache-control": `max-age=${IDENTITY_TTL_SECONDS}` }, |
| 84 | }), |
| 85 | ); |
| 86 | return identity; |
| 87 | } |
| 88 | |
| 89 | // Resolves the signed-in dashboard user, or null. A first-seen identity is |
| 90 | // recorded as `pending`; an ADMIN_EMAILS identity is force-promoted to admin so |
| 91 | // the owner can never be locked out of their own dashboard. |
| 92 | export async function currentUser(request: Request, env: Env): Promise<User | null> { |
| 93 | const identity = await resolveIdentity(request, env); |
| 94 | if (!identity) return null; |
| 95 | |
| 96 | const now = new Date().toISOString(); |
| 97 | const bootstrapAdmin = isAdminEmail(env, identity.email); |
| 98 | let row = await selectAccess(env, identity.email); |
| 99 | if (!row) { |
| 100 | await env.DB.prepare( |
| 101 | "INSERT INTO access (email, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(email) DO NOTHING", |
| 102 | ) |
| 103 | .bind(identity.email, bootstrapAdmin ? "admin" : "pending", now, bootstrapAdmin ? now : null) |
| 104 | .run(); |
| 105 | row = await selectAccess(env, identity.email); |
| 106 | if (!row) return null; |
| 107 | } else if (bootstrapAdmin && row.role !== "admin") { |
| 108 | await env.DB.prepare("UPDATE access SET role = 'admin', approved_at = COALESCE(approved_at, ?2) WHERE id = ?1") |
| 109 | .bind(row.id, now) |
| 110 | .run(); |
| 111 | row.role = "admin"; |
| 112 | } |
| 113 | return row; |
| 114 | } |
| 115 | |
| 116 | function selectAccess(env: Env, email: string): Promise<User | null> { |
| 117 | return env.DB.prepare("SELECT id, email, role, created_at, approved_at FROM access WHERE email = ?1") |
| 118 | .bind(email) |
| 119 | .first<User>(); |
| 120 | } |
| 121 | |
| 122 | // Ends the shared id.reasonix.io session (best-effort) and returns a Set-Cookie |
| 123 | // that clears the shared cookie browser-side. |
| 124 | export async function sharedLogout(request: Request, env: Env): Promise<string> { |
| 125 | const token = getCookie(request, SHARED_COOKIE); |
| 126 | if (token) { |
| 127 | await fetch(`${idOrigin(env)}/auth/logout`, { |
| 128 | method: "POST", |
| 129 | headers: { authorization: `Bearer ${token}` }, |
| 130 | }).catch(() => {}); |
| 131 | } |
| 132 | return `${SHARED_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=.reasonix.io; Max-Age=0`; |
| 133 | } |
| 134 | |
| 135 | export function isAdminEmail(env: Env, email: string): boolean { |
| 136 | const list = (env.ADMIN_EMAILS ?? "") |
| 137 | .split(",") |
| 138 | .map((s) => s.trim().toLowerCase()) |
| 139 | .filter(Boolean); |
| 140 | return list.includes(email.toLowerCase()); |
| 141 | } |
| 142 | |
| 143 | // CSRF guard for cookie-authed POSTs: a missing Origin is rejected, not waved |
| 144 | // through as same-site. |
| 145 | export function sameOrigin(request: Request): boolean { |
| 146 | const origin = request.headers.get("origin"); |
| 147 | if (!origin) return false; |
| 148 | try { |
| 149 | return new URL(origin).host === new URL(request.url).host; |
| 150 | } catch { |
| 151 | return false; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | export async function logAction(env: Env, actor: User, action: string, target = "", detail = ""): Promise<void> { |
| 156 | await env.DB.prepare( |
| 157 | "INSERT INTO audit_log (at, actor_id, actor_email, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
| 158 | ) |
| 159 | .bind(new Date().toISOString(), actor.id, actor.email, action, target, detail) |
| 160 | .run(); |
| 161 | } |
| 162 | |
| 163 | export function userNav(user: User): string { |
| 164 | const admin = |
| 165 | user.role === "admin" |
| 166 | ? `<a class="navlink" href="/community">Community</a><a class="navlink" href="/admin">Admin</a>` |
| 167 | : ""; |
| 168 | return `<span class="chip"><span class="badge ${user.role}">${user.role}</span>${esc(user.email)}</span><a class="navlink" href="/account">Account</a>${admin}<form method="post" action="/logout" class="inline"><button class="btn ghost sm">Sign out</button></form>`; |
| 169 | } |
| 170 |