@openparachute/hub 0.7.4-rc.21 → 0.7.4-rc.22
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__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +26 -0
- package/src/__tests__/oauth-handlers.test.ts +69 -21
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/account-setup.ts +2 -0
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/oauth-handlers.ts +2 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
handleAdminLoginPost,
|
|
9
9
|
handleAdminLogoutPost,
|
|
10
10
|
} from "../admin-handlers.ts";
|
|
11
|
+
import { _resetUnlockStateForTest, requireUnlocked, setPin } from "../admin-lock.ts";
|
|
11
12
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
12
13
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
14
|
import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
|
|
@@ -169,6 +170,33 @@ describe("handleAdminLoginPost", () => {
|
|
|
169
170
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
test("Fix B: a fresh login with a PIN configured records an unlock (no immediate lock)", async () => {
|
|
174
|
+
// The admin-lock PIN guards the idle/grabbed tab — NOT the instant after a
|
|
175
|
+
// full login. The operator just proved their password; re-gating on the PIN
|
|
176
|
+
// the moment after is pure friction. A freshly-minted session must land
|
|
177
|
+
// within an unlock window so the SPA doesn't show the lock screen.
|
|
178
|
+
_resetUnlockStateForTest();
|
|
179
|
+
await createUser(harness.db, "admin", "pw", { passwordChanged: true });
|
|
180
|
+
await setPin(harness.db, "4827"); // lock feature ON
|
|
181
|
+
const { body, headers } = formBody({
|
|
182
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
183
|
+
username: "admin",
|
|
184
|
+
password: "pw",
|
|
185
|
+
next: "/admin/permissions",
|
|
186
|
+
});
|
|
187
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
190
|
+
body,
|
|
191
|
+
});
|
|
192
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
193
|
+
expect(res.status).toBe(302);
|
|
194
|
+
const sid = (res.headers.get("set-cookie") ?? "").match(/parachute_hub_session=([^;]+)/)?.[1];
|
|
195
|
+
expect(sid?.length).toBeTruthy();
|
|
196
|
+
// The just-minted session is unlocked — recordLoginUnlock ran during login.
|
|
197
|
+
expect(requireUnlocked(harness.db, sid ?? "").ok).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
172
200
|
test("ignores an absolute-URL next= from the form", async () => {
|
|
173
201
|
await createUser(harness.db, "admin", "pw", { passwordChanged: true });
|
|
174
202
|
const { body, headers } = formBody({
|
|
@@ -18,7 +18,13 @@ import { HOST_ADMIN_TOKEN_TTL_SECONDS, handleHostAdminToken } from "../admin-hos
|
|
|
18
18
|
import { handleApiTokens } from "../api-tokens.ts";
|
|
19
19
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
20
|
import { validateAccessToken } from "../jwt-sign.ts";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
SESSION_TTL_MS,
|
|
23
|
+
buildSessionCookie,
|
|
24
|
+
createSession,
|
|
25
|
+
deleteSession,
|
|
26
|
+
findSession,
|
|
27
|
+
} from "../sessions.ts";
|
|
22
28
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
23
29
|
import { createUser } from "../users.ts";
|
|
24
30
|
|
|
@@ -186,6 +192,57 @@ describe("handleHostAdminToken", () => {
|
|
|
186
192
|
expect(validated.payload.sub).toBe(adminId);
|
|
187
193
|
});
|
|
188
194
|
|
|
195
|
+
// Sliding session renewal (Fix A) — a successful mint pushes the session's
|
|
196
|
+
// expiry forward and re-issues the cookie, so an active operator (the SPA
|
|
197
|
+
// re-mints ~every 10 min) isn't hard-logged-out at the 24h mark. The cookie
|
|
198
|
+
// must keep the EXACT attributes creation uses — not broadened.
|
|
199
|
+
test("200 renews the session cookie (HttpOnly/Secure/SameSite/host-only) and slides expiry", async () => {
|
|
200
|
+
const user = await createUser(harness.db, "operator", "hunter2");
|
|
201
|
+
// Create the session 12h in the past so the forward slide is observable.
|
|
202
|
+
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
|
|
203
|
+
const session = createSession(harness.db, { userId: user.id, now: () => twelveHoursAgo });
|
|
204
|
+
const originalExpiry = new Date(session.expiresAt).getTime();
|
|
205
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
206
|
+
rotateSigningKey(harness.db);
|
|
207
|
+
|
|
208
|
+
const res = await handleHostAdminToken(
|
|
209
|
+
new Request(`${ISSUER}/admin/host-admin-token`, { headers: { cookie } }),
|
|
210
|
+
{ db: harness.db, issuer: ISSUER },
|
|
211
|
+
);
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
215
|
+
expect(setCookie).toContain("parachute_hub_session=");
|
|
216
|
+
expect(setCookie).toContain("HttpOnly");
|
|
217
|
+
expect(setCookie).toContain("Secure"); // ISSUER is https → Secure kept
|
|
218
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
219
|
+
expect(setCookie).toContain("Path=/");
|
|
220
|
+
expect(setCookie).not.toContain("Path=/oauth");
|
|
221
|
+
// Host-only: the renewed cookie must NOT add a Domain (no broadening).
|
|
222
|
+
expect(setCookie.toLowerCase()).not.toContain("domain=");
|
|
223
|
+
|
|
224
|
+
// The session's expiry slid forward (touchSession ran on the success path).
|
|
225
|
+
const found = findSession(harness.db, session.id);
|
|
226
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("renewed cookie omits Secure over plain HTTP (protocol-correct, not broadened)", async () => {
|
|
230
|
+
const { cookie } = await withSession();
|
|
231
|
+
rotateSigningKey(harness.db);
|
|
232
|
+
// HTTP origin → isHttpsRequest false → no Secure, so the browser keeps the
|
|
233
|
+
// cookie on http://localhost:1939 — mirrors how the session cookie is minted.
|
|
234
|
+
const res = await handleHostAdminToken(
|
|
235
|
+
new Request("http://hub.test/admin/host-admin-token", { headers: { cookie } }),
|
|
236
|
+
{ db: harness.db, issuer: ISSUER },
|
|
237
|
+
);
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
240
|
+
expect(setCookie).toContain("parachute_hub_session=");
|
|
241
|
+
expect(setCookie).not.toContain("Secure");
|
|
242
|
+
expect(setCookie).toContain("HttpOnly");
|
|
243
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
244
|
+
});
|
|
245
|
+
|
|
189
246
|
// Regression for the end-to-end bug that motivated adding `:host:auth`
|
|
190
247
|
// here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
|
|
191
248
|
// its peers) because it carried `:host:admin` only. This test mints
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
getIdleSeconds,
|
|
31
31
|
isLockConfigured,
|
|
32
32
|
isSessionUnlocked,
|
|
33
|
+
recordLoginUnlock,
|
|
33
34
|
recordUnlock,
|
|
34
35
|
refreshActivity,
|
|
35
36
|
requireUnlocked,
|
|
@@ -212,6 +213,31 @@ describe("requireUnlocked gate", () => {
|
|
|
212
213
|
});
|
|
213
214
|
});
|
|
214
215
|
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// recordLoginUnlock (Fix B) — unlock at the auth boundary so a fresh login
|
|
218
|
+
// doesn't immediately hit the PIN lock screen.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe("recordLoginUnlock", () => {
|
|
222
|
+
test("opens an unlock window when a PIN is configured", async () => {
|
|
223
|
+
await setPin(harness.db, "4827");
|
|
224
|
+
// A fresh session with a PIN set but no unlock is locked.
|
|
225
|
+
expect(requireUnlocked(harness.db, "sid-login").ok).toBe(false);
|
|
226
|
+
recordLoginUnlock(harness.db, "sid-login");
|
|
227
|
+
// After login → the freshly-authenticated session is unlocked.
|
|
228
|
+
expect(requireUnlocked(harness.db, "sid-login").ok).toBe(true);
|
|
229
|
+
expect(isSessionUnlocked("sid-login")).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("no-op when the lock feature is OFF (no PIN) — records nothing", () => {
|
|
233
|
+
// Feature off → requireUnlocked is always ok anyway; the helper must not
|
|
234
|
+
// record a spurious window (meaningless, and would grow the map).
|
|
235
|
+
recordLoginUnlock(harness.db, "sid-no-pin");
|
|
236
|
+
expect(isSessionUnlocked("sid-no-pin")).toBe(false);
|
|
237
|
+
expect(requireUnlocked(harness.db, "sid-no-pin").ok).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
215
241
|
// ---------------------------------------------------------------------------
|
|
216
242
|
// The cascade: a locked session refuses ALL four admin-token mints.
|
|
217
243
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +119,9 @@ describe("authorizationServerMetadata", () => {
|
|
|
119
119
|
expect(scopesSupported).toContain("vault:read");
|
|
120
120
|
expect(scopesSupported).toContain("vault:admin");
|
|
121
121
|
expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
|
|
122
|
-
|
|
122
|
+
// hub:admin + scribe:admin are operator-only (non-requestable) — never advertised (2026-06-30)
|
|
123
|
+
expect(scopesSupported).not.toContain("hub:admin");
|
|
124
|
+
expect(scopesSupported).not.toContain("scribe:admin");
|
|
123
125
|
// agent isn't in the fixture manifest → its scopes aren't advertised
|
|
124
126
|
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
125
127
|
expect(scopesSupported).not.toContain("agent:send");
|
|
@@ -169,9 +171,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
169
171
|
// First-party still advertised — no regression
|
|
170
172
|
expect(scopesSupported).toContain("vault:read");
|
|
171
173
|
expect(scopesSupported).toContain("vault:admin");
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
+
// NON_REQUESTABLE filter applies even when the scope is declared:
|
|
175
|
+
// host-* AND the service-admin scopes (hub:admin/scribe:admin) are filtered.
|
|
174
176
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
177
|
+
expect(scopesSupported).not.toContain("hub:admin");
|
|
175
178
|
});
|
|
176
179
|
|
|
177
180
|
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
@@ -209,7 +212,8 @@ describe("authorizationServerMetadata", () => {
|
|
|
209
212
|
// core scopes survive
|
|
210
213
|
expect(scopes).toContain("vault:read");
|
|
211
214
|
expect(scopes).toContain("vault:admin");
|
|
212
|
-
|
|
215
|
+
// hub:admin is operator-only (non-requestable) — never advertised
|
|
216
|
+
expect(scopes).not.toContain("hub:admin");
|
|
213
217
|
// uninstalled optional-module scopes are dropped
|
|
214
218
|
expect(scopes).not.toContain("scribe:transcribe");
|
|
215
219
|
expect(scopes).not.toContain("scribe:admin");
|
|
@@ -241,6 +245,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
241
245
|
});
|
|
242
246
|
const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
243
247
|
expect(scopes2).toContain("scribe:transcribe");
|
|
248
|
+
// scribe:admin stays dropped EVEN WITH scribe installed — proving it's the
|
|
249
|
+
// requestability gate (non-requestable, 2026-06-30) doing the work here, not
|
|
250
|
+
// the optional-module-not-installed gate that drops scribe:transcribe above.
|
|
251
|
+
expect(scopes2).not.toContain("scribe:admin");
|
|
244
252
|
expect(scopes2).not.toContain("agent:send"); // agent still not installed
|
|
245
253
|
});
|
|
246
254
|
});
|
|
@@ -7366,12 +7374,12 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7366
7374
|
}
|
|
7367
7375
|
});
|
|
7368
7376
|
|
|
7369
|
-
test("authorize: same_hub=true + admin
|
|
7370
|
-
// hub:admin is requestable via
|
|
7371
|
-
//
|
|
7372
|
-
//
|
|
7373
|
-
//
|
|
7374
|
-
// hub-
|
|
7377
|
+
test("authorize: same_hub=true + hub:admin → invalid_scope (operator-only, even for same-hub) (2026-06-30)", async () => {
|
|
7378
|
+
// hub:admin is now non-requestable via /oauth/authorize (a vault MCP
|
|
7379
|
+
// connector pointed at the hub-level AS would otherwise be offered
|
|
7380
|
+
// hub-wide admin). Even a trusted same-hub client with an owner session
|
|
7381
|
+
// is rejected with invalid_scope — fails closed before consent. The
|
|
7382
|
+
// legit hub-admin paths are operator-bearer/session, not authorize-flow.
|
|
7375
7383
|
const { db, cleanup } = await makeDb();
|
|
7376
7384
|
try {
|
|
7377
7385
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -7390,24 +7398,24 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7390
7398
|
scope: "hub:admin",
|
|
7391
7399
|
code_challenge: challenge,
|
|
7392
7400
|
code_challenge_method: "S256",
|
|
7401
|
+
state: "abc",
|
|
7393
7402
|
}),
|
|
7394
7403
|
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7395
7404
|
);
|
|
7396
7405
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
expect(
|
|
7400
|
-
|
|
7401
|
-
expect(html).toContain("hub:admin");
|
|
7406
|
+
expect(res.status).toBe(302);
|
|
7407
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7408
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7409
|
+
expect(loc.searchParams.get("error_description")).toContain("hub:admin");
|
|
7402
7410
|
} finally {
|
|
7403
7411
|
cleanup();
|
|
7404
7412
|
}
|
|
7405
7413
|
});
|
|
7406
7414
|
|
|
7407
|
-
test("authorize: same_hub=true + mixed
|
|
7408
|
-
//
|
|
7409
|
-
//
|
|
7410
|
-
//
|
|
7415
|
+
test("authorize: same_hub=true + mixed vault + hub:admin → invalid_scope (a non-requestable scope rejects the whole request) (2026-06-30)", async () => {
|
|
7416
|
+
// A request mixing a requestable scope with hub:admin must NOT slip the
|
|
7417
|
+
// non-requestable scope through on the strength of the others — the whole
|
|
7418
|
+
// request is rejected with invalid_scope (same as parachute:host:admin).
|
|
7411
7419
|
const { db, cleanup } = await makeDb();
|
|
7412
7420
|
try {
|
|
7413
7421
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -7426,12 +7434,52 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7426
7434
|
scope: "vault:default:read hub:admin",
|
|
7427
7435
|
code_challenge: challenge,
|
|
7428
7436
|
code_challenge_method: "S256",
|
|
7437
|
+
state: "abc",
|
|
7429
7438
|
}),
|
|
7430
7439
|
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7431
7440
|
);
|
|
7432
7441
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7433
|
-
expect(res.status).toBe(
|
|
7434
|
-
|
|
7442
|
+
expect(res.status).toBe(302);
|
|
7443
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7444
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7445
|
+
expect(loc.searchParams.get("error_description")).toContain("hub:admin");
|
|
7446
|
+
} finally {
|
|
7447
|
+
cleanup();
|
|
7448
|
+
}
|
|
7449
|
+
});
|
|
7450
|
+
|
|
7451
|
+
test("authorize: explicit scribe:admin → invalid_scope (service-admin, operator-only) (2026-06-30)", async () => {
|
|
7452
|
+
// Symmetry with hub:admin: the other service-admin scope is non-requestable
|
|
7453
|
+
// too. Scribe's admin UI gets scribe:admin via the cookie-gated
|
|
7454
|
+
// /admin/module-token/scribe path, never /oauth/authorize — so rejecting it
|
|
7455
|
+
// here breaks no first-party path.
|
|
7456
|
+
const { db, cleanup } = await makeDb();
|
|
7457
|
+
try {
|
|
7458
|
+
const user = await createUser(db, "owner", "pw");
|
|
7459
|
+
const session = createSession(db, { userId: user.id });
|
|
7460
|
+
const reg = registerClient(db, {
|
|
7461
|
+
redirectUris: ["https://app.example/cb"],
|
|
7462
|
+
status: "approved",
|
|
7463
|
+
sameHub: true,
|
|
7464
|
+
});
|
|
7465
|
+
const { challenge } = makePkce();
|
|
7466
|
+
const req = new Request(
|
|
7467
|
+
authorizeUrl({
|
|
7468
|
+
client_id: reg.client.clientId,
|
|
7469
|
+
redirect_uri: "https://app.example/cb",
|
|
7470
|
+
response_type: "code",
|
|
7471
|
+
scope: "scribe:admin",
|
|
7472
|
+
code_challenge: challenge,
|
|
7473
|
+
code_challenge_method: "S256",
|
|
7474
|
+
state: "abc",
|
|
7475
|
+
}),
|
|
7476
|
+
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7477
|
+
);
|
|
7478
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7479
|
+
expect(res.status).toBe(302);
|
|
7480
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7481
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7482
|
+
expect(loc.searchParams.get("error_description")).toContain("scribe:admin");
|
|
7435
7483
|
} finally {
|
|
7436
7484
|
cleanup();
|
|
7437
7485
|
}
|
|
@@ -129,11 +129,15 @@ describe("NON_REQUESTABLE_SCOPES (#96)", () => {
|
|
|
129
129
|
expect(NON_REQUESTABLE_SCOPES.has("parachute:host:admin")).toBe(true);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
test("
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
132
|
+
test("contains the service-admin scopes hub:admin and scribe:admin (2026-06-30 over-permissioning fix)", () => {
|
|
133
|
+
// A vault MCP connector (e.g. Claude) is pointed at the hub-level AS by the
|
|
134
|
+
// vault's protected-resource metadata, so hub:admin/scribe:admin would be
|
|
135
|
+
// advertised on its consent screen + minted if approved — wildly
|
|
136
|
+
// over-privileged for a vault reader. Every legit use is operator-bearer /
|
|
137
|
+
// session (operator token, DCR self-registration, admin SPA), never
|
|
138
|
+
// /oauth/authorize — so these fail closed without breaking operator paths.
|
|
139
|
+
expect(NON_REQUESTABLE_SCOPES.has("hub:admin")).toBe(true);
|
|
140
|
+
expect(NON_REQUESTABLE_SCOPES.has("scribe:admin")).toBe(true);
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
test("every non-requestable scope is a known first-party scope", () => {
|
|
@@ -148,11 +152,16 @@ describe("isRequestableScope", () => {
|
|
|
148
152
|
expect(isRequestableScope("parachute:host:admin")).toBe(false);
|
|
149
153
|
});
|
|
150
154
|
|
|
151
|
-
test("
|
|
152
|
-
expect(isRequestableScope("hub:admin")).toBe(
|
|
155
|
+
test("false for service-admin scopes hub:admin and scribe:admin (operator-only, 2026-06-30)", () => {
|
|
156
|
+
expect(isRequestableScope("hub:admin")).toBe(false);
|
|
157
|
+
expect(isRequestableScope("scribe:admin")).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("true for non-admin first-party scopes", () => {
|
|
153
161
|
expect(isRequestableScope("vault:read")).toBe(true);
|
|
154
162
|
expect(isRequestableScope("vault:admin")).toBe(true);
|
|
155
163
|
expect(isRequestableScope("agent:send")).toBe(true);
|
|
164
|
+
expect(isRequestableScope("scribe:transcribe")).toBe(true);
|
|
156
165
|
});
|
|
157
166
|
|
|
158
167
|
test("true for unknown scopes (third-party module scopes pass through)", () => {
|
|
@@ -194,8 +203,10 @@ describe("isRequestableScope", () => {
|
|
|
194
203
|
expect(isNonRequestableScope("parachute:Host:Install")).toBe(true);
|
|
195
204
|
// Canonical lowercase still works unchanged.
|
|
196
205
|
expect(isNonRequestableScope("parachute:host:auth")).toBe(true);
|
|
197
|
-
//
|
|
198
|
-
expect(isNonRequestableScope("HUB:ADMIN")).toBe(
|
|
206
|
+
// Service-admin scopes are non-requestable too, case-insensitively (2026-06-30).
|
|
207
|
+
expect(isNonRequestableScope("HUB:ADMIN")).toBe(true);
|
|
208
|
+
// A genuinely requestable scope (even uppercased) stays requestable.
|
|
209
|
+
expect(isNonRequestableScope("VAULT:READ")).toBe(false);
|
|
199
210
|
});
|
|
200
211
|
});
|
|
201
212
|
|
|
@@ -5,12 +5,14 @@ import { join } from "node:path";
|
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import {
|
|
7
7
|
SESSION_COOKIE_NAME,
|
|
8
|
+
SESSION_MAX_LIFETIME_MS,
|
|
8
9
|
buildSessionClearCookie,
|
|
9
10
|
buildSessionCookie,
|
|
10
11
|
createSession,
|
|
11
12
|
deleteSession,
|
|
12
13
|
findSession,
|
|
13
14
|
parseSessionCookie,
|
|
15
|
+
touchSession,
|
|
14
16
|
} from "../sessions.ts";
|
|
15
17
|
import { createUser } from "../users.ts";
|
|
16
18
|
|
|
@@ -67,6 +69,84 @@ describe("createSession + findSession", () => {
|
|
|
67
69
|
});
|
|
68
70
|
});
|
|
69
71
|
|
|
72
|
+
describe("touchSession (sliding renewal)", () => {
|
|
73
|
+
const HOUR = 3600 * 1000;
|
|
74
|
+
const DAY = 24 * HOUR;
|
|
75
|
+
|
|
76
|
+
test("slides expires_at forward to now + TTL", async () => {
|
|
77
|
+
const { db, userId, cleanup } = await makeDb();
|
|
78
|
+
try {
|
|
79
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
80
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
81
|
+
// Original expiry: t0 + 24h.
|
|
82
|
+
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + DAY);
|
|
83
|
+
// Touch 1h later → expiry becomes (t0 + 1h) + 24h.
|
|
84
|
+
const t1 = new Date(t0.getTime() + HOUR);
|
|
85
|
+
touchSession(db, s.id, () => t1);
|
|
86
|
+
const found = findSession(db, s.id, () => t1);
|
|
87
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + DAY);
|
|
88
|
+
} finally {
|
|
89
|
+
cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a touched session outlives the ORIGINAL 24h expiry", async () => {
|
|
94
|
+
const { db, userId, cleanup } = await makeDb();
|
|
95
|
+
try {
|
|
96
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
97
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
98
|
+
// Activity at +12h slides expiry to +36h.
|
|
99
|
+
touchSession(db, s.id, () => new Date(t0.getTime() + 12 * HOUR));
|
|
100
|
+
// At +30h — PAST the original +24h — the session is still alive.
|
|
101
|
+
const at30h = new Date(t0.getTime() + 30 * HOUR);
|
|
102
|
+
expect(findSession(db, s.id, () => at30h)?.id).toBe(s.id);
|
|
103
|
+
} finally {
|
|
104
|
+
cleanup();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an UNtouched session still expires at the original 24h", async () => {
|
|
109
|
+
const { db, userId, cleanup } = await makeDb();
|
|
110
|
+
try {
|
|
111
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
112
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
113
|
+
// No touch — at +25h it's gone (today's absolute-TTL behavior preserved
|
|
114
|
+
// for idle / closed tabs that stop re-minting).
|
|
115
|
+
const at25h = new Date(t0.getTime() + 25 * HOUR);
|
|
116
|
+
expect(findSession(db, s.id, () => at25h)).toBeNull();
|
|
117
|
+
} finally {
|
|
118
|
+
cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("caps at created_at + SESSION_MAX_LIFETIME_MS (sliding can't run forever)", async () => {
|
|
123
|
+
const { db, userId, cleanup } = await makeDb();
|
|
124
|
+
try {
|
|
125
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
126
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
127
|
+
const ceiling = t0.getTime() + SESSION_MAX_LIFETIME_MS;
|
|
128
|
+
// A touch near the ceiling would slide to now + 24h, but the cap pins it.
|
|
129
|
+
const nearCeiling = new Date(ceiling - HOUR); // raw slide would be ceiling + 23h
|
|
130
|
+
touchSession(db, s.id, () => nearCeiling);
|
|
131
|
+
const found = findSession(db, s.id, () => nearCeiling);
|
|
132
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(ceiling);
|
|
133
|
+
// Past the ceiling the session is dead even though it was just "active".
|
|
134
|
+
expect(findSession(db, s.id, () => new Date(ceiling + 1000))).toBeNull();
|
|
135
|
+
} finally {
|
|
136
|
+
cleanup();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("no-op on an unknown session id (does not throw)", async () => {
|
|
141
|
+
const { db, cleanup } = await makeDb();
|
|
142
|
+
try {
|
|
143
|
+
expect(() => touchSession(db, "no-such-session")).not.toThrow();
|
|
144
|
+
} finally {
|
|
145
|
+
cleanup();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
70
150
|
describe("deleteSession", () => {
|
|
71
151
|
test("removes the session row", async () => {
|
|
72
152
|
const { db, userId, cleanup } = await makeDb();
|
package/src/account-setup.ts
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* scope-guard.
|
|
63
63
|
*/
|
|
64
64
|
import type { Database } from "bun:sqlite";
|
|
65
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
65
66
|
import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
|
|
66
67
|
import { type RunResult, provisionVault } from "./admin-vaults.ts";
|
|
67
68
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
@@ -528,6 +529,7 @@ export async function handleAccountSetupPost(
|
|
|
528
529
|
|
|
529
530
|
// (6) Sign the invitee in + land them on /account/.
|
|
530
531
|
const session = createSession(deps.db, { userId });
|
|
532
|
+
recordLoginUnlock(deps.db, session.id);
|
|
531
533
|
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
532
534
|
secure: isHttpsRequest(req),
|
|
533
535
|
});
|
package/src/admin-handlers.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
|
|
12
12
|
*/
|
|
13
13
|
import type { Database } from "bun:sqlite";
|
|
14
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
14
15
|
import { renderAdminError, renderAdminLogin, renderTotpChallenge } from "./admin-login-ui.ts";
|
|
15
16
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
16
17
|
import {
|
|
@@ -283,6 +284,7 @@ function mintSessionAndRedirect(
|
|
|
283
284
|
extraCookies: string[] = [],
|
|
284
285
|
): Response {
|
|
285
286
|
const session = createSession(db, { userId: user.id });
|
|
287
|
+
recordLoginUnlock(db, session.id);
|
|
286
288
|
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
287
289
|
secure: isHttpsRequest(req),
|
|
288
290
|
});
|
|
@@ -49,7 +49,14 @@
|
|
|
49
49
|
import type { Database } from "bun:sqlite";
|
|
50
50
|
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
51
51
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
53
|
+
import {
|
|
54
|
+
SESSION_TTL_MS,
|
|
55
|
+
buildSessionCookie,
|
|
56
|
+
findSession,
|
|
57
|
+
parseSessionCookie,
|
|
58
|
+
touchSession,
|
|
59
|
+
} from "./sessions.ts";
|
|
53
60
|
import { isFirstAdmin } from "./users.ts";
|
|
54
61
|
|
|
55
62
|
/** Short TTL — page-snapshot threats can't carry the token forever. */
|
|
@@ -115,6 +122,21 @@ export async function handleHostAdminToken(
|
|
|
115
122
|
// sentinel matching admin OAuth tokens.
|
|
116
123
|
vaultScope: [],
|
|
117
124
|
});
|
|
125
|
+
// Sliding session renewal (THE frequent-re-login fix). The SPA re-mints here
|
|
126
|
+
// roughly every ~10 min while a tab is open; each successful mint pushes the
|
|
127
|
+
// session's `expires_at` forward, so an active operator isn't hard-logged-out
|
|
128
|
+
// at the 24h mark. A closed tab stops minting and still expires; the absolute
|
|
129
|
+
// ceiling in `touchSession` bounds a left-open-but-idle tab. The renewed
|
|
130
|
+
// Set-Cookie keeps the EXACT attributes session creation uses — HttpOnly,
|
|
131
|
+
// Secure-when-https, SameSite=Lax, Path=/, host-only (no Domain) — so the
|
|
132
|
+
// cookie's Max-Age tracks the extended expiry without broadening the cookie.
|
|
133
|
+
// This does NOT touch the admin-lock idle window (sliding there is
|
|
134
|
+
// heartbeat-only, by design — see admin-lock.ts); the two windows are
|
|
135
|
+
// independent.
|
|
136
|
+
touchSession(deps.db, sid);
|
|
137
|
+
const sessionCookie = buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000), {
|
|
138
|
+
secure: isHttpsRequest(req),
|
|
139
|
+
});
|
|
118
140
|
return new Response(
|
|
119
141
|
JSON.stringify({
|
|
120
142
|
token: minted.token,
|
|
@@ -128,6 +150,7 @@ export async function handleHostAdminToken(
|
|
|
128
150
|
// No browser cache — token rotates per-fetch, and a stale 200 from a
|
|
129
151
|
// back/forward navigation could hand the SPA a long-expired JWT.
|
|
130
152
|
"cache-control": "no-store",
|
|
153
|
+
"set-cookie": sessionCookie,
|
|
131
154
|
},
|
|
132
155
|
},
|
|
133
156
|
);
|
package/src/admin-lock.ts
CHANGED
|
@@ -174,6 +174,22 @@ export function recordUnlock(
|
|
|
174
174
|
unlockedUntil.set(sessionId, now + idleSeconds * 1000);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Open an unlock window for a session that JUST completed a full auth
|
|
179
|
+
* (password + any 2FA). The PIN's threat model is the idle/grabbed tab, NOT a
|
|
180
|
+
* third gate the instant after the auth boundary — re-prompting for the PIN
|
|
181
|
+
* the moment after a successful login is pure friction with no security gain
|
|
182
|
+
* (the user just proved a stronger factor). No-op when the lock feature is off.
|
|
183
|
+
*
|
|
184
|
+
* Called from every session-mint point (password login, OAuth login,
|
|
185
|
+
* account-setup, setup-wizard) so a freshly-authenticated session always lands
|
|
186
|
+
* working, never on the lock screen. Idle re-entry still re-locks as before.
|
|
187
|
+
*/
|
|
188
|
+
export function recordLoginUnlock(db: Database, sessionId: string, now: number = Date.now()): void {
|
|
189
|
+
if (!isLockConfigured(db)) return;
|
|
190
|
+
recordUnlock(sessionId, getIdleSeconds(db), now);
|
|
191
|
+
}
|
|
192
|
+
|
|
177
193
|
/**
|
|
178
194
|
* Slide the unlock window forward by `idleSeconds` IF the session is currently
|
|
179
195
|
* unlocked. Called on admin activity (heartbeat + every successful mint) so an
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { Database } from "bun:sqlite";
|
|
25
25
|
import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
26
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
26
27
|
import { renderTotpChallenge } from "./admin-login-ui.ts";
|
|
27
28
|
import {
|
|
28
29
|
AuthCodeExpiredError,
|
|
@@ -1501,6 +1502,7 @@ async function handleLoginSubmit(
|
|
|
1501
1502
|
}
|
|
1502
1503
|
|
|
1503
1504
|
const session = createSession(db, { userId: user.id });
|
|
1505
|
+
recordLoginUnlock(db, session.id);
|
|
1504
1506
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1505
1507
|
secure: isHttpsRequest(req),
|
|
1506
1508
|
});
|
|
@@ -127,13 +127,23 @@ export const FIRST_PARTY_SCOPES = Object.keys(SCOPE_EXPLANATIONS).sort();
|
|
|
127
127
|
* RFC 8414 §2 says `scopes_supported` is the list a client *can*
|
|
128
128
|
* request, so omitting these is the spec-compliant call.
|
|
129
129
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
130
|
+
* Service-admin scopes (`hub:admin`, `scribe:admin`) are on this list as of
|
|
131
|
+
* 2026-06-30. They read as "delegable to a tooling app," but in practice a
|
|
132
|
+
* vault MCP connector (e.g. Claude) is pointed at the hub-level authorization
|
|
133
|
+
* server by the vault's protected-resource metadata, so the full hub catalog —
|
|
134
|
+
* including `hub:admin` (manage signing keys, registered clients, user
|
|
135
|
+
* accounts) — gets advertised on its consent screen and, if approved, minted
|
|
136
|
+
* into its token. That's wildly over-privileged for a vault reader (cf.
|
|
137
|
+
* hub#671, where the agent-grants client had to hardcode least-privilege to
|
|
138
|
+
* dodge exactly this). The scope-narrowing that should strip it only fires
|
|
139
|
+
* when the client echoes a resolvable RFC 8707 vault `resource`, which MCP
|
|
140
|
+
* clients often don't. Every legitimate hub-admin / scribe-admin use is
|
|
141
|
+
* operator-bearer or session based (operator token, DCR self-registration via
|
|
142
|
+
* `requireScope`, the admin SPA host-admin token) — none route through
|
|
143
|
+
* `/oauth/authorize` — so making these non-requestable fails closed against
|
|
144
|
+
* third-party requests without breaking any first-party operator path.
|
|
145
|
+
* `parachute:host:*` remains for the original reason: it provisions/destroys
|
|
146
|
+
* vaults (cross-vault sovereignty the operator alone owns).
|
|
137
147
|
*/
|
|
138
148
|
export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
|
|
139
149
|
"parachute:host:admin",
|
|
@@ -142,6 +152,9 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
|
|
|
142
152
|
"parachute:host:expose",
|
|
143
153
|
"parachute:host:auth",
|
|
144
154
|
"parachute:host:vault",
|
|
155
|
+
// Service-admin scopes: operator-only, never requestable via /oauth/authorize.
|
|
156
|
+
"hub:admin",
|
|
157
|
+
"scribe:admin",
|
|
145
158
|
]);
|
|
146
159
|
|
|
147
160
|
/**
|
|
@@ -269,8 +282,9 @@ export function isNonRequestableScope(scope: string): boolean {
|
|
|
269
282
|
// Per-vault `vault:<name>:admin` is NO LONGER globally non-requestable
|
|
270
283
|
// (single-consent change, 2026-05-29). It flows through the public OAuth
|
|
271
284
|
// consent path and through `canGrant` rule 1, capped to the consenting
|
|
272
|
-
// user's held authority at the `issueAuthCodeRedirect` choke-point.
|
|
273
|
-
//
|
|
285
|
+
// user's held authority at the `issueAuthCodeRedirect` choke-point. The
|
|
286
|
+
// host-level operator scopes AND the service-admin scopes (hub:admin,
|
|
287
|
+
// scribe:admin) stay non-requestable here (see NON_REQUESTABLE_SCOPES).
|
|
274
288
|
//
|
|
275
289
|
// Item C — case-insensitive guard. The membership check is exact-string,
|
|
276
290
|
// but Parachute scope tokens are canonically lowercase. A casing variant
|
package/src/sessions.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
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.
|
|
8
|
-
*
|
|
7
|
+
* revocation is just a delete. Sessions are SLIDING: `expires_at` starts at
|
|
8
|
+
* `created_at + SESSION_TTL_MS`, and {@link touchSession} pushes it forward on
|
|
9
|
+
* genuine activity (the admin SPA re-mints `/admin/host-admin-token` every
|
|
10
|
+
* ~10 min while a tab is open). An idle session — no more mints — still
|
|
11
|
+
* expires at the original 24h mark, and {@link SESSION_MAX_LIFETIME_MS} caps
|
|
12
|
+
* total life so sliding can't keep a left-open tab alive forever.
|
|
9
13
|
*
|
|
10
14
|
* The cookie value is the session id directly. It's a 32-byte base64url
|
|
11
15
|
* random; collision is statistically impossible. No HMAC needed because the
|
|
@@ -18,6 +22,15 @@ import { randomBytes } from "node:crypto";
|
|
|
18
22
|
export const SESSION_COOKIE_NAME = "parachute_hub_session";
|
|
19
23
|
export const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Absolute ceiling on a session's total lifetime, independent of sliding
|
|
27
|
+
* renewal. Sliding ({@link touchSession}) keeps an active console signed in,
|
|
28
|
+
* but a left-open-but-idle tab whose background polls keep re-minting must
|
|
29
|
+
* still be force-logged-out eventually — this caps life at
|
|
30
|
+
* `created_at + SESSION_MAX_LIFETIME_MS` so renewal can't extend forever.
|
|
31
|
+
*/
|
|
32
|
+
export const SESSION_MAX_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
21
34
|
export interface Session {
|
|
22
35
|
id: string;
|
|
23
36
|
userId: string;
|
|
@@ -77,6 +90,34 @@ export function findSession(
|
|
|
77
90
|
return session;
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Slide a session's expiry forward to `now + SESSION_TTL_MS`, capped at
|
|
95
|
+
* `created_at + SESSION_MAX_LIFETIME_MS`. No-op when the session doesn't exist.
|
|
96
|
+
*
|
|
97
|
+
* This is what makes sessions sliding rather than fixed-24h: the admin SPA
|
|
98
|
+
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is open,
|
|
99
|
+
* and each successful mint calls this — so an actively-used console isn't
|
|
100
|
+
* hard-logged-out at the 24h mark, while a closed tab (no more mints) still
|
|
101
|
+
* expires 24h after its last activity. The ceiling bounds a left-open-but-idle
|
|
102
|
+
* tab (background polls keep re-minting) so sliding can't run forever.
|
|
103
|
+
*
|
|
104
|
+
* Monotonic in practice: the production wall clock only moves forward, so the
|
|
105
|
+
* slid value never undershoots a previously-written expiry; once it reaches the
|
|
106
|
+
* ceiling it stays pinned there. (The write is unconditional — it does not read
|
|
107
|
+
* the current expiry — so an injected backward `now` in tests would shorten the
|
|
108
|
+
* session: a conservative failure mode, not a security issue.) `now` is
|
|
109
|
+
* injectable for tests, matching {@link findSession}.
|
|
110
|
+
*/
|
|
111
|
+
export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
|
|
112
|
+
const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
113
|
+
if (!row) return;
|
|
114
|
+
const nowMs = now().getTime();
|
|
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();
|
|
118
|
+
db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
|
|
119
|
+
}
|
|
120
|
+
|
|
80
121
|
export function deleteSession(db: Database, id: string): void {
|
|
81
122
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
82
123
|
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import type { Database } from "bun:sqlite";
|
|
41
41
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
42
42
|
import { join } from "node:path";
|
|
43
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
43
44
|
import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
|
|
44
45
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
45
46
|
import {
|
|
@@ -1978,6 +1979,7 @@ export async function handleSetupAccountPost(
|
|
|
1978
1979
|
// account creation the operator just completed.
|
|
1979
1980
|
await ensureOperatorTokenForFirstAdmin(deps, user.id);
|
|
1980
1981
|
const session = createSession(deps.db, { userId: user.id });
|
|
1982
|
+
recordLoginUnlock(deps.db, session.id);
|
|
1981
1983
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1982
1984
|
secure: isHttpsRequest(req),
|
|
1983
1985
|
});
|