@lenne.tech/nest-server 11.37.0 → 11.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude/rules/configurable-features.md +29 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/testing.md +26 -4
  4. package/FRAMEWORK-API.md +4 -2
  5. package/dist/config.env.js +1 -1
  6. package/dist/config.env.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
  8. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  9. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  10. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  11. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  12. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  13. package/dist/core/modules/auth/core-auth.module.js +2 -0
  14. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  15. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  16. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  17. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  18. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  19. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  20. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  21. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  22. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  23. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  24. package/dist/core/modules/better-auth/better-auth.config.js +33 -1
  25. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  26. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  27. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
  28. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  29. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +2 -1
  30. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
  31. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  32. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  33. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  34. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  35. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  36. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  37. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  38. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  39. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  40. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  41. package/dist/core/modules/user/core-user.service.d.ts +1 -1
  42. package/dist/core/modules/user/core-user.service.js +29 -12
  43. package/dist/core/modules/user/core-user.service.js.map +1 -1
  44. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  45. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  46. package/dist/core.module.js +2 -0
  47. package/dist/core.module.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/server/modules/user/user.controller.js +2 -1
  52. package/dist/server/modules/user/user.controller.js.map +1 -1
  53. package/dist/server/modules/user/user.resolver.js +2 -1
  54. package/dist/server/modules/user/user.resolver.js.map +1 -1
  55. package/dist/server/modules/user/user.service.d.ts +1 -1
  56. package/dist/server/modules/user/user.service.js +8 -1
  57. package/dist/server/modules/user/user.service.js.map +1 -1
  58. package/dist/tsconfig.build.tsbuildinfo +1 -1
  59. package/docs/REQUEST-LIFECYCLE.md +35 -1
  60. package/migration-guides/11.37.x-to-11.38.x.md +511 -0
  61. package/package.json +1 -1
  62. package/src/config.env.ts +9 -1
  63. package/src/core/common/interfaces/server-options.interface.ts +225 -9
  64. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  65. package/src/core/modules/auth/README.md +104 -0
  66. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  67. package/src/core/modules/auth/core-auth.module.ts +5 -0
  68. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  69. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  70. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  71. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  72. package/src/core/modules/better-auth/README.md +32 -59
  73. package/src/core/modules/better-auth/better-auth.config.ts +106 -4
  74. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
  75. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
  76. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  77. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  78. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  79. package/src/core/modules/user/core-user.service.ts +123 -18
  80. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  81. package/src/core.module.ts +8 -0
  82. package/src/index.ts +3 -0
  83. package/src/server/modules/user/user.controller.ts +7 -1
  84. package/src/server/modules/user/user.resolver.ts +7 -1
  85. package/src/server/modules/user/user.service.ts +35 -9
@@ -1,10 +1,12 @@
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 { runWithResetPassword } from './core-better-auth-password-reset.registry';
9
+ import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
8
10
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
9
11
  import { CoreBetterAuthService } from './core-better-auth.service';
10
12
 
@@ -23,6 +25,29 @@ import { CoreBetterAuthService } from './core-better-auth.service';
23
25
  */
24
26
  const CONTROLLER_HANDLED_PATHS = ['/features', '/sign-in/email', '/sign-up/email', '/sign-out', '/session'];
25
27
 
