86 lines
3.4 KiB
TypeScript
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();
|
|
};
|