@lenne.tech/nest-server 11.37.0 → 11.38.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 (85) hide show
  1. package/.claude/rules/configurable-features.md +29 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/testing.md +26 -4
  4. package/FRAMEWORK-API.md +4 -2
  5. package/dist/config.env.js +1 -1
  6. package/dist/config.env.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
  8. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  9. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  10. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  11. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  12. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  13. package/dist/core/modules/auth/core-auth.module.js +2 -0
  14. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  15. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  16. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  17. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  18. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  19. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  20. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  21. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  22. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  23. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  24. package/dist/core/modules/better-auth/better-auth.config.js +33 -1
  25. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  26. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  27. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
  28. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  29. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +2 -1
  30. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
  31. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  32. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  33. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  34. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  35. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  36. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  37. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  38. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  39. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  40. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  41. package/dist/core/modules/user/core-user.service.d.ts +1 -1
  42. package/dist/core/modules/user/core-user.service.js +29 -12
  43. package/dist/core/modules/user/core-user.service.js.map +1 -1
  44. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  45. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  46. package/dist/core.module.js +2 -0
  47. package/dist/core.module.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/server/modules/user/user.controller.js +2 -1
  52. package/dist/server/modules/user/user.controller.js.map +1 -1
  53. package/dist/server/modules/user/user.resolver.js +2 -1
  54. package/dist/server/modules/user/user.resolver.js.map +1 -1
  55. package/dist/server/modules/user/user.service.d.ts +1 -1
  56. package/dist/server/modules/user/user.service.js +8 -1
  57. package/dist/server/modules/user/user.service.js.map +1 -1
  58. package/dist/tsconfig.build.tsbuildinfo +1 -1
  59. package/docs/REQUEST-LIFECYCLE.md +35 -1
  60. package/migration-guides/11.37.x-to-11.38.x.md +511 -0
  61. package/package.json +1 -1
  62. package/src/config.env.ts +9 -1
  63. package/src/core/common/interfaces/server-options.interface.ts +225 -9
  64. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  65. package/src/core/modules/auth/README.md +104 -0
  66. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  67. package/src/core/modules/auth/core-auth.module.ts +5 -0
  68. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  69. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  70. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  71. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  72. package/src/core/modules/better-auth/README.md +32 -59
  73. package/src/core/modules/better-auth/better-auth.config.ts +106 -4
  74. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
  75. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
  76. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  77. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  78. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  79. package/src/core/modules/user/core-user.service.ts +123 -18
  80. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  81. package/src/core.module.ts +8 -0
  82. package/src/index.ts +3 -0
  83. package/src/server/modules/user/user.controller.ts +7 -1
  84. package/src/server/modules/user/user.resolver.ts +7 -1
  85. package/src/server/modules/user/user.service.ts +35 -9
@@ -5,6 +5,7 @@ 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 { maskEmail } from '../../common/helpers/logging.helper';
8
9
  import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
9
10
  import { ServiceOptions } from '../../common/interfaces/service-options.interface';
10
11
  import { ConfigService } from '../../common/services/config.service';
@@ -185,12 +186,41 @@ export abstract class CoreUserService<
185
186
  // Get user
186
187
  const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
187
188
  if (!dbObject) {
188
- throw new NotFoundException(`No user found with password reset token: ${token}`);
189
+ // The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
190
+ // in the response body and in every log line that records the exception — and this logger
191
+ // feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
192
+ // free log-stuffing, and the message is no more useful for it.
193
+ throw new NotFoundException('Invalid or expired password reset token');
189
194
  }
