@lenne.tech/nest-server 11.38.0 → 11.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.claude/rules/configurable-features.md +23 -1
  2. package/.claude/rules/package-management.md +66 -0
  3. package/.claude/rules/testing.md +59 -5
  4. package/FRAMEWORK-API.md +1 -1
  5. package/dist/config.env.js +4 -2
  6. package/dist/config.env.js.map +1 -1
  7. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  8. package/dist/core/common/helpers/config.helper.js +18 -0
  9. package/dist/core/common/helpers/config.helper.js.map +1 -1
  10. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  11. package/dist/core/common/helpers/cookies.helper.js +9 -0
  12. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  13. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  14. package/dist/core/common/helpers/input.helper.js +4 -0
  15. package/dist/core/common/helpers/input.helper.js.map +1 -1
  16. package/dist/core/common/helpers/service.helper.js +6 -1
  17. package/dist/core/common/helpers/service.helper.js.map +1 -1
  18. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  19. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  20. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  21. package/dist/core/common/services/email.service.d.ts +4 -1
  22. package/dist/core/common/services/email.service.js +25 -2
  23. package/dist/core/common/services/email.service.js.map +1 -1
  24. package/dist/core/common/services/module.service.js +1 -0
  25. package/dist/core/common/services/module.service.js.map +1 -1
  26. package/dist/core/modules/better-auth/better-auth.config.js +7 -0
  27. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
  29. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  30. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
  31. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
  32. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  34. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  35. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  36. package/dist/core/modules/better-auth/index.d.ts +1 -0
  37. package/dist/core/modules/better-auth/index.js +1 -0
  38. package/dist/core/modules/better-auth/index.js.map +1 -1
  39. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  40. package/dist/core/modules/error-code/error-codes.js +24 -0
  41. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  42. package/dist/core/modules/hub/core-hub.service.js +1 -0
  43. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  44. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  45. package/dist/core/modules/user/core-user.model.js +11 -1
  46. package/dist/core/modules/user/core-user.model.js.map +1 -1
  47. package/dist/core/modules/user/core-user.service.d.ts +9 -0
  48. package/dist/core/modules/user/core-user.service.js +96 -4
  49. package/dist/core/modules/user/core-user.service.js.map +1 -1
  50. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  51. package/dist/server/modules/user/user.model.d.ts +5 -0
  52. package/dist/server/modules/user/user.service.js +12 -6
  53. package/dist/server/modules/user/user.service.js.map +1 -1
  54. package/dist/templates/password-reset-de.ejs +12 -0
  55. package/dist/templates/password-reset-en.ejs +12 -0
  56. package/dist/templates/password-reset.ejs +1 -0
  57. package/dist/tsconfig.build.tsbuildinfo +1 -1
  58. package/docs/REQUEST-LIFECYCLE.md +14 -0
  59. package/migration-guides/11.37.x-to-11.38.x.md +18 -1
  60. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  61. package/package.json +1 -1
  62. package/src/config.env.ts +19 -3
  63. package/src/core/common/helpers/config.helper.ts +79 -0
  64. package/src/core/common/helpers/cookies.helper.ts +38 -0
  65. package/src/core/common/helpers/input.helper.ts +37 -0
  66. package/src/core/common/helpers/service.helper.ts +9 -1
  67. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  68. package/src/core/common/interfaces/server-options.interface.ts +103 -3
  69. package/src/core/common/services/email.service.ts +46 -1
  70. package/src/core/common/services/module.service.ts +1 -0
  71. package/src/core/modules/better-auth/better-auth.config.ts +25 -0
  72. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
  73. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
  74. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  75. package/src/core/modules/better-auth/index.ts +1 -0
  76. package/src/core/modules/error-code/error-codes.ts +55 -0
  77. package/src/core/modules/hub/core-hub.service.ts +1 -0
  78. package/src/core/modules/user/core-user.model.ts +28 -1
  79. package/src/core/modules/user/core-user.service.ts +267 -5
  80. package/src/server/modules/user/user.service.ts +26 -7
  81. package/src/templates/password-reset-de.ejs +12 -0
  82. package/src/templates/password-reset-en.ejs +12 -0
  83. package/src/templates/password-reset.ejs +1 -0
@@ -577,3 +577,41 @@ export function buildCorsConfig(options: Partial<IServerOptions>): Record<string
577
577
  // No origins resolvable → return empty (secure default — no open CORS with credentials)
578
578
  return {};
579
579
  }
