@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Translate Better-Auth's own error codes into nest-server's `#LTNS_XXXX:` message format.
|
|
5
|
+
*
|
|
6
|
+
* ── Why this exists ────────────────────────────────────────────────────────────
|
|
7
|
+
* Frontends in this stack translate errors by parsing the message for nest-server's marker —
|
|
8
|
+
* `useLtErrorTranslation` matches `/^#([A-Z_]+_\d+):\s*(.+)$/` and nothing else. Better-Auth
|
|
9
|
+
* answers with `{ code: 'INVALID_TOKEN', message: 'Invalid token' }`, which carries no marker, so
|
|
10
|
+
* the parser finds no code and hands the raw string through. The end user is shown English
|
|
11
|
+
* developer text — not as an edge case, but as the NORMAL outcome of every IAM error.
|
|
12
|
+
*
|
|
13
|
+
* That is felt most on the password-reset page: a link that has expired produces `INVALID_TOKEN`,
|
|
14
|
+
* and the person who already cannot sign in is told "Invalid token" in a language the rest of the
|
|
15
|
+
* product does not use.
|
|
16
|
+
*
|
|
17
|
+
* Wrapping the message here fixes it for every consumer at once — including projects that do not
|
|
18
|
+
* use `@lenne.tech/nuxt-extensions` and would otherwise each need their own code table.
|
|
19
|
+
*
|
|
20
|
+
* ── Two rules that keep this safe ──────────────────────────────────────────────
|
|
21
|
+
* 1. The original `code` field is left ALONE. Anything keying on `code` — Better-Auth's own client,
|
|
22
|
+
* a project's error branch — keeps working. Only the human-facing `message` is rewritten.
|
|
23
|
+
* 2. An unknown code is passed through UNCHANGED. A guessed mapping would show a confident,
|
|
24
|
+
* wrong sentence, which is worse than an untranslated true one. New codes are added here
|
|
25
|
+
* deliberately, not inferred.
|
|
26
|
+
*
|
|
27
|
+
* Mapping only where the meaning is unambiguous. Three deliberate absences:
|
|
28
|
+
*
|
|
29
|
+
* - `SESSION_EXPIRED` means "re-authenticate for this sensitive action", which is not what
|
|
30
|
+
* nest-server's `TOKEN_EXPIRED` ("please sign in again") tells the user to do.
|
|
31
|
+
* - `USER_NOT_FOUND` is an account-enumeration signal. Better-Auth already exposes it as a `code`,
|
|
32
|
+
* but translating it into the user's language would make the oracle friendlier and more legible
|
|
33
|
+
* — the opposite direction from the rest of this release, which spent real effort closing the
|
|
34
|
+
* legacy reset endpoint's equivalent. Left untranslated on purpose.
|
|
35
|
+
* - Anything else Better-Auth may add later. An unmapped code passes through unchanged.
|
|
36
|
+
*
|
|
37
|
+
* `PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` ARE mapped, and their reachability is worth stating
|
|
38
|
+
* because it is narrower than it looks: the lt frontend hashes with `ltSha256` before sending, so
|
|
39
|
+
* every password arrives as 64 hex characters and neither bound is crossed. They remain reachable
|
|
40
|
+
* for a client that does not hash (this is a framework, not only the lt stack) and for a project
|
|
41
|
+
* that configures `minPasswordLength` above 64 — which the middleware reads as a passthrough
|
|
42
|
+
* option, so it is a supported configuration rather than a hypothetical.
|
|
43
|
+
*/
|
|
44
|
+
const BETTER_AUTH_ERROR_CODE_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
45
|
+
EMAIL_ALREADY_VERIFIED: ErrorCode.EMAIL_ALREADY_VERIFIED,
|
|
46
|
+
EMAIL_NOT_VERIFIED: ErrorCode.EMAIL_VERIFICATION_REQUIRED,
|
|
47
|
+
INVALID_EMAIL_OR_PASSWORD: ErrorCode.INVALID_CREDENTIALS,
|
|
48
|
+
INVALID_PASSWORD: ErrorCode.INVALID_PASSWORD,
|
|
49
|
+
// Distinct codes, not LINK_INVALID_OR_EXPIRED: Better-Auth answers BOTH with HTTP 400 on
|
|
50
|
+
// `/reset-password`, so a page branching on status alone cannot tell them apart and shows "your
|
|
51
|
+
// link is dead". The user then requests a new link, pastes the same over-long passphrase from
|
|
52
|
+
// their password manager, and fails again — a closed loop that never names the cause.
|
|
53
|
+
PASSWORD_TOO_LONG: ErrorCode.PASSWORD_TOO_LONG,
|
|
54
|
+
PASSWORD_TOO_SHORT: ErrorCode.PASSWORD_TOO_SHORT,
|
|
55
|
+
// NOT ErrorCode.INVALID_TOKEN: that one is the legacy auth service's refresh/session token and
|
|
56
|
+
// reads "sign in again". Every Better-Auth INVALID_TOKEN reaches the user through a LINK in a
|
|
57
|
+
// mail — reset, verification, magic link — and telling somebody who cannot sign in to sign in is
|
|
58
|
+
// the one instruction that helps least.
|
|
59
|
+
INVALID_TOKEN: ErrorCode.LINK_INVALID_OR_EXPIRED,
|
|
60
|
+
// Also NOT ErrorCode.TOKEN_EXPIRED ("please sign in again"), for the same reason as above and
|
|
61
|
+
// with a sharper edge: Better-Auth throws TOKEN_EXPIRED in exactly ONE place — an expired
|
|
62
|
+
// verification LINK (email-verification.mjs:178) — one line above the INVALID_TOKEN it throws
|
|
63
|
+
// for a broken one. Splitting that single user action ("I clicked an old link in my mail") into
|
|
64
|
+
// two opposite instructions would be wrong in the MORE common half, since links expire far more
|
|
65
|
+
// often than they get mangled. Worse, at that point the user is typically not signed in at all,
|
|
66
|
+
// so "sign in again" is a dead end. `LINK_INVALID_OR_EXPIRED` covers expiry in its wording.
|
|
67
|
+
TOKEN_EXPIRED: ErrorCode.LINK_INVALID_OR_EXPIRED,
|
|
68
|
+
USER_ALREADY_EXISTS: ErrorCode.EMAIL_ALREADY_EXISTS,
|
|
69
|
+
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: ErrorCode.EMAIL_ALREADY_EXISTS,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The nest-server message for a Better-Auth error code, or `undefined` when it has no mapping.
|
|
74
|
+
*
|
|
75
|
+
* Exported so a project can reuse the same table rather than build a second one that drifts.
|
|
76
|
+
*/
|
|
77
|
+
export function mapBetterAuthErrorCode(code: unknown): string | undefined {
|
|
78
|
+
return typeof code === 'string' ? BETTER_AUTH_ERROR_CODE_MAP[code] : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite a failed Better-Auth response so its `message` carries nest-server's error marker.
|
|
83
|
+
*
|
|
84
|
+
* Returns the ORIGINAL response object whenever nothing should change — a successful response, a
|
|
85
|
+
* body that is not JSON, or a code with no mapping. That matters: the response body is a stream
|
|
86
|
+
* that can be read once, so handing back an untouched original rather than a rebuilt copy keeps
|
|
87
|
+
* every other consumer of it working.
|
|
88
|
+
*
|
|
89
|
+
* Never throws. An error surfacing from the error-formatting path would replace a useful message
|
|
90
|
+
* with a 500, which is the one outcome worse than an untranslated string.
|
|
91
|
+
*/
|
|
92
|
+
export async function wrapBetterAuthErrorResponse(response: Response): Promise<Response> {
|
|
93
|
+
if (response.ok) {
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A redirect is not ours to rewrite. Better-Auth reports some failures by REDIRECTING to the
|
|
98
|
+
// caller's `callbackURL` with `?error=<CODE>` (see `redirectOnError` in its email-verification
|
|
99
|
+
// route) — a 3xx with no JSON body, so there is no message to translate. Rewriting the code in
|
|
100
|
+
// that query string instead would invent a second contract on top of Better-Auth's documented
|
|
101
|
+
// one, which the frontend reads. Those cases stay untranslated on purpose, and the frontend maps
|
|
102
|
+
// `?error=` itself.
|
|
103
|
+
if (response.status >= 300 && response.status < 400) {
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const body = await response.clone().json();
|
|
109
|
+
const mapped = mapBetterAuthErrorCode(body?.code);
|
|
110
|
+
|
|
111
|
+
// No mapping, or a message that already carries the marker (a nest-server exception that
|
|
112
|
+
// travelled through Better-Auth): leave it exactly as it is.
|
|
113
|
+
if (!mapped || typeof body?.message !== 'string' || body.message.startsWith('#')) {
|
|
114
|
+
return response;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const headers = new Headers();
|
|
118
|
+
|
|
119
|
+
// `set-cookie` FIRST, and via getSetCookie/append rather than the forEach below. `forEach`
|
|
120
|
+
// yields each cookie separately and `set()` overwrites, so a response carrying two of them
|
|
121
|
+
// would keep only the last. Better-Auth CLEARS session and 2FA cookies on several failure
|
|
122
|
+
// paths — exactly the responses this function rewrites — so collapsing them leaves a stale
|
|
123
|
+
// credential in the browser. `sendWebResponse` uses getSetCookie() for the same reason.
|
|
124
|
+
for (const cookie of response.headers.getSetCookie?.() ?? []) {
|
|
125
|
+
headers.append('set-cookie', cookie);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
response.headers.forEach((value, key) => {
|
|
129
|
+
const lower = key.toLowerCase();
|
|
130
|
+
// content-length: the rewritten body has a different length, and a stale one truncates the
|
|
131
|
+
// response. set-cookie: already appended above, and `set()` here would undo that.
|
|
132
|
+
if (lower !== 'content-length' && lower !== 'set-cookie') {
|
|
133
|
+
headers.set(key, value);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return new Response(JSON.stringify({ ...body, message: mapped }), {
|
|
138
|
+
headers,
|
|
139
|
+
status: response.status,
|
|
140
|
+
statusText: response.statusText,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// Not JSON, or an unreadable body. The original is still the best answer available.
|
|
144
|
+
return response;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carries the password of an in-flight IAM password reset from the API middleware
|
|
3
|
+
* to Better-Auth's `onPasswordReset` hook.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
*
|
|
7
|
+
* A reset has to land in BOTH credential stores when Legacy Auth and IAM run next to
|
|
8
|
+
* each other: IAM keeps scrypt, Legacy keeps bcrypt, and neither hash can be derived
|
|
9
|
+
* from the other. Mirroring a reset into the legacy store therefore needs the password
|
|
10
|
+
* itself — and the two halves are known in two different places:
|
|
11
|
+
*
|
|
12
|
+
* | half | known by |
|
|
13
|
+
* |------|----------|
|
|
14
|
+
* | the new password | `CoreBetterAuthApiMiddleware`, which sees the request body |
|
|
15
|
+
* | which user it belongs to | `emailAndPassword.onPasswordReset`, which Better-Auth calls with the user |
|
|
16
|
+
*
|
|
17
|
+
* Better-Auth hands its hook `{ user }` and the original `Request`, whose body is
|
|
18
|
+
* already consumed by then, so the hook cannot recover the password on its own. The
|
|
19
|
+
* alternative — resolving the user in the middleware by parsing Better-Auth's
|
|
20
|
+
* `reset-password:<token>` verification identifier — would couple us to an internal
|
|
21
|
+
* storage format that no contract keeps stable. Using the supported hook for the user
|
|
22
|
+
* and this registry for the password keeps the only coupling inside our own code.
|
|
23
|
+
*
|
|
24
|
+
* The value stored here is ALREADY NORMALIZED (`normalizePasswordForIam`), i.e. exactly
|
|
25
|
+
* what Better-Auth hashed, so the legacy mirror cannot drift from the IAM credential
|
|
26
|
+
* even when the client posts a plaintext password.
|
|
27
|
+
*
|
|
28
|
+
* A true leaf: it imports nothing but a Node built-in, so it can never be
|
|
29
|
+
* mid-evaluation when a cycle-adjacent file reads it (see
|
|
30
|
+
* `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)").
|
|
31
|
+
*
|
|
32
|
+
* @internal Not public API — but NOT optional. Two shipped core files import it
|
|
33
|
+
* (`core-better-auth-api.middleware.ts`, `core-better-auth.module.ts`), so a vendor-mode sync that
|
|
34
|
+
* skips this file leaves an unresolvable import. `@internal` means "do not depend on this from a
|
|
35
|
+
* project", never "you may leave it out".
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
39
|
+
|
|
40
|
+
interface PasswordResetContext {
|
|
41
|
+
/**
|
|
42
|
+
* The new password as Better-Auth will hash it — already normalized.
|
|
43
|
+
*
|
|
44
|
+
* Cleared on the first read (see {@link getInFlightResetPassword}), so the property is
|
|
45
|
+
* optional after that point.
|
|
46
|
+
*/
|
|
47
|
+
normalizedPassword?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const storage = new AsyncLocalStorage<PasswordResetContext>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs `fn` with the in-flight reset password attached to the async context.
|
|
54
|
+
*
|
|
55
|
+
* Wrap ONLY the Better-Auth handler call: the context has to be alive while
|
|
56
|
+
* `onPasswordReset` runs, and dead everywhere else, so an unrelated request can never
|
|
57
|
+
* pick up a password that was not its own.
|
|
58
|
+
*
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export function runWithResetPassword<T>(normalizedPassword: string, fn: () => T): T {
|
|
62
|
+
return storage.run({ normalizedPassword }, fn);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The normalized password of the reset currently being handled, or `undefined`
|
|
67
|
+
* outside such a request.
|
|
68
|
+
*
|
|
69
|
+
* `undefined` means "not a reset we saw the body of" — never "no password". Callers
|
|
70
|
+
* must skip the mirror rather than guess, because writing a wrong bcrypt hash would
|
|
71
|
+
* lock the account out of the legacy path.
|
|
72
|
+
*
|
|
73
|
+
* @internal
|
|
74
|
+
*/
|
|
75
|
+
export function getInFlightResetPassword(): string | undefined {
|
|
76
|
+
const store = storage.getStore();
|
|
77
|
+
if (!store) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ONE-SHOT. The legacy mirror is the only legitimate reader and it reads once, so handing the
|
|
82
|
+
// value out a second time can only serve something that should not have it.
|
|
83
|
+
//
|
|
84
|
+
// Defence in depth rather than a fix for a known hole: an AsyncLocalStorage store is retained
|
|
85
|
+
// by any async resource created INSIDE the `run()` that outlives it — a timer registered during
|
|
86
|
+
// lazy plugin init, a cached promise. Such a resource would otherwise keep observing a
|
|
87
|
+
// password-equivalent from a request that finished long ago. Clearing on read bounds that to
|
|
88
|
+
// the moment the mirror actually runs.
|
|
89
|
+
const { normalizedPassword } = store;
|
|
90
|
+
store.normalizedPassword = undefined;
|
|
91
|
+
return normalizedPassword;
|
|
92
|
+
}
|
|
@@ -57,6 +57,22 @@ export interface MappedUser {
|
|
|
57
57
|
verified?: boolean;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Options for {@link CoreBetterAuthUserMapper.getMigrationStatus}.
|
|
62
|
+
*/
|
|
63
|
+
export interface GetMigrationStatusOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Whether to collect `pendingUserEmails` (up to 100 addresses).
|
|
66
|
+
*
|
|
67
|
+
* Costs two extra collection-scale queries, one of them a guaranteed COLLSCAN. Pass `false`
|
|
68
|
+
* from callers that only need the counts — they get the same numbers without the scan, and
|
|
69
|
+
* without handling addresses they were going to discard.
|
|
70
|
+
*
|
|
71
|
+
* @default true
|
|
72
|
+
*/
|
|
73
|
+
includePendingEmails?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
/**
|
|
61
77
|
* Interface for migration status result
|
|
62
78
|
*/
|
|
@@ -340,6 +356,11 @@ export class CoreBetterAuthUserMapper {
|
|
|
340
356
|
const bcryptHash = await bcrypt.hash(normalizedPassword, saltRounds);
|
|
341
357
|
|
|
342
358
|
// Update the users collection with the bcrypt hash
|
|
359
|
+
// `$or` across two different indexes (`users.email` unique, `users.iamId` sparse) forces an
|
|
360
|
+
// index-union plan rather than a single-index point lookup. Deliberate — the caller may know
|
|
361
|
+
// only one of the two — and irrelevant at password-change frequency. It would NOT be
|
|
362
|
+
// irrelevant in a bulk or migration loop; anything calling this in one should look up the
|
|
363
|
+
// user once and address it by `_id`.
|
|
343
364
|
const result = await usersCollection.updateOne(
|
|
344
365
|
{ $or: [{ email: userEmail }, { iamId: iamUserId }] },
|
|
345
366
|
{ $set: { password: bcryptHash, updatedAt: new Date() } },
|
|
@@ -976,7 +997,13 @@ export class CoreBetterAuthUserMapper {
|
|
|
976
997
|
*
|
|
977
998
|
* @returns Migration status object with counts and percentage
|
|
978
999
|
*/
|
|
979
|
-
async getMigrationStatus(): Promise<MigrationStatus> {
|
|
1000
|
+
async getMigrationStatus(options?: GetMigrationStatusOptions): Promise<MigrationStatus> {
|
|
1001
|
+
// Building `pendingUserEmails` costs two collection-scale queries, one of them a
|
|
1002
|
+
// guaranteed COLLSCAN (`$exists: false` cannot use the sparse `iamId` index) that scans
|
|
1003
|
+
// the WHOLE collection precisely in the all-migrated steady state, where it finds nothing.
|
|
1004
|
+
// A caller that only needs the counts — the boot-time deprecation warning does — should
|
|
1005
|
+
// not pay for a field it discards.
|
|
1006
|
+
const includePendingEmails = options?.includePendingEmails !== false;
|
|
980
1007
|
if (!this.connection) {
|
|
981
1008
|
this.logger.warn('No database connection available - cannot get migration status');
|
|
982
1009
|
return {
|
|
@@ -1004,10 +1031,13 @@ export class CoreBetterAuthUserMapper {
|
|
|
1004
1031
|
});
|
|
1005
1032
|
|
|
1006
1033
|
// Get unique userIds that have credential accounts
|
|
1007
|
-
|
|
1008
|
-
|
|
1034
|
+
// Counted server-side. The previous form materialized one `{_id}` object per distinct
|
|
1035
|
+
// migrated user in the Node heap only to read `.length` — an allocation that grows with
|
|
1036
|
+
// the user count, at boot, before the first request.
|
|
1037
|
+
const credentialAccountCount = await accountCollection
|
|
1038
|
+
.aggregate([{ $match: { providerId: 'credential' } }, { $group: { _id: '$userId' } }, { $count: 'count' }])
|
|
1009
1039
|
.toArray();
|
|
1010
|
-
const usersWithIamAccount =
|
|
1040
|
+
const usersWithIamAccount = credentialAccountCount[0]?.count ?? 0;
|
|
1011
1041
|
|
|
1012
1042
|
// Get users that are fully migrated (have both iamId AND credential account)
|
|
1013
1043
|
// We need to find users where iamId exists AND there's a matching account
|
|
@@ -1047,15 +1077,17 @@ export class CoreBetterAuthUserMapper {
|
|
|
1047
1077
|
// Get emails of pending users (limit to 100)
|
|
1048
1078
|
// Two-phase approach: first get users without iamId (no $lookup needed),
|
|
1049
1079
|
// then check users with iamId but missing credential account
|
|
1050
|
-
const usersWithoutIamId =
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1080
|
+
const usersWithoutIamId = !includePendingEmails
|
|
1081
|
+
? []
|
|
1082
|
+
: await usersCollection
|
|
1083
|
+
.find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
|
|
1084
|
+
.limit(100)
|
|
1085
|
+
.project({ email: 1 })
|
|
1086
|
+
.toArray();
|
|
1055
1087
|
|
|
1056
1088
|
const remaining = 100 - usersWithoutIamId.length;
|
|
1057
1089
|
let usersWithIamButNoAccount: { email?: string }[] = [];
|
|
1058
|
-
if (remaining > 0) {
|
|
1090
|
+
if (includePendingEmails && remaining > 0) {
|
|
1059
1091
|
usersWithIamButNoAccount = await usersCollection
|
|
1060
1092
|
.aggregate([
|
|
1061
1093
|
{ $match: { iamId: { $exists: true, $ne: null } } },
|
|
@@ -18,6 +18,7 @@ import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options
|
|
|
18
18
|
import { BrevoService } from '../../common/services/brevo.service';
|
|
19
19
|
import { ConfigService } from '../../common/services/config.service';
|
|
20
20
|
import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
|
|
21
|
+
import { isLegacyEndpointEnabled } from '../auth/helpers/legacy-endpoints.helper';
|
|
21
22
|
import { BetterAuthRolesGuard } from './better-auth-roles.guard';
|
|
22
23
|
import { BetterAuthTokenService } from './better-auth-token.service';
|
|
23
24
|
import {
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
BetterAuthInstance,
|
|
26
27
|
CreateBetterAuthResult,
|
|
27
28
|
createBetterAuthInstance,
|
|
29
|
+
OnPasswordResetCallback,
|
|
28
30
|
} from './better-auth.config';
|
|
29
31
|
import { DefaultBetterAuthResolver } from './better-auth.resolver';
|
|
30
32
|
import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware';
|
|
@@ -32,6 +34,7 @@ import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.ser
|
|
|
32
34
|
import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
|
|
33
35
|
import { CoreBetterAuthRateLimitMiddleware } from './core-better-auth-rate-limit.middleware';
|
|
34
36
|
import { CoreBetterAuthRateLimiter } from './core-better-auth-rate-limiter.service';
|
|
37
|
+
import { getInFlightResetPassword } from './core-better-auth-password-reset.registry';
|
|
35
38
|
import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
|
|
36
39
|
import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
|
|
37
40
|
import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
|
|
@@ -261,6 +264,11 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
261
264
|
// Static reference to email verification service for Better-Auth hooks (outside DI context)
|
|
262
265
|
private static emailVerificationService: CoreBetterAuthEmailVerificationService | null = null;
|
|
263
266
|
private static mongoConnection: Connection | null = null;
|
|
267
|
+
/**
|
|
268
|
+
* Config reference for the Better-Auth callbacks, which run OUTSIDE the DI context.
|
|
269
|
+
* Set from the same factories that build the auth instance.
|
|
270
|
+
*/
|
|
271
|
+
private static configServiceInstance: ConfigService | null = null;
|
|
264
272
|
// Safety Net: Track if forRoot() has already been called to detect duplicate registration
|
|
265
273
|
private static forRootCalled = false;
|
|
266
274
|
private static cachedDynamicModule: DynamicModule | null = null;
|
|
@@ -662,12 +670,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
662
670
|
throw new Error('MongoDB database not available');
|
|
663
671
|
}
|
|
664
672
|
|
|
673
|
+
this.configServiceInstance = configService;
|
|
674
|
+
|
|
665
675
|
// Get JWT secrets from config for backwards compatibility fallback
|
|
666
676
|
const jwtConfig = configService.get<{ refresh?: { secret?: string }; secret?: string }>('jwt');
|
|
667
677
|
const fallbackSecrets = [jwtConfig?.secret, jwtConfig?.refresh?.secret];
|
|
668
678
|
|
|
669
679
|
// Create email verification callbacks that delegate to the NestJS service
|
|
670
|
-
const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
|
|
680
|
+
const { onEmailVerified, onPasswordReset, sendResetPasswordEmail, sendVerificationEmail } =
|
|
671
681
|
this.createEmailVerificationCallbacks();
|
|
672
682
|
|
|
673
683
|
// Note: Secret validation is now handled in createBetterAuthInstance
|
|
@@ -677,6 +687,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
677
687
|
db,
|
|
678
688
|
fallbackSecrets,
|
|
679
689
|
onEmailVerified,
|
|
690
|
+
onPasswordReset,
|
|
680
691
|
sendResetPasswordEmail,
|
|
681
692
|
sendVerificationEmail,
|
|
682
693
|
});
|
|
@@ -814,8 +825,16 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
814
825
|
* They access the service via static reference since Better-Auth hooks run outside DI context.
|
|
815
826
|
* @internal
|
|
816
827
|
*/
|
|
817
|
-
|
|
828
|
+
/**
|
|
829
|
+
* `protected` rather than `private`: the callbacks it returns include `onPasswordReset`, which
|
|
830
|
+
* decides what mirroring a reset into the legacy store means for a deployment. A project with a
|
|
831
|
+
* different answer (a different store, an extra audit record, skipping it entirely) must be able
|
|
832
|
+
* to say so through the Module Inheritance Pattern rather than by reaching for
|
|
833
|
+
* `options.emailAndPassword` — which cannot replace the hook anyway, since the two are chained.
|
|
834
|
+
*/
|
|
835
|
+
protected static createEmailVerificationCallbacks(): {
|
|
818
836
|
onEmailVerified: (userId: string) => Promise<void>;
|
|
837
|
+
onPasswordReset: OnPasswordResetCallback;
|
|
819
838
|
sendResetPasswordEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
|
|
820
839
|
sendVerificationEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
|
|
821
840
|
} {
|
|
@@ -840,6 +859,47 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
840
859
|
);
|
|
841
860
|
}
|
|
842
861
|
},
|
|
862
|
+
onPasswordReset: async ({ user }) => {
|
|
863
|
+
// Mirror the new password into the legacy bcrypt store, so a deployment running
|
|
864
|
+
// Legacy Auth next to IAM does not keep the OLD password valid on the legacy path
|
|
865
|
+
// after a reset — including a reset performed BECAUSE the old one leaked.
|
|
866
|
+
//
|
|
867
|
+
// The password is not in Better-Auth's callback payload: it comes from the API
|
|
868
|
+
// middleware through the reset registry, already normalized, i.e. byte-identical
|
|
869
|
+
// to what Better-Auth just hashed for IAM.
|
|
870
|
+
// Skip entirely where there is no legacy surface to keep in step. On an IAM-only
|
|
871
|
+
// deployment the mirror would maintain a bcrypt credential nothing ever reads, and its
|
|
872
|
+
// "the legacy password now differs" warning would name a consequence that cannot occur.
|
|
873
|
+
// Gated on the same resolver the endpoints themselves use, so the two cannot drift.
|
|
874
|
+
const legacyConfig = this.configServiceInstance?.getFastButReadOnly('auth')?.legacyEndpoints;
|
|
875
|
+
if (!isLegacyEndpointEnabled(legacyConfig, 'graphql') && !isLegacyEndpointEnabled(legacyConfig, 'rest')) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const normalizedPassword = getInFlightResetPassword();
|
|
880
|
+
if (!normalizedPassword) {
|
|
881
|
+
// Not a reset whose body we saw. Skipping is the only safe answer — writing a
|
|
882
|
+
// guessed hash would lock the account out of the legacy path.
|
|
883
|
+
this.logger.debug('Password reset without an in-flight password — skipping the legacy mirror.');
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (!user?.email) {
|
|
887
|
+
this.logger.warn('Password reset without a user email — cannot mirror it to the legacy store.');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const mirrored = await this.userMapperInstance?.syncPasswordToLegacy(user.id, user.email, normalizedPassword);
|
|
892
|
+
if (mirrored) {
|
|
893
|
+
this.logger.debug(`Mirrored IAM password reset to the legacy store for ${maskEmail(user.email)}`);
|
|
894
|
+
} else {
|
|
895
|
+
// Report the miss. A silent `false` here is exactly how the opposite direction of
|
|
896
|
+
// this sync stayed broken unnoticed: the endpoint answers success either way.
|
|
897
|
+
this.logger.warn(
|
|
898
|
+
`Could not mirror the IAM password reset to the legacy store for ${maskEmail(user.email)} — ` +
|
|
899
|
+
'the legacy password now differs from the IAM credential.',
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
},
|
|
843
903
|
sendResetPasswordEmail: async (options) => {
|
|
844
904
|
// Delegate to the NestJS service
|
|
845
905
|
if (this.emailVerificationService) {
|
|
@@ -921,18 +981,20 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
921
981
|
{
|
|
922
982
|
// Inject Mongoose Connection to ensure NestJS waits for it to be ready
|
|
923
983
|
// Also inject EmailVerificationService to set static reference before Better-Auth init
|
|
924
|
-
inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService],
|
|
984
|
+
inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService, ConfigService],
|
|
925
985
|
provide: BETTER_AUTH_INSTANCE,
|
|
926
986
|
useFactory: async (
|
|
927
987
|
connection: Connection,
|
|
928
988
|
emailVerificationService: CoreBetterAuthEmailVerificationService,
|
|
989
|
+
configService: ConfigService,
|
|
929
990
|
) => {
|
|
930
991
|
// Set static references for callbacks BEFORE creating Better-Auth instance
|
|
931
992
|
this.setEmailVerificationService(emailVerificationService);
|
|
932
993
|
this.mongoConnection = connection;
|
|
994
|
+
this.configServiceInstance = configService;
|
|
933
995
|
|
|
934
996
|
// Create email verification callbacks that delegate to the NestJS service
|
|
935
|
-
const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
|
|
997
|
+
const { onEmailVerified, onPasswordReset, sendResetPasswordEmail, sendVerificationEmail } =
|
|
936
998
|
this.createEmailVerificationCallbacks();
|
|
937
999
|
|
|
938
1000
|
// Build shared instance options
|
|
@@ -940,6 +1002,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
940
1002
|
config,
|
|
941
1003
|
fallbackSecrets: options?.fallbackSecrets,
|
|
942
1004
|
onEmailVerified,
|
|
1005
|
+
onPasswordReset,
|
|
943
1006
|
sendResetPasswordEmail,
|
|
944
1007
|
sendVerificationEmail,
|
|
945
1008
|
serverAppUrl: options?.serverAppUrl,
|
|
@@ -31,6 +31,7 @@ export * from './core-better-auth-api.middleware';
|
|
|
31
31
|
export * from './core-better-auth-auth.model';
|
|
32
32
|
export * from './core-better-auth-cookie.helper';
|
|
33
33
|
export * from './core-better-auth-email-verification.service';
|
|
34
|
+
export * from './core-better-auth-error-codes.helper';
|
|
34
35
|
export * from './core-better-auth-migration-status.model';
|
|
35
36
|
export * from './core-better-auth-models';
|
|
36
37
|
export * from './core-better-auth-rate-limit.middleware';
|
|
@@ -287,6 +287,61 @@ export const LtnsErrors = {
|
|
|
287
287
|
},
|
|
288
288
|
},
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* A link the user followed from an email is invalid, already used, or expired.
|
|
292
|
+
*
|
|
293
|
+
* Deliberately SEPARATE from `INVALID_TOKEN` (LTNS_0003), which the legacy auth service uses for
|
|
294
|
+
* refresh/session tokens. Those two look alike to a developer and are opposites to a user: one
|
|
295
|
+
* means "your session ended, sign in again", the other means "the link in your mail is dead,
|
|
296
|
+
* request another". Telling somebody who cannot sign in to sign in is the worse of the two.
|
|
297
|
+
*
|
|
298
|
+
* Deliberately BROADER than `EMAIL_VERIFICATION_TOKEN_INVALID` (LTNS_0024), because the
|
|
299
|
+
* underlying `INVALID_TOKEN` from Better-Auth covers password reset, email verification and
|
|
300
|
+
* magic links alike, and cannot tell them apart.
|
|
301
|
+
*
|
|
302
|
+
* "nicht (mehr) gültig" covers unknown AND expired on purpose: Better-Auth answers both with the
|
|
303
|
+
* same code, so a message claiming to know which one would be guessing half the time.
|
|
304
|
+
*/
|
|
305
|
+
/**
|
|
306
|
+
* The submitted password is outside the configured length bounds.
|
|
307
|
+
*
|
|
308
|
+
* Separate codes rather than one "does not meet requirements", because the recipient can only
|
|
309
|
+
* act on a message that names the direction. Better-Auth answers `PASSWORD_TOO_LONG` with the
|
|
310
|
+
* same HTTP 400 it uses for an invalid token, so without a distinct code a reset page shows
|
|
311
|
+
* "this link is no longer valid" — the user requests a new link, pastes the same over-long
|
|
312
|
+
* passphrase from their password manager, and fails again. A closed loop with no hint at the
|
|
313
|
+
* cause.
|
|
314
|
+
*
|
|
315
|
+
* The numbers are Better-Auth's defaults (8 / 128) and a project may configure others; the text
|
|
316
|
+
* therefore names the common case rather than claiming to know the deployment's bounds.
|
|
317
|
+
*/
|
|
318
|
+
PASSWORD_TOO_LONG: {
|
|
319
|
+
code: 'LTNS_0028',
|
|
320
|
+
message: 'Password exceeds the maximum length',
|
|
321
|
+
translations: {
|
|
322
|
+
de: 'Das Passwort ist zu lang. Erlaubt sind standardmäßig höchstens 128 Zeichen.',
|
|
323
|
+
en: 'The password is too long. The default maximum is 128 characters.',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
PASSWORD_TOO_SHORT: {
|
|
328
|
+
code: 'LTNS_0029',
|
|
329
|
+
message: 'Password is below the minimum length',
|
|
330
|
+
translations: {
|
|
331
|
+
de: 'Das Passwort ist zu kurz. Erforderlich sind standardmäßig mindestens 8 Zeichen.',
|
|
332
|
+
en: 'The password is too short. The default minimum is 8 characters.',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
LINK_INVALID_OR_EXPIRED: {
|
|
337
|
+
code: 'LTNS_0027',
|
|
338
|
+
message: 'Link is invalid or expired',
|
|
339
|
+
translations: {
|
|
340
|
+
de: 'Dieser Link ist nicht (mehr) gültig. Bitte fordern Sie einen neuen an.',
|
|
341
|
+
en: 'This link is no longer valid. Please request a new one.',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
|
|
290
345
|
// =====================================================
|
|
291
346
|
// Authorization Errors (LTNS_0100-LTNS_0199)
|
|
292
347
|
// =====================================================
|
|
@@ -95,11 +95,38 @@ export abstract class CoreUserModel extends CorePersistenceModel {
|
|
|
95
95
|
*/
|
|
96
96
|
@UnifiedField({
|
|
97
97
|
isOptional: true,
|
|
98
|
-
|
|
98
|
+
// PARTIAL, not plain and not sparse. `findOne({ passwordResetToken: token })` runs on an
|
|
99
|
+
// unauthenticated, unrated endpoint, and without an index every call is a full collection scan
|
|
100
|
+
// of `users` — the dominant cost of that path and an easy amplifier for a request loop.
|
|
101
|
+
// Sparse would be wrong: the reset path clears the field by UNSETTING it, but a document that
|
|
102
|
+
// once held `null` would still count as present, so the index would accumulate an entry for
|
|
103
|
+
// every user who ever requested a reset. The `$type: 'string'` predicate keeps it to the rows
|
|
104
|
+
// that can actually match.
|
|
105
|
+
//
|
|
106
|
+
// NEVER give the companion `passwordResetTokenExpiresAt` a TTL index: it is a Date, and a TTL
|
|
107
|
+
// index there would delete the USER DOCUMENT rather than the token.
|
|
108
|
+
mongoose: { index: { partialFilterExpression: { passwordResetToken: { $type: 'string' } } } },
|
|
99
109
|
roles: RoleEnum.S_NO_ONE,
|
|
100
110
|
})
|
|
101
111
|
passwordResetToken: string = undefined;
|
|
102
112
|
|
|
113
|
+
/**
|
|
114
|
+
* When the current password-reset token stops being valid.
|
|
115
|
+
*
|
|
116
|
+
* `S_NO_ONE` like the token itself: it never belongs in a response, and revealing that a reset is
|
|
117
|
+
* pending is information an account holder gains nothing from and an attacker does.
|
|
118
|
+
*
|
|
119
|
+
* A user document written before 11.38.0 has no value here, and the reset path treats that as
|
|
120
|
+
* expired — see `IAuthPasswordReset.tokenExpiresInMinutes` for why that direction was chosen.
|
|
121
|
+
*/
|
|
122
|
+
@UnifiedField({
|
|
123
|
+
isOptional: true,
|
|
124
|
+
mongoose: true,
|
|
125
|
+
roles: RoleEnum.S_NO_ONE,
|
|
126
|
+
type: () => Date,
|
|
127
|
+
})
|
|
128
|
+
passwordResetTokenExpiresAt: Date = undefined;
|
|
129
|
+
|
|
103
130
|
/**
|
|
104
131
|
* Refresh tokens (for devices)
|
|
105
132
|
* key: Token
|