@lenne.tech/nest-server 11.38.0 → 11.40.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 (91) hide show
  1. package/.claude/rules/configurable-features.md +24 -1
  2. package/.claude/rules/module-inheritance.md +2 -0
  3. package/.claude/rules/package-management.md +117 -2
  4. package/.claude/rules/testing.md +238 -5
  5. package/CLAUDE.md +13 -1
  6. package/FRAMEWORK-API.md +2 -2
  7. package/dist/config.env.js +4 -2
  8. package/dist/config.env.js.map +1 -1
  9. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  10. package/dist/core/common/helpers/config.helper.js +18 -0
  11. package/dist/core/common/helpers/config.helper.js.map +1 -1
  12. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  13. package/dist/core/common/helpers/cookies.helper.js +9 -0
  14. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  15. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  16. package/dist/core/common/helpers/input.helper.js +4 -0
  17. package/dist/core/common/helpers/input.helper.js.map +1 -1
  18. package/dist/core/common/helpers/service.helper.js +6 -1
  19. package/dist/core/common/helpers/service.helper.js.map +1 -1
  20. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  21. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  22. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
  23. package/dist/core/common/services/email.service.d.ts +4 -1
  24. package/dist/core/common/services/email.service.js +25 -2
  25. package/dist/core/common/services/email.service.js.map +1 -1
  26. package/dist/core/common/services/module.service.js +1 -0
  27. package/dist/core/common/services/module.service.js.map +1 -1
  28. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
  29. package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
  30. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  31. package/dist/core/modules/better-auth/better-auth.config.js +7 -0
  32. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
  34. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  35. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
  36. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
  37. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  38. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  39. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  40. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  41. package/dist/core/modules/better-auth/index.d.ts +1 -0
  42. package/dist/core/modules/better-auth/index.js +1 -0
  43. package/dist/core/modules/better-auth/index.js.map +1 -1
  44. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  45. package/dist/core/modules/error-code/error-codes.js +24 -0
  46. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  47. package/dist/core/modules/hub/core-hub.service.js +1 -0
  48. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  49. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  50. package/dist/core/modules/user/core-user.model.js +11 -1
  51. package/dist/core/modules/user/core-user.model.js.map +1 -1
  52. package/dist/core/modules/user/core-user.service.d.ts +9 -0
  53. package/dist/core/modules/user/core-user.service.js +96 -4
  54. package/dist/core/modules/user/core-user.service.js.map +1 -1
  55. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  56. package/dist/server/modules/user/user.model.d.ts +5 -0
  57. package/dist/server/modules/user/user.service.js +12 -6
  58. package/dist/server/modules/user/user.service.js.map +1 -1
  59. package/dist/templates/password-reset-de.ejs +12 -0
  60. package/dist/templates/password-reset-en.ejs +12 -0
  61. package/dist/templates/password-reset.ejs +1 -0
  62. package/dist/tsconfig.build.tsbuildinfo +1 -1
  63. package/docs/REQUEST-LIFECYCLE.md +14 -0
  64. package/migration-guides/11.37.x-to-11.38.x.md +18 -1
  65. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  66. package/migration-guides/11.39.0-to-11.40.0.md +186 -0
  67. package/package.json +5 -4
  68. package/src/config.env.ts +19 -3
  69. package/src/core/common/helpers/config.helper.ts +79 -0
  70. package/src/core/common/helpers/cookies.helper.ts +38 -0
  71. package/src/core/common/helpers/input.helper.ts +37 -0
  72. package/src/core/common/helpers/service.helper.ts +9 -1
  73. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  74. package/src/core/common/interfaces/server-options.interface.ts +120 -4
  75. package/src/core/common/services/email.service.ts +46 -1
  76. package/src/core/common/services/module.service.ts +1 -0
  77. package/src/core/modules/ai/README.md +33 -0
  78. package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
  79. package/src/core/modules/better-auth/better-auth.config.ts +25 -0
  80. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
  81. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
  82. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  83. package/src/core/modules/better-auth/index.ts +1 -0
  84. package/src/core/modules/error-code/error-codes.ts +55 -0
  85. package/src/core/modules/hub/core-hub.service.ts +1 -0
  86. package/src/core/modules/user/core-user.model.ts +28 -1
  87. package/src/core/modules/user/core-user.service.ts +267 -5
  88. package/src/server/modules/user/user.service.ts +26 -7
  89. package/src/templates/password-reset-de.ejs +12 -0
  90. package/src/templates/password-reset-en.ejs +12 -0
  91. package/src/templates/password-reset.ejs +1 -0
