@astram/guard 0.9.0 → 0.10.1

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, {
@@ -1,16 +1,27 @@
1
1
  /**
2
- * `AttemptLimiter` — a guard-owned, Redis-backed sliding-window attempt counter for
3
- * anti-brute-force on the auth surface (spec §5). ONE class, used at TWO call sites
4
- * with independent thresholds: `/auth/login` (H1 password-login lockout, default
5
- * 10/15min, keyed `login:<identityHint>`) and the `/auth/mfa` recovery branch
6
- * (default 5/15min, keyed `recovery:<principalId>`).
2
+ * `AttemptLimiter` — a guard-owned, Redis-backed FIXED-WINDOW attempt counter for
3
+ * anti-brute-force on the auth surface (spec §5). The window starts at the first counted
4
+ * attempt (`PEXPIRE windowMs`) and is not extended by later ones; the counter resets when
5
+ * it expires or on a success. One class, one instance per DI token with its own threshold:
6
+ * `SHIELD_LOGIN_LIMITER` (`login:pid:<id>`, shared by `/auth/login` and change-password),
7
+ * the per-IP login limiter, `SHIELD_MFA_LIMITER` (`mfa:pid:<id>`, `/auth/mfa` TOTP and the
8
+ * code-gated account MFA routes), `SHIELD_RECOVERY_LIMITER` (`/auth/mfa` recovery codes),
9
+ * and the forgot/reset-password limiters.
7
10
  *
8
11
  * ── CONTRACT ─────────────────────────────────────────────────────────────────
12
+ * consume(key) → Phase 9 #10, the gate for credential-verifying routes (login,
13
+ * /auth/mfa, change-password, the code-gated account MFA routes):
14
+ * atomically COUNTS this attempt first and returns
15
+ * `{ allowed: count <= maxAttempts, count }`. Parallel guesses get
16
+ * distinct counts, so at most `maxAttempts` reach the verifier.
17
+ * FAILS CLOSED: a Redis error throws (caller → 503).
18
+ * refund(key) → give back one consumed attempt that reached no verdict
19
+ * (transient outage; a step that is not a failure). Best-effort.
9
20
  * check(key) → `true` if the caller is UNDER the threshold (allowed);
10
21
  * `false` if AT/over it (locked out for the window).
11
22
  * recordFailure(key) → increment the counter; on the FIRST failure in a window,
12
23
  * set the window TTL (`PEXPIRE windowMs`) so the counter
13
- * auto-expires (a sliding window that resets on inactivity).
24
+ * auto-expires (a fixed window from the first failure).
14
25
  * RETURNS the new count so a caller can detect the LOCKOUT
15
26
  * TRANSITION (count === maxAttempts is the exact edge where
16
27
  * the NEXT check() flips to `false`) and act ONCE on it — e.g.
@@ -21,7 +32,10 @@
21
32
  * reset(key) → clear the counter (a SUCCESS resets — a legitimate user is
22
33
  * never penalized for their earlier fat-fingers).
23
34
  *
24
- * ── FAIL-OPEN-WITH-LOG (spec §5, the deliberate posture) ──────────────────────
35
+ * ── FAIL-OPEN-WITH-LOG (spec §5) — the read-only `check()` path only ──────────
36
+ * (`consume()` fails CLOSED instead — Phase 9 #10; the routes that use it also need
37
+ * Redis to bind or resolve a session, so failing closed locks no one out who could
38
+ * otherwise have signed in.) `check()` remains for forgot/reset-password, where:
25
39
  * A limiter differs from the credential path: a Redis outage must NOT lock out
26
40
  * every legitimate login/recovery. So `check()` FAILS OPEN on a Redis error —
27
41
  * it returns `true` (allowed) and LOGS the blip. This never grants access on its
@@ -30,16 +44,42 @@
30
44
  * count we couldn't write just means the window is a touch more lenient — never a
31
45
  * thrown error into the request path).
32
46
  *
33
- * ── ANTI-ENUMERATION ── the limiter itself is silent; the caller maps a `false`
34
- * from `check()` to the SAME generic 401 as a wrong password / wrong code, so a
35
- * locked-out account is indistinguishable from a bad credential.
47
+ * ── WHAT A LOCK ANSWERS ── the limiter itself is silent; the caller decides. On the
48
+ * unauthenticated `/auth/*` routes a lock is the SAME generic 401 as a wrong password /
49
+ * wrong code, so a locked-out account is indistinguishable from a bad credential. On the
50
+ * authenticated `/account/*` routes (change-password, MFA device remove, recovery-code
51
+ * regenerate) the caller already holds a session, so a lock answers 429 (Phase 8 #1,
52
+ * Phase 9 #3).
36
53
  *
37
54
  * ADR-0023: guard owns the infra adapter (ioredis); the class is a thin, injectable
38
55
  * collaborator the controller consumes via a DI token (`SHIELD_LOGIN_LIMITER` /
39
56
  * `SHIELD_RECOVERY_LIMITER`), each provided with the live Redis + its config.
40
57
  */
41
58
  import type Redis from 'ioredis';
42
- /** Per-limiter tuning: the failure ceiling and the sliding-window length (ms). */
59
+ /**
60
+ * Phase 9 #10 — the atomic increment-first step: count the attempt and arm the window TTL
61
+ * in one script, so the counter can never be left without an expiry between two commands.
62
+ * The TTL is also re-armed when the key somehow has none (`PTTL == -1`), so a counter
63
+ * left persistent by an older non-atomic write heals instead of locking forever.
64
+ */
65
+ 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";
66
+ /** Phase 9 #10 — give back one consumed attempt; deletes the key when it reaches zero. */
67
+ 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";
68
+ /** The verdict of {@link AttemptLimiter.consume}. */
69
+ export interface AttemptVerdict {
70
+ /** `true` iff this attempt is within the budget (`count <= maxAttempts`). */
71
+ readonly allowed: boolean;
72
+ /** The attempt count in the current window, including this attempt. */
73
+ readonly count: number;
74
+ }
75
+ /**
76
+ * Thrown by {@link AttemptLimiter.consume} when Redis cannot answer: the gate fails CLOSED
77
+ * and the caller answers a retryable 503. Carries no key or Redis detail.
78
+ */
79
+ export declare class AttemptLimiterUnavailableError extends Error {
80
+ constructor();
81
+ }
82
+ /** Per-limiter tuning: the failure ceiling and the fixed-window length (ms). */
43
83
  export interface AttemptLimiterConfig {
44
84
  /** Number of failures within the window that trips the lockout (check → false). */
45
85
  readonly maxAttempts: number;
@@ -68,6 +108,30 @@ export declare class AttemptLimiter {
68
108
  * brute-force.
69
109
  */
70
110
  check(key: string): Promise<boolean>;
111
+ /**
112
+ * INCREMENT-FIRST attempt gate (Phase 9 #10). Atomically counts THIS attempt (one Lua
113
+ * `INCR`, arming the window TTL on the first hit) and reports whether it is within the
114
+ * budget (`count <= maxAttempts`). Call it BEFORE verifying the credential: N parallel
115
+ * guesses each get a distinct count, so at most `maxAttempts` of them ever reach the
116
+ * verifier (the old check → verify → recordFailure shape let every in-flight guess pass
117
+ * the check). The attempt stays counted when the credential is wrong; on success the
118
+ * caller calls {@link reset}; when the attempt never reached a verdict (a transient
119
+ * outage, or a step that is not a failure) the caller calls {@link refund}.
120
+ *
121
+ * `count === maxAttempts` on a FAILED attempt is the lockout edge (audit-on-the-edge).
122
+ *
123
+ * FAILS CLOSED: a Redis error throws {@link AttemptLimiterUnavailableError} (the caller
124
+ * answers a retryable 503). Unlike {@link check}, an outage never lets an unthrottled
125
+ * guess through.
126
+ */
127
+ consume(key: string): Promise<AttemptVerdict>;
128
+ /**
129
+ * Give back one attempt taken by {@link consume} that never reached a verdict (a
130
+ * transient dependency failure, or a step that is neither failure nor success, e.g. a
131
+ * password step that now needs a second factor). Removes the key when it reaches zero,
132
+ * so a refunded first attempt leaves no counter behind. Best-effort, never throws.
133
+ */
134
+ refund(key: string): Promise<void>;
71
135
  /**
72
136
  * Increment the failure counter; set the window TTL on the FIRST failure so the
73
137
  * 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;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,gFAAgF;AAChF,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"}