@astram/guard 0.2.0 → 0.3.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 (170) hide show
  1. package/dist/auth/account.controller.d.ts +245 -0
  2. package/dist/auth/account.controller.d.ts.map +1 -0
  3. package/dist/auth/account.controller.js +673 -0
  4. package/dist/auth/account.controller.js.map +1 -0
  5. package/dist/auth/auth.controller.d.ts +219 -5
  6. package/dist/auth/auth.controller.d.ts.map +1 -1
  7. package/dist/auth/auth.controller.js +790 -12
  8. package/dist/auth/auth.controller.js.map +1 -0
  9. package/dist/auth/auth.module.d.ts +100 -2
  10. package/dist/auth/auth.module.d.ts.map +1 -1
  11. package/dist/auth/auth.module.js +299 -7
  12. package/dist/auth/auth.module.js.map +1 -0
  13. package/dist/auth/connections.js.map +1 -0
  14. package/dist/auth/dto.d.ts +16 -6
  15. package/dist/auth/dto.d.ts.map +1 -1
  16. package/dist/auth/dto.js.map +1 -0
  17. package/dist/auth/pending-mfa-store.js.map +1 -0
  18. package/dist/auth/persistence.module.js.map +1 -0
  19. package/dist/auth/ports/audit-sink.d.ts +47 -0
  20. package/dist/auth/ports/audit-sink.d.ts.map +1 -0
  21. package/dist/auth/ports/audit-sink.js +20 -0
  22. package/dist/auth/ports/audit-sink.js.map +1 -0
  23. package/dist/auth/ports/credential-writer.d.ts +23 -0
  24. package/dist/auth/ports/credential-writer.d.ts.map +1 -0
  25. package/dist/auth/ports/credential-writer.js +2 -0
  26. package/dist/auth/ports/credential-writer.js.map +1 -0
  27. package/dist/auth/ports/email-sender.d.ts +14 -0
  28. package/dist/auth/ports/email-sender.d.ts.map +1 -0
  29. package/dist/auth/ports/email-sender.js +2 -0
  30. package/dist/auth/ports/email-sender.js.map +1 -0
  31. package/dist/auth/ports/mfa-device-store.d.ts +45 -0
  32. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
  33. package/dist/auth/ports/mfa-device-store.js +13 -0
  34. package/dist/auth/ports/mfa-device-store.js.map +1 -0
  35. package/dist/auth/ports/profile-writer.d.ts +12 -0
  36. package/dist/auth/ports/profile-writer.d.ts.map +1 -0
  37. package/dist/auth/ports/profile-writer.js +2 -0
  38. package/dist/auth/ports/profile-writer.js.map +1 -0
  39. package/dist/auth/ports/recovery-code-store.d.ts +10 -0
  40. package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
  41. package/dist/auth/ports/recovery-code-store.js +2 -0
  42. package/dist/auth/ports/recovery-code-store.js.map +1 -0
  43. package/dist/auth/ports/reset-token-store.d.ts +12 -0
  44. package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
  45. package/dist/auth/ports/reset-token-store.js +2 -0
  46. package/dist/auth/ports/reset-token-store.js.map +1 -0
  47. package/dist/auth/ports/session-facts-projector.d.ts +44 -0
  48. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
  49. package/dist/auth/ports/session-facts-projector.js +30 -0
  50. package/dist/auth/ports/session-facts-projector.js.map +1 -0
  51. package/dist/auth/ports/session-sweeper.d.ts +12 -0
  52. package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
  53. package/dist/auth/ports/session-sweeper.js +2 -0
  54. package/dist/auth/ports/session-sweeper.js.map +1 -0
  55. package/dist/auth/profile-resolver.d.ts +73 -0
  56. package/dist/auth/profile-resolver.d.ts.map +1 -0
  57. package/dist/auth/profile-resolver.js +28 -0
  58. package/dist/auth/profile-resolver.js.map +1 -0
  59. package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
  60. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
  61. package/dist/auth/rate-limit/attempt-limiter.js +133 -0
  62. package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
  63. package/dist/auth/tokens.d.ts +174 -0
  64. package/dist/auth/tokens.d.ts.map +1 -1
  65. package/dist/auth/tokens.js +190 -1
  66. package/dist/auth/tokens.js.map +1 -0
  67. package/dist/auth/totp-enrollment.d.ts +42 -0
  68. package/dist/auth/totp-enrollment.d.ts.map +1 -0
  69. package/dist/auth/totp-enrollment.js +96 -0
  70. package/dist/auth/totp-enrollment.js.map +1 -0
  71. package/dist/authentication/argon2id-password-hasher.js.map +1 -0
  72. package/dist/authentication/mariadb-credential-resolver.d.ts +38 -24
  73. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  74. package/dist/authentication/mariadb-credential-resolver.js +42 -28
  75. package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
  76. package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
  77. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
  78. package/dist/authz/cross-tenant-grant-writer.js +397 -0
  79. package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
  80. package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
  81. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
  82. package/dist/authz/manifest-operation-authority-adapter.js +108 -0
  83. package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
  84. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
  85. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
  86. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
  87. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
  88. package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
  89. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
  90. package/dist/authz/mariadb-record-rule-adapter.js +154 -0
  91. package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
  92. package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
  93. package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
  94. package/dist/authz/mariadb-record-share-adapter.js +106 -0
  95. package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
  96. package/dist/authz/record-share-cascade.d.ts +52 -0
  97. package/dist/authz/record-share-cascade.d.ts.map +1 -0
  98. package/dist/authz/record-share-cascade.js +49 -0
  99. package/dist/authz/record-share-cascade.js.map +1 -0
  100. package/dist/authz/record-share-writer.d.ts +175 -0
  101. package/dist/authz/record-share-writer.d.ts.map +1 -0
  102. package/dist/authz/record-share-writer.js +225 -0
  103. package/dist/authz/record-share-writer.js.map +1 -0
  104. package/dist/authz/wire-record-authorization.d.ts +66 -0
  105. package/dist/authz/wire-record-authorization.d.ts.map +1 -0
  106. package/dist/authz/wire-record-authorization.js +34 -0
  107. package/dist/authz/wire-record-authorization.js.map +1 -0
  108. package/dist/boot/boot-sequence.js.map +1 -0
  109. package/dist/boot/boot.module.js.map +1 -0
  110. package/dist/completeness/catalog-reconciler.d.ts +21 -0
  111. package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
  112. package/dist/completeness/catalog-reconciler.js +74 -0
  113. package/dist/completeness/catalog-reconciler.js.map +1 -0
  114. package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
  115. package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
  116. package/dist/completeness/operation-inventory-scanner.js +79 -0
  117. package/dist/completeness/operation-inventory-scanner.js.map +1 -0
  118. package/dist/completeness/ports/catalog-store.d.ts +33 -0
  119. package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
  120. package/dist/completeness/ports/catalog-store.js +2 -0
  121. package/dist/completeness/ports/catalog-store.js.map +1 -0
  122. package/dist/config/app-config.js.map +1 -0
  123. package/dist/config/config.module.js.map +1 -0
  124. package/dist/enforcement/classification.decorator.d.ts +33 -14
  125. package/dist/enforcement/classification.decorator.d.ts.map +1 -1
  126. package/dist/enforcement/classification.decorator.js +34 -14
  127. package/dist/enforcement/classification.decorator.js.map +1 -0
  128. package/dist/enforcement/core-seam.js.map +1 -0
  129. package/dist/enforcement/enforcement.module.js.map +1 -0
  130. package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
  131. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  132. package/dist/enforcement/global-enforcement.guard.js +108 -25
  133. package/dist/enforcement/global-enforcement.guard.js.map +1 -0
  134. package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
  135. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
  136. package/dist/enforcement/reenroll-allowlist.js +77 -0
  137. package/dist/enforcement/reenroll-allowlist.js.map +1 -0
  138. package/dist/index.d.ts +35 -3
  139. package/dist/index.d.ts.map +1 -1
  140. package/dist/index.js +159 -7
  141. package/dist/index.js.map +1 -0
  142. package/dist/module/shield-tokens.d.ts +15 -0
  143. package/dist/module/shield-tokens.d.ts.map +1 -0
  144. package/dist/module/shield-tokens.js +17 -0
  145. package/dist/module/shield-tokens.js.map +1 -0
  146. package/dist/module/shield.module.d.ts +153 -9
  147. package/dist/module/shield.module.d.ts.map +1 -1
  148. package/dist/module/shield.module.js +181 -24
  149. package/dist/module/shield.module.js.map +1 -0
  150. package/dist/request-auth/request-principal.d.ts +42 -0
  151. package/dist/request-auth/request-principal.d.ts.map +1 -0
  152. package/dist/request-auth/request-principal.js +87 -0
  153. package/dist/request-auth/request-principal.js.map +1 -0
  154. package/dist/request-auth/session-facts-als.d.ts +65 -0
  155. package/dist/request-auth/session-facts-als.d.ts.map +1 -0
  156. package/dist/request-auth/session-facts-als.js +57 -0
  157. package/dist/request-auth/session-facts-als.js.map +1 -0
  158. package/dist/request-auth/session-token-binder.d.ts +130 -0
  159. package/dist/request-auth/session-token-binder.d.ts.map +1 -0
  160. package/dist/request-auth/session-token-binder.js +277 -0
  161. package/dist/request-auth/session-token-binder.js.map +1 -0
  162. package/dist/request-auth/tokens.d.ts +14 -0
  163. package/dist/request-auth/tokens.d.ts.map +1 -0
  164. package/dist/request-auth/tokens.js +16 -0
  165. package/dist/request-auth/tokens.js.map +1 -0
  166. package/dist/session/crypto-adapters.js.map +1 -0
  167. package/dist/session/index.js.map +1 -0
  168. package/dist/session/mariadb-session-record-store.js.map +1 -0
  169. package/dist/session/redis-hot-session-store.js.map +1 -0
  170. package/package.json +2 -2