28
+ /**
29
+ * Native Better-Auth routes that set a NEW password from a token or OTP, with the body
30
+ * field each of them carries it in.
31
+ *
32
+ * These are forwarded (they are not in CONTROLLER_HANDLED_PATHS), so unlike sign-in and
33
+ * sign-up nothing normalizes their password — Better-Auth hashes whatever arrives. That
34
+ * is a problem, because the sign-in path DOES normalize: a plaintext reset would be
35
+ * stored as `scrypt(plaintext)` while every later sign-in checks `scrypt(sha256(...))`,
36
+ * and the account is locked out with the password its owner just chose. Normalizing
37
+ * here puts every write of a credential on the same footing as every read of one.
38
+ *
39
+ * Matching is EXACT (after trimming a trailing slash and lower-casing), not prefix-based. So
40
+ * `/reset-password/:token` — Better-Auth's GET redirect — never matches an entry at all and is
41
+ * forwarded untouched. That is the intended outcome, but for this reason and not because "the
42
+ * body has no password field": switching the matcher to `startsWith` would start rewriting that
43
+ * redirect's body on the strength of a comment that was never true.
44
+ */
45
+ export const PASSWORD_RESET_PATHS: { field: string; path: string }[] = [
46
+ { field: 'newPassword', path: '/reset-password' },
47
+ { field: 'password', path: '/email-otp/reset-password' },
48
+ { field: 'newPassword', path: '/phone-number/reset-password' },
49
+ ];
50
+
26
51
  /**
27
52
  * Passkey paths that generate challenges
28
53
  */
@@ -59,8 +84,88 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
59
84
  constructor(
60
85
  private readonly betterAuthService: CoreBetterAuthService,
61
86
  @Optional() private readonly challengeService?: CoreBetterAuthChallengeService,
87
+ @Optional() private readonly userMapper?: CoreBetterAuthUserMapper,
62
88
  ) {}
63
89
 
