@lenne.tech/nest-server 11.37.0 → 11.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.claude/rules/configurable-features.md +51 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/package-management.md +66 -0
  4. package/.claude/rules/testing.md +81 -5
  5. package/FRAMEWORK-API.md +4 -2
  6. package/dist/config.env.js +5 -3
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  9. package/dist/core/common/helpers/config.helper.js +18 -0
  10. package/dist/core/common/helpers/config.helper.js.map +1 -1
  11. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  12. package/dist/core/common/helpers/cookies.helper.js +9 -0
  13. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  14. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  15. package/dist/core/common/helpers/input.helper.js +4 -0
  16. package/dist/core/common/helpers/input.helper.js.map +1 -1
  17. package/dist/core/common/helpers/service.helper.js +6 -1
  18. package/dist/core/common/helpers/service.helper.js.map +1 -1
  19. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  20. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -0
  22. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  23. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  24. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  25. package/dist/core/common/services/email.service.d.ts +4 -1
  26. package/dist/core/common/services/email.service.js +25 -2
  27. package/dist/core/common/services/email.service.js.map +1 -1
  28. package/dist/core/common/services/module.service.js +1 -0
  29. package/dist/core/common/services/module.service.js.map +1 -1
  30. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  31. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  32. package/dist/core/modules/auth/core-auth.module.js +2 -0
  33. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  34. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  35. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  36. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  37. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  38. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  39. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  40. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  41. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  42. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  43. package/dist/core/modules/better-auth/better-auth.config.js +40 -1
  44. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  46. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
  47. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  48. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +5 -2
  49. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
  50. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  51. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  52. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  53. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  54. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  55. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  56. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  57. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  58. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  59. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  62. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  63. package/dist/core/modules/better-auth/index.d.ts +1 -0
  64. package/dist/core/modules/better-auth/index.js +1 -0
  65. package/dist/core/modules/better-auth/index.js.map +1 -1
  66. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  67. package/dist/core/modules/error-code/error-codes.js +24 -0
  68. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  69. package/dist/core/modules/hub/core-hub.service.js +1 -0
  70. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  71. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  72. package/dist/core/modules/user/core-user.model.js +11 -1
  73. package/dist/core/modules/user/core-user.model.js.map +1 -1
  74. package/dist/core/modules/user/core-user.service.d.ts +10 -1
  75. package/dist/core/modules/user/core-user.service.js +124 -15
  76. package/dist/core/modules/user/core-user.service.js.map +1 -1
  77. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  78. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  79. package/dist/core.module.js +2 -0
  80. package/dist/core.module.js.map +1 -1
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +3 -0
  83. package/dist/index.js.map +1 -1
  84. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  85. package/dist/server/modules/user/user.controller.js +2 -1
  86. package/dist/server/modules/user/user.controller.js.map +1 -1
  87. package/dist/server/modules/user/user.model.d.ts +5 -0
  88. package/dist/server/modules/user/user.resolver.js +2 -1
  89. package/dist/server/modules/user/user.resolver.js.map +1 -1
  90. package/dist/server/modules/user/user.service.d.ts +1 -1
  91. package/dist/server/modules/user/user.service.js +18 -5
  92. package/dist/server/modules/user/user.service.js.map +1 -1
  93. package/dist/templates/password-reset-de.ejs +12 -0
  94. package/dist/templates/password-reset-en.ejs +12 -0
  95. package/dist/templates/password-reset.ejs +1 -0
  96. package/dist/tsconfig.build.tsbuildinfo +1 -1
  97. package/docs/REQUEST-LIFECYCLE.md +49 -1
  98. package/migration-guides/11.37.x-to-11.38.x.md +528 -0
  99. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  100. package/package.json +1 -1
  101. package/src/config.env.ts +28 -4
  102. package/src/core/common/helpers/config.helper.ts +79 -0
  103. package/src/core/common/helpers/cookies.helper.ts +38 -0
  104. package/src/core/common/helpers/input.helper.ts +37 -0
  105. package/src/core/common/helpers/service.helper.ts +9 -1
  106. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  107. package/src/core/common/interfaces/server-options.interface.ts +326 -10
  108. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  109. package/src/core/common/services/email.service.ts +46 -1
  110. package/src/core/common/services/module.service.ts +1 -0
  111. package/src/core/modules/auth/README.md +104 -0
  112. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  113. package/src/core/modules/auth/core-auth.module.ts +5 -0
  114. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  115. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  116. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  117. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  118. package/src/core/modules/better-auth/README.md +32 -59
  119. package/src/core/modules/better-auth/better-auth.config.ts +131 -4
  120. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
  121. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
  122. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  123. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  124. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  125. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  126. package/src/core/modules/better-auth/index.ts +1 -0
  127. package/src/core/modules/error-code/error-codes.ts +55 -0
  128. package/src/core/modules/hub/core-hub.service.ts +1 -0
  129. package/src/core/modules/user/core-user.model.ts +28 -1
  130. package/src/core/modules/user/core-user.service.ts +389 -22
  131. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  132. package/src/core.module.ts +8 -0
  133. package/src/index.ts +3 -0
  134. package/src/server/modules/user/user.controller.ts +7 -1
  135. package/src/server/modules/user/user.resolver.ts +7 -1
  136. package/src/server/modules/user/user.service.ts +58 -13
  137. package/src/templates/password-reset-de.ejs +12 -0
  138. package/src/templates/password-reset-en.ejs +12 -0
  139. package/src/templates/password-reset.ejs +1 -0