@@ -65,9 +65,14 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
65
65
  Object.defineProperty(exports, "__esModule", { value: true });
66
66
  exports.AuthController = void 0;
67
67
  const common_1 = require("@nestjs/common");
68
+ const node_crypto_1 = require("node:crypto");
69
+ const common_2 = require("@nestjs/common");
68
70
  const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
69
71
  const tokens_js_1 = require("./tokens.js");
70
72
  const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
73
+ const tokens_js_2 = require("../request-auth/tokens.js");
74
+ const request_principal_js_1 = require("../request-auth/request-principal.js");
75
+ const tokens_js_3 = require("./tokens.js");
71
76
  /**
72
77
  * The core `Authenticator`'s generic transient reason (see authenticator.ts —
73
78
  * `REASON_AUTH_UNAVAILABLE`). A `rejected` result carrying THIS reason is the
@@ -84,6 +89,17 @@ const REASON_AUTH_UNAVAILABLE = 'authentication_unavailable';
84
89
  const GENERIC_AUTH_FAILURE = Object.freeze({ error: 'invalid_credentials' });
85
90
  /** The generic retryable body for a transient dependency outage (ADR-0016). */
86
91
  const RETRYABLE_BODY = Object.freeze({ error: 'authentication_unavailable' });
92
+ /** Password-reset token time-to-live: 30 minutes (spec §4.1). */
93
+ const RESET_TTL_MS = 30 * 60 * 1000;
94
+ /** Minimum acceptable new-password length on reset (matches change-password). */
95
+ const MIN_PASSWORD_LEN = 8;
96
+ /**
97
+ * SHA-256 of the raw reset token. The token store only ever sees this HASH — the raw
98
+ * token lives only in the emailed link + the reset request (never persisted).
99
+ */
100
+ function sha256(value) {
101
+ return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
102
+ }
87
103
  /** Throw the generic 401 (indistinguishable across all credential-failure paths). */
