@lenne.tech/nest-server 11.36.5 → 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.
- package/.claude/rules/better-auth.md +32 -0
- package/.claude/rules/configurable-features.md +29 -0
- package/.claude/rules/framework-compatibility.md +1 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/testing.md +26 -4
- package/.claude/rules/versioning.md +6 -0
- package/CLAUDE.md +22 -5
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +1 -1
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
- package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
- package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
- package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
- package/dist/core/modules/auth/core-auth.controller.js +2 -5
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +2 -0
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -5
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js +33 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +2 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +15 -10
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +4 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +3 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +1 -1
- package/dist/core/modules/user/core-user.service.js +29 -12
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/server/modules/user/user.resolver.js +2 -1
- package/dist/server/modules/user/user.resolver.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +8 -1
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +35 -1
- package/migration-guides/11.36.x-to-11.37.0.md +344 -0
- package/migration-guides/11.37.x-to-11.38.x.md +511 -0
- package/package.json +17 -4
- package/src/config.env.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +225 -9
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
- package/src/core/modules/auth/README.md +104 -0
- package/src/core/modules/auth/core-auth.controller.ts +4 -11
- package/src/core/modules/auth/core-auth.module.ts +5 -0
- package/src/core/modules/auth/core-auth.resolver.ts +4 -11
- package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
- package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +46 -1
- package/src/core/modules/better-auth/README.md +96 -59
- package/src/core/modules/better-auth/better-auth.config.ts +106 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
- package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +49 -10
- package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
- package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
- package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
- package/src/core/modules/system-setup/core-system-setup.service.ts +33 -5
- package/src/core/modules/user/core-user.service.ts +123 -18
- package/src/core/modules/user/inputs/core-user.input.ts +16 -1
- package/src/core.module.ts +8 -0
- package/src/index.ts +3 -0
- package/src/server/modules/user/user.controller.ts +7 -1
- package/src/server/modules/user/user.resolver.ts +7 -1
- 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
|
-
|
|
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 {
|
|
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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
1
2
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
2
3
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
4
|
import * as bcrypt from 'bcrypt';
|
|
@@ -56,6 +57,22 @@ export interface MappedUser {
|
|
|
56
57
|
verified?: boolean;
|
|
57
58
|
}
|
|
58
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
|
+
|
|
59
76
|
/**
|
|
60
77
|
* Interface for migration status result
|
|
61
78
|
*/
|
|
@@ -339,6 +356,11 @@ export class CoreBetterAuthUserMapper {
|
|
|
339
356
|
const bcryptHash = await bcrypt.hash(normalizedPassword, saltRounds);
|
|
340
357
|
|
|
341
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`.
|
|
342
364
|
const result = await usersCollection.updateOne(
|
|
343
365
|
{ $or: [{ email: userEmail }, { iamId: iamUserId }] },
|
|
344
366
|
{ $set: { password: bcryptHash, updatedAt: new Date() } },
|
|
@@ -509,10 +531,16 @@ export class CoreBetterAuthUserMapper {
|
|
|
509
531
|
// Store account matching Better-Auth's format:
|
|
510
532
|
// - userId: ObjectId referencing users._id
|
|
511
533
|
// - accountId: string version of users._id
|
|
534
|
+
// - issuer: from better-auth >= 1.7 accounts are keyed by (issuer,
|
|
535
|
+
// accountId), and credential accounts carry a synthetic issuer. Writing
|
|
536
|
+
// the row without it produces an account better-auth cannot find, so the
|
|
537
|
+
// migrated user gets a 401 on the very sign-in that triggered the
|
|
538
|
+
// migration. Always derive it from the helper, never hand-write it.
|
|
512
539
|
await accountsCollection.insertOne({
|
|
513
540
|
accountId: userIdHex,
|
|
514
541
|
createdAt: now,
|
|
515
542
|
id: this.generateId(),
|
|
543
|
+
issuer: createLocalAccountIssuer('credential'),
|
|
516
544
|
password: passwordHash,
|
|
517
545
|
providerId: 'credential',
|
|
518
546
|
updatedAt: now,
|
|
@@ -969,7 +997,13 @@ export class CoreBetterAuthUserMapper {
|
|
|
969
997
|
*
|
|
970
998
|
* @returns Migration status object with counts and percentage
|
|
971
999
|
*/
|
|
972
|
-
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;
|
|
973
1007
|
if (!this.connection) {
|
|
974
1008
|
this.logger.warn('No database connection available - cannot get migration status');
|
|
975
1009
|
return {
|
|
@@ -997,10 +1031,13 @@ export class CoreBetterAuthUserMapper {
|
|
|
997
1031
|
});
|
|
998
1032
|
|
|
999
1033
|
// Get unique userIds that have credential accounts
|
|
1000
|
-
|
|
1001
|
-
|
|
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' }])
|
|
1002
1039
|
.toArray();
|
|
1003
|
-
const usersWithIamAccount =
|
|
1040
|
+
const usersWithIamAccount = credentialAccountCount[0]?.count ?? 0;
|
|
1004
1041
|
|
|
1005
1042
|
// Get users that are fully migrated (have both iamId AND credential account)
|
|
1006
1043
|
// We need to find users where iamId exists AND there's a matching account
|
|
@@ -1040,15 +1077,17 @@ export class CoreBetterAuthUserMapper {
|
|
|
1040
1077
|
// Get emails of pending users (limit to 100)
|
|
1041
1078
|
// Two-phase approach: first get users without iamId (no $lookup needed),
|
|
1042
1079
|
// then check users with iamId but missing credential account
|
|
1043
|
-
const usersWithoutIamId =
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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();
|
|
1048
1087
|
|
|
1049
1088
|
const remaining = 100 - usersWithoutIamId.length;
|
|
1050
1089
|
let usersWithIamButNoAccount: { email?: string }[] = [];
|
|
1051
|
-
if (remaining > 0) {
|
|
1090
|
+
if (includePendingEmails && remaining > 0) {
|
|
1052
1091
|
usersWithIamButNoAccount = await usersCollection
|
|
1053
1092
|
.aggregate([
|
|
1054
1093
|
{ $match: { iamId: { $exists: true, $ne: null } } },
|
|
@@ -71,3 +71,29 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
|
71
71
|
* Declared `@Optional()` in the CoreBetterAuthService constructor.
|
|
72
72
|
*/
|
|
73
73
|
export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* better-auth's default name for the account collection, and for the `issuer` field inside it.
|
|
77
|
+
*
|
|
78
|
+
* Both are overridable by a consumer through `betterAuth.options.account.modelName` /
|
|
79
|
+
* `.fields.issuer`, which `better-auth.config.ts` spreads onto the resolved config verbatim. Any
|
|
80
|
+
* framework code touching that collection directly MUST resolve the real names from the running
|
|
81
|
+
* instance and fall back to these — a hardcoded name silently addresses a collection better-auth
|
|
82
|
+
* does not use, which on the issuer backfill means every password user stays locked out while the
|
|
83
|
+
* operation reports success by saying nothing.
|
|
84
|
+
*/
|
|
85
|
+
export const DEFAULT_ACCOUNT_MODEL_NAME = 'account';
|
|
86
|
+
export const DEFAULT_ACCOUNT_ISSUER_FIELD = 'issuer';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collection holding one-shot completion markers for boot-time data migrations, and the marker id
|
|
90
|
+
* of the `account.issuer` backfill (better-auth 1.7).
|
|
91
|
+
*
|
|
92
|
+
* The marker exists for cost, not for correctness: the backfill is idempotent and safe to repeat,
|
|
93
|
+
* but its filters (`$exists: false`, `$ne`) cannot use an index, so without a marker every boot of
|
|
94
|
+
* every replica pays a full pass over the account collection — forever, and inside the `await` that
|
|
95
|
+
* precedes `app.listen()`. Versioned in the id so a future backfill of the same field can run
|
|
96
|
+
* again without clearing this one.
|
|
97
|
+
*/
|
|
98
|
+
export const BACKFILL_MARKER_COLLECTION = 'better-auth-backfills';
|
|
99
|
+
export const ACCOUNT_ISSUER_BACKFILL_ID = 'account-issuer-backfill-v1';
|
|
@@ -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
|
-
|
|
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,
|