Files
api/src/middleware/portalSession.ts
T
Flea Flicker aabedc8152
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 38s
fix(GRO-2234): bounded sliding expiration for SSO portal sessions (#183)
2026-06-08 18:55:43 +00:00

86 lines
3.4 KiB
TypeScript

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<PortalEnv> = 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();
};