@astram/guard 0.7.0 → 0.9.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.
Files changed (187) hide show
  1. package/dist/auth/account.controller.d.ts +27 -5
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +99 -26
  4. package/dist/auth/auth.controller.d.ts +130 -21
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +537 -143
  7. package/dist/auth/auth.module.d.ts +15 -1
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +212 -30
  10. package/dist/auth/connections.d.ts +1 -1
  11. package/dist/auth/connections.d.ts.map +1 -1
  12. package/dist/auth/connections.js +14 -3
  13. package/dist/auth/default-session-facts-projector.d.ts +26 -0
  14. package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
  15. package/dist/auth/default-session-facts-projector.js +21 -0
  16. package/dist/auth/mfa-step-up.d.ts +32 -0
  17. package/dist/auth/mfa-step-up.d.ts.map +1 -0
  18. package/dist/auth/mfa-step-up.js +109 -0
  19. package/dist/auth/pending-mfa-store.d.ts +50 -52
  20. package/dist/auth/pending-mfa-store.d.ts.map +1 -1
  21. package/dist/auth/pending-mfa-store.js +103 -61
  22. package/dist/auth/ports/session-facts-projector.d.ts +5 -0
  23. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
  24. package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
  25. package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
  26. package/dist/auth/rate-limit/canonical-hint.js +63 -0
  27. package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
  28. package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
  29. package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
  30. package/dist/auth/tokens.d.ts +70 -11
  31. package/dist/auth/tokens.d.ts.map +1 -1
  32. package/dist/auth/tokens.js +71 -12
  33. package/dist/authentication/log-email-sender.d.ts +9 -4
  34. package/dist/authentication/log-email-sender.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-api-key-store.d.ts +5 -1
  36. package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
  37. package/dist/authentication/mariadb-api-key-store.js +43 -7
  38. package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
  39. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  40. package/dist/authentication/mariadb-credential-resolver.js +116 -73
  41. package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
  42. package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
  43. package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
  44. package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
  45. package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
  46. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  47. package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
  48. package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
  49. package/dist/authentication/mariadb-session-sweeper.js +29 -11
  50. package/dist/authentication/noop-email-sender.d.ts +12 -0
  51. package/dist/authentication/noop-email-sender.d.ts.map +1 -0
  52. package/dist/authentication/noop-email-sender.js +9 -0
  53. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
  54. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
  55. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  56. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
  57. package/dist/authz/mariadb-policy-store.js +1 -1
  58. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  59. package/dist/authz/mariadb-record-rule-adapter.js +1 -4
  60. package/dist/authz/permission-version-cache.d.ts +38 -0
  61. package/dist/authz/permission-version-cache.d.ts.map +1 -0
  62. package/dist/authz/permission-version-cache.js +81 -0
  63. package/dist/authz/wire-record-authorization.d.ts +25 -1
  64. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  65. package/dist/authz/wire-record-authorization.js +55 -2
  66. package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
  67. package/dist/config/app-config.d.ts +36 -0
  68. package/dist/config/app-config.d.ts.map +1 -1
  69. package/dist/config/app-config.js +44 -4
  70. package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
  71. package/dist/enforcement/enforcement.module.d.ts.map +1 -1
  72. package/dist/enforcement/enforcement.module.js +9 -0
  73. package/dist/enforcement/engine-error.filter.d.ts +57 -0
  74. package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
  75. package/dist/enforcement/engine-error.filter.js +118 -0
  76. package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
  77. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  78. package/dist/enforcement/global-enforcement.guard.js +143 -5
  79. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  80. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  81. package/dist/identity/permission-facts.adapter.js +2 -3
  82. package/dist/index.d.ts +13 -7
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +68 -28
  85. package/dist/internal.d.ts +12 -0
  86. package/dist/internal.d.ts.map +1 -0
  87. package/dist/internal.js +16 -0
  88. package/dist/module/shield.module.d.ts +9 -2
  89. package/dist/module/shield.module.d.ts.map +1 -1
  90. package/dist/module/shield.module.js +47 -38
  91. package/dist/request-auth/session-facts-als.d.ts +16 -1
  92. package/dist/request-auth/session-facts-als.d.ts.map +1 -1
  93. package/dist/request-auth/session-facts-als.js +23 -0
  94. package/dist/request-auth/session-token-binder.d.ts +166 -10
  95. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  96. package/dist/request-auth/session-token-binder.js +408 -28
  97. package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
  98. package/dist/secrets/encrypted-file-secrets.js +9 -3
  99. package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
  100. package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
  101. package/dist/session/hot-revoking-session-record-store.js +58 -0
  102. package/dist/session/index.d.ts +3 -1
  103. package/dist/session/index.d.ts.map +1 -1
  104. package/dist/session/index.js +8 -1
  105. package/dist/session/mariadb-session-record-store.d.ts +6 -0
  106. package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
  107. package/dist/session/mariadb-session-record-store.js +16 -1
  108. package/dist/session/session-lifetimes.d.ts +30 -0
  109. package/dist/session/session-lifetimes.d.ts.map +1 -0
  110. package/dist/session/session-lifetimes.js +21 -0
  111. package/dist/session/session-restriction-store.d.ts +43 -0
  112. package/dist/session/session-restriction-store.d.ts.map +1 -0
  113. package/dist/session/session-restriction-store.js +35 -0
  114. package/package.json +14 -3
  115. package/dist/auth/account.controller.js.map +0 -1
  116. package/dist/auth/auth.controller.js.map +0 -1
  117. package/dist/auth/auth.module.js.map +0 -1
  118. package/dist/auth/connections.js.map +0 -1
  119. package/dist/auth/dto.js.map +0 -1
  120. package/dist/auth/pending-mfa-store.js.map +0 -1
  121. package/dist/auth/persistence.module.js.map +0 -1
  122. package/dist/auth/ports/audit-sink.js.map +0 -1
  123. package/dist/auth/ports/credential-writer.js.map +0 -1
  124. package/dist/auth/ports/email-sender.js.map +0 -1
  125. package/dist/auth/ports/membership-checker.js.map +0 -1
  126. package/dist/auth/ports/mfa-device-store.js.map +0 -1
  127. package/dist/auth/ports/profile-writer.js.map +0 -1
  128. package/dist/auth/ports/recovery-code-store.js.map +0 -1
  129. package/dist/auth/ports/reset-token-store.js.map +0 -1
  130. package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
  131. package/dist/auth/ports/session-facts-projector.js.map +0 -1
  132. package/dist/auth/ports/session-sweeper.js.map +0 -1
  133. package/dist/auth/profile-resolver.js.map +0 -1
  134. package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
  135. package/dist/auth/tokens.js.map +0 -1
  136. package/dist/auth/totp-enrollment.js.map +0 -1
  137. package/dist/authentication/argon2id-password-hasher.js.map +0 -1
  138. package/dist/authentication/log-email-sender.js.map +0 -1
  139. package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
  140. package/dist/authentication/mariadb-credential-writer.js.map +0 -1
  141. package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
  142. package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
  143. package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
  144. package/dist/authentication/mariadb-profile-writer.js.map +0 -1
  145. package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
  146. package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
  147. package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
  148. package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
  149. package/dist/authz/list-scope-sql.js.map +0 -1
  150. package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
  151. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
  152. package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
  153. package/dist/authz/mariadb-policy-store.js.map +0 -1
  154. package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
  155. package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
  156. package/dist/authz/record-share-cascade.js.map +0 -1
  157. package/dist/authz/record-share-writer.js.map +0 -1
  158. package/dist/authz/wire-record-authorization.js.map +0 -1
  159. package/dist/boot/boot-sequence.js.map +0 -1
  160. package/dist/boot/boot.module.js.map +0 -1
  161. package/dist/completeness/catalog-reconciler.js.map +0 -1
  162. package/dist/completeness/operation-inventory-scanner.js.map +0 -1
  163. package/dist/completeness/ports/catalog-store.js.map +0 -1
  164. package/dist/config/app-config.js.map +0 -1
  165. package/dist/config/config.module.js.map +0 -1
  166. package/dist/enforcement/classification.decorator.js.map +0 -1
  167. package/dist/enforcement/core-seam.js.map +0 -1
  168. package/dist/enforcement/enforcement.module.js.map +0 -1
  169. package/dist/enforcement/global-enforcement.guard.js.map +0 -1
  170. package/dist/enforcement/reenroll-allowlist.js.map +0 -1
  171. package/dist/identity/auth-facts.controller.js.map +0 -1
  172. package/dist/identity/identity-actions.js.map +0 -1
  173. package/dist/identity/module-registry.bridge.js.map +0 -1
  174. package/dist/identity/permission-facts.adapter.js.map +0 -1
  175. package/dist/index.js.map +0 -1
  176. package/dist/migrations-dir.js.map +0 -1
  177. package/dist/module/shield-tokens.js.map +0 -1
  178. package/dist/module/shield.module.js.map +0 -1
  179. package/dist/request-auth/request-principal.js.map +0 -1
  180. package/dist/request-auth/session-facts-als.js.map +0 -1
  181. package/dist/request-auth/session-token-binder.js.map +0 -1
  182. package/dist/request-auth/tokens.js.map +0 -1
  183. package/dist/scope/closure-builder.js.map +0 -1
  184. package/dist/session/crypto-adapters.js.map +0 -1
  185. package/dist/session/index.js.map +0 -1
  186. package/dist/session/mariadb-session-record-store.js.map +0 -1
  187. package/dist/session/redis-hot-session-store.js.map +0 -1