580
+
581
+ /**
582
+ * The app URL as every mail-link builder in this package must resolve it.
583
+ *
584
+ * ── Why this exists rather than four inline reads ──────────────────────────────
585
+ * Two classes build a password-reset link — `CoreUserService.buildPasswordResetLink()` for the
586
+ * legacy flow and `CoreBetterAuthEmailVerificationService.buildPasswordResetUrl()` for IAM — and a
587
+ * third builds the verification link. They are near-identical by construction and were maintained
588
+ * by hand, which is exactly how they drifted: one was fixed to resolve through `resolveServerUrls`
589
+ * while the others kept reading `appUrl` straight off the configuration.
590
+ *
591
+ * A raw read is wrong in three situations that all look fine locally:
592
+ *
593
+ * - `local` / `ci` / `e2e` do not set `appUrl` — their localhost default lives inside
594
+ * `resolveServerUrls`, so a raw read yields nothing and the builder falls back or returns null.
595
+ * - A host-split `baseUrl` such as `https://api.crm.localhost` (what `lt dev up` serves) carries
596
+ * the app origin one label away; a raw read cannot see it.
597
+ * - `cors.deriveAppUrl: false` is how a deployment states that the apex domain is NOT its own —
598
+ * the documented case being a third-party-hosted marketing site. Deriving anyway puts a
599
+ * password-reset token into that origin's access log.
600
+ *
601
+ * The third point is why this is a shared function rather than a convention: it is a security
602
+ * decision, and a security decision repeated by hand in three places is one that will eventually
603
+ * be made differently in one of them.
604
+ *
605
+ * @param configService - anything exposing the frozen-config reader
606
+ * @returns the resolved app URL, or `undefined` when nothing can be resolved — never a guess
607
+ */
608
+ export function resolveAppUrlFromConfig(configService: {
609
+ getFastButReadOnly<T = any>(key: string, defaultValue?: any): T;
610
+ }): string | undefined {
611
+ return resolveServerUrls({
612
+ appUrl: configService.getFastButReadOnly<string>('appUrl'),
613
+ baseUrl: configService.getFastButReadOnly<string>('baseUrl'),
614
+ deriveAppUrl: configService.getFastButReadOnly<boolean>('cors.deriveAppUrl'),
615
+ env: configService.getFastButReadOnly<string>('env'),
616
+ }).appUrl;
617
+ }
@@ -856,3 +856,40 @@ export function typeofArray(arr: any[], strict = false): string {
856
856
  }
857
857
  return type;
858
858
  }
859
+
860
+ /**
861
+ * Whether a value is safe to use as the right-hand side of a Mongoose equality filter.
862
+ *
863
+ * ── The hole this closes ───────────────────────────────────────────────────────
864
+ * A controller parameter declared as `@Body('token') token: string` has
865
+ * `metatype === String`, and `MapAndValidatePipe` short-circuits on exactly that shape:
866
+ * `if (!value || typeof value !== 'object' || !metatype || isBasicType(metatype)) return value`.
867
+ * The declared type is erased at runtime, so nothing checks it — a JSON object reaches the
868
+ * service verbatim.
869
+ *
870
+ * `findOne({ passwordResetToken: token })` with `token = { $ne: null }` therefore selects the
871
+ * first user holding ANY live reset token, and the caller sets that person's password without
872
+ * ever seeing their mail. Confirmed by probe against the real route, not by reading.
873
+ *
874
+ * Express parses query strings with `qs` in extended mode, so `?token[$ne]=` produces the same
875
+ * object without a JSON body at all — `@Query('token') token: string` is exposed identically.
876
+ *
877
+ * `{ $ne: null }` is the takeover primitive; `null` is the quieter one, because MongoDB matches
878
+ * MISSING fields against `null` and selects a user who never requested anything.
879
+ *
880
+ * ── Why a guard at the sink rather than `mongoose.set('sanitizeFilter', true)` ──
881
+ * The global switch wraps every object-valued filter in `$eq`, which would break the framework's
882
+ * own operator-bearing queries (`$in`, `$ne`, `$gt` in the filter helpers) unless each is wrapped
883
+ * in `mongoose.trusted()`. That is a fleet-wide audit, not a fix. This guard is exact: it says
884
+ * "this particular value came from a client and must be a plain string".
885
+ *
886
+ * Rejects an empty string too — `findOne({ token: '' })` is never a legitimate credential lookup,
887
+ * and `undefined` would be stripped from the filter entirely by Mongoose, turning the query into
888
+ * `findOne({})` and matching the first document in the collection.
889
+ *
890
+ * @param value - the raw value as it arrived from the transport
891
+ * @returns whether it may be used as a filter value
892
+ */
893
+ export function isQueryableString(value: unknown): value is string {
894
+ return typeof value === 'string' && value.length > 0;
895
+ }
@@ -18,7 +18,15 @@ import { clone, plainToInstanceClean, processDeep } from './input.helper';
18
18
  // Fields like refreshTokens/tempTokens are kept — they are needed for token validation