90
+ /**
91
+ * Brings a native reset route's password to the same shape sign-in will present, and
92
+ * returns that value so the legacy mirror can reuse it.
93
+ *
94
+ * Returns `undefined` when this is not a password-setting route or the body carries no
95
+ * password — both mean "leave the request untouched".
96
+ *
97
+ * @throws BadRequestException when a plaintext password violates the configured length policy
98
+ */
99
+ protected normalizeResetPassword(req: Request, relativePath: string): string | undefined {
100
+ // Trailing slashes and case are normalized before matching. Express does not collapse a
101
+ // trailing slash on `originalUrl`, and Better-Auth's own router is more permissive than
102
+ // `===` — so a variant it accepts but this table does not would silently skip BOTH the
103
+ // normalization and the legacy mirror, i.e. reproduce the exact lockout this method exists
104
+ // to prevent, through a URL shape rather than a code change.
105
+ const normalizedPath = relativePath.replace(/\/+$/, '').toLowerCase();
106
+ const route = PASSWORD_RESET_PATHS.find((entry) => normalizedPath === entry.path);
107
+ if (!route) {
108
+ return undefined;
109
+ }
110
+
111
+ if (!this.userMapper) {
112
+ // Fail LOUD rather than open. Today every module variant that provides this middleware
113
+ // also provides the mapper, so this is unreachable — but silently forwarding an
114
+ // un-normalized password stores `scrypt(plaintext)` against a sign-in that checks
115
+ // `scrypt(sha256(...))`, and the user is locked out with the password they just chose.
116
+ // A guard on a correctness-critical step must not degrade quietly.
117
+ this.logger.error(
118
+ `No CoreBetterAuthUserMapper available — the password on ${relativePath} is NOT normalized. ` +
119
+ 'Better-Auth will store scrypt(plaintext) while sign-in checks scrypt(sha256(...)), ' +
120
+ 'locking the account out with its new password.',
121
+ );
122
+ return undefined;
123
+ }
124
+
125
+ const submitted = req.body?.[route.field];
126
+ if (!submitted || typeof submitted !== 'string') {
127
+ return undefined;
128
+ }
129
+
130
+ this.assertResetPasswordLength(submitted);
131
+
132
+ const normalized = this.userMapper.normalizePasswordForIam(submitted);
133
+ // `toWebRequest` serializes `req.body`, so writing it back here is what Better-Auth
134
+ // actually hashes.
135
+ req.body[route.field] = normalized;
136
+ return normalized;
137
+ }
138
+
139
+ /**
140
+ * Enforces the configured password length on the value the CLIENT actually sent.
141
+ *
142
+ * Better-Auth checks `minPasswordLength`/`maxPasswordLength` against the body it receives —
143
+ * and by then this middleware has replaced it with a 64-character sha256, so both bounds
144
+ * always pass. Without this check there would be no server-side minimum password length on
145
+ * any reset path at all.
146
+ *
147
+ * An already-hashed value carries no length information, so it cannot be checked here. That
148
+ * limit is real and belongs in the client; it is stated in the migration guide rather than
149
+ * pretended away.
150
+ */
151
+ protected assertResetPasswordLength(submitted: string): void {
152
+ if (/^[a-f0-9]{64}$/i.test(submitted)) {
153
+ return;
154
+ }
155
+
156
+ // The bounds live on the Better-Auth passthrough (`betterAuth.options.emailAndPassword`),
157
+ // not on the framework's own typed block — so they are read defensively. The fallbacks are
158
+ // Better-Auth's own defaults, which is what applied before this check existed.
159
+ const passthrough = (this.betterAuthService.getConfig()?.options as Record<string, any> | undefined)
160
+ ?.emailAndPassword;
161
+ const min = typeof passthrough?.minPasswordLength === 'number' ? passthrough.minPasswordLength : 8;
162
+ const max = typeof passthrough?.maxPasswordLength === 'number' ? passthrough.maxPasswordLength : 128;
163
+
164
+ if (submitted.length < min || submitted.length > max) {
165
+ throw new BadRequestException(`Password must be between ${min} and ${max} characters`);
166
+ }
167
+ }
168
+
64
169
  /**
65
170
  * Gets or creates the cookie helper instance.
66
171
  * Lazy initialization because betterAuthService may not be fully initialized in constructor.
@@ -186,6 +291,10 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
186
291
  }
187
292
  }
188
293
 
294
+ // Put a reset password into the shape sign-in expects BEFORE the body is
295
+ // serialized, and keep the value for the legacy mirror below.
296
+ const resetPassword = this.normalizeResetPassword(req, relativePath);
297
+
189
298
  // Convert Express request to Web Standard Request with proper cookie signing
190
299
  const webRequest = await toWebRequest(req, {
191
300
  basePath,
@@ -195,8 +304,15 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
195
304
  sessionToken,
196
305
  });
197
306
 
198
- // Call Better Auth's native handler
199
- const response = await authInstance.handler(webRequest);
307
+ // Call Better Auth's native handler.
308
+ //
309
+ // For a reset, the call is wrapped so that `emailAndPassword.onPasswordReset` — which
310
+ // Better-Auth invokes inside this call, and which is told WHICH user was reset but not
311
+ // to WHAT — can read the new password and mirror it into the legacy store. The context
312
+ // lives exactly as long as the handler call, so no other request can observe it.
313
+ const response = resetPassword
314
+ ? await runWithResetPassword(resetPassword, () => authInstance.handler(webRequest))
315
+ : await authInstance.handler(webRequest);
200
316
 
201
317
  this.logger.debug(`Better Auth handler response: ${response.status}`);
202
318
 
@@ -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;
@@ -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
 
@@ -569,6 +570,69 @@ export class CoreBetterAuthEmailVerificationService {
569
570
  };
570
571
  }
571
572
 
573
+ /**
574
+ * Where the password-reset mail points.
575
+ *
576
+ * Defaults to the APP rather than to the link Better-Auth generates, which points at the API
577
+ * (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`) and redirects from there.
578
+ * That works, but it puts an unfamiliar domain into a password mail — the one thing recipients
579
+ * are trained to check. In this stack an app host and an API host are the norm, so the app is
580
+ * the better default.
581
+ *
582
+ * WHAT THIS GIVES UP, STATED PLAINLY
583
+ *
584
+ * Better-Auth's redirect route validates the token and its expiry before forwarding, so an
585
+ * expired link produced an error page rather than a form that fails on submit. Linking straight
586
+ * to the app moves that error later. It is NOT a security difference: the token reaches the app
587
+ * URL either way, and the `callbackURL` origin check only exists because of the hop it removes.
588
+ * `passwordResetLink: false` keeps Better-Auth's link for anyone who wants the early error.
589
+ *
590
+ * Resolution order — the first that yields something wins:
591
+ * 1. `betterAuth.emailVerification.passwordResetLink` (`false` → Better-Auth's own link)
592
+ * 2. `<appUrl>/auth/reset-password` — the starter's reset page
593
+ * 3. Better-Auth's link, when no app URL can be resolved
594
+ *
595
+ * `{token}` is substituted anywhere in the configured value; without it the token is appended as
596
+ * `?token=`. That is what lets a page reading a PATH parameter configure
597
+ * `https://example.com/auth/reset-password/{token}`.
598
+ */
599
+ protected buildPasswordResetUrl(options: SendPasswordResetEmailOptions): string {
600
+ const configured = this.config.passwordResetLink;
601
+
602
+ // Explicit opt-out: keep Better-Auth's link, including its token validation hop.
603
+ if (configured === false) {
604
+ return options.url;
605
+ }
606
+
607
+ let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
608
+
609
+ if (!target) {
610
+ const appUrl = this.configService.getFastButReadOnly<string>('appUrl');
611
+ if (!appUrl) {
612
+ // Nothing to point at. Better-Auth's link at least works, which beats a guess.
613
+ return options.url;
614
+ }
615
+ target = `${appUrl.replace(/\/$/, '')}/auth/reset-password`;
616
+ }
617
+
618
+ // Resolve a relative value against appUrl, like buildFrontendVerificationUrl does.
619
+ if (target.startsWith('/')) {
620
+ const appUrl = this.configService.getFastButReadOnly<string>('appUrl');
621
+ if (!appUrl) {
622
+ return options.url;
623
+ }
624
+ target = `${appUrl.replace(/\/$/, '')}${target}`;
625
+ }
626
+
627
+ const token = options.token;
628
+ if (target.includes('{token}')) {
629
+ return target.replace(/\{token\}/g, encodeURIComponent(token));
630
+ }
631
+
632
+ const separator = target.includes('?') ? '&' : '?';
633
+ return `${target}${separator}token=${encodeURIComponent(token)}`;
634
+ }
635
+
572
636
  /**
573
637
  * Build the frontend verification URL from the configured callbackURL and token.
574
638
  *
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Carries the password of an in-flight IAM password reset from the API middleware
3
+ * to Better-Auth's `onPasswordReset` hook.
4
+ *
5
+ * WHY THIS EXISTS
6
+ *
7
+ * A reset has to land in BOTH credential stores when Legacy Auth and IAM run next to
8
+ * each other: IAM keeps scrypt, Legacy keeps bcrypt, and neither hash can be derived
9
+ * from the other. Mirroring a reset into the legacy store therefore needs the password
10
+ * itself — and the two halves are known in two different places:
11
+ *
12
+ * | half | known by |
13
+ * |------|----------|
14
+ * | the new password | `CoreBetterAuthApiMiddleware`, which sees the request body |
15
+ * | which user it belongs to | `emailAndPassword.onPasswordReset`, which Better-Auth calls with the user |
16
+ *
17
+ * Better-Auth hands its hook `{ user }` and the original `Request`, whose body is
18
+ * already consumed by then, so the hook cannot recover the password on its own. The
19
+ * alternative — resolving the user in the middleware by parsing Better-Auth's
20
+ * `reset-password:<token>` verification identifier — would couple us to an internal
21
+ * storage format that no contract keeps stable. Using the supported hook for the user
22
+ * and this registry for the password keeps the only coupling inside our own code.
23
+ *
24
+ * The value stored here is ALREADY NORMALIZED (`normalizePasswordForIam`), i.e. exactly
25
+ * what Better-Auth hashed, so the legacy mirror cannot drift from the IAM credential
26
+ * even when the client posts a plaintext password.
27
+ *
28
+ * A true leaf: it imports nothing but a Node built-in, so it can never be
29
+ * mid-evaluation when a cycle-adjacent file reads it (see
30
+ * `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)").
31
+ *
32
+ * @internal Not public API — but NOT optional. Two shipped core files import it
33
+ * (`core-better-auth-api.middleware.ts`, `core-better-auth.module.ts`), so a vendor-mode sync that
34
+ * skips this file leaves an unresolvable import. `@internal` means "do not depend on this from a
35
+ * project", never "you may leave it out".
36
+ */
37
+
38
+ import { AsyncLocalStorage } from 'async_hooks';
39
+
40
+ interface PasswordResetContext {
41
+ /**
42
+ * The new password as Better-Auth will hash it — already normalized.
43
+ *
44
+ * Cleared on the first read (see {@link getInFlightResetPassword}), so the property is
45
+ * optional after that point.
46
+ */
47
+ normalizedPassword?: string;
48
+ }
49
+
50
+ const storage = new AsyncLocalStorage<PasswordResetContext>();
51
+
52
+ /**
53
+ * Runs `fn` with the in-flight reset password attached to the async context.
54
+ *
55
+ * Wrap ONLY the Better-Auth handler call: the context has to be alive while
56
+ * `onPasswordReset` runs, and dead everywhere else, so an unrelated request can never
57
+ * pick up a password that was not its own.
58
+ *
59
+ * @internal
60
+ */
61
+ export function runWithResetPassword<T>(normalizedPassword: string, fn: () => T): T {
62
+ return storage.run({ normalizedPassword }, fn);
63
+ }
64
+
65
+ /**
66
+ * The normalized password of the reset currently being handled, or `undefined`
67
+ * outside such a request.
68
+ *
69
+ * `undefined` means "not a reset we saw the body of" — never "no password". Callers
70
+ * must skip the mirror rather than guess, because writing a wrong bcrypt hash would
71
+ * lock the account out of the legacy path.
72
+ *
73
+ * @internal
74
+ */
75
+ export function getInFlightResetPassword(): string | undefined {
76
+ const store = storage.getStore();
77
+ if (!store) {
78
+ return undefined;
79
+ }
80
+
81
+ // ONE-SHOT. The legacy mirror is the only legitimate reader and it reads once, so handing the
82
+ // value out a second time can only serve something that should not have it.
83
+ //
84
+ // Defence in depth rather than a fix for a known hole: an AsyncLocalStorage store is retained
85
+ // by any async resource created INSIDE the `run()` that outlives it — a timer registered during
86
+ // lazy plugin init, a cached promise. Such a resource would otherwise keep observing a
87
+ // password-equivalent from a request that finished long ago. Clearing on read bounds that to
88
+ // the moment the mirror actually runs.
89
+ const { normalizedPassword } = store;
90
+ store.normalizedPassword = undefined;
91
+ return normalizedPassword;
92
+ }
@@ -57,6 +57,22 @@ export interface MappedUser {
57
57
  verified?: boolean;
58
58
  }
