@lenne.tech/nest-server 11.37.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 +51 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/package-management.md +66 -0
- package/.claude/rules/testing.md +81 -5
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +5 -3
- 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 +21 -0
- package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
- package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
- package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
- package/dist/core/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/auth/core-auth.controller.js +2 -5
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +2 -0
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -5
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js +40 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +5 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
- 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/core-better-auth-password-reset.registry.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/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 +10 -1
- package/dist/core/modules/user/core-user.service.js +124 -15
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.resolver.js +2 -1
- package/dist/server/modules/user/user.resolver.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +18 -5
- 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 +49 -1
- package/migration-guides/11.37.x-to-11.38.x.md +528 -0
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/package.json +1 -1
- package/src/config.env.ts +28 -4
- 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 +326 -10
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/auth/README.md +104 -0
- package/src/core/modules/auth/core-auth.controller.ts +4 -11
- package/src/core/modules/auth/core-auth.module.ts +5 -0
- package/src/core/modules/auth/core-auth.resolver.ts +4 -11
- package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
- package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
- package/src/core/modules/better-auth/README.md +32 -59
- package/src/core/modules/better-auth/better-auth.config.ts +131 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
- package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
- 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 +389 -22
- package/src/core/modules/user/inputs/core-user.input.ts +16 -1
- package/src/core.module.ts +8 -0
- package/src/index.ts +3 -0
- package/src/server/modules/user/user.controller.ts +7 -1
- package/src/server/modules/user/user.resolver.ts +7 -1
- package/src/server/modules/user/user.service.ts +58 -13
- 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
|
@@ -53,7 +53,7 @@ function generateSecureSecret(): string {
|
|
|
53
53
|
* Hash a password using Node.js native crypto.scrypt (libuv thread pool).
|
|
54
54
|
* Output format matches Better-Auth: "salt:hash" (both hex encoded).
|
|
55
55
|
*/
|
|
56
|
-
async function nativeScryptHash(password: string): Promise<string> {
|
|
56
|
+
export async function nativeScryptHash(password: string): Promise<string> {
|
|
57
57
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
58
58
|
const normalized = password.normalize('NFKC');
|
|
59
59
|
const key = await new Promise<Buffer>((resolve, reject) => {
|
|
@@ -67,8 +67,14 @@ async function nativeScryptHash(password: string): Promise<string> {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Verify a password against a Better-Auth scrypt hash using Node.js native crypto.scrypt.
|
|
70
|
+
*
|
|
71
|
+
* Exported so tests can ask the PRODUCT whether a stored credential accepts a password, rather
|
|
72
|
+
* than re-declaring the scrypt parameters next to their assertion. A copy drifts silently: change
|
|
73
|
+
* a parameter here and the copy derives a different key, turning every affected assertion into a
|
|
74
|
+
* red with a misleading diagnosis — or, if both drift compatibly, into a green for the wrong
|
|
75
|
+
* reason.
|
|
70
76
|
*/
|
|
71
|
-
async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
|
|
77
|
+
export async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
|
|
72
78
|
const [salt, storedKey] = data.hash.split(':');
|
|
73
79
|
if (!salt || !storedKey) return false;
|
|
74
80
|
const normalized = data.password.normalize('NFKC');
|
|
@@ -128,6 +134,13 @@ export interface CreateBetterAuthOptions {
|
|
|
128
134
|
*/
|
|
129
135
|
onEmailVerified?: OnEmailVerifiedCallback;
|
|
130
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Callback invoked after a password reset has been written to the IAM credential.
|
|
139
|
+
* Injected from CoreBetterAuthModule to mirror the new password into the legacy store,
|
|
140
|
+
* so a deployment running both systems does not end up with two different passwords.
|
|
141
|
+
*/
|
|
142
|
+
onPasswordReset?: OnPasswordResetCallback;
|
|
143
|
+
|
|
131
144
|
/**
|
|
132
145
|
* Callback for sending the password-reset email.
|
|
133
146
|
* Injected from CoreBetterAuthModule to use NestJS services.
|
|
@@ -179,6 +192,51 @@ export interface CreateBetterAuthOptions {
|
|
|
179
192
|
*/
|
|
180
193
|
export type OnEmailVerifiedCallback = (userId: string) => Promise<void>;
|
|
181
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Callback for when a password reset has been APPLIED to the IAM credential.
|
|
197
|
+
*
|
|
198
|
+
* Injected from CoreBetterAuthModule so the new password can also be mirrored into the
|
|
199
|
+
* legacy bcrypt store. Better-Auth reports only WHICH user was reset — the password
|
|
200
|
+
* itself comes from `core-better-auth-password-reset.registry.ts`, which the API
|
|
201
|
+
* middleware fills for the duration of the handler call.
|
|
202
|
+
*
|
|
203
|
+
* Fires for every native reset route (token, email-OTP, phone-number).
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* Combines the framework's `onPasswordReset` with a project-supplied one so neither is lost.
|
|
207
|
+
*
|
|
208
|
+
* Returns an empty object when there is nothing to install, so the caller can spread it
|
|
209
|
+
* unconditionally without writing an `onPasswordReset: undefined` key — Better-Auth resolves
|
|
210
|
+
* the hook by presence, and an explicitly-undefined key reads as "declared, does nothing".
|
|
211
|
+
*
|
|
212
|
+
* The framework hook runs FIRST and its failure does not prevent the project hook: they are
|
|
213
|
+
* independent consequences of the same event, and one broken listener must not silently
|
|
214
|
+
* cancel the other.
|
|
215
|
+
*/
|
|
216
|
+
function chainOnPasswordReset(base: unknown, override: unknown): { onPasswordReset?: OnPasswordResetCallback } {
|
|
217
|
+
const baseHook = typeof base === 'function' ? (base as OnPasswordResetCallback) : undefined;
|
|
218
|
+
const overrideHook = typeof override === 'function' ? (override as OnPasswordResetCallback) : undefined;
|
|
219
|
+
|
|
220
|
+
if (!baseHook) {
|
|
221
|
+
return overrideHook ? { onPasswordReset: overrideHook } : {};
|
|
222
|
+
}
|
|
223
|
+
if (!overrideHook) {
|
|
224
|
+
return { onPasswordReset: baseHook };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
onPasswordReset: async (data) => {
|
|
229
|
+
const results = await Promise.allSettled([baseHook(data), overrideHook(data)]);
|
|
230
|
+
const failure = results.find((result) => result.status === 'rejected');
|
|
231
|
+
if (failure && failure.status === 'rejected') {
|
|
232
|
+
throw failure.reason;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type OnPasswordResetCallback = (data: { user: { email?: string; id: string } }) => Promise<void>;
|
|
239
|
+
|
|
182
240
|
/**
|
|
183
241
|
* Options for creating a better-auth instance
|
|
184
242
|
*/
|
|
@@ -371,8 +429,16 @@ export interface CreateBetterAuthResult {
|
|
|
371
429
|
}
|
|
372
430
|
|
|
373
431
|
export function createBetterAuthInstance(options: CreateBetterAuthOptions): CreateBetterAuthResult | null {
|
|
374
|
-
const {
|
|
375
|
-
|
|
432
|
+
const {
|
|
433
|
+
config,
|
|
434
|
+
db,
|
|
435
|
+
fallbackSecrets,
|
|
436
|
+
onEmailVerified,
|
|
437
|
+
onPasswordReset,
|
|
438
|
+
sendResetPasswordEmail,
|
|
439
|
+
sendVerificationEmail,
|
|
440
|
+
serverEnv,
|
|
441
|
+
} = options;
|
|
376
442
|
|
|
377
443
|
// Return null only if better-auth is explicitly disabled
|
|
378
444
|
// BetterAuth is enabled by default (zero-config)
|
|
@@ -517,6 +583,29 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
517
583
|
// here claimed a mail-less server would keep answering RESET_PASSWORD_DISABLED; it would not.
|
|
518
584
|
// `emailAndPassword.passwordReset: false` is the real off switch, for deployments whose reset
|
|
519
585
|
// policy is support-mediated or SSO-primary.
|
|
586
|
+
// Mirror an applied reset into the legacy bcrypt store.
|
|
587
|
+
//
|
|
588
|
+
// Without this, a deployment running Legacy Auth next to IAM keeps the OLD password
|
|
589
|
+
// valid on the legacy path after a reset — including a reset somebody performed
|
|
590
|
+
// BECAUSE the old password was compromised. Better-Auth tells us which user was
|
|
591
|
+
// reset; the password comes from the API middleware via the reset registry.
|
|
592
|
+
...(onPasswordReset && {
|
|
593
|
+
onPasswordReset: (async (data) => {
|
|
594
|
+
try {
|
|
595
|
+
await onPasswordReset(data);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
// Never fail the reset itself: the IAM credential is already written, and
|
|
598
|
+
// refusing the response would tell the user their reset did not work when it
|
|
599
|
+
// half did. Report it instead.
|
|
600
|
+
logger.error(
|
|
601
|
+
`Failed to mirror the password reset to the legacy store: ${
|
|
602
|
+
error instanceof Error ? error.message : String(error)
|
|
603
|
+
}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}) satisfies OnPasswordResetCallback,
|
|
607
|
+
}),
|
|
608
|
+
|
|
520
609
|
// Mirrors the emailVerification.sendVerificationEmail wiring.
|
|
521
610
|
...(sendResetPasswordEmail &&
|
|
522
611
|
config.emailAndPassword?.passwordReset !== false && {
|
|
@@ -606,6 +695,19 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
606
695
|
// READS. The result is an asymmetric pair — anyone who resets their password can then
|
|
607
696
|
// never sign in again. An explicit override still wins; only `undefined` is ignored.
|
|
608
697
|
password: mergeDefined(base.password, override.password),
|
|
698
|
+
// `onPasswordReset` is subject to the SAME hazard as `password` above, and needs the
|
|
699
|
+
// opposite treatment: it must not be replaceable at all.
|
|
700
|
+
//
|
|
701
|
+
// It is what mirrors an applied IAM reset into the legacy bcrypt store. A project has
|
|
702
|
+
// an entirely reasonable motive to declare its own (audit logging, a notification
|
|
703
|
+
// mail) — and the spread would then drop the framework's, silently restoring the
|
|
704
|
+
// pre-11.38.0 defect where the OLD password stays valid on the legacy path after a
|
|
705
|
+
// reset, including a reset performed BECAUSE it leaked. Both stores report success.
|
|
706
|
+
//
|
|
707
|
+
// So the two are CHAINED rather than merged: the framework mirror always runs, the
|
|
708
|
+
// project hook runs after it. That is a deliberate asymmetry to `password`, where an
|
|
709
|
+
// explicit override wins — a hasher is a choice, a credential-store mirror is not.
|
|
710
|
+
...chainOnPasswordReset(base.onPasswordReset, override.onPasswordReset),
|
|
609
711
|
};
|
|
610
712
|
}
|
|
611
713
|
if (optionsAdvanced && typeof optionsAdvanced === 'object') {
|
|
@@ -1589,6 +1691,18 @@ function validateConfig(
|
|
|
1589
1691
|
case 'auto-generated':
|
|
1590
1692
|
warnings.push('BETTER_AUTH: No secret configured - using auto-generated secret.');
|
|
1591
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
|
+
);
|
|
1592
1706
|
warnings.push(
|
|
1593
1707
|
'FOR PRODUCTION: Set betterAuth.secret in config or provide a valid fallback secret (min 32 chars).',
|
|
1594
1708
|
);
|
|
@@ -1598,6 +1712,19 @@ function validateConfig(
|
|
|
1598
1712
|
warnings.push(
|
|
1599
1713
|
'BETTER_AUTH: Using fallback secret (backwards compatible). Consider setting betterAuth.secret explicitly.',
|
|
1600
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
|
+
);
|
|
1601
1728
|
break;
|
|
1602
1729
|
// 'explicit' - no warning needed, explicitly configured
|
|
1603
1730
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { Injectable, Logger, NestMiddleware, Optional } from '@nestjs/common';
|
|
1
|
+
import { BadRequestException, Injectable, Logger, NestMiddleware, Optional } from '@nestjs/common';
|
|
2
2
|
import { Response as ExpressResponse, NextFunction, Request } from 'express';
|
|
3
3
|
|
|
4
4
|
import { isProduction } from '../../common/helpers/logging.helper';
|
|
5
5
|
import { ConfigService } from '../../common/services/config.service';
|
|
6
6
|
import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
|
|
7
7
|
import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
|
|
8
|
+
import { wrapBetterAuthErrorResponse } from './core-better-auth-error-codes.helper';
|
|
9
|
+
import { runWithResetPassword } from './core-better-auth-password-reset.registry';
|
|
10
|
+
import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
|
|
8
11
|
import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
|
|
9
12
|
import { CoreBetterAuthService } from './core-better-auth.service';
|
|
10
13
|
|
|
@@ -23,6 +26,29 @@ import { CoreBetterAuthService } from './core-better-auth.service';
|
|
|
23
26
|
*/
|
|
24
27
|
const CONTROLLER_HANDLED_PATHS = ['/features', '/sign-in/email', '/sign-up/email', '/sign-out', '/session'];
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Native Better-Auth routes that set a NEW password from a token or OTP, with the body
|
|
31
|
+
* field each of them carries it in.
|
|
32
|
+
*
|
|
33
|
+
* These are forwarded (they are not in CONTROLLER_HANDLED_PATHS), so unlike sign-in and
|
|
34
|
+
* sign-up nothing normalizes their password — Better-Auth hashes whatever arrives. That
|
|
35
|
+
* is a problem, because the sign-in path DOES normalize: a plaintext reset would be
|
|
36
|
+
* stored as `scrypt(plaintext)` while every later sign-in checks `scrypt(sha256(...))`,
|
|
37
|
+
* and the account is locked out with the password its owner just chose. Normalizing
|
|
38
|
+
* here puts every write of a credential on the same footing as every read of one.
|
|
39
|
+
*
|
|
40
|
+
* Matching is EXACT (after trimming a trailing slash and lower-casing), not prefix-based. So
|
|
41
|
+
* `/reset-password/:token` — Better-Auth's GET redirect — never matches an entry at all and is
|
|
42
|
+
* forwarded untouched. That is the intended outcome, but for this reason and not because "the
|
|
43
|
+
* body has no password field": switching the matcher to `startsWith` would start rewriting that
|
|
44
|
+
* redirect's body on the strength of a comment that was never true.
|
|
45
|
+
*/
|
|
46
|
+
export const PASSWORD_RESET_PATHS: { field: string; path: string }[] = [
|
|
47
|
+
{ field: 'newPassword', path: '/reset-password' },
|
|
48
|
+
{ field: 'password', path: '/email-otp/reset-password' },
|
|
49
|
+
{ field: 'newPassword', path: '/phone-number/reset-password' },
|
|
50
|
+
];
|
|
51
|
+
|
|
26
52
|
/**
|
|
27
53
|
* Passkey paths that generate challenges
|
|
28
54
|
*/
|
|
@@ -59,8 +85,88 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
|
|
|
59
85
|
constructor(
|
|
60
86
|
private readonly betterAuthService: CoreBetterAuthService,
|
|
61
87
|
@Optional() private readonly challengeService?: CoreBetterAuthChallengeService,
|
|
88
|
+
@Optional() private readonly userMapper?: CoreBetterAuthUserMapper,
|
|
62
89
|
) {}
|
|
63
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Brings a native reset route's password to the same shape sign-in will present, and
|
|
93
|
+
* returns that value so the legacy mirror can reuse it.
|
|
94
|
+
*
|
|
95
|
+
* Returns `undefined` when this is not a password-setting route or the body carries no
|
|
96
|
+
* password — both mean "leave the request untouched".
|
|
97
|
+
*
|
|
98
|
+
* @throws BadRequestException when a plaintext password violates the configured length policy
|
|
99
|
+
*/
|
|
100
|
+
protected normalizeResetPassword(req: Request, relativePath: string): string | undefined {
|
|
101
|
+
// Trailing slashes and case are normalized before matching. Express does not collapse a
|
|
102
|
+
// trailing slash on `originalUrl`, and Better-Auth's own router is more permissive than
|
|
103
|
+
// `===` — so a variant it accepts but this table does not would silently skip BOTH the
|
|
104
|
+
// normalization and the legacy mirror, i.e. reproduce the exact lockout this method exists
|
|
105
|
+
// to prevent, through a URL shape rather than a code change.
|
|
106
|
+
const normalizedPath = relativePath.replace(/\/+$/, '').toLowerCase();
|
|
107
|
+
const route = PASSWORD_RESET_PATHS.find((entry) => normalizedPath === entry.path);
|
|
108
|
+
if (!route) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!this.userMapper) {
|
|
113
|
+
// Fail LOUD rather than open. Today every module variant that provides this middleware
|
|
114
|
+
// also provides the mapper, so this is unreachable — but silently forwarding an
|
|
115
|
+
// un-normalized password stores `scrypt(plaintext)` against a sign-in that checks
|
|
116
|
+
// `scrypt(sha256(...))`, and the user is locked out with the password they just chose.
|
|
117
|
+
// A guard on a correctness-critical step must not degrade quietly.
|
|
118
|
+
this.logger.error(
|
|
119
|
+
`No CoreBetterAuthUserMapper available — the password on ${relativePath} is NOT normalized. ` +
|
|
120
|
+
'Better-Auth will store scrypt(plaintext) while sign-in checks scrypt(sha256(...)), ' +
|
|
121
|
+
'locking the account out with its new password.',
|
|
122
|
+
);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const submitted = req.body?.[route.field];
|
|
127
|
+
if (!submitted || typeof submitted !== 'string') {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.assertResetPasswordLength(submitted);
|
|
132
|
+
|
|
133
|
+
const normalized = this.userMapper.normalizePasswordForIam(submitted);
|
|
134
|
+
// `toWebRequest` serializes `req.body`, so writing it back here is what Better-Auth
|
|
135
|
+
// actually hashes.
|
|
136
|
+
req.body[route.field] = normalized;
|
|
137
|
+
return normalized;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Enforces the configured password length on the value the CLIENT actually sent.
|
|
142
|
+
*
|
|
143
|
+
* Better-Auth checks `minPasswordLength`/`maxPasswordLength` against the body it receives —
|
|
144
|
+
* and by then this middleware has replaced it with a 64-character sha256, so both bounds
|
|
145
|
+
* always pass. Without this check there would be no server-side minimum password length on
|
|
146
|
+
* any reset path at all.
|
|
147
|
+
*
|
|
148
|
+
* An already-hashed value carries no length information, so it cannot be checked here. That
|
|
149
|
+
* limit is real and belongs in the client; it is stated in the migration guide rather than
|
|
150
|
+
* pretended away.
|
|
151
|
+
*/
|
|
152
|
+
protected assertResetPasswordLength(submitted: string): void {
|
|
153
|
+
if (/^[a-f0-9]{64}$/i.test(submitted)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The bounds live on the Better-Auth passthrough (`betterAuth.options.emailAndPassword`),
|
|
158
|
+
// not on the framework's own typed block — so they are read defensively. The fallbacks are
|
|
159
|
+
// Better-Auth's own defaults, which is what applied before this check existed.
|
|
160
|
+
const passthrough = (this.betterAuthService.getConfig()?.options as Record<string, any> | undefined)
|
|
161
|
+
?.emailAndPassword;
|
|
162
|
+
const min = typeof passthrough?.minPasswordLength === 'number' ? passthrough.minPasswordLength : 8;
|
|
163
|
+
const max = typeof passthrough?.maxPasswordLength === 'number' ? passthrough.maxPasswordLength : 128;
|
|
164
|
+
|
|
165
|
+
if (submitted.length < min || submitted.length > max) {
|
|
166
|
+
throw new BadRequestException(`Password must be between ${min} and ${max} characters`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
64
170
|
/**
|
|
65
171
|
* Gets or creates the cookie helper instance.
|
|
66
172
|
* Lazy initialization because betterAuthService may not be fully initialized in constructor.
|
|
@@ -186,6 +292,10 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
|
|
|
186
292
|
}
|
|
187
293
|
}
|
|
188
294
|
|
|
295
|
+
// Put a reset password into the shape sign-in expects BEFORE the body is
|
|
296
|
+
// serialized, and keep the value for the legacy mirror below.
|
|
297
|
+
const resetPassword = this.normalizeResetPassword(req, relativePath);
|
|
298
|
+
|
|
189
299
|
// Convert Express request to Web Standard Request with proper cookie signing
|
|
190
300
|
const webRequest = await toWebRequest(req, {
|
|
191
301
|
basePath,
|
|
@@ -195,8 +305,21 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
|
|
|
195
305
|
sessionToken,
|
|
196
306
|
});
|
|
197
307
|
|
|
198
|
-
// Call Better Auth's native handler
|
|
199
|
-
|
|
308
|
+
// Call Better Auth's native handler.
|
|
309
|
+
//
|
|
310
|
+
// For a reset, the call is wrapped so that `emailAndPassword.onPasswordReset` — which
|
|
311
|
+
// Better-Auth invokes inside this call, and which is told WHICH user was reset but not
|
|
312
|
+
// to WHAT — can read the new password and mirror it into the legacy store. The context
|
|
313
|
+
// lives exactly as long as the handler call, so no other request can observe it.
|
|
314
|
+
const rawResponse = resetPassword
|
|
315
|
+
? await runWithResetPassword(resetPassword, () => authInstance.handler(webRequest))
|
|
316
|
+
: await authInstance.handler(webRequest);
|
|
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);
|
|
200
323
|
|
|
201
324
|
this.logger.debug(`Better Auth handler response: ${response.status}`);
|
|
202
325
|
|
|
@@ -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';
|
|
@@ -22,12 +22,12 @@ import { AuthEmailCallbackOptions, formatProjectName } from './better-auth.confi
|
|
|
22
22
|
*/
|
|
23
23
|
type ResolvedEmailVerificationConfig = Pick<
|
|
24
24
|
IBetterAuthEmailVerificationConfig,
|
|
25
|
-
'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId'
|
|
25
|
+
'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'passwordResetLink'
|
|
26
26
|
> &
|
|
27
27
|
Required<
|
|
28
28
|
Omit<
|
|
29
29
|
IBetterAuthEmailVerificationConfig,
|
|
30
|
-
'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'resendCooldownSeconds'
|
|
30
|
+
'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'passwordResetLink' | 'resendCooldownSeconds'
|
|
31
31
|
>
|
|
32
32
|
> & {
|
|
33
33
|
resendCooldownSeconds: number;
|
|
@@ -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);
|
|
@@ -328,7 +328,8 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
328
328
|
* override that calls this directly must handle it.
|
|
329
329
|
*/
|
|
330
330
|
async sendPasswordResetEmail(options: SendPasswordResetEmailOptions): Promise<void> {
|
|
331
|
-
const {
|
|
331
|
+
const { user } = options;
|
|
332
|
+
const url = this.buildPasswordResetUrl(options);
|
|
332
333
|
|
|
333
334
|
this.logAuthUrlForDevelopment('PASSWORD RESET', user.email, url);
|
|
334
335
|
|
|
@@ -357,6 +358,10 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
357
358
|
const templateData = {
|
|
358
359
|
appName,
|
|
359
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(),
|
|
360
365
|
name: user.name || user.email.split('@')[0],
|
|
361
366
|
};
|
|
362
367
|
|
|
@@ -569,6 +574,139 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
569
574
|
};
|
|
570
575
|
}
|
|
571
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Where the password-reset mail points.
|
|
579
|
+
*
|
|
580
|
+
* Defaults to the APP rather than to the link Better-Auth generates, which points at the API
|
|
581
|
+
* (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`) and redirects from there.
|
|
582
|
+
* That works, but it puts an unfamiliar domain into a password mail — the one thing recipients
|
|
583
|
+
* are trained to check. In this stack an app host and an API host are the norm, so the app is
|
|
584
|
+
* the better default.
|
|
585
|
+
*
|
|
586
|
+
* WHAT THIS GIVES UP, STATED PLAINLY
|
|
587
|
+
*
|
|
588
|
+
* Better-Auth's redirect route validates the token and its expiry before forwarding, so an
|
|
589
|
+
* expired link produced an error page rather than a form that fails on submit. Linking straight
|
|
590
|
+
* to the app moves that error later. It is NOT a security difference: the token reaches the app
|
|
591
|
+
* URL either way, and the `callbackURL` origin check only exists because of the hop it removes.
|
|
592
|
+
* `passwordResetLink: false` keeps Better-Auth's link for anyone who wants the early error.
|
|
593
|
+
*
|
|
594
|
+
* Resolution order — the first that yields something wins:
|
|
595
|
+
* 1. `betterAuth.emailVerification.passwordResetLink` (`false` → Better-Auth's own link)
|
|
596
|
+
* 2. `<appUrl>/auth/reset-password` — the starter's reset page
|
|
597
|
+
* 3. Better-Auth's link, when no app URL can be resolved
|
|
598
|
+
*
|
|
599
|
+
* `{token}` is substituted anywhere in the configured value; without it the token is appended as
|
|
600
|
+
* `?token=`. That is what lets a page reading a PATH parameter configure
|
|
601
|
+
* `https://example.com/auth/reset-password/{token}`.
|
|
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
|
+
|
|
624
|
+
protected buildPasswordResetUrl(options: SendPasswordResetEmailOptions): string {
|
|
625
|
+
const configured = this.config.passwordResetLink;
|
|
626
|
+
|
|
627
|
+
// Explicit opt-out: keep Better-Auth's link, including its token validation hop.
|
|
628
|
+
if (configured === false) {
|
|
629
|
+
return options.url;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
|
|
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
|
+
|
|
648
|
+
if (!target) {
|
|
649
|
+
if (!appUrl) {
|
|
650
|
+
// Nothing to point at. Better-Auth's link at least works, which beats a guess.
|
|
651
|
+
return options.url;
|
|
652
|
+
}
|
|
653
|
+
target = `${appUrl.replace(/\/$/, '')}/auth/reset-password`;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Resolve a relative value against appUrl, like buildFrontendVerificationUrl does.
|
|
657
|
+
if (target.startsWith('/')) {
|
|
658
|
+
if (!appUrl) {
|
|
659
|
+
return options.url;
|
|
660
|
+
}
|
|
661
|
+
target = `${appUrl.replace(/\/$/, '')}${target}`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const token = options.token;
|
|
665
|
+
if (target.includes('{token}')) {
|
|
666
|
+
return target.replace(/\{token\}/g, encodeURIComponent(token));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const separator = target.includes('?') ? '&' : '?';
|
|
670
|
+
return `${target}${separator}token=${encodeURIComponent(token)}`;
|
|
671
|
+
}
|
|
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
|
+
|
|
572
710
|
/**
|
|
573
711
|
* Build the frontend verification URL from the configured callbackURL and token.
|
|
574
712
|
*
|
|
@@ -577,7 +715,7 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
577
715
|
* @param token - The verification token from Better-Auth
|
|
578
716
|
* @returns The full frontend URL with token query parameter
|
|
579
717
|
*/
|
|
580
|
-
protected buildFrontendVerificationUrl(token: string): string {
|
|
718
|
+
protected buildFrontendVerificationUrl(token: string, email?: string): string {
|
|
581
719
|
let baseUrl = this.config.callbackURL!;
|
|
582
720
|
|
|
583
721
|
// Resolve relative paths against appUrl
|
|
@@ -588,7 +726,28 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
588
726
|
|
|
589
727
|
// Append token as query parameter
|
|
590
728
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
|
591
|
-
|
|
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;
|
|
592
751
|
}
|
|
593
752
|
|
|
594
753
|
/**
|