import type { MiddlewareHandler } from "hono"; import { and, eq, getDb, impersonationSessions } from "@groombook/db"; export interface PortalEnv { Variables: { portalClientId: string; portalSessionId: string; }; } /** * Idle lifetime of an SSO-bridge portal impersonation session. Each authenticated * portal request slides `expiresAt` forward to `now + IDLE_TTL`, so an actively-used * session (e.g. a customer working through the multi-step Book New wizard) never * lapses mid-flow. Matches the staff-console impersonation idle window * (SESSION_TIMEOUT_MINUTES in routes/impersonation.ts). (GRO-2234) */ export const PORTAL_SESSION_IDLE_TTL_MS = 30 * 60 * 1000; /** * Absolute cap on a single SSO-bridge portal session's lifetime, measured from * `startedAt`. Sliding can never extend a session beyond this bound, keeping the * impersonation model bounded regardless of how long a customer keeps the tab * active. Deliberately tighter than the previous static 24h mint. (GRO-2234) */ export const PORTAL_SESSION_MAX_LIFETIME_MS = 8 * 60 * 60 * 1000; /** * Minimum extension before we issue a sliding-expiration write. Avoids a DB write * on every rapid successive request — at most one slide per minute per session. */ const PORTAL_SESSION_SLIDE_THRESHOLD_MS = 60 * 1000; /** Reason marker for sessions minted by the Better Auth -> portal bridge. */ const SSO_BRIDGE_REASON = "sso-bridge"; /** * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. * Must be applied to all portal routes. * * Reads x-session-id from request headers, queries impersonationSessions for a row where * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). * Returns 401 if session is invalid/missing/expired. * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). * * Sliding expiration (GRO-2234): for SSO-bridge sessions, each successful request * extends `expiresAt` to `now + PORTAL_SESSION_IDLE_TTL_MS`, bounded by * `startedAt + PORTAL_SESSION_MAX_LIFETIME_MS`. Staff-initiated impersonation * sessions (any other `reason`) are left untouched, preserving their existing * console-enforced timeout behavior. */ export const validatePortalSession: MiddlewareHandler = async (c, next) => { const sessionId = c.req.header("X-Impersonation-Session-Id"); if (!sessionId) { return c.json({ error: "Unauthorized" }, 401); } const db = getDb(); const now = new Date(); const [session] = await db .select() .from(impersonationSessions) .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) .limit(1); if (!session || session.expiresAt <= now) { return c.json({ error: "Unauthorized" }, 401); } // Sliding expiration for SSO-bridge portal sessions only (GRO-2234). if (session.reason === SSO_BRIDGE_REASON) { const maxExpiry = session.startedAt.getTime() + PORTAL_SESSION_MAX_LIFETIME_MS; const slidExpiry = Math.min(now.getTime() + PORTAL_SESSION_IDLE_TTL_MS, maxExpiry); if (slidExpiry - session.expiresAt.getTime() >= PORTAL_SESSION_SLIDE_THRESHOLD_MS) { await db .update(impersonationSessions) .set({ expiresAt: new Date(slidExpiry) }) .where(eq(impersonationSessions.id, session.id)); } } c.set("portalClientId", session.clientId); c.set("portalSessionId", session.id); await next(); };