@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.
Files changed (101) hide show
  1. package/.claude/rules/better-auth.md +32 -0
  2. package/.claude/rules/configurable-features.md +29 -0
  3. package/.claude/rules/framework-compatibility.md +1 -0
  4. package/.claude/rules/module-deprecation.md +25 -1
  5. package/.claude/rules/testing.md +26 -4
  6. package/.claude/rules/versioning.md +6 -0
  7. package/CLAUDE.md +22 -5
  8. package/FRAMEWORK-API.md +4 -2
  9. package/dist/config.env.js +1 -1
  10. package/dist/config.env.js.map +1 -1
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
  12. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  13. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  14. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  15. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  16. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  17. package/dist/core/modules/auth/core-auth.module.js +2 -0
  18. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  19. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  20. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  21. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  22. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  23. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  24. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  25. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  26. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  27. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  28. package/dist/core/modules/better-auth/better-auth.config.js +33 -1
  29. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  30. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  31. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
  32. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +2 -1
  34. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
  35. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  36. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  37. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  38. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  39. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  40. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +15 -10
  41. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  42. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +4 -0
  43. package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
  44. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  46. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  47. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  48. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
  49. package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
  50. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  51. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -1
  52. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  53. package/dist/core/modules/user/core-user.service.d.ts +1 -1
  54. package/dist/core/modules/user/core-user.service.js +29 -12
  55. package/dist/core/modules/user/core-user.service.js.map +1 -1
  56. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  57. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  58. package/dist/core.module.js +2 -0
  59. package/dist/core.module.js.map +1 -1
  60. package/dist/index.d.ts +3 -0
  61. package/dist/index.js +3 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/server/modules/user/user.controller.js +2 -1
  64. package/dist/server/modules/user/user.controller.js.map +1 -1
  65. package/dist/server/modules/user/user.resolver.js +2 -1
  66. package/dist/server/modules/user/user.resolver.js.map +1 -1
  67. package/dist/server/modules/user/user.service.d.ts +1 -1
  68. package/dist/server/modules/user/user.service.js +8 -1
  69. package/dist/server/modules/user/user.service.js.map +1 -1
  70. package/dist/tsconfig.build.tsbuildinfo +1 -1
  71. package/docs/REQUEST-LIFECYCLE.md +35 -1
  72. package/migration-guides/11.36.x-to-11.37.0.md +344 -0
  73. package/migration-guides/11.37.x-to-11.38.x.md +511 -0
  74. package/package.json +17 -4
  75. package/src/config.env.ts +9 -1
  76. package/src/core/common/interfaces/server-options.interface.ts +225 -9
  77. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  78. package/src/core/modules/auth/README.md +104 -0
  79. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  80. package/src/core/modules/auth/core-auth.module.ts +5 -0
  81. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  82. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  83. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  84. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +46 -1
  85. package/src/core/modules/better-auth/README.md +96 -59
  86. package/src/core/modules/better-auth/better-auth.config.ts +106 -4
  87. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
  88. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
  89. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  90. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +49 -10
  91. package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
  92. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  93. package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
  94. package/src/core/modules/system-setup/core-system-setup.service.ts +33 -5
  95. package/src/core/modules/user/core-user.service.ts +123 -18
  96. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  97. package/src/core.module.ts +8 -0
  98. package/src/index.ts +3 -0
  99. package/src/server/modules/user/user.controller.ts +7 -1
  100. package/src/server/modules/user/user.resolver.ts +7 -1
  101. package/src/server/modules/user/user.service.ts +35 -9
@@ -1,3 +1,4 @@
1
+ import { createLocalAccountIssuer } from '@better-auth/core/db';
1
2
  import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
2
3
  import { InjectConnection } from '@nestjs/mongoose';
3
4
  import { Request } from 'express';
@@ -14,7 +15,15 @@ import { BetterAuthInstance } from './better-auth.config';
14
15
  import { isJwtShaped } from './core-better-auth-token.helper';
15
16
  import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
16
17
  import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
17
- import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
18
+ import {
19
+ ACCOUNT_ISSUER_BACKFILL_ID,
20
+ BACKFILL_MARKER_COLLECTION,
21
+ BETTER_AUTH_CONFIG,
22
+ BETTER_AUTH_COOKIE_DOMAIN,
23
+ BETTER_AUTH_INSTANCE,
24
+ DEFAULT_ACCOUNT_ISSUER_FIELD,
25
+ DEFAULT_ACCOUNT_MODEL_NAME,
26
+ } from './core-better-auth.constants';
18
27
 
