@lenne.tech/nest-server 11.36.5 → 11.38.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/better-auth.md +32 -0
- package/.claude/rules/configurable-features.md +29 -0
- package/.claude/rules/framework-compatibility.md +1 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/testing.md +26 -4
- package/.claude/rules/versioning.md +6 -0
- package/CLAUDE.md +22 -5
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +1 -1
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +20 -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/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 +33 -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 +51 -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 +2 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
- 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-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 +15 -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.constants.d.ts +4 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.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/core-better-auth.service.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +3 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +1 -1
- package/dist/core/modules/user/core-user.service.js +29 -12
- 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/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- 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 +8 -1
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +35 -1
- package/migration-guides/11.36.x-to-11.37.0.md +344 -0
- package/migration-guides/11.37.x-to-11.38.x.md +511 -0
- package/package.json +17 -4
- package/src/config.env.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +225 -9
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -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 +46 -1
- package/src/core/modules/better-auth/README.md +96 -59
- package/src/core/modules/better-auth/better-auth.config.ts +106 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
- 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 +49 -10
- package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
- package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
- package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
- package/src/core/modules/system-setup/core-system-setup.service.ts +33 -5
- package/src/core/modules/user/core-user.service.ts +123 -18
- 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 +35 -9
|
@@ -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,166 @@ 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
|
+
|
|
157
302
|
export interface IAuthLegacyEndpoints {
|
|
158
303
|
/**
|
|
159
304
|
* Whether legacy auth endpoints are enabled.
|
|
160
305
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
306
|
+
* **Since 11.38.0 this defaults to `false`.** Legacy Auth is superseded by IAM
|
|
307
|
+
* (Better-Auth) and slated for removal. A project that registers the legacy module
|
|
308
|
+
* but never made a decision here used to keep a second, fully functional
|
|
309
|
+
* password-authentication surface open indefinitely; it now has to be asked for.
|
|
310
|
+
* Disabled endpoints answer HTTP 410 Gone.
|
|
163
311
|
*
|
|
164
|
-
*
|
|
312
|
+
* Set `true` while users are still migrating. `betterAuthMigrationStatus` →
|
|
313
|
+
* `canDisableLegacyAuth` says when it can go back off; the boot log reports the same
|
|
314
|
+
* figure on every start.
|
|
165
315
|
*
|
|
166
316
|
* **Environment Variable:** `LEGACY_AUTH_ENABLED`
|
|
167
317
|
*
|
|
168
|
-
* @default true
|
|
318
|
+
* @default false (was `true` before 11.38.0)
|
|
169
319
|
*
|
|
170
320
|
* @example
|
|
171
321
|
* ```typescript
|
|
172
|
-
* // Via environment variable
|
|
173
|
-
* enabled: process.env.LEGACY_AUTH_ENABLED
|
|
322
|
+
* // Via environment variable — note `=== 'true'`, not `!== 'false'`
|
|
323
|
+
* enabled: process.env.LEGACY_AUTH_ENABLED === 'true',
|
|
174
324
|
* ```
|
|
175
325
|
*/
|
|
176
326
|
enabled?: boolean;
|
|
@@ -179,15 +329,21 @@ export interface IAuthLegacyEndpoints {
|
|
|
179
329
|
* Whether legacy GraphQL auth endpoints are enabled.
|
|
180
330
|
* Affects: signIn, signUp, signOut, refreshToken mutations
|
|
181
331
|
*
|
|
182
|
-
*
|
|
332
|
+
* Overrides `enabled` for this transport — except that an explicit
|
|
333
|
+
* `enabled: false` stays a hard off switch and cannot be reopened here.
|
|
334
|
+
*
|
|
335
|
+
* @default false (inherits from `enabled`, which defaults to false since 11.38.0)
|
|
183
336
|
*/
|
|
184
337
|
graphql?: boolean;
|
|
185
338
|
|
|
186
339
|
/**
|
|
187
340
|
* Whether legacy REST auth endpoints are enabled.
|
|
188
|
-
* Affects: /auth/
|
|
341
|
+
* Affects: /auth/signin, /auth/signup, etc.
|
|
342
|
+
*
|
|
343
|
+
* Overrides `enabled` for this transport — except that an explicit
|
|
344
|
+
* `enabled: false` stays a hard off switch and cannot be reopened here.
|
|
189
345
|
*
|
|
190
|
-
* @default
|
|
346
|
+
* @default false (inherits from `enabled`, which defaults to false since 11.38.0)
|
|
191
347
|
*/
|
|
192
348
|
rest?: boolean;
|
|
193
349
|
}
|
|
@@ -378,6 +534,51 @@ export interface IBetterAuthEmailVerificationConfig {
|
|
|
378
534
|
*/
|
|
379
535
|
passwordResetBrevoTemplateId?: number;
|
|
380
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Where the password-reset mail sends the recipient.
|
|
539
|
+
*
|
|
540
|
+
* **Defaults to the APP**, derived as `<appUrl>/auth/reset-password?token=<token>` — the path the
|
|
541
|
+
* starter's reset page lives at.
|
|
542
|
+
*
|
|
543
|
+
* Without this, the link is the one Better-Auth generates, and that points at the **API**
|
|
544
|
+
* (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`), which then redirects to
|
|
545
|
+
* the app. That works, but it puts a domain the recipient does not recognise into a password
|
|
546
|
+
* mail — precisely what people are trained to be suspicious of. In this stack an app and an API
|
|
547
|
+
* host are the norm, so the app is the better default.
|
|
548
|
+
*
|
|
549
|
+
* **What the API hop did, and what you give up.** Better-Auth's redirect route validates the
|
|
550
|
+
* token and its expiry before forwarding, so an expired link produced an error page instead of a
|
|
551
|
+
* form that fails on submit. Linking straight to the app moves that error later, to the moment
|
|
552
|
+
* the new password is submitted. It is NOT a security difference — the token ends up in the app
|
|
553
|
+
* URL either way, and the `callbackURL` origin check only exists because of the hop. If you want
|
|
554
|
+
* the early error back, either leave this unset or have the reset page validate the token before
|
|
555
|
+
* rendering.
|
|
556
|
+
*
|
|
557
|
+
* `{token}` is substituted anywhere in the value. Without the placeholder, `?token=<token>` is
|
|
558
|
+
* appended — so a page that reads a PATH parameter configures
|
|
559
|
+
* `https://example.com/auth/reset-password/{token}`.
|
|
560
|
+
*
|
|
561
|
+
* Set to `false` to keep Better-Auth's own link.
|
|
562
|
+
*
|
|
563
|
+
* Note this is separate from `email.passwordResetLink`, which serves the LEGACY
|
|
564
|
+
* `/users/password/reset-request` flow and appends the token as a path segment. Two flows, two
|
|
565
|
+
* conventions; a project using both should point them at the same page.
|
|
566
|
+
*
|
|
567
|
+
* @default `<appUrl>/auth/reset-password?token={token}`
|
|
568
|
+
* @since 11.38.0
|
|
569
|
+
*
|
|
570
|
+
* @example
|
|
571
|
+
* ```typescript
|
|
572
|
+
* betterAuth: {
|
|
573
|
+
* emailVerification: {
|
|
574
|
+
* // A page that reads the token from the path
|
|
575
|
+
* passwordResetLink: 'https://example.com/auth/reset-password/{token}',
|
|
576
|
+
* },
|
|
577
|
+
* }
|
|
578
|
+
* ```
|
|
579
|
+
*/
|
|
580
|
+
passwordResetLink?: false | string;
|
|
581
|
+
|
|
381
582
|
/**
|
|
382
583
|
* Cooldown in seconds between resend requests for the same email address.
|
|
383
584
|
* Prevents abuse by limiting how often verification emails can be resent.
|
|
@@ -2643,6 +2844,21 @@ export interface IServerOptions {
|
|
|
2643
2844
|
*
|
|
2644
2845
|
* @since 11.18.0
|
|
2645
2846
|
*/
|
|
2847
|
+
/**
|
|
2848
|
+
* Browser security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy).
|
|
2849
|
+
*
|
|
2850
|
+
* Set on EVERY response, including the ones a guard rejects — they are registered as
|
|
2851
|
+
* middleware, so a `401` carries them too.
|
|
2852
|
+
*
|
|
2853
|
+
* `true` / `{}` / omitted → enabled with the safe defaults. `false` → no headers at all.
|
|
2854
|
+
* A reverse proxy setting the same headers wins, because it writes last; this layer exists so
|
|
2855
|
+
* that a deployment without one, or a local instance, is not bare.
|
|
2856
|
+
*
|
|
2857
|
+
* @default true
|
|
2858
|
+
* @since 11.38.0
|
|
2859
|
+
*/
|
|
2860
|
+
headers?: boolean | ISecurityHeaders;
|
|
2861
|
+
|
|
2646
2862
|
responseModelInterceptor?:
|
|
2647
2863
|
| boolean
|
|
2648
2864
|
| {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Auth Module (Legacy Auth)
|
|
2
|
+
|
|
3
|
+
JWT-based email/password authentication: `signIn`, `signUp`, `logout`, `refreshToken` over GraphQL,
|
|
4
|
+
and `POST /auth/signin`, `/auth/signup`, `/auth/logout`, `/auth/refresh-token` over REST.
|
|
5
|
+
|
|
6
|
+
> **This module is deprecated in favour of IAM (Better-Auth) and will be removed.** Since 11.38.0
|
|
7
|
+
> its endpoints are **off unless explicitly enabled**. New projects should not register it — the
|
|
8
|
+
> one-argument `CoreModule.forRoot(envConfig)` never does. See
|
|
9
|
+
> [`.claude/rules/module-deprecation.md`](../../../../.claude/rules/module-deprecation.md) for the
|
|
10
|
+
> migration path.
|
|
11
|
+
|
|
12
|
+
## When this module exists at all
|
|
13
|
+
|
|
14
|
+
Only when a project passes the three-argument form:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(envConfig.jwt), envConfig);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The one-argument form (`CoreModule.forRoot(envConfig)`) registers IAM only, and nothing in this
|
|
21
|
+
directory is loaded. If you are reading this while planning a new project, that is the form you
|
|
22
|
+
want.
|
|
23
|
+
|
|
24
|
+
## Enabling the endpoints
|
|
25
|
+
|
|
26
|
+
Registering the module is no longer enough. Since 11.38.0 the endpoints answer **HTTP 410 Gone**
|
|
27
|
+
until asked for:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
// config.env.ts
|
|
31
|
+
auth: {
|
|
32
|
+
legacyEndpoints: { enabled: true }, // or LEGACY_AUTH_ENABLED=true
|
|
33
|
+
},
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The default flipped because a second, fully functional password-authentication surface that nobody
|
|
37
|
+
chose is a liability: it is served by `CoreAuthService` rather than Better-Auth, so it bypasses
|
|
38
|
+
every IAM-side control an operator may believe is now in force — two-factor, passkey enforcement,
|
|
39
|
+
Better-Auth session revocation.
|
|
40
|
+
|
|
41
|
+
Resolution is one exported function, `isLegacyEndpointEnabled(config, transport)`, shared by both
|
|
42
|
+
transports so they cannot answer differently:
|
|
43
|
+
|
|
44
|
+
| Configuration | Result |
|
|
45
|
+
| ------------------------------------------- | ------------------------------------ |
|
|
46
|
+
| `enabled: false` | off, whatever `graphql` / `rest` say |
|
|
47
|
+
| per-transport flag set (`graphql` / `rest`) | that flag wins |
|
|
48
|
+
| `enabled: true` | on |
|
|
49
|
+
| nothing set | **off** |
|
|
50
|
+
|
|
51
|
+
An explicit `enabled: false` is a hard off switch that a per-transport `true` cannot reopen — it is
|
|
52
|
+
the setting a project reached for to close legacy down, and an upgrade must never widen it.
|
|
53
|
+
|
|
54
|
+
## Knowing when you can turn it off
|
|
55
|
+
|
|
56
|
+
`CoreLegacyAuthDeprecationInitializer` reports at every boot, in both directions:
|
|
57
|
+
|
|
58
|
+
- while any endpoint is open: a deprecation warning plus the IAM migration percentage,
|
|
59
|
+
- while all are closed but users are still unmigrated: a warning that those users cannot sign in,
|
|
60
|
+
naming the way back.
|
|
61
|
+
|
|
62
|
+
Both are best-effort — the status read runs detached from the boot so it cannot delay readiness.
|
|
63
|
+
The same figures are available on demand through the `betterAuthMigrationStatus` query
|
|
64
|
+
(`canDisableLegacyAuth` is the signal to act on).
|
|
65
|
+
|
|
66
|
+
## Relationship to IAM while both run
|
|
67
|
+
|
|
68
|
+
The two systems share one user collection and keep each other's credentials in step:
|
|
69
|
+
|
|
70
|
+
| Event | Written by | Mirrored into |
|
|
71
|
+
| ------------------------------------------- | --------------- | ------------------------------------- |
|
|
72
|
+
| Sign-up via IAM | IAM (scrypt) | legacy (bcrypt) |
|
|
73
|
+
| Sign-up via Legacy | legacy (bcrypt) | IAM, on first IAM sign-in (migration) |
|
|
74
|
+
| Password reset via the legacy user endpoint | legacy | IAM |
|
|
75
|
+
| Password reset via IAM | IAM | legacy (11.38.0+) |
|
|
76
|
+
| Password change via `update()` | legacy | IAM |
|
|
77
|
+
|
|
78
|
+
`CoreAuthService.signIn` **delegates to IAM** as soon as a user carries an `iamId`, so for a
|
|
79
|
+
migrated user both endpoints answer from the IAM credential. That is worth knowing when debugging:
|
|
80
|
+
a stale legacy bcrypt hash is invisible through either API and only surfaces if IAM is switched
|
|
81
|
+
off.
|
|
82
|
+
|
|
83
|
+
Hash formats differ irreversibly (`bcrypt(sha256(pw))` vs `scrypt(sha256(pw))`), which is why the
|
|
84
|
+
mirrors exist rather than a one-off migration script.
|
|
85
|
+
|
|
86
|
+
## Extending
|
|
87
|
+
|
|
88
|
+
Standard Module Inheritance Pattern. Both endpoint guards are `protected` and must be called from
|
|
89
|
+
an override:
|
|
90
|
+
|
|
91
|
+
| Class | Method to call |
|
|
92
|
+
| -------------------- | --------------------------------- |
|
|
93
|
+
| `CoreAuthResolver` | `checkLegacyGraphQLEnabled(name)` |
|
|
94
|
+
| `CoreAuthController` | `checkLegacyRESTEnabled(name)` |
|
|
95
|
+
|
|
96
|
+
Forgetting one re-opens a disabled endpoint. Use the exported `isLegacyEndpointEnabled()` rather
|
|
97
|
+
than re-implementing the table if you override the check itself.
|
|
98
|
+
|
|
99
|
+
## Related
|
|
100
|
+
|
|
101
|
+
- [`.claude/rules/module-deprecation.md`](../../../../.claude/rules/module-deprecation.md) — the migration roadmap
|
|
102
|
+
- [`.claude/rules/role-system.md`](../../../../.claude/rules/role-system.md) — `@Roles` vs `@UseGuards`, and why `refreshToken` is the one place `@UseGuards` is still required
|
|
103
|
+
- [`../better-auth/README.md`](../better-auth/README.md) — the system replacing this one
|
|
104
|
+
- [`../better-auth/INTEGRATION-CHECKLIST.md`](../better-auth/INTEGRATION-CHECKLIST.md) — integrating IAM
|
|
@@ -22,6 +22,7 @@ import { CoreAuthModel } from './core-auth.model';
|
|
|
22
22
|
import { LegacyAuthDisabledException } from './exceptions/legacy-auth-disabled.exception';
|
|
23
23
|
import { AuthGuard } from './guards/auth.guard';
|
|
24
24
|
import { LegacyAuthRateLimitGuard } from './guards/legacy-auth-rate-limit.guard';
|
|
25
|
+
import { isLegacyEndpointEnabled } from './helpers/legacy-endpoints.helper';
|
|
25
26
|
import { CoreAuthSignInInput } from './inputs/core-auth-sign-in.input';
|
|
26
27
|
import { CoreAuthSignUpInput } from './inputs/core-auth-sign-up.input';
|
|
27
28
|
import { ICoreAuthUser } from './interfaces/core-auth-user.interface';
|
|
@@ -70,23 +71,15 @@ export class CoreAuthController {
|
|
|
70
71
|
/**
|
|
71
72
|
* Check if legacy REST endpoints are enabled
|
|
72
73
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - config.auth.legacyEndpoints.rest is false
|
|
74
|
+
* Since 11.38.0 legacy endpoints are OFF unless a project asks for them — see
|
|
75
|
+
* `isLegacyEndpointEnabled()` for the full resolution table.
|
|
76
76
|
*
|
|
77
77
|
* @throws LegacyAuthDisabledException
|
|
78
78
|
*/
|
|
79
79
|
protected checkLegacyRESTEnabled(endpointName: string): void {
|
|
80
80
|
const authConfig = this.configService.getFastButReadOnly('auth');
|
|
81
|
-
const legacyConfig = authConfig?.legacyEndpoints;
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
if (legacyConfig?.enabled === false) {
|
|
85
|
-
throw new LegacyAuthDisabledException(endpointName);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Check if REST endpoints specifically are disabled
|
|
89
|
-
if (legacyConfig?.rest === false) {
|
|
82
|
+
if (!isLegacyEndpointEnabled(authConfig?.legacyEndpoints, 'rest')) {
|
|
90
83
|
throw new LegacyAuthDisabledException(endpointName);
|
|
91
84
|
}
|
|
92
85
|
}
|
|
@@ -12,6 +12,7 @@ import { RolesGuardRegistry } from './guards/roles-guard-registry';
|
|
|
12
12
|
import { RolesGuard } from './guards/roles.guard';
|
|
13
13
|
import { CoreAuthUserService } from './services/core-auth-user.service';
|
|
14
14
|
import { CoreAuthService } from './services/core-auth.service';
|
|
15
|
+
import { CoreLegacyAuthDeprecationInitializer } from './core-legacy-auth-deprecation.initializer';
|
|
15
16
|
import { LegacyAuthRateLimiter } from './services/legacy-auth-rate-limiter.service';
|
|
16
17
|
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
|
17
18
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
|
@@ -64,6 +65,10 @@ export class CoreAuthModule {
|
|
|
64
65
|
let providers: any[] = [
|
|
65
66
|
// [Global] The GraphQLAuthGuard integrates the user into context
|
|
66
67
|
...rolesGuardProvider,
|
|
68
|
+
// Reports at boot that this deployment still exposes Legacy Auth, and how far the
|
|
69
|
+
// IAM migration has come. Registered here because this module exists only in
|
|
70
|
+
// legacy mode — an IAM-only project never loads it.
|
|
71
|
+
CoreLegacyAuthDeprecationInitializer,
|
|
67
72
|
{
|
|
68
73
|
provide: CoreAuthUserService,
|
|
69
74
|
useClass: UserService,
|
|
@@ -15,6 +15,7 @@ import { CoreAuthModel } from './core-auth.model';
|
|
|
15
15
|
import { LegacyAuthDisabledException } from './exceptions/legacy-auth-disabled.exception';
|
|
16
16
|
import { AuthGuard } from './guards/auth.guard';
|
|
17
17
|
import { LegacyAuthRateLimitGuard } from './guards/legacy-auth-rate-limit.guard';
|
|
18
|
+
import { isLegacyEndpointEnabled } from './helpers/legacy-endpoints.helper';
|
|
18
19
|
import { CoreAuthSignInInput } from './inputs/core-auth-sign-in.input';
|
|
19
20
|
import { CoreAuthSignUpInput } from './inputs/core-auth-sign-up.input';
|
|
20
21
|
import { ICoreAuthUser } from './interfaces/core-auth-user.interface';
|
|
@@ -62,23 +63,15 @@ export class CoreAuthResolver {
|
|
|
62
63
|
/**
|
|
63
64
|
* Check if legacy GraphQL endpoints are enabled
|
|
64
65
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* - config.auth.legacyEndpoints.graphql is false
|
|
66
|
+
* Since 11.38.0 legacy endpoints are OFF unless a project asks for them — see
|
|
67
|
+
* `isLegacyEndpointEnabled()` for the full resolution table.
|
|
68
68
|
*
|
|
69
69
|
* @throws LegacyAuthDisabledException
|
|
70
70
|
*/
|
|
71
71
|
protected checkLegacyGraphQLEnabled(endpointName: string): void {
|
|
72
72
|
const authConfig = this.configService.getFastButReadOnly('auth');
|
|
73
|
-
const legacyConfig = authConfig?.legacyEndpoints;
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
if (legacyConfig?.enabled === false) {
|
|
77
|
-
throw new LegacyAuthDisabledException(endpointName);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Check if GraphQL endpoints specifically are disabled
|
|
81
|
-
if (legacyConfig?.graphql === false) {
|
|
74
|
+
if (!isLegacyEndpointEnabled(authConfig?.legacyEndpoints, 'graphql')) {
|
|
82
75
|
throw new LegacyAuthDisabledException(endpointName);
|
|
83
76
|
}
|
|
84
77
|
}
|