File
Blob: src/worker/auth/session.ts
| 1 | import type { Viewer } from "@/client/server/viewer"; |
| 2 | import { newPrefixedId } from "@/worker/common"; |
| 3 | import { findUserById, listNamespacesForUser } from "@/worker/db/d1/dal"; |
| 4 | import type { UserRow } from "@/worker/db/d1/schema"; |
| 5 | import type { AppContext } from "@/worker/routes/hono"; |
| 6 | import { z } from "zod"; |
| 7 | |
| 8 | import { clearSessionCookie, getSessionCookie, setSessionCookie } from "./cookies"; |
| 9 | |
| 10 | // Session cookie value shape: `goc_sess_<base64url AES-GCM blob>`. |
| 11 | // The sealed blob contains the user id and expiry and is authenticated with |
| 12 | // SESSION_SECRET. Validation decrypts the blob, checks expiry, and loads the |
| 13 | // current user row. Rotating SESSION_SECRET makes existing cookies fail. |
| 14 | const SESSION_TOKEN_PREFIX = "goc_sess_"; |
| 15 | const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days |
| 16 | const SESSION_PURPOSE = "goc-browser-session-v1"; |
| 17 | const SESSION_VERSION = 1; |
| 18 | const AES_GCM_IV_LENGTH = 12; |
| 19 | const AES_KEY_BIT_LENGTH = 256; |
| 20 | |
| 21 | const SessionPayloadSchema = z.object({ |
| 22 | version: z.literal(SESSION_VERSION), |
| 23 | userId: z.string(), |
| 24 | createdAt: z.number(), |
| 25 | expiresAt: z.number(), |
| 26 | }); |
| 27 | |
| 28 | type SessionPayload = z.infer<typeof SessionPayloadSchema>; |
| 29 | |
| 30 | export type ActiveSession = { user: UserRow; payload: SessionPayload }; |
| 31 | |
| 32 | export type SessionConfigResult = |
| 33 | | { ok: true; secret: string } |
| 34 | | { ok: false; reason: "missing_session_secret" }; |
| 35 | |
| 36 | const textEncoder = new TextEncoder(); |
| 37 | const textDecoder = new TextDecoder(); |
| 38 | |
| 39 | function base64UrlEncode(bytes: Uint8Array | ArrayBuffer): string { |
| 40 | const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); |
| 41 | let binary = ""; |
| 42 | for (let i = 0; i < view.length; i += 1) { |
| 43 | binary += String.fromCharCode(view[i]!); |
| 44 | } |
| 45 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 46 | } |
| 47 | |
| 48 | function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> { |
| 49 | const padded = input |
| 50 | .replace(/-/g, "+") |
| 51 | .replace(/_/g, "/") |
| 52 | .padEnd(input.length + ((4 - (input.length % 4)) % 4), "="); |
| 53 | const binary = atob(padded); |
| 54 | const bytes = new Uint8Array(new ArrayBuffer(binary.length)); |
| 55 | for (let i = 0; i < binary.length; i += 1) { |
| 56 | bytes[i] = binary.charCodeAt(i); |
| 57 | } |
| 58 | return bytes; |
| 59 | } |
| 60 | |
| 61 | function randomBytes(length: number): Uint8Array<ArrayBuffer> { |
| 62 | const bytes = new Uint8Array(new ArrayBuffer(length)); |
| 63 | crypto.getRandomValues(bytes); |
| 64 | return bytes; |
| 65 | } |
| 66 | |
| 67 | async function deriveSessionKey(secret: string): Promise<CryptoKey> { |
| 68 | const baseKey = await crypto.subtle.importKey( |
| 69 | "raw", |
| 70 | textEncoder.encode(secret), |
| 71 | { name: "HKDF" }, |
| 72 | false, |
| 73 | ["deriveKey"] |
| 74 | ); |
| 75 | return crypto.subtle.deriveKey( |
| 76 | { |
| 77 | name: "HKDF", |
| 78 | hash: "SHA-256", |
| 79 | salt: new Uint8Array(0), |
| 80 | info: textEncoder.encode(SESSION_PURPOSE), |
| 81 | }, |
| 82 | baseKey, |
| 83 | { name: "AES-GCM", length: AES_KEY_BIT_LENGTH }, |
| 84 | false, |
| 85 | ["encrypt", "decrypt"] |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | async function sealSession(secret: string, payload: SessionPayload): Promise<string> { |
| 90 | const key = await deriveSessionKey(secret); |
| 91 | const iv = randomBytes(AES_GCM_IV_LENGTH); |
| 92 | const plaintext = textEncoder.encode(JSON.stringify(payload)); |
| 93 | const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); |
| 94 | const sealed = new Uint8Array(iv.length + ciphertext.byteLength); |
| 95 | sealed.set(iv, 0); |
| 96 | sealed.set(new Uint8Array(ciphertext), iv.length); |
| 97 | return `${SESSION_TOKEN_PREFIX}${base64UrlEncode(sealed)}`; |
| 98 | } |
| 99 | |
| 100 | type UnsealSessionResult = |
| 101 | | { ok: true; payload: SessionPayload } |
| 102 | | { ok: false; reason: "malformed" | "decrypt_failed" | "invalid_payload" | "expired" }; |
| 103 | |
| 104 | async function unsealSession( |
| 105 | secret: string, |
| 106 | token: string, |
| 107 | now: number = Date.now() |
| 108 | ): Promise<UnsealSessionResult> { |
| 109 | if (!token.startsWith(SESSION_TOKEN_PREFIX)) return { ok: false, reason: "malformed" }; |
| 110 | let raw: Uint8Array<ArrayBuffer>; |
| 111 | try { |
| 112 | raw = base64UrlDecode(token.slice(SESSION_TOKEN_PREFIX.length)); |
| 113 | } catch { |
| 114 | return { ok: false, reason: "malformed" }; |
| 115 | } |
| 116 | if (raw.length <= AES_GCM_IV_LENGTH) return { ok: false, reason: "malformed" }; |
| 117 | const iv = raw.subarray(0, AES_GCM_IV_LENGTH); |
| 118 | const ciphertext = raw.subarray(AES_GCM_IV_LENGTH); |
| 119 | let plaintextBuffer: ArrayBuffer; |
| 120 | try { |
| 121 | const key = await deriveSessionKey(secret); |
| 122 | plaintextBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); |
| 123 | } catch { |
| 124 | return { ok: false, reason: "decrypt_failed" }; |
| 125 | } |
| 126 | let parsed: unknown; |
| 127 | try { |
| 128 | parsed = JSON.parse(textDecoder.decode(plaintextBuffer)); |
| 129 | } catch { |
| 130 | return { ok: false, reason: "invalid_payload" }; |
| 131 | } |
| 132 | const payload = SessionPayloadSchema.safeParse(parsed); |
| 133 | if (!payload.success) return { ok: false, reason: "invalid_payload" }; |
| 134 | if (payload.data.expiresAt <= now) return { ok: false, reason: "expired" }; |
| 135 | return { ok: true, payload: payload.data }; |
| 136 | } |
| 137 | |
| 138 | export function loadSessionConfig(env: Env): SessionConfigResult { |
| 139 | const secret = env.SESSION_SECRET?.trim(); |
| 140 | if (!secret) return { ok: false, reason: "missing_session_secret" }; |
| 141 | return { ok: true, secret }; |
| 142 | } |
| 143 | |
| 144 | export async function createSessionForUser( |
| 145 | env: Env, |
| 146 | c: AppContext, |
| 147 | userId: string, |
| 148 | now: number = Date.now() |
| 149 | ): Promise<{ token: string }> { |
| 150 | const config = loadSessionConfig(env); |
| 151 | if (!config.ok) { |
| 152 | throw new Error(config.reason); |
| 153 | } |
| 154 | const token = await sealSession(config.secret, { |
| 155 | version: SESSION_VERSION, |
| 156 | userId, |
| 157 | createdAt: now, |
| 158 | expiresAt: now + SESSION_TTL_MS, |
| 159 | }); |
| 160 | setSessionCookie(c, token); |
| 161 | return { token }; |
| 162 | } |
| 163 | |
| 164 | async function readActiveSessionUncached(c: AppContext): Promise<ActiveSession | null> { |
| 165 | const token = getSessionCookie(c); |
| 166 | if (!token) return null; |
| 167 | const config = loadSessionConfig(c.env); |
| 168 | if (!config.ok) return null; |
| 169 | try { |
| 170 | const unsealed = await unsealSession(config.secret, token, Date.now()); |
| 171 | if (!unsealed.ok) return null; |
| 172 | const user = await findUserById(c.var.db, unsealed.payload.userId); |
| 173 | if (!user) return null; |
| 174 | return { user, payload: unsealed.payload }; |
| 175 | } catch { |
| 176 | return null; |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // Resolve the active session from the cookie. The sealed payload supplies |
| 181 | // only the user id and expiry; D1 is consulted for the current user row so |
| 182 | // deleted or missing users fail closed. The in-flight promise is stored on |
| 183 | // the request context so multiple access checks share one decrypt + D1 read. |
| 184 | export async function readActiveSession(c: AppContext): Promise<ActiveSession | null> { |
| 185 | const cached = c.var.activeSessionPromise; |
| 186 | if (cached) return await cached; |
| 187 | const promise = readActiveSessionUncached(c); |
| 188 | c.set("activeSessionPromise", promise); |
| 189 | return await promise; |
| 190 | } |
| 191 | |
| 192 | // Lift an active session into a Viewer, resolving the user's primary |
| 193 | // namespace (if any). Used by route handlers that need to render the |
| 194 | // signed-in shell or gate access to /auth/account. |
| 195 | export async function loadViewer(c: AppContext): Promise<Viewer | null> { |
| 196 | const cached = c.var.viewerPromise; |
| 197 | if (cached) return await cached; |
| 198 | const promise = loadViewerUncached(c); |
| 199 | c.set("viewerPromise", promise); |
| 200 | return await promise; |
| 201 | } |
| 202 | |
| 203 | async function loadViewerUncached(c: AppContext): Promise<Viewer | null> { |
| 204 | const active = await readActiveSession(c); |
| 205 | if (!active) return null; |
| 206 | let primaryNamespaceSlug: string | undefined; |
| 207 | try { |
| 208 | const namespaces = await listNamespacesForUser(c.var.db, active.user.id); |
| 209 | primaryNamespaceSlug = namespaces[0]?.slug; |
| 210 | } catch { |
| 211 | primaryNamespaceSlug = undefined; |
| 212 | } |
| 213 | return { userId: active.user.id, primaryNamespaceSlug }; |
| 214 | } |
| 215 | |
| 216 | // Sign-out clears the browser cookie. Because sessions are sealed stateless |
| 217 | // cookies, a copied cookie cannot be server-revoked without adding a |
| 218 | // revocation store; rotating SESSION_SECRET is the coarse invalidation tool. |
| 219 | export async function endSession(c: AppContext): Promise<void> { |
| 220 | clearSessionCookie(c); |
| 221 | const signedOut = Promise.resolve(null); |
| 222 | c.set("activeSessionPromise", signedOut); |
| 223 | c.set("viewerPromise", signedOut); |
| 224 | } |
| 225 | |
| 226 | export function generateUserId(): string { |
| 227 | return newPrefixedId("user"); |
| 228 | } |
| 229 | |
| 230 | export function generateNamespaceId(): string { |
| 231 | return newPrefixedId("ns"); |
| 232 | } |
| 233 | |
| 234 | // Exposed for tests that need to validate sealed-session behavior without |
| 235 | // reaching into module internals. |
| 236 | export const __test = { sealSession, unsealSession }; |