diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index d7623f1..3d0445b 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -282,6 +282,8 @@ This means: | TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted | | TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged | | TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted | +| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | +| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | ### 4.9 Waitlist diff --git a/src/__tests__/portalSessionSliding.test.ts b/src/__tests__/portalSessionSliding.test.ts new file mode 100644 index 0000000..4125548 --- /dev/null +++ b/src/__tests__/portalSessionSliding.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Hono } from "hono"; +import { + validatePortalSession, + PORTAL_SESSION_IDLE_TTL_MS, + PORTAL_SESSION_MAX_LIFETIME_MS, + type PortalEnv, +} from "../middleware/portalSession.js"; + +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; + +// Mutable test state driven per-case. +let selectSessionRow: Record | null = null; +let sessionUpdates: Record[] = []; + +function resetMock() { + selectSessionRow = null; + sessionUpdates = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy index + return target[prop]; + }, + }); + return chain; + } + + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => { + sessionUpdates.push(vals); + return { where: () => Promise.resolve(undefined) }; + }, + }), + }), + impersonationSessions, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +const app = new Hono(); +app.use("/portal/*", validatePortalSession); +app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") })); + +function ping(headers?: Record) { + return app.request("/portal/ping", { method: "GET", headers }); +} + +beforeEach(() => resetMock()); + +describe("validatePortalSession — sliding expiration (GRO-2234)", () => { + it("extends an sso-bridge session's expiresAt on each authenticated request", async () => { + const now = Date.now(); + // Session minted ~28 min ago, originally a 30-min idle window: it is still + // valid (2 min left) but a slow wizard would otherwise let it lapse. + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 28 * 60 * 1000), + expiresAt: new Date(now + 2 * 60 * 1000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + + expect(sessionUpdates).toHaveLength(1); + const newExpiry = sessionUpdates[0]!.expiresAt as Date; + // Slid forward to ~now + 30 min (well past the original 2-min-left window). + expect(newExpiry.getTime()).toBeGreaterThan(now + PORTAL_SESSION_IDLE_TTL_MS - 5_000); + expect(newExpiry.getTime()).toBeLessThanOrEqual(now + PORTAL_SESSION_IDLE_TTL_MS + 5_000); + }); + + it("keeps a slow-wizard customer authorized past the original mint TTL", async () => { + const now = Date.now(); + // Original mint window has fully elapsed in wall-clock terms, but the session + // was slid forward on the previous request, so it is still valid now. + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 35 * 60 * 1000), + expiresAt: new Date(now + 10 * 60 * 1000), // previously slid + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clientId).toBe(CLIENT_ID); + }); + + it("never extends beyond startedAt + MAX_LIFETIME (bounded)", async () => { + const now = Date.now(); + // Session started right at the absolute cap boundary minus a hair. + const startedAt = now - (PORTAL_SESSION_MAX_LIFETIME_MS - 5 * 60 * 1000); + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(startedAt), + expiresAt: new Date(now + 60 * 1000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + expect(sessionUpdates).toHaveLength(1); + const newExpiry = (sessionUpdates[0]!.expiresAt as Date).getTime(); + // Capped at startedAt + MAX_LIFETIME, NOT now + IDLE_TTL. + expect(newExpiry).toBeLessThanOrEqual(startedAt + PORTAL_SESSION_MAX_LIFETIME_MS + 1_000); + expect(newExpiry).toBeGreaterThan(now); // still extends at least a little + }); + + it("does NOT slide a staff-initiated impersonation session (no regression)", async () => { + const now = Date.now(); + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "manager reviewing booking", // staff-console reason, free text + startedAt: new Date(now - 5 * 60 * 1000), + expiresAt: new Date(now + 20 * 60 * 1000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + expect(sessionUpdates).toHaveLength(0); + }); + + it("still rejects an already-expired session (no resurrection)", async () => { + const now = Date.now(); + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 40 * 60 * 1000), + expiresAt: new Date(now - 60 * 1000), // already lapsed + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(401); + expect(sessionUpdates).toHaveLength(0); + }); + + it("skips the write when the extension is below the slide threshold", async () => { + const now = Date.now(); + // Already slid this minute: expiresAt is essentially now + IDLE_TTL already. + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 2 * 60 * 1000), + expiresAt: new Date(now + PORTAL_SESSION_IDLE_TTL_MS - 2_000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + expect(sessionUpdates).toHaveLength(0); + }); +}); diff --git a/src/middleware/portalSession.ts b/src/middleware/portalSession.ts index 6dfdb03..055c3fe 100644 --- a/src/middleware/portalSession.ts +++ b/src/middleware/portalSession.ts @@ -8,6 +8,32 @@ export interface PortalEnv { }; } +/** + * 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. @@ -16,6 +42,12 @@ export interface PortalEnv { * 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"); @@ -24,16 +56,29 @@ export const validatePortalSession: MiddlewareHandler = async (c, nex } 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 <= new Date()) { + 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(); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index aa1593a..d614e51 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; -import { validatePortalSession } from "../middleware/portalSession.js"; +import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; import type { PortalEnv } from "../middleware/portalSession.js"; @@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => { staffId, clientId: client.id, reason: "sso-bridge", - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS), }) .returning();