@lenne.tech/nest-server 11.38.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 (83) hide show
  1. package/.claude/rules/configurable-features.md +23 -1
  2. package/.claude/rules/package-management.md +66 -0
  3. package/.claude/rules/testing.md +59 -5
  4. package/FRAMEWORK-API.md +1 -1
  5. package/dist/config.env.js +4 -2
  6. package/dist/config.env.js.map +1 -1
  7. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  8. package/dist/core/common/helpers/config.helper.js +18 -0
  9. package/dist/core/common/helpers/config.helper.js.map +1 -1
  10. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  11. package/dist/core/common/helpers/cookies.helper.js +9 -0
  12. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  13. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  14. package/dist/core/common/helpers/input.helper.js +4 -0
  15. package/dist/core/common/helpers/input.helper.js.map +1 -1
  16. package/dist/core/common/helpers/service.helper.js +6 -1
  17. package/dist/core/common/helpers/service.helper.js.map +1 -1
  18. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  19. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  20. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  21. package/dist/core/common/services/email.service.d.ts +4 -1
  22. package/dist/core/common/services/email.service.js +25 -2
  23. package/dist/core/common/services/email.service.js.map +1 -1
  24. package/dist/core/common/services/module.service.js +1 -0
  25. package/dist/core/common/services/module.service.js.map +1 -1
  26. package/dist/core/modules/better-auth/better-auth.config.js +7 -0
  27. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
  29. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  30. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
  31. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
  32. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  34. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  35. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  36. package/dist/core/modules/better-auth/index.d.ts +1 -0
  37. package/dist/core/modules/better-auth/index.js +1 -0
  38. package/dist/core/modules/better-auth/index.js.map +1 -1
  39. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  40. package/dist/core/modules/error-code/error-codes.js +24 -0
  41. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  42. package/dist/core/modules/hub/core-hub.service.js +1 -0
  43. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  44. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  45. package/dist/core/modules/user/core-user.model.js +11 -1
  46. package/dist/core/modules/user/core-user.model.js.map +1 -1
  47. package/dist/core/modules/user/core-user.service.d.ts +9 -0
  48. package/dist/core/modules/user/core-user.service.js +96 -4
  49. package/dist/core/modules/user/core-user.service.js.map +1 -1
  50. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  51. package/dist/server/modules/user/user.model.d.ts +5 -0
  52. package/dist/server/modules/user/user.service.js +12 -6
  53. package/dist/server/modules/user/user.service.js.map +1 -1
  54. package/dist/templates/password-reset-de.ejs +12 -0
  55. package/dist/templates/password-reset-en.ejs +12 -0
  56. package/dist/templates/password-reset.ejs +1 -0
  57. package/dist/tsconfig.build.tsbuildinfo +1 -1
  58. package/docs/REQUEST-LIFECYCLE.md +14 -0
  59. package/migration-guides/11.37.x-to-11.38.x.md +18 -1
  60. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  61. package/package.json +1 -1
  62. package/src/config.env.ts +19 -3
  63. package/src/core/common/helpers/config.helper.ts +79 -0
  64. package/src/core/common/helpers/cookies.helper.ts +38 -0
  65. package/src/core/common/helpers/input.helper.ts +37 -0
  66. package/src/core/common/helpers/service.helper.ts +9 -1
  67. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  68. package/src/core/common/interfaces/server-options.interface.ts +103 -3
  69. package/src/core/common/services/email.service.ts +46 -1
  70. package/src/core/common/services/module.service.ts +1 -0
  71. package/src/core/modules/better-auth/better-auth.config.ts +25 -0
  72. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
  73. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
  74. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  75. package/src/core/modules/better-auth/index.ts +1 -0
  76. package/src/core/modules/error-code/error-codes.ts +55 -0
  77. package/src/core/modules/hub/core-hub.service.ts +1 -0
  78. package/src/core/modules/user/core-user.model.ts +28 -1
  79. package/src/core/modules/user/core-user.service.ts +267 -5
  80. package/src/server/modules/user/user.service.ts +26 -7
  81. package/src/templates/password-reset-de.ejs +12 -0
  82. package/src/templates/password-reset-en.ejs +12 -0
  83. package/src/templates/password-reset.ejs +1 -0