190
195
 
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;
196
+ // Capture the submitted password for the IAM sync before the closure below
197
+ // reassigns `newPassword` to its sha256 form.
198
+ //
199
+ // It is passed on exactly as received, INCLUDING an already-sha256-hashed
200
+ // one. This used to skip the sync for a 64-hex value, on the reasoning that
201
+ // "IAM uses scrypt, not bcrypt+sha256" — but the sync does not need a plain
202
+ // password: `hashPasswordForBetterAuth` runs its input through
203
+ // `normalizePasswordForIam`, which passes a 64-hex string through unchanged
204
+ // by design, and `migrateAccountToIam` is fed the very same pre-hashed value
205
+ // when an account is created.
206
+ //
207
+ // The guard therefore disabled the sync for exactly the clients this stack
208
+ // ships. The lt frontends hash in the browser before sending — seven call
209
+ // sites in `nuxt-extensions/src/runtime/lib/auth-client.ts` (`signIn.email`,
210
+ // `resetPassword`, `changePassword` and the rest) run `ltSha256` on the
211
+ // password first. That is independent of the `sha256` config option, which
212
+ // only governs what the SERVER does with a plaintext password it happens to
213
+ // receive. So the value arriving here is 64-hex whatever that option says,
214
+ // and every such reset took the skipped branch.
215
+ //
216
+ // The legacy password was updated, the IAM credential was not, and sign-in —
217
+ // which goes through IAM — kept accepting the OLD password and refusing the
218
+ // new one. The endpoint reported success throughout, so the failure surfaced
219
+ // only at the next sign-in.
220
+ //
221
+ // A client that posts a plaintext password was never affected: the old guard
222
+ // let that one through, and the sync normalized it the same way IAM does.
223
+ const passwordForIamSync = newPassword;
194
224
 
