// Investor Flow — tRPC context + auth/session (DESIGN.md §2.2 auth/session seam). // Signed session cookies (HMAC), scrypt password hashing (design specified argon2id; // adapted to built-in scrypt — reversible, OWASP-approved). import { randomUUID, randomBytes, scryptSync, timingSafeEqual, createHmac } from 'node:crypto'; import type { DatabaseSync } from 'node:sqlite'; import type { CacheRepository } from '../cache/CacheRepository.ts'; import type { AdapterQueue } from '../queue/AdapterQueue.ts'; import type { XCookieAdapter } from '../adapters/XCookieAdapter.ts'; export const SESSION_COOKIE = 'iflow_session'; export const OAUTH_STATE_COOKIE = 'iflow_oauth_state'; // Fail-fast: refuse to run with the unsafe default secret outside dev. const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined; if (!process.env.IFLOW_SESSION_SECRET && !isDev) { throw new Error('IFLOW_SESSION_SECRET is not set and NODE_ENV is not "development". The default dev secret must never be used in production.'); } const SESSION_SECRET = process.env.IFLOW_SESSION_SECRET ?? 'dev-secret-change-me'; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days export interface Context { db: DatabaseSync; cache: CacheRepository; queue: AdapterQueue; xAdapter: XCookieAdapter | null; resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter) userId: string | null; // resolved from the session cookie; null = unauthenticated cookies: Record; // parsed request cookies (session + oauth state) } export interface CreateContextOpts { req: Request; resHeaders: Headers; info: unknown; } // ----- signed token (HMAC) ----- function sign(token: string): string { return `${token}.${createHmac('sha256', SESSION_SECRET).update(token).digest('hex')}`; } function unsign(signed: string): string | null { const idx = signed.lastIndexOf('.'); if (idx <= 0) return null; const token = signed.slice(0, idx); const mac = signed.slice(idx + 1); const expected = createHmac('sha256', SESSION_SECRET).update(token).digest('hex'); const a = Buffer.from(mac); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b) ? token : null; } export function sessionCookie(sessionId: string): string { return `${SESSION_COOKIE}=${sign(sessionId)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`; } export function clearCookie(): string { return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`; } // OAuth CSRF state cookie (short-lived): signs provider:state:redirectUri. export function oauthStateCookie(provider: string, state: string, redirectUri: string): string { return `${OAUTH_STATE_COOKIE}=${sign(`${provider}:${state}:${redirectUri}`)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`; } export function verifyOAuthState(cookieValue: string | undefined, provider: string, state: string, redirectUri: string): boolean { if (!cookieValue) return false; const token = unsign(cookieValue); return token !== null && token === `${provider}:${state}:${redirectUri}`; } export function parseCookies(req: Request): Record { const header = req.headers.get('cookie') ?? ''; const out: Record = {}; for (const part of header.split(';')) { const eq = part.indexOf('='); if (eq < 0) continue; const k = part.slice(0, eq).trim(); const v = part.slice(eq + 1).trim(); if (k) out[k] = v; } return out; } export function createSession(database: DatabaseSync, userId: string): { sessionId: string; cookie: string } { const sessionId = randomUUID(); const now = new Date().toISOString(); const expires = new Date(Date.now() + SESSION_TTL_MS).toISOString(); database.prepare('INSERT INTO sessions (id,user_id,expires_at,created_at) VALUES (?,?,?,?)').run(sessionId, userId, expires, now); return { sessionId, cookie: sessionCookie(sessionId) }; } export function resolveSessionUserId(database: DatabaseSync, req: Request): string | null { const cookies = parseCookies(req); const signed = cookies[SESSION_COOKIE]; if (!signed) return null; const token = unsign(signed); if (!token) return null; const row = database.prepare('SELECT user_id, expires_at FROM sessions WHERE id=?').get(token) as { user_id: string; expires_at: string } | undefined; if (!row) return null; if (Date.parse(row.expires_at) < Date.now()) return null; return row.user_id; } // ----- password hashing (scrypt) ----- export function hashPassword(pw: string): string { const salt = randomBytes(16); const hash = scryptSync(pw, salt, 64); return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; } export function verifyPassword(pw: string, stored: string): boolean { const parts = stored.split('$'); if (parts.length !== 3 || parts[0] !== 'scrypt') return false; const salt = Buffer.from(parts[1], 'hex'); const hash = Buffer.from(parts[2], 'hex'); const test = scryptSync(pw, salt, 64); return hash.length === test.length && timingSafeEqual(hash, test); } // ----- context factory: 1f wires real db+cache; tests inject in-memory ----- export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository; queue: AdapterQueue; xAdapter?: XCookieAdapter | null }) { return ({ req, resHeaders }: CreateContextOpts): Context => { const cookies = parseCookies(req); return { db: opts.db, cache: opts.cache, queue: opts.queue, xAdapter: opts.xAdapter ?? null, resHeaders, cookies, userId: resolveSessionUserId(opts.db, req), }; }; } // Backward-compat helper for tests that build a context from a cookie map. export function userIdFromCookies(database: DatabaseSync, cookies: Record): string | null { const signed = cookies[SESSION_COOKIE]; if (!signed) return null; const token = unsign(signed); if (!token) return null; const row = database.prepare('SELECT user_id, expires_at FROM sessions WHERE id=?').get(token) as { user_id: string; expires_at: string } | undefined; if (!row || Date.parse(row.expires_at) < Date.now()) return null; return row.user_id; }