@openparachute/hub 0.7.7-rc.5 → 0.7.7-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-session.test.ts +244 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/account-session.ts +132 -0
- package/src/hub-server.ts +24 -1
- package/src/sessions.ts +66 -30
package/package.json
CHANGED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { checkAccountSessionResponse } from "@openparachute/door-contract";
|
|
7
|
+
import { handleAccountSession } from "../account-session.ts";
|
|
8
|
+
import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
|
|
9
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
10
|
+
import {
|
|
11
|
+
SESSION_COOKIE_NAME,
|
|
12
|
+
SESSION_SLIDE_THRESHOLD_MS,
|
|
13
|
+
SESSION_TTL_MS,
|
|
14
|
+
buildSessionCookie,
|
|
15
|
+
createSession,
|
|
16
|
+
findSession,
|
|
17
|
+
} from "../sessions.ts";
|
|
18
|
+
import { createUser } from "../users.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tests for `GET /account/session` — the same-origin boot oracle (hub-parity
|
|
22
|
+
* P1, the twin of cloud's `account-session.ts`). Covers:
|
|
23
|
+
* - both branches' body shape (signed-out / signed-in).
|
|
24
|
+
* - CSRF minted when absent, reused (no re-Set-Cookie) when present — on
|
|
25
|
+
* BOTH branches (the G2 anonymous-CSRF invariant).
|
|
26
|
+
* - a deleted-user session row falls back to signed-out.
|
|
27
|
+
* - the bounded slide: an old-but-live session rolls + re-issues the
|
|
28
|
+
* cookie; a fresh session does neither.
|
|
29
|
+
* - 405 on non-GET, `cache-control: no-store`, no CORS headers.
|
|
30
|
+
* - drift: `checkAccountSessionResponse` reports zero issues against the
|
|
31
|
+
* live handler on both branches.
|
|
32
|
+
*/
|
|
33
|
+
const ISSUER = "https://hub.test";
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
db: Database;
|
|
37
|
+
cleanup: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeHarness(): Harness {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-session-"));
|
|
42
|
+
const db = openHubDb(hubDbPath(dir));
|
|
43
|
+
return {
|
|
44
|
+
db,
|
|
45
|
+
cleanup: () => {
|
|
46
|
+
db.close();
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let harness: Harness;
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
harness = makeHarness();
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
harness.cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function getReq(cookie?: string, method = "GET"): Request {
|
|
61
|
+
return new Request(`${ISSUER}/account/session`, {
|
|
62
|
+
method,
|
|
63
|
+
...(cookie ? { headers: { cookie } } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sessionCookiePair(sid: string): string {
|
|
68
|
+
return buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("handleAccountSession — signed-out branch", () => {
|
|
72
|
+
test("no cookie at all → {signed_in:false, csrf} + CSRF Set-Cookie minted", async () => {
|
|
73
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = (await res.json()) as { signed_in: boolean; csrf: string };
|
|
76
|
+
expect(body.signed_in).toBe(false);
|
|
77
|
+
expect(typeof body.csrf).toBe("string");
|
|
78
|
+
expect(body.csrf.length).toBeGreaterThan(0);
|
|
79
|
+
|
|
80
|
+
const setCookie = res.headers.getSetCookie();
|
|
81
|
+
const csrfSet = setCookie.find((c) => c.includes(CSRF_COOKIE_NAME));
|
|
82
|
+
expect(csrfSet).toBeDefined();
|
|
83
|
+
expect(parseCsrfCookie(csrfSet ?? null)).toBe(body.csrf);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reuses an existing CSRF cookie without re-minting (no Set-Cookie)", async () => {
|
|
87
|
+
const existing = generateCsrfToken();
|
|
88
|
+
const cookie = buildCsrfCookie(existing).split(";")[0] ?? "";
|
|
89
|
+
const res = handleAccountSession(getReq(cookie), { db: harness.db });
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
const body = (await res.json()) as { signed_in: boolean; csrf: string };
|
|
92
|
+
expect(body.signed_in).toBe(false);
|
|
93
|
+
expect(body.csrf).toBe(existing);
|
|
94
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a session cookie naming a deleted user → signed-out", async () => {
|
|
98
|
+
// The on-disk FK normally guards against a session outliving its user, so
|
|
99
|
+
// forge the orphaned row directly with `PRAGMA foreign_keys=OFF` (the
|
|
100
|
+
// `api-account.test.ts` precedent) — simulating a delete-user-via-SQL-shell
|
|
101
|
+
// race, not something reachable through the app's own APIs.
|
|
102
|
+
const sessionId = "test-orphaned-session-id-base64url";
|
|
103
|
+
harness.db.exec("PRAGMA foreign_keys = OFF");
|
|
104
|
+
try {
|
|
105
|
+
harness.db
|
|
106
|
+
.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
107
|
+
.run(
|
|
108
|
+
sessionId,
|
|
109
|
+
"nonexistent-user-uuid",
|
|
110
|
+
new Date(Date.now() + SESSION_TTL_MS).toISOString(),
|
|
111
|
+
new Date().toISOString(),
|
|
112
|
+
);
|
|
113
|
+
} finally {
|
|
114
|
+
harness.db.exec("PRAGMA foreign_keys = ON");
|
|
115
|
+
}
|
|
116
|
+
const cookie = sessionCookiePair(sessionId);
|
|
117
|
+
const res = handleAccountSession(getReq(cookie), { db: harness.db });
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const body = (await res.json()) as { signed_in: boolean };
|
|
120
|
+
expect(body.signed_in).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("drift: checkAccountSessionResponse reports zero issues (signed-out)", async () => {
|
|
124
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
125
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
126
|
+
expect(checkAccountSessionResponse(body, { signedIn: false })).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("handleAccountSession — signed-in branch", () => {
|
|
131
|
+
test("username always present; no email when the user row has none", async () => {
|
|
132
|
+
const user = await createUser(harness.db, "operator", "pw", { passwordChanged: true });
|
|
133
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
134
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
137
|
+
expect(body.signed_in).toBe(true);
|
|
138
|
+
expect(body.username).toBe("operator");
|
|
139
|
+
expect(body.email).toBeUndefined();
|
|
140
|
+
expect(body.account_created_at).toBe(user.createdAt);
|
|
141
|
+
expect(body.password_change_required).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("email present when the user row has one", async () => {
|
|
145
|
+
const user = await createUser(harness.db, "withmail", "pw", {
|
|
146
|
+
passwordChanged: true,
|
|
147
|
+
email: "friend@example.com",
|
|
148
|
+
});
|
|
149
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
150
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
151
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
152
|
+
expect(body.email).toBe("friend@example.com");
|
|
153
|
+
expect(body.username).toBe("withmail");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("password_change_required:true for a not-yet-rotated (passwordChanged:false) user", async () => {
|
|
157
|
+
// createUser defaults passwordChanged to false — the admin-created-user
|
|
158
|
+
// posture (force-redirect on first sign-in).
|
|
159
|
+
const user = await createUser(harness.db, "temp", "pw");
|
|
160
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
161
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
162
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
163
|
+
expect(body.signed_in).toBe(true);
|
|
164
|
+
expect(body.password_change_required).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("mints CSRF when absent even on the signed-in branch", async () => {
|
|
168
|
+
const user = await createUser(harness.db, "operator2", "pw", { passwordChanged: true });
|
|
169
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
170
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
171
|
+
const body = (await res.json()) as { csrf: string };
|
|
172
|
+
expect(typeof body.csrf).toBe("string");
|
|
173
|
+
expect(body.csrf.length).toBeGreaterThan(0);
|
|
174
|
+
const setCookie = res.headers.getSetCookie();
|
|
175
|
+
expect(setCookie.some((c) => c.includes(CSRF_COOKIE_NAME))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("drift: checkAccountSessionResponse reports zero issues (signed-in)", async () => {
|
|
179
|
+
const user = await createUser(harness.db, "driftcheck", "pw", { passwordChanged: true });
|
|
180
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
181
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
182
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
183
|
+
expect(checkAccountSessionResponse(body, { signedIn: true })).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("handleAccountSession — bounded slide", () => {
|
|
188
|
+
test("an old-but-live session (within the slide threshold of expiry) rolls + re-issues the cookie", async () => {
|
|
189
|
+
const user = await createUser(harness.db, "slider", "pw", { passwordChanged: true });
|
|
190
|
+
const t0 = new Date();
|
|
191
|
+
// Created far enough in the past that remaining life < TTL - threshold.
|
|
192
|
+
const createdAt = new Date(
|
|
193
|
+
t0.getTime() - (SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS + 60_000),
|
|
194
|
+
);
|
|
195
|
+
const session = createSession(harness.db, { userId: user.id, now: () => createdAt });
|
|
196
|
+
const originalExpiry = new Date(session.expiresAt).getTime();
|
|
197
|
+
|
|
198
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
const setCookie = res.headers.getSetCookie();
|
|
201
|
+
const sessionSet = setCookie.find((c) => c.includes(SESSION_COOKIE_NAME));
|
|
202
|
+
expect(sessionSet).toBeDefined();
|
|
203
|
+
expect(sessionSet).toContain("HttpOnly");
|
|
204
|
+
expect(sessionSet).toContain("SameSite=Lax");
|
|
205
|
+
|
|
206
|
+
const found = findSession(harness.db, session.id);
|
|
207
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a fresh session does NOT slide — no session Set-Cookie, no DB write", async () => {
|
|
211
|
+
const user = await createUser(harness.db, "fresh", "pw", { passwordChanged: true });
|
|
212
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
213
|
+
const originalExpiry = session.expiresAt;
|
|
214
|
+
|
|
215
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
const setCookie = res.headers.getSetCookie();
|
|
218
|
+
expect(setCookie.some((c) => c.includes(SESSION_COOKIE_NAME))).toBe(false);
|
|
219
|
+
|
|
220
|
+
const found = findSession(harness.db, session.id);
|
|
221
|
+
expect(found?.expiresAt).toBe(originalExpiry);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("handleAccountSession — method + headers", () => {
|
|
226
|
+
test("405 on POST", async () => {
|
|
227
|
+
const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
|
|
228
|
+
expect(res.status).toBe(405);
|
|
229
|
+
expect(res.headers.get("allow")).toBe("GET");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("cache-control: no-store on the 200 path", async () => {
|
|
233
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
234
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("no access-control-allow-origin (same-origin only, no CORS)", async () => {
|
|
238
|
+
const req = new Request(`${ISSUER}/account/session`, {
|
|
239
|
+
headers: { origin: "https://evil.example" },
|
|
240
|
+
});
|
|
241
|
+
const res = handleAccountSession(req, { db: harness.db });
|
|
242
|
+
expect(res.headers.get("access-control-allow-origin")).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -603,4 +603,29 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
603
603
|
expect(res.headers.get("location")).toBe("/login");
|
|
604
604
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
|
|
605
605
|
});
|
|
606
|
+
|
|
607
|
+
// App-wire pin (hub-parity P1, §8-M4): the app's `logout()` posts form-
|
|
608
|
+
// encoded, not JSON (`parachute-app/src/lib/account/client.ts:246-258`:
|
|
609
|
+
// `content-type: application/x-www-form-urlencoded`, body
|
|
610
|
+
// `new URLSearchParams({ __csrf: csrf }).toString()`, credentials included).
|
|
611
|
+
// No hub code changed for this — the existing formData()-based handler
|
|
612
|
+
// already accepts exactly this shape; this test freezes the byte-shape so a
|
|
613
|
+
// future refactor of the handler can't silently drop form-body support out
|
|
614
|
+
// from under the app.
|
|
615
|
+
test("app-wire pin — form-encoded body matching client.ts's exact logout() shape", async () => {
|
|
616
|
+
const cookie = await cookieForUser(harness.db, "appuser", "pw");
|
|
617
|
+
const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
|
|
618
|
+
const req = new Request("http://hub.test/logout", {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: {
|
|
621
|
+
cookie,
|
|
622
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
623
|
+
},
|
|
624
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: TEST_CSRF }).toString(),
|
|
625
|
+
});
|
|
626
|
+
const res = await handleAdminLogoutPost(harness.db, req);
|
|
627
|
+
expect(res.status).toBe(302);
|
|
628
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
629
|
+
expect(findSession(harness.db, sid)).toBeNull();
|
|
630
|
+
});
|
|
606
631
|
});
|
|
@@ -5,13 +5,15 @@ import { join } from "node:path";
|
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import {
|
|
7
7
|
SESSION_COOKIE_NAME,
|
|
8
|
-
|
|
8
|
+
SESSION_SLIDE_THRESHOLD_MS,
|
|
9
|
+
SESSION_TTL_MS,
|
|
9
10
|
buildSessionClearCookie,
|
|
10
11
|
buildSessionCookie,
|
|
11
12
|
createSession,
|
|
12
13
|
deleteSession,
|
|
13
14
|
findSession,
|
|
14
15
|
parseSessionCookie,
|
|
16
|
+
shouldSlideSession,
|
|
15
17
|
touchSession,
|
|
16
18
|
} from "../sessions.ts";
|
|
17
19
|
import { createUser } from "../users.ts";
|
|
@@ -57,11 +59,11 @@ describe("createSession + findSession", () => {
|
|
|
57
59
|
try {
|
|
58
60
|
const epoch = new Date("2026-01-01T00:00:00Z");
|
|
59
61
|
const s = createSession(db, { userId, now: () => epoch });
|
|
60
|
-
// Past TTL (
|
|
61
|
-
const later = new Date(epoch.getTime() +
|
|
62
|
+
// Past TTL (90 days).
|
|
63
|
+
const later = new Date(epoch.getTime() + SESSION_TTL_MS + 3600 * 1000);
|
|
62
64
|
expect(findSession(db, s.id, () => later)).toBeNull();
|
|
63
65
|
// Still valid one second before expiry.
|
|
64
|
-
const justBefore = new Date(epoch.getTime() +
|
|
66
|
+
const justBefore = new Date(epoch.getTime() + SESSION_TTL_MS - 1000);
|
|
65
67
|
expect(findSession(db, s.id, () => justBefore)?.id).toBe(s.id);
|
|
66
68
|
} finally {
|
|
67
69
|
cleanup();
|
|
@@ -78,60 +80,64 @@ describe("touchSession (sliding renewal)", () => {
|
|
|
78
80
|
try {
|
|
79
81
|
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
80
82
|
const s = createSession(db, { userId, now: () => t0 });
|
|
81
|
-
// Original expiry: t0 +
|
|
82
|
-
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() +
|
|
83
|
-
// Touch 1h later → expiry becomes (t0 + 1h) +
|
|
83
|
+
// Original expiry: t0 + 90d.
|
|
84
|
+
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + SESSION_TTL_MS);
|
|
85
|
+
// Touch 1h later → expiry becomes (t0 + 1h) + 90d.
|
|
84
86
|
const t1 = new Date(t0.getTime() + HOUR);
|
|
85
87
|
touchSession(db, s.id, () => t1);
|
|
86
88
|
const found = findSession(db, s.id, () => t1);
|
|
87
|
-
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() +
|
|
89
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + SESSION_TTL_MS);
|
|
88
90
|
} finally {
|
|
89
91
|
cleanup();
|
|
90
92
|
}
|
|
91
93
|
});
|
|
92
94
|
|
|
93
|
-
test("a touched session outlives the ORIGINAL
|
|
95
|
+
test("a touched session outlives the ORIGINAL 90d expiry", async () => {
|
|
94
96
|
const { db, userId, cleanup } = await makeDb();
|
|
95
97
|
try {
|
|
96
98
|
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
97
99
|
const s = createSession(db, { userId, now: () => t0 });
|
|
98
|
-
// Activity at +
|
|
99
|
-
touchSession(db, s.id, () => new Date(t0.getTime() +
|
|
100
|
-
// At +
|
|
101
|
-
const
|
|
102
|
-
expect(findSession(db, s.id, () =>
|
|
100
|
+
// Activity at +45d slides expiry to +135d.
|
|
101
|
+
touchSession(db, s.id, () => new Date(t0.getTime() + 45 * DAY));
|
|
102
|
+
// At +100d — PAST the original +90d — the session is still alive.
|
|
103
|
+
const at100d = new Date(t0.getTime() + 100 * DAY);
|
|
104
|
+
expect(findSession(db, s.id, () => at100d)?.id).toBe(s.id);
|
|
103
105
|
} finally {
|
|
104
106
|
cleanup();
|
|
105
107
|
}
|
|
106
108
|
});
|
|
107
109
|
|
|
108
|
-
test("an UNtouched session still expires at the original
|
|
110
|
+
test("an UNtouched session still expires at the original 90d", async () => {
|
|
109
111
|
const { db, userId, cleanup } = await makeDb();
|
|
110
112
|
try {
|
|
111
113
|
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
112
114
|
const s = createSession(db, { userId, now: () => t0 });
|
|
113
|
-
// No touch —
|
|
114
|
-
//
|
|
115
|
-
const
|
|
116
|
-
expect(findSession(db, s.id, () =>
|
|
115
|
+
// No touch — one day past the 90d TTL it's gone (idle / closed tabs
|
|
116
|
+
// that stop re-minting still lapse at the full TTL).
|
|
117
|
+
const past = new Date(t0.getTime() + SESSION_TTL_MS + DAY);
|
|
118
|
+
expect(findSession(db, s.id, () => past)).toBeNull();
|
|
117
119
|
} finally {
|
|
118
120
|
cleanup();
|
|
119
121
|
}
|
|
120
122
|
});
|
|
121
123
|
|
|
122
|
-
test("
|
|
124
|
+
test("NO ceiling (Q4) — an actively-touched session rolls forward indefinitely", async () => {
|
|
125
|
+
// The old SESSION_MAX_LIFETIME_MS (30d) absolute cap is gone: repeatedly
|
|
126
|
+
// touching a session keeps sliding its expiry to now + 90d with no upper
|
|
127
|
+
// bound, matching cloud's rolling posture (an idle session still lapses
|
|
128
|
+
// at the TTL; an active one never does).
|
|
123
129
|
const { db, userId, cleanup } = await makeDb();
|
|
124
130
|
try {
|
|
125
131
|
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
126
132
|
const s = createSession(db, { userId, now: () => t0 });
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(findSession(db, s.id, () =>
|
|
133
|
+
// Touch well past where the old 30-day ceiling would have pinned it.
|
|
134
|
+
const farOut = new Date(t0.getTime() + 200 * DAY);
|
|
135
|
+
touchSession(db, s.id, () => farOut);
|
|
136
|
+
const found = findSession(db, s.id, () => farOut);
|
|
137
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(farOut.getTime() + SESSION_TTL_MS);
|
|
138
|
+
// Still alive nearly 90 more days out, right up to the fresh slide's TTL.
|
|
139
|
+
const stillAlive = new Date(farOut.getTime() + SESSION_TTL_MS - DAY);
|
|
140
|
+
expect(findSession(db, s.id, () => stillAlive)?.id).toBe(s.id);
|
|
135
141
|
} finally {
|
|
136
142
|
cleanup();
|
|
137
143
|
}
|
|
@@ -147,6 +153,47 @@ describe("touchSession (sliding renewal)", () => {
|
|
|
147
153
|
});
|
|
148
154
|
});
|
|
149
155
|
|
|
156
|
+
describe("shouldSlideSession (bounded slide, G3 twin)", () => {
|
|
157
|
+
test("false for a freshly-created session (full TTL remaining)", async () => {
|
|
158
|
+
const { db, userId, cleanup } = await makeDb();
|
|
159
|
+
try {
|
|
160
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
161
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
162
|
+
expect(shouldSlideSession(s, () => t0)).toBe(false);
|
|
163
|
+
} finally {
|
|
164
|
+
cleanup();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("false just before crossing the slide threshold", async () => {
|
|
169
|
+
const { db, userId, cleanup } = await makeDb();
|
|
170
|
+
try {
|
|
171
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
172
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
173
|
+
// Elapsed just UNDER the threshold (30d) → remaining life still above
|
|
174
|
+
// (TTL - threshold) → not yet due to slide.
|
|
175
|
+
const justBefore = new Date(t0.getTime() + SESSION_SLIDE_THRESHOLD_MS - 1000);
|
|
176
|
+
expect(shouldSlideSession(s, () => justBefore)).toBe(false);
|
|
177
|
+
} finally {
|
|
178
|
+
cleanup();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("true once remaining life drops below the slide threshold", async () => {
|
|
183
|
+
const { db, userId, cleanup } = await makeDb();
|
|
184
|
+
try {
|
|
185
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
186
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
187
|
+
// Elapsed just OVER the threshold (30d) → remaining life below
|
|
188
|
+
// (TTL - threshold) → due to slide.
|
|
189
|
+
const justAfter = new Date(t0.getTime() + SESSION_SLIDE_THRESHOLD_MS + 1000);
|
|
190
|
+
expect(shouldSlideSession(s, () => justAfter)).toBe(true);
|
|
191
|
+
} finally {
|
|
192
|
+
cleanup();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
150
197
|
describe("deleteSession", () => {
|
|
151
198
|
test("removes the session row", async () => {
|
|
152
199
|
const { db, userId, cleanup } = await makeDb();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /account/session` — the same-origin boot oracle (hub-parity P1; the
|
|
3
|
+
* hub's twin of cloud `account-session.ts`). Returns `{ signed_in, csrf, ... }`
|
|
4
|
+
* so the same-origin `parachute-app` — served at the hub's own origin — can
|
|
5
|
+
* (a) decide whether to render the signed-in UI or the sign-in ceremony, and
|
|
6
|
+
* (b) echo `csrf` back as `__csrf` on its next same-origin `POST /account/token`
|
|
7
|
+
* mint (C2). The CSRF cookie is `HttpOnly` (csrf.ts), so the SPA can't read it
|
|
8
|
+
* directly; this hands the token over the same way a server-rendered form's
|
|
9
|
+
* hidden input does.
|
|
10
|
+
*
|
|
11
|
+
* PUBLIC — no Bearer, no scope gate. It drives the `parachute_hub_session`
|
|
12
|
+
* cookie directly, same as `/api/me`: a client with no account Bearer yet has
|
|
13
|
+
* nothing else to authenticate a read with, and this endpoint IS the
|
|
14
|
+
* bootstrap. Mounted ABOVE the force-change-password gate (hub-server.ts
|
|
15
|
+
* CHOKE POINT 1) — it's a pure state oracle, not a mutating surface, so a
|
|
16
|
+
* pre-rotation user must still be able to read it (the app needs `signed_in` +
|
|
17
|
+
* `csrf` to drive the rotation ceremony itself, not get walled off before it
|
|
18
|
+
* can even ask). `POST /account/token` stays BELOW the gate — a pre-rotation
|
|
19
|
+
* user hits its 403 `force_change_password` there, and the app is expected to
|
|
20
|
+
* weather that (P4).
|
|
21
|
+
*
|
|
22
|
+
* SAME-ORIGIN ONLY, by design — mirrors cloud's posture exactly: the response
|
|
23
|
+
* is CREDENTIALED (it reflects the session cookie and sets the CSRF cookie),
|
|
24
|
+
* so it carries NO CORS headers. A cross-origin caller simply gets no
|
|
25
|
+
* `access-control-allow-origin`, so the browser blocks it from reading the
|
|
26
|
+
* body — intended, not an oversight.
|
|
27
|
+
*/
|
|
28
|
+
import type { Database } from "bun:sqlite";
|
|
29
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
30
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
31
|
+
import {
|
|
32
|
+
SESSION_TTL_MS,
|
|
33
|
+
buildSessionCookie,
|
|
34
|
+
findActiveSession,
|
|
35
|
+
shouldSlideSession,
|
|
36
|
+
touchSession,
|
|
37
|
+
} from "./sessions.ts";
|
|
38
|
+
import { getUserById } from "./users.ts";
|
|
39
|
+
|
|
40
|
+
export interface AccountSessionDeps {
|
|
41
|
+
db: Database;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Matches the `/account/*` 405 shape (`account-api.ts`'s `methodNotAllowed`)
|
|
45
|
+
* — same `{error, message}` body + `Allow` header, kept local to this file
|
|
46
|
+
* per the one-handler-one-file convention (`api-me.ts`, `account-token.ts`). */
|
|
47
|
+
function methodNotAllowed(allow: string): Response {
|
|
48
|
+
return new Response(JSON.stringify({ error: "method_not_allowed", message: `use ${allow}` }), {
|
|
49
|
+
status: 405,
|
|
50
|
+
headers: { "content-type": "application/json", allow },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A credentialed JSON reply that can carry MULTIPLE Set-Cookie headers (the
|
|
56
|
+
* CSRF mint and the session slide's re-issue can coincide — a plain
|
|
57
|
+
* `Record`-keyed header init can't hold two `set-cookie` values). NO CORS
|
|
58
|
+
* headers (see the module note); `no-store` so a back/forward navigation
|
|
59
|
+
* never replays a stale csrf/session state. Mirrors cloud's `sessionJson`.
|
|
60
|
+
*/
|
|
61
|
+
function sessionJson(body: unknown, cookies: readonly string[]): Response {
|
|
62
|
+
const headers = new Headers({ "content-type": "application/json", "cache-control": "no-store" });
|
|
63
|
+
for (const c of cookies) headers.append("set-cookie", c);
|
|
64
|
+
return new Response(JSON.stringify(body), { status: 200, headers });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function handleAccountSession(req: Request, deps: AccountSessionDeps): Response {
|
|
68
|
+
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
69
|
+
|
|
70
|
+
// CSRF token for BOTH branches (G2 — anonymous CSRF; the piece `api-me.ts`
|
|
71
|
+
// lacks, since it mints only when signed in). ensureCsrfToken reuses an
|
|
72
|
+
// existing CSRF cookie or mints one (setCookie only when it minted) — it
|
|
73
|
+
// works WITHOUT a session and authorizes NOTHING alone (it's one half of a
|
|
74
|
+
// double-submit that also needs the matching cookie + same-origin + a live
|
|
75
|
+
// session at the mint gate it protects). Returning it on the signed-OUT
|
|
76
|
+
// branch lets the app run the sign-in moment in-app.
|
|
77
|
+
const csrf = ensureCsrfToken(req);
|
|
78
|
+
const cookies: string[] = [];
|
|
79
|
+
if (csrf.setCookie) cookies.push(csrf.setCookie);
|
|
80
|
+
|
|
81
|
+
// findActiveSession is the single chokepoint: it refuses an expired row (a
|
|
82
|
+
// logged-out row is gone outright). Nothing below rolls or reads a
|
|
83
|
+
// dead/absent session.
|
|
84
|
+
const session = findActiveSession(deps.db, req);
|
|
85
|
+
if (!session) {
|
|
86
|
+
return sessionJson({ signed_in: false, csrf: csrf.token }, cookies);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Bounded slide (the G3 twin, and the reason NOT to call bare
|
|
90
|
+
// `touchSession` unconditionally here): this endpoint is POLLED — the
|
|
91
|
+
// app's `/check-email` screen hits it every few seconds while it waits for
|
|
92
|
+
// a magic-link-style click — so sliding only past
|
|
93
|
+
// `SESSION_SLIDE_THRESHOLD_MS` of remaining life bounds the write (and the
|
|
94
|
+
// cookie re-issue) to ~once per threshold per session, not per request.
|
|
95
|
+
if (shouldSlideSession(session)) {
|
|
96
|
+
touchSession(deps.db, session.id);
|
|
97
|
+
cookies.push(
|
|
98
|
+
buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
99
|
+
secure: isHttpsRequest(req),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const user = getUserById(deps.db, session.userId);
|
|
105
|
+
if (!user) {
|
|
106
|
+
// Session row points at a deleted user — treat as signed-out (the
|
|
107
|
+
// `api-me.ts` precedent) rather than surface a stale identity. Any slide
|
|
108
|
+
// cookie computed above still rides along; it's harmless (the row is
|
|
109
|
+
// about to be orphaned regardless) and keeping the branch simple beats
|
|
110
|
+
// special-casing a cookie rollback for a should-never-happen state.
|
|
111
|
+
return sessionJson({ signed_in: false, csrf: csrf.token }, cookies);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sessionJson(
|
|
115
|
+
{
|
|
116
|
+
signed_in: true,
|
|
117
|
+
csrf: csrf.token,
|
|
118
|
+
// users.email is null-by-history (migration v15) — username is always
|
|
119
|
+
// present, email only when the row has one (B2 signup capture).
|
|
120
|
+
username: user.username,
|
|
121
|
+
...(user.email ? { email: user.email } : {}),
|
|
122
|
+
account_created_at: user.createdAt,
|
|
123
|
+
// HUB EXTRA — additive, not part of the door-contract pin
|
|
124
|
+
// (`checkAccountSessionResponse` tolerates extra fields): lets the app
|
|
125
|
+
// hop a temp-password user straight to the change-password ceremony
|
|
126
|
+
// instead of hitting the force-change-password 403 wall on their next
|
|
127
|
+
// `/account/*` call.
|
|
128
|
+
...(user.passwordChanged === false ? { password_change_required: true } : {}),
|
|
129
|
+
},
|
|
130
|
+
cookies,
|
|
131
|
+
);
|
|
132
|
+
}
|
package/src/hub-server.ts
CHANGED
|
@@ -114,6 +114,13 @@
|
|
|
114
114
|
* (hub#473; reached after a correct
|
|
115
115
|
* password for a 2FA-enrolled user)
|
|
116
116
|
* /logout (POST) → end admin session
|
|
117
|
+
* /account/session (GET) → same-origin boot oracle {signed_in,
|
|
118
|
+
* csrf,...} (hub-parity P1); cookie-gated,
|
|
119
|
+
* NOT Bearer — mounted ABOVE the
|
|
120
|
+
* force-change-password gate below (a pure
|
|
121
|
+
* state oracle, so a pre-rotation user can
|
|
122
|
+
* still read it to drive the rotation
|
|
123
|
+
* ceremony itself)
|
|
117
124
|
* /account/change-password (GET + POST) → user self-service change-password
|
|
118
125
|
* (force-redirect target for users
|
|
119
126
|
* with password_changed=false; also
|
|
@@ -122,7 +129,8 @@
|
|
|
122
129
|
* + the per-vault proxy is hard-gated
|
|
123
130
|
* per-request on password_changed===true
|
|
124
131
|
* (forceChangePasswordGate); only this
|
|
125
|
-
* route
|
|
132
|
+
* route, /logout, and /account/session
|
|
133
|
+
* (P1, also above the gate) stay reachable
|
|
126
134
|
* pre-rotation.
|
|
127
135
|
* /account/token (POST) → cookie→account-bearer mint (App
|
|
128
136
|
* campaign Phase 2 H1): session cookie →
|
|
@@ -190,6 +198,7 @@ import {
|
|
|
190
198
|
handleAccountSetVaultCaps,
|
|
191
199
|
requireAnyScope,
|
|
192
200
|
} from "./account-api.ts";
|
|
201
|
+
import { type AccountSessionDeps, handleAccountSession } from "./account-session.ts";
|
|
193
202
|
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
194
203
|
import { handleAccountToken } from "./account-token.ts";
|
|
195
204
|
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
@@ -3746,6 +3755,20 @@ export function hubFetch(
|
|
|
3746
3755
|
return new Response("method not allowed", { status: 405 });
|
|
3747
3756
|
}
|
|
3748
3757
|
|
|
3758
|
+
// /account/session — the same-origin boot oracle (hub-parity P1). A
|
|
3759
|
+
// PURE STATE ORACLE, not a mutating surface, so it is mounted ABOVE the
|
|
3760
|
+
// force-change-password gate immediately below: a pre-rotation user
|
|
3761
|
+
// must still be able to read `{signed_in, csrf}` to drive the rotation
|
|
3762
|
+
// ceremony itself, rather than get walled off before it can even ask.
|
|
3763
|
+
// `/account/token` (below the gate) stays gated — a pre-rotation user
|
|
3764
|
+
// hits its 403 `force_change_password` there instead. See
|
|
3765
|
+
// `account-session.ts`.
|
|
3766
|
+
if (pathname === "/account/session") {
|
|
3767
|
+
if (!getDb) return dbNotConfigured();
|
|
3768
|
+
const sessionDeps: AccountSessionDeps = { db: getDb() };
|
|
3769
|
+
return handleAccountSession(req, sessionDeps);
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3749
3772
|
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
|
|
3750
3773
|
// every `/account/*` route BELOW this line is gated. `/logout` and
|
|
3751
3774
|
// `/account/change-password` (the rotation/exit path) ran above and already
|
package/src/sessions.ts
CHANGED
|
@@ -4,12 +4,21 @@
|
|
|
4
4
|
* with that cookie skip the login form and go straight to consent.
|
|
5
5
|
*
|
|
6
6
|
* Stored in `sessions` (one row per active session), so logout / forced
|
|
7
|
-
* revocation is just a delete. Sessions are
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* ~10 min while a tab
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* revocation is just a delete. Sessions are ROLLING (hub-parity P1, Q4 —
|
|
8
|
+
* adopts cloud's posture): `expires_at` starts at `created_at + SESSION_TTL_MS`
|
|
9
|
+
* (90 days), and {@link touchSession} pushes it forward on genuine activity
|
|
10
|
+
* (the admin SPA re-mints `/admin/host-admin-token` every ~10 min while a tab
|
|
11
|
+
* is open; `GET /account/session`, the app's boot/poll oracle, slides once it
|
|
12
|
+
* crosses {@link SESSION_SLIDE_THRESHOLD_MS} of its life — see
|
|
13
|
+
* `account-session.ts`). There is NO absolute ceiling: an ACTIVELY-used
|
|
14
|
+
* session rolls forward indefinitely, while an idle one (no more touches)
|
|
15
|
+
* still lapses at the 90-day mark. Was 24h sliding / 30d hard cap through
|
|
16
|
+
* 2026-07; the cap is gone. The ONLY thing that terminates a session row today
|
|
17
|
+
* is logout (which deletes it). Note what does NOT: the admin screen-lock gates
|
|
18
|
+
* token MINTS (it does not touch or expire the session row), and per-user
|
|
19
|
+
* session revocation does not exist yet (P6-era). So an actively-used session
|
|
20
|
+
* — including a stolen cookie — currently lives on until 90 idle days or an
|
|
21
|
+
* explicit logout; a per-user revoke lever is the standing gap to close.
|
|
13
22
|
*
|
|
14
23
|
* The cookie value is the session id directly. It's a 32-byte base64url
|
|
15
24
|
* random; collision is statistically impossible. No HMAC needed because the
|
|
@@ -20,16 +29,25 @@ import type { Database } from "bun:sqlite";
|
|
|
20
29
|
import { randomBytes } from "node:crypto";
|
|
21
30
|
|
|
22
31
|
export const SESSION_COOKIE_NAME = "parachute_hub_session";
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Session lifetime — 90 days (hub-parity P1, Q4: adopts cloud's rolling
|
|
34
|
+
* posture). A session (and its cookie Max-Age) lasts 90 days from its last
|
|
35
|
+
* slide; {@link findSession} still expires it hard past this, and logout
|
|
36
|
+
* deletes the row outright. Was 24h (sliding, 30d hard cap) prior to this PR.
|
|
37
|
+
*/
|
|
38
|
+
export const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
24
39
|
|
|
25
40
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
41
|
+
* Roll a session forward once it has been used past this much of its life
|
|
42
|
+
* (30 days) — so an ACTIVE session stays alive indefinitely, while an idle one
|
|
43
|
+
* still expires at the full 90-day TTL. Callers that slide on every touch
|
|
44
|
+
* (the admin SPA's host-admin-token re-mint) don't need this threshold; it's
|
|
45
|
+
* for a POLLING caller like `GET /account/session` (the app's `/check-email`
|
|
46
|
+
* screen hits it every few seconds) that must NOT rewrite the row on every
|
|
47
|
+
* request — see `shouldSlideSession` there. Mirrors cloud's
|
|
48
|
+
* `SESSION_REFRESH_THRESHOLD_MS` (`workers/identity/src/sessions.ts`).
|
|
31
49
|
*/
|
|
32
|
-
export const
|
|
50
|
+
export const SESSION_SLIDE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
51
|
|
|
34
52
|
export interface Session {
|
|
35
53
|
id: string;
|
|
@@ -91,30 +109,33 @@ export function findSession(
|
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/**
|
|
94
|
-
* Slide a session's expiry forward to `now + SESSION_TTL_MS
|
|
95
|
-
*
|
|
112
|
+
* Slide a session's expiry forward to `now + SESSION_TTL_MS`. No-op when the
|
|
113
|
+
* session doesn't exist. NO ceiling (hub-parity P1, Q4) — cloud's posture,
|
|
114
|
+
* adopted here: an ACTIVELY-used session rolls forward forever; a closed tab
|
|
115
|
+
* / idle client (no more touches) still expires `SESSION_TTL_MS` after its
|
|
116
|
+
* last activity. The old absolute cap (`SESSION_MAX_LIFETIME_MS`, 30d) is
|
|
117
|
+
* removed — the bounds on a rolling session are logout, the admin
|
|
118
|
+
* screen-lock, and (P6-era) per-user delete, not a lifetime ceiling.
|
|
96
119
|
*
|
|
97
|
-
* This is what makes sessions sliding rather than fixed-
|
|
98
|
-
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
|
|
99
|
-
* and each successful mint calls this
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
120
|
+
* This is what makes sessions sliding rather than fixed-TTL: the admin SPA
|
|
121
|
+
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
|
|
122
|
+
* open, and each successful mint calls this unconditionally; `GET
|
|
123
|
+
* /account/session` (the app's boot/poll oracle) instead calls this only
|
|
124
|
+
* past `SESSION_SLIDE_THRESHOLD_MS` of remaining life (bounded slide — see
|
|
125
|
+
* `account-session.ts`), since it's polled every few seconds and an
|
|
126
|
+
* unconditional touch there would rewrite the row on every poll.
|
|
103
127
|
*
|
|
104
128
|
* Monotonic in practice: the production wall clock only moves forward, so the
|
|
105
|
-
* slid value never undershoots a previously-written expiry
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
129
|
+
* slid value never undershoots a previously-written expiry. (The write is
|
|
130
|
+
* unconditional — it does not read the current expiry — so an injected
|
|
131
|
+
* backward `now` in tests would shorten the session: a conservative failure
|
|
132
|
+
* mode, not a security issue.) `now` is injectable for tests, matching
|
|
133
|
+
* {@link findSession}.
|
|
110
134
|
*/
|
|
111
135
|
export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
|
|
112
136
|
const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
113
137
|
if (!row) return;
|
|
114
|
-
const
|
|
115
|
-
const slidMs = nowMs + SESSION_TTL_MS;
|
|
116
|
-
const ceilingMs = new Date(row.created_at).getTime() + SESSION_MAX_LIFETIME_MS;
|
|
117
|
-
const newExpiresAt = new Date(Math.min(slidMs, ceilingMs)).toISOString();
|
|
138
|
+
const newExpiresAt = new Date(now().getTime() + SESSION_TTL_MS).toISOString();
|
|
118
139
|
db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -122,6 +143,21 @@ export function deleteSession(db: Database, id: string): void {
|
|
|
122
143
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
123
144
|
}
|
|
124
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Should this live session be rolled forward? True once it has been used past
|
|
148
|
+
* {@link SESSION_SLIDE_THRESHOLD_MS} of its life — i.e. its remaining life has
|
|
149
|
+
* dropped below `SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS`. A
|
|
150
|
+
* freshly-created/-slid session is NOT slid; one that's crossed the threshold
|
|
151
|
+
* is. Pure — the caller (`account-session.ts`'s bounded slide, the G3 twin of
|
|
152
|
+
* cloud's `shouldSlideSession`) does the write ({@link touchSession}) + cookie
|
|
153
|
+
* re-issue only when this returns true, bounding both to ~once per threshold
|
|
154
|
+
* per session even under frequent polling.
|
|
155
|
+
*/
|
|
156
|
+
export function shouldSlideSession(session: Session, now: () => Date = () => new Date()): boolean {
|
|
157
|
+
const remainingMs = new Date(session.expiresAt).getTime() - now().getTime();
|
|
158
|
+
return remainingMs < SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS;
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
/**
|
|
126
162
|
* Build a `Set-Cookie` header value for the given session id. HttpOnly +
|
|
127
163
|
* SameSite=Lax + Secure (conditional) + Path=/.
|