@lenne.tech/nest-server 11.37.0 → 11.39.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 (139) hide show
  1. package/.claude/rules/configurable-features.md +51 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/package-management.md +66 -0
  4. package/.claude/rules/testing.md +81 -5
  5. package/FRAMEWORK-API.md +4 -2
  6. package/dist/config.env.js +5 -3
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  9. package/dist/core/common/helpers/config.helper.js +18 -0
  10. package/dist/core/common/helpers/config.helper.js.map +1 -1
  11. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  12. package/dist/core/common/helpers/cookies.helper.js +9 -0
  13. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  14. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  15. package/dist/core/common/helpers/input.helper.js +4 -0
  16. package/dist/core/common/helpers/input.helper.js.map +1 -1
  17. package/dist/core/common/helpers/service.helper.js +6 -1
  18. package/dist/core/common/helpers/service.helper.js.map +1 -1
  19. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  20. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -0
  22. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  23. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  24. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  25. package/dist/core/common/services/email.service.d.ts +4 -1
  26. package/dist/core/common/services/email.service.js +25 -2
  27. package/dist/core/common/services/email.service.js.map +1 -1
  28. package/dist/core/common/services/module.service.js +1 -0
  29. package/dist/core/common/services/module.service.js.map +1 -1
  30. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  31. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  32. package/dist/core/modules/auth/core-auth.module.js +2 -0
  33. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  34. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  35. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  36. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  37. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  38. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  39. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  40. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  41. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  42. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  43. package/dist/core/modules/better-auth/better-auth.config.js +40 -1
  44. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  46. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
  47. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  48. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +5 -2
  49. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
  50. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  51. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  52. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  53. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  54. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  55. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  56. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  57. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  58. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  59. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  62. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  63. package/dist/core/modules/better-auth/index.d.ts +1 -0
  64. package/dist/core/modules/better-auth/index.js +1 -0
  65. package/dist/core/modules/better-auth/index.js.map +1 -1
  66. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  67. package/dist/core/modules/error-code/error-codes.js +24 -0
  68. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  69. package/dist/core/modules/hub/core-hub.service.js +1 -0
  70. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  71. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  72. package/dist/core/modules/user/core-user.model.js +11 -1
  73. package/dist/core/modules/user/core-user.model.js.map +1 -1
  74. package/dist/core/modules/user/core-user.service.d.ts +10 -1
  75. package/dist/core/modules/user/core-user.service.js +124 -15
  76. package/dist/core/modules/user/core-user.service.js.map +1 -1
  77. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  78. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  79. package/dist/core.module.js +2 -0
  80. package/dist/core.module.js.map +1 -1
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +3 -0
  83. package/dist/index.js.map +1 -1
  84. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  85. package/dist/server/modules/user/user.controller.js +2 -1
  86. package/dist/server/modules/user/user.controller.js.map +1 -1
  87. package/dist/server/modules/user/user.model.d.ts +5 -0
  88. package/dist/server/modules/user/user.resolver.js +2 -1
  89. package/dist/server/modules/user/user.resolver.js.map +1 -1
  90. package/dist/server/modules/user/user.service.d.ts +1 -1
  91. package/dist/server/modules/user/user.service.js +18 -5
  92. package/dist/server/modules/user/user.service.js.map +1 -1
  93. package/dist/templates/password-reset-de.ejs +12 -0
  94. package/dist/templates/password-reset-en.ejs +12 -0
  95. package/dist/templates/password-reset.ejs +1 -0
  96. package/dist/tsconfig.build.tsbuildinfo +1 -1
  97. package/docs/REQUEST-LIFECYCLE.md +49 -1
  98. package/migration-guides/11.37.x-to-11.38.x.md +528 -0
  99. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  100. package/package.json +1 -1
  101. package/src/config.env.ts +28 -4
  102. package/src/core/common/helpers/config.helper.ts +79 -0
  103. package/src/core/common/helpers/cookies.helper.ts +38 -0
  104. package/src/core/common/helpers/input.helper.ts +37 -0
  105. package/src/core/common/helpers/service.helper.ts +9 -1
  106. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  107. package/src/core/common/interfaces/server-options.interface.ts +326 -10
  108. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  109. package/src/core/common/services/email.service.ts +46 -1
  110. package/src/core/common/services/module.service.ts +1 -0
  111. package/src/core/modules/auth/README.md +104 -0
  112. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  113. package/src/core/modules/auth/core-auth.module.ts +5 -0
  114. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  115. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  116. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  117. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  118. package/src/core/modules/better-auth/README.md +32 -59
  119. package/src/core/modules/better-auth/better-auth.config.ts +131 -4
  120. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
  121. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
  122. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  123. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  124. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  125. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  126. package/src/core/modules/better-auth/index.ts +1 -0
  127. package/src/core/modules/error-code/error-codes.ts +55 -0
  128. package/src/core/modules/hub/core-hub.service.ts +1 -0
  129. package/src/core/modules/user/core-user.model.ts +28 -1
  130. package/src/core/modules/user/core-user.service.ts +389 -22
  131. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  132. package/src/core.module.ts +8 -0
  133. package/src/index.ts +3 -0
  134. package/src/server/modules/user/user.controller.ts +7 -1
  135. package/src/server/modules/user/user.resolver.ts +7 -1
  136. package/src/server/modules/user/user.service.ts +58 -13
  137. package/src/templates/password-reset-de.ejs +12 -0
  138. package/src/templates/password-reset-en.ejs +12 -0
  139. package/src/templates/password-reset.ejs +1 -0