88
104
  function genericUnauthorized() {
89
105
  throw new common_1.HttpException(GENERIC_AUTH_FAILURE, common_1.HttpStatus.UNAUTHORIZED);
@@ -92,6 +108,15 @@ function genericUnauthorized() {
92
108
  function retryableUnavailable() {
93
109
  throw new common_1.HttpException(RETRYABLE_BODY, common_1.HttpStatus.SERVICE_UNAVAILABLE);
94
110
  }
111
+ /**
112
+ * The generic 429 for a rate-limited RESET-password (feature #141). Reset is
113
+ * token-gated (a 256-bit single-use token), NOT enumeration-sensitive, so surfacing
114
+ * "too many requests" is safe (unlike forgot-password, which stays 204). Carries no
115
+ * detail beyond the neutral throttle signal.
116
+ */
117
+ function tooManyRequests() {
118
+ throw new common_1.HttpException({ error: 'too_many_requests' }, common_1.HttpStatus.TOO_MANY_REQUESTS);
119
+ }
95
120
  /**
96
121
  * Map a core `Session` → the client-safe {@link SessionView}. Copies ONLY the
97
122
  * non-secret lifecycle fields the DTO allows — there is no token/hash on `Session`
@@ -136,10 +161,140 @@ let AuthController = class AuthController {
136
161
  authenticator;
137
162
  sessionAuthority;
138
163
  pendingMfa;
139
- constructor(authenticator, sessionAuthority, pendingMfa) {
164
+ tokenBinder;
165
+ profileResolver;
166
+ credentialResolver;
167
+ resetTokenStore;
168
+ emailSender;
169
+ credentialWriter;
170
+ sessionSweeper;
171
+ passwordHasher;
172
+ resetLinkBaseUrl;
173
+ recoveryCodes;
174
+ loginLimiter;
175
+ recoveryLimiter;
176
+ forgotLimiter;
177
+ forgotHintLimiter;
178
+ resetLimiter;
179
+ auditSink;
180
+ sessionFactsProjector;
181
+ constructor(authenticator, sessionAuthority, pendingMfa, tokenBinder, profileResolver, credentialResolver, resetTokenStore, emailSender, credentialWriter, sessionSweeper, passwordHasher, resetLinkBaseUrl, recoveryCodes, loginLimiter, recoveryLimiter, forgotLimiter, forgotHintLimiter, resetLimiter, auditSink, sessionFactsProjector) {
140
182
  this.authenticator = authenticator;
141
183
  this.sessionAuthority = sessionAuthority;
142
184
  this.pendingMfa = pendingMfa;
185
+ this.tokenBinder = tokenBinder;
186
+ this.profileResolver = profileResolver;
187
+ this.credentialResolver = credentialResolver;
188
+ this.resetTokenStore = resetTokenStore;
189
+ this.emailSender = emailSender;
190
+ this.credentialWriter = credentialWriter;
191
+ this.sessionSweeper = sessionSweeper;
192
+ this.passwordHasher = passwordHasher;
193
+ this.resetLinkBaseUrl = resetLinkBaseUrl;
194
+ this.recoveryCodes = recoveryCodes;
195
+ this.loginLimiter = loginLimiter;
196
+ this.recoveryLimiter = recoveryLimiter;
197
+ this.forgotLimiter = forgotLimiter;
198
+ this.forgotHintLimiter = forgotHintLimiter;
199
+ this.resetLimiter = resetLimiter;
200
+ this.auditSink = auditSink;
201
+ this.sessionFactsProjector = sessionFactsProjector;
202
+ }
203
+ /**
204
+ * Bind the established session's access token to the principal's claims (the
205
+ * request-auth layer). The access-token TTL is `expiresAtMs - issuedAtMs` from the
206
+ * session. Best-effort: a binding failure must not fail the login (the caller still
207
+ * gets valid tokens); the guard fails closed if the binding is absent.
208
+ */
209
+ async bindSession(est, principal, mustReenroll = false) {
210
+ if (this.tokenBinder === undefined)
211
+ return;
212
+ const ttlMs = Math.max(est.session.expiresAtMs - est.session.issuedAtMs, 1);
213
+ // EAGER session-facts projection (ADR-0011): compute the principal's coarse grants
214
+ // NOW so the guard can serve gate 5 from the request's `cachedFacts`. Absent projector
215
+ // / a null result → `cachedFacts` stays undefined → gate 5 DB-reloads (today's
216
+ // behavior). Best-effort inside projectFacts (a failure → undefined, never throws).
217
+ const cachedFacts = await this.projectFacts(principal);
218
+ try {
219
+ await this.tokenBinder.bind(est.tokens.accessToken, {
220
+ id: principal.id,
221
+ tenantId: principal.tenantId,
222
+ roleIds: [...principal.roleIds],
223
+ kind: principal.kind,
224
+ isRoot: principal.isRoot === true,
225
+ sessionId: est.session.sessionId,
226
+ // Enforced-re-enroll flag (spec §4): TRUE only on the recovery-code login
227
+ // path (which passes it); all other logins default to FALSE.
228
+ mustReenroll,
229
+ // Only include the key when a projection produced facts — under
230
+ // exactOptionalPropertyTypes an explicit `undefined` is not assignable to the
231
+ // optional field; a spread keeps it truly absent (→ gate 5 DB-reloads).
232
+ ...(cachedFacts ? { cachedFacts } : {}),
233
+ }, ttlMs);
234
+ }
235
+ catch {
236
+ // Best-effort: never fail a login on a binding hiccup. The guard denies if the
237
+ // binding is missing, so this degrades safely (the user re-logs in).
238
+ }
239
+ }
240
+ /**
241
+ * Project the principal's coarse grants for the session binding (ADR-0011), via the
242
+ * OPTIONAL {@link SessionFactsProjector} seam. Returns the {@link CoarseGrants} to
243
+ * cache, or `undefined` when there is no projector, the projection yielded nothing,
244
+ * OR it threw — every "no facts" path collapses to `undefined` so the caller simply
245
+ * omits `cachedFacts` and the request DB-reloads (fail-closed; a projection failure
246
+ * NEVER fails the login/refresh and never fabricates a grant).
247
+ */
248
+ async projectFacts(principal) {
249
+ const facts = await this.projectFactsFor(principal.id, principal.tenantId);
250
+ // At LOGIN there is no prior cache, so "no projector" (undefined) and "yielded
251
+ // nothing" (null) BOTH mean "omit cachedFacts" → gate 5 DB-reloads. Collapse to
252
+ // undefined for the bind() spread.
253
+ return facts ?? undefined;
254
+ }
255
+ /**
256
+ * Project coarse grants for a principal id + tenant (ADR-0011), distinguishing THREE
257
+ * outcomes for the REFRESH path (which must know whether to carry the existing cache
258
+ * forward, replace it, or strip it):
259
+ * - `undefined` → NO projector wired → carry the existing `cachedFacts` forward.
260
+ * - `null` → projector present but yielded nothing (or threw) → STRIP the cache
261
+ * (→ next request DB-reloads; fail-closed).
262
+ * - `CoarseGrants` → the fresh grants to cache.
263
+ * A throw collapses to `null` (fail-closed strip), never a login/refresh failure and
264
+ * never a fabricated grant.
265
+ */
266
+ async projectFactsFor(principalId, tenantId) {
267
+ const projector = this.sessionFactsProjector;
268
+ if (projector === undefined || projector === null)
269
+ return undefined;
270
+ try {
271
+ return await projector.project(principalId, tenantId);
272
+ }
273
+ catch {
274
+ // Fail-closed: a projection outage → strip/omit the cache → gate 5 DB-reloads.
275
+ return null;
276
+ }
277
+ }
278
+ /**
279
+ * Build a Principal directly from resolved internal claims (recovery/alt-factor
280
+ * paths that don't run the full authenticate() loop). Equivalent to the identity
281
+ * broker's internal-source normalize for this case — the credential resolver
282
+ * already returns canonical Internal-source claims (`id`, `tenantId`, `roleIds`),
283
+ * the same shape the TOTP success path's `requirePrincipal` yields. Returns
284
+ * `null` when `id`/`tenantId` are missing/malformed (→ the caller fails closed to
285
+ * a generic 401). `isRoot` is FORCED false here — a recovery login is
286
+ * never a source of elevated status (root is a broker/server-side decision,
287
+ * never a claim), matching the internal adapter's root-safety.
288
+ */
289
+ principalFromClaims(claims) {
290
+ const id = typeof claims.id === 'string' ? claims.id : '';
291
+ const tenantId = typeof claims.tenantId === 'string' ? claims.tenantId : '';
292
+ if (!id || !tenantId)
293
+ return null;
294
+ const roleIds = Array.isArray(claims.roleIds)
295
+ ? claims.roleIds.filter((r) => typeof r === 'string')
296
+ : [];
297
+ return { kind: 'user', id, tenantId, roleIds, isRoot: false };
143
298
  }
144
299
  /**
145
300
  * `POST /auth/login` — verify a password credential and either establish a
@@ -149,16 +304,36 @@ let AuthController = class AuthController {
149
304
  async login(body) {
150
305
  const identityHint = typeof body?.identityHint === 'string' ? body.identityHint : '';
151
306
  const password = typeof body?.password === 'string' ? body.password : '';
307
+ // Rate-limit gate (spec §5 / H1): after M failed attempts within the window,
308
+ // reject BEFORE consulting the authenticator — the SAME generic 401 as a wrong
309
+ // password (anti-enumeration: a locked-out account is indistinguishable). Keyed
310
+ // on the presented hint (the resolver isn't run pre-auth). Fails OPEN on a Redis
311
+ // blip (the limiter's own posture — never a lockout from an outage).
312
+ const loginKey = `login:${identityHint}`;
313
+ if (this.loginLimiter != null && !(await this.loginLimiter.check(loginKey))) {
314
+ // Already locked → generic 401. We do NOT audit here: `check()` returns false on
315
+ // EVERY attempt once locked, so auditing at this gate would flood one event per
316
+ // attempt. The lockout is audited ONCE on the TRANSITION instead — in the
317
+ // wrong-password branch below, when recordFailure's count first hits the threshold
318
+ // (feature #140 audit-on-the-edge, anti-flood).
319
+ genericUnauthorized();
320
+ }
152
321
  const result = await this.authenticator.authenticate(identityHint, [passwordCredential(password)]);
153
322
  if (result.outcome === 'authenticated') {
323
+ // Correct password → clear the login counter (do not carry earlier failures).
324
+ await this.loginLimiter?.reset(loginKey);
154
325
  const principal = requirePrincipal(result);
155
326
  const est = await this.establishOrReject(principal);
327
+ await this.bindSession(est, principal);
156
328
  return toSessionSuccess(est);
157
329
  }
158
330
  if (result.outcome === 'mfa_required') {
159
- // Password verified, a second factor is outstanding. Hold the pending
160
- // credentials server-side and hand back an OPAQUE, unforgeable challenge
161
- // handle. NO session yet. The handle carries nothing about the user.
331
+ // Password verified, a second factor is outstanding. The PASSWORD was correct,
332
+ // so this is NOT a login failure — reset the login limiter (spec §5: reset on
333
+ // authenticated OR mfa_required). Then hold the pending credentials server-side
334
+ // and hand back an OPAQUE, unforgeable challenge handle. NO session yet; the
335
+ // handle carries nothing about the user.
336
+ await this.loginLimiter?.reset(loginKey);
162
337
  const challengeToken = this.pendingMfa.store(identityHint, password);
163
338
  return { mfaRequired: true, challengeToken };
164
339
  }
@@ -166,33 +341,350 @@ let AuthController = class AuthController {
166
341
  // credential failed): a transient dependency blip is a retryable 503, anything
167
342
  // else is the generic 401.
168
343
  if (result.reason === REASON_AUTH_UNAVAILABLE) {
344
+ // A transient outage is NOT a credential failure — do NOT count it toward the
345
+ // lockout (avoids a Redis/DB blip storm from locking out everyone).
169
346
  retryableUnavailable();
170
347
  }
348
+ // A genuine wrong-password deny → count it toward the lockout window. recordFailure
349
+ // returns the NEW count; when it first equals the limiter's threshold this attempt
350
+ // is the one that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here
351
+ // (the edge), never on the already-locked check-gate above (which would flood).
352
+ // NO notify: this is pre-auth (keyed on the typed hint; the resolver hasn't run), so
353
+ // we cannot resolve the real owner's email — mailing a bare hint could reach a
354
+ // non-owner / confirm the account exists (recordSecurityEvent enforces this too).
355
+ if (this.loginLimiter != null) {
356
+ const count = await this.loginLimiter.recordFailure(loginKey);
357
+ if (count === this.loginLimiter.maxAttempts) {
358
+ this.recordSecurityEvent({
359
+ action: 'auth.login.locked',
360
+ identityHint,
361
+ attributes: { reason: 'rate_limit' },
362
+ notify: false,
363
+ });
364
+ }
365
+ }
171
366
  genericUnauthorized();
172
367
  }
368
+ /**
369
+ * `GET /auth/me` — the signed-in user's **display profile** (name, email, active
370
+ * company name, role names) for the UI header/card. Fills the flagged gap: login
371
+ * returns only IDs, so without this a UI would either leak a raw UUID or show only
372
+ * the login hint.
373
+ *
374
+ * AUTH: this route is `@Public()` to the CLASSIFICATION guard (it is not an
375
+ * object-type permission — "read my own profile" has no typeKey to `decide()` on),
376
+ * but it authenticates the caller ITSELF: it resolves the bearer access token to a
377
+ * live `Principal` via the request-auth layer (the SAME liveness-gated path the
378
+ * enforcement guard uses). No/invalid/expired/revoked token → 401. So it is public
379
+ * only in the "not object-gated" sense — it is NOT unauthenticated.
380
+ *
381
+ * PROFILE: the framework is org-schema-agnostic, so it delegates id→name
382
+ * resolution to the consumer's {@link ProfileResolver} (the `profileResolver`
383
+ * seam). The resolver returns display NAMES (never raw UUIDs). Absent seam → 501
384
+ * (honest "not available"); resolver returns `null` (deleted user) → 404; resolver
385
+ * throws (store outage) → generic 500 (fail-closed, never a partial profile).
386
+ */
387
+ async me(authorization) {
388
+ // 1) Authenticate: resolve the bearer token → a live Principal (fail-closed).
389
+ // Reuses the request-auth read path (token binding + session-liveness gate), so a
390
+ // logged-out/expired/revoked token is rejected here exactly as on a gated route.
391
+ if (this.tokenBinder === undefined) {
392
+ // Auth surface is up but request-auth isn't wired — cannot authenticate.
393
+ throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
394
+ }
395
+ const principal = await (0, request_principal_js_1.resolveRequestPrincipal)(authorization, this.tokenBinder);
396
+ if (principal === null) {
397
+ throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
398
+ }
399
+ // 2) Resolve the display profile via the consumer seam. No seam → honest 501.
400
+ // (The provider binds `null` when the consumer supplied none, so check nullish.)
401
+ if (this.profileResolver === undefined || this.profileResolver === null) {
402
+ throw new common_1.HttpException({ error: 'profile_unavailable' }, common_1.HttpStatus.NOT_IMPLEMENTED);
403
+ }
404
+ let profile;
405
+ try {
406
+ profile = await this.profileResolver.resolve(principal);
407
+ }
408
+ catch {
409
+ // Store outage / resolver error → fail-closed generic 500 (never a partial or
410
+ // id-leaking profile). The UI keeps its interim hint fallback.
411
+ throw new common_1.HttpException({ error: 'profile_unavailable' }, common_1.HttpStatus.INTERNAL_SERVER_ERROR);
412
+ }
413
+ if (profile === null) {
414
+ // The principal has no resolvable profile (e.g. the user row was deleted since
415
+ // login). 404 — never fall back to returning the raw ids.
416
+ throw new common_1.HttpException({ error: 'profile_not_found' }, common_1.HttpStatus.NOT_FOUND);
417
+ }
418
+ return profile;
419
+ }
420
+ /**
421
+ * `POST /auth/forgot-password` — request a password-reset link. ALWAYS returns 204
422
+ * with an identical (empty) body whether or not the hint matches a principal
423
+ * (ANTI-ENUMERATION, spec §4.1): the response must reveal nothing about whether an
424
+ * account exists.
425
+ *
426
+ * ── TIMING ANTI-ENUMERATION ── a MATCHING hint does extra work (mint + hash token,
427
+ * issue it to the store, resolve the email, send it) that an UNKNOWN hint skips —
428
+ * a naive `await` of that work would make the matching branch measurably slower, a
429
+ * timing oracle. So the found-path work is FIRE-AND-FORGET: `deliverResetLink` is
430
+ * invoked WITHOUT `await`, and the handler returns 204 immediately, so BOTH
431
+ * branches return in ~constant time. A `.catch` is attached to the detached
432
+ * promise so a store/email rejection is swallowed (never an unhandled rejection,
433
+ * never a distinguishing error).
434
+ *
435
+ * ── SEAMS ── needs the credentialResolver (hint→principalId), the resetTokenStore,
436
+ * and the emailSender. Any absent → the whole found-path is skipped and we still
437
+ * return 204 (a deployment without the reset seams simply never sends — never a
438
+ * 501 here, which would itself be an enumeration signal on hint validity).
439
+ */
440
+ async forgotPassword(body, ip) {
441
+ const hint = typeof body?.identityHint === 'string' ? body.identityHint : '';
442
+ const resolver = this.credentialResolver;
443
+ const store = this.resetTokenStore;
444
+ const sender = this.emailSender;
445
+ // ── Rate-limit gate (feature #141) — a REQUEST-RATE cap, NOT a failure cap. ──────
446
+ // forgot-password is ALWAYS 204 (a 429 would itself leak account existence), so we
447
+ // COUNT every forgot request against two budgets and, when EITHER is exceeded,
448
+ // SILENTLY skip the email-send while STILL returning 204. Two keys: the client IP
449
+ // (stops one host spraying many hints) and the presented hint (stops one account
450
+ // being email-bombed across a rotating IP pool). Each limiter fails OPEN on a Redis
451
+ // blip (its own posture) so an outage never silently drops a legitimate reset email.
452
+ // Defense-in-depth: a normal Express `req.ip` is always populated, but an empty ip
453
+ // (a non-standard adapter/harness) would collapse EVERY client into one shared
454
+ // `forgot:ip:` bucket → a global lockout. Fall back to a fixed sentinel so a blank ip
455
+ // is its own bucket, never a shared one (mirrors the `hint === ''` guard below).
456
+ const ipKey = `forgot:ip:${ip || 'unknown-ip'}`;
457
+ const hintKey = `forgot:${hint}`;
458
+ // Check BOTH budgets first (a `false` from either = over budget = skip the send).
459
+ // Both checks run so both counters see this request (below), independent of order.
460
+ const ipAllowed = this.forgotLimiter == null || (await this.forgotLimiter.check(ipKey));
461
+ // Only meter the hint budget when there IS a hint (an empty hint is the unknown-user
462
+ // path — it never sends anyway, and we must not let a blank hint burn a shared key).
463
+ const hintAllowed = this.forgotHintLimiter == null || hint === '' || (await this.forgotHintLimiter.check(hintKey));
464
+ // Count THIS request toward each budget (there is no pass/fail signal — every forgot
465
+ // call is an "attempt"; the counter is what trips the silent skip). Best-effort +
466
+ // detached from the 204 (never awaited into the response timing; a throw is swallowed
467
+ // by the limiter's own best-effort recordFailure). NOT reset on "success" — forgot
468
+ // has no success signal, so the window is a pure rolling request-rate cap.
469
+ // `.catch` guards the DETACHED counter writes: the framework AttemptLimiter already
470
+ // swallows its own errors, but a consumer-injected limiter must never surface an
471
+ // unhandled rejection off this fire-and-forget path (never changes the 204).
472
+ // Count this request toward the IP budget; when the returned count first equals the
473
+ // limiter's threshold this request is the one that TRIPPED the throttle → audit
474
+ // `auth.forgot_password.throttled` exactly ONCE on that edge (feature #140). Detached
475
+ // + swallowed like the counter write itself: the audit is fire-and-forget and never
476
+ // changes the 204 (still returned below). NO notify — a forgot-throttle notice would
477
+ // itself confirm the account exists AND could be used to email-bomb the owner; the
478
+ // event is audit-only. NO secret in attributes (a reason code only; the hint is the
479
+ // actor identity, acceptable in the audit row, added by recordSecurityEvent).
480
+ void this.forgotLimiter
481
+ ?.recordFailure(ipKey)
482
+ .then((count) => {
483
+ if (this.forgotLimiter != null && count === this.forgotLimiter.maxAttempts) {
484
+ this.recordSecurityEvent({
485
+ action: 'auth.forgot_password.throttled',
486
+ identityHint: hint || null,
487
+ attributes: { reason: 'rate_limit' },
488
+ notify: false,
489
+ });
490
+ }
491
+ })
492
+ .catch(() => { });
493
+ // Same edge-audit for the per-HINT budget (only when there IS a hint — never audit
494
+ // an empty-hint edge, mirroring the `hint !== ''` gate on the hint limiter itself).
495
+ // A DISTINCT action key (`hint_throttled` vs the IP site's `throttled`) so the two
496
+ // abuse shapes are distinguishable in the audit log: the per-hint edge catches ONE
497
+ // account being email-bombed across ROTATING IPs (each IP staying under its own
498
+ // budget), which the per-IP audit would NEVER see. Detached + swallowed, 204
499
+ // unchanged, no notify (a hint-throttle notice would confirm the account + could
500
+ // itself be the email-bomb).
501
+ if (hint !== '') {
502
+ void this.forgotHintLimiter
503
+ ?.recordFailure(hintKey)
504
+ .then((count) => {
505
+ if (this.forgotHintLimiter != null && count === this.forgotHintLimiter.maxAttempts) {
506
+ this.recordSecurityEvent({
507
+ action: 'auth.forgot_password.hint_throttled',
508
+ identityHint: hint,
509
+ attributes: { reason: 'rate_limit' },
510
+ notify: false,
511
+ });
512
+ }
513
+ })
514
+ .catch(() => { });
515
+ }
516
+ // Only the matching branch does work — but do it DETACHED (no await) so its
517
+ // duration never leaks into the response timing. Absent seams → skip entirely.
518
+ // Throttled (either budget exceeded) → SILENTLY skip the send (still 204 below).
519
+ if (ipAllowed && hintAllowed && hint && resolver && store && sender) {
520
+ // Fire-and-forget: never awaited before the 204. `.catch` swallows any
521
+ // store/email failure (anti-enumeration — a failure must NOT change the
522
+ // response, and must never surface as an unhandled rejection).
523
+ void this.deliverResetLink(hint, resolver, store, sender).catch(() => {
524
+ /* swallow — anti-enumeration: never let a store/email failure be observable */
525
+ });
526
+ }
527
+ // ALWAYS 204, immediately, identical body for every branch (found/unknown/throttled).
528
+ }
529
+ /**
530
+ * The detached found-path work for {@link forgotPassword}: mint a raw token, issue
531
+ * its SHA-256 hash to the store (30-min TTL), resolve the recipient email via the
532
+ * profile resolver, and send the framework-composed reset link. Runs OUTSIDE the
533
+ * request/response timing (fire-and-forget) so it cannot form a timing oracle. Never
534
+ * logs the token or the email.
535
+ */
536
+ async deliverResetLink(hint, resolver, store, sender) {
537
+ const resolved = await resolver.resolve(hint);
538
+ if (resolved === null)
539
+ return; // unknown hint — no token, no email (still 204)
540
+ const claims = resolved.claims;
541
+ const principalId = typeof claims.id === 'string' ? claims.id : '';
542
+ if (!principalId)
543
+ return;
544
+ const tenantId = typeof claims.tenantId === 'string' ? claims.tenantId : '';
545
+ // Mint a raw token; the store only ever sees its SHA-256 hash.
546
+ const rawToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
547
+ await store.issue(principalId, sha256(rawToken), Date.now() + RESET_TTL_MS);
548
+ // Resolve the recipient email via the profile resolver (returns display + email).
549
+ // No resolver / no email on file → issued the token but cannot deliver (still 204).
550
+ if (this.profileResolver === undefined || this.profileResolver === null)
551
+ return;
552
+ const profile = await this.profileResolver.resolve({
553
+ id: principalId,
554
+ tenantId,
555
+ roleIds: [],
556
+ kind: 'user',
557
+ isRoot: false,
558
+ });
559
+ if (!profile?.email)
560
+ return;
561
+ // The framework composes the link (EmailSender stays URL-agnostic).
562
+ const base = this.resetLinkBaseUrl ?? 'http://localhost:5173/reset-password';
563
+ const link = `${base}?token=${rawToken}`;
564
+ await sender.send({
565
+ to: profile.email,
566
+ subject: 'Reset your password',
567
+ text: `Reset your password: ${link}`,
568
+ html: `<p>Reset your password: <a href="${link}">${link}</a></p>`,
569
+ });
570
+ }
571
+ /**
572
+ * `POST /auth/reset-password` — complete a password reset with the emailed token.
573
+ *
574
+ * Outcomes:
575
+ * • newPassword shorter than the minimum → 422 `{ error: 'weak_password' }`
576
+ * • resetTokenStore/credentialWriter/hasher absent → 501 `{ error: 'not_available' }`
577
+ * • token unknown / expired / already-consumed → 400 `{ error: 'invalid_token' }`
578
+ * • success → 204 (no body)
579
+ *
580
+ * On success: consume the token (single-use), hash + write the new password, then
581
+ * revoke ALL of the principal's sessions (reset kills every session, spec §5.1/§6)
582
+ * and invalidate any other outstanding reset tokens for the principal.
583
+ */
584
+ async resetPassword(body, ip) {
585
+ const token = typeof body?.token === 'string' ? body.token : '';
586
+ const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : '';
587
+ // ── Rate-limit gate (feature #141) — DoS/abuse throttle, NOT anti-brute-force. ──
588
+ // Reset is token-gated (a 256-bit single-use token), so this is only to cap abusive
589
+ // request volume — and because a token-gated endpoint is NOT enumeration-sensitive,
590
+ // a locked-out caller MAY receive a generic 429 (unlike forgot-password, which stays
591
+ // 204). Checked BEFORE any token/seam work so a locked IP never touches the store.
592
+ // Fails OPEN on a Redis blip (a legitimate reset is never blocked by an outage).
593
+ // Defense-in-depth empty-ip fallback (see forgotPassword): a blank ip must be its
594
+ // own bucket, never collapse all callers into one shared `reset:ip:` lockout bucket.
595
+ const resetKey = `reset:ip:${ip || 'unknown-ip'}`;
596
+ if (this.resetLimiter != null && !(await this.resetLimiter.check(resetKey))) {
597
+ // Already locked → generic 429. We do NOT audit here: check() returns false on
598
+ // EVERY attempt once locked, so this gate would flood one event per attempt. The
599
+ // lockout is audited ONCE on the TRANSITION instead — in the invalid-token branch
600
+ // below, when recordFailure's count first hits the threshold (audit-on-the-edge).
601
+ tooManyRequests();
602
+ }
603
+ if (newPassword.length < MIN_PASSWORD_LEN) {
604
+ throw new common_1.HttpException({ error: 'weak_password' }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
605
+ }
606
+ // Fail-closed when the reset seams are unplugged — honest "not available".
607
+ const store = this.resetTokenStore;
608
+ const writer = this.credentialWriter;
609
+ const hasher = this.passwordHasher;
610
+ if (store === undefined || store === null || writer === undefined || writer === null || hasher === undefined) {
611
+ throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
612
+ }
613
+ // Consume the token (single-use, TTL-checked). Unknown/expired/consumed → 400.
614
+ const principalId = await store.consume(sha256(token));
615
+ if (principalId === null) {
616
+ // A bad/invalid token is the ABUSE signal — count it toward the IP lockout, THEN
617
+ // the existing generic 400. (A valid reset never records a failure.) recordFailure
618
+ // returns the new count; when it first equals the threshold this bad-token attempt
619
+ // TRIPPED the lockout → audit `auth.password_reset.locked` exactly ONCE on that edge
620
+ // (never on the already-locked 429 gate above, which would flood; and NOT on every
621
+ // invalid-token attempt, only the transition). No hint/principal is available from a
622
+ // bare token — that's fine, the event is audited without one. NO notify (no
623
+ // resolvable owner from a token) and NO secret in attributes (a reason code only).
624
+ if (this.resetLimiter != null) {
625
+ const count = await this.resetLimiter.recordFailure(resetKey);
626
+ if (count === this.resetLimiter.maxAttempts) {
627
+ this.recordSecurityEvent({
628
+ action: 'auth.password_reset.locked',
629
+ attributes: { reason: 'rate_limit' },
630
+ notify: false,
631
+ });
632
+ }
633
+ }
634
+ throw new common_1.HttpException({ error: 'invalid_token' }, common_1.HttpStatus.BAD_REQUEST);
635
+ }
636
+ const newHash = await hasher.hash(newPassword);
637
+ await writer.setPassword(principalId, newHash);
638
+ // A SUCCESSFUL reset clears the IP counter (do not carry earlier bad-token attempts
639
+ // forward — the caller proved a valid single-use token).
640
+ await this.resetLimiter?.reset(resetKey);
641
+ // Reset kills ALL sessions (unlike change-password, which keeps the current one).
642
+ if (this.sessionSweeper !== undefined && this.sessionSweeper !== null) {
643
+ await this.sessionSweeper.revokeAllForPrincipal(principalId);
644
+ }
645
+ // Burn any other outstanding reset tokens for the principal (single active reset).
646
+ await store.invalidateAllForPrincipal(principalId);
647
+ }
173
648
  /**
174
649
  * `POST /auth/mfa` — complete a pending MFA login. Consumes the single-use
175
650
  * challenge handle (unknown/expired/forged → same generic 401 as a bad code),
176
- * then RE-RUNS `authenticate(hint, [password, totp])` with the presented code
177
- * (the authenticator has no standalone factor-verify seam — the full attempt IS
178
- * the verify). Success → establish + tokens.
651
+ * then BRANCHES on the presented factor:
652
+ * • `'totp'` → RE-RUNS `authenticate(hint, [password, totp])` with the code
653
+ * (the authenticator has no standalone factor-verify seam — the full attempt
654
+ * IS the verify). Success → establish + tokens.
655
+ * • `'recovery_code'` → the lost-device escape hatch: verify a one-time
656
+ * recovery code (a separate branch, NOT the core factor loop — recovery codes
657
+ * are N single-use Argon2 hashes, incompatible with the pure one-secret
658
+ * `FactorVerifier`). See {@link completeViaRecoveryCode}.
659
+ * • any other factor → generic 401 (no leak of "unsupported factor").
660
+ *
661
+ * Anti-enumeration: unknown/forged/expired challenge, unknown hint, wrong code,
662
+ * and wrong recovery code ALL return the identical generic 401.
179
663
  */
180
664
  async mfa(body) {
181
665
  const challengeToken = typeof body?.challengeToken === 'string' ? body.challengeToken : '';
182
666
  const code = typeof body?.code === 'string' ? body.code : '';
183
- // Phase 0b: only 'totp' is real. A body claiming another factor cannot be
184
- // satisfied → treat as a generic failure (no leak of "unsupported factor").
185
667
  const factor = body?.factor;
186
668
  // Consume the pending challenge (single-use). A miss (unknown/forged/expired)
187
- // is indistinguishable from a wrong code — generic 401, no leak.
669
+ // is indistinguishable from a wrong code — generic 401, no leak. Consumed ONCE
670
+ // here, up front, so BOTH branches (totp + recovery) share the single-use
671
+ // semantics (a spent/forged challenge never reaches either verify path).
188
672
  const pending = this.pendingMfa.consume(challengeToken);
189
- if (pending === null || factor !== 'totp') {
673
+ if (pending === null)
190
674
  genericUnauthorized();
675
+ // Recovery-code branch — the lost-device escape hatch (a separate verify path).
676
+ if (factor === 'recovery_code') {
677
+ return this.completeViaRecoveryCode(pending, code);
191
678
  }
679
+ // Phase 0b: only 'totp' is a real re-authenticate factor. Any other → generic
680
+ // 401 (no leak of "unsupported factor").
681
+ if (factor !== 'totp')
682
+ genericUnauthorized();
192
683
  const result = await this.authenticator.authenticate(pending.identityHint, [passwordCredential(pending.password), totpCredential(code)]);
193
684
  if (result.outcome === 'authenticated') {
194
685
  const principal = requirePrincipal(result);
195
686
  const est = await this.establishOrReject(principal);
687
+ await this.bindSession(est, principal);
196
688
  return toSessionSuccess(est);
197
689
  }
198
690
  // Anything else (wrong code → rejected; still-outstanding → mfa_required) is a
@@ -203,6 +695,211 @@ let AuthController = class AuthController {
203
695
  }
204
696
  genericUnauthorized();
205
697
  }
698
+ /**
699
+ * The `/auth/mfa` RECOVERY-CODE branch (spec §3). The lost-device user completes
700
+ * login with a pre-issued single-use recovery code instead of a TOTP.
701
+ *
702
+ * `pending` is the ALREADY-CONSUMED challenge (proof the password passed at
703
+ * `/auth/login` — §3: "the challenge IS proof of password"), so this branch does
704
+ * NOT re-verify the password, mirroring exactly how the TOTP branch trusts the
705
+ * challenge for the password and only checks the code.
706
+ *
707
+ * ── ANTI-ENUMERATION ── seam-absent, unknown hint, malformed claims, and a wrong
708
+ * recovery code ALL raise the identical generic 401 — no path reveals whether
709
+ * the user exists, has recovery codes, or which check failed.
710
+ *
711
+ * ── NO SAFEGUARDS YET ── rate-limit (Phase 3), `mustReenroll` (Phase 2), and
712
+ * notify+audit (Phase 4) attach at the marked seams below.
713
+ */
714
+ async completeViaRecoveryCode(pending, code) {
715
+ // Seam must be present — no resolver or no recovery store → fail closed.
716
+ if (this.credentialResolver == null || this.recoveryCodes == null) {
717
+ genericUnauthorized();
718
+ }
719
+ // hint → principalId. A resolver that throws (store outage) OR returns null
720
+ // (unknown hint) is the SAME generic 401 (anti-enumeration; the resolver never
721
+ // distinguishes unknown-user from a blip to the caller).
722
+ const resolved = await this.credentialResolver.resolve(pending.identityHint).catch(() => null);
723
+ if (resolved == null)
724
+ genericUnauthorized();
725
+ const claims = resolved.claims;
726
+ const principalId = typeof claims.id === 'string' ? claims.id : '';
727
+ if (!principalId)
728
+ genericUnauthorized();
729
+ // Rate-limit gate (spec §5): after N failures within the window, reject BEFORE
730
+ // consulting the recovery store — the SAME generic 401 as a wrong code (no leak
731
+ // of "rate-limited"; the store is never even touched when locked). Keyed by the
732
+ // RESOLVED principalId (recovery codes don't rotate → a lower ceiling than login).
733
+ // Fails OPEN on a Redis blip (the limiter's own posture — never a lockout).
734
+ const recoveryKey = `recovery:${principalId}`;
735
+ if (this.recoveryLimiter != null && !(await this.recoveryLimiter.check(recoveryKey))) {
736
+ genericUnauthorized();
737
+ }
738
+ // Atomic single-use argon2-verify. A throw is swallowed to `false` → generic
739
+ // 401 (never surface a store error as a distinct outcome).
740
+ const ok = await this.recoveryCodes.consume(principalId, code).catch(() => false);
741
+ if (!ok) {
742
+ // A wrong (or unmatched) recovery code → count it toward the lockout window,
743
+ // THEN the identical generic 401. recordFailure returns the new count; when it
744
+ // first equals the threshold this attempt TRIPPED the lockout — audit
745
+ // `auth.mfa.recovery_locked` exactly ONCE on that edge (never on the already-locked
746
+ // check-gate above, which returns false on every attempt = flood).
747
+ // NO notify: a locked-out attacker is (by assumption) NOT the account owner, so
748
+ // emailing on every recovery lockout would let an attacker email-bomb the owner —
749
+ // audit-only is safer; the once-per-window edge already dedups it.
750
+ if (this.recoveryLimiter != null) {
751
+ const count = await this.recoveryLimiter.recordFailure(recoveryKey);
752
+ if (count === this.recoveryLimiter.maxAttempts) {
753
+ this.recordSecurityEvent({
754
+ action: 'auth.mfa.recovery_locked',
755
+ actorId: principalId,
756
+ tenantId: typeof claims.tenantId === 'string' ? claims.tenantId : null,
757
+ attributes: { reason: 'rate_limit' },
758
+ notify: false,
759
+ });
760
+ }
761
+ }
762
+ genericUnauthorized();
763
+ }
764
+ // A correct recovery code → clear the counter (do not carry earlier failures).
765
+ await this.recoveryLimiter?.reset(recoveryKey);
766
+ // The pending challenge is proof the password already passed (§3) — no password
767
+ // re-verify. Mint the Principal directly from the resolved claims.
768
+ const principal = this.principalFromClaims(claims);
769
+ if (principal === null)
770
+ genericUnauthorized();
771
+ const est = await this.establishOrReject(principal);
772
+ // Enforced re-enroll (spec §4): a recovery-code login is lower-assurance, so the
773
+ // session is bound with mustReenroll:true — the enforcement gate then restricts it
774
+ // to the re-secure allowlist until a fresh device is confirmed.
775
+ await this.bindSession(est, principal, true);
776
+ // Notify + audit (spec §6) via the REUSABLE security-event seam (feature #140):
777
+ // records `mfa.recovery_used` AND notifies the account owner, BOTH fire-and-forget +
778
+ // best-effort. A throwing/slow sink or sender NEVER fails the (legitimate) login
779
+ // (nothing here is awaited into the response, each swallows its own rejection). NO
780
+ // secret / code / token / uuid appears in either body — the audit carries only the
781
+ // actor id, tenant, and action; the notice is a neutral security warning.
782
+ this.recordSecurityEvent({
783
+ action: 'mfa.recovery_used',
784
+ actorId: principal.id,
785
+ tenantId: principal.tenantId,
786
+ notify: true,
787
+ });
788
+ return { ...toSessionSuccess(est), mfaRecoveryUsed: true };
789
+ }
790
+ /**
791
+ * Fire-and-forget security notice for a recovery-code login (spec §6.1). Resolves the
792
+ * recipient email via the already-injected {@link ProfileResolver} (the same seam
793
+ * forgot-password uses), then delivers a NEUTRAL "a recovery code was used to sign in"
794
+ * warning via {@link EmailSender}. Best-effort: NOT awaited by the caller, and it
795
+ * swallows every rejection so a missing email / resolver throw / send failure never
796
+ * fails the login. NO code / token / principal uuid is ever placed in the body — only
797
+ * the neutral notice the account owner needs to act on it.
798
+ */
799
+ notifyRecoveryUsed(principalId, tenantId) {
800
+ const resolver = this.profileResolver;
801
+ const sender = this.emailSender;
802
+ if (resolver === undefined || resolver === null || sender === undefined || sender === null) {
803
+ return; // no recipient lookup or no delivery seam → nothing to send (still logged-in)
804
+ }
805
+ void (async () => {
806
+ const profile = await resolver.resolve({
807
+ id: principalId,
808
+ tenantId,
809
+ roleIds: [],
810
+ kind: 'user',
811
+ isRoot: false,
812
+ });
813
+ if (!profile?.email)
814
+ return; // no email on file → cannot notify (login unaffected)
815
+ await sender.send({
816
+ to: profile.email,
817
+ subject: 'A recovery code was used to sign in',
818
+ text: 'A recovery code was just used to sign in to your account. ' +
819
+ 'If this was you, you can ignore this message. If this was NOT you, secure ' +
820
+ 'your account now: change your password and re-enroll a new authenticator device.',
821
+ html: '<p>A recovery code was just used to sign in to your account.</p>' +
822
+ '<p>If this was you, you can ignore this message. If this was <strong>not</strong> ' +
823
+ 'you, secure your account now: change your password and re-enroll a new ' +
824
+ 'authenticator device.</p>',
825
+ });
826
+ })().catch(() => {
827
+ /* best-effort: a resolver/email failure never fails the login */
828
+ });
829
+ }
830
+ /**
831
+ * Record an auth-SECURITY event (best-effort, fire-and-forget) and OPTIONALLY notify
832
+ * the account owner. The REUSABLE audit+notify primitive (feature #140) — it
833
+ * generalizes the two inlined blocks that {@link completeViaRecoveryCode} used to
834
+ * carry (the `auditSink.record(...)` call + the `notifyRecoveryUsed` email) so EVERY
835
+ * lockout/abuse path records a uniform, secret-free security event through ONE seam.
836
+ *
837
+ * ── NEVER THROWS INTO THE REQUEST PATH ── the (already-decided) HTTP response must
838
+ * NOT change because an audit/notify failed or is slow: nothing here is awaited by
839
+ * the caller, the audit `record` is fire-and-forget with a swallowed `.catch`, and
840
+ * the notify runs inside {@link maybeNotifyOwner}'s own detached, swallowed IIFE.
841
+ *
842
+ * ── NO SECRET LEAK ── `attributes` carries only non-secret structured detail (a
843
+ * reason code, never a password / recovery code / TOTP secret / token / hash). The
844
+ * `identityHint` (the email/username the user typed) is acceptable in an AUDIT row
845
+ * — it IS the actor identity the event is about — but it is written ONLY to the
846
+ * audit attributes, NEVER used as a notify recipient (see the notify rule below).
847
+ *
848
+ * ── NOTIFY ONLY WITH A RESOLVED PRINCIPAL ── the owner email is resolved from
849
+ * `actorId` + `tenantId` via the profile resolver. When there is NO resolved
850
+ * principal (a pre-auth login lockout keys on the typed hint only — the resolver
851
+ * has not run), we CANNOT know the real account owner, so we DO NOT notify: mailing
852
+ * a bare hint could email a non-owner (the hint may be someone else's address / a
853
+ * typo) and would confirm the account exists. In that case the AUDIT still fires;
854
+ * only the notify is skipped.
855
+ */
856
+ recordSecurityEvent(params) {
857
+ // ── Audit (always, if the sink is wired) — fire-and-forget, swallowed. ──────────
858
+ // The identityHint (typed email/username) is the actor identity → acceptable in an
859
+ // AUDIT attribute; it is NEVER placed in a notify body (only the RESOLVED profile
860
+ // email is a recipient). No secret ever enters attributes.
861
+ const attributes = {
862
+ ...(params.attributes ?? {}),
863
+ ...(params.identityHint ? { identityHint: params.identityHint } : {}),
864
+ };
865
+ void this.auditSink
866
+ ?.record({
867
+ type: 'authentication',
868
+ occurredAtMs: Date.now(),
869
+ actorId: params.actorId ?? '',
870
+ ...(params.tenantId ? { tenantId: params.tenantId } : {}),
871
+ action: params.action,
872
+ attributes,
873
+ })
874
+ .catch(() => {
875
+ /* best-effort: an audit failure never changes the response */
876
+ });
877
+ // ── Notify (ONLY when asked AND a principal is resolvable) ──────────────────────
878
+ // No actorId/tenantId → pre-auth (login lockout keyed on the typed hint): we cannot
879
+ // reliably resolve the OWNER's email, so we SKIP the notify (never email a bare
880
+ // hint — it could reach a non-owner and would confirm account existence). Audit has
881
+ // already fired above regardless.
882
+ if (params.notify === true && params.actorId && params.tenantId) {
883
+ this.maybeNotifyOwner(params.actorId, params.tenantId);
884
+ }
885
+ }
886
+ /**
887
+ * Fire-and-forget "a security-sensitive event happened on your account" notice to the
888
+ * resolved owner (the generalized {@link notifyRecoveryUsed} path). Resolves the
889
+ * recipient email from `principalId` + `tenantId` via the injected profile resolver,
890
+ * then delivers a NEUTRAL security warning via the email sender. Best-effort: NOT
891
+ * awaited, and every rejection (missing email / resolver throw / send failure) is
892
+ * swallowed so it never changes the response. NO code / token / uuid ever enters the
893
+ * body — only the neutral notice the owner needs to act on it.
894
+ *
895
+ * Today the only notify caller is the recovery-code login (which routes through
896
+ * {@link recordSecurityEvent} with `notify:true`), so this delegates to the existing
897
+ * {@link notifyRecoveryUsed} message — the exact neutral, no-secret email the recovery
898
+ * behavior + its tests already assert (the refactor keeps that email byte-for-byte).
899
+ */
900
+ maybeNotifyOwner(principalId, tenantId) {
901
+ this.notifyRecoveryUsed(principalId, tenantId);
902
+ }
206
903
  /**
207
904
  * `POST /auth/refresh` — rotate the refresh token. Reuse/invalid/expired →
208
905
  * generic 401 (`session_rejected`; the engine already killed the lineage,
@@ -214,6 +911,20 @@ let AuthController = class AuthController {
214
911
  const refreshToken = typeof body?.refreshToken === 'string' ? body.refreshToken : '';
215
912
  try {
216
913
  const est = await this.sessionAuthority.rotate(refreshToken);
914
+ // Carry the principal claims forward to the NEW access token (the durable
915
+ // session row lacks roles/kind — the session-claims entry has them).
916
+ if (this.tokenBinder !== undefined) {
917
+ const ttlMs = Math.max(est.session.expiresAtMs - est.session.issuedAtMs, 1);
918
+ // EAGER re-projection at REFRESH (ADR-0011): re-read the principal's coarse grants
919
+ // so the cache stays warm past the version the LOGIN projected at (a role change
920
+ // between login and this refresh is picked up here). The rotated session carries
921
+ // the principal id + tenant; project from those. `undefined` (no projector) →
922
+ // rebindOnRotate carries the existing `cachedFacts` forward unchanged (today's
923
+ // behavior); a fresh CoarseGrants → replaces it; `null` (projection yielded
924
+ // nothing) → strips it (→ next request DB-reloads; fail-closed).
925
+ const freshFacts = await this.projectFactsFor(est.session.principalId, est.session.tenantId);
926
+ await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, freshFacts);
927
+ }
217
928
  return toSessionSuccess(est);
218
929
  }
219
930
  catch {
@@ -235,6 +946,10 @@ let AuthController = class AuthController {
235
946
  // logout never 500s (which could itself be an existence oracle).
236
947
  try {
237
948
  await this.sessionAuthority.revoke(sessionId);
949
+ // Drop the session-claims cushion so a post-logout rotate can't re-mint a
950
+ // binding (the session is revoked anyway; this is defense-in-depth).
951
+ if (this.tokenBinder !== undefined)
952
+ await this.tokenBinder.unbindSession(sessionId);
238
953
  }
239
954
  catch {
240
955
  // Swallow — logout is idempotent + must not leak. 204 regardless.
@@ -265,6 +980,35 @@ __decorate([
265
980
  __metadata("design:paramtypes", [Object]),
266
981
  __metadata("design:returntype", Promise)
267
982
  ], AuthController.prototype, "login", null);
983
+ __decorate([
984
+ (0, classification_decorator_js_1.Public)(),
985
+ (0, common_1.Get)('me'),
986
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
987
+ __param(0, (0, common_1.Headers)('authorization')),
988
+ __metadata("design:type", Function),
989
+ __metadata("design:paramtypes", [String]),
990
+ __metadata("design:returntype", Promise)
991
+ ], AuthController.prototype, "me", null);
992
+ __decorate([
993
+ (0, classification_decorator_js_1.Public)(),
994
+ (0, common_1.Post)('forgot-password'),
995
+ (0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
996
+ __param(0, (0, common_1.Body)()),
997
+ __param(1, (0, common_1.Ip)()),
998
+ __metadata("design:type", Function),
999
+ __metadata("design:paramtypes", [Object, String]),
1000
+ __metadata("design:returntype", Promise)
1001
+ ], AuthController.prototype, "forgotPassword", null);
1002
+ __decorate([
1003
+ (0, classification_decorator_js_1.Public)(),
1004
+ (0, common_1.Post)('reset-password'),
1005
+ (0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
1006
+ __param(0, (0, common_1.Body)()),
1007
+ __param(1, (0, common_1.Ip)()),
1008
+ __metadata("design:type", Function),
1009
+ __metadata("design:paramtypes", [Object, String]),
1010
+ __metadata("design:returntype", Promise)
1011
+ ], AuthController.prototype, "resetPassword", null);
268
1012
  __decorate([
269
1013
  (0, classification_decorator_js_1.Public)(),
270
1014
  (0, common_1.Post)('mfa'),
@@ -296,7 +1040,41 @@ exports.AuthController = AuthController = __decorate([
296
1040
  (0, common_1.Controller)('auth'),
297
1041
  __param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_AUTHENTICATOR)),
298
1042
  __param(1, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_AUTHORITY)),
299
- __metadata("design:paramtypes", [Object, Object, pending_mfa_store_js_1.PendingMfaStore])
1043
+ __param(3, (0, common_2.Optional)()),
1044
+ __param(3, (0, common_1.Inject)(tokens_js_2.SHIELD_SESSION_TOKEN_BINDER)),
1045
+ __param(4, (0, common_2.Optional)()),
1046
+ __param(4, (0, common_1.Inject)(tokens_js_3.SHIELD_PROFILE_RESOLVER)),
1047
+ __param(5, (0, common_2.Optional)()),
1048
+ __param(5, (0, common_1.Inject)(tokens_js_1.SHIELD_CREDENTIAL_RESOLVER)),
1049
+ __param(6, (0, common_2.Optional)()),
1050
+ __param(6, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_TOKEN_STORE)),
1051
+ __param(7, (0, common_2.Optional)()),
1052
+ __param(7, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_SENDER)),
1053
+ __param(8, (0, common_2.Optional)()),
1054
+ __param(8, (0, common_1.Inject)(tokens_js_1.SHIELD_CREDENTIAL_WRITER)),
1055
+ __param(9, (0, common_2.Optional)()),
1056
+ __param(9, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_SWEEPER)),
1057
+ __param(10, (0, common_2.Optional)()),
1058
+ __param(10, (0, common_1.Inject)(tokens_js_1.SHIELD_PASSWORD_HASHER)),
1059
+ __param(11, (0, common_2.Optional)()),
1060
+ __param(11, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LINK_BASE_URL)),
1061
+ __param(12, (0, common_2.Optional)()),
1062
+ __param(12, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_CODE_STORE)),
1063
+ __param(13, (0, common_2.Optional)()),
1064
+ __param(13, (0, common_1.Inject)(tokens_js_1.SHIELD_LOGIN_LIMITER)),
1065
+ __param(14, (0, common_2.Optional)()),
1066
+ __param(14, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_LIMITER)),
1067
+ __param(15, (0, common_2.Optional)()),
1068
+ __param(15, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_LIMITER)),
1069
+ __param(16, (0, common_2.Optional)()),
1070
+ __param(16, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_HINT_LIMITER)),
1071
+ __param(17, (0, common_2.Optional)()),
1072
+ __param(17, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LIMITER)),
1073
+ __param(18, (0, common_2.Optional)()),
1074
+ __param(18, (0, common_1.Inject)(tokens_js_1.SHIELD_AUDIT_SINK)),
1075
+ __param(19, (0, common_2.Optional)()),
1076
+ __param(19, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_FACTS_PROJECTOR)),
1077
+ __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])
300
1078
  ], AuthController);
301
1079
  /**
302
1080
  * Narrow an `authenticated` result to its guaranteed principal. The core engine