@lenne.tech/nest-server 11.36.0 → 11.36.2
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/configurable-features.md +3 -1
- package/.claude/rules/testing.md +24 -1
- package/CLAUDE.md +1 -0
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/helpers/logging.helper.js +1 -1
- package/dist/core/common/helpers/logging.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
- package/dist/core/modules/better-auth/better-auth.config.js +34 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
- 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-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +11 -2
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +65 -0
- package/dist/templates/password-reset-en.ejs +65 -0
- package/dist/templates/password-reset.ejs +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +17 -11
- package/migration-guides/11.36.0-to-11.36.1.md +283 -0
- package/migration-guides/11.36.1-to-11.36.2.md +67 -0
- package/package.json +10 -11
- package/src/core/common/helpers/logging.helper.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +50 -8
- package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
- package/src/core/modules/better-auth/README.md +100 -15
- package/src/core/modules/better-auth/better-auth.config.ts +138 -9
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
- package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
- package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
- package/src/core/modules/tenant/core-tenant.service.ts +41 -4
- package/src/templates/password-reset-de.ejs +65 -0
- package/src/templates/password-reset-en.ejs +65 -0
- package/src/templates/password-reset.ejs +1 -1
|
@@ -4,6 +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
8
|
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
8
9
|
import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
|
|
9
10
|
import { BrevoService } from '../../common/services/brevo.service';
|
|
@@ -11,14 +12,24 @@ import { ConfigService } from '../../common/services/config.service';
|
|
|
11
12
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
12
13
|
import { EmailService } from '../../common/services/email.service';
|
|
13
14
|
import { TemplateService } from '../../common/services/template.service';
|
|
14
|
-
import { formatProjectName } from './better-auth.config';
|
|
15
|
+
import { AuthEmailCallbackOptions, formatProjectName } from './better-auth.config';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Resolved configuration type for email verification
|
|
18
|
-
* Uses Required for mandatory fields but preserves optional nature of
|
|
19
|
+
* Uses Required for mandatory fields but preserves optional nature of the
|
|
20
|
+
* Brevo template ids — those stay undefined when no Brevo template is
|
|
21
|
+
* configured, which is what selects the SMTP/EJS path.
|
|
19
22
|
*/
|
|
20
|
-
type ResolvedEmailVerificationConfig = Pick<
|
|
21
|
-
|
|
23
|
+
type ResolvedEmailVerificationConfig = Pick<
|
|
24
|
+
IBetterAuthEmailVerificationConfig,
|
|
25
|
+
'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId'
|
|
26
|
+
> &
|
|
27
|
+
Required<
|
|
28
|
+
Omit<
|
|
29
|
+
IBetterAuthEmailVerificationConfig,
|
|
30
|
+
'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'resendCooldownSeconds'
|
|
31
|
+
>
|
|
32
|
+
> & {
|
|
22
33
|
resendCooldownSeconds: number;
|
|
23
34
|
};
|
|
24
35
|
|
|
@@ -94,9 +105,36 @@ export interface SendVerificationEmailOptions {
|
|
|
94
105
|
*/
|
|
95
106
|
const LOCAL_SLOT_TOKEN = 'local';
|
|
96
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Options for sending the password-reset email.
|
|
110
|
+
*
|
|
111
|
+
* Structurally the shared auth-email payload ({@link AuthEmailCallbackOptions}): `token` is the raw
|
|
112
|
+
* reset token for consumers that build their own link, `url` the ready-made link Better-Auth
|
|
113
|
+
* generated, `user` the account the reset was requested for. Aliased rather than redeclared so a
|
|
114
|
+
* future field lands in one place instead of six.
|
|
115
|
+
*/
|
|
116
|
+
export type SendPasswordResetEmailOptions = AuthEmailCallbackOptions;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Template for the password-reset mail. Resolved through the same
|
|
120
|
+
* project-first / locale-aware lookup as the verification template, so a
|
|
121
|
+
* deployment brands it by dropping its own `password-reset[-<locale>].ejs`
|
|
122
|
+
* into the project template directory.
|
|
123
|
+
*/
|
|
124
|
+
const PASSWORD_RESET_TEMPLATE = 'password-reset';
|
|
125
|
+
|
|
97
126
|
@Injectable()
|
|
98
127
|
export class CoreBetterAuthEmailVerificationService {
|
|
99
128
|
protected readonly logger = new Logger(CoreBetterAuthEmailVerificationService.name);
|
|
129
|
+
|
|
130
|
+
/** Memoised `getAppName()` result — see the note there. */
|
|
131
|
+
protected cachedAppName?: string;
|
|
132
|
+
|
|
133
|
+
/** Compiled nest-server templates, keyed by resolved absolute path — see renderFrameworkTemplate. */
|
|
134
|
+
protected readonly compiledTemplates = new Map<string, ejs.TemplateFunction>();
|
|
135
|
+
|
|
136
|
+
/** Memoised `resolveTemplatePath()` results, keyed by `<name>:<locale>`. */
|
|
137
|
+
protected readonly resolvedTemplatePaths = new Map<string, { isAbsolute: boolean; path: string }>();
|
|
100
138
|
protected config: ResolvedEmailVerificationConfig = DEFAULT_CONFIG;
|
|
101
139
|
|
|
102
140
|
/**
|
|
@@ -187,13 +225,7 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
187
225
|
url = this.buildFrontendVerificationUrl(token);
|
|
188
226
|
}
|
|
189
227
|
|
|
190
|
-
|
|
191
|
-
// Uses console.log directly to ensure reliable capture in test environments (Vitest)
|
|
192
|
-
// NestJS Logger may buffer output which makes interception unreliable in tests
|
|
193
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
194
|
-
// oxlint-disable-next-line no-console
|
|
195
|
-
console.log(`[EMAIL VERIFICATION] User: ${user.email}, URL: ${url}`);
|
|
196
|
-
}
|
|
228
|
+
this.logAuthUrlForDevelopment('EMAIL VERIFICATION', user.email, url);
|
|
197
229
|
|
|
198
230
|
// Brevo template path: send via Brevo transactional API if configured
|
|
199
231
|
if (this.config.brevoTemplateId && this.brevoService) {
|
|
@@ -245,9 +277,8 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
245
277
|
};
|
|
246
278
|
|
|
247
279
|
if (resolved.isAbsolute) {
|
|
248
|
-
// Fallback template from nest-server: render directly via EJS
|
|
249
|
-
const
|
|
250
|
-
const html = ejs.render(templateContent, templateData);
|
|
280
|
+
// Fallback template from nest-server: render directly via EJS (compiled once)
|
|
281
|
+
const html = this.renderFrameworkTemplate(resolved.path, templateData);
|
|
251
282
|
|
|
252
283
|
await this.emailService.sendMail(user.email, this.getEmailSubject(appName), { html });
|
|
253
284
|
} else {
|
|
@@ -273,6 +304,135 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
273
304
|
}
|
|
274
305
|
}
|
|
275
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Send the password-reset email.
|
|
309
|
+
*
|
|
310
|
+
* Called from Better-Auth's `emailAndPassword.sendResetPassword` hook.
|
|
311
|
+
* Override to customise delivery.
|
|
312
|
+
*
|
|
313
|
+
* Deliberately does NOT consult `isEnabled()` / `emailVerification.enabled`. Those govern email
|
|
314
|
+
* VERIFICATION; switching that off must not also take away password recovery, which is a
|
|
315
|
+
* different concern and the only way back in for a locked-out user. The reset flow's own switch
|
|
316
|
+
* is `betterAuth.emailAndPassword.passwordReset`. (This method lives on the verification service
|
|
317
|
+
* because it shares the template lookup, the Brevo overlay and the cooldown store with it — the
|
|
318
|
+
* config key `passwordResetBrevoTemplateId` sits under `emailVerification` for the same reason.)
|
|
319
|
+
*
|
|
320
|
+
* Throttled per ADDRESS (see the slot comment in the body), not per session: the abuse this
|
|
321
|
+
* unauthenticated route enables runs on the recipient axis, which an IP-keyed limiter cannot
|
|
322
|
+
* express. The route-level `betterAuth.rateLimit` remains the IP-axis bound.
|
|
323
|
+
*
|
|
324
|
+
* @param options - The reset email options from Better-Auth
|
|
325
|
+
* @throws Re-throws a Brevo or SMTP send failure after logging it with the masked address. The
|
|
326
|
+
* framework's own caller (`sendResetPassword` in better-auth.config.ts) invokes this through
|
|
327
|
+
* `sendAuthEmailSafely`, which catches and logs — so a throw never reaches the request. An
|
|
328
|
+
* override that calls this directly must handle it.
|
|
329
|
+
*/
|
|
330
|
+
async sendPasswordResetEmail(options: SendPasswordResetEmailOptions): Promise<void> {
|
|
331
|
+
const { url, user } = options;
|
|
332
|
+
|
|
333
|
+
this.logAuthUrlForDevelopment('PASSWORD RESET', user.email, url);
|
|
334
|
+
|
|
335
|
+
// Per-ADDRESS throttle. The abuse this route enables runs on the RECIPIENT axis: an attacker
|
|
336
|
+
// rotating IPs mail-bombs one victim and writes one `verification` document per request, and an
|
|
337
|
+
// IP-keyed limiter cannot express "one reset mail per address per window". The slot is
|
|
338
|
+
// namespaced so the two flows cannot starve each other — a pending verification mail must not
|
|
339
|
+
// block a password reset.
|
|
340
|
+
//
|
|
341
|
+
// Returning early is enumeration-safe: Better-Auth has already produced its uniform
|
|
342
|
+
// "if this email exists in our system…" response by the time this hook runs, and the send is
|
|
343
|
+
// detached, so nothing about the response varies.
|
|
344
|
+
const slotKey = `${PASSWORD_RESET_TEMPLATE}:${user.email}`;
|
|
345
|
+
const slotToken = await this.acquireSendSlot(slotKey);
|
|
346
|
+
if (!slotToken) {
|
|
347
|
+
this.logger.debug(`Password-reset cooldown active for ${this.maskEmail(user.email)}, skipping email send`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Only a SUCCESSFUL send may burn the cooldown — otherwise a transient SMTP failure would lock
|
|
352
|
+
// a user out of retrying for the whole window, on the one route that gets them back in.
|
|
353
|
+
let sent = false;
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const appName = this.getAppName();
|
|
357
|
+
const templateData = {
|
|
358
|
+
appName,
|
|
359
|
+
link: url,
|
|
360
|
+
name: user.name || user.email.split('@')[0],
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Brevo path: only when a reset-specific template is configured. Falling back
|
|
364
|
+
// to the VERIFICATION template id here would mail the user "confirm your
|
|
365
|
+
// address" for a password reset.
|
|
366
|
+
if (this.config.passwordResetBrevoTemplateId && this.brevoService) {
|
|
367
|
+
try {
|
|
368
|
+
const result = await this.brevoService.sendMail(
|
|
369
|
+
user.email,
|
|
370
|
+
this.config.passwordResetBrevoTemplateId,
|
|
371
|
+
templateData,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// `sendMail()` swallows SDK errors and resolves to `null` unless
|
|
375
|
+
// `brevo.throwOnError` is set, so "did not throw" is NOT "was delivered".
|
|
376
|
+
// Returning here on a null would leave a locked-out user with no mail at
|
|
377
|
+
// all; fall through to SMTP instead. Mirrors sendVerificationEmail.
|
|
378
|
+
if (result === null) {
|
|
379
|
+
this.logger.error(
|
|
380
|
+
`Brevo password-reset send failed for ${this.maskEmail(user.email)} — falling back to SMTP`,
|
|
381
|
+
);
|
|
382
|
+
// Deliberately no `return`: fall through to the EmailService path.
|
|
383
|
+
} else {
|
|
384
|
+
sent = true;
|
|
385
|
+
this.logger.debug(`Password-reset email sent via Brevo to ${this.maskEmail(user.email)}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
// Mirrors sendVerificationEmail: a Brevo THROW skips the SMTP fallback (only a `null`
|
|
390
|
+
// falls through). The address-identifying line matters — without it the only trace is
|
|
391
|
+
// the generic handler in better-auth.config.ts, which knows neither the user nor which
|
|
392
|
+
// of the two mail paths failed.
|
|
393
|
+
this.logger.error(
|
|
394
|
+
`Failed to send password-reset email via Brevo to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
395
|
+
);
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!this.emailService) {
|
|
401
|
+
this.logger.warn('EmailService not available, cannot send password-reset email');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const resolved = await this.resolveTemplatePath(PASSWORD_RESET_TEMPLATE, this.config.locale);
|
|
407
|
+
const subject = this.getPasswordResetSubject(appName);
|
|
408
|
+
|
|
409
|
+
if (resolved.isAbsolute) {
|
|
410
|
+
// nest-server fallback template: render directly via EJS (compiled once, see the helper)
|
|
411
|
+
const html = this.renderFrameworkTemplate(resolved.path, templateData);
|
|
412
|
+
await this.emailService.sendMail(user.email, subject, { html });
|
|
413
|
+
} else {
|
|
414
|
+
// Project template: use TemplateService (relative path)
|
|
415
|
+
await this.emailService.sendMail(user.email, subject, {
|
|
416
|
+
htmlTemplate: resolved.path,
|
|
417
|
+
templateData,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
sent = true;
|
|
422
|
+
this.logger.debug(`Password-reset email sent to ${this.maskEmail(user.email)}`);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
this.logger.error(
|
|
425
|
+
`Failed to send password-reset email to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
426
|
+
);
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
} finally {
|
|
430
|
+
if (!sent) {
|
|
431
|
+
await this.releaseSendSlot(slotKey, slotToken);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
276
436
|
/**
|
|
277
437
|
* Reserve the resend-cooldown slot for an email address.
|
|
278
438
|
*
|
|
@@ -448,6 +608,15 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
448
608
|
templateName: string,
|
|
449
609
|
locale: string,
|
|
450
610
|
): Promise<{ isAbsolute: boolean; path: string }> {
|
|
611
|
+
// Memoised: this walk costs up to four blocking `existsSync` calls, and it runs per outgoing
|
|
612
|
+
// auth mail on a route an unauthenticated caller can drive. Template files do not appear at
|
|
613
|
+
// runtime, so re-walking buys nothing after the first resolution.
|
|
614
|
+
const cacheKey = `${templateName}:${locale}`;
|
|
615
|
+
const cached = this.resolvedTemplatePaths.get(cacheKey);
|
|
616
|
+
if (cached) {
|
|
617
|
+
return cached;
|
|
618
|
+
}
|
|
619
|
+
|
|
451
620
|
const projectTemplatesPath = this.configService.getFastButReadOnly<string>('templates.path');
|
|
452
621
|
const nestServerTemplatesPath = path.join(__dirname, '..', '..', '..', 'templates');
|
|
453
622
|
|
|
@@ -467,12 +636,13 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
467
636
|
|
|
468
637
|
const fullPath = path.join(candidate.base, `${candidate.name}.ejs`);
|
|
469
638
|
if (fs.existsSync(fullPath)) {
|
|
470
|
-
|
|
471
|
-
// nest-server template:
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
639
|
+
const resolved = candidate.isNestServer
|
|
640
|
+
? // nest-server template: absolute path (rendered directly via EJS)
|
|
641
|
+
{ isAbsolute: true, path: fullPath.replace('.ejs', '') }
|
|
642
|
+
: // Project template: relative name (for TemplateService)
|
|
643
|
+
{ isAbsolute: false, path: candidate.name };
|
|
644
|
+
this.resolvedTemplatePaths.set(cacheKey, resolved);
|
|
645
|
+
return resolved;
|
|
476
646
|
}
|
|
477
647
|
}
|
|
478
648
|
|
|
@@ -484,7 +654,71 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
484
654
|
/**
|
|
485
655
|
* Get the app name for the email
|
|
486
656
|
*/
|
|
657
|
+
/**
|
|
658
|
+
* Render a nest-server fallback template, compiling it at most once per resolved path.
|
|
659
|
+
*
|
|
660
|
+
* `TemplateService` already caches compiled templates — but only for PROJECT templates. The
|
|
661
|
+
* absolute-path branch bypasses it entirely, so before this every outgoing auth mail re-read the
|
|
662
|
+
* file and re-compiled the EJS: measured at ~113 µs of event-loop-blocking work per mail against
|
|
663
|
+
* ~2 µs for a cached call. `readFileSync` blocks every other in-flight request while it runs, and
|
|
664
|
+
* the password-reset route that drives it is unauthenticated.
|
|
665
|
+
*
|
|
666
|
+
* Template files do not appear or change at runtime, so an unbounded map is bounded in practice
|
|
667
|
+
* by the number of templates on disk.
|
|
668
|
+
*/
|
|
669
|
+
protected renderFrameworkTemplate(pathWithoutExtension: string, data: Record<string, unknown>): string {
|
|
670
|
+
let compiled = this.compiledTemplates.get(pathWithoutExtension);
|
|
671
|
+
if (!compiled) {
|
|
672
|
+
compiled = ejs.compile(fs.readFileSync(`${pathWithoutExtension}.ejs`, 'utf-8'));
|
|
673
|
+
this.compiledTemplates.set(pathWithoutExtension, compiled);
|
|
674
|
+
}
|
|
675
|
+
return compiled(data);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Print an auth link (verification / password reset) to stdout for local development.
|
|
680
|
+
*
|
|
681
|
+
* The URL embeds a bearer token: the verification link confirms an address, the reset link
|
|
682
|
+
* changes a password outright. Neither may reach a deployment holding real accounts, so the gate
|
|
683
|
+
* is `isProductionLikeEnv()` — the same two-layer check `EmailService` and the cookie helpers
|
|
684
|
+
* already use. `NODE_ENV !== 'production'` alone is NOT enough: a staging deployment sets
|
|
685
|
+
* `NODE_ENV=staging` so `getEnvironmentConfig()` loads its config block, which passes that test
|
|
686
|
+
* while holding real users.
|
|
687
|
+
*
|
|
688
|
+
* The address is masked (PII, and the rest of this file already masks it); the URL is printed in
|
|
689
|
+
* full because following it without a mail server is the whole point locally. Set
|
|
690
|
+
* `LT_LOG_AUTH_URLS=0` to suppress it even outside production.
|
|
691
|
+
*
|
|
692
|
+
* `console.log` rather than the NestJS logger, deliberately and for two reasons: the logger
|
|
693
|
+
* buffers, which makes interception unreliable under Vitest, and a logger call would route the
|
|
694
|
+
* token into `HubLogBufferService`'s ring buffer, which is readable over HTTP by any ADMIN.
|
|
695
|
+
*/
|
|
696
|
+
protected logAuthUrlForDevelopment(label: string, email: string, url: string): void {
|
|
697
|
+
if (process.env.LT_LOG_AUTH_URLS === '0') {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (isProductionLikeEnv(this.configService?.getFastButReadOnly<string>('env'))) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// oxlint-disable-next-line no-console
|
|
704
|
+
console.log(`[${label}] User: ${this.maskEmail(email)}, URL: ${url}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
487
707
|
protected getAppName(): string {
|
|
708
|
+
// `package.json` never changes at runtime, but this runs once per outgoing auth mail — on a
|
|
709
|
+
// route an unauthenticated caller can drive. Cache it so that cannot become a synchronous
|
|
710
|
+
// file read per request. Mirrors `cachedProjectAppName` in better-auth.config.ts.
|
|
711
|
+
if (this.cachedAppName !== undefined) {
|
|
712
|
+
return this.cachedAppName;
|
|
713
|
+
}
|
|
714
|
+
this.cachedAppName = this.resolveAppName();
|
|
715
|
+
return this.cachedAppName;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Resolve the application name from package.json, falling back to a framework default.
|
|
720
|
+
*/
|
|
721
|
+
protected resolveAppName(): string {
|
|
488
722
|
// Try to get from package.json name
|
|
489
723
|
try {
|
|
490
724
|
const packageJsonPath = path.join(process.cwd(), 'package.json');
|
|
@@ -517,6 +751,17 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
517
751
|
return `${appName} - Verify your email address`;
|
|
518
752
|
}
|
|
519
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Subject line of the password-reset mail.
|
|
756
|
+
*/
|
|
757
|
+
protected getPasswordResetSubject(appName: string): string {
|
|
758
|
+
const subjects: Record<string, string> = {
|
|
759
|
+
de: `${appName} - Passwort zurücksetzen`,
|
|
760
|
+
en: `${appName} - Reset your password`,
|
|
761
|
+
};
|
|
762
|
+
return subjects[this.config.locale] ?? subjects.en;
|
|
763
|
+
}
|
|
764
|
+
|
|
520
765
|
/**
|
|
521
766
|
* Format expires in seconds to human readable string
|
|
522
767
|
*/
|
|
@@ -49,7 +49,21 @@ const DEFAULT_CONFIG: Required<IBetterAuthRateLimit> = {
|
|
|
49
49
|
maxEntries: 10000,
|
|
50
50
|
message: 'Too many requests, please try again later.',
|
|
51
51
|
skipEndpoints: ['/session', '/callback'],
|
|
52
|
-
|
|
52
|
+
// The routes Better-Auth actually serves, plus the spellings a project may route itself.
|
|
53
|
+
// `/request-password-reset` is the entry point that MINTS a reset token and sends mail; it
|
|
54
|
+
// matched nothing here before (the only `/` in it is followed by `request`, so
|
|
55
|
+
// `includes('/reset-password')` is false), which left the expensive, mail-sending half of the
|
|
56
|
+
// flow on the FULL limit while the cheap submit half got the halved one. `/forget-password` is
|
|
57
|
+
// Better-Auth's own alias spelling — `/forgot-password` never matched any real route.
|
|
58
|
+
strictEndpoints: [
|
|
59
|
+
'/sign-in',
|
|
60
|
+
'/sign-up',
|
|
61
|
+
'/request-password-reset',
|
|
62
|
+
'/forget-password',
|
|
63
|
+
'/forgot-password',
|
|
64
|
+
'/reset-password',
|
|
65
|
+
'/change-password',
|
|
66
|
+
],
|
|
53
67
|
windowSeconds: 60,
|
|
54
68
|
};
|
|
55
69
|
|
|
@@ -88,10 +102,24 @@ export class CoreBetterAuthRateLimiter {
|
|
|
88
102
|
*
|
|
89
103
|
* @param config - Rate limiting configuration
|
|
90
104
|
*/
|
|
91
|
-
configure(config: IBetterAuthRateLimit | undefined): void {
|
|
105
|
+
configure(config: IBetterAuthRateLimit | null | undefined): void {
|
|
106
|
+
// Absent config leaves rate limiting off — backward compatible.
|
|
107
|
+
if (config === undefined || config === null) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Presence of config implies enabled, unless explicitly disabled. This is the contract
|
|
112
|
+
// `.claude/rules/configurable-features.md` documents for `betterAuth.rateLimit`, and the one
|
|
113
|
+
// `LegacyAuthRateLimiter.configure()` has always implemented. Spreading over an
|
|
114
|
+
// `enabled: false` default without recomputing meant the two sibling limiters had OPPOSITE
|
|
115
|
+
// semantics: a documented `rateLimit: { max: 20 }` armed the legacy limiter and silently
|
|
116
|
+
// armed nothing here — on the side where every new project lives.
|
|
117
|
+
const enabled = config.enabled !== false;
|
|
118
|
+
|
|
92
119
|
this.config = {
|
|
93
120
|
...DEFAULT_CONFIG,
|
|
94
121
|
...config,
|
|
122
|
+
enabled,
|
|
95
123
|
// Ensure arrays are properly merged
|
|
96
124
|
skipEndpoints: config?.skipEndpoints ?? DEFAULT_CONFIG.skipEndpoints,
|
|
97
125
|
strictEndpoints: config?.strictEndpoints ?? DEFAULT_CONFIG.strictEndpoints,
|
|
@@ -13,13 +13,19 @@ import { APP_GUARD } from '@nestjs/core';
|
|
|
13
13
|
import { getConnectionToken } from '@nestjs/mongoose';
|
|
14
14
|
import mongoose, { Connection } from 'mongoose';
|
|
15
15
|
|
|
16
|
+
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
16
17
|
import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
|
|
17
18
|
import { BrevoService } from '../../common/services/brevo.service';
|
|
18
19
|
import { ConfigService } from '../../common/services/config.service';
|
|
19
20
|
import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
|
|
20
21
|
import { BetterAuthRolesGuard } from './better-auth-roles.guard';
|
|
21
22
|
import { BetterAuthTokenService } from './better-auth-token.service';
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
AuthEmailCallbackOptions,
|
|
25
|
+
BetterAuthInstance,
|
|
26
|
+
CreateBetterAuthResult,
|
|
27
|
+
createBetterAuthInstance,
|
|
28
|
+
} from './better-auth.config';
|
|
23
29
|
import { DefaultBetterAuthResolver } from './better-auth.resolver';
|
|
24
30
|
import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware';
|
|
25
31
|
import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
|
|
@@ -406,8 +412,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
406
412
|
consumer.apply(CoreBetterAuthMiddleware).forRoutes('(.*)'); // New path-to-regexp syntax for wildcard
|
|
407
413
|
CoreBetterAuthModule.logger.debug('CoreBetterAuthMiddleware registered for all routes');
|
|
408
414
|
|
|
409
|
-
// Apply rate limiting to Better-Auth endpoints only
|
|
410
|
-
|
|
415
|
+
// Apply rate limiting to Better-Auth endpoints only.
|
|
416
|
+
// Mirrors the "presence implies enabled" contract `CoreBetterAuthRateLimiter.configure()`
|
|
417
|
+
// implements. Gating the MOUNT on `.enabled` alone made that fix unobservable: the limiter
|
|
418
|
+
// would report itself enabled while no middleware was ever registered.
|
|
419
|
+
if (
|
|
420
|
+
CoreBetterAuthModule.currentConfig?.rateLimit &&
|
|
421
|
+
CoreBetterAuthModule.currentConfig.rateLimit.enabled !== false
|
|
422
|
+
) {
|
|
411
423
|
consumer.apply(CoreBetterAuthRateLimitMiddleware).forRoutes(`${basePath}/*path`);
|
|
412
424
|
CoreBetterAuthModule.logger.debug(`Rate limiting middleware registered for ${basePath}/*path endpoints`);
|
|
413
425
|
}
|
|
@@ -655,7 +667,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
655
667
|
const fallbackSecrets = [jwtConfig?.secret, jwtConfig?.refresh?.secret];
|
|
656
668
|
|
|
657
669
|
// Create email verification callbacks that delegate to the NestJS service
|
|
658
|
-
const { onEmailVerified, sendVerificationEmail } =
|
|
670
|
+
const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
|
|
671
|
+
this.createEmailVerificationCallbacks();
|
|
659
672
|
|
|
660
673
|
// Note: Secret validation is now handled in createBetterAuthInstance
|
|
661
674
|
// with fallback to jwt.secret, jwt.refresh.secret, or auto-generation
|
|
@@ -664,6 +677,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
664
677
|
db,
|
|
665
678
|
fallbackSecrets,
|
|
666
679
|
onEmailVerified,
|
|
680
|
+
sendResetPasswordEmail,
|
|
667
681
|
sendVerificationEmail,
|
|
668
682
|
});
|
|
669
683
|
this.authInstance = result?.instance ?? null;
|
|
@@ -802,11 +816,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
802
816
|
*/
|
|
803
817
|
private static createEmailVerificationCallbacks(): {
|
|
804
818
|
onEmailVerified: (userId: string) => Promise<void>;
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
url: string;
|
|
808
|
-
user: { email: string; id: string; name?: null | string };
|
|
809
|
-
}) => Promise<void>;
|
|
819
|
+
sendResetPasswordEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
|
|
820
|
+
sendVerificationEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
|
|
810
821
|
} {
|
|
811
822
|
return {
|
|
812
823
|
onEmailVerified: async (userId: string) => {
|
|
@@ -829,14 +840,33 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
829
840
|
);
|
|
830
841
|
}
|
|
831
842
|
},
|
|
843
|
+
sendResetPasswordEmail: async (options) => {
|
|
844
|
+
// Delegate to the NestJS service
|
|
845
|
+
if (this.emailVerificationService) {
|
|
846
|
+
await this.emailVerificationService.sendPasswordResetEmail(options);
|
|
847
|
+
} else {
|
|
848
|
+
// Report the failure, never the capability. The reset URL is a bearer token for account
|
|
849
|
+
// takeover, this branch has no environment gate, and `this.logger` feeds
|
|
850
|
+
// `HubLogBufferService`'s ring buffer — which is readable over HTTP by any ADMIN. The
|
|
851
|
+
// operator needs to know delivery is broken, not to be handed the victim's token.
|
|
852
|
+
this.logger.error(
|
|
853
|
+
`Password reset requested for ${maskEmail(options.user.email)} but CoreBetterAuthEmailVerificationService ` +
|
|
854
|
+
'is not available — NO mail was sent. Register the service, or disable the flow via ' +
|
|
855
|
+
'betterAuth.emailAndPassword.passwordReset: false.',
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
},
|
|
832
859
|
sendVerificationEmail: async (options) => {
|
|
833
860
|
// Delegate to the NestJS service
|
|
834
861
|
if (this.emailVerificationService) {
|
|
835
862
|
await this.emailVerificationService.sendVerificationEmail(options);
|
|
836
863
|
} else {
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
this.logger.
|
|
864
|
+
// Same reasoning as the password-reset branch above: the URL carries a token and this
|
|
865
|
+
// logger feeds the ADMIN-readable Hub log buffer, so report the breakage instead.
|
|
866
|
+
this.logger.error(
|
|
867
|
+
`Verification mail requested for ${maskEmail(options.user.email)} but ` +
|
|
868
|
+
'CoreBetterAuthEmailVerificationService is not available — NO mail was sent.',
|
|
869
|
+
);
|
|
840
870
|
}
|
|
841
871
|
},
|
|
842
872
|
};
|
|
@@ -902,13 +932,15 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
902
932
|
this.mongoConnection = connection;
|
|
903
933
|
|
|
904
934
|
// Create email verification callbacks that delegate to the NestJS service
|
|
905
|
-
const { onEmailVerified, sendVerificationEmail } =
|
|
935
|
+
const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
|
|
936
|
+
this.createEmailVerificationCallbacks();
|
|
906
937
|
|
|
907
938
|
// Build shared instance options
|
|
908
939
|
const sharedInstanceOptions = {
|
|
909
940
|
config,
|
|
910
941
|
fallbackSecrets: options?.fallbackSecrets,
|
|
911
942
|
onEmailVerified,
|
|
943
|
+
sendResetPasswordEmail,
|
|
912
944
|
sendVerificationEmail,
|
|
913
945
|
serverAppUrl: options?.serverAppUrl,
|
|
914
946
|
serverBaseUrl: options?.serverBaseUrl,
|
|
@@ -93,7 +93,7 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Ensure performance indices exist on session and
|
|
96
|
+
* Ensure performance indices exist on the session, users, account and verification collections.
|
|
97
97
|
* Indices are idempotent — calling createIndex on an existing index is a no-op.
|
|
98
98
|
*/
|
|
99
99
|
async onModuleInit(): Promise<void> {
|
|
@@ -112,9 +112,17 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
112
112
|
// Account: userId lookup ($lookup in getMigrationStatus) and providerId filtering
|
|
113
113
|
db.collection('account').createIndex({ userId: 1 }),
|
|
114
114
|
db.collection('account').createIndex({ providerId: 1, userId: 1 }),
|
|
115
|
+
// Verification: identifier lookup (findVerificationValue runs on every verify/reset).
|
|
116
|
+
db.collection('verification').createIndex({ identifier: 1 }),
|
|
117
|
+
// Verification TTL — this one is correctness, not just speed. Better-Auth writes a
|
|
118
|
+
// verification document per email-verification AND per password-reset request, and removes
|
|
119
|
+
// it only when the token is CONSUMED. `POST /iam/request-password-reset` is unauthenticated,
|
|
120
|
+
// so every un-clicked request left a row that nothing would ever delete. The documents
|
|
121
|
+
// already carry `expiresAt`; `expireAfterSeconds: 0` makes MongoDB honour it.
|
|
122
|
+
db.collection('verification').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
|
|
115
123
|
]);
|
|
116
124
|
|
|
117
|
-
this.logger.debug('Performance indices ensured on session, users, and
|
|
125
|
+
this.logger.debug('Performance indices ensured on session, users, account, and verification collections');
|
|
118
126
|
} catch (error) {
|
|
119
127
|
// Non-fatal: indices improve performance but are not required for correctness
|
|
120
128
|
this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
@@ -74,7 +74,22 @@ export class CoreTenantService {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Get a single membership
|
|
77
|
+
* Get a single membership REGARDLESS of its status.
|
|
78
|
+
*
|
|
79
|
+
* Read the name literally: a removal is a status change to `SUSPENDED`, not
|
|
80
|
+
* a delete, so this still answers for somebody who was thrown out. That is
|
|
81
|
+
* deliberate and `addMember` depends on it — it reactivates the existing row
|
|
82
|
+
* instead of creating a duplicate.
|
|
83
|
+
*
|
|
84
|
+
* It is therefore the WRONG method for an authorization check. Asking "is
|
|
85
|
+
* this user a member with role X?" through it answers yes for a removed
|
|
86
|
+
* member, and a route that guards itself this way keeps granting a
|
|
87
|
+
* suspended administrator the right to invite, remove and re-role — the very
|
|
88
|
+
* rights that being removed was supposed to take away. Routes that carry
|
|
89
|
+
* `@SkipTenantCheck()` and decide for themselves are exactly the ones at
|
|
90
|
+
* risk, because the tenant guard never sees them.
|
|
91
|
+
*
|
|
92
|
+
* For an authorization check use {@link getActiveMembership}.
|
|
78
93
|
*/
|
|
79
94
|
async getMembership(tenantId: string, userId: string): Promise<CoreTenantMemberModel | null> {
|
|
80
95
|
return this.memberModel
|
|
@@ -83,6 +98,25 @@ export class CoreTenantService {
|
|
|
83
98
|
.exec() as Promise<CoreTenantMemberModel | null>;
|
|
84
99
|
}
|
|
85
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Get a membership only while it is live — the one to use when deciding what
|
|
103
|
+
* somebody may do.
|
|
104
|
+
*
|
|
105
|
+
* Returns `null` for a suspended or invited membership, so "removed" means
|
|
106
|
+
* removed everywhere, not just for the data the tenant guard happens to
|
|
107
|
+
* cover.
|
|
108
|
+
*/
|
|
109
|
+
async getActiveMembership(tenantId: string, userId: string): Promise<CoreTenantMemberModel | null> {
|
|
110
|
+
if (!tenantId?.trim() || !userId?.trim()) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return this.memberModel
|
|
115
|
+
.findOne({ status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId })
|
|
116
|
+
.lean()
|
|
117
|
+
.exec() as Promise<CoreTenantMemberModel | null>;
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
/**
|
|
87
121
|
* Add a member to a tenant.
|
|
88
122
|
* Uses bypassTenantGuard to avoid tenant filtering on the membership collection itself.
|
|
@@ -193,8 +227,11 @@ export class CoreTenantService {
|
|
|
193
227
|
assertAssignableMembershipRole(role);
|
|
194
228
|
const highestRole = this.getHighestRole();
|
|
195
229
|
|
|
196
|
-
// If demoting from highest role, ensure it's not the last one
|
|
197
|
-
|
|
230
|
+
// If demoting from highest role, ensure it's not the last one. Active
|
|
231
|
+
// only: a suspended membership is not an owner any more, and letting it
|
|
232
|
+
// trigger the guard produces "cannot demote the last owner" for a user who
|
|
233
|
+
// is not even a member.
|
|
234
|
+
const existing = await this.getActiveMembership(tenantId, userId);
|
|
198
235
|
if (existing?.role === highestRole && role !== highestRole) {
|
|
199
236
|
await this.assertNotLastOwner(tenantId, userId);
|
|
200
237
|
}
|
|
@@ -237,7 +274,7 @@ export class CoreTenantService {
|
|
|
237
274
|
});
|
|
238
275
|
|
|
239
276
|
if (ownerCount <= 1) {
|
|
240
|
-
const membership = await this.
|
|
277
|
+
const membership = await this.getActiveMembership(tenantId, userId);
|
|
241
278
|
if (membership?.role === highestRole) {
|
|
242
279
|
throw new BadRequestException('Cannot remove or demote the last owner of a tenant');
|
|
243
280
|
}
|