@@ -15,10 +15,13 @@
15
15
  * ── OUTCOME → HTTP STATUS MAP (the security contract) ────────────────────────
16
16
  * login authenticated → 200 SessionSuccess { session(no-secret), tokens }
17
17
  * login mfa_required → 200 MfaChallengeResponse { mfaRequired, challengeToken }
18
+ * (a Redis challenge: principal + hint + credential
19
+ * fingerprint, never the password — M6)
18
20
  * login invalid_credentials → 401 GENERIC ← unknown-user AND wrong-password IDENTICAL
19
21
  * login auth_unavailable → 503 RETRYABLE ← transient blip is NOT a credential deny
20
22
  * mfa success → 200 SessionSuccess
21
23
  * mfa wrong/expired/forged → 401 GENERIC ← indistinguishable from a bad code
24
+ * mfa TOTP step locked (#9) → 401 GENERIC (5 wrong codes / 15 min per account)
22
25
  * mfa auth_unavailable → 503 RETRYABLE
23
26
  * refresh ok → 200 SessionSuccess (new tokens, rotated session)
24
27
  * refresh reuse/invalid → 401 GENERIC session_rejected (lineage-kill is the engine's)
@@ -69,8 +72,15 @@ const node_crypto_1 = require("node:crypto");
69
72
  const common_2 = require("@nestjs/common");
70
73
  const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
71
74
  const tokens_js_1 = require("./tokens.js");
75
+ const mariadb_session_record_store_js_1 = require("../session/mariadb-session-record-store.js");
76
+ const crypto_adapters_js_1 = require("../session/crypto-adapters.js");
77
+ const session_lifetimes_js_1 = require("../session/session-lifetimes.js");
78
+ const mfa_step_up_js_1 = require("./mfa-step-up.js");
79
+ const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
80
+ const trust_proxy_check_js_1 = require("./rate-limit/trust-proxy-check.js");
72
81
  const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
73
82
  const tokens_js_2 = require("../request-auth/tokens.js");
83
+ const session_token_binder_js_1 = require("../request-auth/session-token-binder.js");
74
84
  const request_principal_js_1 = require("../request-auth/request-principal.js");
75
85
  const tokens_js_3 = require("./tokens.js");
76
86
  /**
@@ -172,6 +182,8 @@ let AuthController = class AuthController {
172
182
  resetLinkBaseUrl;
173
183
  recoveryCodes;
174
184
  loginLimiter;
185
+ loginIpLimiter;
186
+ trustProxyCheck;
175
187
  recoveryLimiter;
176
188
  forgotLimiter;
177
189
  forgotHintLimiter;
@@ -181,7 +193,14 @@ let AuthController = class AuthController {
181
193
  scopeSubtreeResolver;
182
194
  membershipChecker;
183
195
  permissionFacts;
184
- constructor(authenticator, sessionAuthority, pendingMfa, tokenBinder, profileResolver, credentialResolver, resetTokenStore, emailSender, credentialWriter, sessionSweeper, passwordHasher, resetLinkBaseUrl, recoveryCodes, loginLimiter, recoveryLimiter, forgotLimiter, forgotHintLimiter, resetLimiter, auditSink, sessionFactsProjector, scopeSubtreeResolver, membershipChecker, permissionFacts) {
196
+ mfaLimiter;
197
+ mfaStepUp;
198
+ clock;
199
+ lifetimes;
200
+ rootIds;
201
+ sessionRecords;
202
+ sessionRestrictions;
203
+ constructor(authenticator, sessionAuthority, pendingMfa, tokenBinder, profileResolver, credentialResolver, resetTokenStore, emailSender, credentialWriter, sessionSweeper, passwordHasher, resetLinkBaseUrl, recoveryCodes, loginLimiter, loginIpLimiter, trustProxyCheck, recoveryLimiter, forgotLimiter, forgotHintLimiter, resetLimiter, auditSink, sessionFactsProjector, scopeSubtreeResolver, membershipChecker, permissionFacts, mfaLimiter, mfaStepUp, clock, lifetimes, rootIds, sessionRecords, sessionRestrictions) {
185
204
  this.authenticator = authenticator;
186
205
  this.sessionAuthority = sessionAuthority;
187
206
  this.pendingMfa = pendingMfa;
@@ -196,6 +215,8 @@ let AuthController = class AuthController {
196
215
  this.resetLinkBaseUrl = resetLinkBaseUrl;
197
216
  this.recoveryCodes = recoveryCodes;
198
217
  this.loginLimiter = loginLimiter;
218
+ this.loginIpLimiter = loginIpLimiter;
219
+ this.trustProxyCheck = trustProxyCheck;
199
220
  this.recoveryLimiter = recoveryLimiter;
200
221
  this.forgotLimiter = forgotLimiter;
201
222
  this.forgotHintLimiter = forgotHintLimiter;
@@ -205,34 +226,63 @@ let AuthController = class AuthController {
205
226
  this.scopeSubtreeResolver = scopeSubtreeResolver;
206
227
  this.membershipChecker = membershipChecker;
207
228
  this.permissionFacts = permissionFacts;
229
+ this.mfaLimiter = mfaLimiter;
230
+ this.mfaStepUp = mfaStepUp;
231
+ this.clock = clock;
232
+ this.lifetimes = lifetimes;
233
+ this.rootIds = rootIds;
234
+ this.sessionRecords = sessionRecords;
235
+ this.sessionRestrictions = sessionRestrictions;
236
+ }
237
+ /**
238
+ * The access-token binding TTL for an establishment (security hardening #8):
239
+ * `clamp(session.expiresAtMs − now, 1, accessTtlMs)` — on login AND refresh. Never derived
240
+ * from `issuedAtMs` (the original login), which made refresh-minted tokens live for the
241
+ * session's age.
242
+ */
243
+ accessTtlFor(est) {
244
+ const now = this.clock?.nowMs() ?? Date.now();
245
+ return (0, session_lifetimes_js_1.accessBindingTtlMs)(est.session.expiresAtMs, now, this.lifetimes?.accessTtlMs ?? session_lifetimes_js_1.DEFAULT_ACCESS_TTL_MS);
208
246
  }
209
247
  /**
210
248
  * Bind the established session's access token to the principal's claims (the
211
- * request-auth layer). The access-token TTL is `expiresAtMs - issuedAtMs` from the
212
- * session. Best-effort: a binding failure must not fail the login (the caller still
249
+ * request-auth layer). The access-token TTL is {@link accessTtlFor} (≤ the access TTL).
250
+ * Best-effort: a binding failure must not fail the login (the caller still
213
251
  * gets valid tokens); the guard fails closed if the binding is absent.
214
252
  */
215
253
  async bindSession(est, principal, mustReenroll = false) {
216
254
  if (this.tokenBinder === undefined)
217
255
  return;
218
- const ttlMs = Math.max(est.session.expiresAtMs - est.session.issuedAtMs, 1);
256
+ const ttlMs = this.accessTtlFor(est);
219
257
  // EAGER session-facts projection (ADR-0011): compute the principal's coarse grants
220
258
  // NOW so the guard can serve gate 5 from the request's `cachedFacts`. Absent projector
221
259
  // / a null result → `cachedFacts` stays undefined → gate 5 DB-reloads (today's
222
260
  // behavior). Best-effort inside projectFacts (a failure → undefined, never throws).
223
- const cachedFacts = await this.projectFacts(principal);
261
+ const projected = await this.projectFactsFor(principal.id, principal.tenantId);
262
+ const cachedFacts = projected ?? undefined;
224
263
  // EAGER active-scope-subtree resolution (Task 2.2, ADR-0027 / ADR-0024): resolve the
225
264
  // principal's closure subtree (self node + descendants) NOW so the per-request tenant gate
226
265
  // reads it from the binding with zero DB (the parity guarantee — the closure query happens
227
266
  // ONLY here, at login). Absent resolver / empty result / a thrown resolver → undefined →
228
267
  // `scopeSubtreeNodeIds` stays absent → the per-request path falls back to the self-node.
229
268
  // Best-effort inside resolveScopeSubtree (a failure → undefined, never throws).
269
+ // The version is read FIRST (Phase 1C) so the stamp can only be older than the subtree.
270
+ const scopeSubtreeVersion = await this.subtreeVersionFor(principal.tenantId);
230
271
  const scopeSubtreeNodeIds = await this.resolveScopeSubtree(principal);
272
+ // LIVE ROLES (ADR-0035, GAPS #4): the in-effect posting roles from the SAME projection as the
273
+ // cached facts, not the credential's login-time `ic_roles`.
274
+ // - projection reported roles → those roles;
275
+ // - an active projector failed / yielded nothing (null) → `[]` (review P2-d: never widen to
276
+ // the `ic_roles` snapshot; no facts are cached, so the first request's gate-5 reload binds
277
+ // the live roles before any handler runs);
278
+ // - a projection without `roleIds` (a port that does not report roles) or no projector at all
279
+ // → the authenticated principal's roles (back-compat for apps that own their roles).
280
+ const roleIds = projected === null ? [] : ((0, session_token_binder_js_1.liveRoleIds)(projected) ?? [...principal.roleIds]);
231
281
  try {
232
282
  await this.tokenBinder.bind(est.tokens.accessToken, {
233
283
  id: principal.id,
234
284
  tenantId: principal.tenantId,
235
- roleIds: [...principal.roleIds],
285
+ roleIds,
236
286
  kind: principal.kind,
237
287
  isRoot: principal.isRoot === true,
238
288
  sessionId: est.session.sessionId,
@@ -242,11 +292,15 @@ let AuthController = class AuthController {
242
292
  // Only include the key when a projection produced facts — under
243
293
  // exactOptionalPropertyTypes an explicit `undefined` is not assignable to the
244
294
  // optional field; a spread keeps it truly absent (→ gate 5 DB-reloads).
245
- ...(cachedFacts ? { cachedFacts } : {}),
295
+ ...(cachedFacts ? { cachedFacts: (0, session_token_binder_js_1.factsSlot)(cachedFacts) } : {}),
246
296
  // Same spread discipline for the resolved subtree: present only when the resolver
247
297
  // produced a non-empty set (→ the per-request tenant gate reads the real subtree);
248
298
  // absent otherwise (→ the per-request path falls back to the self-node, non-widening).
249
299
  ...(scopeSubtreeNodeIds ? { scopeSubtreeNodeIds } : {}),
300
+ // The version the subtree was resolved at (Phase 1C) — only beside a subtree.
301
+ ...(scopeSubtreeNodeIds && scopeSubtreeVersion !== undefined
302
+ ? { scopeSubtreeVersion }
303
+ : {}),
250
304
  }, ttlMs);
251
305
  }
252
306
  catch {
@@ -254,21 +308,6 @@ let AuthController = class AuthController {
254
308
  // binding is missing, so this degrades safely (the user re-logs in).
255
309
  }
256
310
  }
257
- /**
258
- * Project the principal's coarse grants for the session binding (ADR-0011), via the
259
- * OPTIONAL {@link SessionFactsProjector} seam. Returns the {@link CoarseGrants} to
260
- * cache, or `undefined` when there is no projector, the projection yielded nothing,
261
- * OR it threw — every "no facts" path collapses to `undefined` so the caller simply
262
- * omits `cachedFacts` and the request DB-reloads (fail-closed; a projection failure
263
- * NEVER fails the login/refresh and never fabricates a grant).
264
- */
265
- async projectFacts(principal) {
266
- const facts = await this.projectFactsFor(principal.id, principal.tenantId);
267
- // At LOGIN there is no prior cache, so "no projector" (undefined) and "yielded
268
- // nothing" (null) BOTH mean "omit cachedFacts" → gate 5 DB-reloads. Collapse to
269
- // undefined for the bind() spread.
270
- return facts ?? undefined;
271
- }
272
311
  /**
273
312
  * Project coarse grants for a principal id + tenant (ADR-0011), distinguishing THREE
274
313
  * outcomes for the REFRESH path (which must know whether to carry the existing cache
@@ -284,6 +323,9 @@ let AuthController = class AuthController {
284
323
  const projector = this.sessionFactsProjector;
285
324
  if (projector === undefined || projector === null)
286
325
  return undefined;
326
+ // The framework's deferred default projector before its port is attached = no projector.
327
+ if (typeof projector.isActive === 'function' && projector.isActive() !== true)
328
+ return undefined;
287
329
  try {
288
330
  return await projector.project(principalId, tenantId);
289
331
  }
@@ -317,6 +359,30 @@ let AuthController = class AuthController {
317
359
  * bumped by moveNode/deleteNode) re-derives its CORRECT subtree here, closing the
318
360
  * stale-subtree-after-move window (bounded by the access-token lifetime — ADR-0011).
319
361
  */
362
+ /**
363
+ * The permission version of `tenantId`, read just BEFORE its subtree is resolved, to stamp the
364
+ * binding (`scopeSubtreeVersion`, security hardening Phase 1C): when gate 5 later reloads facts
365
+ * at a newer version, the enforcement guard re-derives the subtree. Read before the resolve so a
366
+ * racing bump leaves the stamp too old (one extra re-derive), never too new. Only when a
367
+ * subtree resolver is wired (no subtree → nothing to stamp, no extra read). Best-effort: no
368
+ * facts adapter, a non-integer, or a throw → `undefined` (unstamped → the guard re-derives once
369
+ * at the next reload). One indexed read at login/refresh/scope-switch, never per request.
370
+ */
371
+ async subtreeVersionFor(tenantId) {
372
+ if (this.scopeSubtreeResolver === undefined || this.scopeSubtreeResolver === null) {
373
+ return undefined;
374
+ }
375
+ const facts = this.permissionFacts;
376
+ if (facts === undefined || facts === null)
377
+ return undefined;
378
+ try {
379
+ const v = await facts.currentVersionFor('', tenantId);
380
+ return Number.isSafeInteger(v) && v >= 0 ? v : undefined;
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ }
320
386
  async resolveScopeSubtreeFor(tenantId) {
321
387
  const resolver = this.scopeSubtreeResolver;
322
388
  if (resolver === undefined || resolver === null)
@@ -403,21 +469,70 @@ let AuthController = class AuthController {
403
469
  isRoot: false,
404
470
  };
405
471
  }
472
+ /**
473
+ * The account (principal id) a typed hint resolves to, for keying the per-account
474
+ * limiters (review F1). Goes through the SAME injected {@link CredentialResolver} the
475
+ * composed authenticator uses — the default is the indexed
476
+ * `WHERE ic_identity_hint = ? LIMIT 1` on the `utf8mb4_unicode_ci` column — so the DB
477
+ * collation, not client-side normalisation, decides which spellings are one account.
478
+ * No new access path. The RAW hint is passed (never normalised).
479
+ *
480
+ * Returns `null` for an unknown hint, a blank hint, or when no resolver is wired (→ the
481
+ * canonical-hash fallback key). A store ERROR propagates (the caller fails closed).
482
+ */
483
+ /**
484
+ * The ONE gate for every per-IP limiter (login failures, forgot-password, reset-password):
485
+ * returns the limiter only when bootstrap confirmed an explicit Express `trust proxy`
486
+ * ({@link TrustProxyBootCheck.perIpLimitingEnabled}); otherwise `null` (limiter OFF).
487
+ * Without it every client behind a proxy shares one `req.ip`, so enforcing a per-IP
488
+ * limit would block everyone (an unauthenticated global DoS). Per-account limits are
489
+ * never routed through here.
490
+ */
491
+ perIp(limiter) {
492
+ return this.trustProxyCheck?.perIpLimitingEnabled === true ? (limiter ?? null) : null;
493
+ }
494
+ async lookupAccount(rawHint) {
495
+ const resolver = this.credentialResolver;
496
+ if (resolver == null || rawHint === '')
497
+ return null;
498
+ return resolver.resolve(rawHint);
499
+ }
406
500
  /**
407
501
  * `POST /auth/login` — verify a password credential and either establish a
408
502
  * session, issue an MFA challenge, deny generically, or signal a transient
409
503
  * outage. NEVER logs the request body.
410
504
  */
411
- async login(body) {
505
+ async login(body, ip) {
506
+ // F5: the hint goes to the store EXACTLY as typed — the DB collation
507
+ // (utf8mb4_unicode_ci) decides which spellings are the same account. Never rewritten.
412
508
  const identityHint = typeof body?.identityHint === 'string' ? body.identityHint : '';
413
509
  const password = typeof body?.password === 'string' ? body.password : '';
414
- // Rate-limit gate (spec §5 / H1): after M failed attempts within the window,
415
- // reject BEFORE consulting the authenticator — the SAME generic 401 as a wrong
416
- // password (anti-enumeration: a locked-out account is indistinguishable). Keyed
417
- // on the presented hint (the resolver isn't run pre-auth). Fails OPEN on a Redis
418
- // blip (the limiter's own posture — never a lockout from an outage).
419
- const loginKey = `login:${identityHint}`;
420
- if (this.loginLimiter != null && !(await this.loginLimiter.check(loginKey))) {
510
+ // Rate-limit gate (spec §5 / H1 + #5): after M failed attempts within the window —
511
+ // for this ACCOUNT, or from this client IP when per-IP limiting is on — reject BEFORE
512
+ // 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).
515
+ //
516
+ // Per-IP first (cheap, no DB): a locked IP never reaches the account lookup.
517
+ const ipLimiter = this.perIp(this.loginIpLimiter);
518
+ const ipKey = (0, canonical_hint_js_1.loginIpKey)(ip);
519
+ if (ipLimiter != null && !(await ipLimiter.check(ipKey))) {
520
+ genericUnauthorized();
521
+ }
522
+ // F1: key the per-account budget on the account the store resolves for this hint
523
+ // (`login:pid:<id>`; unknown → canonical-hash fallback). A store ERROR fails CLOSED
524
+ // (503), consistent with the authenticator's treatment of a resolver outage.
525
+ let account;
526
+ try {
527
+ account = await this.lookupAccount(identityHint);
528
+ }
529
+ catch {
530
+ retryableUnavailable();
531
+ }
532
+ const accountId = (0, canonical_hint_js_1.principalIdOf)(account);
533
+ const loginKey = (0, canonical_hint_js_1.loginHintKey)(identityHint, accountId);
534
+ const hintAllowed = this.loginLimiter == null || (await this.loginLimiter.check(loginKey));
535
+ if (!hintAllowed) {
421
536
  // Already locked → generic 401. We do NOT audit here: `check()` returns false on
422
537
  // EVERY attempt once locked, so auditing at this gate would flood one event per
423
538
  // attempt. The lockout is audited ONCE on the TRANSITION instead — in the
@@ -425,7 +540,9 @@ let AuthController = class AuthController {
425
540
  // (feature #140 audit-on-the-edge, anti-flood).
426
541
  genericUnauthorized();
427
542
  }
428
- const result = await this.authenticator.authenticate(identityHint, [passwordCredential(password)]);
543
+ const result = await this.authenticator.authenticate(identityHint, [
544
+ passwordCredential(password),
545
+ ]);
429
546
  if (result.outcome === 'authenticated') {
430
547
  // Correct password → clear the login counter (do not carry earlier failures).
431
548
  await this.loginLimiter?.reset(loginKey);
@@ -437,13 +554,29 @@ let AuthController = class AuthController {
437
554
  return this.sessionSuccessWithFacts(est, principal);
438
555
  }
439
556
  if (result.outcome === 'mfa_required') {
440
- // Password verified, a second factor is outstanding. The PASSWORD was correct,
441
- // so this is NOT a login failure — reset the login limiter (spec §5: reset on
442
- // authenticated OR mfa_required). Then hold the pending credentials server-side
443
- // and hand back an OPAQUE, unforgeable challenge handle. NO session yet; the
444
- // handle carries nothing about the user.
445
- await this.loginLimiter?.reset(loginKey);
446
- const challengeToken = this.pendingMfa.store(identityHint, password);
557
+ // Password verified, a second factor is outstanding. NOT a failure — but NOT a
558
+ // 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.
560
+ //
561
+ // M6: record the password step's SUCCESS — the resolved principal, the typed hint,
562
+ // and a fingerprint of the credential it was checked against — NEVER the password.
563
+ // `/auth/mfa` verifies only the second factor against that principal. No resolved
564
+ // account here (no resolver wired / the account vanished mid-login) → fail closed.
565
+ if (account === null || accountId === null)
566
+ genericUnauthorized();
567
+ const challenge = {
568
+ principalId: accountId,
569
+ identityHint,
570
+ credentialFingerprint: (0, mfa_step_up_js_1.credentialFingerprint)(account),
571
+ };
572
+ let challengeToken;
573
+ try {
574
+ challengeToken = await this.pendingMfa.store(challenge);
575
+ }
576
+ catch {
577
+ // The challenge store (Redis) is down: a transient outage, not a credential deny.
578
+ retryableUnavailable();
579
+ }
447
580
  return { mfaRequired: true, challengeToken };
448
581
  }
449
582
  // outcome === 'rejected'. Distinguish ONLY transient-vs-deny (never which
@@ -458,9 +591,9 @@ let AuthController = class AuthController {
458
591
  // returns the NEW count; when it first equals the limiter's threshold this attempt
459
592
  // is the one that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here
460
593
  // (the edge), never on the already-locked check-gate above (which would flood).
461
- // NO notify: this is pre-auth (keyed on the typed hint; the resolver hasn't run), so
462
- // we cannot resolve the real owner's email — mailing a bare hint could reach a
463
- // non-owner / confirm the account exists (recordSecurityEvent enforces this too).
594
+ // NO notify: this is pre-auth (the caller has not proven ownership), and mailing the
595
+ // account on every lockout would let anyone email-bomb it / confirm it exists
596
+ // (recordSecurityEvent enforces this too).
464
597
  if (this.loginLimiter != null) {
465
598
  const count = await this.loginLimiter.recordFailure(loginKey);
466
599
  if (count === this.loginLimiter.maxAttempts) {
@@ -472,6 +605,20 @@ let AuthController = class AuthController {
472
605
  });
473
606
  }
474
607
  }
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) {
614
+ this.recordSecurityEvent({
615
+ action: 'auth.login.ip_locked',
616
+ identityHint: null,
617
+ attributes: { reason: 'rate_limit' },
618
+ notify: false,
619
+ });
620
+ }
621
+ }
475
622
  genericUnauthorized();
476
623
  }
477
624
  /**
@@ -600,6 +747,8 @@ let AuthController = class AuthController {
600
747
  }
601
748
  // 4) Re-resolve the subtree for the NEW active node (self-node fallback if unwired /
602
749
  // empty / thrown — non-widening, mirrors login). The new subtree REPLACES the old.
750
+ // The target node's version is read first and stamped beside it (Phase 1C).
751
+ const subtreeVersion = await this.subtreeVersionFor(targetNodeId);
603
752
  const resolved = await this.resolveScopeSubtree({
604
753
  kind: 'user',
605
754
  id: claims.id,
@@ -610,12 +759,18 @@ let AuthController = class AuthController {
610
759
  isRoot: false,
611
760
  });
612
761
  const subtree = resolved ?? [targetNodeId];
762
+ // ADR-0035: the TARGET node's coarse grants + live roles (roles are per node). No projection
763
+ // → the facts are stripped and the first request under the new scope reloads them (and the
764
+ // roles with them).
765
+ const targetFacts = await this.projectFactsFor(claims.id, targetNodeId);
613
766
  // 5) RE-BIND the current session in place (same token — no rotate). The bound tenantId
614
767
  // becomes targetNodeId (→ activeScopeNodeId) + scopeSubtreeNodeIds becomes the NEW
615
768
  // subtree; the OLD node's subtree + cachedFacts are DROPPED (stale authority gone).
616
769
  // A failed re-bind (session gone / Redis down) → deny (fail-closed, the old binding
617
770
  // already effectively dead).
618
- const ok = await this.tokenBinder.rebindActiveScope(token, claims.sessionId, targetNodeId, subtree);
771
+ const ok = await this.tokenBinder.rebindActiveScope(token, claims.sessionId, targetNodeId, subtree,
772
+ // Stamp only a RESOLVED subtree; the self-node fallback stays unstamped.
773
+ resolved !== undefined ? subtreeVersion : undefined, targetFacts ?? null, (0, session_token_binder_js_1.liveRoleIds)(targetFacts));
619
774
  if (!ok) {
620
775
  throw new common_1.HttpException({ error: 'scope_switch_denied' }, common_1.HttpStatus.FORBIDDEN);
621
776
  }
@@ -642,29 +797,27 @@ let AuthController = class AuthController {
642
797
  * 501 here, which would itself be an enumeration signal on hint validity).
643
798
  */
644
799
  async forgotPassword(body, ip) {
800
+ // F5: the hint is looked up EXACTLY as typed — the DB collation (utf8mb4_unicode_ci)
801
+ // decides which spellings are one account. Never rewritten.
645
802
  const hint = typeof body?.identityHint === 'string' ? body.identityHint : '';
646
- const resolver = this.credentialResolver;
647
- const store = this.resetTokenStore;
648
- const sender = this.emailSender;
649
803
  // ── Rate-limit gate (feature #141) — a REQUEST-RATE cap, NOT a failure cap. ──────
650
804
  // forgot-password is ALWAYS 204 (a 429 would itself leak account existence), so we
651
805
  // COUNT every forgot request against two budgets and, when EITHER is exceeded,
652
806
  // SILENTLY skip the email-send while STILL returning 204. Two keys: the client IP
653
- // (stops one host spraying many hints) and the presented hint (stops one account
654
- // being email-bombed across a rotating IP pool). Each limiter fails OPEN on a Redis
655
- // blip (its own posture) so an outage never silently drops a legitimate reset email.
807
+ // (stops one host spraying many hints) and the ACCOUNT the hint resolves to (stops one
808
+ // account being email-bombed across a rotating IP pool and across spelling variants —
809
+ // review F1). Each limiter fails OPEN on a Redis blip (its own posture) so an outage
810
+ // never silently drops a legitimate reset email.
656
811
  // Defense-in-depth: a normal Express `req.ip` is always populated, but an empty ip
657
812
  // (a non-standard adapter/harness) would collapse EVERY client into one shared
658
813
  // `forgot:ip:` bucket → a global lockout. Fall back to a fixed sentinel so a blank ip
659
- // is its own bucket, never a shared one (mirrors the `hint === ''` guard below).
814
+ // is its own bucket, never a shared one.
660
815
  const ipKey = `forgot:ip:${ip || 'unknown-ip'}`;
661
- const hintKey = `forgot:${hint}`;
662
- // Check BOTH budgets first (a `false` from either = over budget = skip the send).
663
- // Both checks run so both counters see this request (below), independent of order.
664
- const ipAllowed = this.forgotLimiter == null || (await this.forgotLimiter.check(ipKey));
665
- // Only meter the hint budget when there IS a hint (an empty hint is the unknown-user
666
- // path — it never sends anyway, and we must not let a blank hint burn a shared key).
667
- const hintAllowed = this.forgotHintLimiter == null || hint === '' || (await this.forgotHintLimiter.check(hintKey));
816
+ // The IP budget applies only when bootstrap confirmed an explicit `trust proxy`
817
+ // (otherwise every client shares the proxy's IP and 5 requests would silently drop
818
+ // EVERY reset email). The per-account budget below is unaffected.
819
+ const forgotIpLimiter = this.perIp(this.forgotLimiter);
820
+ const ipAllowed = forgotIpLimiter == null || (await forgotIpLimiter.check(ipKey));
668
821
  // Count THIS request toward each budget (there is no pass/fail signal — every forgot
669
822
  // call is an "attempt"; the counter is what trips the silent skip). Best-effort +
670
823
  // detached from the 204 (never awaited into the response timing; a throw is swallowed
@@ -681,10 +834,10 @@ let AuthController = class AuthController {
681
834
  // itself confirm the account exists AND could be used to email-bomb the owner; the
682
835
  // event is audit-only. NO secret in attributes (a reason code only; the hint is the
683
836
  // actor identity, acceptable in the audit row, added by recordSecurityEvent).
684
- void this.forgotLimiter
837
+ void forgotIpLimiter
685
838
  ?.recordFailure(ipKey)
686
839
  .then((count) => {
687
- if (this.forgotLimiter != null && count === this.forgotLimiter.maxAttempts) {
840
+ if (count === forgotIpLimiter.maxAttempts) {
688
841
  this.recordSecurityEvent({
689
842
  action: 'auth.forgot_password.throttled',
690
843
  identityHint: hint || null,
@@ -694,53 +847,75 @@ let AuthController = class AuthController {
694
847
  }
695
848
  })
696
849
  .catch(() => { });
697
- // Same edge-audit for the per-HINT budget (only when there IS a hint — never audit
698
- // an empty-hint edge, mirroring the `hint !== ''` gate on the hint limiter itself).
699
- // A DISTINCT action key (`hint_throttled` vs the IP site's `throttled`) so the two
700
- // abuse shapes are distinguishable in the audit log: the per-hint edge catches ONE
701
- // account being email-bombed across ROTATING IPs (each IP staying under its own
702
- // budget), which the per-IP audit would NEVER see. Detached + swallowed, 204
703
- // unchanged, no notify (a hint-throttle notice would confirm the account + could
704
- // itself be the email-bomb).
850
+ // The per-ACCOUNT budget and the found-path delivery run DETACHED (no await): resolving
851
+ // the account and minting/sending must never leak into the response timing. Only when
852
+ // there IS a hint (an empty hint never sends, and must not burn a shared key).
705
853
  if (hint !== '') {
706
- void this.forgotHintLimiter
707
- ?.recordFailure(hintKey)
708
- .then((count) => {
709
- if (this.forgotHintLimiter != null && count === this.forgotHintLimiter.maxAttempts) {
710
- this.recordSecurityEvent({
711
- action: 'auth.forgot_password.hint_throttled',
712
- identityHint: hint,
713
- attributes: { reason: 'rate_limit' },
714
- notify: false,
715
- });
716
- }
717
- })
718
- .catch(() => { });
719
- }
720
- // Only the matching branch does work — but do it DETACHED (no await) so its
721
- // duration never leaks into the response timing. Absent seams → skip entirely.
722
- // Throttled (either budget exceeded) → SILENTLY skip the send (still 204 below).
723
- if (ipAllowed && hintAllowed && hint && resolver && store && sender) {
724
- // Fire-and-forget: never awaited before the 204. `.catch` swallows any
725
- // store/email failure (anti-enumeration — a failure must NOT change the
726
- // response, and must never surface as an unhandled rejection).
727
- void this.deliverResetLink(hint, resolver, store, sender).catch(() => {
854
+ void this.meterAndDeliverReset(hint, ipAllowed).catch(() => {
728
855
  /* swallow — anti-enumeration: never let a store/email failure be observable */
729
856
  });
730
857
  }
731
858
  // ALWAYS 204, immediately, identical body for every branch (found/unknown/throttled).
732
859
  }
860
+ /**
861
+ * The detached per-account half of {@link forgotPassword} (review F1):
862
+ * 1. resolve the RAW hint through the same {@link CredentialResolver} the authenticator
863
+ * uses (the DB collation decides which spellings are one account);
864
+ * 2. meter the per-account budget — `forgot:pid:<principalId>`, or the canonical-hash
865
+ * fallback key for an unknown hint — and audit `auth.forgot_password.hint_throttled`
866
+ * ONCE on the edge (distinct from the IP site's `throttled`, so "one account bombed
867
+ * across rotating IPs" is visible). No notify (it would itself be the email-bomb);
868
+ * 3. send only when both budgets allow, the account resolved, and the reset seams exist.
869
+ * A lookup error still meters (fallback key) and skips the send (still 204).
870
+ */
871
+ async meterAndDeliverReset(hint, ipAllowed) {
872
+ const resolver = this.credentialResolver;
873
+ let resolved = null;
874
+ let lookupFailed = false;
875
+ if (resolver != null) {
876
+ try {
877
+ resolved = await resolver.resolve(hint);
878
+ }
879
+ catch {
880
+ lookupFailed = true;
881
+ }
882
+ }
883
+ const principalId = (0, canonical_hint_js_1.principalIdOf)(resolved);
884
+ const hintKey = (0, canonical_hint_js_1.forgotHintKey)(hint, principalId);
885
+ let hintAllowed = true;
886
+ const limiter = this.forgotHintLimiter;
887
+ if (limiter != null) {
888
+ // Fail OPEN on a (consumer) limiter rejection, like AttemptLimiter itself.
889
+ hintAllowed = await limiter.check(hintKey).catch(() => true);
890
+ const count = await limiter.recordFailure(hintKey).catch(() => 0);
891
+ if (count === limiter.maxAttempts) {
892
+ this.recordSecurityEvent({
893
+ action: 'auth.forgot_password.hint_throttled',
894
+ identityHint: hint,
895
+ attributes: { reason: 'rate_limit' },
896
+ notify: false,
897
+ });
898
+ }
899
+ }
900
+ const store = this.resetTokenStore;
901
+ const sender = this.emailSender;
902
+ if (!ipAllowed || !hintAllowed || lookupFailed || resolved === null || !principalId)
903
+ return;
904
+ if (!store || !sender)
905
+ return; // absent seams → never sends (still 204)
906
+ await this.deliverResetLink(resolved, store, sender);
907
+ }
733
908
  /**
734
909
  * The detached found-path work for {@link forgotPassword}: mint a raw token, issue
735
910
  * its SHA-256 hash to the store (30-min TTL), resolve the recipient email via the
736
911
  * profile resolver, and send the framework-composed reset link. Runs OUTSIDE the
737
- * request/response timing (fire-and-forget) so it cannot form a timing oracle. Never
738
- * logs the token or the email.
912
+ * request/response timing (fire-and-forget) so it cannot form a timing oracle. This
913
+ * method itself writes no log line — but the raw token and the email ARE handed to the
914
+ * bound {@link EmailSender}, and the dev-only `LogEmailSender` (the default ONLY when
915
+ * NODE_ENV is `development`/`test`) prints the whole message, link included, to stdout.
916
+ * Any other environment must supply a real sender or `emailSender: 'disabled'`.
739
917
  */
740
- async deliverResetLink(hint, resolver, store, sender) {
741
- const resolved = await resolver.resolve(hint);
742
- if (resolved === null)
743
- return; // unknown hint — no token, no email (still 204)
918
+ async deliverResetLink(resolved, store, sender) {
744
919
  const claims = resolved.claims;
745
920
  const principalId = typeof claims.id === 'string' ? claims.id : '';
746
921
  if (!principalId)
@@ -800,7 +975,10 @@ let AuthController = class AuthController {
800
975
  // Defense-in-depth empty-ip fallback (see forgotPassword): a blank ip must be its
801
976
  // own bucket, never collapse all callers into one shared `reset:ip:` lockout bucket.
802
977
  const resetKey = `reset:ip:${ip || 'unknown-ip'}`;
803
- if (this.resetLimiter != null && !(await this.resetLimiter.check(resetKey))) {
978
+ // Enforced only when bootstrap confirmed an explicit `trust proxy`; otherwise every
979
+ // client shares the proxy's IP and 10 bad tokens would 429 EVERY reset.
980
+ const resetIpLimiter = this.perIp(this.resetLimiter);
981
+ if (resetIpLimiter != null && !(await resetIpLimiter.check(resetKey))) {
804
982
  // Already locked → generic 429. We do NOT audit here: check() returns false on
805
983
  // EVERY attempt once locked, so this gate would flood one event per attempt. The
806
984
  // lockout is audited ONCE on the TRANSITION instead — in the invalid-token branch
@@ -814,7 +992,11 @@ let AuthController = class AuthController {
814
992
  const store = this.resetTokenStore;
815
993
  const writer = this.credentialWriter;
816
994
  const hasher = this.passwordHasher;
817
- if (store === undefined || store === null || writer === undefined || writer === null || hasher === undefined) {
995
+ if (store === undefined ||
996
+ store === null ||
997
+ writer === undefined ||
998
+ writer === null ||
999
+ hasher === undefined) {
818
1000
  throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
819
1001
  }
820
1002
  // Consume the token (single-use, TTL-checked). Unknown/expired/consumed → 400.
@@ -828,9 +1010,9 @@ let AuthController = class AuthController {
828
1010
  // invalid-token attempt, only the transition). No hint/principal is available from a
829
1011
  // bare token — that's fine, the event is audited without one. NO notify (no
830
1012
  // resolvable owner from a token) and NO secret in attributes (a reason code only).
831
- if (this.resetLimiter != null) {
832
- const count = await this.resetLimiter.recordFailure(resetKey);
833
- if (count === this.resetLimiter.maxAttempts) {
1013
+ if (resetIpLimiter != null) {
1014
+ const count = await resetIpLimiter.recordFailure(resetKey);
1015
+ if (count === resetIpLimiter.maxAttempts) {
834
1016
  this.recordSecurityEvent({
835
1017
  action: 'auth.password_reset.locked',
836
1018
  attributes: { reason: 'rate_limit' },
@@ -844,21 +1026,28 @@ let AuthController = class AuthController {
844
1026
  await writer.setPassword(principalId, newHash);
845
1027
  // A SUCCESSFUL reset clears the IP counter (do not carry earlier bad-token attempts
846
1028
  // forward — the caller proved a valid single-use token).
847
- await this.resetLimiter?.reset(resetKey);
1029
+ await resetIpLimiter?.reset(resetKey);
848
1030
  // Reset kills ALL sessions (unlike change-password, which keeps the current one).
1031
+ // The sweeper now SURFACES a failed Redis unbind (Phase 3 review P1); the other reset tokens
1032
+ // are still burned before that error propagates.
1033
+ let sweepError;
849
1034
  if (this.sessionSweeper !== undefined && this.sessionSweeper !== null) {
850
- await this.sessionSweeper.revokeAllForPrincipal(principalId);
1035
+ await this.sessionSweeper.revokeAllForPrincipal(principalId).catch((err) => {
1036
+ sweepError = err;
1037
+ });
851
1038
  }
852
1039
  // Burn any other outstanding reset tokens for the principal (single active reset).
853
1040
  await store.invalidateAllForPrincipal(principalId);
1041
+ if (sweepError !== undefined)
1042
+ throw sweepError;
854
1043
  }
855
1044
  /**
856
1045
  * `POST /auth/mfa` — complete a pending MFA login. Consumes the single-use
857
1046
  * challenge handle (unknown/expired/forged → same generic 401 as a bad code),
858
1047
  * then BRANCHES on the presented factor:
859
- * • `'totp'` → RE-RUNS `authenticate(hint, [password, totp])` with the code
860
- * (the authenticator has no standalone factor-verify seam — the full attempt
861
- * IS the verify). Success → establish + tokens.
1048
+ * • `'totp'` → the MFA STEP-UP (M6): verify ONLY the code against the principal the
1049
+ * password step recorded (no password is held or re-run). Rate-limited per account
1050
+ * (#9). See {@link completeViaTotp}.
862
1051
  * • `'recovery_code'` → the lost-device escape hatch: verify a one-time
863
1052
  * recovery code (a separate branch, NOT the core factor loop — recovery codes
864
1053
  * are N single-use Argon2 hashes, incompatible with the pure one-secret
@@ -866,7 +1055,8 @@ let AuthController = class AuthController {
866
1055
  * • any other factor → generic 401 (no leak of "unsupported factor").
867
1056
  *
868
1057
  * Anti-enumeration: unknown/forged/expired challenge, unknown hint, wrong code,
869
- * and wrong recovery code ALL return the identical generic 401.
1058
+ * a locked TOTP step, and a wrong recovery code ALL return the identical generic 401.
1059
+ * A challenge-store outage is a retryable 503.
870
1060
  */
871
1061
  async mfa(body) {
872
1062
  const challengeToken = typeof body?.challengeToken === 'string' ? body.challengeToken : '';
@@ -876,32 +1066,76 @@ let AuthController = class AuthController {
876
1066
  // is indistinguishable from a wrong code — generic 401, no leak. Consumed ONCE
877
1067
  // here, up front, so BOTH branches (totp + recovery) share the single-use
878
1068
  // semantics (a spent/forged challenge never reaches either verify path).
879
- const pending = this.pendingMfa.consume(challengeToken);
1069
+ let pending;
1070
+ try {
1071
+ pending = await this.pendingMfa.consume(challengeToken);
1072
+ }
1073
+ catch {
1074
+ retryableUnavailable(); // challenge store (Redis) unavailable — transient, not a deny
1075
+ }
880
1076
  if (pending === null)
881
1077
  genericUnauthorized();
882
1078
  // Recovery-code branch — the lost-device escape hatch (a separate verify path).
883
1079
  if (factor === 'recovery_code') {
884
1080
  return this.completeViaRecoveryCode(pending, code);
885
1081
  }
886
- // Phase 0b: only 'totp' is a real re-authenticate factor. Any other → generic
887
- // 401 (no leak of "unsupported factor").
1082
+ // Only 'totp' is a step-up factor. Any other → generic 401 (no "unsupported" leak).
888
1083
  if (factor !== 'totp')
889
1084
  genericUnauthorized();
890
- const result = await this.authenticator.authenticate(pending.identityHint, [passwordCredential(pending.password), totpCredential(code)]);
891
- if (result.outcome === 'authenticated') {
892
- const principal = requirePrincipal(result);
893
- const est = await this.establishOrReject(principal);
894
- await this.bindSession(est, principal);
895
- // Same inline-facts enrichment as the password-login success (one round-trip).
896
- return this.sessionSuccessWithFacts(est, principal);
1085
+ return this.completeViaTotp(pending, code);
1086
+ }
1087
+ /**
1088
+ * The `/auth/mfa` TOTP branch (M6 + #9). The consumed challenge proves the password
1089
+ * step passed for `pending.principalId`; this verifies ONLY the code, through the
1090
+ * {@link MfaStepUp} (same `OrTotpVerifier` + replay guard as the authenticator, principal
1091
+ * minted by the identity broker).
1092
+ *
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.
1098
+ * - success → establish + bind, then reset the TOTP counter AND the login counter (the
1099
+ * login counter is reset only here, once authentication fully succeeded).
1100
+ */
1101
+ async completeViaTotp(pending, code) {
1102
+ const mfaKey = `mfa:pid:${pending.principalId}`;
1103
+ if (this.mfaLimiter != null && !(await this.mfaLimiter.check(mfaKey))) {
1104
+ genericUnauthorized();
897
1105
  }
898
- // Anything else (wrong code → rejected; still-outstanding → mfa_required) is a
899
- // failure. A transient blip is retryable; everything else is a generic 401.
900
- // (A repeat mfa_required here means the code did not satisfy the factor.)
901
- if (result.reason === REASON_AUTH_UNAVAILABLE) {
1106
+ const stepUp = this.mfaStepUp;
1107
+ if (stepUp == null)
1108
+ genericUnauthorized(); // not wired → fail closed
1109
+ let outcome;
1110
+ try {
1111
+ outcome = await stepUp.verify(pending, totpCredential(code));
1112
+ }
1113
+ catch {
902
1114
  retryableUnavailable();
903
1115
  }
904
- genericUnauthorized();
1116
+ if (outcome.outcome === 'unavailable')
1117
+ retryableUnavailable();
1118
+ 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
+ }
1129
+ }
1130
+ genericUnauthorized();
1131
+ }
1132
+ const principal = outcome.principal;
1133
+ const est = await this.establishOrReject(principal);
1134
+ await this.bindSession(est, principal);
1135
+ await this.mfaLimiter?.reset(mfaKey);
1136
+ await this.loginLimiter?.reset((0, canonical_hint_js_1.loginHintKey)(pending.identityHint, pending.principalId));
1137
+ // Same inline-facts enrichment as the password-login success (one round-trip).
1138
+ return this.sessionSuccessWithFacts(est, principal);
905
1139
  }
906
1140
  /**
907
1141
  * The `/auth/mfa` RECOVERY-CODE branch (spec §3). The lost-device user completes
@@ -930,6 +1164,10 @@ let AuthController = class AuthController {
930
1164
  const resolved = await this.credentialResolver.resolve(pending.identityHint).catch(() => null);
931
1165
  if (resolved == null)
932
1166
  genericUnauthorized();
1167
+ // M6: the credential must still be the one the password step was checked against
1168
+ // (same principal, same stored credential hash) — else the challenge is void.
1169
+ if (!(0, mfa_step_up_js_1.challengeMatches)(resolved, pending))
1170
+ genericUnauthorized();
933
1171
  const claims = resolved.claims;
934
1172
  const principalId = typeof claims.id === 'string' ? claims.id : '';
935
1173
  if (!principalId)
@@ -969,14 +1207,30 @@ let AuthController = class AuthController {
969
1207
  }
970
1208
  genericUnauthorized();
971
1209
  }
972
- // A correct recovery code → clear the counter (do not carry earlier failures).
1210
+ // A correct recovery code → clear the counter (do not carry earlier failures), and —
1211
+ // authentication being complete — the login counter too (#9).
973
1212
  await this.recoveryLimiter?.reset(recoveryKey);
1213
+ await this.loginLimiter?.reset((0, canonical_hint_js_1.loginHintKey)(pending.identityHint, principalId));
974
1214
  // The pending challenge is proof the password already passed (§3) — no password
975
1215
  // re-verify. Mint the Principal directly from the resolved claims.
976
1216
  const principal = this.principalFromClaims(claims);
977
1217
  if (principal === null)
978
1218
  genericUnauthorized();
979
1219
  const est = await this.establishOrReject(principal);
1220
+ // Phase 3B review P1: persist the restriction on the DURABLE session BEFORE any token leaves,
1221
+ // so a binding rebuilt after a Redis loss is restricted too. A failed mark → the session is
1222
+ // revoked and the login answers a retryable 503 (fail closed: an unmarked recovery session
1223
+ // could later be rebuilt unrestricted). No store wired → no rebuild is possible either.
1224
+ const restrictions = this.sessionRestrictions;
1225
+ if (restrictions !== undefined && restrictions !== null) {
1226
+ try {
1227
+ await restrictions.markMustReenroll(est.session.sessionId);
1228
+ }
1229
+ catch {
1230
+ await this.sessionAuthority.revoke(est.session.sessionId).catch(() => undefined);
1231
+ retryableUnavailable();
1232
+ }
1233
+ }
980
1234
  // Enforced re-enroll (spec §4): a recovery-code login is lower-assurance, so the
981
1235
  // session is bound with mustReenroll:true — the enforcement gate then restricts it
982
1236
  // to the re-secure allowlist until a fresh device is confirmed.
@@ -1118,18 +1372,31 @@ let AuthController = class AuthController {
1118
1372
  /**
1119
1373
  * `POST /auth/refresh` — rotate the refresh token. Reuse/invalid/expired →
1120
1374
  * generic 401 (`session_rejected`; the engine already killed the lineage,
1121
- * ADR-0015). ⚠ See the class header: the engine's rotate collapses a transient
1375
+ * ADR-0015, and `HotRevokingSessionRecordStore` unbinds it in Redis so the live access
1376
+ * token dies on its next request — M-A). The new access token is bound for ≤ the access
1377
+ * TTL; the previous one keeps a 30 s grace (#8, D7). If the session's claims cannot be
1378
+ * carried forward (`rebindOnRotate` → false) the refresh is a generic 401 — never an
1379
+ * unbound token. ⚠ See the class header: the engine's rotate collapses a transient
1122
1380
  * store outage into the SAME reject, so refresh cannot emit the ADR-0016 503 from
1123
1381
  * this seam today (flagged core-API gap).
1124
1382
  */
1125
1383
  async refresh(body) {
1126
1384
  const refreshToken = typeof body?.refreshToken === 'string' ? body.refreshToken : '';
1385
+ // Phase 2 review F1 (ADR-0035): ask Redis BEFORE the durable rotate spends the refresh token.
1386
+ // Unreachable → retryable 503 (ADR-0016) and the token stays valid for the client's retry.
1387
+ const probe = this.tokenBinder;
1388
+ if (typeof probe?.isAvailable === 'function' && !(await probe.isAvailable())) {
1389
+ retryableUnavailable();
1390
+ }
1127
1391
  try {
1128
1392
  const est = await this.sessionAuthority.rotate(refreshToken);
1129
1393
  // Carry the principal claims forward to the NEW access token (the durable
1130
1394
  // session row lacks roles/kind — the session-claims entry has them).
1131
1395
  if (this.tokenBinder !== undefined) {
1132
- const ttlMs = Math.max(est.session.expiresAtMs - est.session.issuedAtMs, 1);
1396
+ // #8: ≤ the access TTL (`clamp(expiresAtMs − now, 1, accessTtlMs)`), NOT
1397
+ // `expiresAtMs − issuedAtMs` — issuedAtMs is the original LOGIN, so that grew with
1398
+ // the session's age (a day-10 refresh minted a ~10-day token).
1399
+ const ttlMs = this.accessTtlFor(est);
1133
1400
  // EAGER re-projection at REFRESH (ADR-0011): re-read the principal's coarse grants
1134
1401
  // so the cache stays warm past the version the LOGIN projected at (a role change
1135
1402
  // between login and this refresh is picked up here). The rotated session carries
@@ -1137,15 +1404,28 @@ let AuthController = class AuthController {
1137
1404
  // rebindOnRotate carries the existing `cachedFacts` forward unchanged (today's
1138
1405
  // behavior); a fresh CoarseGrants → replaces it; `null` (projection yielded
1139
1406
  // nothing) → strips it (→ next request DB-reloads; fail-closed).
1140
- const freshFacts = await this.projectFactsFor(est.session.principalId, est.session.tenantId);
1141
- // EAGER subtree RE-RESOLUTION at REFRESH (the stale-subtree-after-move fix): re-read the
1142
- // principal's closure subtree so a session whose node was re-parented (moveNode/deleteNode
1143
- // bumped its version) gets its CORRECT, possibly-smaller subtree — REPLACING the stale one
1144
- // carried from login. One indexed closure query at refresh (NOT per request — parity
1145
- // preserved). `undefined` (no resolver / empty / threw) → rebindOnRotate carries the
1146
- // existing subtree forward (fail-safe, non-widening — never fails the refresh).
1147
- const freshSubtree = await this.resolveScopeSubtreeFor(est.session.tenantId);
1148
- await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, freshFacts, freshSubtree);
1407
+ //
1408
+ // ACTIVE NODE (review follow-up): after a scope-switch the session acts under the node in
1409
+ // its claims, not the durable login tenant. Project facts, roles and subtree for THAT node
1410
+ // (rebindOnRotate applies them only if the claims are still on it). No claims (Redis loss)
1411
+ // → the durable tenant, which is what a rebuild binds.
1412
+ const peek = this.tokenBinder;
1413
+ const activeTenant = (typeof peek.sessionTenant === 'function'
1414
+ ? await peek.sessionTenant(est.session.sessionId)
1415
+ : null) ?? est.session.tenantId;
1416
+ const fresh = await this.projectForNode(est.session.principalId, activeTenant);
1417
+ const rebound = await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, fresh.facts, fresh.subtree, fresh.subtreeVersion, fresh.roles, activeTenant);
1418
+ // The claims were gone (Redis loss) or a logout won the race (`SET XX` refused):
1419
+ // SILENT RECOVERY (invariant #12, ADR-0013/0035) rebuilds them from durable state; a
1420
+ // rebuild that cannot be proven safe is a generic 401 — never an unbound token.
1421
+ if (rebound !== true) {
1422
+ const durableFresh = activeTenant === est.session.tenantId
1423
+ ? fresh
1424
+ : await this.projectForNode(est.session.principalId, est.session.tenantId);
1425
+ const recovered = await this.rebuildBinding(est, ttlMs, durableFresh);
1426
+ if (!recovered)
1427
+ genericUnauthorized();
1428
+ }
1149
1429
  }
1150
1430
  return toSessionSuccess(est);
1151
1431
  }
@@ -1156,6 +1436,101 @@ let AuthController = class AuthController {
1156
1436
  genericUnauthorized();
1157
1437
  }
1158
1438
  }
1439
+ /**
1440
+ * The refresh-time projection for one node: coarse grants + live roles (ADR-0035) and the
1441
+ * subtree with its version stamp (Phase 1C: the version is read FIRST). Each part is best-effort
1442
+ * exactly as at login (`undefined` = carry / omit, `null` facts = strip).
1443
+ */
1444
+ async projectForNode(principalId, tenantId) {
1445
+ const facts = await this.projectFactsFor(principalId, tenantId);
1446
+ const subtreeVersion = await this.subtreeVersionFor(tenantId);
1447
+ const subtree = await this.resolveScopeSubtreeFor(tenantId);
1448
+ return { facts, roles: (0, session_token_binder_js_1.liveRoleIds)(facts), subtree, subtreeVersion };
1449
+ }
1450
+ /**
1451
+ * SILENT RECOVERY of a lost session binding (invariant #12, ADR-0013, ADR-0035, GAPS #5).
1452
+ * `rotate()` has already proved the durable session is active and inside its idle and absolute
1453
+ * windows, and returned its principal + tenant. The rebuilt claims NEVER carry more authority
1454
+ * than the session had (Phase 3B review P1); they come from durable sources only:
1455
+ * - principal + tenant: the durable session record (the LOGIN node; a scope-switch is not
1456
+ * durable, so a rebuilt session returns to the node it was opened on);
1457
+ * - the RE-ENROLL restriction: `shield_identity.session.s_must_reenroll`, set at a
1458
+ * recovery-code login. A restricted session rebuilds `mustReenroll: true` and NEVER root
1459
+ * (exactly how the recovery login bound it);
1460
+ * - the account status: a disabled user gets no rebuild;
1461
+ * - roles: the live projection (ADR-0035) — `[]` when none is available (non-widening; the
1462
+ * first request's gate-5 reload fills them, because no facts are cached then);
1463
+ * - `isRoot` (unrestricted sessions only): the server-side root allowlist, never a claim;
1464
+ * - subtree: the resolver (absent → the self-node fallback, non-widening);
1465
+ * - kind `user` (sessions are human; service accounts authenticate with keys).
1466
+ * No restriction store, or any error reading it → no rebuild (fail closed). The claims entry is
1467
+ * written with `NX`, then the durable session is READ AGAIN: if a logout or revoke landed
1468
+ * meanwhile, the rebuilt binding is dropped and the refresh fails. Returns `true` when the
1469
+ * session is bound and still active. Any error → `false` (the caller answers 401).
1470
+ */
1471
+ async rebuildBinding(est, ttlMs, fresh) {
1472
+ const binder = this.tokenBinder;
1473
+ const records = this.sessionRecords;
1474
+ const restrictions = this.sessionRestrictions;
1475
+ if (binder === undefined || records === undefined || records === null)
1476
+ return false;
1477
+ if (restrictions === undefined || restrictions === null)
1478
+ return false;
1479
+ if (typeof binder.bindRecovered !== 'function')
1480
+ return false;
1481
+ const { sessionId, principalId, tenantId } = est.session;
1482
+ if (!sessionId || !principalId || !tenantId)
1483
+ return false;
1484
+ let durableFacts;
1485
+ try {
1486
+ durableFacts = await restrictions.rebuildFacts(sessionId, principalId);
1487
+ }
1488
+ catch {
1489
+ return false; // cannot prove the session's restriction → no rebuild (fail closed)
1490
+ }
1491
+ if (durableFacts.accountActive !== true)
1492
+ return false;
1493
+ const restricted = durableFacts.mustReenroll !== false;
1494
+ const claims = {
1495
+ id: principalId,
1496
+ tenantId,
1497
+ roleIds: fresh.roles !== undefined ? [...fresh.roles] : [],
1498
+ kind: 'user',
1499
+ isRoot: !restricted && Array.isArray(this.rootIds) && this.rootIds.includes(principalId),
1500
+ sessionId,
1501
+ mustReenroll: restricted,
1502
+ ...(fresh.facts ? { cachedFacts: (0, session_token_binder_js_1.factsSlot)(fresh.facts) } : {}),
1503
+ ...(fresh.subtree !== undefined && fresh.subtree.length > 0
1504
+ ? { scopeSubtreeNodeIds: [...fresh.subtree] }
1505
+ : {}),
1506
+ ...(fresh.subtree !== undefined &&
1507
+ fresh.subtree.length > 0 &&
1508
+ fresh.subtreeVersion !== undefined
1509
+ ? { scopeSubtreeVersion: fresh.subtreeVersion }
1510
+ : {}),
1511
+ };
1512
+ try {
1513
+ const written = await binder.bindRecovered(est.tokens.accessToken, claims, ttlMs);
1514
+ if (!written)
1515
+ return false;
1516
+ const durable = await records.findSessionById(sessionId);
1517
+ if (durable === null || durable === undefined || durable.status !== 'active') {
1518
+ await binder.unbindSession(sessionId);
1519
+ return false;
1520
+ }
1521
+ return true;
1522
+ }
1523
+ catch {
1524
+ // Redis or the durable store failed mid-rebuild → leave nothing bound (best-effort), refuse.
1525
+ try {
1526
+ await binder.unbindSession(sessionId);
1527
+ }
1528
+ catch {
1529
+ // best-effort
1530
+ }
1531
+ return false;
1532
+ }
1533
+ }
1159
1534
  /**
1160
1535
  * `POST /auth/logout` — revoke the session. Idempotent + leak-free: revoking an
1161
1536
  * unknown or already-revoked session still returns 204 (the engine's `revoke`
@@ -1198,8 +1573,9 @@ __decorate([
1198
1573
  (0, common_1.Post)('login'),
1199
1574
  (0, common_1.HttpCode)(common_1.HttpStatus.OK),
1200
1575
  __param(0, (0, common_1.Body)()),
1576
+ __param(1, (0, common_1.Ip)()),
1201
1577
  __metadata("design:type", Function),
1202
- __metadata("design:paramtypes", [Object]),
1578
+ __metadata("design:paramtypes", [Object, String]),
1203
1579
  __metadata("design:returntype", Promise)
1204
1580
  ], AuthController.prototype, "login", null);
1205
1581
  __decorate([
@@ -1295,24 +1671,42 @@ exports.AuthController = AuthController = __decorate([
1295
1671
  __param(13, (0, common_2.Optional)()),
1296
1672
  __param(13, (0, common_1.Inject)(tokens_js_1.SHIELD_LOGIN_LIMITER)),
1297
1673
  __param(14, (0, common_2.Optional)()),
1298
- __param(14, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_LIMITER)),
1674
+ __param(14, (0, common_1.Inject)(tokens_js_1.SHIELD_LOGIN_IP_LIMITER)),
1299
1675
  __param(15, (0, common_2.Optional)()),
1300
- __param(15, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_LIMITER)),
1676
+ __param(15, (0, common_1.Inject)(trust_proxy_check_js_1.TrustProxyBootCheck)),
1301
1677
  __param(16, (0, common_2.Optional)()),
1302
- __param(16, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_HINT_LIMITER)),
1678
+ __param(16, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_LIMITER)),
1303
1679
  __param(17, (0, common_2.Optional)()),
1304
- __param(17, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LIMITER)),
1680
+ __param(17, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_LIMITER)),
1305
1681
  __param(18, (0, common_2.Optional)()),
1306
- __param(18, (0, common_1.Inject)(tokens_js_1.SHIELD_AUDIT_SINK)),
1682
+ __param(18, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_HINT_LIMITER)),
1307
1683
  __param(19, (0, common_2.Optional)()),
1308
- __param(19, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_FACTS_PROJECTOR)),
1684
+ __param(19, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LIMITER)),
1309
1685
  __param(20, (0, common_2.Optional)()),
1310
- __param(20, (0, common_1.Inject)(tokens_js_1.SHIELD_SCOPE_SUBTREE_RESOLVER)),
1686
+ __param(20, (0, common_1.Inject)(tokens_js_1.SHIELD_AUDIT_SINK)),
1311
1687
  __param(21, (0, common_2.Optional)()),
1312
- __param(21, (0, common_1.Inject)(tokens_js_1.SHIELD_MEMBERSHIP_CHECKER)),
1688
+ __param(21, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_FACTS_PROJECTOR)),
1313
1689
  __param(22, (0, common_2.Optional)()),
1314
- __param(22, (0, common_1.Inject)(tokens_js_1.SHIELD_PERMISSION_FACTS)),
1315
- __metadata("design:paramtypes", [Object, Object, pending_mfa_store_js_1.PendingMfaStore, Function, Object, Object, Object, Object, Object, Object, Object, String, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
1690
+ __param(22, (0, common_1.Inject)(tokens_js_1.SHIELD_SCOPE_SUBTREE_RESOLVER)),
1691
+ __param(23, (0, common_2.Optional)()),
1692
+ __param(23, (0, common_1.Inject)(tokens_js_1.SHIELD_MEMBERSHIP_CHECKER)),
1693
+ __param(24, (0, common_2.Optional)()),
1694
+ __param(24, (0, common_1.Inject)(tokens_js_1.SHIELD_PERMISSION_FACTS)),
1695
+ __param(25, (0, common_2.Optional)()),
1696
+ __param(25, (0, common_1.Inject)(tokens_js_1.SHIELD_MFA_LIMITER)),
1697
+ __param(26, (0, common_2.Optional)()),
1698
+ __param(26, (0, common_1.Inject)(tokens_js_1.SHIELD_MFA_STEP_UP)),
1699
+ __param(27, (0, common_2.Optional)()),
1700
+ __param(27, (0, common_1.Inject)(crypto_adapters_js_1.SHIELD_CLOCK)),
1701
+ __param(28, (0, common_2.Optional)()),
1702
+ __param(28, (0, common_1.Inject)(session_lifetimes_js_1.SHIELD_SESSION_LIFETIMES)),
1703
+ __param(29, (0, common_2.Optional)()),
1704
+ __param(29, (0, common_1.Inject)(tokens_js_1.SHIELD_ROOT_IDS)),
1705
+ __param(30, (0, common_2.Optional)()),
1706
+ __param(30, (0, common_1.Inject)(mariadb_session_record_store_js_1.SHIELD_SESSION_RECORD_STORE)),
1707
+ __param(31, (0, common_2.Optional)()),
1708
+ __param(31, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_RESTRICTIONS)),
1709
+ __metadata("design:paramtypes", [Object, Object, pending_mfa_store_js_1.PendingMfaStore, Function, Object, Object, Object, Object, Object, Object, Object, String, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
1316
1710
  ], AuthController);
1317
1711
  /**
1318
1712
  * Narrow an `authenticated` result to its guaranteed principal. The core engine