@@ -287,6 +287,61 @@ export const LtnsErrors = {
287
287
  },
288
288
  },
289
289
 
290
+ /**
291
+ * A link the user followed from an email is invalid, already used, or expired.
292
+ *
293
+ * Deliberately SEPARATE from `INVALID_TOKEN` (LTNS_0003), which the legacy auth service uses for
294
+ * refresh/session tokens. Those two look alike to a developer and are opposites to a user: one
295
+ * means "your session ended, sign in again", the other means "the link in your mail is dead,
296
+ * request another". Telling somebody who cannot sign in to sign in is the worse of the two.
297
+ *
298
+ * Deliberately BROADER than `EMAIL_VERIFICATION_TOKEN_INVALID` (LTNS_0024), because the
299
+ * underlying `INVALID_TOKEN` from Better-Auth covers password reset, email verification and
300
+ * magic links alike, and cannot tell them apart.
301
+ *
302
+ * "nicht (mehr) gültig" covers unknown AND expired on purpose: Better-Auth answers both with the
303
+ * same code, so a message claiming to know which one would be guessing half the time.
304
+ */
305
+ /**
306
+ * The submitted password is outside the configured length bounds.
307
+ *
308
+ * Separate codes rather than one "does not meet requirements", because the recipient can only
309
+ * act on a message that names the direction. Better-Auth answers `PASSWORD_TOO_LONG` with the
310
+ * same HTTP 400 it uses for an invalid token, so without a distinct code a reset page shows
311
+ * "this link is no longer valid" — the user requests a new link, pastes the same over-long
312
+ * passphrase from their password manager, and fails again. A closed loop with no hint at the
313
+ * cause.
314
+ *
315
+ * The numbers are Better-Auth's defaults (8 / 128) and a project may configure others; the text
316
+ * therefore names the common case rather than claiming to know the deployment's bounds.
317
+ */
318
+ PASSWORD_TOO_LONG: {
319
+ code: 'LTNS_0028',
320
+ message: 'Password exceeds the maximum length',
321
+ translations: {
322
+ de: 'Das Passwort ist zu lang. Erlaubt sind standardmäßig höchstens 128 Zeichen.',
323
+ en: 'The password is too long. The default maximum is 128 characters.',
324
+ },
325
+ },
326
+
327
+ PASSWORD_TOO_SHORT: {
328
+ code: 'LTNS_0029',
329
+ message: 'Password is below the minimum length',
330
+ translations: {
331
+ de: 'Das Passwort ist zu kurz. Erforderlich sind standardmäßig mindestens 8 Zeichen.',
332
+ en: 'The password is too short. The default minimum is 8 characters.',
333
+ },
334
+ },
335
+
336
+ LINK_INVALID_OR_EXPIRED: {
337
+ code: 'LTNS_0027',
338
+ message: 'Link is invalid or expired',
339
+ translations: {
340
+ de: 'Dieser Link ist nicht (mehr) gültig. Bitte fordern Sie einen neuen an.',
341
+ en: 'This link is no longer valid. Please request a new one.',
342
+ },
343
+ },
344
+
290
345
  // =====================================================
291
346
  // Authorization Errors (LTNS_0100-LTNS_0199)
292
347
  // =====================================================
