Skip to content
File

Blob: src/worker/auth/session.ts

typescript237 lines
1import type { Viewer } from "@/client/server/viewer";
2import { newPrefixedId } from "@/worker/common";
3import { findUserById, listNamespacesForUser } from "@/worker/db/d1/dal";
4import type { UserRow } from "@/worker/db/d1/schema";
5import type { AppContext } from "@/worker/routes/hono";
6import { z } from "zod";
7 
8import { 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.
14const SESSION_TOKEN_PREFIX = "goc_sess_";
15const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
16const SESSION_PURPOSE = "goc-browser-session-v1";
17const SESSION_VERSION = 1;
18const AES_GCM_IV_LENGTH = 12;
19const AES_KEY_BIT_LENGTH = 256;
20 
21const SessionPayloadSchema = z.object({
22 version: z.literal(SESSION_VERSION),
23 userId: z.string(),
24 createdAt: z.number(),
25 expiresAt: z.number(),
26});
27 
28type SessionPayload = z.infer<typeof SessionPayloadSchema>;
29 
30export type ActiveSession = { user: UserRow; payload: SessionPayload };
31 
32export type SessionConfigResult =
33 | { ok: true; secret: string }
34 | { ok: false; reason: "missing_session_secret" };
35 
36const textEncoder = new TextEncoder();
37const textDecoder = new TextDecoder();
38 
39function 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 
48function 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 
61function randomBytes(length: number): Uint8Array<ArrayBuffer> {
62 const bytes = new Uint8Array(new ArrayBuffer(length));
63 crypto.getRandomValues(bytes);
64 return bytes;
65}
66 
67async 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 
89async 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 
100type UnsealSessionResult =
101 | { ok: true; payload: SessionPayload }
102 | { ok: false; reason: "malformed" | "decrypt_failed" | "invalid_payload" | "expired" };
103 
104async 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 
138export 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 
144export 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 
164async 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.
184export 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.
195export 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 
203async 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.
219export 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 
226export function generateUserId(): string {
227 return newPrefixedId("user");
228}
229 
230export 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.
236export const __test = { sealSession, unsealSession };