195
225
  return this.process(
196
226
  async () => {
@@ -204,17 +234,38 @@ export abstract class CoreUserService<
204
234
  const updatedUser = await assignPlain(dbObject, {
205
235
  password: await bcrypt.hash(newPassword, 10),
206
236
  passwordResetToken: null,
237
+ // A reset is what somebody reaches for after a suspected takeover, so it must not
238
+ // leave the attacker's session live. Clearing the refresh tokens ends every legacy
239
+ // session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
240
+ // which is off by default because it is a behaviour change for existing deployments —
241
+ // the migration guide recommends turning it on.
242
+ //
243
+ // Without this, "the reset now lands in both stores" would still leave the account
244
+ // reachable with the credential the reset was meant to retire.
245
+ refreshTokens: {},
207
246
  }).save();
208
247
 
209
248
  // Sync password to Better-Auth (IAM) if mapper is available
210
249
  // This ensures users can sign in via IAM after password reset
211
- if (this.options?.betterAuthUserMapper && plainPasswordForIamSync && dbObject.email) {
250
+ if (this.options?.betterAuthUserMapper && passwordForIamSync && dbObject.email) {
212
251
  try {
213
- await this.options.betterAuthUserMapper.syncPasswordChangeToIam(dbObject.email, plainPasswordForIamSync);
252
+ // Same reasoning as in update(): a `false` return means the reset landed in the
253
+ // legacy store only, which is the shape of failure this whole path exists to
254
+ // prevent. It must not be indistinguishable from success.
255
+ const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
256
+ dbObject.email,
257
+ passwordForIamSync,
258
+ );
259
+ if (!synced) {
260
+ this.userServiceLogger.warn(
261
+ `Password reset for ${maskEmail(dbObject.email)} was NOT synced to IAM (no credential account) — ` +
262
+ 'the legacy password now differs from the IAM credential.',
263
+ );
264
+ }
214
265
  } catch (error) {
215
266
  // Log but don't fail - Legacy Auth password was updated successfully
216
267
  this.userServiceLogger.warn(
217
- `Failed to sync password reset to IAM for ${dbObject.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
268
+ `Failed to sync password reset to IAM for ${maskEmail(dbObject.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
218
269
  );
219
270
  }
220
271
  }
@@ -226,13 +277,48 @@ export abstract class CoreUserService<
226
277
  }
227
278
 
228
279
  /**
229
- * Set password rest token for email
280
+ * Set a password-reset token for an email address
281
+ *
282
+ * Returns `null` for an unknown address when `auth.passwordReset.preventUserEnumeration` is on
283
+ * (the default since 11.38.0) — the caller must then answer exactly as it would for a known one.
284
+ * With the option off it throws `NotFoundException`, the pre-11.38.0 behaviour.
285
+ *
286
+ * WHY THE DEFAULT CHANGED
287
+ *
288
+ * Throwing made the endpoint an account oracle: HTTP 404 for an unknown address, 201 for a known
289
+ * one, so anyone could test who has an account. In a multi-tenant product that also answers who
290
+ * works at which customer. The framework already answers this correctly on the IAM path —
291
+ * Better-Auth's `/request-password-reset` returns the same body either way — so the two halves
292
+ * of one framework disagreed about the same question.
293
+ *
294
+ * THE STATUS CODE IS THE SMALLER HALF
295
+ *
296
+ * Response TIME distinguishes the cases too, and by far more: the known path writes a token and
297
+ * (in the caller) sends mail, the unknown path returns immediately. This method equalises what it
298
+ * can — the token generation still happens, so the CPU cost matches — but the mail send lives in
299
+ * the caller. A `sendPasswordResetMail()` that AWAITS the send leaks the difference as latency,
300
+ * whatever this method does. `src/server/modules/user/user.service.ts` shows the shape that does
301
+ * not; the IAM path uses the same trick, with the reasoning recorded in `better-auth.config.ts`.
302
+ *
303
+ * Anything measuring this honestly should say so rather than claim the channel is closed.
230
304
  */
231
- async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<TUser> {
305
+ async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<null | TUser> {
232
306
  // Get user
233
307
  const dbObject = await this.mainDbModel.findOne({ email }).exec();
234
308
  if (!dbObject) {
235
- throw new NotFoundException(`No user found with email: ${email}`);
309
+ const preventEnumeration =
310
+ this.configService.getFastButReadOnly('auth')?.passwordReset?.preventUserEnumeration !== false;
311
+
312
+ if (!preventEnumeration) {
313
+ throw new NotFoundException(`No user found with email: ${email}`);
314
+ }
315
+
316
+ // Do the work the known path does, so the two do not differ in CPU cost. It is cheap next to
317
+ // a mail send, which is why this alone does not close the timing channel — see the note above.
318
+ crypto.randomBytes(32).toString('hex');
319
+
320
+ this.userServiceLogger.debug(`Password reset requested for an unknown address (${maskEmail(email)})`);
321
+ return null;
236
322
  }
237
323
 
238
324
  return this.process(
@@ -311,10 +397,14 @@ export abstract class CoreUserService<
311
397
  const oldUser = (await this.mainDbModel.findById(id).lean().exec()) as null | TUser;
312
398
  const oldEmail = oldUser?.email;
313
399
 
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;
400
+ // Capture the submitted password for the IAM sync before super.update()
401
+ // hashes it in place.
402
+ //
403
+ // Passed on exactly as received, including an already-sha256-hashed one
404
+ // see the note in `resetPassword`: the sync normalizes a 64-hex value
405
+ // through unchanged, so skipping it there disabled the sync for the
406
+ // standard setup, where the frontend hashes before sending.
407
+ const passwordForIamSync = (input as any).password;
318
408
 
319
409
  // Perform the update
320
410
  const updatedUser = await super.update(id, input, serviceOptions);
@@ -333,13 +423,28 @@ export abstract class CoreUserService<
333
423
  }
334
424
 
335
425
  // Sync password change to IAM if password was changed and mapper is available
336
- if (this.options?.betterAuthUserMapper && plainPasswordForIamSync && oldUser?.email) {
426
+ if (this.options?.betterAuthUserMapper && passwordForIamSync && oldUser?.email) {
337
427
  try {
338
- await this.options.betterAuthUserMapper.syncPasswordChangeToIam(oldUser.email, plainPasswordForIamSync);
339
- this.userServiceLogger.debug(`Synced password change to IAM for user ${oldUser.email}`);
428
+ // Report what actually happened, not that the call was made. `syncPasswordChangeToIam`
429
+ // answers `false` never throws — when there is no IAM credential to update, and
430
+ // logging success regardless is how a half-applied password change stays invisible:
431
+ // the endpoint reports success, the user is left with two different passwords, and
432
+ // nothing in the log says so.
433
+ const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
434
+ oldUser.email,
435
+ passwordForIamSync,
436
+ );
437
+ if (synced) {
438
+ this.userServiceLogger.debug(`Synced password change to IAM for user ${maskEmail(oldUser.email)}`);
439
+ } else {
440
+ this.userServiceLogger.warn(
441
+ `Password change for ${maskEmail(oldUser.email)} was NOT synced to IAM (no credential account) — ` +
442
+ 'the legacy password now differs from the IAM credential.',
443
+ );
444
+ }
340
445
  } catch (error) {
341
446
  this.userServiceLogger.warn(
342
- `Failed to sync password change to IAM for ${oldUser.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
447
+ `Failed to sync password change to IAM for ${maskEmail(oldUser.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
343
448
  );
344
449
  // Don't throw - password sync failure shouldn't block the update
345
450
  }
@@ -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
  /**
@@ -74,19 +74,45 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
74
74
 
75
75
  /**
76
76
  * Request password reset mail
77
+ *
78
+ * REFERENCE IMPLEMENTATION — the two details below are the ones that matter, and a project
79
+ * copying this method needs both. Either one alone leaves the endpoint an account oracle.
80
+ *
81
+ * 1. An unknown address yields `null` (since 11.38.0, unless
82
+ * `auth.passwordReset.preventUserEnumeration` is off) and this method returns quietly, so the
83
+ * caller answers the same way it would for a known one.
84
+ * 2. The mail send is NOT awaited. This is the half that actually closes the channel: the send
85
+ * is a network round trip to SMTP or Brevo, orders of magnitude above everything else in the
86
+ * request. Awaiting it would make the known path visibly slower whatever the status code says
87
+ * — you would have given up the "unknown address" hint in the UI and kept the oracle.
88
+ *
89
+ * A failed send must still be reported rather than crash the process, which is why it carries
90
+ * its own `catch`. The user is told the mail is on its way either way; that is the same trade
91
+ * Better-Auth makes on the IAM path.
77
92
  */
78
- async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<User> {
93
+ async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
79
94
  // Set password reset token
80
95
  const user = await super.setPasswordResetTokenForEmail(email, serviceOptions);
81
96
 
82
- // Send email
83
- await this.emailService.sendMail(user.email, 'Password reset', {
84
- htmlTemplate: 'password-reset',
85
- templateData: {
86
- link: `${this.configService.configFastButReadOnly.email.passwordResetLink}/${user.passwordResetToken}`,
87
- name: user.username,
88
- },
89
- });
97
+ if (!user) {
98
+ // Unknown address, enumeration protection on. Answer exactly as for a known one.
99
+ return null;
100
+ }
101
+
102
+ // Deliberately NOT awaited — see the note above.
103
+ void this.emailService
104
+ .sendMail(user.email, 'Password reset', {
105
+ htmlTemplate: 'password-reset',
106
+ templateData: {
107
+ link: `${this.configService.configFastButReadOnly.email.passwordResetLink}/${user.passwordResetToken}`,
108
+ name: user.username,
109
+ },
110
+ })
111
+ .catch((error: unknown) => {
112
+ this.userServiceLogger.error(
113
+ `Failed to send the password-reset mail: ${error instanceof Error ? error.message : String(error)}`,
114
+ );
115
+ });
90
116
 
91
117
  // Return user
92
118
  return user;