@@ -20,6 +20,7 @@ const FRAMEWORK_SECRET_FIELDS = [
20
20
  'password',
21
21
  'verificationToken',
22
22
  'passwordResetToken',
23
+ 'passwordResetTokenExpiresAt',
23
24
  'refreshTokens',
24
25
  'tempTokens',
25
26
  'apiKeyEncrypted',
@@ -95,11 +95,38 @@ export abstract class CoreUserModel extends CorePersistenceModel {
95
95
  */
96
96
  @UnifiedField({
97
97
  isOptional: true,
98
- mongoose: true,
98
+ // PARTIAL, not plain and not sparse. `findOne({ passwordResetToken: token })` runs on an
99
+ // unauthenticated, unrated endpoint, and without an index every call is a full collection scan
100
+ // of `users` — the dominant cost of that path and an easy amplifier for a request loop.
101
+ // Sparse would be wrong: the reset path clears the field by UNSETTING it, but a document that
102
+ // once held `null` would still count as present, so the index would accumulate an entry for
103
+ // every user who ever requested a reset. The `$type: 'string'` predicate keeps it to the rows
104
+ // that can actually match.
105
+ //
106
+ // NEVER give the companion `passwordResetTokenExpiresAt` a TTL index: it is a Date, and a TTL
107
+ // index there would delete the USER DOCUMENT rather than the token.
108
+ mongoose: { index: { partialFilterExpression: { passwordResetToken: { $type: 'string' } } } },
99
109
  roles: RoleEnum.S_NO_ONE,
100
110
  })
101
111
  passwordResetToken: string = undefined;
102
112
 
113
+ /**
114
+ * When the current password-reset token stops being valid.
115
+ *
116
+ * `S_NO_ONE` like the token itself: it never belongs in a response, and revealing that a reset is
117
+ * pending is information an account holder gains nothing from and an attacker does.
118
+ *
119
+ * A user document written before 11.38.0 has no value here, and the reset path treats that as
120
+ * expired — see `IAuthPasswordReset.tokenExpiresInMinutes` for why that direction was chosen.
121
+ */
122
+ @UnifiedField({
123
+ isOptional: true,
124
+ mongoose: true,
125
+ roles: RoleEnum.S_NO_ONE,
126
+ type: () => Date,
127
+ })
128
+ passwordResetTokenExpiresAt: Date = undefined;
129
+
103
130
  /**
104
131
  * Refresh tokens (for devices)
105
132
  * key: Token
@@ -5,11 +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 { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
8
9
  import { maskEmail } from '../../common/helpers/logging.helper';
9
- import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
10
+ import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
10
11
  import { ServiceOptions } from '../../common/interfaces/service-options.interface';
11
12
  import { ConfigService } from '../../common/services/config.service';
12
13
  import { CrudService } from '../../common/services/crud.service';
14
+ import { ErrorCode } from '../error-code/error-codes';
13
15
  import { EmailService } from '../../common/services/email.service';
14
16
  import { CoreModelConstructor } from '../../common/types/core-model-constructor.type';
15
17
  import { CoreUserModel } from './core-user.model';
@@ -23,6 +25,14 @@ import { CoreUserServiceOptions } from './interfaces/core-user-service-options.i
23
25
  * Provides user management with automatic synchronization between
24
26
  * Legacy Auth and Better-Auth (IAM) systems when both are enabled.
25
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
+
26
36
  export abstract class CoreUserService<
27
37
  TUser extends CoreUserModel,
28
38
  TUserInput extends CoreUserInput,
@@ -42,6 +52,56 @@ export abstract class CoreUserService<
42
52
  protected readonly options?: CoreUserServiceOptions,
43
53
  ) {
44
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
+ );
45
105
  }
46
106
 
47
107
  // ===================================================================================================================
@@ -137,6 +197,10 @@ export abstract class CoreUserService<
137
197
  * Get verified state of user by token
138
198
  */
139
199
  async getVerifiedState(token: string, _serviceOptions?: ServiceOptions): Promise<boolean> {
200
+ if (!isQueryableString(token)) {
201
+ return false;
202
+ }
203
+
140
204
  const user = await this.mainDbModel.findOne({ verificationToken: token }).exec();
141
205
 
142
206
  if (!user) {
@@ -151,6 +215,10 @@ export abstract class CoreUserService<
151
215
  */
152
216
  async verify(token: string, serviceOptions?: ServiceOptions): Promise<string | TUser> {
153
217
  // Get user
218
+ if (!isQueryableString(token)) {
219
+ throw new NotFoundException(ErrorCode.INVALID_TOKEN);
220
+ }
221
+
154
222
  const dbObject = await this.mainDbModel.findOne({ verificationToken: token }).exec();
155
223
  if (!dbObject) {
156
224
  throw new NotFoundException(`No user found with verify token: ${token}`);
@@ -184,13 +252,36 @@ export abstract class CoreUserService<
184
252
  */
185
253
  async resetPassword(token: string, newPassword: string, serviceOptions?: ServiceOptions): Promise<TUser> {
186
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
+
187
262
  const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
188
- if (!dbObject) {
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)) {
189
280
  // The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
190
281
  // in the response body and in every log line that records the exception — and this logger
191
282
  // feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
192
283
  // free log-stuffing, and the message is no more useful for it.
193
- throw new NotFoundException('Invalid or expired password reset token');
284
+ throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
194
285
  }
195
286
 
196
287
  // Capture the submitted password for the IAM sync before the closure below
@@ -234,6 +325,7 @@ export abstract class CoreUserService<
234
325
  const updatedUser = await assignPlain(dbObject, {
235
326
  password: await bcrypt.hash(newPassword, 10),
236
327
  passwordResetToken: null,
328
+ passwordResetTokenExpiresAt: null,
237
329
  // A reset is what somebody reaches for after a suspected takeover, so it must not
238
330
  // leave the attacker's session live. Clearing the refresh tokens ends every legacy
239
331
  // session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
@@ -303,6 +395,45 @@ export abstract class CoreUserService<
303
395
  * Anything measuring this honestly should say so rather than claim the channel is closed.
304
396
  */
305
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
+
306
437
  // Get user
307
438
  const dbObject = await this.mainDbModel.findOne({ email }).exec();
308
439
  if (!dbObject) {
@@ -321,10 +452,14 @@ export abstract class CoreUserService<
321
452
  return null;
322
453
  }
323
454
 
324
- return this.process(
455
+ const token = crypto.randomBytes(32).toString('hex');
456
+ const expiresAt = this.passwordResetTokenExpiry();
457
+
458
+ const user = await this.process(
325
459
  async () => {
326
460
  // Set reset token and return
327
- dbObject.passwordResetToken = crypto.randomBytes(32).toString('hex');
461
+ dbObject.passwordResetToken = token;
462
+ dbObject.passwordResetTokenExpiresAt = expiresAt;
328
463
 
329
464
  // Save
330
465
  await dbObject.save();
@@ -334,6 +469,133 @@ export abstract class CoreUserService<
334
469
  },
335
470
  { dbObject, serviceOptions },
336
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)}`;
337
599
  }
338
600
 
339
601
  /**
@@ -91,21 +91,40 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
91
91
  * Better-Auth makes on the IAM path.
92
92
  */
93
93
  async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
94
- // Set password reset token
95
- const user = await super.setPasswordResetTokenForEmail(email, serviceOptions);
94
+ // `createPasswordResetToken`, not `setPasswordResetTokenForEmail`: the latter returns the user
95
+ // through `process()`, where the security interceptor strips `passwordResetToken`. Reading the
96
+ // token off that object is what mailed the word `undefined` to a real recipient.
97
+ const created = await this.createPasswordResetToken(email, serviceOptions);
96
98
 
97
- if (!user) {
99
+ if (!created) {
98
100
  // Unknown address, enumeration protection on. Answer exactly as for a known one.
99
101
  return null;
100
102
  }
101
103
 
104
+ // `buildPasswordResetLink`, not string concatenation: `email.passwordResetLink` has no default,
105
+ // and this config file does not set one, so concatenating produced `undefined/<token>`.
106
+ const link = this.buildPasswordResetLink(created.token);
107
+ if (!link) {
108
+ // Sending a mail whose link cannot work is worse than sending none. The recipient has no
109
+ // second way in, and a dead link gives them nothing to act on — while no mail at least reads
110
+ // as "try again". The address is still answered exactly as a known one.
111
+ this.userServiceLogger.error(
112
+ 'Password reset mail not sent: no reset link could be built. Set `email.passwordResetLink` or `appUrl`.',
113
+ );
114
+ return created.user;
115
+ }
116
+
102
117
  // Deliberately NOT awaited — see the note above.
103
118
  void this.emailService
104
- .sendMail(user.email, 'Password reset', {
119
+ .sendMail(created.user.email, 'Password reset', {
105
120
  htmlTemplate: 'password-reset',
106
121
  templateData: {
107
- link: `${this.configService.configFastButReadOnly.email.passwordResetLink}/${user.passwordResetToken}`,
108
- name: user.username,
122
+ link,
123
+ // The mail is the only place the recipient can learn there IS a deadline. Without it an
124
+ // expired link is indistinguishable from a broken one — the same experience this whole
125
+ // area was repaired for.
126
+ linkExpiresInMinutes: this.passwordResetTokenExpiryMinutes(),
127
+ name: created.user.username,
109
128
  },
110
129
  })
111
130
  .catch((error: unknown) => {
@@ -115,7 +134,7 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
115
134
  });
116
135
 
117
136
  // Return user
118
- return user;
137
+ return created.user;
119
138
  }
120
139
 
121
140
  /**
@@ -50,6 +50,18 @@
50
50
  Falls die Schaltfläche nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
51
51
  <a href="<%= link %>" style="color:#1d4ed8; word-break:break-all; overflow-wrap:break-word;"><%= link %></a>
52
52
  </p>
53
+ <%
54
+ /* Optional, like `appName`: a caller that omits it must not turn a password-reset
55
+ mail into a 500. Rendered only for a real, positive value — a deployment that
56
+ switched the expiry off (`0`) has no deadline to announce. */
57
+ const expiryMinutes = (typeof linkExpiresInMinutes !== 'undefined' && Number(linkExpiresInMinutes) > 0)
58
+ ? Number(linkExpiresInMinutes) : 0;
59
+ -%>
60
+ <% if (expiryMinutes) { %>
61
+ <p style="margin:16px 0 0 0; font-size:13px; line-height:20px; color:#737373;">
62
+ Der Link ist <%= expiryMinutes %> Minuten lang gültig. Danach kannst du einfach einen neuen anfordern.
63
+ </p>
64
+ <% } %>
53
65
  </td>
54
66
  </tr>
55
67
  <tr>
@@ -50,6 +50,18 @@
50
50
  If the button doesn't work, copy and paste this link into your browser:<br>
51
51
  <a href="<%= link %>" style="color:#1d4ed8; word-break:break-all; overflow-wrap:break-word;"><%= link %></a>
52
52
  </p>
53
+ <%
54
+ /* Optional, like `appName`: a caller that omits it must not turn a password-reset
55
+ mail into a 500. Rendered only for a real, positive value — a deployment that
56
+ switched the expiry off (`0`) has no deadline to announce. */
57
+ const expiryMinutes = (typeof linkExpiresInMinutes !== 'undefined' && Number(linkExpiresInMinutes) > 0)
58
+ ? Number(linkExpiresInMinutes) : 0;
59
+ -%>
60
+ <% if (expiryMinutes) { %>
61
+ <p style="margin:16px 0 0 0; font-size:13px; line-height:20px; color:#737373;">
62
+ This link is valid for <%= expiryMinutes %> minutes. After that, just request a new one.
63
+ </p>
64
+ <% } %>
53
65
  </td>
54
66
  </tr>
55
67
  <tr>
@@ -1,3 +1,4 @@
1
1
  <h1>Hello <%= name %>,</h1>
2
2
  <p>you requested a link for setting a new password. Here it is.</p><br />
3
3
  <a href="<%= link %>">Reset password</a>
4
+ <% if (typeof linkExpiresInMinutes !== 'undefined' && Number(linkExpiresInMinutes) > 0) { %><br /><p>This link is valid for <%= Number(linkExpiresInMinutes) %> minutes. After that, just request a new one.</p><% } %>