@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
|
@@ -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
|
|
@@ -118,6 +118,13 @@ export interface IAuth {
|
|
|
118
118
|
*/
|
|
119
119
|
preventUserEnumeration?: boolean;
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Password-reset request behaviour.
|
|
123
|
+
*
|
|
124
|
+
* @since 11.38.0
|
|
125
|
+
*/
|
|
126
|
+
passwordReset?: IAuthPasswordReset;
|
|
127
|
+
|
|
121
128
|
/**
|
|
122
129
|
* Rate limiting configuration for Legacy Auth endpoints
|
|
123
130
|
*
|
|
@@ -154,23 +161,211 @@ export interface IAuth {
|
|
|
154
161
|
* @since 11.7.1
|
|
155
162
|
* @see https://github.com/lenneTech/nest-server/blob/develop/.claude/rules/module-deprecation.md
|
|
156
163
|
*/
|
|
164
|
+
/**
|
|
165
|
+
* Interface for the legacy password-reset request endpoint
|
|
166
|
+
*
|
|
167
|
+
* @since 11.38.0
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* Browser security headers set on every response.
|
|
171
|
+
*
|
|
172
|
+
* @since 11.38.0
|
|
173
|
+
*/
|
|
174
|
+
export interface ISecurityHeaders {
|
|
175
|
+
/**
|
|
176
|
+
* `Content-Security-Policy` value.
|
|
177
|
+
*
|
|
178
|
+
* **Opt-in on purpose — there is no safe default.** A CSP that does not match what the
|
|
179
|
+
* application loads breaks it rather than hardening it, and only the project knows that. This
|
|
180
|
+
* package additionally serves its own HTML from two surfaces (the Hub and the GraphQL
|
|
181
|
+
* playground), so any value shipped here would be wrong for somebody.
|
|
182
|
+
*
|
|
183
|
+
* @default undefined (no header)
|
|
184
|
+
*/
|
|
185
|
+
contentSecurityPolicy?: string;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `X-Content-Type-Options: nosniff` — stops a browser from re-interpreting a response as a
|
|
189
|
+
* type the server did not declare, which is what turns an uploaded file into a script.
|
|
190
|
+
*
|
|
191
|
+
* @default true
|
|
192
|
+
*/
|
|
193
|
+
contentTypeOptions?: boolean;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether the headers are set at all. `false` disables the middleware entirely.
|
|
197
|
+
*
|
|
198
|
+
* @default true
|
|
199
|
+
*/
|
|
200
|
+
enabled?: boolean;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `X-Frame-Options` — clickjacking protection. `false` omits the header.
|
|
204
|
+
*
|
|
205
|
+
* @default 'DENY'
|
|
206
|
+
*/
|
|
207
|
+
frameOptions?: 'DENY' | 'SAMEORIGIN' | false;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `Strict-Transport-Security`. `false` omits it.
|
|
211
|
+
*
|
|
212
|
+
* **Only ever sent on a request that actually arrived over HTTPS** — decided by
|
|
213
|
+
* `x-forwarded-proto`, falling back to the connection protocol, and never by configuration.
|
|
214
|
+
* A browser REMEMBERS this header: one sent from a dev server over `http://localhost` makes
|
|
215
|
+
* every project on that host unreachable over http for up to a year, with no way to undo it
|
|
216
|
+
* from the server. So it must not be possible to switch it on where it does not belong.
|
|
217
|
+
*
|
|
218
|
+
* Note this makes `trustProxy` load-bearing here too: behind a TLS-terminating proxy the
|
|
219
|
+
* connection reaching Node is plain http, so without a trusted forwarded header no production
|
|
220
|
+
* request looks secure and no HSTS is sent.
|
|
221
|
+
*
|
|
222
|
+
* @default `{ includeSubDomains: true, maxAge: 31536000, preload: false }`
|
|
223
|
+
*/
|
|
224
|
+
hsts?: boolean | ISecurityHstsOptions;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `Referrer-Policy` — how much of the current URL travels to a third party. `false` omits it.
|
|
228
|
+
*
|
|
229
|
+
* @default 'strict-origin-when-cross-origin'
|
|
230
|
+
*/
|
|
231
|
+
referrerPolicy?: false | string;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether to strip Express's `X-Powered-By`, which names the stack to an attacker and buys
|
|
235
|
+
* nothing.
|
|
236
|
+
*
|
|
237
|
+
* @default true
|
|
238
|
+
*/
|
|
239
|
+
removePoweredBy?: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `Strict-Transport-Security` directives.
|
|
244
|
+
*
|
|
245
|
+
* @since 11.38.0
|
|
246
|
+
*/
|
|
247
|
+
export interface ISecurityHstsOptions {
|
|
248
|
+
/** Apply to every subdomain. @default true */
|
|
249
|
+
includeSubDomains?: boolean;
|
|
250
|
+
|
|
251
|
+
/** Lifetime in seconds. @default 31536000 (one year) */
|
|
252
|
+
maxAge?: number;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Add the `preload` directive.
|
|
256
|
+
*
|
|
257
|
+
* **Off by default.** It is a submission to a browser-vendor list that is slow and awkward to
|
|
258
|
+
* reverse and commits every subdomain — a decision for whoever owns the domain, not a framework
|
|
259
|
+
* default.
|
|
260
|
+
*
|
|
261
|
+
* @default false
|
|
262
|
+
*/
|
|
263
|
+
preload?: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface IAuthPasswordReset {
|
|
267
|
+
/**
|
|
268
|
+
* Whether `POST /users/password/reset-request` answers identically for a known and an unknown
|
|
269
|
+
* address.
|
|
270
|
+
*
|
|
271
|
+
* **Defaults to `true`** — the safe answer. Set `false` only to restore the previous behaviour,
|
|
272
|
+
* where an unknown address produced HTTP 404 and a known one HTTP 201: a working oracle for
|
|
273
|
+
* "does this person have an account here", which matters most in a multi-tenant product, where
|
|
274
|
+
* it also answers "who works at which customer".
|
|
275
|
+
*
|
|
276
|
+
* Deliberately NOT the same switch as {@link IAuth.preventUserEnumeration}, which governs the
|
|
277
|
+
* SIGN-IN messages and defaults to `false` for backward compatibility. Two settings because the
|
|
278
|
+
* trade-offs differ: at sign-in the distinction is genuine UX ("wrong password" vs "unknown
|
|
279
|
+
* address"), while a reset request carries no password at all — the only thing given up here is
|
|
280
|
+
* the form's ability to say "we do not know this address".
|
|
281
|
+
*
|
|
282
|
+
* **The status code is the smaller half.** The response TIME also distinguishes the two cases:
|
|
283
|
+
* the known path writes a token and sends mail, the unknown path returns immediately. This
|
|
284
|
+
* option equalises what the framework controls, but a project whose `sendPasswordResetMail()`
|
|
285
|
+
* AWAITS the mail send still leaks the difference — the send is a network round trip, orders of
|
|
286
|
+
* magnitude above anything else in the request. See `src/server/modules/user/user.service.ts`
|
|
287
|
+
* for the reference implementation, which does not await it, and the 11.38.x migration guide.
|
|
288
|
+
*
|
|
289
|
+
* @default true
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```typescript
|
|
293
|
+
* auth: {
|
|
294
|
+
* // Restore the pre-11.38.0 behaviour — the reset form can then say "unknown address"
|
|
295
|
+
* passwordReset: { preventUserEnumeration: false },
|
|
296
|
+
* }
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
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;
|
|
345
|
+
}
|
|
346
|
+
|
|
157
347
|
export interface IAuthLegacyEndpoints {
|
|
158
348
|
/**
|
|
159
349
|
* Whether legacy auth endpoints are enabled.
|
|
160
350
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
351
|
+
* **Since 11.38.0 this defaults to `false`.** Legacy Auth is superseded by IAM
|
|
352
|
+
* (Better-Auth) and slated for removal. A project that registers the legacy module
|
|
353
|
+
* but never made a decision here used to keep a second, fully functional
|
|
354
|
+
* password-authentication surface open indefinitely; it now has to be asked for.
|
|
355
|
+
* Disabled endpoints answer HTTP 410 Gone.
|
|
163
356
|
*
|
|
164
|
-
*
|
|
357
|
+
* Set `true` while users are still migrating. `betterAuthMigrationStatus` →
|
|
358
|
+
* `canDisableLegacyAuth` says when it can go back off; the boot log reports the same
|
|
359
|
+
* figure on every start.
|
|
165
360
|
*
|
|
166
361
|
* **Environment Variable:** `LEGACY_AUTH_ENABLED`
|
|
167
362
|
*
|
|
168
|
-
* @default true
|
|
363
|
+
* @default false (was `true` before 11.38.0)
|
|
169
364
|
*
|
|
170
365
|
* @example
|
|
171
366
|
* ```typescript
|
|
172
|
-
* // Via environment variable
|
|
173
|
-
* enabled: process.env.LEGACY_AUTH_ENABLED
|
|
367
|
+
* // Via environment variable — note `=== 'true'`, not `!== 'false'`
|
|
368
|
+
* enabled: process.env.LEGACY_AUTH_ENABLED === 'true',
|
|
174
369
|
* ```
|
|
175
370
|
*/
|
|
176
371
|
enabled?: boolean;
|
|
@@ -179,15 +374,21 @@ export interface IAuthLegacyEndpoints {
|
|
|
179
374
|
* Whether legacy GraphQL auth endpoints are enabled.
|
|
180
375
|
* Affects: signIn, signUp, signOut, refreshToken mutations
|
|
181
376
|
*
|
|
182
|
-
*
|
|
377
|
+
* Overrides `enabled` for this transport — except that an explicit
|
|
378
|
+
* `enabled: false` stays a hard off switch and cannot be reopened here.
|
|
379
|
+
*
|
|
380
|
+
* @default false (inherits from `enabled`, which defaults to false since 11.38.0)
|
|
183
381
|
*/
|
|
184
382
|
graphql?: boolean;
|
|
185
383
|
|
|
186
384
|
/**
|
|
187
385
|
* Whether legacy REST auth endpoints are enabled.
|
|
188
|
-
* Affects: /auth/
|
|
386
|
+
* Affects: /auth/signin, /auth/signup, etc.
|
|
387
|
+
*
|
|
388
|
+
* Overrides `enabled` for this transport — except that an explicit
|
|
389
|
+
* `enabled: false` stays a hard off switch and cannot be reopened here.
|
|
189
390
|
*
|
|
190
|
-
* @default
|
|
391
|
+
* @default false (inherits from `enabled`, which defaults to false since 11.38.0)
|
|
191
392
|
*/
|
|
192
393
|
rest?: boolean;
|
|
193
394
|
}
|
|
@@ -337,6 +538,12 @@ export interface IBetterAuthEmailVerificationConfig {
|
|
|
337
538
|
* When not set, the verification link points directly to the backend
|
|
338
539
|
* endpoint which handles verification and redirects.
|
|
339
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
|
+
*
|
|
340
547
|
* @default undefined (backend-handled verification)
|
|
341
548
|
* @since 11.13.0
|
|
342
549
|
*/
|
|
@@ -378,6 +585,57 @@ export interface IBetterAuthEmailVerificationConfig {
|
|
|
378
585
|
*/
|
|
379
586
|
passwordResetBrevoTemplateId?: number;
|
|
380
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Where the password-reset mail sends the recipient.
|
|
590
|
+
*
|
|
591
|
+
* **Defaults to the APP**, derived as `<appUrl>/auth/reset-password?token=<token>` — the path the
|
|
592
|
+
* starter's reset page lives at.
|
|
593
|
+
*
|
|
594
|
+
* Without this, the link is the one Better-Auth generates, and that points at the **API**
|
|
595
|
+
* (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`), which then redirects to
|
|
596
|
+
* the app. That works, but it puts a domain the recipient does not recognise into a password
|
|
597
|
+
* mail — precisely what people are trained to be suspicious of. In this stack an app and an API
|
|
598
|
+
* host are the norm, so the app is the better default.
|
|
599
|
+
*
|
|
600
|
+
* **What the API hop did, and what you give up.** Better-Auth's redirect route validates the
|
|
601
|
+
* token and its expiry before forwarding, so an expired link produced an error page instead of a
|
|
602
|
+
* form that fails on submit. Linking straight to the app moves that error later, to the moment
|
|
603
|
+
* the new password is submitted. It is NOT a security difference — the token ends up in the app
|
|
604
|
+
* URL either way, and the `callbackURL` origin check only exists because of the hop. If you want
|
|
605
|
+
* the early error back, either leave this unset or have the reset page validate the token before
|
|
606
|
+
* rendering.
|
|
607
|
+
*
|
|
608
|
+
* `{token}` is substituted anywhere in the value. Without the placeholder, `?token=<token>` is
|
|
609
|
+
* appended — so a page that reads a PATH parameter configures
|
|
610
|
+
* `https://example.com/auth/reset-password/{token}`.
|
|
611
|
+
*
|
|
612
|
+
* Set to `false` to keep Better-Auth's own link.
|
|
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
|
+
*
|
|
618
|
+
* Note this is separate from `email.passwordResetLink`, which serves the LEGACY
|
|
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.
|
|
623
|
+
*
|
|
624
|
+
* @default `<appUrl>/auth/reset-password?token={token}`
|
|
625
|
+
* @since 11.38.0
|
|
626
|
+
*
|
|
627
|
+
* @example
|
|
628
|
+
* ```typescript
|
|
629
|
+
* betterAuth: {
|
|
630
|
+
* emailVerification: {
|
|
631
|
+
* // A page that reads the token from the path
|
|
632
|
+
* passwordResetLink: 'https://example.com/auth/reset-password/{token}',
|
|
633
|
+
* },
|
|
634
|
+
* }
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
passwordResetLink?: false | string;
|
|
638
|
+
|
|
381
639
|
/**
|
|
382
640
|
* Cooldown in seconds between resend requests for the same email address.
|
|
383
641
|
* Prevents abuse by limiting how often verification emails can be resent.
|
|
@@ -1983,7 +2241,50 @@ export interface IServerOptions {
|
|
|
1983
2241
|
mailjet?: MailjetOptions;
|
|
1984
2242
|
|
|
1985
2243
|
/**
|
|
1986
|
-
*
|
|
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
|
+
* ```
|
|
1987
2288
|
*/
|
|
1988
2289
|
passwordResetLink?: string;
|
|
1989
2290
|
|
|
@@ -2643,6 +2944,21 @@ export interface IServerOptions {
|
|
|
2643
2944
|
*
|
|
2644
2945
|
* @since 11.18.0
|
|
2645
2946
|
*/
|
|
2947
|
+
/**
|
|
2948
|
+
* Browser security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy).
|
|
2949
|
+
*
|
|
2950
|
+
* Set on EVERY response, including the ones a guard rejects — they are registered as
|
|
2951
|
+
* middleware, so a `401` carries them too.
|
|
2952
|
+
*
|
|
2953
|
+
* `true` / `{}` / omitted → enabled with the safe defaults. `false` → no headers at all.
|
|
2954
|
+
* A reverse proxy setting the same headers wins, because it writes last; this layer exists so
|
|
2955
|
+
* that a deployment without one, or a local instance, is not bare.
|
|
2956
|
+
*
|
|
2957
|
+
* @default true
|
|
2958
|
+
* @since 11.38.0
|
|
2959
|
+
*/
|
|
2960
|
+
headers?: boolean | ISecurityHeaders;
|
|
2961
|
+
|
|
2646
2962
|
responseModelInterceptor?:
|
|
2647
2963
|
| boolean
|
|
2648
2964
|
| {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import { Response as ExpressResponse, NextFunction, Request } from 'express';
|
|
3
|
+
|
|
4
|
+
import { ISecurityHeaders } from '../interfaces/server-options.interface';
|
|
5
|
+
import { ConfigService } from '../services/config.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sets the standard browser security headers on every response.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS IS IN THE FRAMEWORK AND NOT IN EACH PROJECT
|
|
11
|
+
*
|
|
12
|
+
* The same header set was being rebuilt by hand across the stack — one project inline in
|
|
13
|
+
* `main.ts`, another as its own middleware, a third about to. A freshly generated project sent
|
|
14
|
+
* none at all. Anything rebuilt per project drifts per project, and a header that is missing
|
|
15
|
+
* produces no error anywhere: it shows up in a pentest report months later.
|
|
16
|
+
*
|
|
17
|
+
* Putting it here also reaches EXISTING projects on their next update, which a change in the
|
|
18
|
+
* starter would not.
|
|
19
|
+
*
|
|
20
|
+
* ORDER MATTERS: THIS IS MIDDLEWARE, NOT AN INTERCEPTOR
|
|
21
|
+
*
|
|
22
|
+
* Registered through `CoreModule.configure()`, so it runs before guards and before the handler.
|
|
23
|
+
* That is deliberate — a `401` from a roles guard is still a response a browser renders, and it
|
|
24
|
+
* has to carry the headers too. An interceptor would miss every request rejected before the
|
|
25
|
+
* handler, which is precisely the set an attacker is most likely to be generating.
|
|
26
|
+
*
|
|
27
|
+
* NOT A REPLACEMENT FOR THE EDGE
|
|
28
|
+
*
|
|
29
|
+
* A reverse proxy (Traefik, Caddy, nginx) can set the same headers, and where one does, its
|
|
30
|
+
* values win — the proxy writes last. That is fine and intended: this layer exists so that a
|
|
31
|
+
* deployment WITHOUT such a proxy, or a local instance, is not bare. Defense in depth, not a
|
|
32
|
+
* turf claim.
|
|
33
|
+
*/
|
|
34
|
+
@Injectable()
|
|
35
|
+
export class SecurityHeadersMiddleware implements NestMiddleware {
|
|
36
|
+
constructor(protected readonly configService: ConfigService) {}
|
|
37
|
+
|
|
38
|
+
use(req: Request, res: ExpressResponse, next: NextFunction): void {
|
|
39
|
+
const config = this.resolveConfig();
|
|
40
|
+
if (!config) {
|
|
41
|
+
return next();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Express advertises itself by default. It tells an attacker which stack to target and buys
|
|
45
|
+
// nothing; `app.disable('x-powered-by')` would work too, but doing it here keeps the whole
|
|
46
|
+
// header policy in one readable place.
|
|
47
|
+
if (config.removePoweredBy) {
|
|
48
|
+
res.removeHeader('X-Powered-By');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (config.contentTypeOptions) {
|
|
52
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (config.frameOptions) {
|
|
56
|
+
res.setHeader('X-Frame-Options', config.frameOptions);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (config.referrerPolicy) {
|
|
60
|
+
res.setHeader('Referrer-Policy', config.referrerPolicy);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (config.contentSecurityPolicy) {
|
|
64
|
+
res.setHeader('Content-Security-Policy', config.contentSecurityPolicy);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config.hsts && this.isSecureRequest(req)) {
|
|
68
|
+
const directives = [`max-age=${config.hsts.maxAge}`];
|
|
69
|
+
if (config.hsts.includeSubDomains) {
|
|
70
|
+
directives.push('includeSubDomains');
|
|
71
|
+
}
|
|
72
|
+
if (config.hsts.preload) {
|
|
73
|
+
directives.push('preload');
|
|
74
|
+
}
|
|
75
|
+
res.setHeader('Strict-Transport-Security', directives.join('; '));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
next();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether this request actually arrived over HTTPS.
|
|
83
|
+
*
|
|
84
|
+
* **HSTS is decided by the protocol, never by configuration or environment.** A browser
|
|
85
|
+
* remembers the header: one `Strict-Transport-Security` sent from a dev server over
|
|
86
|
+
* `http://localhost` makes EVERY project on that host unreachable over http for up to a year,
|
|
87
|
+
* and there is no way to take it back from the server side. So an env flag must not be able to
|
|
88
|
+
* turn it on where it does not belong — the request itself has to say so.
|
|
89
|
+
*
|
|
90
|
+
* `x-forwarded-proto` is read first because in every deployment that terminates TLS at a proxy
|
|
91
|
+
* the connection reaching Node is plain http, and `req.secure` alone would answer "no" for
|
|
92
|
+
* every production request. That header is only trustworthy behind a proxy, which is what
|
|
93
|
+
* `trustProxy` exists to declare — and Express only populates `req.secure` from it when
|
|
94
|
+
* `trust proxy` is set, so both halves agree on the same configuration.
|
|
95
|
+
*/
|
|
96
|
+
protected isSecureRequest(req: Request): boolean {
|
|
97
|
+
const forwarded = req.headers['x-forwarded-proto'];
|
|
98
|
+
if (typeof forwarded === 'string' && forwarded.length) {
|
|
99
|
+
// A proxy chain may append: "https,http". The client-facing hop is the first entry.
|
|
100
|
+
return forwarded.split(',')[0].trim().toLowerCase() === 'https';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return req.secure === true || req.protocol === 'https';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolves the effective policy, or `null` when the feature is switched off.
|
|
108
|
+
*
|
|
109
|
+
* Safe by default: an unconfigured project gets the full set except CSP. CSP is opt-in because
|
|
110
|
+
* a wrong one breaks the application rather than hardening it, and a correct one depends on
|
|
111
|
+
* what the project loads — this package cannot know that, and two of its own surfaces (the Hub
|
|
112
|
+
* and the GraphQL playground) serve their own HTML.
|
|
113
|
+
*/
|
|
114
|
+
protected resolveConfig():
|
|
115
|
+
| null
|
|
116
|
+
| (Required<Omit<ISecurityHeaders, 'contentSecurityPolicy' | 'enabled' | 'hsts'>> & {
|
|
117
|
+
contentSecurityPolicy?: string;
|
|
118
|
+
hsts: null | { includeSubDomains: boolean; maxAge: number; preload: boolean };
|
|
119
|
+
}) {
|
|
120
|
+
const raw = this.configService.getFastButReadOnly('security')?.headers;
|
|
121
|
+
|
|
122
|
+
if (raw === false) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const options: ISecurityHeaders = raw === true || raw === undefined ? {} : raw;
|
|
127
|
+
if (options.enabled === false) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hsts =
|
|
132
|
+
options.hsts === false ? null : options.hsts === true || options.hsts === undefined ? {} : options.hsts;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
contentSecurityPolicy: options.contentSecurityPolicy,
|
|
136
|
+
contentTypeOptions: options.contentTypeOptions !== false,
|
|
137
|
+
frameOptions: options.frameOptions === false ? undefined : (options.frameOptions ?? 'DENY'),
|
|
138
|
+
hsts: hsts
|
|
139
|
+
? {
|
|
140
|
+
includeSubDomains: hsts.includeSubDomains !== false,
|
|
141
|
+
// One year. The value the major preload lists require, and long enough that it is a
|
|
142
|
+
// decision rather than a formality.
|
|
143
|
+
maxAge: typeof hsts.maxAge === 'number' ? hsts.maxAge : 31536000,
|
|
144
|
+
// NOT on by default: `preload` is a submission to a browser-vendor list that is
|
|
145
|
+
// slow and awkward to reverse, and it commits every subdomain. That belongs to
|
|
146
|
+
// whoever owns the domain, not to a framework default.
|
|
147
|
+
preload: hsts.preload === true,
|
|
148
|
+
}
|
|
149
|
+
: null,
|
|
150
|
+
referrerPolicy:
|
|
151
|
+
options.referrerPolicy === false ? undefined : (options.referrerPolicy ?? 'strict-origin-when-cross-origin'),
|
|
152
|
+
removePoweredBy: options.removePoweredBy !== false,
|
|
153
|
+
} as any;
|
|
154
|
+
}
|
|
155
|
+
}
|