@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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationBootstrap, Optional } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
4
|
+
import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.mapper';
|
|
5
|
+
import { isLegacyEndpointEnabled } from './helpers/legacy-endpoints.helper';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Says out loud, once at boot, that this deployment still exposes Legacy Auth.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS EXISTS
|
|
11
|
+
*
|
|
12
|
+
* Legacy Auth is superseded by IAM (Better-Auth) and slated for removal (see
|
|
13
|
+
* `.claude/rules/module-deprecation.md`). Until 11.38.0 nothing ever mentioned that a
|
|
14
|
+
* project was still running it: `auth.legacyEndpoints.enabled` defaulted to `true`, so a
|
|
15
|
+
* second fully functional password-authentication surface stayed open by inertia, and
|
|
16
|
+
* `betterAuthMigrationStatus.canDisableLegacyAuth` — which exists precisely to say when
|
|
17
|
+
* it can be closed — only answered when somebody thought to ask.
|
|
18
|
+
*
|
|
19
|
+
* Registered only by `CoreAuthModule`, which exists only when a project passes the
|
|
20
|
+
* legacy three-argument `CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(...),
|
|
21
|
+
* envConfig)`. An IAM-only project never sees any of this.
|
|
22
|
+
*
|
|
23
|
+
* It only REPORTS. Turning legacy auth off for a project that still needs it would lock
|
|
24
|
+
* out every user who has not signed in through IAM yet, which is the opposite of a safe
|
|
25
|
+
* default — the migration has to be finished before the door closes, and only the
|
|
26
|
+
* operator can know when that is.
|
|
27
|
+
*/
|
|
28
|
+
@Injectable()
|
|
29
|
+
export class CoreLegacyAuthDeprecationInitializer implements OnApplicationBootstrap {
|
|
30
|
+
protected readonly logger = new Logger(CoreLegacyAuthDeprecationInitializer.name);
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
protected readonly configService: ConfigService,
|
|
34
|
+
@Optional() protected readonly userMapper?: CoreBetterAuthUserMapper,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
onApplicationBootstrap(): void {
|
|
38
|
+
const legacyConfig = this.configService.getFastButReadOnly('auth')?.legacyEndpoints;
|
|
39
|
+
const graphqlOn = isLegacyEndpointEnabled(legacyConfig, 'graphql');
|
|
40
|
+
const restOn = isLegacyEndpointEnabled(legacyConfig, 'rest');
|
|
41
|
+
|
|
42
|
+
if (!graphqlOn && !restOn) {
|
|
43
|
+
// Every endpoint is closed. That is the state the migration aims at — but it is only
|
|
44
|
+
// GOOD if the migration is actually finished, and 11.38.0 made this the DEFAULT. So a
|
|
45
|
+
// project that upgraded without deciding lands here whether or not its users can still
|
|
46
|
+
// sign in.
|
|
47
|
+
//
|
|
48
|
+
// Reporting only the enabled case would mean the warning fires in the safe state and
|
|
49
|
+
// stays silent in the dangerous one. This branch says the other half: you just closed a
|
|
50
|
+
// door some of your users still need.
|
|
51
|
+
void this.reportLockoutRisk();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const surfaces = [graphqlOn && 'GraphQL', restOn && 'REST'].filter(Boolean).join(' + ');
|
|
56
|
+
this.logger.warn(
|
|
57
|
+
`Legacy Auth is ENABLED (${surfaces}). It is deprecated in favour of IAM (Better-Auth) and will be removed. ` +
|
|
58
|
+
'Close it with auth.legacyEndpoints.enabled: false once your users are migrated.',
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Say how far the migration actually is, so the warning carries a next step rather
|
|
62
|
+
// than just a verdict.
|
|
63
|
+
//
|
|
64
|
+
// Deliberately NOT awaited. Nest waits for every onApplicationBootstrap hook before
|
|
65
|
+
// `listen()` resolves, and `getMigrationStatus()` is collection-scale work — so awaiting it
|
|
66
|
+
// would add to time-to-ready on every pod of every rolling deploy, for a log line. A
|
|
67
|
+
// readiness probe that fails while the server counts users is a worse outcome than a
|
|
68
|
+
// warning that arrives a second late.
|
|
69
|
+
void this.reportMigrationStatus();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Warns when legacy auth is closed while users still depend on it.
|
|
74
|
+
*
|
|
75
|
+
* The counterpart to the deprecation warning: that one fires when the door is open, this one
|
|
76
|
+
* when it was just shut on people who cannot get in any other way. Best-effort and detached
|
|
77
|
+
* from the boot, for the same reason as {@link reportMigrationStatus}.
|
|
78
|
+
*/
|
|
79
|
+
protected async reportLockoutRisk(): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
const status = await this.userMapper?.getMigrationStatus?.({ includePendingEmails: false });
|
|
82
|
+
if (!status || status.totalUsers === 0 || status.canDisableLegacyAuth) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.logger.warn(
|
|
87
|
+
`Legacy Auth is DISABLED, but only ${status.fullyMigratedUsers}/${status.totalUsers} users ` +
|
|
88
|
+
`(${status.migrationPercentage}%) are migrated to IAM — the rest cannot sign in at all. ` +
|
|
89
|
+
'Set auth.legacyEndpoints.enabled: true (LEGACY_AUTH_ENABLED=true) until this reaches 100%.',
|
|
90
|
+
);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.logger.debug(
|
|
93
|
+
`Could not read the IAM migration status: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reads and logs the IAM migration progress. Best-effort by construction: it runs detached
|
|
100
|
+
* from the boot, and a failure is reported at debug level rather than surfaced.
|
|
101
|
+
*/
|
|
102
|
+
protected async reportMigrationStatus(): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
// `includePendingEmails: false` — this reporter needs the counts, never the addresses.
|
|
105
|
+
// Collecting them costs two more collection-scale queries, one a guaranteed COLLSCAN,
|
|
106
|
+
// and handling PII that is then discarded.
|
|
107
|
+
const status = await this.userMapper?.getMigrationStatus?.({ includePendingEmails: false });
|
|
108
|
+
if (!status) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (status.canDisableLegacyAuth) {
|
|
112
|
+
this.logger.warn(
|
|
113
|
+
`All ${status.totalUsers} users are migrated to IAM — Legacy Auth can be switched off now ` +
|
|
114
|
+
'(auth.legacyEndpoints.enabled: false).',
|
|
115
|
+
);
|
|
116
|
+
} else {
|
|
117
|
+
this.logger.log(
|
|
118
|
+
`IAM migration status: ${status.fullyMigratedUsers}/${status.totalUsers} users migrated ` +
|
|
119
|
+
`(${status.migrationPercentage}%). Legacy Auth stays required until this reaches 100%.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.logger.debug(
|
|
124
|
+
`Could not read the IAM migration status: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { IAuthLegacyEndpoints } from '../../../common/interfaces/server-options.interface';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two transports legacy auth is reachable over.
|
|
5
|
+
*/
|
|
6
|
+
export type LegacyEndpointTransport = 'graphql' | 'rest';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decides whether the legacy auth endpoints are reachable over one transport.
|
|
10
|
+
*
|
|
11
|
+
* ONE resolver for both transports on purpose: the GraphQL resolver and the REST
|
|
12
|
+
* controller each carried their own copy of this decision, and a rule about which auth
|
|
13
|
+
* endpoints exist is exactly the kind of rule that must not be able to answer
|
|
14
|
+
* differently depending on which door somebody knocks on.
|
|
15
|
+
*
|
|
16
|
+
* **Since 11.38.0 the default is OFF.** Legacy auth is superseded by IAM
|
|
17
|
+
* (Better-Auth), which every project created from the starter has used for a while:
|
|
18
|
+
* `CoreModule.forRoot(envConfig)` never registers the legacy module at all. What
|
|
19
|
+
* changed is the answer for a project that DOES register it and never made a decision
|
|
20
|
+
* about `legacyEndpoints` — previously it kept a second, fully functional
|
|
21
|
+
* password-authentication surface open indefinitely, silently. A second way in that
|
|
22
|
+
* nobody chose is a liability, so it now has to be asked for.
|
|
23
|
+
*
|
|
24
|
+
* Resolution order:
|
|
25
|
+
*
|
|
26
|
+
* | Configuration | Result |
|
|
27
|
+
* |---------------|--------|
|
|
28
|
+
* | `enabled: false` | off, whatever the per-transport flags say |
|
|
29
|
+
* | per-transport flag set (`graphql` / `rest`) | that flag wins |
|
|
30
|
+
* | `enabled: true` | on |
|
|
31
|
+
* | nothing set | **off** (was: on, before 11.38.0) |
|
|
32
|
+
*
|
|
33
|
+
* `enabled: false` stays a hard off switch rather than something a per-transport `true`
|
|
34
|
+
* can reopen: it is the setting a project reached for to close legacy down, and an
|
|
35
|
+
* upgrade must never widen it.
|
|
36
|
+
*
|
|
37
|
+
* @param legacyConfig the `auth.legacyEndpoints` config block, if any
|
|
38
|
+
* @param transport which transport is asking
|
|
39
|
+
* @returns `true` when the legacy endpoints are reachable over that transport, `false` otherwise
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* // In a project that overrides the check on its own resolver
|
|
44
|
+
* protected override checkLegacyGraphQLEnabled(endpointName: string): void {
|
|
45
|
+
* const legacyConfig = this.configService.getFastButReadOnly('auth')?.legacyEndpoints;
|
|
46
|
+
* if (!isLegacyEndpointEnabled(legacyConfig, 'graphql')) {
|
|
47
|
+
* throw new LegacyAuthDisabledException(endpointName);
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Call this rather than re-implementing the table: the two transports resolving the same rule
|
|
53
|
+
* differently is the defect the shared function exists to prevent.
|
|
54
|
+
*/
|
|
55
|
+
export function isLegacyEndpointEnabled(
|
|
56
|
+
legacyConfig: IAuthLegacyEndpoints | undefined,
|
|
57
|
+
transport: LegacyEndpointTransport,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (legacyConfig?.enabled === false) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const perTransport = legacyConfig?.[transport];
|
|
64
|
+
if (typeof perTransport === 'boolean') {
|
|
65
|
+
return perTransport;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return legacyConfig?.enabled === true;
|
|
69
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**For integrating BetterAuth into projects using `@lenne.tech/nest-server`.**
|
|
4
4
|
|
|
5
|
-
> **Estimated time:** 10-15 minutes
|
|
5
|
+
> **Estimated time:** 10-15 minutes (plus Step 0 on a fresh integration)
|
|
6
6
|
|
|
7
7
|
**Need customization?** See [CUSTOMIZATION.md](./CUSTOMIZATION.md) for:
|
|
8
8
|
|
|
@@ -12,6 +12,29 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## Step 0: Install the Peer Dependencies (required from 11.37.0)
|
|
16
|
+
|
|
17
|
+
From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of this package. It is a
|
|
18
|
+
non-optional **peer dependency**, so your project declares and owns the version:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add better-auth@1.7.1 @better-auth/passkey@1.7.1 @better-auth/core@1.7.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
|
|
25
|
+
mixed set fails in ways the type checker does not catch.
|
|
26
|
+
|
|
27
|
+
> **Do not rely on `pnpm install` to remind you.** On pnpm's default `autoInstallPeers: true` the
|
|
28
|
+
> packages are installed silently at whatever version satisfies `>=1.7.1 <1.8.0` — your build stays
|
|
29
|
+
> green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
|
|
30
|
+
|
|
31
|
+
**Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
|
|
32
|
+
too. When this package owned the version, the API and the app could silently end up on different
|
|
33
|
+
better-auth versions with incompatible payload shapes. Owning it in your own manifest makes that a
|
|
34
|
+
single, visible decision.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
15
38
|
## Choose Your Scenario
|
|
16
39
|
|
|
17
40
|
| Scenario | Use When | CoreModule Signature | Steps |
|
|
@@ -112,8 +135,13 @@ The `CoreBetterAuthUserMapper` enables bidirectional password synchronization:
|
|
|
112
135
|
|
|
113
136
|
- User signs up via BetterAuth → password synced to Legacy Auth (bcrypt hash)
|
|
114
137
|
- User changes password → synced between both systems
|
|
138
|
+
- User resets via IAM → mirrored into the legacy store (11.38.0+; see Password Reset below)
|
|
139
|
+
- User resets via the legacy user endpoint → synced into the IAM credential
|
|
115
140
|
- **Without this, users can only authenticate via ONE system!**
|
|
116
141
|
|
|
142
|
+
The mapper is also what the IAM→legacy reset mirror runs through. Without it registered, that
|
|
143
|
+
mirror is skipped — the reset itself still succeeds, and a warning names the divergence.
|
|
144
|
+
|
|
117
145
|
---
|
|
118
146
|
|
|
119
147
|
### 5. Update ServerModule
|
|
@@ -292,6 +320,23 @@ Check these four things when integrating:
|
|
|
292
320
|
60 s), which is the axis that matters against mail-bombing one victim from rotating IPs.
|
|
293
321
|
- [ ] **Your reset page exists and points at `POST /iam/reset-password`** with `{ token, newPassword }`.
|
|
294
322
|
Tokens are valid for 1 h by default.
|
|
323
|
+
- [ ] **The IAM→legacy mirror is wired (11.38.0+).** `emailAndPassword.onPasswordReset` mirrors an
|
|
324
|
+
applied IAM reset into the legacy bcrypt store, for every native reset route (token,
|
|
325
|
+
email-OTP, phone-number), so a deployment running both systems does not keep the OLD
|
|
326
|
+
password valid on the legacy path — including after a reset performed _because_ it leaked.
|
|
327
|
+
Automatic, but it needs `CoreBetterAuthUserMapper` resolvable (see above), and it is skipped
|
|
328
|
+
entirely when no legacy endpoint is enabled. A skipped mirror logs
|
|
329
|
+
`Could not mirror the IAM password reset to the legacy store` — if you see that, the mapper
|
|
330
|
+
is missing, not the reset.
|
|
331
|
+
- [ ] **You did not add your own `options.emailAndPassword.onPasswordReset`** expecting it to
|
|
332
|
+
replace the framework's. It cannot: the two are CHAINED, framework first. That is
|
|
333
|
+
deliberate — a project hook is an addition, never a way to silently switch off credential-
|
|
334
|
+
store parity.
|
|
335
|
+
- [ ] **Password length is enforced client-side too.** The middleware normalizes a plaintext reset
|
|
336
|
+
password to sha256 before Better-Auth sees it, so Better-Auth's own
|
|
337
|
+
`minPasswordLength`/`maxPasswordLength` cannot fire. nest-server checks the raw value it
|
|
338
|
+
receives — but a client that hashes before sending (every lt frontend does) presents a
|
|
339
|
+
64-hex string that carries no length information. The policy has to live in the form.
|
|
295
340
|
- [ ] **`trustedOrigins` contains no wildcard.** `redirectTo` is validated against it and the reset
|
|
296
341
|
redirect carries the token, so a wildcard hands a live token to any origin it admits. The
|
|
297
342
|
framework warns at boot if it finds one.
|
|
@@ -30,6 +30,7 @@ const config = {
|
|
|
30
30
|
- [Features](#features)
|
|
31
31
|
- [Quick Integration](#quick-integration-for-claude-code--ai-assistants)
|
|
32
32
|
- [Project Integration Guide](#project-integration-guide-required-steps)
|
|
33
|
+
- [Boot-Time Behaviour](#boot-time-behaviour)
|
|
33
34
|
- [Configuration](#configuration)
|
|
34
35
|
- [REST API Endpoints](#rest-api-endpoints)
|
|
35
36
|
- [GraphQL API](#graphql-api)
|
|
@@ -1015,6 +1016,61 @@ const config = {
|
|
|
1015
1016
|
- Full flexibility for specialized plugins
|
|
1016
1017
|
- No package updates needed for new Better-Auth plugins
|
|
1017
1018
|
|
|
1019
|
+
## Boot-Time Behaviour
|
|
1020
|
+
|
|
1021
|
+
`CoreBetterAuthService.onModuleInit()` runs two steps against the database before the application
|
|
1022
|
+
starts listening. Both are `protected`, so a subclass can override either — **an override of
|
|
1023
|
+
`onModuleInit()` that does not call `super.onModuleInit()` skips both.**
|
|
1024
|
+
|
|
1025
|
+
### 1. `ensureIndices()` — performance
|
|
1026
|
+
|
|
1027
|
+
Idempotent indices on `session`, `users`, `account` and `verification`, including a TTL index that
|
|
1028
|
+
expires unconsumed verification and password-reset documents. Failures are logged at `warn` and do
|
|
1029
|
+
not block the boot: these help speed, not correctness.
|
|
1030
|
+
|
|
1031
|
+
### 2. `backfillAccountIssuers()` — correctness (11.37.0+)
|
|
1032
|
+
|
|
1033
|
+
From better-auth 1.7 an account is keyed by `(issuer, accountId)` and the sign-in route filters on
|
|
1034
|
+
it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing password
|
|
1035
|
+
user would be locked out by the upgrade**, with a bare 401 and nothing in the log to explain it.
|
|
1036
|
+
This step repairs those rows on the first boot after the upgrade.
|
|
1037
|
+
|
|
1038
|
+
| Property | Behaviour |
|
|
1039
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1040
|
+
| Scope | Credential accounts only. OAuth/SSO rows are reported, never rewritten — see below |
|
|
1041
|
+
| Idempotency | Structural: only rows missing the field are selected |
|
|
1042
|
+
| Repeat cost | A completion marker in `better-auth-backfills` short-circuits later boots to one `_id` lookup. Neither backfill query is indexable, so without it every boot would scan the whole collection |
|
|
1043
|
+
| Schema overrides | Collection and field names are resolved from the running better-auth instance; a customised name is logged at `warn` |
|
|
1044
|
+
| Partial failure | The write is unordered, so one bad row cannot shadow the rest. The marker is **not** written, and the next boot retries |
|
|
1045
|
+
| Failure | Logged at `error`; the boot continues. A server that starts with a loud error beats one that will not start |
|
|
1046
|
+
|
|
1047
|
+
**Log lines to expect on the first boot after upgrading:**
|
|
1048
|
+
|
|
1049
|
+
| Line | Meaning |
|
|
1050
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
1051
|
+
| `Backfilled account.issuer on N credential account(s)` | Success — those N users can sign in again |
|
|
1052
|
+
| `Backfilled N/M credential accounts. X user(s) CANNOT sign in` | Partial. Usually a duplicate `(issuer, accountId)` pair from an earlier migration. Resolve and restart |
|
|
1053
|
+
| `Could not backfill the account issuer: …` | The operation failed entirely. Affected users cannot sign in until it succeeds |
|
|
1054
|
+
| `At least one non-credential account has no "issuer"` | See below — act before the next social sign-in |
|
|
1055
|
+
| `Backfilling the account issuer against a customised schema` | You renamed the account model or issuer field |
|
|
1056
|
+
|
|
1057
|
+
**Why OAuth/SSO rows are not backfilled.** `local:credential` is a pure function of the provider id
|
|
1058
|
+
and therefore derivable. An OAuth issuer is not — it is either the provider's real OIDC issuer or
|
|
1059
|
+
the synthetic `local:oauth:<providerId>` fallback, decided per provider. Guessing would not fail
|
|
1060
|
+
loudly: better-auth falls back to matching the user by the provider-asserted email and implicitly
|
|
1061
|
+
links a **second** account row; if that email has changed, it creates a **new user** and orphans the
|
|
1062
|
+
old account together with its provider tokens, which no unlink will ever remove. So those rows are
|
|
1063
|
+
reported and the decision stays with the project. Set the issuer per provider **before** the first
|
|
1064
|
+
social sign-in after the upgrade — see
|
|
1065
|
+
[migration-guides/11.36.x-to-11.37.0.md](../../../../migration-guides/11.36.x-to-11.37.0.md) §4.
|
|
1066
|
+
|
|
1067
|
+
> **Do not add `issuer` to this package's own reads of the `account` collection**
|
|
1068
|
+
> (`syncPasswordChangeToIam`, `migrateAccountToIam`, `getMigrationStatus`). They filter on
|
|
1069
|
+
> `providerId` alone on purpose, so they keep working on rows the backfill has not reached yet —
|
|
1070
|
+
> which matters most when the backfill has failed.
|
|
1071
|
+
|
|
1072
|
+
---
|
|
1073
|
+
|
|
1018
1074
|
## Module Setup
|
|
1019
1075
|
|
|
1020
1076
|
See the [Project Integration Guide](#project-integration-guide-required-steps) at the top of this document for complete step-by-step instructions.
|
|
@@ -1689,23 +1745,37 @@ When both Legacy Auth and BetterAuth (IAM) are active, passwords are automatical
|
|
|
1689
1745
|
| Sign up via BetterAuth | IAM | Legacy | ✅ Yes |
|
|
1690
1746
|
| Sign up via Legacy Auth | Legacy | IAM | ⚠️ On first IAM sign-in |
|
|
1691
1747
|
| Password reset via Legacy | Legacy | IAM | ✅ Yes |
|
|
1692
|
-
| Password reset via BetterAuth | IAM | Legacy |
|
|
1748
|
+
| Password reset via BetterAuth | IAM | Legacy | ✅ Yes (since 11.38.0) |
|
|
1693
1749
|
| Password change via user update | Legacy | IAM | ✅ Yes |
|
|
1694
1750
|
|
|
1695
1751
|
#### IAM Password Reset → Legacy Sync
|
|
1696
1752
|
|
|
1697
|
-
|
|
1753
|
+
**Automatic since 11.38.0. No configuration, and nothing to implement.**
|
|
1698
1754
|
|
|
1699
|
-
|
|
1755
|
+
This used to be the one direction the framework could not cover, on the reasoning that the
|
|
1756
|
+
plain password is gone once BetterAuth has hashed it with scrypt. That is true of the hash,
|
|
1757
|
+
but the password is still in the request — and the two halves needed to mirror it are simply
|
|
1758
|
+
known in two different places:
|
|
1700
1759
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1760
|
+
| half | known by |
|
|
1761
|
+
| ------------------------ | ------------------------------------------------------------------------ |
|
|
1762
|
+
| the new password | `CoreBetterAuthApiMiddleware`, which sees the request body |
|
|
1763
|
+
| which user it belongs to | `emailAndPassword.onPasswordReset`, which BetterAuth calls with the user |
|
|
1703
1764
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1765
|
+
The middleware normalizes the password on the native reset routes and puts it on an
|
|
1766
|
+
`AsyncLocalStorage` context scoped to exactly that handler call
|
|
1767
|
+
(`core-better-auth-password-reset.registry.ts`); the `onPasswordReset` hook reads it there and
|
|
1768
|
+
calls `syncPasswordToLegacy()`. The mirror therefore covers **every** native reset route —
|
|
1769
|
+
token, email-OTP and phone-number — and writes the same value BetterAuth just hashed, so the
|
|
1770
|
+
two stores cannot drift.
|
|
1706
1771
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1772
|
+
Failure is non-fatal by design: the hook is wrapped, and a failed mirror is logged rather than
|
|
1773
|
+
turned into a failed reset. The IAM credential is already written at that point, and telling a
|
|
1774
|
+
user their reset did not work when it half did is the worse outcome.
|
|
1775
|
+
|
|
1776
|
+
> **Why this matters beyond convenience.** Before 11.38.0 a reset performed _because a password
|
|
1777
|
+
> leaked_ left that password valid on the legacy path. Two stores that disagree about a
|
|
1778
|
+
> credential is not an inconvenience — it is the old password still working somewhere.
|
|
1709
1779
|
|
|
1710
1780
|
### Automatic Sync (No Configuration Required)
|
|
1711
1781
|
|
|
@@ -1895,6 +1965,14 @@ When a user is deleted:
|
|
|
1895
1965
|
db.account.findOne({ userId: ObjectId('...'), providerId: 'credential' });
|
|
1896
1966
|
```
|
|
1897
1967
|
|
|
1968
|
+
From better-auth 1.7 the row must ALSO carry `issuer: 'local:credential'`. A row matching only
|
|
1969
|
+
`providerId` is not usable — better-auth keys accounts by `(issuer, accountId)` and will not find
|
|
1970
|
+
it, so sign-in answers 401 while this query happily reports that the account exists:
|
|
1971
|
+
|
|
1972
|
+
```javascript
|
|
1973
|
+
db.account.findOne({ issuer: 'local:credential', providerId: 'credential', userId: ObjectId('...') });
|
|
1974
|
+
```
|
|
1975
|
+
|
|
1898
1976
|
3. Check server logs for sync warnings:
|
|
1899
1977
|
```
|
|
1900
1978
|
[CoreUserService] Failed to sync password to IAM...
|
|
@@ -1918,57 +1996,16 @@ await betterAuthUserMapper.migrateAccountToIam(email, plainPassword);
|
|
|
1918
1996
|
|
|
1919
1997
|
### Custom Password Reset with Sync
|
|
1920
1998
|
|
|
1921
|
-
|
|
1999
|
+
**Retired in 11.38.0 — do not build this.** This section used to carry a custom
|
|
2000
|
+
`reset-password-sync` controller endpoint, because the framework could not mirror an IAM reset
|
|
2001
|
+
into the legacy store. It now does, automatically, for every native reset route (see
|
|
2002
|
+
[IAM Password Reset → Legacy Sync](#iam-password-reset--legacy-sync)).
|
|
1922
2003
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
@Controller('iam')
|
|
1929
|
-
export class BetterAuthController extends CoreBetterAuthController {
|
|
1930
|
-
constructor(
|
|
1931
|
-
// ... other dependencies
|
|
1932
|
-
private readonly betterAuthUserMapper: CoreBetterAuthUserMapper,
|
|
1933
|
-
) {
|
|
1934
|
-
super(...);
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
/**
|
|
1938
|
-
* Custom password reset that syncs to both auth systems
|
|
1939
|
-
*/
|
|
1940
|
-
@Post('reset-password-sync')
|
|
1941
|
-
@Roles(RoleEnum.S_EVERYONE)
|
|
1942
|
-
async resetPasswordWithSync(
|
|
1943
|
-
@Body() input: { token: string; newPassword: string },
|
|
1944
|
-
): Promise<{ success: boolean }> {
|
|
1945
|
-
// 1. Reset password via BetterAuth native API
|
|
1946
|
-
const api = this.betterAuthService.getApi();
|
|
1947
|
-
await api.resetPassword({
|
|
1948
|
-
body: { token: input.token, newPassword: input.newPassword },
|
|
1949
|
-
});
|
|
1950
|
-
|
|
1951
|
-
// 2. Sync to Legacy Auth using the plain password
|
|
1952
|
-
// Get user email from the token (you may need to decode it or lookup)
|
|
1953
|
-
const userEmail = await this.getUserEmailFromToken(input.token);
|
|
1954
|
-
if (userEmail) {
|
|
1955
|
-
await this.betterAuthUserMapper.syncPasswordToLegacy(
|
|
1956
|
-
'', // iamUserId not needed for email lookup
|
|
1957
|
-
userEmail,
|
|
1958
|
-
input.newPassword,
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
return { success: true };
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
private async getUserEmailFromToken(token: string): Promise<string | null> {
|
|
1966
|
-
// Implementation depends on your token structure
|
|
1967
|
-
// You may need to query the database or decode the token
|
|
1968
|
-
return null;
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
```
|
|
2004
|
+
Re-implementing it is now actively harmful: the middleware has already normalized the body, so
|
|
2005
|
+
a hand-rolled endpoint that calls `normalizePasswordForIam()` again would hash an
|
|
2006
|
+
already-hashed value and store a credential nothing can verify. If you carry such an override
|
|
2007
|
+
from an earlier version, remove it — the framework mirror runs either way, and the second write
|
|
2008
|
+
is what breaks.
|
|
1972
2009
|
|
|
1973
2010
|
**Alternative: Use Legacy Password Reset**
|
|
1974
2011
|
|
|
@@ -53,7 +53,7 @@ function generateSecureSecret(): string {
|
|
|
53
53
|
* Hash a password using Node.js native crypto.scrypt (libuv thread pool).
|
|
54
54
|
* Output format matches Better-Auth: "salt:hash" (both hex encoded).
|
|
55
55
|
*/
|
|
56
|
-
async function nativeScryptHash(password: string): Promise<string> {
|
|
56
|
+
export async function nativeScryptHash(password: string): Promise<string> {
|
|
57
57
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
58
58
|
const normalized = password.normalize('NFKC');
|
|
59
59
|
const key = await new Promise<Buffer>((resolve, reject) => {
|
|
@@ -67,8 +67,14 @@ async function nativeScryptHash(password: string): Promise<string> {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Verify a password against a Better-Auth scrypt hash using Node.js native crypto.scrypt.
|
|
70
|
+
*
|
|
71
|
+
* Exported so tests can ask the PRODUCT whether a stored credential accepts a password, rather
|
|
72
|
+
* than re-declaring the scrypt parameters next to their assertion. A copy drifts silently: change
|
|
73
|
+
* a parameter here and the copy derives a different key, turning every affected assertion into a
|
|
74
|
+
* red with a misleading diagnosis — or, if both drift compatibly, into a green for the wrong
|
|
75
|
+
* reason.
|
|
70
76
|
*/
|
|
71
|
-
async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
|
|
77
|
+
export async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
|
|
72
78
|
const [salt, storedKey] = data.hash.split(':');
|
|
73
79
|
if (!salt || !storedKey) return false;
|
|
74
80
|
const normalized = data.password.normalize('NFKC');
|
|
@@ -128,6 +134,13 @@ export interface CreateBetterAuthOptions {
|
|
|
128
134
|
*/
|
|
129
135
|
onEmailVerified?: OnEmailVerifiedCallback;
|
|
130
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Callback invoked after a password reset has been written to the IAM credential.
|
|
139
|
+
* Injected from CoreBetterAuthModule to mirror the new password into the legacy store,
|
|
140
|
+
* so a deployment running both systems does not end up with two different passwords.
|
|
141
|
+
*/
|
|
142
|
+
onPasswordReset?: OnPasswordResetCallback;
|
|
143
|
+
|
|
131
144
|
/**
|
|
132
145
|
* Callback for sending the password-reset email.
|
|
133
146
|
* Injected from CoreBetterAuthModule to use NestJS services.
|
|
@@ -179,6 +192,51 @@ export interface CreateBetterAuthOptions {
|
|
|
179
192
|
*/
|
|
180
193
|
export type OnEmailVerifiedCallback = (userId: string) => Promise<void>;
|
|
181
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Callback for when a password reset has been APPLIED to the IAM credential.
|
|
197
|
+
*
|
|
198
|
+
* Injected from CoreBetterAuthModule so the new password can also be mirrored into the
|
|
199
|
+
* legacy bcrypt store. Better-Auth reports only WHICH user was reset — the password
|
|
200
|
+
* itself comes from `core-better-auth-password-reset.registry.ts`, which the API
|
|
201
|
+
* middleware fills for the duration of the handler call.
|
|
202
|
+
*
|
|
203
|
+
* Fires for every native reset route (token, email-OTP, phone-number).
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* Combines the framework's `onPasswordReset` with a project-supplied one so neither is lost.
|
|
207
|
+
*
|
|
208
|
+
* Returns an empty object when there is nothing to install, so the caller can spread it
|
|
209
|
+
* unconditionally without writing an `onPasswordReset: undefined` key — Better-Auth resolves
|
|
210
|
+
* the hook by presence, and an explicitly-undefined key reads as "declared, does nothing".
|
|
211
|
+
*
|
|
212
|
+
* The framework hook runs FIRST and its failure does not prevent the project hook: they are
|
|
213
|
+
* independent consequences of the same event, and one broken listener must not silently
|
|
214
|
+
* cancel the other.
|
|
215
|
+
*/
|
|
216
|
+
function chainOnPasswordReset(base: unknown, override: unknown): { onPasswordReset?: OnPasswordResetCallback } {
|
|
217
|
+
const baseHook = typeof base === 'function' ? (base as OnPasswordResetCallback) : undefined;
|
|
218
|
+
const overrideHook = typeof override === 'function' ? (override as OnPasswordResetCallback) : undefined;
|
|
219
|
+
|
|
220
|
+
if (!baseHook) {
|
|
221
|
+
return overrideHook ? { onPasswordReset: overrideHook } : {};
|
|
222
|
+
}
|
|
223
|
+
if (!overrideHook) {
|
|
224
|
+
return { onPasswordReset: baseHook };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
onPasswordReset: async (data) => {
|
|
229
|
+
const results = await Promise.allSettled([baseHook(data), overrideHook(data)]);
|
|
230
|
+
const failure = results.find((result) => result.status === 'rejected');
|
|
231
|
+
if (failure && failure.status === 'rejected') {
|
|
232
|
+
throw failure.reason;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type OnPasswordResetCallback = (data: { user: { email?: string; id: string } }) => Promise<void>;
|
|
239
|
+
|
|
182
240
|
/**
|
|
183
241
|
* Options for creating a better-auth instance
|
|
184
242
|
*/
|
|
@@ -371,8 +429,16 @@ export interface CreateBetterAuthResult {
|
|
|
371
429
|
}
|
|
372
430
|
|
|
373
431
|
export function createBetterAuthInstance(options: CreateBetterAuthOptions): CreateBetterAuthResult | null {
|
|
374
|
-
const {
|
|
375
|
-
|
|
432
|
+
const {
|
|
433
|
+
config,
|
|
434
|
+
db,
|
|
435
|
+
fallbackSecrets,
|
|
436
|
+
onEmailVerified,
|
|
437
|
+
onPasswordReset,
|
|
438
|
+
sendResetPasswordEmail,
|
|
439
|
+
sendVerificationEmail,
|
|
440
|
+
serverEnv,
|
|
441
|
+
} = options;
|
|
376
442
|
|
|
377
443
|
// Return null only if better-auth is explicitly disabled
|
|
378
444
|
// BetterAuth is enabled by default (zero-config)
|
|
@@ -517,6 +583,29 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
517
583
|
// here claimed a mail-less server would keep answering RESET_PASSWORD_DISABLED; it would not.
|
|
518
584
|
// `emailAndPassword.passwordReset: false` is the real off switch, for deployments whose reset
|
|
519
585
|
// policy is support-mediated or SSO-primary.
|
|
586
|
+
// Mirror an applied reset into the legacy bcrypt store.
|
|
587
|
+
//
|
|
588
|
+
// Without this, a deployment running Legacy Auth next to IAM keeps the OLD password
|
|
589
|
+
// valid on the legacy path after a reset — including a reset somebody performed
|
|
590
|
+
// BECAUSE the old password was compromised. Better-Auth tells us which user was
|
|
591
|
+
// reset; the password comes from the API middleware via the reset registry.
|
|
592
|
+
...(onPasswordReset && {
|
|
593
|
+
onPasswordReset: (async (data) => {
|
|
594
|
+
try {
|
|
595
|
+
await onPasswordReset(data);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
// Never fail the reset itself: the IAM credential is already written, and
|
|
598
|
+
// refusing the response would tell the user their reset did not work when it
|
|
599
|
+
// half did. Report it instead.
|
|
600
|
+
logger.error(
|
|
601
|
+
`Failed to mirror the password reset to the legacy store: ${
|
|
602
|
+
error instanceof Error ? error.message : String(error)
|
|
603
|
+
}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}) satisfies OnPasswordResetCallback,
|
|
607
|
+
}),
|
|
608
|
+
|
|
520
609
|
// Mirrors the emailVerification.sendVerificationEmail wiring.
|
|
521
610
|
...(sendResetPasswordEmail &&
|
|
522
611
|
config.emailAndPassword?.passwordReset !== false && {
|
|
@@ -606,6 +695,19 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
606
695
|
// READS. The result is an asymmetric pair — anyone who resets their password can then
|
|
607
696
|
// never sign in again. An explicit override still wins; only `undefined` is ignored.
|
|
608
697
|
password: mergeDefined(base.password, override.password),
|
|
698
|
+
// `onPasswordReset` is subject to the SAME hazard as `password` above, and needs the
|
|
699
|
+
// opposite treatment: it must not be replaceable at all.
|
|
700
|
+
//
|
|
701
|
+
// It is what mirrors an applied IAM reset into the legacy bcrypt store. A project has
|
|
702
|
+
// an entirely reasonable motive to declare its own (audit logging, a notification
|
|
703
|
+
// mail) — and the spread would then drop the framework's, silently restoring the
|
|
704
|
+
// pre-11.38.0 defect where the OLD password stays valid on the legacy path after a
|
|
705
|
+
// reset, including a reset performed BECAUSE it leaked. Both stores report success.
|
|
706
|
+
//
|
|
707
|
+
// So the two are CHAINED rather than merged: the framework mirror always runs, the
|
|
708
|
+
// project hook runs after it. That is a deliberate asymmetry to `password`, where an
|
|
709
|
+
// explicit override wins — a hasher is a choice, a credential-store mirror is not.
|
|
710
|
+
...chainOnPasswordReset(base.onPasswordReset, override.onPasswordReset),
|
|
609
711
|
};
|
|
610
712
|
}
|
|
611
713
|
if (optionsAdvanced && typeof optionsAdvanced === 'object') {
|