@blamejs/core 0.8.59 → 0.8.64
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 +5 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +530 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +307 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/session.js
CHANGED
|
@@ -53,10 +53,29 @@ var clusterStorage = require("./cluster-storage");
|
|
|
53
53
|
var C = require("./constants");
|
|
54
54
|
var { generateToken, sha3Hash } = require("./crypto");
|
|
55
55
|
var cryptoField = require("./crypto-field");
|
|
56
|
+
var lazyRequire = require("./lazy-require");
|
|
56
57
|
var requestHelpers = require("./request-helpers");
|
|
57
58
|
var safeJson = require("./safe-json");
|
|
58
59
|
var { SessionError } = require("./framework-error");
|
|
59
60
|
|
|
61
|
+
// vault is initialized at boot before sessions; lazyRequire keeps the
|
|
62
|
+
// load order independent of module-import order. Used to seal/unseal
|
|
63
|
+
// the cookie-side sid so the wire token is ciphertext rather than
|
|
64
|
+
// plaintext (sealed-cookie default since v0.8.61).
|
|
65
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
66
|
+
|
|
67
|
+
// Pluggable session-storage backend. Default uses cluster-storage (which
|
|
68
|
+
// in turn dispatches to the framework's main DB or external DB). An
|
|
69
|
+
// operator can switch to an isolated backend (e.g. an in-memory or
|
|
70
|
+
// tmpfs SQLite via `b.session.stores.localDbThin`) so heavy session
|
|
71
|
+
// churn doesn't fight the main DB's WAL fsync + at-rest re-encryption
|
|
72
|
+
// cycle. Set once at boot via `b.session.useStore(store)`; the store
|
|
73
|
+
// must expose `execute(sql, params)` and `executeOne(sql, params)`
|
|
74
|
+
// returning the same `{ rowCount, rows? }` / row-or-null shape the
|
|
75
|
+
// cluster-storage path returns.
|
|
76
|
+
var _store = null;
|
|
77
|
+
function _currentStore() { return _store || clusterStorage; }
|
|
78
|
+
|
|
60
79
|
var _err = SessionError.factory;
|
|
61
80
|
|
|
62
81
|
var DEFAULT_TTL_MS = C.TIME.days(7);
|
|
@@ -109,6 +128,41 @@ function _hashSid(sid) {
|
|
|
109
128
|
return sha3Hash(SID_NAMESPACE + sid);
|
|
110
129
|
}
|
|
111
130
|
|
|
131
|
+
// Sealed-cookie format. `b.vault.seal` produces a `vault:`-prefixed
|
|
132
|
+
// envelope (ML-KEM-1024 + P-384 hybrid + XChaCha20-Poly1305). Pre-
|
|
133
|
+
// v0.8.61 the framework returned the plaintext sid to the caller; the
|
|
134
|
+
// sealed default since v0.8.61 keeps the wire token as ciphertext. The
|
|
135
|
+
// DB still keys on `sha3('bj-session:' || sid)` — sealing is a
|
|
136
|
+
// wire-format upgrade, not a storage change.
|
|
137
|
+
//
|
|
138
|
+
// Pre-v1.0 the framework ships no backwards-compat path: raw-format
|
|
139
|
+
// cookies from before v0.8.61 fail to unseal here and the affected
|
|
140
|
+
// caller force-logs-out (re-auths and gets a sealed cookie). The
|
|
141
|
+
// upgrade is operator-visible and documented in the release notes.
|
|
142
|
+
var SEALED_COOKIE_PREFIX = "vault:";
|
|
143
|
+
|
|
144
|
+
function _sealCookieToken(sid) {
|
|
145
|
+
// vault.seal is idempotent on already-sealed input. Defensive null
|
|
146
|
+
// pass-through matches vault's contract — feed it the raw sid only.
|
|
147
|
+
return vault().seal(sid);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _unsealCookieToken(token) {
|
|
151
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
152
|
+
if (token.indexOf(SEALED_COOKIE_PREFIX) !== 0) {
|
|
153
|
+
// Pre-v0.8.61 raw-sid format — refused under the sealed-cookie
|
|
154
|
+
// default. Returning null surfaces as "session not found" at the
|
|
155
|
+
// verify call site so the caller force-logs-out cleanly.
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
try { return vault().unseal(token); }
|
|
159
|
+
catch (_e) {
|
|
160
|
+
// Tampered cookie / wrong keypair / vault rotation skew — treat as
|
|
161
|
+
// not-found. The caller already handles `null` (re-auth flow).
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
112
166
|
// Build a sealed row object with all SESSION_COLS keys present (null
|
|
113
167
|
// where not set). The cryptoField.sealRow call seals userId/data and
|
|
114
168
|
// produces userIdHash from userId.
|
|
@@ -138,6 +192,120 @@ function _sealForInsert(row) {
|
|
|
138
192
|
// itself, extended to the fingerprint.
|
|
139
193
|
var DEFAULT_FINGERPRINT_FIELDS = ["clientIp", "userAgent", "acceptLanguage"];
|
|
140
194
|
|
|
195
|
+
// Subnet binding: roaming carriers (T-Mobile / Verizon / etc.) flip the
|
|
196
|
+
// public client IP every few requests as the device hops cells, so a
|
|
197
|
+
// strict full-IP fingerprint logs out healthy mobile users. The
|
|
198
|
+
// "clientIpPrefix" field hashes a /24 mask for IPv4 (256-address bucket
|
|
199
|
+
// — same Class C-shaped neighborhood) and a /64 mask for IPv6 (the IPv6
|
|
200
|
+
// "site" prefix the RIRs allocate to every ISP customer). Drift across
|
|
201
|
+
// /24 OR /64 is meaningfully suspicious; drift within is not.
|
|
202
|
+
//
|
|
203
|
+
// Per the IPv6 addressing architecture (RFC 4291 §2.5.4) every customer
|
|
204
|
+
// LAN is assigned at least a /64; tightening below /64 punishes IPv6
|
|
205
|
+
// privacy-extension address rotation. /24 IPv4 is the original
|
|
206
|
+
// IP-geolocation bucket size and matches the legacy carrier-NAT pool
|
|
207
|
+
// stride. Operators with stricter needs pass a function-form
|
|
208
|
+
// fingerprint field for custom mask widths.
|
|
209
|
+
//
|
|
210
|
+
// Protocol constants — named so the bit-arithmetic stays readable.
|
|
211
|
+
var IP_BITS_PER_BYTE = 8; // allow:raw-byte-literal — bits per byte; protocol constant, not a byte size
|
|
212
|
+
var IPV4_OCTET_COUNT = 4;
|
|
213
|
+
var IPV4_OCTET_RANGE = 256; // allow:raw-byte-literal — 0..255 inclusive; v4 octet domain
|
|
214
|
+
var IPV4_TOTAL_BITS = 32; // allow:raw-byte-literal — IPv4 address width in bits
|
|
215
|
+
var IPV4_DEFAULT_PREFIX = 24; // allow:raw-byte-literal — /24 carrier-NAT pool stride
|
|
216
|
+
var IPV6_GROUP_COUNT = 8; // allow:raw-byte-literal — 8 16-bit groups in v6
|
|
217
|
+
var IPV6_BYTE_COUNT = 16; // allow:raw-byte-literal — 16 bytes in v6
|
|
218
|
+
var IPV6_DEFAULT_PREFIX = 64; // allow:raw-byte-literal — /64 customer LAN per RFC 4291 §2.5.4
|
|
219
|
+
var BYTE_MASK = 0xff;
|
|
220
|
+
var HEX_RADIX = 16; // allow:raw-byte-literal — base-16 radix
|
|
221
|
+
var V4_MAPPED_V6_PREFIX = "::ffff:";
|
|
222
|
+
|
|
223
|
+
function _maskIpv4(ip, prefix) {
|
|
224
|
+
// ip = "a.b.c.d"; prefix is bits to keep (1..32).
|
|
225
|
+
var parts = String(ip).split(".");
|
|
226
|
+
if (parts.length !== IPV4_OCTET_COUNT) return null;
|
|
227
|
+
var n = 0;
|
|
228
|
+
for (var i = 0; i < IPV4_OCTET_COUNT; i++) {
|
|
229
|
+
var oct = parseInt(parts[i], 10);
|
|
230
|
+
if (!Number.isInteger(oct) || oct < 0 || oct >= IPV4_OCTET_RANGE) return null;
|
|
231
|
+
n = (n * IPV4_OCTET_RANGE) + oct;
|
|
232
|
+
}
|
|
233
|
+
// Apply prefix mask.
|
|
234
|
+
var mask = prefix === 0 ? 0 : (-1 >>> (IPV4_TOTAL_BITS - prefix)) << (IPV4_TOTAL_BITS - prefix);
|
|
235
|
+
// Bitwise on 32-bit unsigned. JS coerces to 32-bit signed, so use
|
|
236
|
+
// unsigned right shift to recover.
|
|
237
|
+
var masked = (n & mask) >>> 0;
|
|
238
|
+
return ((masked >>> IP_BITS_PER_BYTE * 3) & BYTE_MASK) + "." +
|
|
239
|
+
((masked >>> IP_BITS_PER_BYTE * 2) & BYTE_MASK) + "." +
|
|
240
|
+
((masked >>> IP_BITS_PER_BYTE) & BYTE_MASK) + "." +
|
|
241
|
+
(masked & BYTE_MASK) + "/" + prefix;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _maskIpv6(ip, prefix) {
|
|
245
|
+
// Expand to 8 16-bit groups. Accept :: shorthand. Reject if invalid.
|
|
246
|
+
var raw = String(ip).toLowerCase();
|
|
247
|
+
// Strip an embedded zone id (fe80::1%eth0); not part of the address.
|
|
248
|
+
var pct = raw.indexOf("%");
|
|
249
|
+
if (pct !== -1) raw = raw.substring(0, pct);
|
|
250
|
+
var doubleColonAt = raw.indexOf("::");
|
|
251
|
+
var groups;
|
|
252
|
+
if (doubleColonAt === -1) {
|
|
253
|
+
groups = raw.split(":");
|
|
254
|
+
if (groups.length !== IPV6_GROUP_COUNT) return null;
|
|
255
|
+
} else {
|
|
256
|
+
var left = raw.substring(0, doubleColonAt).split(":");
|
|
257
|
+
var right = raw.substring(doubleColonAt + 2).split(":");
|
|
258
|
+
if (left.length === 1 && left[0] === "") left = [];
|
|
259
|
+
if (right.length === 1 && right[0] === "") right = [];
|
|
260
|
+
var fillCount = IPV6_GROUP_COUNT - left.length - right.length;
|
|
261
|
+
if (fillCount < 0) return null;
|
|
262
|
+
var middle = [];
|
|
263
|
+
for (var fi = 0; fi < fillCount; fi++) middle.push("0");
|
|
264
|
+
groups = left.concat(middle).concat(right);
|
|
265
|
+
}
|
|
266
|
+
// Each group is 1–4 hex chars.
|
|
267
|
+
var bytes = [];
|
|
268
|
+
for (var gi = 0; gi < IPV6_GROUP_COUNT; gi++) {
|
|
269
|
+
var g = groups[gi];
|
|
270
|
+
if (typeof g !== "string" || g.length === 0 || g.length > 4 || /[^0-9a-f]/.test(g)) return null;
|
|
271
|
+
var v = parseInt(g, HEX_RADIX);
|
|
272
|
+
if (!Number.isInteger(v) || v < 0 || v > 0xffff) return null;
|
|
273
|
+
bytes.push((v >> IP_BITS_PER_BYTE) & BYTE_MASK);
|
|
274
|
+
bytes.push(v & BYTE_MASK);
|
|
275
|
+
}
|
|
276
|
+
// Apply prefix in bits.
|
|
277
|
+
var keepBytes = Math.floor(prefix / IP_BITS_PER_BYTE);
|
|
278
|
+
var keepBits = prefix % IP_BITS_PER_BYTE;
|
|
279
|
+
for (var bi = 0; bi < IPV6_BYTE_COUNT; bi++) {
|
|
280
|
+
if (bi < keepBytes) continue;
|
|
281
|
+
if (bi === keepBytes && keepBits > 0) {
|
|
282
|
+
var m = (BYTE_MASK << (IP_BITS_PER_BYTE - keepBits)) & BYTE_MASK;
|
|
283
|
+
bytes[bi] = bytes[bi] & m;
|
|
284
|
+
} else {
|
|
285
|
+
bytes[bi] = 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Re-emit as colon-hex (no compression — deterministic for hashing).
|
|
289
|
+
var out = [];
|
|
290
|
+
for (var oi = 0; oi < IPV6_BYTE_COUNT; oi += 2) {
|
|
291
|
+
out.push(((bytes[oi] << IP_BITS_PER_BYTE) | bytes[oi + 1]).toString(HEX_RADIX));
|
|
292
|
+
}
|
|
293
|
+
return out.join(":") + "/" + prefix;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function _ipPrefix(ip) {
|
|
297
|
+
if (typeof ip !== "string" || ip.length === 0) return "";
|
|
298
|
+
// IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4
|
|
299
|
+
// mask applies. Same bucket regardless of how the proxy reported it.
|
|
300
|
+
var lower = ip.toLowerCase();
|
|
301
|
+
if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
|
|
302
|
+
return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), IPV4_DEFAULT_PREFIX) || "";
|
|
303
|
+
}
|
|
304
|
+
if (ip.indexOf(":") !== -1) return _maskIpv6(ip, IPV6_DEFAULT_PREFIX) || "";
|
|
305
|
+
if (ip.indexOf(".") !== -1) return _maskIpv4(ip, IPV4_DEFAULT_PREFIX) || "";
|
|
306
|
+
return "";
|
|
307
|
+
}
|
|
308
|
+
|
|
141
309
|
function _buildFingerprintInputs(req, fields) {
|
|
142
310
|
if (!req) return null;
|
|
143
311
|
var headers = req.headers || {};
|
|
@@ -146,6 +314,9 @@ function _buildFingerprintInputs(req, fields) {
|
|
|
146
314
|
var f = fields[i];
|
|
147
315
|
if (f === "clientIp") {
|
|
148
316
|
inputs.clientIp = requestHelpers.clientIp(req) || "";
|
|
317
|
+
} else if (f === "clientIpPrefix") {
|
|
318
|
+
// /24 v4 + /64 v6 — see _ipPrefix() commentary.
|
|
319
|
+
inputs.clientIpPrefix = _ipPrefix(requestHelpers.clientIp(req) || "");
|
|
149
320
|
} else if (f === "userAgent") {
|
|
150
321
|
inputs.userAgent = String(headers["user-agent"] || "");
|
|
151
322
|
} else if (f === "acceptLanguage") {
|
|
@@ -206,10 +377,33 @@ function _hashFingerprint(sid, inputs) {
|
|
|
206
377
|
* res.setHeader("Set-Cookie", "sid=" + s.token + "; HttpOnly; Secure; SameSite=Strict");
|
|
207
378
|
* // → { token: "9f2c…", expiresAt: 1735689600000 }
|
|
208
379
|
*/
|
|
380
|
+
// Anonymous-session prefix. b.session.create({ anonymous: true })
|
|
381
|
+
// auto-mints userId = ANON_PREFIX + crypto.randomUUID() so operators
|
|
382
|
+
// running pre-login flows (cart, partial-funnel telemetry, public
|
|
383
|
+
// landing-page personalization) keep the framework's full sealed-
|
|
384
|
+
// cookie + sealed-userId + sidHash + idle/absolute timeout posture
|
|
385
|
+
// without rolling their own opaque-id pattern. destroyAllForUser
|
|
386
|
+
// refuses anon ids: they're per-session and aren't portable.
|
|
387
|
+
var ANON_PREFIX = "anon:";
|
|
388
|
+
function _isAnonymousUserId(id) {
|
|
389
|
+
return typeof id === "string" && id.indexOf(ANON_PREFIX) === 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
209
392
|
async function create(opts) {
|
|
210
393
|
cluster.requireLeader();
|
|
211
|
-
|
|
212
|
-
|
|
394
|
+
opts = opts || {};
|
|
395
|
+
if (opts.anonymous === true) {
|
|
396
|
+
if (opts.userId !== undefined && opts.userId !== null) {
|
|
397
|
+
throw _err("INVALID_ARG",
|
|
398
|
+
"session.create: pass either anonymous: true OR userId, not both", true);
|
|
399
|
+
}
|
|
400
|
+
// crypto.randomUUID is the framework's existing entropy source
|
|
401
|
+
// for opaque ids; anon sessions inherit the same 122-bit space.
|
|
402
|
+
var nodeCryptoForUuid = require("node:crypto"); // allow:inline-require — only the anon path needs randomUUID
|
|
403
|
+
opts = Object.assign({}, opts, { userId: ANON_PREFIX + nodeCryptoForUuid.randomUUID() });
|
|
404
|
+
}
|
|
405
|
+
if (!opts.userId) {
|
|
406
|
+
throw _err("INVALID_ARG", "session.create requires { userId } (or { anonymous: true })", true);
|
|
213
407
|
}
|
|
214
408
|
var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
215
409
|
_validateTtl(ttl, "session.create");
|
|
@@ -242,12 +436,12 @@ async function create(opts) {
|
|
|
242
436
|
var values = SESSION_COLS.map(function (c) { return sealed[c]; });
|
|
243
437
|
var placeholders = SESSION_COLS.map(function () { return "?"; }).join(", ");
|
|
244
438
|
var quoted = SESSION_COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
245
|
-
await
|
|
439
|
+
await _currentStore().execute(
|
|
246
440
|
"INSERT INTO _blamejs_sessions (" + quoted + ") VALUES (" + placeholders + ")",
|
|
247
441
|
values
|
|
248
442
|
);
|
|
249
443
|
|
|
250
|
-
return { token: sid, expiresAt: expiresAt };
|
|
444
|
+
return { token: _sealCookieToken(sid), expiresAt: expiresAt };
|
|
251
445
|
}
|
|
252
446
|
|
|
253
447
|
/**
|
|
@@ -295,9 +489,14 @@ async function create(opts) {
|
|
|
295
489
|
async function verify(token, verifyOpts) {
|
|
296
490
|
if (typeof token !== "string" || token.length === 0) return null;
|
|
297
491
|
verifyOpts = verifyOpts || {};
|
|
298
|
-
|
|
492
|
+
// Sealed-cookie default — unseal the wire token to recover the sid.
|
|
493
|
+
// Pre-v0.8.61 raw cookies / tampered envelopes return null and the
|
|
494
|
+
// caller re-auths. The plaintext sid never leaves this function.
|
|
495
|
+
var sid = _unsealCookieToken(token);
|
|
496
|
+
if (sid === null) return null;
|
|
497
|
+
var sidHash = _hashSid(sid);
|
|
299
498
|
|
|
300
|
-
var row = await
|
|
499
|
+
var row = await _currentStore().executeOne(
|
|
301
500
|
"SELECT sidHash, userId, userIdHash, data, createdAt, expiresAt, lastActivity " +
|
|
302
501
|
"FROM _blamejs_sessions WHERE sidHash = ?",
|
|
303
502
|
[sidHash]
|
|
@@ -399,7 +598,7 @@ async function verify(token, verifyOpts) {
|
|
|
399
598
|
var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
|
|
400
599
|
? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
|
|
401
600
|
var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields);
|
|
402
|
-
var currentHash = _hashFingerprint(
|
|
601
|
+
var currentHash = _hashFingerprint(sid, currentInputs);
|
|
403
602
|
if (currentHash !== storedFingerprint) {
|
|
404
603
|
fingerprintDrift = true;
|
|
405
604
|
// Operator-supplied scorer: receives { storedHash, currentInputs,
|
|
@@ -474,11 +673,13 @@ async function verify(token, verifyOpts) {
|
|
|
474
673
|
async function destroy(token) {
|
|
475
674
|
cluster.requireLeader();
|
|
476
675
|
if (typeof token !== "string" || token.length === 0) return false;
|
|
477
|
-
|
|
676
|
+
var sid = _unsealCookieToken(token);
|
|
677
|
+
if (sid === null) return false;
|
|
678
|
+
return await _deleteBySidHash(_hashSid(sid));
|
|
478
679
|
}
|
|
479
680
|
|
|
480
681
|
async function _deleteBySidHash(sidHash) {
|
|
481
|
-
var result = await
|
|
682
|
+
var result = await _currentStore().execute(
|
|
482
683
|
"DELETE FROM _blamejs_sessions WHERE sidHash = ?",
|
|
483
684
|
[sidHash]
|
|
484
685
|
);
|
|
@@ -506,6 +707,16 @@ async function _deleteBySidHash(sidHash) {
|
|
|
506
707
|
async function destroyAllForUser(userId) {
|
|
507
708
|
cluster.requireLeader();
|
|
508
709
|
if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
|
|
710
|
+
if (_isAnonymousUserId(userId)) {
|
|
711
|
+
// Anon ids are minted per-session and aren't reused — destroying
|
|
712
|
+
// "all" anon sessions for one anon id is the same as destroying
|
|
713
|
+
// that single session. Refuse loudly so the operator doesn't
|
|
714
|
+
// think they're sweeping across an anon population.
|
|
715
|
+
throw _err("INVALID_ARG",
|
|
716
|
+
"session.destroyAllForUser: anonymous-prefix ids (\"anon:...\") are per-session — " +
|
|
717
|
+
"use destroy(token) for that session, OR purgeExpired() for housekeeping",
|
|
718
|
+
true);
|
|
719
|
+
}
|
|
509
720
|
// userId is sealed; look up via derived userIdHash.
|
|
510
721
|
var lookup = cryptoField.lookupHash("_blamejs_sessions", "userId", userId);
|
|
511
722
|
if (!lookup) {
|
|
@@ -513,7 +724,7 @@ async function destroyAllForUser(userId) {
|
|
|
513
724
|
"_blamejs_sessions schema is missing the userIdHash derived hash — framework misconfigured",
|
|
514
725
|
true);
|
|
515
726
|
}
|
|
516
|
-
var result = await
|
|
727
|
+
var result = await _currentStore().execute(
|
|
517
728
|
"DELETE FROM _blamejs_sessions WHERE userIdHash = ?",
|
|
518
729
|
[lookup.value]
|
|
519
730
|
);
|
|
@@ -551,7 +762,9 @@ async function touch(token, opts) {
|
|
|
551
762
|
cluster.requireLeader();
|
|
552
763
|
opts = opts || {};
|
|
553
764
|
if (typeof token !== "string" || token.length === 0) return false;
|
|
554
|
-
var
|
|
765
|
+
var sid = _unsealCookieToken(token);
|
|
766
|
+
if (sid === null) return false;
|
|
767
|
+
var sidHash = _hashSid(sid);
|
|
555
768
|
var nowMs = Date.now();
|
|
556
769
|
// Two SQL paths so the SET list stays static (no dynamic column
|
|
557
770
|
// assembly) and matches the call shape clusterStorage expects.
|
|
@@ -563,14 +776,14 @@ async function touch(token, opts) {
|
|
|
563
776
|
if (opts.extendBy !== undefined && opts.extendBy !== null) {
|
|
564
777
|
_validateTtl(opts.extendBy, "session.touch");
|
|
565
778
|
var newExpires = nowMs + opts.extendBy;
|
|
566
|
-
var result = await
|
|
779
|
+
var result = await _currentStore().execute(
|
|
567
780
|
"UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
|
|
568
781
|
"WHERE sidHash = ? AND expiresAt >= ?",
|
|
569
782
|
[nowMs, newExpires, sidHash, nowMs]
|
|
570
783
|
);
|
|
571
784
|
return (result.rowCount || 0) > 0;
|
|
572
785
|
}
|
|
573
|
-
var result2 = await
|
|
786
|
+
var result2 = await _currentStore().execute(
|
|
574
787
|
"UPDATE _blamejs_sessions SET lastActivity = ? " +
|
|
575
788
|
"WHERE sidHash = ? AND expiresAt >= ?",
|
|
576
789
|
[nowMs, sidHash, nowMs]
|
|
@@ -618,10 +831,12 @@ async function rotate(oldToken, opts) {
|
|
|
618
831
|
cluster.requireLeader();
|
|
619
832
|
if (typeof oldToken !== "string" || oldToken.length === 0) return null;
|
|
620
833
|
opts = opts || {};
|
|
834
|
+
var oldSid = _unsealCookieToken(oldToken);
|
|
835
|
+
if (oldSid === null) return null;
|
|
621
836
|
|
|
622
837
|
var newSid = generateToken(SID_BYTES);
|
|
623
838
|
var newSidHash = _hashSid(newSid);
|
|
624
|
-
var oldSidHash = _hashSid(
|
|
839
|
+
var oldSidHash = _hashSid(oldSid);
|
|
625
840
|
var nowMs = Date.now();
|
|
626
841
|
var newExpires = null;
|
|
627
842
|
if (opts.ttlMs !== undefined) {
|
|
@@ -646,11 +861,11 @@ async function rotate(oldToken, opts) {
|
|
|
646
861
|
var sql = "UPDATE _blamejs_sessions SET " + setParts.join(", ") +
|
|
647
862
|
" WHERE sidHash = ? AND expiresAt >= ?";
|
|
648
863
|
var params = setParams.concat([oldSidHash, nowMs]);
|
|
649
|
-
var result = await
|
|
864
|
+
var result = await _currentStore().execute(sql, params);
|
|
650
865
|
if ((result.rowCount || 0) === 0) return null;
|
|
651
866
|
|
|
652
867
|
// Read the row's effective expiresAt to return — single source of truth.
|
|
653
|
-
var row = await
|
|
868
|
+
var row = await _currentStore().executeOne(
|
|
654
869
|
'SELECT "expiresAt" FROM _blamejs_sessions WHERE sidHash = ?',
|
|
655
870
|
[newSidHash]
|
|
656
871
|
);
|
|
@@ -667,7 +882,7 @@ async function rotate(oldToken, opts) {
|
|
|
667
882
|
});
|
|
668
883
|
} catch (_e) { /* audit emit best-effort — never block rotate() */ }
|
|
669
884
|
|
|
670
|
-
return { token: newSid, expiresAt: expiresAt };
|
|
885
|
+
return { token: _sealCookieToken(newSid), expiresAt: expiresAt };
|
|
671
886
|
}
|
|
672
887
|
|
|
673
888
|
/**
|
|
@@ -696,7 +911,7 @@ async function rotate(oldToken, opts) {
|
|
|
696
911
|
*/
|
|
697
912
|
async function purgeExpired() {
|
|
698
913
|
cluster.requireLeader();
|
|
699
|
-
var result = await
|
|
914
|
+
var result = await _currentStore().execute(
|
|
700
915
|
"DELETE FROM _blamejs_sessions WHERE expiresAt < ?",
|
|
701
916
|
[Date.now()]
|
|
702
917
|
);
|
|
@@ -722,14 +937,82 @@ async function purgeExpired() {
|
|
|
722
937
|
* // → 482
|
|
723
938
|
*/
|
|
724
939
|
async function count() {
|
|
725
|
-
var row = await
|
|
940
|
+
var row = await _currentStore().executeOne(
|
|
726
941
|
"SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
|
|
727
942
|
[Date.now()]
|
|
728
943
|
);
|
|
729
944
|
return row ? Number(row.c) : 0;
|
|
730
945
|
}
|
|
731
946
|
|
|
732
|
-
function _resetForTest() {
|
|
947
|
+
function _resetForTest() { _store = null; }
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* @primitive b.session.useStore
|
|
951
|
+
* @signature b.session.useStore(store)
|
|
952
|
+
* @since 0.8.61
|
|
953
|
+
* @status stable
|
|
954
|
+
* @related b.session.stores.localDbThin
|
|
955
|
+
*
|
|
956
|
+
* Replace the default `_blamejs_sessions` storage backend (the
|
|
957
|
+
* framework's main DB / external DB via cluster-storage) with an
|
|
958
|
+
* operator-supplied store. The store must expose
|
|
959
|
+
* `execute(sql, params)` and `executeOne(sql, params)` returning the
|
|
960
|
+
* same `{ rows, rowCount }` / `row | null` shape `b.clusterStorage`
|
|
961
|
+
* returns. Pass `null` to revert to the default.
|
|
962
|
+
*
|
|
963
|
+
* Typical use is to point session writes at an isolated SQLite file
|
|
964
|
+
* (often tmpfs) so session churn doesn't fight the main DB's encrypted-
|
|
965
|
+
* at-rest re-flush cycle. The first-party adapter is
|
|
966
|
+
* `b.session.stores.localDbThin({ file })`.
|
|
967
|
+
*
|
|
968
|
+
* Call this once at boot, BEFORE the first `session.create` /
|
|
969
|
+
* `session.verify`. Switching stores on a running app strands every
|
|
970
|
+
* existing session in the old store.
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* var b = require("@blamejs/core");
|
|
974
|
+
* await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
|
|
975
|
+
* await b.db.init({ dataDir: "/var/lib/blamejs" });
|
|
976
|
+
* var sessionStore = b.session.stores.localDbThin({ file: "/dev/shm/sessions.db" });
|
|
977
|
+
* b.session.useStore(sessionStore);
|
|
978
|
+
* // Every b.session.* call now routes through the tmpfs file.
|
|
979
|
+
*/
|
|
980
|
+
function useStore(store) {
|
|
981
|
+
if (store === null || store === undefined) {
|
|
982
|
+
_store = null;
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (typeof store !== "object" ||
|
|
986
|
+
typeof store.execute !== "function" ||
|
|
987
|
+
typeof store.executeOne !== "function") {
|
|
988
|
+
throw _err("INVALID_ARG",
|
|
989
|
+
"session.useStore: store must expose execute(sql,params) and executeOne(sql,params)", true);
|
|
990
|
+
}
|
|
991
|
+
_store = store;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* @primitive b.session.isAnonymous
|
|
996
|
+
* @signature b.session.isAnonymous(userId)
|
|
997
|
+
* @since 0.8.62
|
|
998
|
+
* @status stable
|
|
999
|
+
* @related b.session.create
|
|
1000
|
+
*
|
|
1001
|
+
* Returns `true` if the supplied userId was minted by
|
|
1002
|
+
* `b.session.create({ anonymous: true })` (i.e., starts with the
|
|
1003
|
+
* `anon:` prefix). Operators use this to gate post-auth behavior
|
|
1004
|
+
* (e.g., refuse a payment confirmation when the session is still
|
|
1005
|
+
* anonymous, or render the "log in to continue" banner).
|
|
1006
|
+
*
|
|
1007
|
+
* @example
|
|
1008
|
+
* var info = await b.session.verify(req.cookies.sid);
|
|
1009
|
+
* if (info && b.session.isAnonymous(info.userId)) {
|
|
1010
|
+
* res.statusCode = 401; res.end("login required"); return;
|
|
1011
|
+
* }
|
|
1012
|
+
*/
|
|
1013
|
+
function isAnonymous(userId) {
|
|
1014
|
+
return _isAnonymousUserId(userId);
|
|
1015
|
+
}
|
|
733
1016
|
|
|
734
1017
|
module.exports = {
|
|
735
1018
|
create: create,
|
|
@@ -740,6 +1023,10 @@ module.exports = {
|
|
|
740
1023
|
rotate: rotate,
|
|
741
1024
|
purgeExpired: purgeExpired,
|
|
742
1025
|
count: count,
|
|
1026
|
+
useStore: useStore,
|
|
1027
|
+
isAnonymous: isAnonymous,
|
|
1028
|
+
stores: require("./session-stores"), // allow:inline-require — session-stores depends on local-db-thin which requires audit lazily; eager require is fine here
|
|
743
1029
|
DEFAULT_TTL_MS: DEFAULT_TTL_MS,
|
|
1030
|
+
ANON_PREFIX: ANON_PREFIX,
|
|
744
1031
|
_resetForTest: _resetForTest,
|
|
745
1032
|
};
|
package/lib/validate-opts.js
CHANGED
|
@@ -308,6 +308,46 @@ function makeAuditEmitter(audit) {
|
|
|
308
308
|
};
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// makeNamespacedEmitters — collapses the per-primitive
|
|
312
|
+
// function _emitAudit(action, outcome, metadata) { audit.safeEmit({...}) }
|
|
313
|
+
// function _emitMetric(verb) { observability.safeEvent(...) }
|
|
314
|
+
// boilerplate into one helper. Every primitive that emits both audit
|
|
315
|
+
// events AND observability metrics under a fixed prefix shares the
|
|
316
|
+
// same shape; pre-v0.8.62 this was inlined in 13+ lib/auth files.
|
|
317
|
+
//
|
|
318
|
+
// var emit = validateOpts.makeNamespacedEmitters("auth.ciba", { audit, observability });
|
|
319
|
+
// emit.audit("token_received", "success", { hash: ... });
|
|
320
|
+
// emit.metric("token-received");
|
|
321
|
+
//
|
|
322
|
+
// The audit/observability arguments are lazyRequire-resolved at the
|
|
323
|
+
// call site so the helper itself adds no module-load coupling.
|
|
324
|
+
function makeNamespacedEmitters(prefix, deps) {
|
|
325
|
+
if (typeof prefix !== "string" || prefix.length === 0) {
|
|
326
|
+
throw new Error("makeNamespacedEmitters: prefix must be a non-empty string");
|
|
327
|
+
}
|
|
328
|
+
deps = deps || {};
|
|
329
|
+
function audit(action, outcome, metadata) {
|
|
330
|
+
var auditMod = deps.audit;
|
|
331
|
+
if (typeof auditMod === "function") auditMod = auditMod();
|
|
332
|
+
if (!auditMod || typeof auditMod.safeEmit !== "function") return;
|
|
333
|
+
try {
|
|
334
|
+
auditMod.safeEmit({
|
|
335
|
+
action: prefix + "." + action,
|
|
336
|
+
outcome: outcome,
|
|
337
|
+
metadata: metadata || {},
|
|
338
|
+
});
|
|
339
|
+
} catch (_e) { /* audit best-effort */ }
|
|
340
|
+
}
|
|
341
|
+
function metric(verb, value, attrs) {
|
|
342
|
+
var obsMod = deps.observability;
|
|
343
|
+
if (typeof obsMod === "function") obsMod = obsMod();
|
|
344
|
+
if (!obsMod || typeof obsMod.safeEvent !== "function") return;
|
|
345
|
+
try { obsMod.safeEvent(prefix + "." + verb, value || 1, attrs || {}); }
|
|
346
|
+
catch (_e) { /* observability best-effort */ }
|
|
347
|
+
}
|
|
348
|
+
return { audit: audit, metric: metric };
|
|
349
|
+
}
|
|
350
|
+
|
|
311
351
|
// observabilityShape — operator-supplied `opts.observability` must
|
|
312
352
|
// expose an `event` function. Parallel to auditShape; the n=1 catalog
|
|
313
353
|
// tracks both inline-shape regexes.
|
|
@@ -338,3 +378,4 @@ module.exports.observabilityShape = observabilityShape;
|
|
|
338
378
|
module.exports.requireObject = requireObject;
|
|
339
379
|
module.exports.applyDefaults = applyDefaults;
|
|
340
380
|
module.exports.makeAuditEmitter = makeAuditEmitter;
|
|
381
|
+
module.exports.makeNamespacedEmitters = makeNamespacedEmitters;
|