@@ -0,0 +1,146 @@
1
+ import { ErrorCode } from '../error-code/error-codes';
2
+
3
+ /**
4
+ * Translate Better-Auth's own error codes into nest-server's `#LTNS_XXXX:` message format.
5
+ *
6
+ * ── Why this exists ────────────────────────────────────────────────────────────
7
+ * Frontends in this stack translate errors by parsing the message for nest-server's marker —
8
+ * `useLtErrorTranslation` matches `/^#([A-Z_]+_\d+):\s*(.+)$/` and nothing else. Better-Auth
9
+ * answers with `{ code: 'INVALID_TOKEN', message: 'Invalid token' }`, which carries no marker, so
10
+ * the parser finds no code and hands the raw string through. The end user is shown English
11
+ * developer text — not as an edge case, but as the NORMAL outcome of every IAM error.
12
+ *
13
+ * That is felt most on the password-reset page: a link that has expired produces `INVALID_TOKEN`,
14
+ * and the person who already cannot sign in is told "Invalid token" in a language the rest of the
15
+ * product does not use.
16
+ *
17
+ * Wrapping the message here fixes it for every consumer at once — including projects that do not
18
+ * use `@lenne.tech/nuxt-extensions` and would otherwise each need their own code table.
19
+ *
20
+ * ── Two rules that keep this safe ──────────────────────────────────────────────
21
+ * 1. The original `code` field is left ALONE. Anything keying on `code` — Better-Auth's own client,
22
+ * a project's error branch — keeps working. Only the human-facing `message` is rewritten.
23
+ * 2. An unknown code is passed through UNCHANGED. A guessed mapping would show a confident,
24
+ * wrong sentence, which is worse than an untranslated true one. New codes are added here
25
+ * deliberately, not inferred.
26
+ *
27
+ * Mapping only where the meaning is unambiguous. Three deliberate absences:
28
+ *
29
+ * - `SESSION_EXPIRED` means "re-authenticate for this sensitive action", which is not what
30
+ * nest-server's `TOKEN_EXPIRED` ("please sign in again") tells the user to do.
31
+ * - `USER_NOT_FOUND` is an account-enumeration signal. Better-Auth already exposes it as a `code`,
32
+ * but translating it into the user's language would make the oracle friendlier and more legible
33
+ * — the opposite direction from the rest of this release, which spent real effort closing the
34
+ * legacy reset endpoint's equivalent. Left untranslated on purpose.
35
+ * - Anything else Better-Auth may add later. An unmapped code passes through unchanged.
36
+ *
37
+ * `PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` ARE mapped, and their reachability is worth stating
38
+ * because it is narrower than it looks: the lt frontend hashes with `ltSha256` before sending, so
39
+ * every password arrives as 64 hex characters and neither bound is crossed. They remain reachable
40
+ * for a client that does not hash (this is a framework, not only the lt stack) and for a project
41
+ * that configures `minPasswordLength` above 64 — which the middleware reads as a passthrough
42
+ * option, so it is a supported configuration rather than a hypothetical.
43
+ */
44
+ const BETTER_AUTH_ERROR_CODE_MAP: Readonly<Record<string, string>> = Object.freeze({
45
+ EMAIL_ALREADY_VERIFIED: ErrorCode.EMAIL_ALREADY_VERIFIED,
46
+ EMAIL_NOT_VERIFIED: ErrorCode.EMAIL_VERIFICATION_REQUIRED,
47
+ INVALID_EMAIL_OR_PASSWORD: ErrorCode.INVALID_CREDENTIALS,
48
+ INVALID_PASSWORD: ErrorCode.INVALID_PASSWORD,
49
+ // Distinct codes, not LINK_INVALID_OR_EXPIRED: Better-Auth answers BOTH with HTTP 400 on
50
+ // `/reset-password`, so a page branching on status alone cannot tell them apart and shows "your
51
+ // link is dead". The user then requests a new link, pastes the same over-long passphrase from
52
+ // their password manager, and fails again — a closed loop that never names the cause.
53
+ PASSWORD_TOO_LONG: ErrorCode.PASSWORD_TOO_LONG,
54
+ PASSWORD_TOO_SHORT: ErrorCode.PASSWORD_TOO_SHORT,
55
+ // NOT ErrorCode.INVALID_TOKEN: that one is the legacy auth service's refresh/session token and
56
+ // reads "sign in again". Every Better-Auth INVALID_TOKEN reaches the user through a LINK in a
57
+ // mail — reset, verification, magic link — and telling somebody who cannot sign in to sign in is
58
+ // the one instruction that helps least.
59
+ INVALID_TOKEN: ErrorCode.LINK_INVALID_OR_EXPIRED,
60
+ // Also NOT ErrorCode.TOKEN_EXPIRED ("please sign in again"), for the same reason as above and
61
+ // with a sharper edge: Better-Auth throws TOKEN_EXPIRED in exactly ONE place — an expired
62
+ // verification LINK (email-verification.mjs:178) — one line above the INVALID_TOKEN it throws
63
+ // for a broken one. Splitting that single user action ("I clicked an old link in my mail") into
64
+ // two opposite instructions would be wrong in the MORE common half, since links expire far more
65
+ // often than they get mangled. Worse, at that point the user is typically not signed in at all,
66
+ // so "sign in again" is a dead end. `LINK_INVALID_OR_EXPIRED` covers expiry in its wording.
67
+ TOKEN_EXPIRED: ErrorCode.LINK_INVALID_OR_EXPIRED,
68
+ USER_ALREADY_EXISTS: ErrorCode.EMAIL_ALREADY_EXISTS,
69
+ USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: ErrorCode.EMAIL_ALREADY_EXISTS,
70
+ });
71
+
72
+ /**
73
+ * The nest-server message for a Better-Auth error code, or `undefined` when it has no mapping.
74
+ *
75
+ * Exported so a project can reuse the same table rather than build a second one that drifts.
76
+ */
77
+ export function mapBetterAuthErrorCode(code: unknown): string | undefined {
78
+ return typeof code === 'string' ? BETTER_AUTH_ERROR_CODE_MAP[code] : undefined;
79
+ }
80
+
81
+ /**
82
+ * Rewrite a failed Better-Auth response so its `message` carries nest-server's error marker.
83
+ *
84
+ * Returns the ORIGINAL response object whenever nothing should change — a successful response, a
85
+ * body that is not JSON, or a code with no mapping. That matters: the response body is a stream
86
+ * that can be read once, so handing back an untouched original rather than a rebuilt copy keeps
87
+ * every other consumer of it working.
88
+ *
89
+ * Never throws. An error surfacing from the error-formatting path would replace a useful message
90
+ * with a 500, which is the one outcome worse than an untranslated string.
91
+ */
92
+ export async function wrapBetterAuthErrorResponse(response: Response): Promise<Response> {
93
+ if (response.ok) {
94
+ return response;
95
+ }
96
+
97
+ // A redirect is not ours to rewrite. Better-Auth reports some failures by REDIRECTING to the
98
+ // caller's `callbackURL` with `?error=<CODE>` (see `redirectOnError` in its email-verification
99
+ // route) — a 3xx with no JSON body, so there is no message to translate. Rewriting the code in
100
+ // that query string instead would invent a second contract on top of Better-Auth's documented
101
+ // one, which the frontend reads. Those cases stay untranslated on purpose, and the frontend maps
102
+ // `?error=` itself.
103
+ if (response.status >= 300 && response.status < 400) {
104
+ return response;
105
+ }
106
+
107
+ try {
108
+ const body = await response.clone().json();
109
+ const mapped = mapBetterAuthErrorCode(body?.code);
110
+
111
+ // No mapping, or a message that already carries the marker (a nest-server exception that
112
+ // travelled through Better-Auth): leave it exactly as it is.
113
+ if (!mapped || typeof body?.message !== 'string' || body.message.startsWith('#')) {
114
+ return response;
115
+ }
116
+
117
+ const headers = new Headers();
118
+
119
+ // `set-cookie` FIRST, and via getSetCookie/append rather than the forEach below. `forEach`
120
+ // yields each cookie separately and `set()` overwrites, so a response carrying two of them
121
+ // would keep only the last. Better-Auth CLEARS session and 2FA cookies on several failure
122
+ // paths — exactly the responses this function rewrites — so collapsing them leaves a stale
123
+ // credential in the browser. `sendWebResponse` uses getSetCookie() for the same reason.
124
+ for (const cookie of response.headers.getSetCookie?.() ?? []) {
125
+ headers.append('set-cookie', cookie);
126
+ }
127
+
128
+ response.headers.forEach((value, key) => {
129
+ const lower = key.toLowerCase();
130
+ // content-length: the rewritten body has a different length, and a stale one truncates the
131
+ // response. set-cookie: already appended above, and `set()` here would undo that.
132
+ if (lower !== 'content-length' && lower !== 'set-cookie') {
133
+ headers.set(key, value);
134
+ }
135
+ });
136
+
137
+ return new Response(JSON.stringify({ ...body, message: mapped }), {
138
+ headers,
139
+ status: response.status,
140
+ statusText: response.statusText,
141
+ });
142
+ } catch {
143
+ // Not JSON, or an unreadable body. The original is still the best answer available.
144
+ return response;
145
+ }
146
+ }
@@ -31,6 +31,7 @@ export * from './core-better-auth-api.middleware';
31
31
  export * from './core-better-auth-auth.model';
32
32
  export * from './core-better-auth-cookie.helper';
33
33
  export * from './core-better-auth-email-verification.service';
34
+ export * from './core-better-auth-error-codes.helper';
34
35
  export * from './core-better-auth-migration-status.model';
35
36
  export * from './core-better-auth-models';
36
37
  export * from './core-better-auth-rate-limit.middleware';
@@ -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
  /**