@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.
- package/.claude/rules/configurable-features.md +23 -1
- package/.claude/rules/package-management.md +66 -0
- package/.claude/rules/testing.md +59 -5
- package/FRAMEWORK-API.md +1 -1
- package/dist/config.env.js +4 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/config.helper.d.ts +2 -0
- package/dist/core/common/helpers/config.helper.js +18 -0
- package/dist/core/common/helpers/config.helper.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
- package/dist/core/common/helpers/cookies.helper.js +9 -0
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -0
- package/dist/core/common/helpers/input.helper.js +4 -0
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +6 -1
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/common/services/email.service.d.ts +4 -1
- package/dist/core/common/services/email.service.js +25 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/module.service.js +1 -0
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +7 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
- 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 +3 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
- 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-error-codes.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +27 -0
- package/dist/core/modules/error-code/error-codes.js +24 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/core-hub.service.js +1 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -1
- package/dist/core/modules/user/core-user.model.d.ts +1 -0
- package/dist/core/modules/user/core-user.model.js +11 -1
- package/dist/core/modules/user/core-user.model.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +9 -0
- package/dist/core/modules/user/core-user.service.js +96 -4
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.service.js +12 -6
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +12 -0
- package/dist/templates/password-reset-en.ejs +12 -0
- package/dist/templates/password-reset.ejs +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +14 -0
- package/migration-guides/11.37.x-to-11.38.x.md +18 -1
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/package.json +1 -1
- package/src/config.env.ts +19 -3
- package/src/core/common/helpers/config.helper.ts +79 -0
- package/src/core/common/helpers/cookies.helper.ts +38 -0
- package/src/core/common/helpers/input.helper.ts +37 -0
- package/src/core/common/helpers/service.helper.ts +9 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
- package/src/core/common/interfaces/server-options.interface.ts +103 -3
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/better-auth/better-auth.config.ts +25 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +55 -0
- package/src/core/modules/hub/core-hub.service.ts +1 -0
- package/src/core/modules/user/core-user.model.ts +28 -1
- package/src/core/modules/user/core-user.service.ts +267 -5
- package/src/server/modules/user/user.service.ts +26 -7
- package/src/templates/password-reset-de.ejs +12 -0
- package/src/templates/password-reset-en.ejs +12 -0
- 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([
|
|
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
|
|
@@ -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
|
|
565
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|