fix(GRO-2234): bounded sliding expiration for SSO portal sessions (#183)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 38s

This commit was merged in pull request #183.
This commit is contained in:
2026-06-08 18:55:43 +00:00
parent ca62fb8ef6
commit aabedc8152
4 changed files with 238 additions and 3 deletions
+188
View File
@@ -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<string, unknown> | null = null;
let sessionUpdates: Record<string, unknown>[] = [];
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<string, unknown>) => {
sessionUpdates.push(vals);
return { where: () => Promise.resolve(undefined) };
},
}),
}),
impersonationSessions,
eq: vi.fn(),
and: vi.fn(),
};
});
const app = new Hono<PortalEnv>();
app.use("/portal/*", validatePortalSession);
app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") }));
function ping(headers?: Record<string, string>) {
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);
});
});