@astram/guard 0.8.0 → 0.10.0

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.
@@ -118,6 +118,21 @@ function genericUnauthorized() {
118
118
  function retryableUnavailable() {
119
119
  throw new common_1.HttpException(RETRYABLE_BODY, common_1.HttpStatus.SERVICE_UNAVAILABLE);
120
120
  }
121
+ /**
122
+ * Phase 9 #10 — count this attempt BEFORE verifying (atomic increment-first). Returns the
123
+ * verdict, or `null` when no limiter is wired. A limiter that cannot answer throws the
124
+ * retryable 503: the gate fails CLOSED, never lets an unthrottled guess through.
125
+ */
126
+ async function consumeOrUnavailable(limiter, key) {
127
+ if (limiter == null)
128
+ return null;
129
+ try {
130
+ return await limiter.consume(key);
131
+ }
132
+ catch {
133
+ retryableUnavailable();
134
+ }
135
+ }
121
136
  /**
122
137
  * The generic 429 for a rate-limited RESET-password (feature #141). Reset is
123
138
  * token-gated (a 256-bit single-use token), NOT enumeration-sensitive, so surfacing
@@ -510,15 +525,26 @@ let AuthController = class AuthController {
510
525
  // Rate-limit gate (spec §5 / H1 + #5): after M failed attempts within the window —
511
526
  // for this ACCOUNT, or from this client IP when per-IP limiting is on — reject BEFORE
512
527
  // consulting the authenticator, with the SAME generic 401 as a wrong password
513
- // (anti-enumeration: a locked-out account is indistinguishable). Each limiter fails
514
- // OPEN on a Redis blip (its own posture — never a lockout from an outage).
528
+ // (anti-enumeration: a locked-out account is indistinguishable).
529
+ //
530
+ // Phase 9 #10: INCREMENT-FIRST. Each limiter counts THIS attempt atomically before the
531
+ // password is checked (`consume`), so N parallel guesses get N distinct counts and at
532
+ // most `maxAttempts` of them reach the authenticator. An attempt that turns out not to
533
+ // be a failure is given back (`refund`) or cleared (`reset`) below, so the counters
534
+ // keep their meaning (failures only; the IP counter is never reset by a success). A
535
+ // Redis error on the gate fails CLOSED (503) — before, it failed open (KI #40).
515
536
  //
516
537
  // Per-IP first (cheap, no DB): a locked IP never reaches the account lookup.
517
538
  const ipLimiter = this.perIp(this.loginIpLimiter);
518
539
  const ipKey = (0, canonical_hint_js_1.loginIpKey)(ip);
519
- if (ipLimiter != null && !(await ipLimiter.check(ipKey))) {
540
+ const ipVerdict = await consumeOrUnavailable(ipLimiter, ipKey);
541
+ if (ipVerdict !== null && !ipVerdict.allowed)
520
542
  genericUnauthorized();
521
- }
543
+ /** Give back this attempt's IP count (it was not a password failure). */
544
+ const refundIp = async () => {
545
+ if (ipVerdict !== null)
546
+ await ipLimiter?.refund(ipKey);
547
+ };
522
548
  // F1: key the per-account budget on the account the store resolves for this hint
523
549
  // (`login:pid:<id>`; unknown → canonical-hash fallback). A store ERROR fails CLOSED
524
550
  // (503), consistent with the authenticator's treatment of a resolver outage.
@@ -527,22 +553,44 @@ let AuthController = class AuthController {
527
553
  account = await this.lookupAccount(identityHint);
528
554
  }
529
555
  catch {
556
+ await refundIp();
530
557
  retryableUnavailable();
531
558
  }
532
559
  const accountId = (0, canonical_hint_js_1.principalIdOf)(account);
533
560
  const loginKey = (0, canonical_hint_js_1.loginHintKey)(identityHint, accountId);
534
- const hintAllowed = this.loginLimiter == null || (await this.loginLimiter.check(loginKey));
535
- if (!hintAllowed) {
536
- // Already locked → generic 401. We do NOT audit here: `check()` returns false on
537
- // EVERY attempt once locked, so auditing at this gate would flood one event per
538
- // attempt. The lockout is audited ONCE on the TRANSITION instead — in the
539
- // wrong-password branch below, when recordFailure's count first hits the threshold
540
- // (feature #140 audit-on-the-edge, anti-flood).
561
+ let hintVerdict;
562
+ try {
563
+ hintVerdict = await consumeOrUnavailable(this.loginLimiter, loginKey);
564
+ }
565
+ catch (err) {
566
+ await refundIp();
567
+ throw err;
568
+ }
569
+ if (hintVerdict !== null && !hintVerdict.allowed) {
570
+ // Already locked → generic 401. We do NOT audit here: every attempt past the limit
571
+ // lands here, so auditing at this gate would flood one event per attempt. The lockout
572
+ // is audited ONCE on the TRANSITION instead — in the wrong-password branch below,
573
+ // when the attempt's count equals the threshold (feature #140 audit-on-the-edge).
574
+ // An attempt refused by the account lock is not an IP failure (as before #10).
575
+ await refundIp();
541
576
  genericUnauthorized();
542
577
  }
543
- const result = await this.authenticator.authenticate(identityHint, [
544
- passwordCredential(password),
545
- ]);
578
+ let result;
579
+ try {
580
+ result = await this.authenticator.authenticate(identityHint, [passwordCredential(password)]);
581
+ }
582
+ catch (err) {
583
+ // No verdict: give both attempts back, then surface the error.
584
+ await this.loginLimiter?.refund(loginKey);
585
+ await refundIp();
586
+ throw err;
587
+ }
588
+ if (result.outcome !== 'rejected') {
589
+ // Not a password failure (authenticated / mfa_required): the IP counter counts
590
+ // failures only, and a success must never reset it (so an attacker's own valid login
591
+ // cannot wipe the budget) — give back just this attempt.
592
+ await refundIp();
593
+ }
546
594
  if (result.outcome === 'authenticated') {
547
595
  // Correct password → clear the login counter (do not carry earlier failures).
548
596
  await this.loginLimiter?.reset(loginKey);
@@ -556,7 +604,9 @@ let AuthController = class AuthController {
556
604
  if (result.outcome === 'mfa_required') {
557
605
  // Password verified, a second factor is outstanding. NOT a failure — but NOT a
558
606
  // success either (#9): the login counter is reset only once `/auth/mfa` completes,
559
- // so a stolen password cannot loop login→mfa to guess TOTP codes for free.
607
+ // so a stolen password cannot loop login→mfa to guess TOTP codes for free. This
608
+ // attempt's own count is given back (Phase 9 #10), leaving earlier failures intact.
609
+ await this.loginLimiter?.refund(loginKey);
560
610
  //
561
611
  // M6: record the password step's SUCCESS — the resolved principal, the typed hint,
562
612
  // and a fingerprint of the credential it was checked against — NEVER the password.
@@ -584,33 +634,33 @@ let AuthController = class AuthController {
584
634
  // else is the generic 401.
585
635
  if (result.reason === REASON_AUTH_UNAVAILABLE) {
586
636
  // A transient outage is NOT a credential failure — do NOT count it toward the
587
- // lockout (avoids a Redis/DB blip storm from locking out everyone).
637
+ // lockout (avoids a Redis/DB blip storm from locking out everyone): give both
638
+ // attempts back.
639
+ await this.loginLimiter?.refund(loginKey);
640
+ await refundIp();
588
641
  retryableUnavailable();
589
642
  }
590
- // A genuine wrong-password deny → count it toward the lockout window. recordFailure
591
- // returns the NEW count; when it first equals the limiter's threshold this attempt
592
- // is the one that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here
593
- // (the edge), never on the already-locked check-gate above (which would flood).
643
+ // A genuine wrong-password deny: the attempt was already counted by `consume` and
644
+ // stays counted. When its count equals the limiter's threshold this attempt is the one
645
+ // that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here (the edge),
646
+ // never on the already-locked gate above (which would flood).
594
647
  // NO notify: this is pre-auth (the caller has not proven ownership), and mailing the
595
648
  // account on every lockout would let anyone email-bomb it / confirm it exists
596
649
  // (recordSecurityEvent enforces this too).
597
- if (this.loginLimiter != null) {
598
- const count = await this.loginLimiter.recordFailure(loginKey);
599
- if (count === this.loginLimiter.maxAttempts) {
600
- this.recordSecurityEvent({
601
- action: 'auth.login.locked',
602
- identityHint,
603
- attributes: { reason: 'rate_limit' },
604
- notify: false,
605
- });
606
- }
650
+ if (this.loginLimiter != null && hintVerdict?.count === this.loginLimiter.maxAttempts) {
651
+ this.recordSecurityEvent({
652
+ action: 'auth.login.locked',
653
+ identityHint,
654
+ attributes: { reason: 'rate_limit' },
655
+ notify: false,
656
+ });
607
657
  }
608
- // #5: count the failure against the client IP too (failures only — a success never
609
- // touches this counter, so an attacker's own valid login cannot reset the budget).
610
- // Audited once on the edge, with no hint (the IP budget spans many accounts).
611
- if (ipLimiter != null) {
612
- const ipCount = await ipLimiter.recordFailure(ipKey);
613
- if (ipCount === ipLimiter.maxAttempts) {
658
+ // #5: the failure also stays counted against the client IP (failures only — a success
659
+ // gives its attempt back and never resets this counter, so an attacker's own valid
660
+ // login cannot wipe the budget). Audited once on the edge, with no hint (the IP budget
661
+ // spans many accounts).
662
+ if (ipLimiter != null && ipVerdict !== null) {
663
+ if (ipVerdict.count === ipLimiter.maxAttempts) {
614
664
  this.recordSecurityEvent({
615
665
  action: 'auth.login.ip_locked',
616
666
  identityHint: null,
@@ -1090,42 +1140,46 @@ let AuthController = class AuthController {
1090
1140
  * {@link MfaStepUp} (same `OrTotpVerifier` + replay guard as the authenticator, principal
1091
1141
  * minted by the identity broker).
1092
1142
  *
1093
- * - `mfa:pid:<principalId>` is checked BEFORE verifying: locked → generic 401 and the
1094
- * step-up is never consulted.
1095
- * - a rejected code records a failure; the edge where the count first reaches the
1096
- * threshold audits `auth.mfa.locked` ONCE (no notify — pre-auth, see recovery lockout).
1097
- * - a transient outage → 503, never counted.
1143
+ * - the attempt is counted against `mfa:pid:<principalId>` BEFORE verifying (Phase 9
1144
+ * #10, atomic increment-first): over budget → generic 401 and the step-up is never
1145
+ * consulted; limiter unreachable → 503 (fail closed).
1146
+ * - a rejected code stays counted; the attempt whose count equals the threshold audits
1147
+ * `auth.mfa.locked` ONCE (no notify — pre-auth, see recovery lockout).
1148
+ * - a transient outage → 503, the attempt is given back (never counted).
1098
1149
  * - success → establish + bind, then reset the TOTP counter AND the login counter (the
1099
1150
  * login counter is reset only here, once authentication fully succeeded).
1100
1151
  */
1101
1152
  async completeViaTotp(pending, code) {
1102
1153
  const mfaKey = `mfa:pid:${pending.principalId}`;
1103
- if (this.mfaLimiter != null && !(await this.mfaLimiter.check(mfaKey))) {
1104
- genericUnauthorized();
1105
- }
1106
1154
  const stepUp = this.mfaStepUp;
1107
1155
  if (stepUp == null)
1108
1156
  genericUnauthorized(); // not wired → fail closed
1157
+ // Phase 9 #10: count the attempt FIRST (atomic), so parallel guesses cannot all pass a
1158
+ // read-only check. Over budget → generic 401; limiter unreachable → 503 (fail closed).
1159
+ const verdict = await consumeOrUnavailable(this.mfaLimiter, mfaKey);
1160
+ if (verdict !== null && !verdict.allowed)
1161
+ genericUnauthorized();
1109
1162
  let outcome;
1110
1163
  try {
1111
1164
  outcome = await stepUp.verify(pending, totpCredential(code));
1112
1165
  }
1113
1166
  catch {
1114
- retryableUnavailable();
1167
+ outcome = { outcome: 'unavailable' };
1115
1168
  }
1116
- if (outcome.outcome === 'unavailable')
1169
+ if (outcome.outcome === 'unavailable') {
1170
+ // A transient outage is not a wrong code: give the attempt back (never counted).
1171
+ await this.mfaLimiter?.refund(mfaKey);
1117
1172
  retryableUnavailable();
1173
+ }
1118
1174
  if (outcome.outcome !== 'authenticated' || outcome.principal.id !== pending.principalId) {
1119
- if (this.mfaLimiter != null) {
1120
- const count = await this.mfaLimiter.recordFailure(mfaKey);
1121
- if (count === this.mfaLimiter.maxAttempts) {
1122
- this.recordSecurityEvent({
1123
- action: 'auth.mfa.locked',
1124
- actorId: pending.principalId,
1125
- attributes: { reason: 'rate_limit' },
1126
- notify: false,
1127
- });
1128
- }
1175
+ // The attempt stays counted; the edge (count === threshold) audits ONCE.
1176
+ if (this.mfaLimiter != null && verdict?.count === this.mfaLimiter.maxAttempts) {
1177
+ this.recordSecurityEvent({
1178
+ action: 'auth.mfa.locked',
1179
+ actorId: pending.principalId,
1180
+ attributes: { reason: 'rate_limit' },
1181
+ notify: false,
1182
+ });
1129
1183
  }
1130
1184
  genericUnauthorized();
1131
1185
  }
@@ -1176,34 +1230,32 @@ let AuthController = class AuthController {
1176
1230
  // consulting the recovery store — the SAME generic 401 as a wrong code (no leak
1177
1231
  // of "rate-limited"; the store is never even touched when locked). Keyed by the
1178
1232
  // RESOLVED principalId (recovery codes don't rotate → a lower ceiling than login).
1179
- // Fails OPEN on a Redis blip (the limiter's own posture — never a lockout).
1233
+ // Phase 9 #10: the attempt is counted FIRST (atomic increment-first), so parallel
1234
+ // guesses cannot all pass a read-only check and reach the Argon2 scan. A Redis error on
1235
+ // the gate fails CLOSED (503).
1180
1236
  const recoveryKey = `recovery:${principalId}`;
1181
- if (this.recoveryLimiter != null && !(await this.recoveryLimiter.check(recoveryKey))) {
1237
+ const verdict = await consumeOrUnavailable(this.recoveryLimiter, recoveryKey);
1238
+ if (verdict !== null && !verdict.allowed)
1182
1239
  genericUnauthorized();
1183
- }
1184
1240
  // Atomic single-use argon2-verify. A throw is swallowed to `false` → generic
1185
1241
  // 401 (never surface a store error as a distinct outcome).
1186
1242
  const ok = await this.recoveryCodes.consume(principalId, code).catch(() => false);
1187
1243
  if (!ok) {
1188
- // A wrong (or unmatched) recovery code → count it toward the lockout window,
1189
- // THEN the identical generic 401. recordFailure returns the new count; when it
1190
- // first equals the threshold this attempt TRIPPED the lockout — audit
1191
- // `auth.mfa.recovery_locked` exactly ONCE on that edge (never on the already-locked
1192
- // check-gate above, which returns false on every attempt = flood).
1244
+ // A wrong (or unmatched) recovery code → the attempt stays counted, THEN the
1245
+ // identical generic 401. When its count equals the threshold this attempt TRIPPED
1246
+ // the lockout — audit `auth.mfa.recovery_locked` exactly ONCE on that edge (never on
1247
+ // the already-locked gate above, which every later attempt hits = flood).
1193
1248
  // NO notify: a locked-out attacker is (by assumption) NOT the account owner, so
1194
1249
  // emailing on every recovery lockout would let an attacker email-bomb the owner —
1195
1250
  // audit-only is safer; the once-per-window edge already dedups it.
1196
- if (this.recoveryLimiter != null) {
1197
- const count = await this.recoveryLimiter.recordFailure(recoveryKey);
1198
- if (count === this.recoveryLimiter.maxAttempts) {
1199
- this.recordSecurityEvent({
1200
- action: 'auth.mfa.recovery_locked',
1201
- actorId: principalId,
1202
- tenantId: typeof claims.tenantId === 'string' ? claims.tenantId : null,
1203
- attributes: { reason: 'rate_limit' },
1204
- notify: false,
1205
- });
1206
- }
1251
+ if (this.recoveryLimiter != null && verdict?.count === this.recoveryLimiter.maxAttempts) {
1252
+ this.recordSecurityEvent({
1253
+ action: 'auth.mfa.recovery_locked',
1254
+ actorId: principalId,
1255
+ tenantId: typeof claims.tenantId === 'string' ? claims.tenantId : null,
1256
+ attributes: { reason: 'rate_limit' },
1257
+ notify: false,
1258
+ });
1207
1259
  }
1208
1260
  genericUnauthorized();
1209
1261
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"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"}
@@ -328,8 +328,8 @@ const pendingMfaStoreProvider = {
328
328
  };
329
329
  /**
330
330
  * The TOTP-step limiter (#9) — 5 failures / 15-min window, keyed `mfa:pid:<principalId>`.
331
- * Checked before the factor is verified; fails open on a Redis outage like every limiter
332
- * (the code itself still gates — and its replay guard fails closed).
331
+ * Each attempt is counted before the factor is verified (Phase 9 #10, increment-first);
332
+ * a Redis outage fails closed (503), like its replay guard.
333
333
  */
334
334
  const mfaLimiterProvider = {
335
335
  provide: tokens_js_1.SHIELD_MFA_LIMITER,
@@ -366,8 +366,9 @@ const authBootLogger = new common_1.Logger('ShieldAuth');
366
366
  * the DB collation maps to one account shares one budget; an unknown hint falls back to
367
367
  * `login:<sha256(canonical hint)>`; no PII in the Redis key). Same
368
368
  * live-Redis provider pattern as {@link sessionTokenBinderProvider} (a `useFactory`
369
- * injecting {@link SHIELD_REDIS}); provided only under `auth: true`. Fail-open on a
370
- * Redis outage is the limiter's own behavior (never locks out on a blip).
369
+ * injecting {@link SHIELD_REDIS}); provided only under `auth: true`. Each attempt is
370
+ * counted before the password is checked (Phase 9 #10, increment-first); a Redis outage
371
+ * fails closed (503) — login could not bind a session then anyway.
371
372
  */
372
373
  const loginLimiterProvider = {
373
374
  provide: tokens_js_1.SHIELD_LOGIN_LIMITER,
@@ -379,8 +380,9 @@ const loginLimiterProvider = {
379
380
  * Redis-injected {@link AttemptLimiter} keyed `login:ip:<req.ip>`, default 50 failures /
380
381
  * 15-min window, configurable through `AppConfig.loginIpLimit` (env
381
382
  * `LOGIN_IP_MAX_FAILURES` / `LOGIN_IP_WINDOW_MS`). Stops password spraying across many
382
- * distinct hints from one client. Failures only; never reset on success. Fails open on
383
- * a Redis outage like every limiter. ENFORCED ONLY when the app set Express `trust proxy`
383
+ * distinct hints from one client. Failures only (a non-failure gives its attempt back);
384
+ * never reset on success. Counted before the password check (Phase 9 #10); a Redis outage
385
+ * fails closed (503). ENFORCED ONLY when the app set Express `trust proxy`
384
386
  * explicitly (0 = no proxy, else the hop count); otherwise {@link TrustProxyBootCheck}
385
387
  * switches it OFF at boot with a warning, because every client behind a proxy would share
386
388
  * one IP and 50 failures would lock everyone out.
@@ -395,7 +397,8 @@ const loginIpLimiterProvider = {
395
397
  * 5 failures / 15-min window, keyed `recovery:<principalId>` (a lower ceiling than
396
398
  * login: recovery codes don't rotate, so they're higher-value). Same live-Redis
397
399
  * provider pattern as {@link sessionTokenBinderProvider}; provided only under
398
- * `auth: true`.
400
+ * `auth: true`. Each attempt is counted before the code is checked (Phase 9 #10); a Redis
401
+ * outage fails closed (503).
399
402
  */
400
403
  const recoveryLimiterProvider = {
401
404
  provide: tokens_js_1.SHIELD_RECOVERY_LIMITER,
@@ -1 +1 @@
1
- {"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../../src/auth/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,MAAM,SAAS,CAAC;AAC5B,OAAO,EAAoC,KAAK,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAcpD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,KAAK,CAc1D;AAED;;;;;;GAMG;AACH,qBACa,mBAAoB,YAAW,eAAe;IAEvD,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK;IAGzB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;CAavC"}
1
+ {"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../../src/auth/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,MAAM,SAAS,CAAC;AAC5B,OAAO,EAAoC,KAAK,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAcpD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,KAAK,CAgB1D;AAED;;;;;;GAMG;AACH,qBACa,mBAAoB,YAAW,eAAe;IAEvD,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK;IAGzB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;CAavC"}
@@ -81,7 +81,9 @@ function createRedisClient(config) {
81
81
  // back instead of failing closed (ADR-0013: "if Redis cannot answer, the request is denied").
82
82
  // Now each command fails after `commandTimeout` (default 2 s, `REDIS_COMMAND_TIMEOUT_MS`) or
83
83
  // after one reconnect attempt; every caller treats a Redis error as its fail-closed outcome
84
- // (401 / 503), except the rate limiters, which fail OPEN by design (never a lockout blip).
84
+ // (401 / 503). The attempt limiters' increment-first gate (`consume`, Phase 9 #10: login,
85
+ // /auth/mfa, change-password, the code-gated account MFA routes) fails closed too; only
86
+ // the read-only `check` (forgot/reset-password) still fails OPEN (never a lockout blip).
85
87
  // Reconnects keep ioredis's default strategy. Nothing in the framework relies on queued
86
88
  // commands (no pub/sub, no blocking reads, no MULTI/pipeline).
87
89
  return new ioredis_1.default(config.redis.url, {
@@ -6,6 +6,14 @@
6
6
  * (default 5/15min, keyed `recovery:<principalId>`).
7
7
  *
8
8
  * ── CONTRACT ─────────────────────────────────────────────────────────────────
9
+ * consume(key) → Phase 9 #10, the gate for credential-verifying routes (login,
10
+ * /auth/mfa, change-password, the code-gated account MFA routes):
11
+ * atomically COUNTS this attempt first and returns
12
+ * `{ allowed: count <= maxAttempts, count }`. Parallel guesses get
13
+ * distinct counts, so at most `maxAttempts` reach the verifier.
14
+ * FAILS CLOSED: a Redis error throws (caller → 503).
15
+ * refund(key) → give back one consumed attempt that reached no verdict
16
+ * (transient outage; a step that is not a failure). Best-effort.
9
17
  * check(key) → `true` if the caller is UNDER the threshold (allowed);
10
18
  * `false` if AT/over it (locked out for the window).
11
19
  * recordFailure(key) → increment the counter; on the FIRST failure in a window,
@@ -21,7 +29,10 @@
21
29
  * reset(key) → clear the counter (a SUCCESS resets — a legitimate user is
22
30
  * never penalized for their earlier fat-fingers).
23
31
  *
24
- * ── FAIL-OPEN-WITH-LOG (spec §5, the deliberate posture) ──────────────────────
32
+ * ── FAIL-OPEN-WITH-LOG (spec §5) — the read-only `check()` path only ──────────
33
+ * (`consume()` fails CLOSED instead — Phase 9 #10; the routes that use it also need
34
+ * Redis to bind or resolve a session, so failing closed locks no one out who could
35
+ * otherwise have signed in.) `check()` remains for forgot/reset-password, where:
25
36
  * A limiter differs from the credential path: a Redis outage must NOT lock out
26
37
  * every legitimate login/recovery. So `check()` FAILS OPEN on a Redis error —
27
38
  * it returns `true` (allowed) and LOGS the blip. This never grants access on its
@@ -39,6 +50,29 @@
39
50
  * `SHIELD_RECOVERY_LIMITER`), each provided with the live Redis + its config.
40
51
  */
41
52
  import type Redis from 'ioredis';
53
+ /**
54
+ * Phase 9 #10 — the atomic increment-first step: count the attempt and arm the window TTL
55
+ * in one script, so the counter can never be left without an expiry between two commands.
56
+ * The TTL is also re-armed when the key somehow has none (`PTTL == -1`), so a counter
57
+ * left persistent by an older non-atomic write heals instead of locking forever.
58
+ */
59
+ export declare const ATTEMPT_CONSUME_SCRIPT = "local n = redis.call('INCR', KEYS[1])\nif n == 1 or redis.call('PTTL', KEYS[1]) == -1 then\n redis.call('PEXPIRE', KEYS[1], ARGV[1])\nend\nreturn n";
60
+ /** Phase 9 #10 — give back one consumed attempt; deletes the key when it reaches zero. */
61
+ export declare const ATTEMPT_REFUND_SCRIPT = "if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end\nlocal n = redis.call('DECR', KEYS[1])\nif n <= 0 then redis.call('DEL', KEYS[1]) end\nreturn n";
62
+ /** The verdict of {@link AttemptLimiter.consume}. */
63
+ export interface AttemptVerdict {
64
+ /** `true` iff this attempt is within the budget (`count <= maxAttempts`). */
65
+ readonly allowed: boolean;
66
+ /** The attempt count in the current window, including this attempt. */
67
+ readonly count: number;
68
+ }
69
+ /**
70
+ * Thrown by {@link AttemptLimiter.consume} when Redis cannot answer: the gate fails CLOSED
71
+ * and the caller answers a retryable 503. Carries no key or Redis detail.
72
+ */
73
+ export declare class AttemptLimiterUnavailableError extends Error {
74
+ constructor();
75
+ }
42
76
  /** Per-limiter tuning: the failure ceiling and the sliding-window length (ms). */
43
77
  export interface AttemptLimiterConfig {
44
78
  /** Number of failures within the window that trips the lockout (check → false). */
@@ -68,6 +102,30 @@ export declare class AttemptLimiter {
68
102
  * brute-force.
69
103
  */
70
104
  check(key: string): Promise<boolean>;
105
+ /**
106
+ * INCREMENT-FIRST attempt gate (Phase 9 #10). Atomically counts THIS attempt (one Lua
107
+ * `INCR`, arming the window TTL on the first hit) and reports whether it is within the
108
+ * budget (`count <= maxAttempts`). Call it BEFORE verifying the credential: N parallel
109
+ * guesses each get a distinct count, so at most `maxAttempts` of them ever reach the
110
+ * verifier (the old check → verify → recordFailure shape let every in-flight guess pass
111
+ * the check). The attempt stays counted when the credential is wrong; on success the
112
+ * caller calls {@link reset}; when the attempt never reached a verdict (a transient
113
+ * outage, or a step that is not a failure) the caller calls {@link refund}.
114
+ *
115
+ * `count === maxAttempts` on a FAILED attempt is the lockout edge (audit-on-the-edge).
116
+ *
117
+ * FAILS CLOSED: a Redis error throws {@link AttemptLimiterUnavailableError} (the caller
118
+ * answers a retryable 503). Unlike {@link check}, an outage never lets an unthrottled
119
+ * guess through.
120
+ */
121
+ consume(key: string): Promise<AttemptVerdict>;
122
+ /**
123
+ * Give back one attempt taken by {@link consume} that never reached a verdict (a
124
+ * transient dependency failure, or a step that is neither failure nor success, e.g. a
125
+ * password step that now needs a second factor). Removes the key when it reaches zero,
126
+ * so a refunded first attempt leaves no counter behind. Best-effort, never throws.
127
+ */
128
+ refund(key: string): Promise<void>;
71
129
  /**
72
130
  * Increment the failure counter; set the window TTL on the FIRST failure so the
73
131
  * counter auto-expires after `windowMs` of inactivity. Best-effort — a write we
@@ -1 +1 @@
1
- {"version":3,"file":"attempt-limiter.d.ts","sourceRoot":"","sources":["../../../src/auth/rate-limit/attempt-limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,qBAAa,cAAc;IAKvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IALtB,oFAAoF;IACpF,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAmC;gBAG9C,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,oBAAoB;IAG5C;;;;;OAKG;IACH,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,iFAAiF;IACjF,OAAO,CAAC,CAAC;IAIT;;;;;OAKG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAc1C;;;;;;;;;;;OAWG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBjD;;;;OAIG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOxC"}
1
+ {"version":3,"file":"attempt-limiter.d.ts","sourceRoot":"","sources":["../../../src/auth/rate-limit/attempt-limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,yJAI1B,CAAC;AAEV,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB,4JAGzB,CAAC;AAEV,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,qBAAa,8BAA+B,SAAQ,KAAK;;CAKxD;AAED,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,qBAAa,cAAc;IAKvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IALtB,oFAAoF;IACpF,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAmC;gBAG9C,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,oBAAoB;IAG5C;;;;;OAKG;IACH,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,iFAAiF;IACjF,OAAO,CAAC,CAAC;IAIT;;;;;OAKG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAc1C;;;;;;;;;;;;;;;OAeG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAmBnD;;;;;OAKG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUxC;;;;;;;;;;;OAWG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBjD;;;;OAIG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOxC"}
@@ -7,6 +7,14 @@
7
7
  * (default 5/15min, keyed `recovery:<principalId>`).
8
8
  *
9
9
  * ── CONTRACT ─────────────────────────────────────────────────────────────────
10
+ * consume(key) → Phase 9 #10, the gate for credential-verifying routes (login,
11
+ * /auth/mfa, change-password, the code-gated account MFA routes):
12
+ * atomically COUNTS this attempt first and returns
13
+ * `{ allowed: count <= maxAttempts, count }`. Parallel guesses get
14
+ * distinct counts, so at most `maxAttempts` reach the verifier.
15
+ * FAILS CLOSED: a Redis error throws (caller → 503).
16
+ * refund(key) → give back one consumed attempt that reached no verdict
17
+ * (transient outage; a step that is not a failure). Best-effort.
10
18
  * check(key) → `true` if the caller is UNDER the threshold (allowed);
11
19
  * `false` if AT/over it (locked out for the window).
12
20
  * recordFailure(key) → increment the counter; on the FIRST failure in a window,
@@ -22,7 +30,10 @@
22
30
  * reset(key) → clear the counter (a SUCCESS resets — a legitimate user is
23
31
  * never penalized for their earlier fat-fingers).
24
32
  *
25
- * ── FAIL-OPEN-WITH-LOG (spec §5, the deliberate posture) ──────────────────────
33
+ * ── FAIL-OPEN-WITH-LOG (spec §5) — the read-only `check()` path only ──────────
34
+ * (`consume()` fails CLOSED instead — Phase 9 #10; the routes that use it also need
35
+ * Redis to bind or resolve a session, so failing closed locks no one out who could
36
+ * otherwise have signed in.) `check()` remains for forgot/reset-password, where:
26
37
  * A limiter differs from the credential path: a Redis outage must NOT lock out
27
38
  * every legitimate login/recovery. So `check()` FAILS OPEN on a Redis error —
28
39
  * it returns `true` (allowed) and LOGS the blip. This never grants access on its
@@ -40,8 +51,35 @@
40
51
  * `SHIELD_RECOVERY_LIMITER`), each provided with the live Redis + its config.
41
52
  */
42
53
  Object.defineProperty(exports, "__esModule", { value: true });
43
- exports.AttemptLimiter = void 0;
54
+ exports.AttemptLimiter = exports.AttemptLimiterUnavailableError = exports.ATTEMPT_REFUND_SCRIPT = exports.ATTEMPT_CONSUME_SCRIPT = void 0;
44
55
  const common_1 = require("@nestjs/common");
56
+ /**
57
+ * Phase 9 #10 — the atomic increment-first step: count the attempt and arm the window TTL
58
+ * in one script, so the counter can never be left without an expiry between two commands.
59
+ * The TTL is also re-armed when the key somehow has none (`PTTL == -1`), so a counter
60
+ * left persistent by an older non-atomic write heals instead of locking forever.
61
+ */
62
+ exports.ATTEMPT_CONSUME_SCRIPT = `local n = redis.call('INCR', KEYS[1])
63
+ if n == 1 or redis.call('PTTL', KEYS[1]) == -1 then
64
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
65
+ end
66
+ return n`;
67
+ /** Phase 9 #10 — give back one consumed attempt; deletes the key when it reaches zero. */
68
+ exports.ATTEMPT_REFUND_SCRIPT = `if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
69
+ local n = redis.call('DECR', KEYS[1])
70
+ if n <= 0 then redis.call('DEL', KEYS[1]) end
71
+ return n`;
72
+ /**
73
+ * Thrown by {@link AttemptLimiter.consume} when Redis cannot answer: the gate fails CLOSED
74
+ * and the caller answers a retryable 503. Carries no key or Redis detail.
75
+ */
76
+ class AttemptLimiterUnavailableError extends Error {
77
+ constructor() {
78
+ super('attempt limiter unavailable');
79
+ this.name = 'AttemptLimiterUnavailableError';
80
+ }
81
+ }
82
+ exports.AttemptLimiterUnavailableError = AttemptLimiterUnavailableError;
45
83
  class AttemptLimiter {
46
84
  redis;
47
85
  cfg;
@@ -82,6 +120,51 @@ class AttemptLimiter {
82
120
  return true;
83
121
  }
84
122
  }
123
+ /**
124
+ * INCREMENT-FIRST attempt gate (Phase 9 #10). Atomically counts THIS attempt (one Lua
125
+ * `INCR`, arming the window TTL on the first hit) and reports whether it is within the
126
+ * budget (`count <= maxAttempts`). Call it BEFORE verifying the credential: N parallel
127
+ * guesses each get a distinct count, so at most `maxAttempts` of them ever reach the
128
+ * verifier (the old check → verify → recordFailure shape let every in-flight guess pass
129
+ * the check). The attempt stays counted when the credential is wrong; on success the
130
+ * caller calls {@link reset}; when the attempt never reached a verdict (a transient
131
+ * outage, or a step that is not a failure) the caller calls {@link refund}.
132
+ *
133
+ * `count === maxAttempts` on a FAILED attempt is the lockout edge (audit-on-the-edge).
134
+ *
135
+ * FAILS CLOSED: a Redis error throws {@link AttemptLimiterUnavailableError} (the caller
136
+ * answers a retryable 503). Unlike {@link check}, an outage never lets an unthrottled
137
+ * guess through.
138
+ */
139
+ async consume(key) {
140
+ let n;
141
+ try {
142
+ n = Number(await this.redis.eval(exports.ATTEMPT_CONSUME_SCRIPT, 1, this.k(key), String(this.cfg.windowMs)));
143
+ }
144
+ catch (err) {
145
+ AttemptLimiter.logger.warn(`attempt-limiter consume failed closed (denying) — Redis unavailable: ${describeError(err)}`);
146
+ throw new AttemptLimiterUnavailableError();
147
+ }
148
+ if (!Number.isFinite(n) || n < 1) {
149
+ // An answer that is not a count cannot prove the attempt is within budget.
150
+ throw new AttemptLimiterUnavailableError();
151
+ }
152
+ return { allowed: n <= this.cfg.maxAttempts, count: n };
153
+ }
154
+ /**
155
+ * Give back one attempt taken by {@link consume} that never reached a verdict (a
156
+ * transient dependency failure, or a step that is neither failure nor success, e.g. a
157
+ * password step that now needs a second factor). Removes the key when it reaches zero,
158
+ * so a refunded first attempt leaves no counter behind. Best-effort, never throws.
159
+ */
160
+ async refund(key) {
161
+ try {
162
+ await this.redis.eval(exports.ATTEMPT_REFUND_SCRIPT, 1, this.k(key));
163
+ }
164
+ catch (err) {
165
+ AttemptLimiter.logger.warn(`attempt-limiter refund best-effort miss — Redis unavailable: ${describeError(err)}`);
166
+ }
167
+ }
85
168
  /**
86
169
  * Increment the failure counter; set the window TTL on the FIRST failure so the
87
170
  * counter auto-expires after `windowMs` of inactivity. Best-effort — a write we