19
28
  /**
20
29
  * Result of a session validation
@@ -94,12 +103,31 @@ export class CoreBetterAuthService implements OnModuleInit {
94
103
  }
95
104
 
96
105
  /**
97
- * Ensure performance indices exist on the session, users, account and verification collections.
98
- * Indices are idempotent — calling createIndex on an existing index is a no-op.
106
+ * Boot steps for the better-auth integration.
107
+ *
108
+ * Two jobs live here and they are deliberately separate methods: ensuring indices is a
109
+ * PERFORMANCE concern that repeats forever and degrades gracefully (hence `warn`), while the
110
+ * issuer backfill is a CORRECTNESS concern that should happen once and locks users out when it
111
+ * does not (hence `error`). Keeping them in one method made those two severities look like one
112
+ * decision.
113
+ *
114
+ * Order matters: the backfill's update can use the `{ providerId: 1, userId: 1 }` index created
115
+ * below, so indices come first.
99
116
  */
100
117
  async onModuleInit(): Promise<void> {
101
118
  if (!this.isEnabled() || !this.connection?.db) return;
102
119
 
120
+ await this.ensureIndices();
121
+ await this.backfillAccountIssuers();
122
+ }
123
+
124
+ /**
125
+ * Ensure performance indices exist on the session, users, account and verification collections.
126
+ * Indices are idempotent — calling createIndex on an existing index is a no-op.
127
+ */
128
+ protected async ensureIndices(): Promise<void> {
129
+ if (!this.connection?.db) return;
130
+
103
131
  try {
104
132
  const db = this.connection.db;
105
133
 
@@ -130,6 +158,154 @@ export class CoreBetterAuthService implements OnModuleInit {
130
158
  }
131
159
  }
132
160
 
161
+ /**
162
+ * Backfills `account.issuer` on rows written before better-auth 1.7.
163
+ *
164
+ * From 1.7 an account is keyed by (issuer, accountId), and sign-in filters on
165
+ * it verbatim:
166
+ *
167
+ * account.providerId === 'credential' && account.issuer === credentialIssuer
168
+ *
169
+ * A row written by 1.6 has no `issuer` at all, so that comparison can never
170
+ * hold: every existing password user would be locked out by the upgrade
171
+ * itself, with a 401 and nothing in the logs to explain it. This closes that
172
+ * gap on the first boot after the upgrade.
173
+ *
174
+ * Idempotent — the filter only matches rows still missing the field, so the
175
+ * second start updates nothing.
176
+ *
177
+ * Scope is deliberately limited to credential accounts, where the issuer is
178
+ * derivable without guessing. OAuth accounts are NOT touched: their issuer
179
+ * depends on the provider (a real OIDC issuer, or the synthetic
180
+ * `local:oauth:<id>` fallback), and writing the wrong one would not fail
181
+ * loudly — it would create a second account on the next social sign-in. Those
182
+ * rows are reported instead, so the decision stays with the project.
183
+ *
184
+ * ONE THING NOT TO "TIDY UP": this package's own reads of the `account`
185
+ * collection filter on `providerId` alone — `syncPasswordChangeToIam`,
186
+ * `migrateAccountToIam` and `getMigrationStatus` in
187
+ * core-better-auth-user.mapper.ts. Adding `issuer` to those filters looks like
188
+ * consistency and is a regression. This backfill is deliberately NON-FATAL: if
189
+ * it fails, the server still boots and logs an error. Reads that do not require
190
+ * the field keep working on un-backfilled rows — `getMigrationStatus` would
191
+ * otherwise report zero migrated users, and `syncPasswordChangeToIam` would
192
+ * stop finding the account it is meant to update. Only better-auth's own
193
+ * sign-in path needs the issuer, and that one is better-auth's code, not ours.
194
+ */
195
+ protected async backfillAccountIssuers(): Promise<void> {
196
+ if (!this.isEnabled() || !this.connection?.db) return;
197
+
198
+ const db = this.connection.db;
199
+
200
+ // The consumer can rename both of these through `betterAuth.options.account`, which
201
+ // better-auth.config.ts spreads onto the resolved config verbatim. Hardcoding them turns this
202
+ // whole method into a silent no-op on such a project: nothing matches, `modifiedCount` is 0,
203
+ // neither log line fires, and every password user is locked out while the operator's upgrade
204
+ // checklist is satisfied by silence in both directions.
205
+ const accountOptions = (this.authInstance as any)?.options?.account;
206
+ const modelName: string = accountOptions?.modelName ?? DEFAULT_ACCOUNT_MODEL_NAME;
207
+ const issuerField: string = accountOptions?.fields?.issuer ?? DEFAULT_ACCOUNT_ISSUER_FIELD;
208
+
209
+ try {
210
+ const accounts = db.collection(modelName);
211
+
212
+ // A completion marker, checked before anything expensive. Without it both queries below run
213
+ // on EVERY boot of EVERY replica, forever — and neither is indexable (`$exists: false` cannot
214
+ // appear in a partialFilterExpression, `$ne` is not selective), so each is a full pass over
215
+ // the account collection, awaited before the app starts listening. With the marker the steady
216
+ // state is a single `_id` lookup. `system-setup-locks` in CoreSystemSetupService is the
217
+ // precedent for this kind of once-per-deployment boot state.
218
+ const markers = db.collection(BACKFILL_MARKER_COLLECTION);
219
+ if (await markers.findOne({ _id: ACCOUNT_ISSUER_BACKFILL_ID as any })) {
220
+ this.logger.debug(`account.${issuerField} backfill already completed — skipping.`);
221
+ return;
222
+ }
223
+
224
+ if (modelName !== DEFAULT_ACCOUNT_MODEL_NAME || issuerField !== DEFAULT_ACCOUNT_ISSUER_FIELD) {
225
+ this.logger.warn(
226
+ `Backfilling the account issuer against a customised schema (collection "${modelName}", ` +
227
+ `field "${issuerField}"). Verify these match what better-auth actually writes.`,
228
+ );
229
+ }
230
+
231
+ const pending = await accounts
232
+ .find({ [issuerField]: { $exists: false }, providerId: 'credential' }, { projection: { _id: 1 } })
233
+ .toArray();
234
+
235
+ let backfilled = 0;
236
+
237
+ if (pending.length) {
238
+ // `ordered: false` is load-bearing. better-auth declares a UNIQUE index on
239
+ // (issuer, accountId); a single pre-existing duplicate would abort an ordered write and
240
+ // leave every row after it untouched — those users stay locked out, with one log line and
241
+ // a green boot. Unordered, one bad row costs only itself.
242
+ const result = await accounts.bulkWrite(
243
+ pending.map((doc) => ({
244
+ updateOne: {
245
+ filter: { _id: doc._id },
246
+ update: { $set: { [issuerField]: createLocalAccountIssuer('credential') } },
247
+ },
248
+ })),
249
+ { ordered: false },
250
+ );
251
+
252
+ backfilled = result.modifiedCount;
253
+
254
+ if (backfilled > 0) {
255
+ this.logger.log(
256
+ `Backfilled account.${issuerField} on ${backfilled} credential account(s) for better-auth >= 1.7. ` +
257
+ 'Without it these users could not sign in.',
258
+ );
259
+ }
260
+
261
+ if (backfilled < pending.length) {
262
+ // Do NOT write the marker in this case — the next boot must retry the remainder.
263
+ this.logger.error(
264
+ `Backfilled ${backfilled}/${pending.length} credential accounts. ` +
265
+ `${pending.length - backfilled} user(s) CANNOT sign in until this is resolved manually. ` +
266
+ 'The most likely cause is a duplicate (issuer, accountId) pair from an earlier partial migration.',
267
+ );
268
+ return;
269
+ }
270
+ } else {
271
+ this.logger.debug(`No account.${issuerField} backfill needed — no credential rows predate better-auth 1.7.`);
272
+ }
273
+
274
+ // Only a complete run earns the marker.
275
+ await markers.updateOne(
276
+ { _id: ACCOUNT_ISSUER_BACKFILL_ID as any },
277
+ { $set: { backfilled, completedAt: new Date(), issuerField, modelName } },
278
+ { upsert: true },
279
+ );
280
+
281
+ // Existence probe rather than a count: `$ne` cannot use an index, so counting to the end is a
282
+ // guaranteed collection scan for a log line. "At least one" is all the message needs to say.
283
+ const staleOther = await accounts.findOne(
284
+ { [issuerField]: { $exists: false }, providerId: { $ne: 'credential' } },
285
+ { projection: { _id: 1 } },
286
+ );
287
+
288
+ if (staleOther) {
289
+ this.logger.warn(
290
+ `At least one non-credential account has no "${issuerField}". better-auth >= 1.7 will not match it by ` +
291
+ '(issuer, accountId) — and such a sign-in does NOT simply fail: it falls back to matching the user by ' +
292
+ 'the provider-asserted email and implicitly links a SECOND account row. If the provider-side email has ' +
293
+ 'changed, a NEW user is created instead and the existing account is orphaned, together with the provider ' +
294
+ 'tokens on the old row, which no unlink will ever remove. Set the issuer per provider BEFORE the first ' +
295
+ "social sign-in after this upgrade: the provider's real OIDC issuer, or the synthetic " +
296
+ '"local:oauth:<providerId>".',
297
+ );
298
+ }
299
+ } catch (error) {
300
+ // Correctness, not performance — say so loudly, but do not stop the boot:
301
+ // a server that starts with a warning beats one that will not start at all.
302
+ this.logger.error(
303
+ `Could not backfill the account issuer: ${error instanceof Error ? error.message : 'unknown'}. ` +
304
+ 'Existing password users may be unable to sign in until this succeeds.',
305
+ );
306
+ }
307
+ }
308
+
133
309
  /**
134
310
  * Checks if better-auth is enabled and initialized
135
311
  * Returns true only if:
@@ -1,3 +1,4 @@
1
+ import { createLocalAccountIssuer } from '@better-auth/core/db';
1
2
  import { ForbiddenException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
2
3
  import { InjectConnection } from '@nestjs/mongoose';
3
4
  import { isEmail } from 'class-validator';
@@ -271,11 +272,32 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
271
272
  const normalizedPassword = this.userMapper.normalizePasswordForIam(input.password);
272
273
 
273
274
  // Create user via internalAdapter (bypasses disableSignUp)
274
- const iamUser = await context.internalAdapter.createUser({
275
- email: input.email,
276
- emailVerified: true,
277
- name: input.name || input.email.split('@')[0],
278
- });
275
+ //
276
+ // better-auth >= 1.7 requires a provisioning source. Without it, `createUser` throws
277
+ // FORBIDDEN/validation_source_missing as soon as a project configures
278
+ // `betterAuth.options.user.validateUserInfo`. `admin` is the honest value — the system
279
+ // provisions the FIRST admin, nobody signs up — and it is what better-auth's own admin
280
+ // plugin passes.
281
+ //
282
+ // This does NOT bypass `validateUserInfo`. The hook still runs and receives
283
+ // `{ method: 'admin', action: 'create-user' }`, so a project gate can still reject the
284
+ // initial admin; branch on `method` there if the setup should be exempt.
285
+ //
286
+ // KNOWN LIMITATION, and the reason this is spelled out: when `validateUserInfo` is
287
+ // configured, better-auth additionally calls `getCurrentAuthContext()`, which throws outside
288
+ // an endpoint context. System setup runs from `OnApplicationBootstrap` / a plain controller,
289
+ // never inside better-auth's request pipeline, so the call fails with
290
+ // FORBIDDEN/validation_context_missing and no initial admin is created. Fail-closed, but
291
+ // opaque. A project using that hook must provision the first admin another way — see
292
+ // migration-guides/11.36.x-to-11.37.0.md §7.
293
+ const iamUser = await context.internalAdapter.createUser(
294
+ {
295
+ email: input.email,
296
+ emailVerified: true,
297
+ name: input.name || input.email.split('@')[0],
298
+ },
299
+ { method: 'admin' },
300
+ );
279
301
 
280
302
  if (!iamUser) {
281
303
  throw new Error('Failed to create IAM user');
@@ -283,8 +305,14 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
283
305
 
284
306
  // Hash password and create credential account
285
307
  const hashedPassword = await context.password.hash(normalizedPassword);
308
+ // better-auth >= 1.7 keys accounts by (issuer, accountId) and requires the
309
+ // issuer. Credential accounts have no real issuer, so better-auth derives a
310
+ // synthetic one — always via this helper, never a hand-written literal:
311
+ // the format is better-auth's to change, and a copy of it would silently
312
+ // stop matching the accounts better-auth writes itself.
286
313
  await context.internalAdapter.linkAccount({
287
314
  accountId: iamUser.id,
315
+ issuer: createLocalAccountIssuer('credential'),
288
316
  password: hashedPassword,
289
317
  providerId: 'credential',
290
318
  userId: iamUser.id,
@@ -5,6 +5,7 @@ import { sha256 } from 'js-sha256';
5
5
  import { Document, Model } from 'mongoose';
6
6
 
7
7
  import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
8
+ import { maskEmail } from '../../common/helpers/logging.helper';
8
9
  import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
9
10
  import { ServiceOptions } from '../../common/interfaces/service-options.interface';
10
11
  import { ConfigService } from '../../common/services/config.service';
@@ -185,12 +186,41 @@ export abstract class CoreUserService<
185
186
  // Get user
186
187
  const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
187
188
  if (!dbObject) {
188
- throw new NotFoundException(`No user found with password reset token: ${token}`);
189
+ // The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
190
+ // in the response body and in every log line that records the exception — and this logger
191
+ // feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
192
+ // free log-stuffing, and the message is no more useful for it.
193
+ throw new NotFoundException('Invalid or expired password reset token');
189
194
  }
190
195
 
191
- // Store the original plain password for IAM sync before any hashing
192
- // We need the plain password because IAM uses scrypt, not bcrypt+sha256
193
- const plainPasswordForIamSync = /^[a-f0-9]{64}$/i.test(newPassword) ? undefined : newPassword;
196
+ // Capture the submitted password for the IAM sync before the closure below
197
+ // reassigns `newPassword` to its sha256 form.
198
+ //
199
+ // It is passed on exactly as received, INCLUDING an already-sha256-hashed
200
+ // one. This used to skip the sync for a 64-hex value, on the reasoning that
201
+ // "IAM uses scrypt, not bcrypt+sha256" — but the sync does not need a plain
202
+ // password: `hashPasswordForBetterAuth` runs its input through
203
+ // `normalizePasswordForIam`, which passes a 64-hex string through unchanged
204
+ // by design, and `migrateAccountToIam` is fed the very same pre-hashed value
205
+ // when an account is created.
206
+ //
207
+ // The guard therefore disabled the sync for exactly the clients this stack
208
+ // ships. The lt frontends hash in the browser before sending — seven call
209
+ // sites in `nuxt-extensions/src/runtime/lib/auth-client.ts` (`signIn.email`,
210
+ // `resetPassword`, `changePassword` and the rest) run `ltSha256` on the
211
+ // password first. That is independent of the `sha256` config option, which
212
+ // only governs what the SERVER does with a plaintext password it happens to
213
+ // receive. So the value arriving here is 64-hex whatever that option says,
214
+ // and every such reset took the skipped branch.
215
+ //
216
+ // The legacy password was updated, the IAM credential was not, and sign-in —
217
+ // which goes through IAM — kept accepting the OLD password and refusing the
218
+ // new one. The endpoint reported success throughout, so the failure surfaced
219
+ // only at the next sign-in.
220
+ //
221
+ // A client that posts a plaintext password was never affected: the old guard
222
+ // let that one through, and the sync normalized it the same way IAM does.
223
+ const passwordForIamSync = newPassword;
194
224
 
195
225
  return this.process(
196
226
  async () => {
@@ -204,17 +234,38 @@ export abstract class CoreUserService<
204
234
  const updatedUser = await assignPlain(dbObject, {
205
235
  password: await bcrypt.hash(newPassword, 10),
206
236
  passwordResetToken: null,
237
+ // A reset is what somebody reaches for after a suspected takeover, so it must not
238
+ // leave the attacker's session live. Clearing the refresh tokens ends every legacy
239
+ // session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
240
+ // which is off by default because it is a behaviour change for existing deployments —
241
+ // the migration guide recommends turning it on.
242
+ //
243
+ // Without this, "the reset now lands in both stores" would still leave the account
244
+ // reachable with the credential the reset was meant to retire.
245
+ refreshTokens: {},
207
246
  }).save();
208
247
 
209
248
  // Sync password to Better-Auth (IAM) if mapper is available
210
249
  // This ensures users can sign in via IAM after password reset
211
- if (this.options?.betterAuthUserMapper && plainPasswordForIamSync && dbObject.email) {
250
+ if (this.options?.betterAuthUserMapper && passwordForIamSync && dbObject.email) {
212
251
  try {
213
- await this.options.betterAuthUserMapper.syncPasswordChangeToIam(dbObject.email, plainPasswordForIamSync);
252
+ // Same reasoning as in update(): a `false` return means the reset landed in the
253
+ // legacy store only, which is the shape of failure this whole path exists to
254
+ // prevent. It must not be indistinguishable from success.
255
+ const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
256
+ dbObject.email,
257
+ passwordForIamSync,
258
+ );
259
+ if (!synced) {
260
+ this.userServiceLogger.warn(
261
+ `Password reset for ${maskEmail(dbObject.email)} was NOT synced to IAM (no credential account) — ` +
262
+ 'the legacy password now differs from the IAM credential.',
263
+ );
264
+ }
214
265
  } catch (error) {
215
266
  // Log but don't fail - Legacy Auth password was updated successfully
216
267
  this.userServiceLogger.warn(
217
- `Failed to sync password reset to IAM for ${dbObject.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
268
+ `Failed to sync password reset to IAM for ${maskEmail(dbObject.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
218
269
  );
219
270
  }
220
271
  }
@@ -226,13 +277,48 @@ export abstract class CoreUserService<
226
277
  }
227
278
 
228
279
  /**
229
- * Set password rest token for email
280
+ * Set a password-reset token for an email address
281
+ *
282
+ * Returns `null` for an unknown address when `auth.passwordReset.preventUserEnumeration` is on
283
+ * (the default since 11.38.0) — the caller must then answer exactly as it would for a known one.
284
+ * With the option off it throws `NotFoundException`, the pre-11.38.0 behaviour.
285
+ *
286
+ * WHY THE DEFAULT CHANGED
287
+ *
288
+ * Throwing made the endpoint an account oracle: HTTP 404 for an unknown address, 201 for a known
289
+ * one, so anyone could test who has an account. In a multi-tenant product that also answers who
290
+ * works at which customer. The framework already answers this correctly on the IAM path —
291
+ * Better-Auth's `/request-password-reset` returns the same body either way — so the two halves
292
+ * of one framework disagreed about the same question.
293
+ *
294
+ * THE STATUS CODE IS THE SMALLER HALF
295
+ *
296
+ * Response TIME distinguishes the cases too, and by far more: the known path writes a token and
297
+ * (in the caller) sends mail, the unknown path returns immediately. This method equalises what it
298
+ * can — the token generation still happens, so the CPU cost matches — but the mail send lives in
299
+ * the caller. A `sendPasswordResetMail()` that AWAITS the send leaks the difference as latency,
300
+ * whatever this method does. `src/server/modules/user/user.service.ts` shows the shape that does
301
+ * not; the IAM path uses the same trick, with the reasoning recorded in `better-auth.config.ts`.
302
+ *
303
+ * Anything measuring this honestly should say so rather than claim the channel is closed.
230
304
  */
231
- async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<TUser> {
305
+ async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<null | TUser> {
232
306
  // Get user
233
307
  const dbObject = await this.mainDbModel.findOne({ email }).exec();
234
308
  if (!dbObject) {
235
- throw new NotFoundException(`No user found with email: ${email}`);
309
+ const preventEnumeration =
310
+ this.configService.getFastButReadOnly('auth')?.passwordReset?.preventUserEnumeration !== false;
311
+
312
+ if (!preventEnumeration) {
313
+ throw new NotFoundException(`No user found with email: ${email}`);
314
+ }
315
+
316
+ // Do the work the known path does, so the two do not differ in CPU cost. It is cheap next to
317
+ // a mail send, which is why this alone does not close the timing channel — see the note above.
318
+ crypto.randomBytes(32).toString('hex');
319
+
320
+ this.userServiceLogger.debug(`Password reset requested for an unknown address (${maskEmail(email)})`);
321
+ return null;
236
322
  }
237
323
 
238
324
  return this.process(
@@ -311,10 +397,14 @@ export abstract class CoreUserService<
311
397
  const oldUser = (await this.mainDbModel.findById(id).lean().exec()) as null | TUser;
312
398
  const oldEmail = oldUser?.email;
313
399
 
314
- // Store plain password for IAM sync before any hashing occurs
315
- // We need to capture this before super.update() which may hash it
316
- const inputPassword = (input as any).password;
317
- const plainPasswordForIamSync = inputPassword && !/^[a-f0-9]{64}$/i.test(inputPassword) ? inputPassword : undefined;
400
+ // Capture the submitted password for the IAM sync before super.update()
401
+ // hashes it in place.
402
+ //
403
+ // Passed on exactly as received, including an already-sha256-hashed one
404
+ // see the note in `resetPassword`: the sync normalizes a 64-hex value
405
+ // through unchanged, so skipping it there disabled the sync for the
406
+ // standard setup, where the frontend hashes before sending.
407
+ const passwordForIamSync = (input as any).password;
318
408
 
319
409
  // Perform the update
320
410
  const updatedUser = await super.update(id, input, serviceOptions);
@@ -333,13 +423,28 @@ export abstract class CoreUserService<
333
423
  }
334
424
 
335
425
  // Sync password change to IAM if password was changed and mapper is available
336
- if (this.options?.betterAuthUserMapper && plainPasswordForIamSync && oldUser?.email) {
426
+ if (this.options?.betterAuthUserMapper && passwordForIamSync && oldUser?.email) {
337
427
  try {
338
- await this.options.betterAuthUserMapper.syncPasswordChangeToIam(oldUser.email, plainPasswordForIamSync);
339
- this.userServiceLogger.debug(`Synced password change to IAM for user ${oldUser.email}`);
428
+ // Report what actually happened, not that the call was made. `syncPasswordChangeToIam`
429
+ // answers `false` never throws — when there is no IAM credential to update, and
430
+ // logging success regardless is how a half-applied password change stays invisible:
431
+ // the endpoint reports success, the user is left with two different passwords, and
432
+ // nothing in the log says so.
433
+ const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
434
+ oldUser.email,
435
+ passwordForIamSync,
436
+ );
437
+ if (synced) {
438
+ this.userServiceLogger.debug(`Synced password change to IAM for user ${maskEmail(oldUser.email)}`);
439
+ } else {
440
+ this.userServiceLogger.warn(
441
+ `Password change for ${maskEmail(oldUser.email)} was NOT synced to IAM (no credential account) — ` +
442
+ 'the legacy password now differs from the IAM credential.',
443
+ );
444
+ }
340
445
  } catch (error) {
341
446
  this.userServiceLogger.warn(
342
- `Failed to sync password change to IAM for ${oldUser.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
447
+ `Failed to sync password change to IAM for ${maskEmail(oldUser.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
343
448
  );
344
449
  // Don't throw - password sync failure shouldn't block the update
345
450
  }
@@ -97,11 +97,26 @@ export abstract class CoreUserInput extends CoreInput {
97
97
 
98
98
  /**
99
99
  * Password of the user
100
+ *
101
+ * Restricted to ADMIN and the account owner since 11.38.0. It used to be `S_EVERYONE`, i.e.
102
+ * settable by whoever was allowed to update the record at all — and `updateUser` grants that to
103
+ * `S_CREATOR`, which in an invite or admin-provisioning flow is the INVITING ADMIN, permanently
104
+ * (see `.claude/rules/role-system.md`).
105
+ *
106
+ * That was largely inert before: the lt frontends hash client-side, and the 64-hex guard then
107
+ * routed such a write away from the IAM sync, so it only ever landed in the legacy store — which
108
+ * this release also turns off by default. Removing the guard makes the same write reach the LIVE
109
+ * IAM credential. A dormant privilege issue would have become an account-takeover path in the
110
+ * very release that closes the legacy door, so the field is narrowed in the same step.
111
+ *
112
+ * A user changing their own password is unaffected (`S_SELF`). A project that genuinely needs a
113
+ * third party to set a password should go through an invitation or reset flow, both of which
114
+ * prove possession of the mailbox.
100
115
  */
101
116
  @UnifiedField({
102
117
  description: 'Password of the user',
103
118
  isOptional: true,
104
- roles: RoleEnum.S_EVERYONE,
119
+ roles: [RoleEnum.ADMIN, RoleEnum.S_SELF],
105
120
  })
106
121
  password?: string = undefined;
107
122
  }
@@ -29,6 +29,7 @@ import {
29
29
  IServerOptions,
30
30
  } from './core/common/interfaces/server-options.interface';
31
31
  import { RequestContextMiddleware } from './core/common/middleware/request-context.middleware';
32
+ import { SecurityHeadersMiddleware } from './core/common/middlewares/security-headers.middleware';
32
33
  import { MapAndValidatePipe } from './core/common/pipes/map-and-validate.pipe';
33
34
  import { ComplexityPlugin } from './core/common/plugins/complexity.plugin';
34
35
  import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
@@ -84,6 +85,13 @@ export class CoreModule implements NestModule {
84
85
  configure(consumer: MiddlewareConsumer) {
85
86
  // RequestContext middleware must run for all routes to provide AsyncLocalStorage context
86
87
  consumer.apply(RequestContextMiddleware).forRoutes('*');
88
+
89
+ // Security headers on EVERY response, guards' rejections included. Middleware rather than an
90
+ // interceptor for exactly that reason: an interceptor never runs for a request a guard turns
91
+ // away, and those are the ones an attacker generates most of. The middleware itself decides
92
+ // whether it is enabled — keeping the policy in one file rather than splitting it between a
93
+ // config read here and the values there.
94
+ consumer.apply(SecurityHeadersMiddleware).forRoutes('*');
87
95
  if (CoreModule.graphQlEnabled) {
88
96
  consumer.apply(graphqlUploadExpress()).forRoutes('graphql');
89
97
  }
package/src/index.ts CHANGED
@@ -73,6 +73,7 @@ export * from './core/common/interfaces/server-options.interface';
73
73
  export * from './core/common/interfaces/service-options.interface';
74
74
  export * from './core/common/middleware/request-context.middleware';
75
75
  export * from './core/common/middlewares/to-lower-case.middleware';
76
+ export * from './core/common/middlewares/security-headers.middleware';
76
77
  export * from './core/common/models/core-model.model';
77
78
  export * from './core/common/models/core-persistence.model';
78
79
  export * from './core/common/models/pagination-info.model';
@@ -133,11 +134,13 @@ export * from './core/modules/auth/core-auth.controller';
133
134
  export * from './core/modules/auth/core-auth.model';
134
135
  export * from './core/modules/auth/core-auth.module';
135
136
  export * from './core/modules/auth/core-auth.resolver';
137
+ export * from './core/modules/auth/core-legacy-auth-deprecation.initializer';
136
138
  export * from './core/modules/auth/exceptions/expired-refresh-token.exception';
137
139
  export * from './core/modules/auth/exceptions/expired-token.exception';
138
140
  export * from './core/modules/auth/exceptions/invalid-token.exception';
139
141
  export * from './core/modules/auth/exceptions/legacy-auth-disabled.exception';
140
142
  export * from './core/modules/auth/guards/auth.guard';
143
+ export * from './core/modules/auth/helpers/legacy-endpoints.helper';
141
144
  export * from './core/modules/auth/guards/legacy-auth-rate-limit.guard';
142
145
  export * from './core/modules/auth/guards/roles-guard-registry';
143
146
  export * from './core/modules/auth/guards/roles.guard';
@@ -144,7 +144,13 @@ export class UserController {
144
144
  @Post('password/reset-request')
145
145
  @Roles(RoleEnum.S_EVERYONE)
146
146
  async requestPasswordResetMail(@Body('email') email: string): Promise<boolean> {
147
- return !!(await this.userService.sendPasswordResetMail(email));
147
+ // Always `true`, whether or not the address exists. `sendPasswordResetMail` returns `null` for
148
+ // an unknown one (see CoreUserService.setPasswordResetTokenForEmail), and forwarding that as
149
+ // `false` would rebuild the account oracle one layer up: the status code is equal, the body
150
+ // is not. The caller is told the same thing either way — "if this address is known, mail is on
151
+ // its way" — which is the only honest answer that reveals nothing.
152
+ await this.userService.sendPasswordResetMail(email);
153
+ return true;
148
154
  }
149
155
 
150
156
  /**
@@ -125,7 +125,13 @@ export class UserResolver {
125
125
  @Mutation(() => Boolean, { description: 'Request new password for user with email' })
126
126
  @Roles(RoleEnum.S_EVERYONE)
127
127
  async requestPasswordResetMail(@Args('email') email: string): Promise<boolean> {
128
- return !!(await this.userService.sendPasswordResetMail(email));
128
+ // Always `true`, whether or not the address exists. `sendPasswordResetMail` returns `null` for
129
+ // an unknown one (see CoreUserService.setPasswordResetTokenForEmail), and forwarding that as
130
+ // `false` would rebuild the account oracle one layer up: the status code is equal, the body
131
+ // is not. The caller is told the same thing either way — "if this address is known, mail is on
132
+ // its way" — which is the only honest answer that reveals nothing.
133
+ await this.userService.sendPasswordResetMail(email);
134
+ return true;
129
135
  }
130
136
 
131
137
  /**
@@ -74,19 +74,45 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
74
74
 
75
75
  /**
76
76
  * Request password reset mail
77
+ *
78
+ * REFERENCE IMPLEMENTATION — the two details below are the ones that matter, and a project
79
+ * copying this method needs both. Either one alone leaves the endpoint an account oracle.
80
+ *
81
+ * 1. An unknown address yields `null` (since 11.38.0, unless
82
+ * `auth.passwordReset.preventUserEnumeration` is off) and this method returns quietly, so the
83
+ * caller answers the same way it would for a known one.
84
+ * 2. The mail send is NOT awaited. This is the half that actually closes the channel: the send
85
+ * is a network round trip to SMTP or Brevo, orders of magnitude above everything else in the
86
+ * request. Awaiting it would make the known path visibly slower whatever the status code says
87
+ * — you would have given up the "unknown address" hint in the UI and kept the oracle.
88
+ *
89
+ * A failed send must still be reported rather than crash the process, which is why it carries
90
+ * its own `catch`. The user is told the mail is on its way either way; that is the same trade
91
+ * Better-Auth makes on the IAM path.
77
92
  */
78
- async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<User> {
93
+ async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
79
94
  // Set password reset token
80
95
  const user = await super.setPasswordResetTokenForEmail(email, serviceOptions);
81
96
 
82
- // Send email
83
- await this.emailService.sendMail(user.email, 'Password reset', {
84
- htmlTemplate: 'password-reset',
85
- templateData: {
86
- link: `${this.configService.configFastButReadOnly.email.passwordResetLink}/${user.passwordResetToken}`,
87
- name: user.username,
88
- },
89
- });
97
+ if (!user) {
98
+ // Unknown address, enumeration protection on. Answer exactly as for a known one.
99
+ return null;
100
+ }
101
+
102
+ // Deliberately NOT awaited — see the note above.
103
+ void this.emailService
104
+ .sendMail(user.email, 'Password reset', {
105
+ htmlTemplate: 'password-reset',
106
+ templateData: {
107
+ link: `${this.configService.configFastButReadOnly.email.passwordResetLink}/${user.passwordResetToken}`,
108
+ name: user.username,
109
+ },
110
+ })
111
+ .catch((error: unknown) => {
112
+ this.userServiceLogger.error(
113
+ `Failed to send the password-reset mail: ${error instanceof Error ? error.message : String(error)}`,
114
+ );
115
+ });
90
116
 
91
117
  // Return user
92
118
  return user;