59
59
 
60
+ /**
61
+ * Options for {@link CoreBetterAuthUserMapper.getMigrationStatus}.
62
+ */
63
+ export interface GetMigrationStatusOptions {
64
+ /**
65
+ * Whether to collect `pendingUserEmails` (up to 100 addresses).
66
+ *
67
+ * Costs two extra collection-scale queries, one of them a guaranteed COLLSCAN. Pass `false`
68
+ * from callers that only need the counts — they get the same numbers without the scan, and
69
+ * without handling addresses they were going to discard.
70
+ *
71
+ * @default true
72
+ */
73
+ includePendingEmails?: boolean;
74
+ }
75
+
60
76
  /**
61
77
  * Interface for migration status result
62
78
  */
@@ -340,6 +356,11 @@ export class CoreBetterAuthUserMapper {
340
356
  const bcryptHash = await bcrypt.hash(normalizedPassword, saltRounds);
341
357
 
342
358
  // Update the users collection with the bcrypt hash
359
+ // `$or` across two different indexes (`users.email` unique, `users.iamId` sparse) forces an
360
+ // index-union plan rather than a single-index point lookup. Deliberate — the caller may know
361
+ // only one of the two — and irrelevant at password-change frequency. It would NOT be
362
+ // irrelevant in a bulk or migration loop; anything calling this in one should look up the
363
+ // user once and address it by `_id`.
343
364
  const result = await usersCollection.updateOne(
344
365
  { $or: [{ email: userEmail }, { iamId: iamUserId }] },
345
366
  { $set: { password: bcryptHash, updatedAt: new Date() } },
@@ -976,7 +997,13 @@ export class CoreBetterAuthUserMapper {
976
997
  *
977
998
  * @returns Migration status object with counts and percentage
978
999
  */
979
- async getMigrationStatus(): Promise<MigrationStatus> {
1000
+ async getMigrationStatus(options?: GetMigrationStatusOptions): Promise<MigrationStatus> {
1001
+ // Building `pendingUserEmails` costs two collection-scale queries, one of them a
1002
+ // guaranteed COLLSCAN (`$exists: false` cannot use the sparse `iamId` index) that scans
1003
+ // the WHOLE collection precisely in the all-migrated steady state, where it finds nothing.
1004
+ // A caller that only needs the counts — the boot-time deprecation warning does — should
1005
+ // not pay for a field it discards.
1006
+ const includePendingEmails = options?.includePendingEmails !== false;
980
1007
  if (!this.connection) {
981
1008
  this.logger.warn('No database connection available - cannot get migration status');
982
1009
  return {
@@ -1004,10 +1031,13 @@ export class CoreBetterAuthUserMapper {
1004
1031
  });
1005
1032
 
1006
1033
  // Get unique userIds that have credential accounts
1007
- const credentialAccounts = await accountCollection
1008
- .aggregate([{ $match: { providerId: 'credential' } }, { $group: { _id: '$userId' } }])
1034
+ // Counted server-side. The previous form materialized one `{_id}` object per distinct
1035
+ // migrated user in the Node heap only to read `.length` an allocation that grows with
1036
+ // the user count, at boot, before the first request.
1037
+ const credentialAccountCount = await accountCollection
1038
+ .aggregate([{ $match: { providerId: 'credential' } }, { $group: { _id: '$userId' } }, { $count: 'count' }])
1009
1039
  .toArray();
1010
- const usersWithIamAccount = credentialAccounts.length;
1040
+ const usersWithIamAccount = credentialAccountCount[0]?.count ?? 0;
1011
1041
 
1012
1042
  // Get users that are fully migrated (have both iamId AND credential account)
1013
1043
  // We need to find users where iamId exists AND there's a matching account
@@ -1047,15 +1077,17 @@ export class CoreBetterAuthUserMapper {
1047
1077
  // Get emails of pending users (limit to 100)
1048
1078
  // Two-phase approach: first get users without iamId (no $lookup needed),
1049
1079
  // then check users with iamId but missing credential account
1050
- const usersWithoutIamId = await usersCollection
1051
- .find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
1052
- .limit(100)
1053
- .project({ email: 1 })
1054
- .toArray();
1080
+ const usersWithoutIamId = !includePendingEmails
1081
+ ? []
1082
+ : await usersCollection
1083
+ .find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
1084
+ .limit(100)
1085
+ .project({ email: 1 })
1086
+ .toArray();
1055
1087
 
1056
1088
  const remaining = 100 - usersWithoutIamId.length;
1057
1089
  let usersWithIamButNoAccount: { email?: string }[] = [];
1058
- if (remaining > 0) {
1090
+ if (includePendingEmails && remaining > 0) {
1059
1091
  usersWithIamButNoAccount = await usersCollection
1060
1092
  .aggregate([
1061
1093
  { $match: { iamId: { $exists: true, $ne: null } } },
@@ -18,6 +18,7 @@ import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options
18
18
  import { BrevoService } from '../../common/services/brevo.service';
19
19
  import { ConfigService } from '../../common/services/config.service';
20
20
  import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
21
+ import { isLegacyEndpointEnabled } from '../auth/helpers/legacy-endpoints.helper';
21
22
  import { BetterAuthRolesGuard } from './better-auth-roles.guard';
22
23
  import { BetterAuthTokenService } from './better-auth-token.service';
23
24
  import {
@@ -25,6 +26,7 @@ import {
25
26
  BetterAuthInstance,
26
27
  CreateBetterAuthResult,
27
28
  createBetterAuthInstance,
29
+ OnPasswordResetCallback,
28
30
  } from './better-auth.config';
29
31
  import { DefaultBetterAuthResolver } from './better-auth.resolver';
30
32
  import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware';
@@ -32,6 +34,7 @@ import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.ser
32
34
  import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
33
35
  import { CoreBetterAuthRateLimitMiddleware } from './core-better-auth-rate-limit.middleware';
34
36
  import { CoreBetterAuthRateLimiter } from './core-better-auth-rate-limiter.service';
37
+ import { getInFlightResetPassword } from './core-better-auth-password-reset.registry';
35
38
  import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
36
39
  import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
37
40
  import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
@@ -261,6 +264,11 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
261
264
  // Static reference to email verification service for Better-Auth hooks (outside DI context)
262
265
  private static emailVerificationService: CoreBetterAuthEmailVerificationService | null = null;
263
266
  private static mongoConnection: Connection | null = null;
267
+ /**
268
+ * Config reference for the Better-Auth callbacks, which run OUTSIDE the DI context.
269
+ * Set from the same factories that build the auth instance.
270
+ */
271
+ private static configServiceInstance: ConfigService | null = null;
264
272
  // Safety Net: Track if forRoot() has already been called to detect duplicate registration
265
273
  private static forRootCalled = false;
266
274
  private static cachedDynamicModule: DynamicModule | null = null;
@@ -662,12 +670,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
662
670
  throw new Error('MongoDB database not available');
663
671
  }
664
672
 
673
+ this.configServiceInstance = configService;
674
+
665
675
  // Get JWT secrets from config for backwards compatibility fallback
666
676
  const jwtConfig = configService.get<{ refresh?: { secret?: string }; secret?: string }>('jwt');
667
677
  const fallbackSecrets = [jwtConfig?.secret, jwtConfig?.refresh?.secret];
668
678
 
669
679
  // Create email verification callbacks that delegate to the NestJS service
670
- const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
680
+ const { onEmailVerified, onPasswordReset, sendResetPasswordEmail, sendVerificationEmail } =
671
681
  this.createEmailVerificationCallbacks();
672
682
 
673
683
  // Note: Secret validation is now handled in createBetterAuthInstance
@@ -677,6 +687,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
677
687
  db,
678
688
  fallbackSecrets,
679
689
  onEmailVerified,
690
+ onPasswordReset,
680
691
  sendResetPasswordEmail,
681
692
  sendVerificationEmail,
682
693
  });
@@ -814,8 +825,16 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
814
825
  * They access the service via static reference since Better-Auth hooks run outside DI context.
815
826
  * @internal
816
827
  */
817
- private static createEmailVerificationCallbacks(): {
828
+ /**
829
+ * `protected` rather than `private`: the callbacks it returns include `onPasswordReset`, which
830
+ * decides what mirroring a reset into the legacy store means for a deployment. A project with a
831
+ * different answer (a different store, an extra audit record, skipping it entirely) must be able
832
+ * to say so through the Module Inheritance Pattern rather than by reaching for
833
+ * `options.emailAndPassword` — which cannot replace the hook anyway, since the two are chained.
834
+ */
835
+ protected static createEmailVerificationCallbacks(): {
818
836
  onEmailVerified: (userId: string) => Promise<void>;
837
+ onPasswordReset: OnPasswordResetCallback;
819
838
  sendResetPasswordEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
820
839
  sendVerificationEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
821
840
  } {
@@ -840,6 +859,47 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
840
859
  );
841
860
  }
842
861
  },
862
+ onPasswordReset: async ({ user }) => {
863
+ // Mirror the new password into the legacy bcrypt store, so a deployment running
864
+ // Legacy Auth next to IAM does not keep the OLD password valid on the legacy path
865
+ // after a reset — including a reset performed BECAUSE the old one leaked.
866
+ //
867
+ // The password is not in Better-Auth's callback payload: it comes from the API
868
+ // middleware through the reset registry, already normalized, i.e. byte-identical
869
+ // to what Better-Auth just hashed for IAM.
870
+ // Skip entirely where there is no legacy surface to keep in step. On an IAM-only
871
+ // deployment the mirror would maintain a bcrypt credential nothing ever reads, and its
872
+ // "the legacy password now differs" warning would name a consequence that cannot occur.
873
+ // Gated on the same resolver the endpoints themselves use, so the two cannot drift.
874
+ const legacyConfig = this.configServiceInstance?.getFastButReadOnly('auth')?.legacyEndpoints;
875
+ if (!isLegacyEndpointEnabled(legacyConfig, 'graphql') && !isLegacyEndpointEnabled(legacyConfig, 'rest')) {
876
+ return;
877
+ }
878
+
879
+ const normalizedPassword = getInFlightResetPassword();
880
+ if (!normalizedPassword) {
881
+ // Not a reset whose body we saw. Skipping is the only safe answer — writing a
882
+ // guessed hash would lock the account out of the legacy path.
883
+ this.logger.debug('Password reset without an in-flight password — skipping the legacy mirror.');
884
+ return;
885
+ }
886
+ if (!user?.email) {
887
+ this.logger.warn('Password reset without a user email — cannot mirror it to the legacy store.');
888
+ return;
889
+ }
890
+
891
+ const mirrored = await this.userMapperInstance?.syncPasswordToLegacy(user.id, user.email, normalizedPassword);
892
+ if (mirrored) {
893
+ this.logger.debug(`Mirrored IAM password reset to the legacy store for ${maskEmail(user.email)}`);
894
+ } else {
895
+ // Report the miss. A silent `false` here is exactly how the opposite direction of
896
+ // this sync stayed broken unnoticed: the endpoint answers success either way.
897
+ this.logger.warn(
898
+ `Could not mirror the IAM password reset to the legacy store for ${maskEmail(user.email)} — ` +
899
+ 'the legacy password now differs from the IAM credential.',
900
+ );
901
+ }
902
+ },
843
903
  sendResetPasswordEmail: async (options) => {
844
904
  // Delegate to the NestJS service
845
905
  if (this.emailVerificationService) {
@@ -921,18 +981,20 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
921
981
  {
922
982
  // Inject Mongoose Connection to ensure NestJS waits for it to be ready
923
983
  // Also inject EmailVerificationService to set static reference before Better-Auth init
924
- inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService],
984
+ inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService, ConfigService],
925
985
  provide: BETTER_AUTH_INSTANCE,
926
986
  useFactory: async (
927
987
  connection: Connection,
928
988
  emailVerificationService: CoreBetterAuthEmailVerificationService,
989
+ configService: ConfigService,
929
990
  ) => {
930
991
  // Set static references for callbacks BEFORE creating Better-Auth instance
931
992
  this.setEmailVerificationService(emailVerificationService);
932
993
  this.mongoConnection = connection;
994
+ this.configServiceInstance = configService;
933
995
 
934
996
  // Create email verification callbacks that delegate to the NestJS service
935
- const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
997
+ const { onEmailVerified, onPasswordReset, sendResetPasswordEmail, sendVerificationEmail } =
936
998
  this.createEmailVerificationCallbacks();
937
999
 
938
1000
  // Build shared instance options
@@ -940,6 +1002,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
940
1002
  config,
941
1003
  fallbackSecrets: options?.fallbackSecrets,
942
1004
  onEmailVerified,
1005
+ onPasswordReset,
943
1006
  sendResetPasswordEmail,
944
1007
  sendVerificationEmail,
945
1008
  serverAppUrl: options?.serverAppUrl,