@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
|
@@ -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 {
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
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 &&
|
|
250
|
+
if (this.options?.betterAuthUserMapper && passwordForIamSync && dbObject.email) {
|
|
212
251
|
try {
|
|
213
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
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 &&
|
|
426
|
+
if (this.options?.betterAuthUserMapper && passwordForIamSync && oldUser?.email) {
|
|
337
427
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
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.
|
|
119
|
+
roles: [RoleEnum.ADMIN, RoleEnum.S_SELF],
|
|
105
120
|
})
|
|
106
121
|
password?: string = undefined;
|
|
107
122
|
}
|
package/src/core.module.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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;
|