@@ -5,10 +5,13 @@ import { sha256 } from 'js-sha256';
5
5
  import { Document, Model } from 'mongoose';
6
6
 
7
7
  import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
8
- import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
8
+ import { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
9
+ import { maskEmail } from '../../common/helpers/logging.helper';
10
+ import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
9
11
  import { ServiceOptions } from '../../common/interfaces/service-options.interface';
10
12
  import { ConfigService } from '../../common/services/config.service';
11
13
  import { CrudService } from '../../common/services/crud.service';
14
+ import { ErrorCode } from '../error-code/error-codes';
12
15
  import { EmailService } from '../../common/services/email.service';
13
16
  import { CoreModelConstructor } from '../../common/types/core-model-constructor.type';
14
17
  import { CoreUserModel } from './core-user.model';
@@ -22,6 +25,14 @@ import { CoreUserServiceOptions } from './interfaces/core-user-service-options.i
22
25
  * Provides user management with automatic synchronization between
23
26
  * Legacy Auth and Better-Auth (IAM) systems when both are enabled.
24
27
  */
28
+ /**
29
+ * Default lifetime of a legacy password-reset token, in minutes.
30
+ *
31
+ * One hour, matching Better-Auth's `resetPasswordTokenExpiresIn` — the half of this framework that
32
+ * already had an expiry. Before 11.38.0 the legacy half had none at all.
33
+ */
34
+ const DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES = 60;
35
+
25
36
  export abstract class CoreUserService<
26
37
  TUser extends CoreUserModel,
27
38
  TUserInput extends CoreUserInput,
@@ -41,6 +52,56 @@ export abstract class CoreUserService<
41
52
  protected readonly options?: CoreUserServiceOptions,
42
53
  ) {
43
54
  super();
55
+ this.warnOnAmbiguousResetLinkConvention();
56
+ }
57
+
58
+ /**
59
+ * Warn once at boot when `email.passwordResetLink` is set WITHOUT a `{token}` placeholder.
60
+ *
61
+ * Two conventions live side by side in this flow, and which one applies depends on something
62
+ * nobody would guess: whether the option is present at all.
63
+ *
64
+ * not set → `<appUrl>/auth/reset-password?token=<token>`
65
+ * set to `<appUrl>/auth/reset-password` → `<appUrl>/auth/reset-password/<token>`
66
+ *
67
+ * Same string, same intent, different link. Writing the default out by hand — to make it
68
+ * overridable per environment, or to derive it from `appUrl` — silently selects the OTHER
69
+ * convention. The mail still arrives, still looks right, and fails only for whoever clicks it:
70
+ * the exact failure mode this whole area was repaired for.
71
+ *
72
+ * The path-segment rule is kept regardless, because a project that already configures such a
73
+ * value has a page built for it, and moving it to `?token=` would break that page. So the fix
74
+ * cannot be to change the behaviour — it has to be to make it visible.
75
+ *
76
+ * Adding `{token}` explicitly silences this warning AND records the intent in the config, which
77
+ * is why that is the remedy named in the message rather than "ignore this if deliberate".
78
+ *
79
+ * The message also names a PRECONDITION for that remedy, and it is load-bearing: `{token}` is
80
+ * substituted by `buildPasswordResetLink()` and by nothing else. A project whose
81
+ * `sendPasswordResetMail()` still concatenates the link by hand — i.e. precisely the projects
82
+ * this warning is aimed at — would follow the advice and mail `/reset-password/{token}/<token>`.
83
+ * That is the same failure this whole area was repaired for: a mail that arrives, looks right,
84
+ * and fails only for whoever clicks it. Advice that is correct for half its audience and
85
+ * silently harmful to the other half is worse than none, so the ORDER of the two steps belongs
86
+ * in the message rather than in a doc the reader may never reach.
87
+ */
88
+ protected warnOnAmbiguousResetLinkConvention(): void {
89
+ const configured = this.configService.getFastButReadOnly<string>('email.passwordResetLink');
90
+
91
+ if (typeof configured !== 'string' || !configured.trim().length || configured.includes('{token}')) {
92
+ return;
93
+ }
94
+
95
+ this.userServiceLogger.warn(
96
+ `email.passwordResetLink is set without a {token} placeholder ("${configured.trim()}"), so the token is ` +
97
+ 'appended as a PATH segment. Leaving the option unset would instead produce "?token=", the shape the ' +
98
+ 'reset page shipped by nuxt-base-starter reads. Both are supported — add {token} where your page ' +
99
+ 'expects it (".../reset-password/{token}" or ".../reset-password?token={token}") to state which, and ' +
100
+ 'to silence this warning. FIRST make sure the mail is built by ' +
101
+ 'CoreUserService.buildPasswordResetLink(): it is the only thing that substitutes {token}, so a ' +
102
+ 'sendPasswordResetMail() that still concatenates the link itself would mail the placeholder ' +
103
+ 'verbatim ("/reset-password/{token}/<token>").',
104
+ );
44
105
  }