19
19
  // and process flows (password reset, email verification). The CheckSecurityInterceptor
20
20
  // removes those from HTTP responses as a separate layer.
21
- const SECRET_FIELD_NAMES = Object.freeze(['password', 'verificationToken', 'passwordResetToken']);
21
+ const SECRET_FIELD_NAMES = Object.freeze([
22
+ 'password',
23
+ 'verificationToken',
24
+ 'passwordResetToken',
25
+ // `S_NO_ONE` covers Model instances; this list is what runs on a plain-object path (`.lean()`,
26
+ // `aggregate`, a spread). Its sibling token has always been here — the timestamp says "a reset
27
+ // is pending for this account", which is exactly what the field's own JSDoc calls attacker-useful.
28
+ 'passwordResetTokenExpiresAt',
29
+ ]);
22
30
 
23
31
  /**
24
32
  * Helper class for services
@@ -21,6 +21,7 @@ export class CheckSecurityInterceptor implements NestInterceptor {
21
21
  'password',
22
22
  'verificationToken',
23
23
  'passwordResetToken',
24
+ 'passwordResetTokenExpiresAt',
24
25
  'refreshTokens',
25
26
  'tempTokens',
26
27
  'apiKeyEncrypted',
@@ -297,6 +297,51 @@ export interface IAuthPasswordReset {
297
297
  * ```
298
298
  */
299
299
  preventUserEnumeration?: boolean;
300
+
301
+ /**
302
+ * How long a LEGACY password-reset token stays valid, in minutes.
303
+ *
304
+ * **Before 11.38.0 it never expired at all.** `resetPassword()` looked the token up by value and
305
+ * nothing else, while the exception it threw on a miss read "Invalid or expired password reset
306
+ * token" — a message describing a check that did not exist. A reset link is a bearer credential
307
+ * for full account takeover, so an unbounded one means a mail sitting in an archive, a forwarded
308
+ * message or a restored backup opens the account years later.
309
+ *
310
+ * That gap became more reachable in this very release, which is why it is closed here: until now
311
+ * a project relying on the default mailed a link containing the word `undefined`, so the eternal
312
+ * token was unusable by accident. Repairing the link without adding an expiry would have turned a
313
+ * dead credential into a live and permanent one.
314
+ *
315
+ * The IAM flow already expires its token after one hour (Better-Auth's
316
+ * `resetPasswordTokenExpiresIn`), so 60 matches the half of the framework that had it right.
317
+ *
318
+ * **Value semantics — the two ends mean different things, deliberately:**
319
+ *
320
+ * | Value | Meaning |
321
+ * |-------|---------|
322
+ * | unset | 60 minutes |
323
+ * | a positive number | that many minutes |
324
+ * | `0` | **no expiry** — restores the pre-11.38.0 behaviour |
325
+ * | negative, `NaN`, non-numeric | 60 minutes, i.e. the safe default rather than "unbounded" |
326
+ *
327
+ * `0` opting OUT while an invalid value falls back to the DEFAULT is intentional: switching the
328
+ * expiry off is a decision somebody has to state, and a typo in an environment variable must
329
+ * never be the thing that states it.
330
+ *
331
+ * **Upgrade note:** a token minted before this release carries no expiry timestamp and is treated
332
+ * as expired. Anyone holding an unredeemed reset mail must request a new one — which, for every
333
+ * project that relied on the default link, is the first one that will actually work.
334
+ *
335
+ * @default 60
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * auth: {
340
+ * passwordReset: { tokenExpiresInMinutes: 15 },
341
+ * }
342
+ * ```
343
+ */
344
+ tokenExpiresInMinutes?: number;
300
345
  }
301
346
 