@@ -53,7 +53,7 @@ function generateSecureSecret(): string {
53
53
  * Hash a password using Node.js native crypto.scrypt (libuv thread pool).
54
54
  * Output format matches Better-Auth: "salt:hash" (both hex encoded).
55
55
  */
56
- async function nativeScryptHash(password: string): Promise<string> {
56
+ export async function nativeScryptHash(password: string): Promise<string> {
57
57
  const salt = crypto.randomBytes(16).toString('hex');
58
58
  const normalized = password.normalize('NFKC');
59
59
  const key = await new Promise<Buffer>((resolve, reject) => {
@@ -67,8 +67,14 @@ async function nativeScryptHash(password: string): Promise<string> {
67
67
 
68
68
  /**
69
69
  * Verify a password against a Better-Auth scrypt hash using Node.js native crypto.scrypt.
70
+ *
71
+ * Exported so tests can ask the PRODUCT whether a stored credential accepts a password, rather
72
+ * than re-declaring the scrypt parameters next to their assertion. A copy drifts silently: change
73
+ * a parameter here and the copy derives a different key, turning every affected assertion into a
74
+ * red with a misleading diagnosis — or, if both drift compatibly, into a green for the wrong
75
+ * reason.
70
76
  */
71
- async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
77
+ export async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
72
78
  const [salt, storedKey] = data.hash.split(':');
73
79
  if (!salt || !storedKey) return false;
74
80
  const normalized = data.password.normalize('NFKC');
@@ -128,6 +134,13 @@ export interface CreateBetterAuthOptions {
128
134
  */
129
135
  onEmailVerified?: OnEmailVerifiedCallback;
130
136
 
137
+ /**
138
+ * Callback invoked after a password reset has been written to the IAM credential.
139
+ * Injected from CoreBetterAuthModule to mirror the new password into the legacy store,
140
+ * so a deployment running both systems does not end up with two different passwords.
141
+ */
142
+ onPasswordReset?: OnPasswordResetCallback;
143
+
131
144
  /**
132
145
  * Callback for sending the password-reset email.
133
146
  * Injected from CoreBetterAuthModule to use NestJS services.
@@ -179,6 +192,51 @@ export interface CreateBetterAuthOptions {
179
192
  */
180
193
  export type OnEmailVerifiedCallback = (userId: string) => Promise<void>;
181
194
 
195
+ /**
196
+ * Callback for when a password reset has been APPLIED to the IAM credential.
197
+ *
198
+ * Injected from CoreBetterAuthModule so the new password can also be mirrored into the
199
+ * legacy bcrypt store. Better-Auth reports only WHICH user was reset — the password
200
+ * itself comes from `core-better-auth-password-reset.registry.ts`, which the API
201
+ * middleware fills for the duration of the handler call.
202
+ *
203
+ * Fires for every native reset route (token, email-OTP, phone-number).
204
+ */
205
+ /**
206
+ * Combines the framework's `onPasswordReset` with a project-supplied one so neither is lost.
207
+ *
208
+ * Returns an empty object when there is nothing to install, so the caller can spread it
209
+ * unconditionally without writing an `onPasswordReset: undefined` key — Better-Auth resolves
210
+ * the hook by presence, and an explicitly-undefined key reads as "declared, does nothing".
211
+ *
212
+ * The framework hook runs FIRST and its failure does not prevent the project hook: they are
213
+ * independent consequences of the same event, and one broken listener must not silently
214
+ * cancel the other.
215
+ */
216
+ function chainOnPasswordReset(base: unknown, override: unknown): { onPasswordReset?: OnPasswordResetCallback } {
217
+ const baseHook = typeof base === 'function' ? (base as OnPasswordResetCallback) : undefined;
218
+ const overrideHook = typeof override === 'function' ? (override as OnPasswordResetCallback) : undefined;
219
+
220
+ if (!baseHook) {
221
+ return overrideHook ? { onPasswordReset: overrideHook } : {};
222
+ }
223
+ if (!overrideHook) {
224
+ return { onPasswordReset: baseHook };
225
+ }
226
+
227
+ return {
228
+ onPasswordReset: async (data) => {
229
+ const results = await Promise.allSettled([baseHook(data), overrideHook(data)]);
230
+ const failure = results.find((result) => result.status === 'rejected');
231
+ if (failure && failure.status === 'rejected') {
232
+ throw failure.reason;
233
+ }
234
+ },
235
+ };
236
+ }
237
+
238
+ export type OnPasswordResetCallback = (data: { user: { email?: string; id: string } }) => Promise<void>;
239
+
182
240
  /**
183
241
  * Options for creating a better-auth instance
184
242
  */
@@ -371,8 +429,16 @@ export interface CreateBetterAuthResult {
371
429
  }
372
430
 
373
431
  export function createBetterAuthInstance(options: CreateBetterAuthOptions): CreateBetterAuthResult | null {
374
- const { config, db, fallbackSecrets, onEmailVerified, sendResetPasswordEmail, sendVerificationEmail, serverEnv } =
375
- options;
432
+ const {
433
+ config,
434
+ db,
435
+ fallbackSecrets,
436
+ onEmailVerified,
437
+ onPasswordReset,
438
+ sendResetPasswordEmail,
439
+ sendVerificationEmail,
440
+ serverEnv,
441
+ } = options;
376
442
 
377
443
  // Return null only if better-auth is explicitly disabled
378
444
  // BetterAuth is enabled by default (zero-config)
@@ -517,6 +583,29 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
517
583
  // here claimed a mail-less server would keep answering RESET_PASSWORD_DISABLED; it would not.
518
584
  // `emailAndPassword.passwordReset: false` is the real off switch, for deployments whose reset
519
585
  // policy is support-mediated or SSO-primary.
586
+ // Mirror an applied reset into the legacy bcrypt store.
587
+ //
588
+ // Without this, a deployment running Legacy Auth next to IAM keeps the OLD password
589
+ // valid on the legacy path after a reset — including a reset somebody performed
590
+ // BECAUSE the old password was compromised. Better-Auth tells us which user was
591
+ // reset; the password comes from the API middleware via the reset registry.
592
+ ...(onPasswordReset && {
593
+ onPasswordReset: (async (data) => {
594
+ try {
595
+ await onPasswordReset(data);
596
+ } catch (error) {
597
+ // Never fail the reset itself: the IAM credential is already written, and
598
+ // refusing the response would tell the user their reset did not work when it
599
+ // half did. Report it instead.
600
+ logger.error(
601
+ `Failed to mirror the password reset to the legacy store: ${
602
+ error instanceof Error ? error.message : String(error)
603
+ }`,
604
+ );
605
+ }
606
+ }) satisfies OnPasswordResetCallback,
607
+ }),
608
+
520
609
  // Mirrors the emailVerification.sendVerificationEmail wiring.
521
610
  ...(sendResetPasswordEmail &&
522
611
  config.emailAndPassword?.passwordReset !== false && {
@@ -606,6 +695,19 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
606
695
  // READS. The result is an asymmetric pair — anyone who resets their password can then
607
696
  // never sign in again. An explicit override still wins; only `undefined` is ignored.
608
697
  password: mergeDefined(base.password, override.password),
698
+ // `onPasswordReset` is subject to the SAME hazard as `password` above, and needs the
699
+ // opposite treatment: it must not be replaceable at all.
700
+ //
701
+ // It is what mirrors an applied IAM reset into the legacy bcrypt store. A project has
702
+ // an entirely reasonable motive to declare its own (audit logging, a notification
703
+ // mail) — and the spread would then drop the framework's, silently restoring the
704
+ // pre-11.38.0 defect where the OLD password stays valid on the legacy path after a
705
+ // reset, including a reset performed BECAUSE it leaked. Both stores report success.
706
+ //
707
+ // So the two are CHAINED rather than merged: the framework mirror always runs, the
708
+ // project hook runs after it. That is a deliberate asymmetry to `password`, where an
709
+ // explicit override wins — a hasher is a choice, a credential-store mirror is not.
710
+ ...chainOnPasswordReset(base.onPasswordReset, override.onPasswordReset),
609
711
  };
610
712
  }
611
713
  if (optionsAdvanced && typeof optionsAdvanced === 'object') {
@@ -1589,6 +1691,18 @@ function validateConfig(
1589
1691
  case 'auto-generated':
1590
1692
  warnings.push('BETTER_AUTH: No secret configured - using auto-generated secret.');
1591
1693
  warnings.push('CONSEQUENCE: All user sessions will be invalidated on server restart!');
1694
+ // The session consequence above is transient — a restart clears it. This one is not, and it
1695
+ // surfaces long after the cause: the JWT plugin PERSISTS a `jwks` document encrypted with
1696
+ // whatever secret was active when it was created. Booting once without a secret is therefore
1697
+ // enough to leave a row that no later, real secret can decrypt. Nothing fails at the time;
1698
+ // `/iam/token` starts answering "Failed to decrypt private key" whenever somebody next asks
1699
+ // for a JWT, which on a deployment with few sign-ins can be months later. Observed in
1700
+ // production on a key written at setup time.
1701
+ warnings.push(
1702
+ 'CONSEQUENCE: a `jwks` key persisted now is encrypted with THIS throwaway secret and stays ' +
1703
+ 'unreadable once a real one is configured — /iam/token then fails with "Failed to decrypt ' +
1704
+ 'private key". Drop the `jwks` collection after setting a permanent secret.',
1705
+ );
1592
1706
  warnings.push(
1593
1707
  'FOR PRODUCTION: Set betterAuth.secret in config or provide a valid fallback secret (min 32 chars).',
1594
1708
  );
@@ -1598,6 +1712,19 @@ function validateConfig(
1598
1712
  warnings.push(
1599
1713
  'BETTER_AUTH: Using fallback secret (backwards compatible). Consider setting betterAuth.secret explicitly.',
1600
1714
  );
1715
+ // The same persistence trap as the auto-generated branch, and MORE likely to be walked into,
1716
+ // because this transition is a planned upgrade step rather than an accident: a deployment
1717
+ // runs on `jwt.secret`, the JWT plugin persists a `jwks` document encrypted with it, and
1718
+ // months later somebody "configures betterAuth properly" with a NEW value. The key is then
1719
+ // unreadable, nothing fails at the time, and `/iam/token` starts answering "Failed to
1720
+ // decrypt private key" whenever the next JWT is requested. Observed in production, where
1721
+ // cause and symptom were five weeks apart.
1722
+ warnings.push(
1723
+ 'BEFORE setting betterAuth.secret later: any `jwks` key persisted now is encrypted with THIS ' +
1724
+ 'fallback secret. Setting a DIFFERENT explicit secret makes it unreadable and /iam/token ' +
1725
+ 'then fails with "Failed to decrypt private key" — reuse the same value, or drop the `jwks` ' +
1726
+ 'collection when you change it.',
1727
+ );
1601
1728
  break;
1602
1729
  // 'explicit' - no warning needed, explicitly configured
1603
1730
  }
@@ -1,10 +1,13 @@
1
- import { Injectable, Logger, NestMiddleware, Optional } from '@nestjs/common';
1
+ import { BadRequestException, Injectable, Logger, NestMiddleware, Optional } from '@nestjs/common';
2
2
  import { Response as ExpressResponse, NextFunction, Request } from 'express';
3
3
 
4
4
  import { isProduction } from '../../common/helpers/logging.helper';
5
5
  import { ConfigService } from '../../common/services/config.service';
6
6
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
7
7
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
8
+ import { wrapBetterAuthErrorResponse } from './core-better-auth-error-codes.helper';
9
+ import { runWithResetPassword } from './core-better-auth-password-reset.registry';
10
+ import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
8
11
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
9
12
  import { CoreBetterAuthService } from './core-better-auth.service';
10
13
 
@@ -23,6 +26,29 @@ import { CoreBetterAuthService } from './core-better-auth.service';
23
26
  */
24
27
  const CONTROLLER_HANDLED_PATHS = ['/features', '/sign-in/email', '/sign-up/email', '/sign-out', '/session'];
25
28
 
29
+ /**
30
+ * Native Better-Auth routes that set a NEW password from a token or OTP, with the body
31
+ * field each of them carries it in.
32
+ *
33
+ * These are forwarded (they are not in CONTROLLER_HANDLED_PATHS), so unlike sign-in and
34
+ * sign-up nothing normalizes their password — Better-Auth hashes whatever arrives. That
35
+ * is a problem, because the sign-in path DOES normalize: a plaintext reset would be
36
+ * stored as `scrypt(plaintext)` while every later sign-in checks `scrypt(sha256(...))`,
37
+ * and the account is locked out with the password its owner just chose. Normalizing
38
+ * here puts every write of a credential on the same footing as every read of one.
39
+ *
40
+ * Matching is EXACT (after trimming a trailing slash and lower-casing), not prefix-based. So
41
+ * `/reset-password/:token` — Better-Auth's GET redirect — never matches an entry at all and is
42
+ * forwarded untouched. That is the intended outcome, but for this reason and not because "the
43
+ * body has no password field": switching the matcher to `startsWith` would start rewriting that
44
+ * redirect's body on the strength of a comment that was never true.
45
+ */
46
+ export const PASSWORD_RESET_PATHS: { field: string; path: string }[] = [
47
+ { field: 'newPassword', path: '/reset-password' },
48
+ { field: 'password', path: '/email-otp/reset-password' },
49
+ { field: 'newPassword', path: '/phone-number/reset-password' },
50
+ ];
51
+
26
52
  /**
27
53
  * Passkey paths that generate challenges
28
54
  */
@@ -59,8 +85,88 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
59
85
  constructor(
60
86
  private readonly betterAuthService: CoreBetterAuthService,
61
87
  @Optional() private readonly challengeService?: CoreBetterAuthChallengeService,
88
+ @Optional() private readonly userMapper?: CoreBetterAuthUserMapper,
62
89
  ) {}
63
90
 
91
+ /**
92
+ * Brings a native reset route's password to the same shape sign-in will present, and
93
+ * returns that value so the legacy mirror can reuse it.
94
+ *
95
+ * Returns `undefined` when this is not a password-setting route or the body carries no
96
+ * password — both mean "leave the request untouched".
97
+ *
98
+ * @throws BadRequestException when a plaintext password violates the configured length policy
99
+ */
100
+ protected normalizeResetPassword(req: Request, relativePath: string): string | undefined {
101
+ // Trailing slashes and case are normalized before matching. Express does not collapse a
102
+ // trailing slash on `originalUrl`, and Better-Auth's own router is more permissive than
103
+ // `===` — so a variant it accepts but this table does not would silently skip BOTH the
104
+ // normalization and the legacy mirror, i.e. reproduce the exact lockout this method exists
105
+ // to prevent, through a URL shape rather than a code change.
106
+ const normalizedPath = relativePath.replace(/\/+$/, '').toLowerCase();
107
+ const route = PASSWORD_RESET_PATHS.find((entry) => normalizedPath === entry.path);
108
+ if (!route) {
109
+ return undefined;
110
+ }
111
+
112
+ if (!this.userMapper) {
113
+ // Fail LOUD rather than open. Today every module variant that provides this middleware
114
+ // also provides the mapper, so this is unreachable — but silently forwarding an
115
+ // un-normalized password stores `scrypt(plaintext)` against a sign-in that checks
116
+ // `scrypt(sha256(...))`, and the user is locked out with the password they just chose.
117
+ // A guard on a correctness-critical step must not degrade quietly.
118
+ this.logger.error(
119
+ `No CoreBetterAuthUserMapper available — the password on ${relativePath} is NOT normalized. ` +
120
+ 'Better-Auth will store scrypt(plaintext) while sign-in checks scrypt(sha256(...)), ' +
121
+ 'locking the account out with its new password.',
122
+ );
123
+ return undefined;
124
+ }
125
+
126
+ const submitted = req.body?.[route.field];
127
+ if (!submitted || typeof submitted !== 'string') {
128
+ return undefined;
129
+ }
130
+
131
+ this.assertResetPasswordLength(submitted);
132
+
133
+ const normalized = this.userMapper.normalizePasswordForIam(submitted);
134
+ // `toWebRequest` serializes `req.body`, so writing it back here is what Better-Auth
135
+ // actually hashes.
136
+ req.body[route.field] = normalized;
137
+ return normalized;
138
+ }
139
+
140
+ /**
141
+ * Enforces the configured password length on the value the CLIENT actually sent.
142
+ *
143
+ * Better-Auth checks `minPasswordLength`/`maxPasswordLength` against the body it receives —
144
+ * and by then this middleware has replaced it with a 64-character sha256, so both bounds
145
+ * always pass. Without this check there would be no server-side minimum password length on
146
+ * any reset path at all.
147
+ *
148
+ * An already-hashed value carries no length information, so it cannot be checked here. That
149
+ * limit is real and belongs in the client; it is stated in the migration guide rather than
150
+ * pretended away.
151
+ */
152
+ protected assertResetPasswordLength(submitted: string): void {
153
+ if (/^[a-f0-9]{64}$/i.test(submitted)) {
154
+ return;
155
+ }
156
+
157
+ // The bounds live on the Better-Auth passthrough (`betterAuth.options.emailAndPassword`),
158
+ // not on the framework's own typed block — so they are read defensively. The fallbacks are
159
+ // Better-Auth's own defaults, which is what applied before this check existed.
160
+ const passthrough = (this.betterAuthService.getConfig()?.options as Record<string, any> | undefined)
161
+ ?.emailAndPassword;
162
+ const min = typeof passthrough?.minPasswordLength === 'number' ? passthrough.minPasswordLength : 8;
163
+ const max = typeof passthrough?.maxPasswordLength === 'number' ? passthrough.maxPasswordLength : 128;
164
+
165
+ if (submitted.length < min || submitted.length > max) {
166
+ throw new BadRequestException(`Password must be between ${min} and ${max} characters`);
167
+ }
168
+ }
169
+
64
170
  /**
65
171
  * Gets or creates the cookie helper instance.
66
172
  * Lazy initialization because betterAuthService may not be fully initialized in constructor.
@@ -186,6 +292,10 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
186
292
  }
187
293
  }
188
294
 
295
+ // Put a reset password into the shape sign-in expects BEFORE the body is
296
+ // serialized, and keep the value for the legacy mirror below.
297
+ const resetPassword = this.normalizeResetPassword(req, relativePath);
298
+
189
299
  // Convert Express request to Web Standard Request with proper cookie signing
190
300
  const webRequest = await toWebRequest(req, {
191
301
  basePath,
@@ -195,8 +305,21 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
195
305
  sessionToken,
196
306
  });
197
307
 
198
- // Call Better Auth's native handler
199
- const response = await authInstance.handler(webRequest);
308
+ // Call Better Auth's native handler.
309
+ //
310
+ // For a reset, the call is wrapped so that `emailAndPassword.onPasswordReset` — which
311
+ // Better-Auth invokes inside this call, and which is told WHICH user was reset but not
312
+ // to WHAT — can read the new password and mirror it into the legacy store. The context
313
+ // lives exactly as long as the handler call, so no other request can observe it.
314
+ const rawResponse = resetPassword
315
+ ? await runWithResetPassword(resetPassword, () => authInstance.handler(webRequest))
316
+ : await authInstance.handler(webRequest);
317
+
318
+ // The single choke point for every error exit below. Each later branch acts only on
319
+ // `response.ok`, so rewriting failures HERE reaches all of them without touching any — and
320
+ // without a second place that has to remember to do it. Successful responses are returned
321
+ // by identity, so nothing on the happy path changes shape.
322
+ const response = await wrapBetterAuthErrorResponse(rawResponse);
200
323
 
201
324
  this.logger.debug(`Better Auth handler response: ${response.status}`);
202
325
 
@@ -4,7 +4,7 @@ import ejs = require('ejs');
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
 
7
- import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
7
+ import { isProductionLikeEnv, resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
8
8
  import { maskEmail } from '../../common/helpers/logging.helper';
9
9
  import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
10
10
  import { BrevoService } from '../../common/services/brevo.service';
@@ -22,12 +22,12 @@ import { AuthEmailCallbackOptions, formatProjectName } from './better-auth.confi
22
22
  */
23
23
  type ResolvedEmailVerificationConfig = Pick<
24
24
  IBetterAuthEmailVerificationConfig,
25
- 'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId'
25
+ 'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'passwordResetLink'
26
26
  > &
27
27
  Required<
28
28
  Omit<
29
29
  IBetterAuthEmailVerificationConfig,
30
- 'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'resendCooldownSeconds'
30
+ 'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'passwordResetLink' | 'resendCooldownSeconds'
31
31
  >
32
32
  > & {
33
33
  resendCooldownSeconds: number;
@@ -222,7 +222,7 @@ export class CoreBetterAuthEmailVerificationService {
222
222
  try {
223
223
  // Override URL if callbackURL is configured (frontend-based verification)
224
224
  if (this.config.callbackURL) {
225
- url = this.buildFrontendVerificationUrl(token);
225
+ url = this.buildFrontendVerificationUrl(token, user.email);
226
226
  }
227
227
 
228
228
  this.logAuthUrlForDevelopment('EMAIL VERIFICATION', user.email, url);
@@ -328,7 +328,8 @@ export class CoreBetterAuthEmailVerificationService {
328
328
  * override that calls this directly must handle it.
329
329
  */
330
330
  async sendPasswordResetEmail(options: SendPasswordResetEmailOptions): Promise<void> {
331
- const { url, user } = options;
331
+ const { user } = options;
332
+ const url = this.buildPasswordResetUrl(options);
332
333
 
333
334
  this.logAuthUrlForDevelopment('PASSWORD RESET', user.email, url);
334
335
 
@@ -357,6 +358,10 @@ export class CoreBetterAuthEmailVerificationService {
357
358
  const templateData = {
358
359
  appName,
359
360
  link: url,
361
+ // Better-Auth's own lifetime, NOT the legacy `auth.passwordReset.tokenExpiresInMinutes`.
362
+ // The two flows expire independently, and announcing the wrong one would be worse than
363
+ // announcing none.
364
+ linkExpiresInMinutes: this.passwordResetLinkExpiryMinutes(),
360
365
  name: user.name || user.email.split('@')[0],
361
366
  };
362
367
 
@@ -569,6 +574,139 @@ export class CoreBetterAuthEmailVerificationService {
569
574
  };
570
575
  }
571
576
 
577
+ /**
578
+ * Where the password-reset mail points.
579
+ *
580
+ * Defaults to the APP rather than to the link Better-Auth generates, which points at the API
581
+ * (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`) and redirects from there.
582
+ * That works, but it puts an unfamiliar domain into a password mail — the one thing recipients
583
+ * are trained to check. In this stack an app host and an API host are the norm, so the app is
584
+ * the better default.
585
+ *
586
+ * WHAT THIS GIVES UP, STATED PLAINLY
587
+ *
588
+ * Better-Auth's redirect route validates the token and its expiry before forwarding, so an
589
+ * expired link produced an error page rather than a form that fails on submit. Linking straight
590
+ * to the app moves that error later. It is NOT a security difference: the token reaches the app
591
+ * URL either way, and the `callbackURL` origin check only exists because of the hop it removes.
592
+ * `passwordResetLink: false` keeps Better-Auth's link for anyone who wants the early error.
593
+ *
594
+ * Resolution order — the first that yields something wins:
595
+ * 1. `betterAuth.emailVerification.passwordResetLink` (`false` → Better-Auth's own link)
596
+ * 2. `<appUrl>/auth/reset-password` — the starter's reset page
597
+ * 3. Better-Auth's link, when no app URL can be resolved
598
+ *
599
+ * `{token}` is substituted anywhere in the configured value; without it the token is appended as
600
+ * `?token=`. That is what lets a page reading a PATH parameter configure
601
+ * `https://example.com/auth/reset-password/{token}`.
602
+ */
603
+ /**
604
+ * How long the IAM reset link stays valid, in whole minutes, for the mail to state.
605
+ *
606
+ * Read from Better-Auth's own `emailAndPassword.resetPasswordTokenExpiresIn` — expressed in
607
+ * SECONDS, defaulting to 3600 (`password.mjs`). Deliberately not the legacy
608
+ * `auth.passwordReset.tokenExpiresInMinutes`: the two flows expire independently, and a mail that
609
+ * announces the other flow's deadline is worse than one that announces none.
610
+ *
611
+ * Returns 0 for anything unusable, which the templates render as no sentence at all — silence is
612
+ * the safe failure for a deadline nobody can verify.
613
+ */
614
+ protected passwordResetLinkExpiryMinutes(): number {
615
+ const raw = this.configService.getFastButReadOnly<unknown>(
616
+ 'betterAuth.options.emailAndPassword.resetPasswordTokenExpiresIn',
617
+ );
618
+ const seconds = typeof raw === 'string' ? Number(raw) : raw;
619
+ const resolved = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? seconds : 3600;
620
+
621
+ return Math.max(1, Math.round(resolved / 60));
622
+ }
623
+
624
+ protected buildPasswordResetUrl(options: SendPasswordResetEmailOptions): string {
625
+ const configured = this.config.passwordResetLink;
626
+
627
+ // Explicit opt-out: keep Better-Auth's link, including its token validation hop.
628
+ if (configured === false) {
629
+ return options.url;
630
+ }
631
+
632
+ let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
633
+
634
+ // The caller's own `redirectTo`, when it sent one, beats anything this server would guess.
635
+ // `nuxt-base-starter` sends it on every request (`forgot-password.vue` →
636
+ // `requestPasswordReset({ email, redirectTo: appUrl('/auth/reset-password', siteUrl) })`), and
637
+ // a client that names its page is a better authority on that page than a framework default.
638
+ if (!target) {
639
+ target = this.readCallbackUrlFromBetterAuthLink(options.url);
640
+ }
641
+
642
+ // The SAME resolver the legacy twin uses. Until 11.39.0 this read `appUrl` straight off the
643
+ // configuration, which meant no localhost default in local/ci/e2e, no host-split `baseUrl`
644
+ // derivation, and no `cors.deriveAppUrl` opt-out — three behaviours the other half of the same
645
+ // framework had. Two hand-maintained copies of one decision is how that happened.
646
+ const appUrl = resolveAppUrlFromConfig(this.configService);
647
+
648
+ if (!target) {
649
+ if (!appUrl) {
650
+ // Nothing to point at. Better-Auth's link at least works, which beats a guess.
651
+ return options.url;
652
+ }
653
+ target = `${appUrl.replace(/\/$/, '')}/auth/reset-password`;
654
+ }
655
+
656
+ // Resolve a relative value against appUrl, like buildFrontendVerificationUrl does.
657
+ if (target.startsWith('/')) {
658
+ if (!appUrl) {
659
+ return options.url;
660
+ }
661
+ target = `${appUrl.replace(/\/$/, '')}${target}`;
662
+ }
663
+
664
+ const token = options.token;
665
+ if (target.includes('{token}')) {
666
+ return target.replace(/\{token\}/g, encodeURIComponent(token));
667
+ }
668
+
669
+ const separator = target.includes('?') ? '&' : '?';
670
+ return `${target}${separator}token=${encodeURIComponent(token)}`;
671
+ }
672
+
673
+ /**
674
+ * Read the `callbackURL` out of the link Better-Auth built, i.e. the caller's own `redirectTo`.
675
+ *
676
+ * ── Why reading this back is SAFE, and why that is not obvious ──────────────────
677
+ * `redirectTo` arrives in the REQUEST BODY of `/request-password-reset`, which is unauthenticated.
678
+ * Putting a client-supplied URL into a password-reset mail would otherwise be a token-exfiltration
679
+ * vector in its purest form: an attacker requests a reset for a victim's address with
680
+ * `redirectTo: https://evil.example`, and the victim receives a genuine mail, from the real
681
+ * sender, carrying a valid token to the attacker's site.
682
+ *
683
+ * It is safe here only because Better-Auth validates the value BEFORE this runs. The endpoint
684
+ * declares `use: [originCheck((ctx) => ctx.body.redirectTo)]`, so a `redirectTo` outside
685
+ * `trustedOrigins` is rejected at request time and `sendResetPassword` is never reached. By the
686
+ * time we see it, the origin is one this deployment already trusts.
687
+ *
688
+ * That makes `trustedOrigins` load-bearing for THIS path too, not only for CORS — which is worth
689
+ * knowing before anyone widens it. `cors.allowAll` does not widen it (an origin check has no
690
+ * "allow everything" mode), but an over-broad `allowedOrigins` would.
691
+ *
692
+ * Returns `undefined` for anything unparseable or absent, so the caller falls through to its own
693
+ * resolution rather than to a half-formed URL.
694
+ */
695
+ protected readCallbackUrlFromBetterAuthLink(betterAuthUrl: string): string | undefined {
696
+ if (typeof betterAuthUrl !== 'string' || !betterAuthUrl.length) {
697
+ return undefined;
698
+ }
699
+
700
+ try {
701
+ const callbackUrl = new URL(betterAuthUrl).searchParams.get('callbackURL');
702
+ return callbackUrl?.trim().length ? callbackUrl.trim() : undefined;
703
+ } catch {
704
+ // Not a parseable absolute URL. Nothing to read, and nothing worth logging — the caller has
705
+ // two further fallbacks.
706
+ return undefined;
707
+ }
708
+ }
709
+
572
710
  /**
573
711
  * Build the frontend verification URL from the configured callbackURL and token.
574
712
  *
@@ -577,7 +715,7 @@ export class CoreBetterAuthEmailVerificationService {
577
715
  * @param token - The verification token from Better-Auth
578
716
  * @returns The full frontend URL with token query parameter
579
717
  */
580
- protected buildFrontendVerificationUrl(token: string): string {
718
+ protected buildFrontendVerificationUrl(token: string, email?: string): string {
581
719
  let baseUrl = this.config.callbackURL!;
582
720
 
583
721
  // Resolve relative paths against appUrl
@@ -588,7 +726,28 @@ export class CoreBetterAuthEmailVerificationService {
588
726
 
589
727
  // Append token as query parameter
590
728
  const separator = baseUrl.includes('?') ? '&' : '?';
591
- return `${baseUrl}${separator}token=${token}`;
729
+ let url = `${baseUrl}${separator}token=${encodeURIComponent(token)}`;
730
+
731
+ // The address travels alongside the token so the page can offer "send a new email" when the
732
+ // token turns out to be expired — which is the ONLY moment that button matters, and precisely
733
+ // the moment the page has nothing else to work with. The verification page shipped by
734
+ // nuxt-base-starter gates that button on `route.query.email`, so without this the user is told
735
+ // correctly what went wrong and left with no way to fix it.
736
+ //
737
+ // The frontend cannot recover the address itself. It is inside the token's JWT payload, and
738
+ // reading it there would mean trusting an unverified signature for display — not a pattern to
739
+ // put in a starter.
740
+ //
741
+ // The cost, stated plainly: the address appears in the URL, so it reaches browser history and
742
+ // any access log the app keeps. It is the recipient's OWN address, arriving in their own
743
+ // mailbox next to a token that is far more sensitive, so this widens nothing that the link did
744
+ // not already carry. Encoded, because a `+` in an address (Gmail tags, and therefore most test
745
+ // addresses) would otherwise arrive as a space.
746
+ if (email) {
747
+ url += `&email=${encodeURIComponent(email)}`;
748
+ }
749
+
750
+ return url;
592
751
  }
593
752
 
594
753
  /**