@blamejs/core 0.15.64 → 0.15.65
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 +2 -0
- package/lib/auth/ato-kill-switch.js +46 -11
- package/lib/auth/lockout.js +140 -14
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.65 (2026-06-29) — **The account-takeover kill-switch now actually locks the account: b.auth.lockout gains a lock() method and the kill-switch engages it through the operator's lockout instance.** b.auth.atoKillSwitch.trigger is the incident-response path for a compromised account: it destroys the user's sessions and then locks them out of new logins. The lockout step called b.auth.lockout.lock(), but no such method existed (and the kill-switch invoked it on the lockout module, which has no store), so the call threw and was swallowed — the kill-switch reported success while never locking the account, leaving an attacker who still held the credentials free to log straight back in. b.auth.lockout instances now expose lock(key, { durationMs?, untilMs?, reason? }) to force an account into lockout immediately, atomically, independent of the failure counter, and atoKillSwitch.trigger engages it through the lockout instance the operator passes as opts.lockout. When no lockout instance is supplied the step is skipped and the result's lockoutApplied is false (with an audit row), rather than silently claiming the account was locked. The release also closes a lock-clear race in lockout and refuses a self-disabling window. **Added:** *b.auth.lockout instances expose lock()* — lock(key, { durationMs?, untilMs?, reason? }) forces an account into lockout immediately — the operator action behind an ATO kill-switch or incident response — independent of the failure counter, defaulting to a long admin lock. A forced lock is released only by an explicit unlock(): recordSuccess() does not clear it, so a successful login by someone who still holds the compromised password cannot release a kill-switch lock. It uses the same atomic compare-and-set as the failure counter and throws if the lock cannot be committed (the caller must know it did not lock). **Security:** *Account-takeover kill-switch engages the lockout instead of silently doing nothing* — b.auth.atoKillSwitch.trigger invoked b.auth.lockout.lock(), which did not exist; the call threw and was caught by a best-effort guard, so the kill-switch destroyed sessions but never locked the account — an attacker still holding valid credentials could immediately re-authenticate. b.auth.lockout instances now provide lock() (an atomic, compare-and-set forced lockout), and the kill-switch calls it on the lockout instance supplied as opts.lockout. Without a lockout instance the lockout step cannot run; the result's lockoutApplied is false and an audit row records that the account was not locked, so an operator is not misled into believing a compromised account was secured. opts.lockout is now the lockout instance (or false to skip), not a boolean toggle. · *lockout clears the failure counter atomically and refuses a zero window* — b.auth.lockout.recordSuccess and unlock cleared state with a read-then-delete, which could erase a lock a concurrent recordFailure had just engaged; they now clear under the same compare-and-set the failure path uses. create() also refuses windowMs: 0, which previously disabled lockout entirely (every failure decayed immediately and the zero-TTL state never persisted).
|
|
12
|
+
|
|
11
13
|
- v0.15.64 (2026-06-29) — **Token and HTTP-message-signature verifiers now reject a non-finite clock-skew or tolerance value instead of letting it disable the expiry, not-before, and future-dating checks.** Several verifiers read an operator-supplied clock-skew or tolerance value with a bare type check that accepted Infinity and NaN. Because the resulting comparison (for example exp + skew < now, or age > tolerance) is always false when the skew or tolerance is Infinity, a misconfigured non-finite value silently disabled the time-window check — so an expired token, a not-yet-valid token, a future-dated signature, or a replayed (too-old) signature would verify. The affected paths are the shared external-JWT verifier b.auth.jwt.verifyExternal (which the JAR / signed-request-object path also uses), the SD-JWT-VC credential verifier, the OAuth ID-token and client-attestation verifiers, and the HTTP message signature verifier's freshness and clock-skew windows. Each now requires a present skew or tolerance to be a non-negative finite number: the throw-based JWT, SD-JWT-VC, and OAuth verifiers reject a malformed value, and the verdict-returning HTTP-message-signature verifier falls back to its safe default rather than disabling the window. A build check now fails if any verifier reads one of these options without a finiteness guard. **Added:** *verifyExternal and jar.parse accept an `now` clock override* — b.auth.jwt.verifyExternal and b.auth.jar.parse now accept an optional `now` (a finite epoch-milliseconds value) to evaluate a token's exp / nbf / iat as of a specific instant instead of the wall clock — consistent with b.auth.sdJwtVc.verify. A negative clockSkewMs is no longer accepted as a way to shift the evaluation time; use `now`. **Security:** *Non-finite clock-skew / tolerance no longer disables a verifier's time-window check* — b.auth.jwt.verifyExternal, b.auth.sdJwtVc.verify, b.auth.oauth (ID-token and client-attestation verification), and the HTTP message signature verifier read their clock-skew, max-clock-skew, max-PoP-age, and tolerance options with a bare `typeof === "number"` check that let Infinity and NaN through. With such a value the expiry / not-before / future-dating comparison is always false, so the check was silently skipped and an expired or not-yet-valid token — or a future-dated or replayed signature — would verify. The JWT, SD-JWT-VC, and OAuth verifiers now reject a non-finite or negative skew (the option is operator configuration, not attacker-controlled), and the HTTP message signature verifier falls back to its default tolerance and skew. The CWT, DPoP, SAML, and OpenID Federation verifiers already enforced this and are unchanged. **Detectors:** *Verifier clock-skew / tolerance options must be finite-guarded* — A build check fails if a verifier reads a clock-skew or tolerance option with a bare `typeof === "number"` without a finiteness guard (a non-negative-finite check, an inline isFinite fallback, or a create-time non-negative-finite schema), preventing a future verifier from reintroducing the disable-the-window class.
|
|
12
14
|
|
|
13
15
|
- v0.15.63 (2026-06-29) — **OID4VCI now enforces single-use of a pre-authorized code and of a single-use access token under concurrency, so two simultaneous requests can no longer mint two credentials from one.** On the OID4VCI credential issuer, two single-use values were consumed by a delete whose result was ignored, so concurrent requests could each act on the same value. exchangePreAuthorizedCode read the pre-authorized code's entry, validated the transaction code, then deleted the code and minted an access token without checking that its own call had removed the entry — two simultaneous /token requests with the same code each saw the entry, each deleted it, and each minted a distinct access token, issuing two access tokens (and ultimately two credentials) from a code OID4VCI requires to be single-use. issueCredential had the same shape: with single-use access tokens (the default), it minted the credential first and deleted the access token afterward as best-effort cleanup, so two concurrent requests bearing the same token both read it and the same not-yet-rotated c_nonce, both proofs verified, and both minted a credential. Both paths now claim the value with an atomic delete and proceed only when that delete succeeded; the losing request is refused. The transaction-code and proof checks still run first, so a bad transaction code or proof does not consume the value (a wallet can retry). **Security:** *OID4VCI pre-authorized code and access token are single-use under concurrency* — exchangePreAuthorizedCode and issueCredential consumed their single-use value (the pre-authorized code, and the single-use access token) with a delete whose return was discarded, and in issueCredential's case after the credential was already minted. Two concurrent requests carrying the same value therefore each succeeded — minting two access tokens from one pre-authorized code, or two credentials from one single-use access token — defeating the single-use guarantee OID4VCI §3.5 requires (an authorization intended for one credential could yield two). Both paths now delete the value atomically and issue only if that delete removed it, refusing the request that lost the race; the transaction-code and proof verifications run before the claim, so an invalid attempt does not burn the value. If the operator's credential issuer throws after the access token has been claimed (a transient signer outage), the token is restored so the wallet can retry rather than being permanently consumed without a credential.
|
|
@@ -10,17 +10,22 @@
|
|
|
10
10
|
* cleanup path once the trigger fires:
|
|
11
11
|
*
|
|
12
12
|
* 1. destroy every session for the user across the cluster
|
|
13
|
-
* 2. lock the user out of new logins
|
|
13
|
+
* 2. lock the user out of new logins via the supplied lockout instance
|
|
14
14
|
* 3. emit an audit row with reason / actor for downstream forensics
|
|
15
15
|
*
|
|
16
16
|
* await b.auth.atoKillSwitch.trigger({
|
|
17
17
|
* userId: "u_42",
|
|
18
18
|
* reason: "fraud-signal: 14 failed MFA from new geo",
|
|
19
19
|
* actor: { id: req.user && req.user.id, role: req.user && req.user.role },
|
|
20
|
-
* lockout:
|
|
20
|
+
* lockout: appLockout, // a b.auth.lockout instance (or false to skip)
|
|
21
21
|
* accessLock: "locked", // optional — flip the global access-lock mode
|
|
22
22
|
* });
|
|
23
23
|
*
|
|
24
|
+
* The lockout step needs the operator's configured b.auth.lockout instance
|
|
25
|
+
* (there is no module-level lockout store); pass it as `opts.lockout`. When it
|
|
26
|
+
* is omitted, the lockout step is skipped and the returned `lockoutApplied` is
|
|
27
|
+
* false (with an audit row) — the account is NOT locked.
|
|
28
|
+
*
|
|
24
29
|
* Returns `{ sessionsDestroyed, lockoutApplied, accessLockMode }`.
|
|
25
30
|
*/
|
|
26
31
|
|
|
@@ -29,7 +34,6 @@ var validateOpts = require("../validate-opts");
|
|
|
29
34
|
var { defineClass } = require("../framework-error");
|
|
30
35
|
|
|
31
36
|
var session = lazyRequire(function () { return require("../session"); });
|
|
32
|
-
var lockout = lazyRequire(function () { return require("./lockout"); });
|
|
33
37
|
var accessLock = lazyRequire(function () { return require("./access-lock"); });
|
|
34
38
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
35
39
|
|
|
@@ -43,8 +47,15 @@ async function trigger(opts) {
|
|
|
43
47
|
|
|
44
48
|
validateOpts.requireNonEmptyString(opts.userId, "userId", AtoKillSwitchError, "auth-ato-kill-switch/missing-user-id");
|
|
45
49
|
validateOpts.requireNonEmptyString(opts.reason, "reason", AtoKillSwitchError, "auth-ato-kill-switch/missing-reason");
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// opts.lockout is the operator's configured b.auth.lockout INSTANCE to lock
|
|
51
|
+
// the account in (or false to skip the lockout step). The kill-switch needs
|
|
52
|
+
// that instance's store to engage the lock — there is no module-level lockout
|
|
53
|
+
// singleton — so without an instance the lockout step cannot run and is
|
|
54
|
+
// reported (rather than silently claiming success while never locking).
|
|
55
|
+
var skipLockout = opts.lockout === false;
|
|
56
|
+
var lockoutInst = (opts.lockout && typeof opts.lockout === "object" &&
|
|
57
|
+
typeof opts.lockout.lock === "function") ? opts.lockout : null;
|
|
58
|
+
var accessLockMode = typeof opts.accessLock === "string" ? opts.accessLock : null;
|
|
48
59
|
|
|
49
60
|
var sessionsDestroyed = 0;
|
|
50
61
|
try {
|
|
@@ -63,13 +74,37 @@ async function trigger(opts) {
|
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
var lockoutApplied = false;
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
if (!skipLockout) {
|
|
78
|
+
if (lockoutInst) {
|
|
79
|
+
try {
|
|
80
|
+
await lockoutInst.lock(opts.userId, {
|
|
81
|
+
reason: "ato-kill-switch:" + opts.reason,
|
|
82
|
+
});
|
|
83
|
+
lockoutApplied = true;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// The lockout step failed (e.g. a cache outage) AFTER sessions were
|
|
86
|
+
// destroyed. Don't fail the whole kill-switch — but surface it so the
|
|
87
|
+
// operator knows the account was NOT locked, rather than swallowing.
|
|
88
|
+
audit().safeEmit({
|
|
89
|
+
action: "auth.ato_kill_switch.partial",
|
|
90
|
+
outcome: "failure",
|
|
91
|
+
metadata: { userId: opts.userId, step: "lockout", reason: e && e.message },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// No lockout instance supplied: the kill-switch cannot lock the account.
|
|
96
|
+
// Report it (the result's lockoutApplied stays false) so the operator
|
|
97
|
+
// doesn't believe the user was locked out of new logins.
|
|
98
|
+
audit().safeEmit({
|
|
99
|
+
action: "auth.ato_kill_switch.partial",
|
|
100
|
+
outcome: "failure",
|
|
101
|
+
metadata: {
|
|
102
|
+
userId: opts.userId,
|
|
103
|
+
step: "lockout",
|
|
104
|
+
reason: "no lockout instance supplied (pass opts.lockout = b.auth.lockout.create({ cache, namespace }))",
|
|
105
|
+
},
|
|
70
106
|
});
|
|
71
|
-
|
|
72
|
-
} catch (_e) { /* lockout is best-effort; sessions already destroyed */ }
|
|
107
|
+
}
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
var modeApplied = null;
|
package/lib/auth/lockout.js
CHANGED
|
@@ -90,6 +90,11 @@ var observability = lazyRequire(function () { return require("../observability")
|
|
|
90
90
|
|
|
91
91
|
var _err = LockoutError.factory;
|
|
92
92
|
|
|
93
|
+
// Default duration for an operator-forced lock() (ATO kill-switch / incident
|
|
94
|
+
// response) when neither untilMs nor durationMs is supplied — long enough to
|
|
95
|
+
// require an explicit admin unlock() during an active incident.
|
|
96
|
+
var DEFAULT_ADMIN_LOCK_MS = C.TIME.hours(24);
|
|
97
|
+
|
|
93
98
|
var DEFAULTS = Object.freeze({
|
|
94
99
|
maxAttempts: 5,
|
|
95
100
|
windowMs: C.TIME.minutes(15),
|
|
@@ -173,7 +178,11 @@ function create(opts) {
|
|
|
173
178
|
_requirePositiveInt("maxAttempts", maxAttempts);
|
|
174
179
|
|
|
175
180
|
var windowMs = opts.windowMs !== undefined ? opts.windowMs : DEFAULTS.windowMs;
|
|
176
|
-
|
|
181
|
+
// windowMs must be POSITIVE: a 0 window makes every failure "decay" on the
|
|
182
|
+
// next request (the `now - lastFailureAt > windowMs` reset fires for any
|
|
183
|
+
// elapsed time) so the counter never reaches maxAttempts, AND the cache TTL
|
|
184
|
+
// of 0 makes the state non-persistent — together silently disabling lockout.
|
|
185
|
+
_requirePositiveInt("windowMs", windowMs);
|
|
177
186
|
|
|
178
187
|
var lockoutDurations = opts.lockoutDurations !== undefined
|
|
179
188
|
? opts.lockoutDurations : DEFAULTS.lockoutDurations;
|
|
@@ -242,6 +251,49 @@ function create(opts) {
|
|
|
242
251
|
}
|
|
243
252
|
}
|
|
244
253
|
|
|
254
|
+
// Atomically clear the lockout state under a compare-and-set, so a clear
|
|
255
|
+
// (recordSuccess / unlock) cannot race a concurrent recordFailure that just
|
|
256
|
+
// engaged a lock — a read-then-del would erase that fresh lock. onState is
|
|
257
|
+
// invoked with the pre-clear state inside the CAS mutator (it may re-run on a
|
|
258
|
+
// retry; the last invocation reflects the committed state) to capture audit
|
|
259
|
+
// detail. On a backend that can't do an atomic update at runtime it falls
|
|
260
|
+
// back to read-then-del (a lost clear leaves a lock in place — fail-safe).
|
|
261
|
+
// preserveIf(state) → true to KEEP the state (abort the clear) rather than
|
|
262
|
+
// delete it. recordSuccess passes a predicate that preserves an active forced
|
|
263
|
+
// (admin / ATO kill-switch) lock, so a successful login by someone who still
|
|
264
|
+
// holds the compromised password cannot clear it — only an explicit unlock()
|
|
265
|
+
// releases a forced lock.
|
|
266
|
+
async function _atomicClear(key, onState, preserveIf) {
|
|
267
|
+
try {
|
|
268
|
+
await cache.update(_scopedKey(key), function (state) {
|
|
269
|
+
state = state || null;
|
|
270
|
+
onState(state);
|
|
271
|
+
if (!state) return { abort: true };
|
|
272
|
+
if (preserveIf && preserveIf(state)) return { abort: true };
|
|
273
|
+
return { delete: true };
|
|
274
|
+
}, { ttlMs: windowMs });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
if (e && e.code === "UNSUPPORTED") {
|
|
277
|
+
var st = await _readState(key);
|
|
278
|
+
onState(st);
|
|
279
|
+
if (st && !(preserveIf && preserveIf(st))) await _deleteState(key);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
_signalCacheError("update");
|
|
283
|
+
// Best-effort fallback so a transient cache error doesn't leave the
|
|
284
|
+
// counter un-cleared on the happy path.
|
|
285
|
+
var st2 = await _readState(key);
|
|
286
|
+
onState(st2);
|
|
287
|
+
if (st2 && !(preserveIf && preserveIf(st2))) await _deleteState(key);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// An active forced/admin lock (set by lock()) must survive recordSuccess.
|
|
292
|
+
function _isActiveForcedLock(state) {
|
|
293
|
+
return !!(state && state.forced === true &&
|
|
294
|
+
typeof state.lockedUntil === "number" && state.lockedUntil > clock());
|
|
295
|
+
}
|
|
296
|
+
|
|
245
297
|
function _verdictFromState(state, now) {
|
|
246
298
|
if (!state) return { locked: false, attempts: 0 };
|
|
247
299
|
if (state.lockedUntil && state.lockedUntil > now) {
|
|
@@ -380,13 +432,19 @@ function create(opts) {
|
|
|
380
432
|
async function recordSuccess(key, callOpts) {
|
|
381
433
|
_requireKey(key);
|
|
382
434
|
callOpts = callOpts || {};
|
|
383
|
-
var
|
|
384
|
-
var
|
|
385
|
-
|
|
435
|
+
var hadCounter = false;
|
|
436
|
+
var clearedAttempts = 0;
|
|
437
|
+
// Preserve an active forced (admin / ATO) lock: a successful credential
|
|
438
|
+
// verification must not release a kill-switch lock — the password may still
|
|
439
|
+
// be the compromised one. A forced lock yields only to unlock().
|
|
440
|
+
await _atomicClear(key, function (state) {
|
|
441
|
+
hadCounter = !!(state && (state.attempts > 0 || state.lockedUntil));
|
|
442
|
+
clearedAttempts = (state && state.attempts) || 0;
|
|
443
|
+
}, _isActiveForcedLock);
|
|
386
444
|
_emitObs("auth.lockout.success", { namespace: namespace });
|
|
387
445
|
if (auditSuccess) {
|
|
388
446
|
_emitAudit("auth.lockout.success", key, "success",
|
|
389
|
-
{ attemptsCleared:
|
|
447
|
+
{ attemptsCleared: clearedAttempts,
|
|
390
448
|
hadCounter: hadCounter },
|
|
391
449
|
callOpts.req);
|
|
392
450
|
}
|
|
@@ -401,26 +459,93 @@ function create(opts) {
|
|
|
401
459
|
async function unlock(key, callOpts) {
|
|
402
460
|
_requireKey(key);
|
|
403
461
|
callOpts = callOpts || {};
|
|
404
|
-
var state = await _readState(key);
|
|
405
462
|
var now = clock();
|
|
406
|
-
var hadLock =
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
463
|
+
var hadLock = false;
|
|
464
|
+
var prior = { attempts: 0, lockedUntil: null, lockNumber: 0 };
|
|
465
|
+
await _atomicClear(key, function (state) {
|
|
466
|
+
hadLock = !!(state && (
|
|
467
|
+
(state.lockedUntil && state.lockedUntil > now) ||
|
|
468
|
+
(state.attempts || 0) > 0
|
|
469
|
+
));
|
|
470
|
+
prior = {
|
|
471
|
+
attempts: (state && state.attempts) || 0,
|
|
472
|
+
lockedUntil: (state && state.lockedUntil) || null,
|
|
473
|
+
lockNumber: (state && state.lockNumber) || 0,
|
|
474
|
+
};
|
|
475
|
+
});
|
|
411
476
|
_emitObs("auth.lockout.unlock", { namespace: namespace });
|
|
412
477
|
if (auditUnlock) {
|
|
413
478
|
_emitAudit("auth.lockout.unlock", key, "success",
|
|
414
479
|
{ hadLock: hadLock,
|
|
415
|
-
priorAttempts:
|
|
416
|
-
priorLockedUntil:
|
|
417
|
-
priorLockNumber:
|
|
480
|
+
priorAttempts: prior.attempts,
|
|
481
|
+
priorLockedUntil: prior.lockedUntil,
|
|
482
|
+
priorLockNumber: prior.lockNumber,
|
|
418
483
|
reason: callOpts.reason || null },
|
|
419
484
|
callOpts.req);
|
|
420
485
|
}
|
|
421
486
|
return hadLock;
|
|
422
487
|
}
|
|
423
488
|
|
|
489
|
+
// Force an account into lockout immediately — the operator action behind an
|
|
490
|
+
// ATO kill-switch / incident response, independent of the failure counter.
|
|
491
|
+
// Sets lockedUntil to `untilMs` (absolute) or now+`durationMs`, defaulting to
|
|
492
|
+
// a long admin lock; bumps lockNumber so a subsequent failure ladders from
|
|
493
|
+
// here. Uses the same compare-and-set as recordFailure (atomic, retried). An
|
|
494
|
+
// admin lock that cannot be committed THROWS (the caller — e.g. the kill-
|
|
495
|
+
// switch — must know it did not lock), rather than the hot-path fail-open.
|
|
496
|
+
async function lock(key, callOpts) {
|
|
497
|
+
_requireKey(key);
|
|
498
|
+
callOpts = callOpts || {};
|
|
499
|
+
var now = clock();
|
|
500
|
+
var lockedUntil;
|
|
501
|
+
if (typeof callOpts.untilMs === "number" && isFinite(callOpts.untilMs)) {
|
|
502
|
+
lockedUntil = callOpts.untilMs;
|
|
503
|
+
} else if (typeof callOpts.durationMs === "number" && isFinite(callOpts.durationMs) && callOpts.durationMs > 0) {
|
|
504
|
+
lockedUntil = now + callOpts.durationMs;
|
|
505
|
+
} else {
|
|
506
|
+
lockedUntil = now + DEFAULT_ADMIN_LOCK_MS;
|
|
507
|
+
}
|
|
508
|
+
if (lockedUntil <= now) {
|
|
509
|
+
throw _err("BAD_OPT", "lock: resolved lockedUntil is not in the future " +
|
|
510
|
+
"(untilMs/durationMs) — use unlock() to clear a lock");
|
|
511
|
+
}
|
|
512
|
+
var ttl = lockedUntil - now + windowMs;
|
|
513
|
+
var lockNumber = 0;
|
|
514
|
+
try {
|
|
515
|
+
await cache.update(_scopedKey(key), function (state) {
|
|
516
|
+
lockNumber = ((state && state.lockNumber) || 0) + 1;
|
|
517
|
+
return {
|
|
518
|
+
value: {
|
|
519
|
+
attempts: 0,
|
|
520
|
+
lockNumber: lockNumber,
|
|
521
|
+
firstFailureAt: (state && state.firstFailureAt) || now,
|
|
522
|
+
lastFailureAt: now,
|
|
523
|
+
lockedUntil: lockedUntil,
|
|
524
|
+
// Marks this as an operator-forced lock so recordSuccess won't clear
|
|
525
|
+
// it — it is released only by an explicit unlock().
|
|
526
|
+
forced: true,
|
|
527
|
+
},
|
|
528
|
+
ttlMs: ttl,
|
|
529
|
+
};
|
|
530
|
+
}, { ttlMs: ttl });
|
|
531
|
+
} catch (e) {
|
|
532
|
+
if (e && e.code === "UNSUPPORTED") {
|
|
533
|
+
throw _err("CACHE_NO_ATOMIC_UPDATE",
|
|
534
|
+
"auth.lockout: the cache backend does not support atomic update() — " +
|
|
535
|
+
"lock() cannot enforce the lockout across nodes on a get/set-only backend.");
|
|
536
|
+
}
|
|
537
|
+
throw e;
|
|
538
|
+
}
|
|
539
|
+
_emitObs("auth.lockout.engaged", { namespace: namespace, lockNumber: String(lockNumber) });
|
|
540
|
+
if (auditEngaged) {
|
|
541
|
+
_emitAudit("auth.lockout.engaged", key, "denied",
|
|
542
|
+
{ lockNumber: lockNumber, lockedUntil: lockedUntil, durationMs: lockedUntil - now,
|
|
543
|
+
forced: true, reason: callOpts.reason || null },
|
|
544
|
+
callOpts.req);
|
|
545
|
+
}
|
|
546
|
+
return { locked: true, lockedUntil: lockedUntil, lockNumber: lockNumber };
|
|
547
|
+
}
|
|
548
|
+
|
|
424
549
|
async function attempts(key) {
|
|
425
550
|
_requireKey(key);
|
|
426
551
|
var state = await _readState(key);
|
|
@@ -437,6 +562,7 @@ function create(opts) {
|
|
|
437
562
|
recordFailure: recordFailure,
|
|
438
563
|
recordSuccess: recordSuccess,
|
|
439
564
|
check: check,
|
|
565
|
+
lock: lock,
|
|
440
566
|
unlock: unlock,
|
|
441
567
|
attempts: attempts,
|
|
442
568
|
close: close,
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:107d84f4-f93e-4798-846a-d948f93d854d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-29T23:25:12.730Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.65",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.65",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.65",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.65",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|