@lenne.tech/nest-server 11.37.0 → 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.
Files changed (85) hide show
  1. package/.claude/rules/configurable-features.md +29 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/testing.md +26 -4
  4. package/FRAMEWORK-API.md +4 -2
  5. package/dist/config.env.js +1 -1
  6. package/dist/config.env.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
  8. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  9. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  10. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  11. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  12. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  13. package/dist/core/modules/auth/core-auth.module.js +2 -0
  14. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  15. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  16. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  17. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  18. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  19. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  20. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  21. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  22. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  23. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  24. package/dist/core/modules/better-auth/better-auth.config.js +33 -1
  25. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  26. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  27. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
  28. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  29. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +2 -1
  30. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
  31. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  32. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  33. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  34. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  35. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  36. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  37. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  38. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  39. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  40. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  41. package/dist/core/modules/user/core-user.service.d.ts +1 -1
  42. package/dist/core/modules/user/core-user.service.js +29 -12
  43. package/dist/core/modules/user/core-user.service.js.map +1 -1
  44. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  45. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  46. package/dist/core.module.js +2 -0
  47. package/dist/core.module.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/server/modules/user/user.controller.js +2 -1
  52. package/dist/server/modules/user/user.controller.js.map +1 -1
  53. package/dist/server/modules/user/user.resolver.js +2 -1
  54. package/dist/server/modules/user/user.resolver.js.map +1 -1
  55. package/dist/server/modules/user/user.service.d.ts +1 -1
  56. package/dist/server/modules/user/user.service.js +8 -1
  57. package/dist/server/modules/user/user.service.js.map +1 -1
  58. package/dist/tsconfig.build.tsbuildinfo +1 -1
  59. package/docs/REQUEST-LIFECYCLE.md +35 -1
  60. package/migration-guides/11.37.x-to-11.38.x.md +511 -0
  61. package/package.json +1 -1
  62. package/src/config.env.ts +9 -1
  63. package/src/core/common/interfaces/server-options.interface.ts +225 -9
  64. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  65. package/src/core/modules/auth/README.md +104 -0
  66. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  67. package/src/core/modules/auth/core-auth.module.ts +5 -0
  68. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  69. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  70. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  71. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  72. package/src/core/modules/better-auth/README.md +32 -59
  73. package/src/core/modules/better-auth/better-auth.config.ts +106 -4
  74. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
  75. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
  76. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  77. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  78. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  79. package/src/core/modules/user/core-user.service.ts +123 -18
  80. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  81. package/src/core.module.ts +8 -0
  82. package/src/index.ts +3 -0
  83. package/src/server/modules/user/user.controller.ts +7 -1
  84. package/src/server/modules/user/user.resolver.ts +7 -1
  85. 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
- * Set to false to disable all legacy auth endpoints (GraphQL and REST).
162
- * Use this after all users have migrated to BetterAuth (IAM).
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
- * Check migration status via the `betterAuthMigrationStatus` query.
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 !== 'false',
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
- * @default true (inherits from `enabled`)
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/sign-in, /auth/sign-up, etc.
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 true (inherits from `enabled`)
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
- * Throws LegacyAuthDisabledException if:
74
- * - config.auth.legacyEndpoints.enabled is false
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
- // Check if legacy endpoints are globally disabled
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
- * Throws LegacyAuthDisabledException if:
66
- * - config.auth.legacyEndpoints.enabled is false
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
- // Check if legacy endpoints are globally disabled
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
  }