@blamejs/core 0.7.18 → 0.7.20
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/CHANGELOG.md +427 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +350 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +399 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/fetch-metadata.js +129 -0
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +85 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +121 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +7 -5
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/session.js
CHANGED
|
@@ -1,478 +1,535 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Session store — DB-backed, vault-sealed, sid-hashed-at-rest.
|
|
4
|
-
*
|
|
5
|
-
* Single-node: stored in the framework's main DB under `_blamejs_sessions`
|
|
6
|
-
* (baked into db.js's FRAMEWORK_SCHEMA — apps cannot opt out).
|
|
7
|
-
* Cluster mode: stored in external-db under the same name (via
|
|
8
|
-
* frameworkSchema.ensureSchema). cluster-storage.execute routes the SQL
|
|
9
|
-
* to the right place based on cluster.isClusterMode(); session.js itself
|
|
10
|
-
* doesn't branch on mode.
|
|
11
|
-
*
|
|
12
|
-
* Token discipline:
|
|
13
|
-
* - The session id (sid) is a 32-byte random value returned to the caller
|
|
14
|
-
* once. The caller stores it in a cookie / authorization header / etc.
|
|
15
|
-
* - The DB primary key is sha3('bj-session:' || sid) — the sid itself
|
|
16
|
-
* never lands in the database. DB exfiltration alone cannot impersonate
|
|
17
|
-
* a session: the attacker would also need the original sid (which only
|
|
18
|
-
* the user has).
|
|
19
|
-
* - data is vault-sealed JSON; userId is sealed; userIdHash indexes for
|
|
20
|
-
* destroyAllForUser without unsealing every row.
|
|
21
|
-
*
|
|
22
|
-
* Public API:
|
|
23
|
-
*
|
|
24
|
-
* session.create({ userId, data?, ttlMs? }) → { token, expiresAt }
|
|
25
|
-
* session.verify(token) → { userId, data, createdAt, expiresAt, lastActivity } or null
|
|
26
|
-
* session.destroy(token) → boolean
|
|
27
|
-
* session.destroyAllForUser(userId) → number deleted
|
|
28
|
-
* session.touch(token, { extendBy? }) → boolean (updates lastActivity, optionally extends expiresAt)
|
|
29
|
-
* session.purgeExpired() → number deleted
|
|
30
|
-
* session.count() → number of active (non-expired) sessions
|
|
31
|
-
*
|
|
32
|
-
* Cluster posture per blamejs-cluster-spec.md:
|
|
33
|
-
* create / destroy / destroyAllForUser / touch / purgeExpired
|
|
34
|
-
* — leader-only (cluster.requireLeader gate at call entry)
|
|
35
|
-
* verify / count
|
|
36
|
-
* — anywhere (any node can read shared session state)
|
|
37
|
-
*/
|
|
38
|
-
var audit = require("./audit");
|
|
39
|
-
var canonicalJson = require("./canonical-json");
|
|
40
|
-
var cluster = require("./cluster");
|
|
41
|
-
var clusterStorage = require("./cluster-storage");
|
|
42
|
-
var C = require("./constants");
|
|
43
|
-
var { generateToken, sha3Hash } = require("./crypto");
|
|
44
|
-
var cryptoField = require("./crypto-field");
|
|
45
|
-
var requestHelpers = require("./request-helpers");
|
|
46
|
-
var safeJson = require("./safe-json");
|
|
47
|
-
var { SessionError } = require("./framework-error");
|
|
48
|
-
|
|
49
|
-
var _err = SessionError.factory;
|
|
50
|
-
|
|
51
|
-
var DEFAULT_TTL_MS = C.TIME.days(7);
|
|
52
|
-
// Sanity bound: any session that lives longer than this is almost
|
|
53
|
-
// certainly a misconfigured Infinity / oversized literal. Keeps
|
|
54
|
-
// expiresAt away from epoch overflow + database-int boundary issues.
|
|
55
|
-
var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
var
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
var
|
|
179
|
-
var
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
var
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
var
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Session store — DB-backed, vault-sealed, sid-hashed-at-rest.
|
|
4
|
+
*
|
|
5
|
+
* Single-node: stored in the framework's main DB under `_blamejs_sessions`
|
|
6
|
+
* (baked into db.js's FRAMEWORK_SCHEMA — apps cannot opt out).
|
|
7
|
+
* Cluster mode: stored in external-db under the same name (via
|
|
8
|
+
* frameworkSchema.ensureSchema). cluster-storage.execute routes the SQL
|
|
9
|
+
* to the right place based on cluster.isClusterMode(); session.js itself
|
|
10
|
+
* doesn't branch on mode.
|
|
11
|
+
*
|
|
12
|
+
* Token discipline:
|
|
13
|
+
* - The session id (sid) is a 32-byte random value returned to the caller
|
|
14
|
+
* once. The caller stores it in a cookie / authorization header / etc.
|
|
15
|
+
* - The DB primary key is sha3('bj-session:' || sid) — the sid itself
|
|
16
|
+
* never lands in the database. DB exfiltration alone cannot impersonate
|
|
17
|
+
* a session: the attacker would also need the original sid (which only
|
|
18
|
+
* the user has).
|
|
19
|
+
* - data is vault-sealed JSON; userId is sealed; userIdHash indexes for
|
|
20
|
+
* destroyAllForUser without unsealing every row.
|
|
21
|
+
*
|
|
22
|
+
* Public API:
|
|
23
|
+
*
|
|
24
|
+
* session.create({ userId, data?, ttlMs? }) → { token, expiresAt }
|
|
25
|
+
* session.verify(token) → { userId, data, createdAt, expiresAt, lastActivity } or null
|
|
26
|
+
* session.destroy(token) → boolean
|
|
27
|
+
* session.destroyAllForUser(userId) → number deleted
|
|
28
|
+
* session.touch(token, { extendBy? }) → boolean (updates lastActivity, optionally extends expiresAt)
|
|
29
|
+
* session.purgeExpired() → number deleted
|
|
30
|
+
* session.count() → number of active (non-expired) sessions
|
|
31
|
+
*
|
|
32
|
+
* Cluster posture per blamejs-cluster-spec.md:
|
|
33
|
+
* create / destroy / destroyAllForUser / touch / purgeExpired
|
|
34
|
+
* — leader-only (cluster.requireLeader gate at call entry)
|
|
35
|
+
* verify / count
|
|
36
|
+
* — anywhere (any node can read shared session state)
|
|
37
|
+
*/
|
|
38
|
+
var audit = require("./audit");
|
|
39
|
+
var canonicalJson = require("./canonical-json");
|
|
40
|
+
var cluster = require("./cluster");
|
|
41
|
+
var clusterStorage = require("./cluster-storage");
|
|
42
|
+
var C = require("./constants");
|
|
43
|
+
var { generateToken, sha3Hash } = require("./crypto");
|
|
44
|
+
var cryptoField = require("./crypto-field");
|
|
45
|
+
var requestHelpers = require("./request-helpers");
|
|
46
|
+
var safeJson = require("./safe-json");
|
|
47
|
+
var { SessionError } = require("./framework-error");
|
|
48
|
+
|
|
49
|
+
var _err = SessionError.factory;
|
|
50
|
+
|
|
51
|
+
var DEFAULT_TTL_MS = C.TIME.days(7);
|
|
52
|
+
// Sanity bound: any session that lives longer than this is almost
|
|
53
|
+
// certainly a misconfigured Infinity / oversized literal. Keeps
|
|
54
|
+
// expiresAt away from epoch overflow + database-int boundary issues.
|
|
55
|
+
var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
|
|
56
|
+
|
|
57
|
+
// Idle + absolute timeout defaults per OWASP ASVS 5.0 §3.3 + NIST
|
|
58
|
+
// SP 800-63B-4. expiresAt is the operator-set window; idle/absolute
|
|
59
|
+
// are independent enforcement floors that shorten the effective
|
|
60
|
+
// session lifetime even when the operator picked a long ttlMs.
|
|
61
|
+
//
|
|
62
|
+
// - idle: session expires N ms after the last verify() / touch().
|
|
63
|
+
// Default 30 minutes — short enough to defeat session-token
|
|
64
|
+
// theft via short-lived foothold; long enough that a user
|
|
65
|
+
// reading a long article doesn't get logged out.
|
|
66
|
+
// - absolute: session always expires at most N ms after creation,
|
|
67
|
+
// regardless of activity. Default 12 hours — re-auth at
|
|
68
|
+
// least once per shift even on a continuously-active
|
|
69
|
+
// session. Repeated touch() with extendBy cannot push past
|
|
70
|
+
// this ceiling.
|
|
71
|
+
var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(30);
|
|
72
|
+
var DEFAULT_ABSOLUTE_TIMEOUT_MS = C.TIME.hours(12);
|
|
73
|
+
|
|
74
|
+
function _validateTtl(ttl, where) {
|
|
75
|
+
if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0) {
|
|
76
|
+
throw _err("INVALID_ARG",
|
|
77
|
+
where + ": ttlMs must be a positive finite number, got " + JSON.stringify(ttl), true);
|
|
78
|
+
}
|
|
79
|
+
if (ttl > MAX_TTL_MS) {
|
|
80
|
+
throw _err("INVALID_ARG",
|
|
81
|
+
where + ": ttlMs " + ttl + " exceeds maximum " + MAX_TTL_MS + " (~10 years). " +
|
|
82
|
+
"Sessions this long suggest a misconfigured value.", true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var SID_NAMESPACE = "bj-session:";
|
|
86
|
+
// Session ID = 32 random bytes (64 hex chars) — 256-bit entropy floor
|
|
87
|
+
// keeps sids unforgeable even before sealed-cookie encryption layers on
|
|
88
|
+
// top. Routed through C.BYTES so every byte literal in the file lives
|
|
89
|
+
// behind the same helper.
|
|
90
|
+
var SID_BYTES = C.BYTES.bytes(32);
|
|
91
|
+
|
|
92
|
+
// Column order used for INSERT — kept as a constant so the placeholders
|
|
93
|
+
// list and the values list stay in sync. Must match _blamejs_sessions's
|
|
94
|
+
// schema in db.js (single-node) and framework-schema.js (cluster mode).
|
|
95
|
+
var SESSION_COLS = ["sidHash", "userId", "userIdHash", "data", "createdAt", "expiresAt", "lastActivity"];
|
|
96
|
+
|
|
97
|
+
function _hashSid(sid) {
|
|
98
|
+
return sha3Hash(SID_NAMESPACE + sid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Build a sealed row object with all SESSION_COLS keys present (null
|
|
102
|
+
// where not set). The cryptoField.sealRow call seals userId/data and
|
|
103
|
+
// produces userIdHash from userId.
|
|
104
|
+
function _sealForInsert(row) {
|
|
105
|
+
var sealed = cryptoField.sealRow("_blamejs_sessions", row);
|
|
106
|
+
for (var i = 0; i < SESSION_COLS.length; i++) {
|
|
107
|
+
if (!(SESSION_COLS[i] in sealed)) sealed[SESSION_COLS[i]] = null;
|
|
108
|
+
}
|
|
109
|
+
return sealed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Public API ----
|
|
113
|
+
|
|
114
|
+
// Build a stable fingerprint from a request's client-derived signals.
|
|
115
|
+
// Operators opt in via session.create({ req, fingerprintFields }) and
|
|
116
|
+
// session.verify({ req, ... }); when the fingerprint drifts (different
|
|
117
|
+
// IP / user-agent / accept-language), the verify result carries
|
|
118
|
+
// fingerprintDrift: true and an audit event fires. Operators in strict
|
|
119
|
+
// mode pass requireFingerprintMatch:true to make the drift kill the
|
|
120
|
+
// session; default returns the session (so a phone roaming between
|
|
121
|
+
// wifi and LTE doesn't get logged out, but the operator AND ops sees
|
|
122
|
+
// the drift signal).
|
|
123
|
+
//
|
|
124
|
+
// The fingerprint is HMAC'd with the session sid so a stolen DB can't
|
|
125
|
+
// be cross-correlated with public IP-UA logs to attribute sessions to
|
|
126
|
+
// users — same defense the sidHash already provides for the token
|
|
127
|
+
// itself, extended to the fingerprint.
|
|
128
|
+
var DEFAULT_FINGERPRINT_FIELDS = ["clientIp", "userAgent", "acceptLanguage"];
|
|
129
|
+
|
|
130
|
+
function _buildFingerprintInputs(req, fields) {
|
|
131
|
+
if (!req) return null;
|
|
132
|
+
var headers = req.headers || {};
|
|
133
|
+
var inputs = {};
|
|
134
|
+
for (var i = 0; i < fields.length; i++) {
|
|
135
|
+
var f = fields[i];
|
|
136
|
+
if (f === "clientIp") {
|
|
137
|
+
inputs.clientIp = requestHelpers.clientIp(req) || "";
|
|
138
|
+
} else if (f === "userAgent") {
|
|
139
|
+
inputs.userAgent = String(headers["user-agent"] || "");
|
|
140
|
+
} else if (f === "acceptLanguage") {
|
|
141
|
+
// Take only the primary tag (en-US,en;q=0.9 → en-US) so a
|
|
142
|
+
// browser's secondary q-list reordering doesn't flap drift.
|
|
143
|
+
var raw = String(headers["accept-language"] || "");
|
|
144
|
+
var primary = raw.split(",")[0] || "";
|
|
145
|
+
inputs.acceptLanguage = primary.split(";")[0].trim().toLowerCase();
|
|
146
|
+
} else if (typeof f === "function") {
|
|
147
|
+
try { inputs[f.name || ("custom" + i)] = String(f(req) || ""); }
|
|
148
|
+
catch (_e) { inputs[f.name || ("custom" + i)] = ""; }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return inputs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _hashFingerprint(sid, inputs) {
|
|
155
|
+
if (!inputs) return null;
|
|
156
|
+
// Deterministic stringify with sorted keys at every depth — refuses to
|
|
157
|
+
// silently round-trip Date / Buffer / Map / Set / BigInt to "{}".
|
|
158
|
+
var canonical = canonicalJson.stringify(inputs);
|
|
159
|
+
return sha3Hash("bj-session-fingerprint:" + sid + ":" + canonical);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function create(opts) {
|
|
163
|
+
cluster.requireLeader();
|
|
164
|
+
if (!opts || !opts.userId) {
|
|
165
|
+
throw _err("INVALID_ARG", "session.create requires { userId }", true);
|
|
166
|
+
}
|
|
167
|
+
var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
168
|
+
_validateTtl(ttl, "session.create");
|
|
169
|
+
|
|
170
|
+
var sid = generateToken(SID_BYTES); // hex-encoded; only place the plaintext sid lives
|
|
171
|
+
var sidHash = _hashSid(sid);
|
|
172
|
+
var nowMs = Date.now();
|
|
173
|
+
var expiresAt = nowMs + ttl;
|
|
174
|
+
|
|
175
|
+
// Fingerprint capture (opt-in via opts.req). Stored as a reserved
|
|
176
|
+
// key inside the sealed `data` field so it lives alongside the
|
|
177
|
+
// operator-supplied session data without needing a schema column.
|
|
178
|
+
var dataObj = opts.data ? Object.assign({}, opts.data) : null;
|
|
179
|
+
var fpFields = Array.isArray(opts.fingerprintFields) && opts.fingerprintFields.length > 0
|
|
180
|
+
? opts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
|
|
181
|
+
var fpInputs = _buildFingerprintInputs(opts.req, fpFields);
|
|
182
|
+
if (fpInputs) {
|
|
183
|
+
if (!dataObj) dataObj = {};
|
|
184
|
+
dataObj.__bj_fingerprint = _hashFingerprint(sid, fpInputs);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
var sealed = _sealForInsert({
|
|
188
|
+
sidHash: sidHash,
|
|
189
|
+
userId: opts.userId,
|
|
190
|
+
data: dataObj ? JSON.stringify(dataObj) : null,
|
|
191
|
+
createdAt: nowMs,
|
|
192
|
+
expiresAt: expiresAt,
|
|
193
|
+
lastActivity: nowMs,
|
|
194
|
+
});
|
|
195
|
+
var values = SESSION_COLS.map(function (c) { return sealed[c]; });
|
|
196
|
+
var placeholders = SESSION_COLS.map(function () { return "?"; }).join(", ");
|
|
197
|
+
var quoted = SESSION_COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
198
|
+
await clusterStorage.execute(
|
|
199
|
+
"INSERT INTO _blamejs_sessions (" + quoted + ") VALUES (" + placeholders + ")",
|
|
200
|
+
values
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return { token: sid, expiresAt: expiresAt };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function verify(token, verifyOpts) {
|
|
207
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
208
|
+
verifyOpts = verifyOpts || {};
|
|
209
|
+
var sidHash = _hashSid(token);
|
|
210
|
+
|
|
211
|
+
var row = await clusterStorage.executeOne(
|
|
212
|
+
"SELECT sidHash, userId, userIdHash, data, createdAt, expiresAt, lastActivity " +
|
|
213
|
+
"FROM _blamejs_sessions WHERE sidHash = ?",
|
|
214
|
+
[sidHash]
|
|
215
|
+
);
|
|
216
|
+
if (!row) return null;
|
|
217
|
+
var nowMs = Date.now();
|
|
218
|
+
if (Number(row.expiresAt) < nowMs) {
|
|
219
|
+
// Expired (operator-set ttl) — clean up and return null. Cleanup
|
|
220
|
+
// is leader-only; verify is anywhere, so a follower observing an
|
|
221
|
+
// expired row skips the cleanup (next leader-side call purges it).
|
|
222
|
+
if (cluster.isLeader()) {
|
|
223
|
+
try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Idle + absolute timeout enforcement (OWASP ASVS 5.0 §3.3 / NIST
|
|
229
|
+
// SP 800-63B-4). These shorten the effective lifetime even when the
|
|
230
|
+
// operator picked a long ttlMs. Defaults: idle 30m, absolute 12h.
|
|
231
|
+
// Operator opt-out by passing 0 (disables that timeout).
|
|
232
|
+
var idleMs = verifyOpts.idleTimeoutMs !== undefined
|
|
233
|
+
? verifyOpts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
|
|
234
|
+
var absMs = verifyOpts.absoluteTimeoutMs !== undefined
|
|
235
|
+
? verifyOpts.absoluteTimeoutMs : DEFAULT_ABSOLUTE_TIMEOUT_MS;
|
|
236
|
+
if (idleMs > 0) {
|
|
237
|
+
var lastActivity = Number(row.lastActivity);
|
|
238
|
+
if ((nowMs - lastActivity) > idleMs) {
|
|
239
|
+
try {
|
|
240
|
+
audit.safeEmit({
|
|
241
|
+
action: "auth.session.expired_idle", outcome: "warning",
|
|
242
|
+
metadata: { idleMs: nowMs - lastActivity, threshold: idleMs },
|
|
243
|
+
});
|
|
244
|
+
} catch (_ignored) { /* audit best-effort */ }
|
|
245
|
+
if (cluster.isLeader()) {
|
|
246
|
+
try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (absMs > 0) {
|
|
252
|
+
var createdAt = Number(row.createdAt);
|
|
253
|
+
if ((nowMs - createdAt) > absMs) {
|
|
254
|
+
try {
|
|
255
|
+
audit.safeEmit({
|
|
256
|
+
action: "auth.session.expired_absolute", outcome: "warning",
|
|
257
|
+
metadata: { ageMs: nowMs - createdAt, threshold: absMs },
|
|
258
|
+
});
|
|
259
|
+
} catch (_ignored) { /* audit best-effort */ }
|
|
260
|
+
if (cluster.isLeader()) {
|
|
261
|
+
try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Unseal sealed columns (userId, data) using the cryptoField pipeline
|
|
267
|
+
// so we return cleartext to the caller — same shape as the previous
|
|
268
|
+
// db().from(...).first() path delivered.
|
|
269
|
+
var unsealed = cryptoField.unsealRow("_blamejs_sessions", row);
|
|
270
|
+
var data = null;
|
|
271
|
+
var storedFingerprint = null;
|
|
272
|
+
if (unsealed.data) {
|
|
273
|
+
try {
|
|
274
|
+
data = safeJson.parse(unsealed.data);
|
|
275
|
+
if (data && typeof data === "object" && typeof data.__bj_fingerprint === "string") {
|
|
276
|
+
storedFingerprint = data.__bj_fingerprint;
|
|
277
|
+
// Strip the reserved key from the operator-visible data so
|
|
278
|
+
// routes don't accidentally render it / log it / pass it on.
|
|
279
|
+
delete data.__bj_fingerprint;
|
|
280
|
+
if (Object.keys(data).length === 0) data = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
// Decrypt-then-parse failure is rare but operationally important —
|
|
285
|
+
// it usually signals key-rotation skew, DB corruption, or
|
|
286
|
+
// tampering. Emit an audit event so ops can spot it before the
|
|
287
|
+
// operator notices empty-`data` flows. data stays null so the
|
|
288
|
+
// session remains usable for non-data flows.
|
|
289
|
+
data = null;
|
|
290
|
+
try {
|
|
291
|
+
audit.safeEmit({
|
|
292
|
+
action: "auth.session.data_unparseable",
|
|
293
|
+
outcome: "failure",
|
|
294
|
+
reason: (e && e.message) || String(e),
|
|
295
|
+
metadata: { hasUserId: !!unsealed.userId },
|
|
296
|
+
});
|
|
297
|
+
} catch (_ignored) { /* audit best-effort */ }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Fingerprint check — opt-in via verifyOpts.req. When the stored
|
|
302
|
+
// fingerprint differs from the current request's fingerprint, audit
|
|
303
|
+
// the drift and (in strict mode) refuse the session. Default mode
|
|
304
|
+
// returns the session with `fingerprintDrift: true` so the operator
|
|
305
|
+
// can decide (some drift — phone roaming wifi/LTE — is benign; a
|
|
306
|
+
// login-from-Tokyo-then-immediately-from-Brazil pattern is not).
|
|
307
|
+
var fingerprintDrift = false;
|
|
308
|
+
var fingerprintAnomalyScore = null;
|
|
309
|
+
if (storedFingerprint && verifyOpts.req) {
|
|
310
|
+
var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
|
|
311
|
+
? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
|
|
312
|
+
var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields);
|
|
313
|
+
var currentHash = _hashFingerprint(token, currentInputs);
|
|
314
|
+
if (currentHash !== storedFingerprint) {
|
|
315
|
+
fingerprintDrift = true;
|
|
316
|
+
// Operator-supplied scorer: receives { storedHash, currentInputs,
|
|
317
|
+
// currentHash, sessionAge: ms-since-create }. Returns a number
|
|
318
|
+
// in [0, 1] — 0 = benign drift (phone roaming wifi), 1 =
|
|
319
|
+
// definitely-malicious. Errors are swallowed; scorer-throw
|
|
320
|
+
// doesn't break verify.
|
|
321
|
+
if (typeof verifyOpts.scorer === "function") {
|
|
322
|
+
try {
|
|
323
|
+
var rawScore = verifyOpts.scorer({
|
|
324
|
+
storedHash: storedFingerprint,
|
|
325
|
+
currentInputs: currentInputs,
|
|
326
|
+
currentHash: currentHash,
|
|
327
|
+
sessionAge: Date.now() - Number(unsealed.createdAt),
|
|
328
|
+
});
|
|
329
|
+
if (typeof rawScore === "number" && isFinite(rawScore)) {
|
|
330
|
+
fingerprintAnomalyScore = Math.max(0, Math.min(1, rawScore));
|
|
331
|
+
}
|
|
332
|
+
} catch (_e) { /* scorer best-effort */ }
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
audit.safeEmit({
|
|
336
|
+
action: "auth.session.fingerprint_drift",
|
|
337
|
+
outcome: "warning",
|
|
338
|
+
metadata: { hasUserId: !!unsealed.userId,
|
|
339
|
+
anomalyScore: fingerprintAnomalyScore },
|
|
340
|
+
});
|
|
341
|
+
} catch (_ignored) { /* audit best-effort */ }
|
|
342
|
+
// Strict modes:
|
|
343
|
+
// requireFingerprintMatch: true — any drift kills the session
|
|
344
|
+
// maxAnomalyScore: <0..1> — drift above threshold kills
|
|
345
|
+
if (verifyOpts.requireFingerprintMatch === true) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (typeof verifyOpts.maxAnomalyScore === "number" &&
|
|
349
|
+
fingerprintAnomalyScore !== null &&
|
|
350
|
+
fingerprintAnomalyScore > verifyOpts.maxAnomalyScore) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
userId: unsealed.userId,
|
|
358
|
+
data: data,
|
|
359
|
+
createdAt: Number(unsealed.createdAt),
|
|
360
|
+
expiresAt: Number(unsealed.expiresAt),
|
|
361
|
+
lastActivity: Number(unsealed.lastActivity),
|
|
362
|
+
fingerprintDrift: fingerprintDrift,
|
|
363
|
+
fingerprintAnomalyScore: fingerprintAnomalyScore,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function destroy(token) {
|
|
368
|
+
cluster.requireLeader();
|
|
369
|
+
if (typeof token !== "string" || token.length === 0) return false;
|
|
370
|
+
return await _deleteBySidHash(_hashSid(token));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function _deleteBySidHash(sidHash) {
|
|
374
|
+
var result = await clusterStorage.execute(
|
|
375
|
+
"DELETE FROM _blamejs_sessions WHERE sidHash = ?",
|
|
376
|
+
[sidHash]
|
|
377
|
+
);
|
|
378
|
+
return (result.rowCount || 0) > 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function destroyAllForUser(userId) {
|
|
382
|
+
cluster.requireLeader();
|
|
383
|
+
if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
|
|
384
|
+
// userId is sealed; look up via derived userIdHash.
|
|
385
|
+
var lookup = cryptoField.lookupHash("_blamejs_sessions", "userId", userId);
|
|
386
|
+
if (!lookup) {
|
|
387
|
+
throw _err("MISCONFIGURED",
|
|
388
|
+
"_blamejs_sessions schema is missing the userIdHash derived hash — framework misconfigured",
|
|
389
|
+
true);
|
|
390
|
+
}
|
|
391
|
+
var result = await clusterStorage.execute(
|
|
392
|
+
"DELETE FROM _blamejs_sessions WHERE userIdHash = ?",
|
|
393
|
+
[lookup.value]
|
|
394
|
+
);
|
|
395
|
+
return result.rowCount || 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function touch(token, opts) {
|
|
399
|
+
cluster.requireLeader();
|
|
400
|
+
opts = opts || {};
|
|
401
|
+
if (typeof token !== "string" || token.length === 0) return false;
|
|
402
|
+
var sidHash = _hashSid(token);
|
|
403
|
+
var nowMs = Date.now();
|
|
404
|
+
// Two SQL paths so the SET list stays static (no dynamic column
|
|
405
|
+
// assembly) and matches the call shape clusterStorage expects.
|
|
406
|
+
// extendBy resets expiresAt relative to NOW, not relative to the
|
|
407
|
+
// current expiresAt — a soaked session with continuous traffic
|
|
408
|
+
// shouldn't accumulate unbounded expiry. The same MAX_TTL_MS
|
|
409
|
+
// ceiling create() and rotate() apply gates extendBy too — repeated
|
|
410
|
+
// touch() calls cannot push expiresAt past the framework's bound.
|
|
411
|
+
if (opts.extendBy !== undefined && opts.extendBy !== null) {
|
|
412
|
+
_validateTtl(opts.extendBy, "session.touch");
|
|
413
|
+
var newExpires = nowMs + opts.extendBy;
|
|
414
|
+
var result = await clusterStorage.execute(
|
|
415
|
+
"UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
|
|
416
|
+
"WHERE sidHash = ? AND expiresAt >= ?",
|
|
417
|
+
[nowMs, newExpires, sidHash, nowMs]
|
|
418
|
+
);
|
|
419
|
+
return (result.rowCount || 0) > 0;
|
|
420
|
+
}
|
|
421
|
+
var result2 = await clusterStorage.execute(
|
|
422
|
+
"UPDATE _blamejs_sessions SET lastActivity = ? " +
|
|
423
|
+
"WHERE sidHash = ? AND expiresAt >= ?",
|
|
424
|
+
[nowMs, sidHash, nowMs]
|
|
425
|
+
);
|
|
426
|
+
return (result2.rowCount || 0) > 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// rotate(oldToken, opts?) — session fixation defense. Generates a fresh
|
|
430
|
+
// sid for the same userId + data, atomically replacing the old sid in
|
|
431
|
+
// the row. Standard pattern: call after auth state changes (login from
|
|
432
|
+
// anonymous, MFA verified, role escalation) so any sid an attacker
|
|
433
|
+
// might have planted pre-login becomes invalid.
|
|
434
|
+
//
|
|
435
|
+
// opts:
|
|
436
|
+
// data: optional replacement session data (re-sealed)
|
|
437
|
+
// ttlMs: optional new TTL; if absent, expiresAt is preserved
|
|
438
|
+
// reason: free-form audit metadata ('login', 'mfa', etc.)
|
|
439
|
+
//
|
|
440
|
+
// Returns { token, expiresAt } on success, or null when the old token
|
|
441
|
+
// doesn't exist / has expired (operator distinguishes by checking
|
|
442
|
+
// for null).
|
|
443
|
+
//
|
|
444
|
+
// Atomicity: single UPDATE swaps sidHash. The old + new tokens never
|
|
445
|
+
// coexist — the moment the UPDATE commits, only the new token verifies.
|
|
446
|
+
// Backends that can't do the WHERE-guarded UPDATE atomically (none of
|
|
447
|
+
// the framework's supported backends fall in that bucket) would need
|
|
448
|
+
// a transactional shim.
|
|
449
|
+
async function rotate(oldToken, opts) {
|
|
450
|
+
cluster.requireLeader();
|
|
451
|
+
if (typeof oldToken !== "string" || oldToken.length === 0) return null;
|
|
452
|
+
opts = opts || {};
|
|
453
|
+
|
|
454
|
+
var newSid = generateToken(SID_BYTES);
|
|
455
|
+
var newSidHash = _hashSid(newSid);
|
|
456
|
+
var oldSidHash = _hashSid(oldToken);
|
|
457
|
+
var nowMs = Date.now();
|
|
458
|
+
var newExpires = null;
|
|
459
|
+
if (opts.ttlMs !== undefined) {
|
|
460
|
+
_validateTtl(opts.ttlMs, "session.rotate");
|
|
461
|
+
newExpires = nowMs + opts.ttlMs;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
var setParts = ['"sidHash" = ?', '"lastActivity" = ?'];
|
|
465
|
+
var setParams = [newSidHash, nowMs];
|
|
466
|
+
|
|
467
|
+
if (opts.data !== undefined) {
|
|
468
|
+
var dataJson = opts.data ? JSON.stringify(opts.data) : null;
|
|
469
|
+
var sealedRow = cryptoField.sealRow("_blamejs_sessions", { data: dataJson });
|
|
470
|
+
setParts.push('"data" = ?');
|
|
471
|
+
setParams.push(sealedRow.data);
|
|
472
|
+
}
|
|
473
|
+
if (newExpires !== null) {
|
|
474
|
+
setParts.push('"expiresAt" = ?');
|
|
475
|
+
setParams.push(newExpires);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
var sql = "UPDATE _blamejs_sessions SET " + setParts.join(", ") +
|
|
479
|
+
" WHERE sidHash = ? AND expiresAt >= ?";
|
|
480
|
+
var params = setParams.concat([oldSidHash, nowMs]);
|
|
481
|
+
var result = await clusterStorage.execute(sql, params);
|
|
482
|
+
if ((result.rowCount || 0) === 0) return null;
|
|
483
|
+
|
|
484
|
+
// Read the row's effective expiresAt to return — single source of truth.
|
|
485
|
+
var row = await clusterStorage.executeOne(
|
|
486
|
+
'SELECT "expiresAt" FROM _blamejs_sessions WHERE sidHash = ?',
|
|
487
|
+
[newSidHash]
|
|
488
|
+
);
|
|
489
|
+
var expiresAt = row ? Number(row.expiresAt) : null;
|
|
490
|
+
|
|
491
|
+
// Audit emit — best-effort. The framework's audit chain logs the
|
|
492
|
+
// privilege transition so post-incident review can trace which
|
|
493
|
+
// session id covered which privilege state.
|
|
494
|
+
try {
|
|
495
|
+
audit.emit({
|
|
496
|
+
action: "auth.session.rotate",
|
|
497
|
+
outcome: "success",
|
|
498
|
+
metadata: { reason: opts.reason || "explicit" },
|
|
499
|
+
});
|
|
500
|
+
} catch (_e) { /* audit emit best-effort — never block rotate() */ }
|
|
501
|
+
|
|
502
|
+
return { token: newSid, expiresAt: expiresAt };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function purgeExpired() {
|
|
506
|
+
cluster.requireLeader();
|
|
507
|
+
var result = await clusterStorage.execute(
|
|
508
|
+
"DELETE FROM _blamejs_sessions WHERE expiresAt < ?",
|
|
509
|
+
[Date.now()]
|
|
510
|
+
);
|
|
511
|
+
return result.rowCount || 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function count() {
|
|
515
|
+
var row = await clusterStorage.executeOne(
|
|
516
|
+
"SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
|
|
517
|
+
[Date.now()]
|
|
518
|
+
);
|
|
519
|
+
return row ? Number(row.c) : 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function _resetForTest() { /* no module state to reset; clusterStorage and cryptoField own theirs */ }
|
|
523
|
+
|
|
524
|
+
module.exports = {
|
|
525
|
+
create: create,
|
|
526
|
+
verify: verify,
|
|
527
|
+
destroy: destroy,
|
|
528
|
+
destroyAllForUser: destroyAllForUser,
|
|
529
|
+
touch: touch,
|
|
530
|
+
rotate: rotate,
|
|
531
|
+
purgeExpired: purgeExpired,
|
|
532
|
+
count: count,
|
|
533
|
+
DEFAULT_TTL_MS: DEFAULT_TTL_MS,
|
|
534
|
+
_resetForTest: _resetForTest,
|
|
535
|
+
};
|