302
347
  export interface IAuthLegacyEndpoints {
@@ -493,6 +538,12 @@ export interface IBetterAuthEmailVerificationConfig {
493
538
  * When not set, the verification link points directly to the backend
494
539
  * endpoint which handles verification and redirects.
495
540
  *
541
+ * Since 11.38.0 the generated link carries the recipient's address as well —
542
+ * `{callbackURL}?token=<token>&email=<address>`. The verification page needs it to offer
543
+ * "send a new email" once the token has expired, and it cannot recover the address itself:
544
+ * that value lives inside the token's JWT payload, and reading it there would mean rendering
545
+ * data from an unverified signature.
546
+ *
496
547
  * @default undefined (backend-handled verification)
497
548
  * @since 11.13.0
498
549
  */
@@ -560,9 +611,15 @@ export interface IBetterAuthEmailVerificationConfig {
560
611
  *
561
612
  * Set to `false` to keep Better-Auth's own link.
562
613
  *
614
+ * **Resolution order**, first hit wins: this option → the caller's `redirectTo` → `<appUrl>/auth/
615
+ * reset-password` → Better-Auth's own URL. `false` is a hard opt-out that a `redirectTo` does not
616
+ * override.
617
+ *
563
618
  * Note this is separate from `email.passwordResetLink`, which serves the LEGACY
564
- * `/users/password/reset-request` flow and appends the token as a path segment. Two flows, two
565
- * conventions; a project using both should point them at the same page.
619
+ * `/users/password/reset-request` flow. Both DEFAULTS point at the same page with `?token=`; what
620
+ * still differs is the fallback for a configured value without a placeholder `?token=` here, a
621
+ * path segment there. A project using both should point them at the same page, and writing
622
+ * `{token}` in both makes them identical.
566
623
  *
567
624
  * @default `<appUrl>/auth/reset-password?token={token}`
568
625
  * @since 11.38.0
@@ -2184,7 +2241,50 @@ export interface IServerOptions {
2184
2241
  mailjet?: MailjetOptions;
2185
2242
 
2186
2243
  /**
2187
- * Password reset link for email
2244
+ * Base of the link in the LEGACY password-reset mail (`/users/password/reset-request`).
2245
+ *
2246
+ * Three cases, and the difference between them is where the token lands:
2247
+ *
2248
+ * | Value | Link the recipient gets |
2249
+ * | --------------------------------------- | ---------------------------------------- |
2250
+ * | contains `{token}` | placeholder replaced, wherever it sits |
2251
+ * | set, but no `{token}` | token appended as a PATH segment |
2252
+ * | not set (the default below) | `?token=<token>`, per the default's shape |
2253
+ *
2254
+ * **Copying the default is safe; writing your own base is where it gets sharp.** The default
2255
+ * carries `{token}`, so pasting it lands in row one and behaves exactly as leaving the option
2256
+ * out. A base WITHOUT the placeholder lands in row two instead — same URL, different link. That
2257
+ * is deliberate: it is the convention the legacy flow has always used, and changing it would
2258
+ * silently break every project whose page reads a path parameter. It is also easy to walk into
2259
+ * by accident, so the framework logs a warning at boot for any configured value without a
2260
+ * placeholder. The way to silence it is to write `{token}` where you want the token, which
2261
+ * states the convention in the place it applies instead of leaving it implicit.
2262
+ *
2263
+ * Build the link with `CoreUserService.buildPasswordResetLink(token)` rather than by
2264
+ * concatenation. It returns `null` when it can resolve nothing — which is the difference
2265
+ * between sending no mail and sending one whose link reads `undefined/<token>`. The latter
2266
+ * happened in a downstream project and reached real recipients: the request succeeds, the mail
2267
+ * arrives, it looks right, and only the click reveals it, to somebody who by definition has no
2268
+ * second way in.
2269
+ *
2270
+ * Note this is separate from `betterAuth.emailVerification.passwordResetLink`, which serves the
2271
+ * IAM flow. Both DEFAULTS now point at the same page with `?token=`; what still differs is the
2272
+ * fallback for a configured value without a placeholder — path segment here, `?token=` there.
2273
+ * A project using both flows should point them at the same page, and writing `{token}` in both
2274
+ * makes them identical.
2275
+ *
2276
+ * @default `<appUrl>/auth/reset-password?token={token}`
2277
+ * @since 11.38.0
2278
+ *
2279
+ * @example
2280
+ * ```typescript
2281
+ * email: {
2282
+ * // A page that reads the token from the path. Spelling out `{token}` is what keeps the
2283
+ * // boot warning quiet — `'https://example.com/auth/reset-password'` produces the same
2284
+ * // link, but leaves the reader guessing which convention was meant.
2285
+ * passwordResetLink: 'https://example.com/auth/reset-password/{token}',
2286
+ * }
2287
+ * ```
2188
2288
  */
2189
2289
  passwordResetLink?: string;
2190
2290
 
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'crypto';
2
- import { Inject, Injectable, OnModuleDestroy, Optional } from '@nestjs/common';
2
+ import { Inject, Injectable, Logger, OnModuleDestroy, Optional } from '@nestjs/common';
3
3
  import nodemailer = require('nodemailer');
4
4
  import { Attachment } from 'nodemailer/lib/mailer';
5
5
 
6
+ import { isImpossibleSmtpTlsCombination } from '../helpers/config.helper';
6
7
  import { isNonEmptyString, isTrue, returnFalse } from '../helpers/input.helper';
7
8
  import { MailTransportOptions } from '../interfaces/server-options.interface';
8
9
  import { HUB_EMAIL_CAPTURE } from '../../modules/hub/hub.constants';
@@ -22,6 +23,11 @@ export class EmailService implements OnModuleDestroy {
22
23
  private cachedTransporter: nodemailer.Transporter | null = null;
23
24
  private cachedSmtpConfig: string | null = null;
24
25
 
26
+ protected readonly emailServiceLogger = new Logger(EmailService.name);
27
+
28
+ /** Once-per-process latch for the SMTP TLS warning — a per-send warning would be noise. */
29
+ private smtpTlsWarningEmitted = false;
30
+
25
31
  /**
26
32
  * Inject services
27
33
  */
@@ -33,6 +39,43 @@ export class EmailService implements OnModuleDestroy {
33
39
  @Optional() @Inject(HUB_EMAIL_CAPTURE) protected readonly emailCapture?: IHubEmailCapture,
34
40
  ) {}
35
41
 
42
+ /**
43
+ * Warn once when the SMTP port and TLS mode describe a connection that cannot be established.
44
+ *
45
+ * `secure: true` means implicit TLS, which only port 465 speaks. On 587 — the submission port,
46
+ * which upgrades through STARTTLS — nodemailer sends a TLS ClientHello, the server answers with
47
+ * an SMTP greeting, and OpenSSL reports `wrong version number`. This package's own `production`
48
+ * profile shipped that pair, and because authentication mail is deliberately not awaited, the
49
+ * API answered 200 while every message died in transport. It was found in production.
50
+ *
51
+ * Reported rather than corrected: a deployment may legitimately run submission on a non-standard
52
+ * port, and silently overriding an explicit setting is how the original defect stayed invisible.
53
+ * A warning names the problem without deciding it.
54
+ */
55
+ protected warnOnImpossibleSmtpTlsCombination(smtp: unknown): void {
56
+ if (this.smtpTlsWarningEmitted || typeof smtp !== 'object' || smtp === null) {
57
+ return;
58
+ }
59
+
60
+ const { port, secure } = smtp as { port?: unknown; secure?: unknown };
61
+ if (typeof port !== 'number' || typeof secure !== 'boolean') {
62
+ return;
63
+ }
64
+
65
+ if (!isImpossibleSmtpTlsCombination(port, secure)) {
66
+ return;
67
+ }
68
+
69
+ this.smtpTlsWarningEmitted = true;
70
+ this.emailServiceLogger.warn(
71
+ `SMTP is configured with port ${port} and secure: ${secure}, a combination that cannot connect. ` +
72
+ '`secure: true` starts TLS immediately, which only port 465 supports; port 587 and friends open in ' +
73
+ 'plaintext and upgrade via STARTTLS, which needs `secure: false`. Expect ' +
74
+ '"wrong version number" from OpenSSL and NO outgoing mail — silently, because authentication mail ' +
75
+ 'is not awaited. Set SMTP_SECURE=false for port 587, or SMTP_PORT=465 to keep implicit TLS.',
76
+ );
77
+ }
78
+
36
79
  onModuleDestroy(): void {
37
80
  if (this.cachedTransporter) {
38
81
  this.cachedTransporter.close();
@@ -109,6 +152,8 @@ export class EmailService implements OnModuleDestroy {
109
152
  }
110
153
  }
111
154
 
155
+ this.warnOnImpossibleSmtpTlsCombination(smtp);
156
+
112
157
  // Hub mailbox capture (Mailpit-style). Runs after templates are rendered, before the transport.
113
158
  // In capture mode it records the mail and suppresses the send (returns a jsonTransport-like ack).
114
159
  // Fully guarded: a broken mailbox hook must never break (or crash) the mail path.
@@ -177,6 +177,7 @@ export abstract class ModuleService<T extends CoreModel = any> {
177
177
  'password',
178
178
  'verificationToken',
179
179
  'passwordResetToken',
180
+ 'passwordResetTokenExpiresAt',
180
181
  'refreshTokens',
181
182
  'tempTokens',
182
183
  ]);
@@ -1691,6 +1691,18 @@ function validateConfig(
1691
1691
  case 'auto-generated':
1692
1692
  warnings.push('BETTER_AUTH: No secret configured - using auto-generated secret.');
1693
1693
  warnings.push('CONSEQUENCE: All user sessions will be invalidated on server restart!');
1694
+ // The session consequence above is transient — a restart clears it. This one is not, and it
1695
+ // surfaces long after the cause: the JWT plugin PERSISTS a `jwks` document encrypted with
1696
+ // whatever secret was active when it was created. Booting once without a secret is therefore
1697
+ // enough to leave a row that no later, real secret can decrypt. Nothing fails at the time;
1698
+ // `/iam/token` starts answering "Failed to decrypt private key" whenever somebody next asks
1699
+ // for a JWT, which on a deployment with few sign-ins can be months later. Observed in
1700
+ // production on a key written at setup time.
1701
+ warnings.push(
1702
+ 'CONSEQUENCE: a `jwks` key persisted now is encrypted with THIS throwaway secret and stays ' +
1703
+ 'unreadable once a real one is configured — /iam/token then fails with "Failed to decrypt ' +
1704
+ 'private key". Drop the `jwks` collection after setting a permanent secret.',
1705
+ );
1694
1706
  warnings.push(
1695
1707
  'FOR PRODUCTION: Set betterAuth.secret in config or provide a valid fallback secret (min 32 chars).',
1696
1708
  );
@@ -1700,6 +1712,19 @@ function validateConfig(
1700
1712
  warnings.push(
1701
1713
  'BETTER_AUTH: Using fallback secret (backwards compatible). Consider setting betterAuth.secret explicitly.',
1702
1714
  );
1715
+ // The same persistence trap as the auto-generated branch, and MORE likely to be walked into,
1716
+ // because this transition is a planned upgrade step rather than an accident: a deployment
1717
+ // runs on `jwt.secret`, the JWT plugin persists a `jwks` document encrypted with it, and
1718
+ // months later somebody "configures betterAuth properly" with a NEW value. The key is then
1719
+ // unreadable, nothing fails at the time, and `/iam/token` starts answering "Failed to
1720
+ // decrypt private key" whenever the next JWT is requested. Observed in production, where
1721
+ // cause and symptom were five weeks apart.
1722
+ warnings.push(
1723
+ 'BEFORE setting betterAuth.secret later: any `jwks` key persisted now is encrypted with THIS ' +
1724
+ 'fallback secret. Setting a DIFFERENT explicit secret makes it unreadable and /iam/token ' +
1725
+ 'then fails with "Failed to decrypt private key" — reuse the same value, or drop the `jwks` ' +
1726
+ 'collection when you change it.',
1727
+ );
1703
1728
  break;
1704
1729
  // 'explicit' - no warning needed, explicitly configured
1705
1730
  }
@@ -5,6 +5,7 @@ import { isProduction } from '../../common/helpers/logging.helper';
5
5
  import { ConfigService } from '../../common/services/config.service';
6
6
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
7
7
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
8
+ import { wrapBetterAuthErrorResponse } from './core-better-auth-error-codes.helper';
8
9
  import { runWithResetPassword } from './core-better-auth-password-reset.registry';
9
10
  import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
10
11
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
@@ -310,10 +311,16 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
310
311
  // Better-Auth invokes inside this call, and which is told WHICH user was reset but not
311
312
  // to WHAT — can read the new password and mirror it into the legacy store. The context
312
313
  // lives exactly as long as the handler call, so no other request can observe it.
313
- const response = resetPassword
314
+ const rawResponse = resetPassword
314
315
  ? await runWithResetPassword(resetPassword, () => authInstance.handler(webRequest))
315
316
  : await authInstance.handler(webRequest);
316
317
 
318
+ // The single choke point for every error exit below. Each later branch acts only on
319
+ // `response.ok`, so rewriting failures HERE reaches all of them without touching any — and
320
+ // without a second place that has to remember to do it. Successful responses are returned
321
+ // by identity, so nothing on the happy path changes shape.
322
+ const response = await wrapBetterAuthErrorResponse(rawResponse);
323
+
317
324
  this.logger.debug(`Better Auth handler response: ${response.status}`);
318
325
 
319
326
  // For passkey generate requests with DB storage, extract verificationToken and store mapping
@@ -4,7 +4,7 @@ import ejs = require('ejs');
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
 
7
- import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
7
+ import { isProductionLikeEnv, resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
8
8
  import { maskEmail } from '../../common/helpers/logging.helper';
9
9
  import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
10
10
  import { BrevoService } from '../../common/services/brevo.service';
@@ -222,7 +222,7 @@ export class CoreBetterAuthEmailVerificationService {
222
222
  try {
223
223
  // Override URL if callbackURL is configured (frontend-based verification)
224
224
  if (this.config.callbackURL) {
225
- url = this.buildFrontendVerificationUrl(token);
225
+ url = this.buildFrontendVerificationUrl(token, user.email);
226
226
  }
227
227
 
228
228
  this.logAuthUrlForDevelopment('EMAIL VERIFICATION', user.email, url);
@@ -358,6 +358,10 @@ export class CoreBetterAuthEmailVerificationService {
358
358
  const templateData = {
359
359
  appName,
360
360
  link: url,
361
+ // Better-Auth's own lifetime, NOT the legacy `auth.passwordReset.tokenExpiresInMinutes`.
362
+ // The two flows expire independently, and announcing the wrong one would be worse than
363
+ // announcing none.
364
+ linkExpiresInMinutes: this.passwordResetLinkExpiryMinutes(),
361
365
  name: user.name || user.email.split('@')[0],
362
366
  };
363
367
 
@@ -596,6 +600,27 @@ export class CoreBetterAuthEmailVerificationService {
596
600
  * `?token=`. That is what lets a page reading a PATH parameter configure
597
601
  * `https://example.com/auth/reset-password/{token}`.
598
602
  */
603
+ /**
604
+ * How long the IAM reset link stays valid, in whole minutes, for the mail to state.
605
+ *
606
+ * Read from Better-Auth's own `emailAndPassword.resetPasswordTokenExpiresIn` — expressed in
607
+ * SECONDS, defaulting to 3600 (`password.mjs`). Deliberately not the legacy
608
+ * `auth.passwordReset.tokenExpiresInMinutes`: the two flows expire independently, and a mail that
609
+ * announces the other flow's deadline is worse than one that announces none.
610
+ *
611
+ * Returns 0 for anything unusable, which the templates render as no sentence at all — silence is
612
+ * the safe failure for a deadline nobody can verify.
613
+ */
614
+ protected passwordResetLinkExpiryMinutes(): number {
615
+ const raw = this.configService.getFastButReadOnly<unknown>(
616
+ 'betterAuth.options.emailAndPassword.resetPasswordTokenExpiresIn',
617
+ );
618
+ const seconds = typeof raw === 'string' ? Number(raw) : raw;
619
+ const resolved = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? seconds : 3600;
620
+
621
+ return Math.max(1, Math.round(resolved / 60));
622
+ }
623
+
599
624
  protected buildPasswordResetUrl(options: SendPasswordResetEmailOptions): string {
600
625
  const configured = this.config.passwordResetLink;
601
626
 
@@ -606,8 +631,21 @@ export class CoreBetterAuthEmailVerificationService {
606
631
 
607
632
  let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
608
633
 
634
+ // The caller's own `redirectTo`, when it sent one, beats anything this server would guess.
635
+ // `nuxt-base-starter` sends it on every request (`forgot-password.vue` →
636
+ // `requestPasswordReset({ email, redirectTo: appUrl('/auth/reset-password', siteUrl) })`), and
637
+ // a client that names its page is a better authority on that page than a framework default.
638
+ if (!target) {
639
+ target = this.readCallbackUrlFromBetterAuthLink(options.url);
640
+ }
641
+
642
+ // The SAME resolver the legacy twin uses. Until 11.39.0 this read `appUrl` straight off the
643
+ // configuration, which meant no localhost default in local/ci/e2e, no host-split `baseUrl`
644
+ // derivation, and no `cors.deriveAppUrl` opt-out — three behaviours the other half of the same
645
+ // framework had. Two hand-maintained copies of one decision is how that happened.
646
+ const appUrl = resolveAppUrlFromConfig(this.configService);
647
+
609
648
  if (!target) {
610
- const appUrl = this.configService.getFastButReadOnly<string>('appUrl');
611
649
  if (!appUrl) {
612
650
  // Nothing to point at. Better-Auth's link at least works, which beats a guess.
613
651
  return options.url;
@@ -617,7 +655,6 @@ export class CoreBetterAuthEmailVerificationService {
617
655
 
618
656
  // Resolve a relative value against appUrl, like buildFrontendVerificationUrl does.
619
657
  if (target.startsWith('/')) {
620
- const appUrl = this.configService.getFastButReadOnly<string>('appUrl');
621
658
  if (!appUrl) {
622
659
  return options.url;
623
660
  }
@@ -633,6 +670,43 @@ export class CoreBetterAuthEmailVerificationService {
633
670
  return `${target}${separator}token=${encodeURIComponent(token)}`;
634
671
  }
635
672
 
673
+ /**
674
+ * Read the `callbackURL` out of the link Better-Auth built, i.e. the caller's own `redirectTo`.
675
+ *
676
+ * ── Why reading this back is SAFE, and why that is not obvious ──────────────────
677
+ * `redirectTo` arrives in the REQUEST BODY of `/request-password-reset`, which is unauthenticated.
678
+ * Putting a client-supplied URL into a password-reset mail would otherwise be a token-exfiltration
679
+ * vector in its purest form: an attacker requests a reset for a victim's address with
680
+ * `redirectTo: https://evil.example`, and the victim receives a genuine mail, from the real
681
+ * sender, carrying a valid token to the attacker's site.
682
+ *
683
+ * It is safe here only because Better-Auth validates the value BEFORE this runs. The endpoint
684
+ * declares `use: [originCheck((ctx) => ctx.body.redirectTo)]`, so a `redirectTo` outside
685
+ * `trustedOrigins` is rejected at request time and `sendResetPassword` is never reached. By the
686
+ * time we see it, the origin is one this deployment already trusts.
687
+ *
688
+ * That makes `trustedOrigins` load-bearing for THIS path too, not only for CORS — which is worth
689
+ * knowing before anyone widens it. `cors.allowAll` does not widen it (an origin check has no
690
+ * "allow everything" mode), but an over-broad `allowedOrigins` would.
691
+ *
692
+ * Returns `undefined` for anything unparseable or absent, so the caller falls through to its own
693
+ * resolution rather than to a half-formed URL.
694
+ */
695
+ protected readCallbackUrlFromBetterAuthLink(betterAuthUrl: string): string | undefined {
696
+ if (typeof betterAuthUrl !== 'string' || !betterAuthUrl.length) {
697
+ return undefined;
698
+ }
699
+
700
+ try {
701
+ const callbackUrl = new URL(betterAuthUrl).searchParams.get('callbackURL');
702
+ return callbackUrl?.trim().length ? callbackUrl.trim() : undefined;
703
+ } catch {
704
+ // Not a parseable absolute URL. Nothing to read, and nothing worth logging — the caller has
705
+ // two further fallbacks.
706
+ return undefined;
707
+ }
708
+ }
709
+
636
710
  /**
637
711
  * Build the frontend verification URL from the configured callbackURL and token.
638
712
  *
@@ -641,7 +715,7 @@ export class CoreBetterAuthEmailVerificationService {
641
715
  * @param token - The verification token from Better-Auth
642
716
  * @returns The full frontend URL with token query parameter
643
717
  */
644
- protected buildFrontendVerificationUrl(token: string): string {
718
+ protected buildFrontendVerificationUrl(token: string, email?: string): string {
645
719
  let baseUrl = this.config.callbackURL!;
646
720
 
647
721
  // Resolve relative paths against appUrl
@@ -652,7 +726,28 @@ export class CoreBetterAuthEmailVerificationService {
652
726
 
653
727
  // Append token as query parameter
654
728
  const separator = baseUrl.includes('?') ? '&' : '?';
655
- return `${baseUrl}${separator}token=${token}`;
729
+ let url = `${baseUrl}${separator}token=${encodeURIComponent(token)}`;
730
+
731
+ // The address travels alongside the token so the page can offer "send a new email" when the
732
+ // token turns out to be expired — which is the ONLY moment that button matters, and precisely
733
+ // the moment the page has nothing else to work with. The verification page shipped by
734
+ // nuxt-base-starter gates that button on `route.query.email`, so without this the user is told
735
+ // correctly what went wrong and left with no way to fix it.
736
+ //
737
+ // The frontend cannot recover the address itself. It is inside the token's JWT payload, and
738
+ // reading it there would mean trusting an unverified signature for display — not a pattern to
739
+ // put in a starter.
740
+ //
741
+ // The cost, stated plainly: the address appears in the URL, so it reaches browser history and
742
+ // any access log the app keeps. It is the recipient's OWN address, arriving in their own
743
+ // mailbox next to a token that is far more sensitive, so this widens nothing that the link did
744
+ // not already carry. Encoded, because a `+` in an address (Gmail tags, and therefore most test
745
+ // addresses) would otherwise arrive as a space.
746
+ if (email) {
747
+ url += `&email=${encodeURIComponent(email)}`;
748
+ }
749
+
750
+ return url;
656
751
  }
657
752
 
658
753
  /**