45
106
 
46
107
  // ===================================================================================================================
@@ -136,6 +197,10 @@ export abstract class CoreUserService<
136
197
  * Get verified state of user by token
137
198
  */
138
199
  async getVerifiedState(token: string, _serviceOptions?: ServiceOptions): Promise<boolean> {
200
+ if (!isQueryableString(token)) {
201
+ return false;
202
+ }
203
+
139
204
  const user = await this.mainDbModel.findOne({ verificationToken: token }).exec();
140
205
 
141
206
  if (!user) {
@@ -150,6 +215,10 @@ export abstract class CoreUserService<
150
215
  */
151
216
  async verify(token: string, serviceOptions?: ServiceOptions): Promise<string | TUser> {
152
217
  // Get user
218
+ if (!isQueryableString(token)) {
219
+ throw new NotFoundException(ErrorCode.INVALID_TOKEN);
220
+ }
221
+
153
222
  const dbObject = await this.mainDbModel.findOne({ verificationToken: token }).exec();
154
223
  if (!dbObject) {
155
224
  throw new NotFoundException(`No user found with verify token: ${token}`);
@@ -183,14 +252,66 @@ export abstract class CoreUserService<
183
252
  */
184
253
  async resetPassword(token: string, newPassword: string, serviceOptions?: ServiceOptions): Promise<TUser> {
185
254
  // Get user
255
+ // A client-supplied value reaches here with its declared `string` type erased — see
256
+ // `isQueryableString`. Without this, `{ $ne: null }` selects the first user holding ANY live
257
+ // reset token and takes over that account without the attacker ever seeing the mail.
258
+ if (!isQueryableString(token)) {
259
+ throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
260
+ }
261
+
186
262
  const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
187
- if (!dbObject) {
188
- throw new NotFoundException(`No user found with password reset token: ${token}`);
263
+
264
+ // An EXPIRED token is answered exactly like an unknown one — same exception, same 404. The
265
+ // distinction would tell a caller holding a stale token that it was once real and belongs to a
266
+ // live account, which is precisely what a stolen mail archive wants confirmed.
267
+ if (dbObject && this.isPasswordResetTokenExpired(dbObject.passwordResetTokenExpiresAt)) {
268
+ // Burn it on sight rather than leaving it to be retried. It is worthless now, and a value
269
+ // that stays in the row is one a later change could start honouring again.
270
+ await this.mainDbModel
271
+ // `$unset`, not `$set: null` — a null still counts as present, so it would keep an entry
272
+ // in the partial index the token field carries and grow it by one per user who ever
273
+ // requested a reset.
274
+ .updateOne({ _id: dbObject._id }, { $unset: { passwordResetToken: 1, passwordResetTokenExpiresAt: 1 } })
275
+ .exec();
276
+ this.userServiceLogger.debug(`Rejected an expired password-reset token for ${maskEmail(dbObject.email)}`);
277
+ }
278
+
279
+ if (!dbObject || this.isPasswordResetTokenExpired(dbObject.passwordResetTokenExpiresAt)) {
280
+ // The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
281
+ // in the response body and in every log line that records the exception — and this logger
282
+ // feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
283
+ // free log-stuffing, and the message is no more useful for it.
284
+ throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
189
285
  }
190
286
 
191
- // Store the original plain password for IAM sync before any hashing
192
- // We need the plain password because IAM uses scrypt, not bcrypt+sha256
193
- const plainPasswordForIamSync = /^[a-f0-9]{64}$/i.test(newPassword) ? undefined : newPassword;
287
+ // Capture the submitted password for the IAM sync before the closure below
288
+ // reassigns `newPassword` to its sha256 form.
289
+ //
290
+ // It is passed on exactly as received, INCLUDING an already-sha256-hashed
291
+ // one. This used to skip the sync for a 64-hex value, on the reasoning that
292
+ // "IAM uses scrypt, not bcrypt+sha256" — but the sync does not need a plain
293
+ // password: `hashPasswordForBetterAuth` runs its input through
294
+ // `normalizePasswordForIam`, which passes a 64-hex string through unchanged
295
+ // by design, and `migrateAccountToIam` is fed the very same pre-hashed value
296
+ // when an account is created.
297
+ //
298
+ // The guard therefore disabled the sync for exactly the clients this stack
299
+ // ships. The lt frontends hash in the browser before sending — seven call
300
+ // sites in `nuxt-extensions/src/runtime/lib/auth-client.ts` (`signIn.email`,
301
+ // `resetPassword`, `changePassword` and the rest) run `ltSha256` on the
302
+ // password first. That is independent of the `sha256` config option, which
303
+ // only governs what the SERVER does with a plaintext password it happens to
304
+ // receive. So the value arriving here is 64-hex whatever that option says,
305
+ // and every such reset took the skipped branch.
306
+ //
307
+ // The legacy password was updated, the IAM credential was not, and sign-in —
308
+ // which goes through IAM — kept accepting the OLD password and refusing the
309
+ // new one. The endpoint reported success throughout, so the failure surfaced
310
+ // only at the next sign-in.
311
+ //
312
+ // A client that posts a plaintext password was never affected: the old guard
313
+ // let that one through, and the sync normalized it the same way IAM does.
314
+ const passwordForIamSync = newPassword;
194
315
 
195
316
  return this.process(
196
317
  async () => {
@@ -204,17 +325,39 @@ export abstract class CoreUserService<
204
325
  const updatedUser = await assignPlain(dbObject, {
205
326
  password: await bcrypt.hash(newPassword, 10),
206
327
  passwordResetToken: null,
328
+ passwordResetTokenExpiresAt: null,
329
+ // A reset is what somebody reaches for after a suspected takeover, so it must not
330
+ // leave the attacker's session live. Clearing the refresh tokens ends every legacy
331
+ // session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
332
+ // which is off by default because it is a behaviour change for existing deployments —
333
+ // the migration guide recommends turning it on.
334
+ //
335
+ // Without this, "the reset now lands in both stores" would still leave the account
336
+ // reachable with the credential the reset was meant to retire.
337
+ refreshTokens: {},
207
338
  }).save();
208
339
 
209
340
  // Sync password to Better-Auth (IAM) if mapper is available
210
341
  // This ensures users can sign in via IAM after password reset
211
- if (this.options?.betterAuthUserMapper && plainPasswordForIamSync && dbObject.email) {
342
+ if (this.options?.betterAuthUserMapper && passwordForIamSync && dbObject.email) {
212
343
  try {
213
- await this.options.betterAuthUserMapper.syncPasswordChangeToIam(dbObject.email, plainPasswordForIamSync);
344
+ // Same reasoning as in update(): a `false` return means the reset landed in the
345
+ // legacy store only, which is the shape of failure this whole path exists to
346
+ // prevent. It must not be indistinguishable from success.
347
+ const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
348
+ dbObject.email,
349
+ passwordForIamSync,
350
+ );
351
+ if (!synced) {
352
+ this.userServiceLogger.warn(
353
+ `Password reset for ${maskEmail(dbObject.email)} was NOT synced to IAM (no credential account) — ` +
354
+ 'the legacy password now differs from the IAM credential.',
355
+ );
356
+ }
214
357
  } catch (error) {
215
358
  // Log but don't fail - Legacy Auth password was updated successfully
216
359
  this.userServiceLogger.warn(
217
- `Failed to sync password reset to IAM for ${dbObject.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
360
+ `Failed to sync password reset to IAM for ${maskEmail(dbObject.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
218
361
  );
219
362
  }
220
363
  }
@@ -226,19 +369,97 @@ export abstract class CoreUserService<
226
369
  }
227
370
 
228
371
  /**
229
- * Set password rest token for email
372
+ * Set a password-reset token for an email address
373
+ *
374
+ * Returns `null` for an unknown address when `auth.passwordReset.preventUserEnumeration` is on
375
+ * (the default since 11.38.0) — the caller must then answer exactly as it would for a known one.
376
+ * With the option off it throws `NotFoundException`, the pre-11.38.0 behaviour.
377
+ *
378
+ * WHY THE DEFAULT CHANGED
379
+ *
380
+ * Throwing made the endpoint an account oracle: HTTP 404 for an unknown address, 201 for a known
381
+ * one, so anyone could test who has an account. In a multi-tenant product that also answers who
382
+ * works at which customer. The framework already answers this correctly on the IAM path —
383
+ * Better-Auth's `/request-password-reset` returns the same body either way — so the two halves
384
+ * of one framework disagreed about the same question.
385
+ *
386
+ * THE STATUS CODE IS THE SMALLER HALF
387
+ *
388
+ * Response TIME distinguishes the cases too, and by far more: the known path writes a token and
389
+ * (in the caller) sends mail, the unknown path returns immediately. This method equalises what it
390
+ * can — the token generation still happens, so the CPU cost matches — but the mail send lives in
391
+ * the caller. A `sendPasswordResetMail()` that AWAITS the send leaks the difference as latency,
392
+ * whatever this method does. `src/server/modules/user/user.service.ts` shows the shape that does
393
+ * not; the IAM path uses the same trick, with the reasoning recorded in `better-auth.config.ts`.
394
+ *
395
+ * Anything measuring this honestly should say so rather than claim the channel is closed.
230
396
  */
231
- async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<TUser> {
397
+ async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<null | TUser> {
398
+ return (await this.createPasswordResetToken(email, serviceOptions))?.user ?? null;
399
+ }
400
+
401
+ /**
402
+ * Mint a password-reset token and hand back BOTH the token and the user.
403
+ *
404
+ * Use this instead of `setPasswordResetTokenForEmail` whenever the token itself is needed —
405
+ * which is every caller that sends the mail, i.e. every project, because the mail templates are
406
+ * project-specific.
407
+ *
408
+ * ── Why this method exists ──────────────────────────────────────────────────────
409
+ * `setPasswordResetTokenForEmail` returns its result through `process()`, and the security
410
+ * interceptor strips `passwordResetToken` on the way out. That is correct and must stay: a reset
411
+ * token may never leave the server inside a response, and this service backs an endpoint.
412
+ *
413
+ * But the same return value is the only thing a caller had to build the mail link from. The
414
+ * method named after setting the token did not hand it over, so `user.passwordResetToken` read
415
+ * `undefined` and the mail went out with a link ending in `/undefined`.
416
+ *
417
+ * It fails in the worst shape available: the request succeeds, the mail arrives, it looks right,
418
+ * and only the click reveals it — to somebody who by definition has no second way in. It reached
419
+ * real recipients in a downstream project before anyone noticed, with a green test suite either
420
+ * side of it, because the tests read the token from the DATABASE and never from the mail.
421
+ *
422
+ * The token is therefore generated OUTSIDE `process()` and returned alongside the scrubbed user.
423
+ * Nothing about what leaves the server in a response changes.
424
+ */
425
+ async createPasswordResetToken(
426
+ email: string,
427
+ serviceOptions?: ServiceOptions,
428
+ ): Promise<null | { token: string; user: TUser }> {
429
+ // Same erased-type hazard as the token sinks (see `isQueryableString`): `{ $ne: null }` here
430
+ // would select an arbitrary account and mint a live reset token for it. Treated as an unknown
431
+ // address, which is the answer this method already gives for anything it cannot resolve — so
432
+ // the enumeration parity below covers it too.
433
+ if (!isQueryableString(email)) {
434
+ return null;
435
+ }
436
+
232
437
  // Get user
233
438
  const dbObject = await this.mainDbModel.findOne({ email }).exec();
234
439
  if (!dbObject) {
235
- throw new NotFoundException(`No user found with email: ${email}`);
440
+ const preventEnumeration =
441
+ this.configService.getFastButReadOnly('auth')?.passwordReset?.preventUserEnumeration !== false;
442
+
443
+ if (!preventEnumeration) {
444
+ throw new NotFoundException(`No user found with email: ${email}`);
445
+ }
446
+
447
+ // Do the work the known path does, so the two do not differ in CPU cost. It is cheap next to
448
+ // a mail send, which is why this alone does not close the timing channel — see the note above.
449
+ crypto.randomBytes(32).toString('hex');
450
+
451
+ this.userServiceLogger.debug(`Password reset requested for an unknown address (${maskEmail(email)})`);
452
+ return null;
236
453
  }
237
454
 
238
- return this.process(
455
+ const token = crypto.randomBytes(32).toString('hex');
456
+ const expiresAt = this.passwordResetTokenExpiry();
457
+
458
+ const user = await this.process(
239
459
  async () => {
240
460
  // Set reset token and return
241
- dbObject.passwordResetToken = crypto.randomBytes(32).toString('hex');
461
+ dbObject.passwordResetToken = token;
462
+ dbObject.passwordResetTokenExpiresAt = expiresAt;
242
463
 
243
464
  // Save
244
465
  await dbObject.save();
@@ -248,6 +469,133 @@ export abstract class CoreUserService<
248
469
  },
249
470
  { dbObject, serviceOptions },
250
471
  );
472
+
473
+ return { token, user };
474
+ }
475
+
476
+ /**
477
+ * How many minutes a password-reset token stays valid; `0` means it never expires.
478
+ *
479
+ * `0` opts OUT while any INVALID value falls back to the default, which is asymmetric on purpose:
480
+ * switching off the expiry of a full-account-takeover credential is a decision somebody has to
481
+ * state, and a typo in an environment variable must never be the thing that states it. So a
482
+ * negative number, `NaN`, an empty string and a word all resolve to 60 rather than to "unbounded".
483
+ *
484
+ * The value is coerced from a string because configuration reaches this through `NEST_SERVER_CONFIG`
485
+ * and `NSC__*`, where a number frequently arrives as one. `Number('')` is `0`, so an empty value
486
+ * would otherwise read as a deliberate opt-out — it is rejected before the coercion.
487
+ */
488
+ protected passwordResetTokenExpiryMinutes(): number {
489
+ const raw = this.configService.getFastButReadOnly<unknown>('auth.passwordReset.tokenExpiresInMinutes');
490
+
491
+ if (typeof raw === 'string' && !raw.trim().length) {
492
+ return DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES;
493
+ }
494
+
495
+ const value = typeof raw === 'string' ? Number(raw) : raw;
496
+
497
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
498
+ return value;
499
+ }
500
+
501
+ return DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES;
502
+ }
503
+
504
+ /**
505
+ * The moment the token being minted stops being valid, or `null` when expiry is switched off.
506
+ */
507
+ protected passwordResetTokenExpiry(): Date | null {
508
+ const minutes = this.passwordResetTokenExpiryMinutes();
509
+ return minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null;
510
+ }
511
+
512
+ /**
513
+ * Whether a stored expiry timestamp has passed.
514
+ *
515
+ * A MISSING timestamp counts as expired. That is the load-bearing half: every token minted before
516
+ * 11.38.0 has none, and reading "no timestamp" as "valid forever" would preserve exactly the
517
+ * defect this closes — permanently, since those rows never gain one. The cost is that an
518
+ * unredeemed reset mail from before the upgrade stops working, and the remedy is one click.
519
+ *
520
+ * With expiry switched off (`0`) nothing is expired, including those legacy rows: a project that
521
+ * deliberately opted out must not have its tokens invalidated by the same setting.
522
+ */
523
+ protected isPasswordResetTokenExpired(expiresAt: Date | null | undefined): boolean {
524
+ if (this.passwordResetTokenExpiryMinutes() === 0) {
525
+ return false;
526
+ }
527
+
528
+ if (!expiresAt) {
529
+ return true;
530
+ }
531
+
532
+ const time = expiresAt instanceof Date ? expiresAt.getTime() : new Date(expiresAt).getTime();
533
+
534
+ // An unparseable timestamp is treated as expired rather than trusted — the safe direction for
535
+ // a credential.
536
+ return !Number.isFinite(time) || time <= Date.now();
537
+ }
538
+
539
+ /**
540
+ * Build the link that goes into the password-reset mail.
541
+ *
542
+ * Mirrors `CoreBetterAuthEmailVerificationService.buildPasswordResetUrl` so the two reset flows
543
+ * in this package resolve their link the same way. Resolution order, first hit wins:
544
+ *
545
+ * 1. `email.passwordResetLink` from the configuration
546
+ * 2. `<appUrl>/auth/reset-password` — the starter's reset page
547
+ * 3. `null`, when neither is available
548
+ *
549
+ * The app URL comes from `resolveServerUrls`, the same resolution the cookie and CORS setup
550
+ * uses, rather than a raw read of `appUrl`. That is what gives `local`/`ci`/`e2e` their
551
+ * documented localhost default and derives the app origin from a host-split `baseUrl` such as
552
+ * `https://api.crm.localhost`. Reading the option directly — which is what the IAM twin still
553
+ * does — makes a link that works in production come back empty in every local environment.
554
+ *
555
+ * `{token}` is substituted anywhere in the configured value. WITHOUT it the token is appended as
556
+ * a PATH segment, which is what this flow has always done — changing that would silently break
557
+ * every project already relying on it.
558
+ *
559
+ * Returns `null` rather than a string containing `undefined`. The option has no default and
560
+ * nothing validated it, so a project that never set it produced `undefined/<token>` and sent it.
561
+ * A caller that gets `null` here can log and send nothing, which is the honest failure: a mail
562
+ * that does not arrive says "try again", one with a dead link says nothing at all.
563
+ */
564
+ buildPasswordResetLink(token: string): null | string {
565
+ const configured = this.configService.getFastButReadOnly<string>('email.passwordResetLink');
566
+ // One resolver for every mail link in this package — localhost defaults, host-split `baseUrl`,
567
+ // and the `cors.deriveAppUrl` opt-out that keeps a reset token out of an untrusted apex domain.
568
+ // Shared rather than inlined because the IAM twin drifted away from exactly this logic once.
569
+ const appUrl = resolveAppUrlFromConfig(this.configService);
570
+
571
+ let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
572
+
573
+ if (!target) {
574
+ if (!appUrl) {
575
+ this.userServiceLogger.error(
576
+ 'Cannot build a password-reset link: neither `email.passwordResetLink` nor `appUrl` is configured',
577
+ );
578
+ return null;
579
+ }
580
+ target = `${appUrl.replace(/\/$/, '')}/auth/reset-password?token={token}`;
581
+ }
582
+
583
+ // A relative value resolves against appUrl, like the IAM flow does.
584
+ if (target.startsWith('/')) {
585
+ if (!appUrl) {
586
+ this.userServiceLogger.error(
587
+ 'Cannot build a password-reset link: `email.passwordResetLink` is relative and `appUrl` is not configured',
588
+ );
589
+ return null;
590
+ }
591
+ target = `${appUrl.replace(/\/$/, '')}${target}`;
592
+ }
593
+
594
+ if (target.includes('{token}')) {
595
+ return target.replace(/\{token\}/g, encodeURIComponent(token));
596
+ }
597
+
598
+ return `${target.replace(/\/$/, '')}/${encodeURIComponent(token)}`;
251
599
  }
252
600
 
253
601
  /**
@@ -311,10 +659,14 @@ export abstract class CoreUserService<
311
659
  const oldUser = (await this.mainDbModel.findById(id).lean().exec()) as null | TUser;
312
660
  const oldEmail = oldUser?.email;
313
661
 
314
- // Store plain password for IAM sync before any hashing occurs
315
- // We need to capture this before super.update() which may hash it
316
- const inputPassword = (input as any).password;
317
- const plainPasswordForIamSync = inputPassword && !/^[a-f0-9]{64}$/i.test(inputPassword) ? inputPassword : undefined;
662
+ // Capture the submitted password for the IAM sync before super.update()
663
+ // hashes it in place.
664
+ //
665
+ // Passed on exactly as received, including an already-sha256-hashed one
666
+ // see the note in `resetPassword`: the sync normalizes a 64-hex value
667
+ // through unchanged, so skipping it there disabled the sync for the
668
+ // standard setup, where the frontend hashes before sending.
669
+ const passwordForIamSync = (input as any).password;
318
670
 
319
671
  // Perform the update
320
672
  const updatedUser = await super.update(id, input, serviceOptions);
@@ -333,13 +685,28 @@ export abstract class CoreUserService<
333
685
  }
334
686
 
335
687
  // Sync password change to IAM if password was changed and mapper is available
336
- if (this.options?.betterAuthUserMapper && plainPasswordForIamSync && oldUser?.email) {
688
+ if (this.options?.betterAuthUserMapper && passwordForIamSync && oldUser?.email) {
337
689
  try {
338
- await this.options.betterAuthUserMapper.syncPasswordChangeToIam(oldUser.email, plainPasswordForIamSync);
339
- this.userServiceLogger.debug(`Synced password change to IAM for user ${oldUser.email}`);
690
+ // Report what actually happened, not that the call was made. `syncPasswordChangeToIam`
691
+ // answers `false` never throws — when there is no IAM credential to update, and
692
+ // logging success regardless is how a half-applied password change stays invisible:
693
+ // the endpoint reports success, the user is left with two different passwords, and
694
+ // nothing in the log says so.
695
+ const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
696
+ oldUser.email,
697
+ passwordForIamSync,
698
+ );
699
+ if (synced) {
700
+ this.userServiceLogger.debug(`Synced password change to IAM for user ${maskEmail(oldUser.email)}`);
701
+ } else {
702
+ this.userServiceLogger.warn(
703
+ `Password change for ${maskEmail(oldUser.email)} was NOT synced to IAM (no credential account) — ` +
704
+ 'the legacy password now differs from the IAM credential.',
705
+ );
706
+ }
340
707
  } catch (error) {
341
708
  this.userServiceLogger.warn(
342
- `Failed to sync password change to IAM for ${oldUser.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
709
+ `Failed to sync password change to IAM for ${maskEmail(oldUser.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
343
710
  );
344
711
  // Don't throw - password sync failure shouldn't block the update
345
712
  }
@@ -97,11 +97,26 @@ export abstract class CoreUserInput extends CoreInput {
97
97
 
98
98
  /**
99
99
  * Password of the user
100
+ *
101
+ * Restricted to ADMIN and the account owner since 11.38.0. It used to be `S_EVERYONE`, i.e.
102
+ * settable by whoever was allowed to update the record at all — and `updateUser` grants that to
103
+ * `S_CREATOR`, which in an invite or admin-provisioning flow is the INVITING ADMIN, permanently
104
+ * (see `.claude/rules/role-system.md`).
105
+ *
106
+ * That was largely inert before: the lt frontends hash client-side, and the 64-hex guard then
107
+ * routed such a write away from the IAM sync, so it only ever landed in the legacy store — which
108
+ * this release also turns off by default. Removing the guard makes the same write reach the LIVE
109
+ * IAM credential. A dormant privilege issue would have become an account-takeover path in the
110
+ * very release that closes the legacy door, so the field is narrowed in the same step.
111
+ *
112
+ * A user changing their own password is unaffected (`S_SELF`). A project that genuinely needs a
113
+ * third party to set a password should go through an invitation or reset flow, both of which
114
+ * prove possession of the mailbox.
100
115
  */
101
116
  @UnifiedField({
102
117
  description: 'Password of the user',
103
118
  isOptional: true,
104
- roles: RoleEnum.S_EVERYONE,
119
+ roles: [RoleEnum.ADMIN, RoleEnum.S_SELF],
105
120
  })
106
121
  password?: string = undefined;
107
122
  }
@@ -29,6 +29,7 @@ import {
29
29
  IServerOptions,
30
30
  } from './core/common/interfaces/server-options.interface';
31
31
  import { RequestContextMiddleware } from './core/common/middleware/request-context.middleware';
32
+ import { SecurityHeadersMiddleware } from './core/common/middlewares/security-headers.middleware';
32
33
  import { MapAndValidatePipe } from './core/common/pipes/map-and-validate.pipe';
33
34
  import { ComplexityPlugin } from './core/common/plugins/complexity.plugin';
34
35
  import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
@@ -84,6 +85,13 @@ export class CoreModule implements NestModule {
84
85
  configure(consumer: MiddlewareConsumer) {
85
86
  // RequestContext middleware must run for all routes to provide AsyncLocalStorage context
86
87
  consumer.apply(RequestContextMiddleware).forRoutes('*');
88
+
89
+ // Security headers on EVERY response, guards' rejections included. Middleware rather than an
90
+ // interceptor for exactly that reason: an interceptor never runs for a request a guard turns
91
+ // away, and those are the ones an attacker generates most of. The middleware itself decides
92
+ // whether it is enabled — keeping the policy in one file rather than splitting it between a
93
+ // config read here and the values there.
94
+ consumer.apply(SecurityHeadersMiddleware).forRoutes('*');
87
95
  if (CoreModule.graphQlEnabled) {
88
96
  consumer.apply(graphqlUploadExpress()).forRoutes('graphql');
89
97
  }
package/src/index.ts CHANGED
@@ -73,6 +73,7 @@ export * from './core/common/interfaces/server-options.interface';
73
73
  export * from './core/common/interfaces/service-options.interface';
74
74
  export * from './core/common/middleware/request-context.middleware';
75
75
  export * from './core/common/middlewares/to-lower-case.middleware';
76
+ export * from './core/common/middlewares/security-headers.middleware';
76
77
  export * from './core/common/models/core-model.model';
77
78
  export * from './core/common/models/core-persistence.model';
78
79
  export * from './core/common/models/pagination-info.model';
@@ -133,11 +134,13 @@ export * from './core/modules/auth/core-auth.controller';
133
134
  export * from './core/modules/auth/core-auth.model';
134
135
  export * from './core/modules/auth/core-auth.module';
135
136
  export * from './core/modules/auth/core-auth.resolver';
137
+ export * from './core/modules/auth/core-legacy-auth-deprecation.initializer';
136
138
  export * from './core/modules/auth/exceptions/expired-refresh-token.exception';
137
139
  export * from './core/modules/auth/exceptions/expired-token.exception';
138
140
  export * from './core/modules/auth/exceptions/invalid-token.exception';
139
141
  export * from './core/modules/auth/exceptions/legacy-auth-disabled.exception';
140
142
  export * from './core/modules/auth/guards/auth.guard';
143
+ export * from './core/modules/auth/helpers/legacy-endpoints.helper';
141
144
  export * from './core/modules/auth/guards/legacy-auth-rate-limit.guard';
142
145
  export * from './core/modules/auth/guards/roles-guard-registry';
143
146
  export * from './core/modules/auth/guards/roles.guard';
@@ -144,7 +144,13 @@ export class UserController {
144
144
  @Post('password/reset-request')
145
145
  @Roles(RoleEnum.S_EVERYONE)
146
146
  async requestPasswordResetMail(@Body('email') email: string): Promise<boolean> {
147
- return !!(await this.userService.sendPasswordResetMail(email));
147
+ // Always `true`, whether or not the address exists. `sendPasswordResetMail` returns `null` for
148
+ // an unknown one (see CoreUserService.setPasswordResetTokenForEmail), and forwarding that as
149
+ // `false` would rebuild the account oracle one layer up: the status code is equal, the body
150
+ // is not. The caller is told the same thing either way — "if this address is known, mail is on
151
+ // its way" — which is the only honest answer that reveals nothing.
152
+ await this.userService.sendPasswordResetMail(email);
153
+ return true;
148
154
  }
149
155
 
150
156
  /**
@@ -125,7 +125,13 @@ export class UserResolver {
125
125
  @Mutation(() => Boolean, { description: 'Request new password for user with email' })
126
126
  @Roles(RoleEnum.S_EVERYONE)
127
127
  async requestPasswordResetMail(@Args('email') email: string): Promise<boolean> {
128
- return !!(await this.userService.sendPasswordResetMail(email));
128
+ // Always `true`, whether or not the address exists. `sendPasswordResetMail` returns `null` for
129
+ // an unknown one (see CoreUserService.setPasswordResetTokenForEmail), and forwarding that as
130
+ // `false` would rebuild the account oracle one layer up: the status code is equal, the body
131
+ // is not. The caller is told the same thing either way — "if this address is known, mail is on
132
+ // its way" — which is the only honest answer that reveals nothing.
133
+ await this.userService.sendPasswordResetMail(email);
134
+ return true;
129
135
  }
130
136
 
131
137
  /**