@vsaas/loopback 12.1.1 → 12.2.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/README.md +6 -0
- package/common/models/rate-limit-state.json +121 -0
- package/common/models/two-factor-challenge.json +112 -0
- package/common/models/two-factor-credential.json +117 -0
- package/common/models/two-factor-recovery-code.json +63 -0
- package/common/models/user.json +30 -0
- package/dist/_virtual/_rolldown/runtime.cjs +9 -2
- package/dist/common/models/access-token.d.cts +3 -0
- package/dist/common/models/acl.cjs +1 -3
- package/dist/common/models/acl.d.cts +1 -0
- package/dist/common/models/application.d.cts +3 -0
- package/dist/common/models/change.cjs +21 -18
- package/dist/common/models/change.d.cts +1 -0
- package/dist/common/models/checkpoint.d.cts +3 -0
- package/dist/common/models/email.d.cts +3 -0
- package/dist/common/models/key-value-model.d.cts +3 -0
- package/dist/common/models/rate-limit-state.cjs +7 -0
- package/dist/common/models/rate-limit-state.d.cts +3 -0
- package/dist/common/models/rate-limit-state2.cjs +109 -0
- package/dist/common/models/role-mapping.d.cts +3 -0
- package/dist/common/models/role.cjs +12 -8
- package/dist/common/models/role.d.cts +1 -0
- package/dist/common/models/scope.d.cts +3 -0
- package/dist/common/models/two-factor-challenge.cjs +7 -0
- package/dist/common/models/two-factor-challenge.d.cts +3 -0
- package/dist/common/models/two-factor-challenge2.cjs +108 -0
- package/dist/common/models/two-factor-credential.cjs +7 -0
- package/dist/common/models/two-factor-credential.d.cts +3 -0
- package/dist/common/models/two-factor-credential2.cjs +103 -0
- package/dist/common/models/two-factor-recovery-code.cjs +7 -0
- package/dist/common/models/two-factor-recovery-code.d.cts +3 -0
- package/dist/common/models/two-factor-recovery-code2.cjs +57 -0
- package/dist/common/models/user.cjs +258 -21
- package/dist/common/models/user.d.cts +3 -0
- package/dist/common/models/user2.cjs +30 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +3 -0
- package/dist/lib/access-context.d.cts +33 -0
- package/dist/lib/application.d.cts +1 -0
- package/dist/lib/authentication-security-initializer.cjs +351 -0
- package/dist/lib/authentication-security-initializer.d.cts +4 -0
- package/dist/lib/builtin-models.cjs +12 -0
- package/dist/lib/builtin-models.d.cts +3 -0
- package/dist/lib/configure-shared-methods.cjs +7 -4
- package/dist/lib/configure-shared-methods.d.cts +3 -0
- package/dist/lib/connectors/base-connector.d.cts +6 -0
- package/dist/lib/connectors/mail-direct-transport.cjs +26 -23
- package/dist/lib/connectors/mail-direct-transport.d.cts +22 -0
- package/dist/lib/connectors/mail-stub-transport.d.cts +14 -0
- package/dist/lib/connectors/mail.d.cts +7 -0
- package/dist/lib/connectors/memory.d.cts +6 -0
- package/dist/lib/current-context.d.cts +3 -0
- package/dist/lib/globalize.d.cts +10 -0
- package/dist/lib/loopback.cjs +6 -0
- package/dist/lib/loopback.d.cts +1 -0
- package/dist/lib/model.d.cts +1 -0
- package/dist/lib/persisted-model.cjs +7 -12
- package/dist/lib/persisted-model.d.cts +1 -0
- package/dist/lib/rate-limit/algorithms/common.cjs +37 -0
- package/dist/lib/rate-limit/algorithms/failure-lockout.cjs +70 -0
- package/dist/lib/rate-limit/algorithms/fixed-window.cjs +61 -0
- package/dist/lib/rate-limit/algorithms/index.cjs +55 -0
- package/dist/lib/rate-limit/config.cjs +107 -0
- package/dist/lib/rate-limit/errors.cjs +58 -0
- package/dist/lib/rate-limit/index.cjs +15 -0
- package/dist/lib/rate-limit/index.d.cts +1 -0
- package/dist/lib/rate-limit/initializer.cjs +92 -0
- package/dist/lib/rate-limit/initializer.d.cts +4 -0
- package/dist/lib/rate-limit/limiter.cjs +368 -0
- package/dist/lib/rate-limit/registry.cjs +92 -0
- package/dist/lib/rate-limit/remote.cjs +180 -0
- package/dist/lib/rate-limit/remote.d.cts +6 -0
- package/dist/lib/rate-limit/stores/persisted-model.cjs +353 -0
- package/dist/lib/rate-limit/subject.cjs +127 -0
- package/dist/lib/rate-limit/types.cjs +1 -0
- package/dist/lib/rate-limit/types.d.cts +167 -0
- package/dist/lib/registry.d.cts +1 -0
- package/dist/lib/runtime.d.cts +10 -0
- package/dist/lib/server-app.d.cts +3 -0
- package/dist/lib/two-factor/config.cjs +199 -0
- package/dist/lib/two-factor/config.d.cts +55 -0
- package/dist/lib/two-factor/crypto.cjs +68 -0
- package/dist/lib/two-factor/crypto.d.cts +16 -0
- package/dist/lib/two-factor/errors.cjs +58 -0
- package/dist/lib/two-factor/errors.d.cts +18 -0
- package/dist/lib/two-factor/rate-limit.cjs +153 -0
- package/dist/lib/two-factor/rate-limit.d.cts +22 -0
- package/dist/lib/two-factor/recovery-codes.cjs +49 -0
- package/dist/lib/two-factor/recovery-codes.d.cts +16 -0
- package/dist/lib/two-factor/service.cjs +1108 -0
- package/dist/lib/two-factor/service.d.cts +78 -0
- package/dist/lib/two-factor/totp.cjs +50 -0
- package/dist/lib/two-factor/totp.d.cts +14 -0
- package/dist/lib/utils.d.cts +36 -0
- package/dist/package.cjs +46 -10
- package/dist/server/middleware/context.d.cts +3 -0
- package/dist/server/middleware/error-handler.d.cts +3 -0
- package/dist/server/middleware/favicon.d.cts +7 -0
- package/dist/server/middleware/rest.d.cts +6 -0
- package/dist/server/middleware/static.d.cts +3 -0
- package/dist/server/middleware/status.d.cts +19 -0
- package/dist/server/middleware/token.d.cts +3 -0
- package/dist/server/middleware/url-not-found.d.cts +13 -0
- package/docs/api-explorer-details.md +110 -0
- package/docs/application-guide.md +1110 -0
- package/docs/assets/explorer-api.png +0 -0
- package/docs/assets/explorer-endpoint.png +0 -0
- package/docs/assets/explorer-listing.png +0 -0
- package/docs/assets/explorer-req-res.png +0 -0
- package/docs/assets/lb-modules.png +0 -0
- package/docs/assets/loopback-architecture.png +0 -0
- package/docs/assets/loopback-concepts.png +0 -0
- package/docs/assets/loopback_ov.png +0 -0
- package/docs/rate-limiting.md +879 -0
- package/docs/two-factor-authentication.md +611 -0
- package/docs/two-factor-authenticator-loss-runbook.md +117 -0
- package/package.json +56 -20
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# Two-factor authentication
|
|
2
|
+
|
|
3
|
+
LoopBack can add TOTP and single-use recovery-code authentication to a concrete `User` model. The implementation protects enrollment, login challenges, recovery codes, token issuance, and authentication-specific rate limits with durable private state.
|
|
4
|
+
|
|
5
|
+
Two-factor authentication is opt-in per concrete `User` model. Existing models keep their legacy login behavior until a `twoFactor` setting is present.
|
|
6
|
+
|
|
7
|
+
## Required models and initialization
|
|
8
|
+
|
|
9
|
+
Attach the following private models to a durable datasource:
|
|
10
|
+
|
|
11
|
+
- `TwoFactorCredential`
|
|
12
|
+
- `TwoFactorChallenge`
|
|
13
|
+
- `TwoFactorRecoveryCode`
|
|
14
|
+
- `RateLimitState` when `authenticationRateLimit.enabled` is `true`
|
|
15
|
+
|
|
16
|
+
Do not expose these models through REST. The credential store requires a physical unique constraint over `principalType`, `principalId`, and `provider`; model metadata alone does not replace the database constraint.
|
|
17
|
+
|
|
18
|
+
After all models and datasources have been attached, initialize authentication security before accepting traffic:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
await app.initializeAuthenticationSecurity();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The initializer validates every concrete `User` model, registers its private stores and keyrings, initializes general rate limiting, checks model capabilities, and installs the authentication hooks. It supports promises and callbacks and is idempotent.
|
|
25
|
+
|
|
26
|
+
LoopBack also calls `initializeAuthenticationSecurity()` automatically once the application emits `booted`; an initialization failure is reported through the `authenticationSecurityError` application event and does not crash the server. The explicit call above is for custom boot flows that must await readiness before listening, and for tests.
|
|
27
|
+
|
|
28
|
+
The current state is exposed as `app.authenticationSecurityReadiness` (`{ ready, error }`). Custom endpoints that depend on two-factor state can guard themselves with `app.assertAuthenticationSecurityReady(UserModel)`, which throws `AUTHENTICATION_SECURITY_UNAVAILABLE` when the model requires security that has not finished initializing.
|
|
29
|
+
|
|
30
|
+
Do not call `initializeRateLimiting()` first when authentication security is configured. `initializeAuthenticationSecurity()` owns the complete startup sequence. Configured authentication endpoints fail closed with `AUTHENTICATION_SECURITY_UNAVAILABLE` until initialization succeeds.
|
|
31
|
+
|
|
32
|
+
## Basic configuration
|
|
33
|
+
|
|
34
|
+
Settings belong to the concrete model:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
SecureUser.settings.twoFactor = {
|
|
38
|
+
mode: 'enroll-and-verify',
|
|
39
|
+
issuer: 'Xompass',
|
|
40
|
+
challengeTtl: 300,
|
|
41
|
+
verificationLeaseTtl: 30,
|
|
42
|
+
enrollmentTtl: 600,
|
|
43
|
+
maxAttempts: 5,
|
|
44
|
+
maxClockSkewSteps: 1,
|
|
45
|
+
totp: {
|
|
46
|
+
algorithm: 'SHA1',
|
|
47
|
+
digits: 6,
|
|
48
|
+
period: 30,
|
|
49
|
+
secretBytes: 20,
|
|
50
|
+
},
|
|
51
|
+
recoveryCodeCount: 10,
|
|
52
|
+
recoveryCodeBytes: 16,
|
|
53
|
+
loginContextMaxBytes: 8192,
|
|
54
|
+
sessions: {
|
|
55
|
+
onEnable: 'revoke-others',
|
|
56
|
+
onDisable: 'revoke-all',
|
|
57
|
+
onRecoveryCodesRegenerated: 'revoke-others',
|
|
58
|
+
},
|
|
59
|
+
encryption: {
|
|
60
|
+
currentKeyId: '2026-01',
|
|
61
|
+
keys: {
|
|
62
|
+
'2026-01': process.env.TWO_FACTOR_ENCRYPTION_KEY,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
recoveryCodeHmac: {
|
|
66
|
+
currentKeyId: '2026-01',
|
|
67
|
+
keys: {
|
|
68
|
+
'2026-01': process.env.TWO_FACTOR_RECOVERY_HMAC_KEY,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
principalBindingHmac: {
|
|
72
|
+
currentKeyId: '2026-01',
|
|
73
|
+
keys: {
|
|
74
|
+
'2026-01': process.env.TWO_FACTOR_BINDING_HMAC_KEY,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Load key material through the application's normal configuration and secret-management layer. Do not commit it to model files.
|
|
81
|
+
|
|
82
|
+
## Operating modes
|
|
83
|
+
|
|
84
|
+
| Mode | Login behavior | Management behavior |
|
|
85
|
+
| ------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `disabled` | Does not require a second factor. Startup fails if enabled credentials already exist. | Enrollment and credential-management operations are unavailable. |
|
|
87
|
+
| `verify-only` | Existing enabled credentials are enforced. | New enrollment is disabled; verification and existing-credential operations remain available. |
|
|
88
|
+
| `enroll-and-verify` | Existing enabled credentials are enforced. Users without an enabled credential log in normally. | Full enrollment and credential management are available. |
|
|
89
|
+
|
|
90
|
+
If the complete `twoFactor` setting is absent, the model retains legacy behavior and its two-factor remote methods are disabled.
|
|
91
|
+
|
|
92
|
+
Use `verify-only` as the rollback mode when verification must remain enforced but new enrollment should stop. Do not switch to `disabled` while enabled credentials exist.
|
|
93
|
+
|
|
94
|
+
In `disabled` mode, the remaining two-factor options are not used or validated. This makes it suitable for installing schemas and code before provisioning production keyrings, but not for bypassing credentials that have already been enabled.
|
|
95
|
+
|
|
96
|
+
## Two-factor options
|
|
97
|
+
|
|
98
|
+
All public duration values in `twoFactor` are in **seconds**.
|
|
99
|
+
|
|
100
|
+
### General and lifecycle options
|
|
101
|
+
|
|
102
|
+
| Option | Default | Valid values | Description |
|
|
103
|
+
| ---------------------- | ---------: | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
104
|
+
| `mode` | `disabled` | `disabled`, `verify-only`, `enroll-and-verify` | Enables verification and/or enrollment for the model. |
|
|
105
|
+
| `issuer` | — | Non-empty string | Issuer included in the TOTP URI and authenticator label. Required in active modes. |
|
|
106
|
+
| `challengeTtl` | `300` | `60`–`600` | Lifetime of an external login challenge. |
|
|
107
|
+
| `verificationLeaseTtl` | `30` | `1` to `challengeTtl - 1` | Short lease used while one worker verifies and consumes a challenge. |
|
|
108
|
+
| `enrollmentTtl` | `600` | `60`–`3600` | Lifetime of an unconfirmed enrollment. |
|
|
109
|
+
| `maxAttempts` | `5` | `1`–`10` | Maximum invalid-code attempts for an external login challenge. |
|
|
110
|
+
| `maxClockSkewSteps` | `1` | `0`–`2` | Number of adjacent TOTP time steps accepted on each side of the current step. |
|
|
111
|
+
| `recoveryCodeCount` | `10` | `1`–`32` | Number of recovery codes issued on enable or regeneration. |
|
|
112
|
+
| `recoveryCodeBytes` | `16` | `10`–`32` | Random bytes used to generate each recovery code. This controls entropy, not the displayed character count directly. |
|
|
113
|
+
| `loginContextMaxBytes` | `8192` | `256`–`65536` | Maximum serialized encrypted login context stored with a challenge. |
|
|
114
|
+
|
|
115
|
+
Increasing clock skew improves tolerance of incorrectly synchronized devices but expands the code-validity window. Keep server clocks synchronized and use the smallest value compatible with clients.
|
|
116
|
+
|
|
117
|
+
### TOTP options
|
|
118
|
+
|
|
119
|
+
| Option | Default | Valid values | Description |
|
|
120
|
+
| ------------------ | ------: | ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
121
|
+
| `totp.algorithm` | `SHA1` | `SHA1`, `SHA256` | HMAC algorithm advertised in the TOTP URI and used for verification. Confirm client-app support before choosing SHA-256. |
|
|
122
|
+
| `totp.digits` | `6` | `6`, `8` | Number of digits in each TOTP code. |
|
|
123
|
+
| `totp.period` | `30` | `15`–`120` seconds | Duration of each TOTP step. |
|
|
124
|
+
| `totp.secretBytes` | `20` | `20`–`64` | Entropy used for a new TOTP secret. |
|
|
125
|
+
|
|
126
|
+
Changing TOTP algorithm, digits, or period does not transparently migrate already enrolled authenticators. Plan re-enrollment before changing these options for existing users.
|
|
127
|
+
|
|
128
|
+
### Session-revocation options
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
sessions: {
|
|
132
|
+
onEnable: 'revoke-others',
|
|
133
|
+
onDisable: 'revoke-all',
|
|
134
|
+
onRecoveryCodesRegenerated: 'revoke-others',
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Each option accepts `revoke-all` or `revoke-others`.
|
|
139
|
+
|
|
140
|
+
| Option | Default | Description |
|
|
141
|
+
| ------------------------------------- | --------------- | ----------------------------------------------------------------- |
|
|
142
|
+
| `sessions.onEnable` | `revoke-others` | Token-revocation policy after enabling two-factor authentication. |
|
|
143
|
+
| `sessions.onDisable` | `revoke-all` | Token-revocation policy after disabling it. |
|
|
144
|
+
| `sessions.onRecoveryCodesRegenerated` | `revoke-others` | Token-revocation policy after replacing recovery codes. |
|
|
145
|
+
|
|
146
|
+
`revoke-others` preserves the authenticated token used for the management operation when that token is available. `revoke-all` also revokes the current token.
|
|
147
|
+
|
|
148
|
+
### Cryptographic keyrings
|
|
149
|
+
|
|
150
|
+
Three independent keyrings protect different data:
|
|
151
|
+
|
|
152
|
+
| Keyring | Key requirement | Purpose |
|
|
153
|
+
| ---------------------- | ------------------------- | ------------------------------------------------------------------------------------------- |
|
|
154
|
+
| `encryption` | Exactly 32 decoded bytes | AES-256-GCM encryption for TOTP secrets and protected challenge context. |
|
|
155
|
+
| `recoveryCodeHmac` | At least 32 decoded bytes | One-way identifiers for recovery codes. Plain recovery codes are returned only when issued. |
|
|
156
|
+
| `principalBindingHmac` | At least 32 decoded bytes | Binds a login challenge to the principal's current authentication material. |
|
|
157
|
+
|
|
158
|
+
Each keyring has:
|
|
159
|
+
|
|
160
|
+
| Option | Description |
|
|
161
|
+
| -------------- | ------------------------------------------------------------------------------------ |
|
|
162
|
+
| `currentKeyId` | Key used for newly created state. It must exist in `keys`. |
|
|
163
|
+
| `keys.<keyId>` | Base64-encoded secret. Retained keys can read or verify older state during rotation. |
|
|
164
|
+
|
|
165
|
+
Key IDs may contain letters, digits, `.`, `_`, and `-`, up to 64 characters. Recovery-code and principal-binding keys must be cryptographically distinct. Use independent random keys in every environment.
|
|
166
|
+
|
|
167
|
+
By default, principal binding uses the user's password hash. A password change therefore invalidates outstanding two-factor login challenges.
|
|
168
|
+
|
|
169
|
+
## Login behavior
|
|
170
|
+
|
|
171
|
+
A user without an enabled two-factor credential receives the normal access token after valid primary credentials.
|
|
172
|
+
|
|
173
|
+
A user with an enabled credential has two supported flows.
|
|
174
|
+
|
|
175
|
+
### Inline verification
|
|
176
|
+
|
|
177
|
+
Include the method and code with normal login credentials:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"email": "user@example.com",
|
|
182
|
+
"password": "correct horse battery staple",
|
|
183
|
+
"twoFactorMethod": "totp",
|
|
184
|
+
"twoFactorCode": "123456"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`twoFactorMethod` accepts `totp` or `recovery`; when omitted it defaults to `totp`. A valid code completes login and returns the access token in the normal response. TOTP verification records the last used time-step counter per credential with a compare-and-swap update, so replaying a captured code is rejected even while the code is still within its validity window.
|
|
189
|
+
|
|
190
|
+
### External challenge
|
|
191
|
+
|
|
192
|
+
When valid primary credentials are submitted without a second-factor code, login responds with:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"error": {
|
|
197
|
+
"statusCode": 403,
|
|
198
|
+
"code": "TWO_FACTOR_REQUIRED",
|
|
199
|
+
"details": {
|
|
200
|
+
"challengeId": "...",
|
|
201
|
+
"methods": ["totp", "recovery"],
|
|
202
|
+
"expiresIn": 300
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Complete the challenge through `POST /Users/two-factor/verify`:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"challengeId": "...",
|
|
213
|
+
"method": "totp",
|
|
214
|
+
"code": "123456"
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`methods` reflects the second factors currently available to the user: only
|
|
219
|
+
`["totp"]` while no recovery-code set is active, and `["totp", "recovery"]`
|
|
220
|
+
otherwise. Clients should render the offered methods from this field instead
|
|
221
|
+
of assuming both are always present.
|
|
222
|
+
|
|
223
|
+
The challenge stores a bounded encrypted login context so successful verification can issue the same kind of token the original login requested. Challenge consumption and recovery-code consumption use compare-and-swap state transitions to prevent replay under concurrency.
|
|
224
|
+
|
|
225
|
+
The `User.createAccessToken` override supports both `(ttl, callback)` and `(ttl, options, callback)`. Existing login options such as relation inclusion are preserved through the two-factor flow.
|
|
226
|
+
|
|
227
|
+
### Challenge lifecycle
|
|
228
|
+
|
|
229
|
+
Every challenge moves through a small durable state machine. The states are useful for monitoring queries and support investigations; the service enforces the transitions with compare-and-swap updates so concurrent verifications cannot double-consume a challenge.
|
|
230
|
+
|
|
231
|
+
| Status | Terminal | Meaning |
|
|
232
|
+
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
|
|
233
|
+
| `pending` | No | Created after valid primary credentials; awaiting second-factor verification. |
|
|
234
|
+
| `verifying` | No | A worker holds the short verification lease while it validates the code. |
|
|
235
|
+
| `consuming` | No | The code was accepted; token issuance and revocation of prior sessions are in progress. |
|
|
236
|
+
| `consumed` | Yes | Verification completed and the access token was issued. |
|
|
237
|
+
| `expired` | Yes | `challengeTtl` elapsed without successful verification. |
|
|
238
|
+
| `locked` | Yes | `maxAttempts` invalid codes were submitted. |
|
|
239
|
+
| `reconciling_no_token` | No | A verification lease expired mid-verification; cleanup releases reservations and re-arms or expires the challenge. |
|
|
240
|
+
| `token_revocation_pending`| Yes | Consumption was interrupted before token revocation could be confirmed; cleanup quarantines these. |
|
|
241
|
+
| `token_failed` | Yes | A quarantined token was confirmed revoked by cleanup; operator reconciliation is complete. |
|
|
242
|
+
|
|
243
|
+
The non-terminal states are transient and self-healing. Alert on challenges that remain in `token_revocation_pending`, since those require operator reconciliation (see [Audit and monitoring](#audit-and-monitoring) and the cleanup section below).
|
|
244
|
+
|
|
245
|
+
## Enrollment and management
|
|
246
|
+
|
|
247
|
+
Management endpoints require an authenticated access token, and the service verifies that the token belongs to the target principal. Setup, recovery-code regeneration, and disablement additionally require the current password for reauthentication. Confirmation is the exception: it relies on possession of the pending `enrollmentId` together with a valid authenticator code inside the enrollment lifetime, because proving control of the authenticator at this step is itself the strongest signal.
|
|
248
|
+
|
|
249
|
+
### Start enrollment
|
|
250
|
+
|
|
251
|
+
`POST /Users/two-factor/setup`
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{
|
|
255
|
+
"currentPassword": "correct horse battery staple"
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The response contains `enrollmentId`, `secret`, `keyUri`, and `expiresIn`. The plaintext secret is returned only here.
|
|
260
|
+
|
|
261
|
+
### Confirm enrollment
|
|
262
|
+
|
|
263
|
+
`POST /Users/two-factor/confirm`
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"enrollmentId": "...",
|
|
268
|
+
"code": "123456"
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Successful confirmation enables the credential and returns the initial recovery-code set once. Store or display those codes securely; the server cannot recover their plaintext later.
|
|
273
|
+
|
|
274
|
+
### Regenerate recovery codes
|
|
275
|
+
|
|
276
|
+
`POST /Users/two-factor/recovery-codes`
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"currentPassword": "correct horse battery staple",
|
|
281
|
+
"method": "totp",
|
|
282
|
+
"code": "123456"
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
This invalidates the previous set and returns the replacement codes once.
|
|
287
|
+
|
|
288
|
+
### Disable two-factor authentication
|
|
289
|
+
|
|
290
|
+
`POST /Users/two-factor/disable`
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"currentPassword": "correct horse battery staple",
|
|
295
|
+
"method": "totp",
|
|
296
|
+
"code": "123456"
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The session policy in `sessions.onDisable` is applied after the credential is disabled.
|
|
301
|
+
|
|
302
|
+
Sensitive success and error responses use `Cache-Control: no-store`.
|
|
303
|
+
|
|
304
|
+
## Authentication rate limiting
|
|
305
|
+
|
|
306
|
+
Authentication rate limiting is independent of two-factor mode and is configured per concrete `User` model:
|
|
307
|
+
|
|
308
|
+
```js
|
|
309
|
+
SecureUser.settings.authenticationRateLimit = {
|
|
310
|
+
enabled: true,
|
|
311
|
+
store: 'authentication',
|
|
312
|
+
subjectHash: {
|
|
313
|
+
currentKeyId: '2026-01',
|
|
314
|
+
keys: {
|
|
315
|
+
'2026-01': process.env.AUTH_RATE_LIMIT_SUBJECT_KEY,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
retryAfterGranularity: 30,
|
|
319
|
+
stateTtl: 604800,
|
|
320
|
+
policies: {
|
|
321
|
+
password: {
|
|
322
|
+
policyId: 'authentication-password',
|
|
323
|
+
policyVersion: '1',
|
|
324
|
+
maxFailedAttempts: 5,
|
|
325
|
+
observationWindow: 900,
|
|
326
|
+
blockDuration: 60,
|
|
327
|
+
strategy: 'exponential',
|
|
328
|
+
multiplier: 2,
|
|
329
|
+
maxBlockDuration: 3600,
|
|
330
|
+
backoffResetAfter: 86400,
|
|
331
|
+
},
|
|
332
|
+
secondFactor: {
|
|
333
|
+
maxFailedAttempts: 5,
|
|
334
|
+
observationWindow: 300,
|
|
335
|
+
blockDuration: 30,
|
|
336
|
+
strategy: 'exponential',
|
|
337
|
+
multiplier: 2,
|
|
338
|
+
maxBlockDuration: 900,
|
|
339
|
+
backoffResetAfter: 86400,
|
|
340
|
+
},
|
|
341
|
+
management: {
|
|
342
|
+
maxFailedAttempts: 3,
|
|
343
|
+
observationWindow: 600,
|
|
344
|
+
blockDuration: 300,
|
|
345
|
+
strategy: 'fixed',
|
|
346
|
+
maxBlockDuration: 300,
|
|
347
|
+
backoffResetAfter: 86400,
|
|
348
|
+
},
|
|
349
|
+
challengeIssuance: {
|
|
350
|
+
maxAttempts: 10,
|
|
351
|
+
observationWindow: 300,
|
|
352
|
+
blockDuration: 300,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
source: {
|
|
356
|
+
enabled: true,
|
|
357
|
+
login: {
|
|
358
|
+
maxAttempts: 100,
|
|
359
|
+
observationWindow: 60,
|
|
360
|
+
blockDuration: 60,
|
|
361
|
+
},
|
|
362
|
+
secondFactor: {
|
|
363
|
+
maxAttempts: 60,
|
|
364
|
+
observationWindow: 60,
|
|
365
|
+
blockDuration: 60,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
All durations in `authenticationRateLimit` are in **seconds**.
|
|
372
|
+
|
|
373
|
+
### Top-level authentication options
|
|
374
|
+
|
|
375
|
+
| Option | Default | Description |
|
|
376
|
+
| ----------------------- | --------------: | ------------------------------------------------------------------------------------------------------------------------- |
|
|
377
|
+
| `enabled` | `false` | Only the literal value `true` enables authentication rate limiting. |
|
|
378
|
+
| `store` | — | Name of an application `rateLimit.stores` entry, or an inline store definition. The resolved store must have a namespace. |
|
|
379
|
+
| `subjectHash` | — | HMAC keyring used to pseudonymize users, unknown identifiers, and sources. Keys require at least 32 decoded bytes. |
|
|
380
|
+
| `retryAfterGranularity` | `30` | `Retry-After` rounding granularity, from 1 to 3600 seconds. |
|
|
381
|
+
| `stateTtl` | `604800` | Default state TTL, from 1 second to 365 days. It must cover every observation, maximum block, and backoff-reset duration. |
|
|
382
|
+
| `policies` | Built-in values | Per-bucket policy overrides. |
|
|
383
|
+
| `source` | Enabled | Source-volume policies that complement per-principal or per-identifier policies. |
|
|
384
|
+
|
|
385
|
+
An inline store accepts the same persisted-model provider options documented in [Rate limiting](./rate-limiting.md#stores). A named store reuses the application-level provider and namespace. Use a named store for the optional [Redis component](https://github.com/vsaas/loopback-monorepo/tree/main/loopback-component-rate-limit-redis#two-factor-authentication); inline authentication stores support only the built-in `persisted-model` provider.
|
|
386
|
+
|
|
387
|
+
```js
|
|
388
|
+
store: {
|
|
389
|
+
provider: 'persisted-model',
|
|
390
|
+
model: 'RateLimitState',
|
|
391
|
+
namespace: 'cloud-api-production-authentication',
|
|
392
|
+
maxRetries: 20,
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Startup fails if the resolved store model uses the built-in memory connector
|
|
397
|
+
and the definition does not set `allowMemory: true`. Like every memory-backed
|
|
398
|
+
rate-limit store, that opt-in is intended for tests and single-instance
|
|
399
|
+
development; authentication lockout state must be shared across instances.
|
|
400
|
+
|
|
401
|
+
The bucket determines the algorithm. `password`, `secondFactor`, and `management` are failure lockouts; `challengeIssuance` is a fixed-window volume limit. Do not add an `algorithm` option to these policy objects.
|
|
402
|
+
|
|
403
|
+
### Failure-lockout policy options
|
|
404
|
+
|
|
405
|
+
| Option | Description |
|
|
406
|
+
| ------------------- | -------------------------------------------------------------- |
|
|
407
|
+
| `policyId` | Stable policy identity. A bucket-specific default is provided. |
|
|
408
|
+
| `policyVersion` | Policy revision, default `1`. Change it with semantic changes. |
|
|
409
|
+
| `maxFailedAttempts` | Failure capacity, from 1 to 100. |
|
|
410
|
+
| `observationWindow` | Failure observation window in seconds. |
|
|
411
|
+
| `blockDuration` | Initial block duration in seconds. |
|
|
412
|
+
| `strategy` | `fixed` or `exponential`. |
|
|
413
|
+
| `multiplier` | Exponential multiplier, from 1 to 10. |
|
|
414
|
+
| `maxBlockDuration` | Maximum block duration in seconds. |
|
|
415
|
+
| `backoffResetAfter` | Quiet or success period that resets escalation, in seconds. |
|
|
416
|
+
| `stateTtl` | Optional per-policy TTL override. |
|
|
417
|
+
|
|
418
|
+
Defaults:
|
|
419
|
+
|
|
420
|
+
| Bucket | Attempts | Observation | Initial block | Strategy | Maximum block | Reset after |
|
|
421
|
+
| -------------- | -------: | ----------: | ------------: | -------------- | ------------: | ----------: |
|
|
422
|
+
| `password` | `5` | `900` | `60` | exponential ×2 | `3600` | `86400` |
|
|
423
|
+
| `secondFactor` | `5` | `300` | `30` | exponential ×2 | `900` | `86400` |
|
|
424
|
+
| `management` | `3` | `600` | `300` | fixed | `300` | `86400` |
|
|
425
|
+
|
|
426
|
+
Password failures for a known user consume the principal bucket. Unknown usernames or email addresses consume a pseudonymized identifier bucket so account enumeration does not create state keyed by plaintext identifiers. A successful login resets only the failure state it safely observed; a concurrent newer failure is not erased.
|
|
427
|
+
|
|
428
|
+
### Fixed-window policy options
|
|
429
|
+
|
|
430
|
+
`challengeIssuance` and source-volume policies accept:
|
|
431
|
+
|
|
432
|
+
| Option | Description |
|
|
433
|
+
| ------------------- | --------------------------------- |
|
|
434
|
+
| `policyId` | Stable policy identity. |
|
|
435
|
+
| `policyVersion` | Revision, default `1`. |
|
|
436
|
+
| `maxAttempts` | Capacity, from 1 to 100000. |
|
|
437
|
+
| `observationWindow` | Window duration in seconds. |
|
|
438
|
+
| `blockDuration` | Block duration in seconds. |
|
|
439
|
+
| `stateTtl` | Optional per-policy TTL override. |
|
|
440
|
+
|
|
441
|
+
The default challenge-issuance policy allows 10 challenges per 300 seconds and blocks for 300 seconds.
|
|
442
|
+
|
|
443
|
+
### Source policies
|
|
444
|
+
|
|
445
|
+
Source limits provide an additional volume boundary across many user identifiers:
|
|
446
|
+
|
|
447
|
+
| Option | Default | Description |
|
|
448
|
+
| --------------------- | ------------------------------------------ | ----------------------------------------------- |
|
|
449
|
+
| `source.enabled` | `true` | Enables source buckets. |
|
|
450
|
+
| `source.login` | 100 attempts / 60 seconds, 60-second block | Applies to primary login activity. |
|
|
451
|
+
| `source.secondFactor` | 60 attempts / 60 seconds, 60-second block | Applies to second-factor verification activity. |
|
|
452
|
+
|
|
453
|
+
The default source resolver uses `options.authenticationSource`, then the request IP or remote socket address. If it returns `null`, no source bucket is applied. Ensure the application derives client addresses only from trusted proxy configuration; otherwise a caller may spoof the source or all callers may collapse into a proxy address.
|
|
454
|
+
|
|
455
|
+
Authentication rate-limit store failures always return `AUTHENTICATION_RATE_LIMIT_UNAVAILABLE`; credentials are not evaluated without the required state.
|
|
456
|
+
|
|
457
|
+
## Model hooks
|
|
458
|
+
|
|
459
|
+
Applications can customize identity and login integration through these hooks. Each supports callbacks or promises.
|
|
460
|
+
|
|
461
|
+
### `getTwoFactorPrincipalBindingMaterial(user, options, callback)`
|
|
462
|
+
|
|
463
|
+
Returns the authentication material used to bind challenges to the principal. The default is the current password hash. Override it when primary authentication is external or passwordless.
|
|
464
|
+
|
|
465
|
+
The returned material must change when primary credentials are revoked or replaced.
|
|
466
|
+
|
|
467
|
+
### `validateTwoFactorLoginPrincipal(user, options, callback)`
|
|
468
|
+
|
|
469
|
+
Performs application-specific checks after the user is resolved and before a challenge or token is issued. The default allows the principal. Override it to reject disabled, locked, deleted, or otherwise ineligible accounts.
|
|
470
|
+
|
|
471
|
+
### `buildTwoFactorLoginContext(credentials, options, callback)`
|
|
472
|
+
|
|
473
|
+
Builds the serializable context stored with an external challenge. The default preserves supported token-creation options. Keep custom context minimal, free of secrets, and within `loginContextMaxBytes`; it is encrypted but still security-sensitive persisted state.
|
|
474
|
+
|
|
475
|
+
### `resolveAuthenticationRateLimitSource(options, callback)`
|
|
476
|
+
|
|
477
|
+
Returns the canonical source used by source-volume limits. Override it when the deployment uses a trusted gateway, device identity, or another application-specific source signal.
|
|
478
|
+
|
|
479
|
+
## Errors
|
|
480
|
+
|
|
481
|
+
| Code | HTTP status | Meaning |
|
|
482
|
+
| --------------------------------------- | ----------: | ------------------------------------------------------------------------------------ |
|
|
483
|
+
| `TWO_FACTOR_REQUIRED` | `403` | Primary credentials are valid and a second factor is required. |
|
|
484
|
+
| `INVALID_TWO_FACTOR_CHALLENGE` | `401` | Challenge is invalid, expired, consumed, or no longer bound to the principal. |
|
|
485
|
+
| `INVALID_TWO_FACTOR_CODE` | `401` | TOTP or recovery code is invalid. |
|
|
486
|
+
| `AUTHENTICATION_RATE_LIMITED` | `429` | An authentication bucket is blocked. |
|
|
487
|
+
| `AUTHENTICATION_RATE_LIMIT_UNAVAILABLE` | `503` | Required authentication rate-limit state is unavailable. |
|
|
488
|
+
| `AUTHENTICATION_SECURITY_UNAVAILABLE` | `503` | Authentication security did not initialize or required durable state is unavailable. |
|
|
489
|
+
| `TWO_FACTOR_NOT_CONFIGURED` | `404` | The concrete model has no two-factor configuration. |
|
|
490
|
+
| `TWO_FACTOR_ENROLLMENT_DISABLED` | `403` | Current mode does not permit enrollment. |
|
|
491
|
+
| `TWO_FACTOR_ALREADY_ENABLED` | `409` | The principal already has an enabled credential. |
|
|
492
|
+
| `TWO_FACTOR_ENROLLMENT_EXPIRED` | `410` | Enrollment was not confirmed before expiry, or the identifier does not match the pending enrollment. |
|
|
493
|
+
| `TWO_FACTOR_NOT_ENABLED` | `409` | The principal has no enabled credential for the requested operation. |
|
|
494
|
+
| `AUTHORIZATION_REQUIRED` | `401` | An authenticated management token is required. |
|
|
495
|
+
|
|
496
|
+
Responses with status `429` include a `Retry-After` header, rounded up to the configured `retryAfterGranularity`. Login requests can also fail with ordinary credential errors that keep their usual meanings: `LOGIN_FAILED`, `LOGIN_FAILED_EMAIL_NOT_VERIFIED`, and the request-validation codes `USERNAME_EMAIL_REQUIRED`, `REALM_REQUIRED`, `INVALID_REALM`, `INVALID_USERNAME`, and `INVALID_EMAIL`. These are produced by the shared login validation path before two-factor handling begins.
|
|
497
|
+
|
|
498
|
+
Clients should handle codes, not error-message text. Do not disclose whether a login identifier exists through different public responses.
|
|
499
|
+
|
|
500
|
+
## Cleanup and recovery
|
|
501
|
+
|
|
502
|
+
Run cleanup as a recurring maintenance job:
|
|
503
|
+
|
|
504
|
+
```js
|
|
505
|
+
const result = await app.cleanupAuthenticationSecurity({
|
|
506
|
+
now: new Date(),
|
|
507
|
+
terminalRetentionMs: 7 * 24 * 60 * 60 * 1000,
|
|
508
|
+
disabledCredentialRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
509
|
+
consumingTimeoutMs: 10 * 60 * 1000,
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Cleanup options use **milliseconds**, unlike public model configuration.
|
|
514
|
+
|
|
515
|
+
| Option | Default | Description |
|
|
516
|
+
| ------------------------------- | -----------: | -------------------------------------------------------------------------------------------- |
|
|
517
|
+
| `now` | Current time | Reference `Date` for the cleanup pass. |
|
|
518
|
+
| `terminalRetentionMs` | 7 days | Retention for terminal challenges and enrollment operations. |
|
|
519
|
+
| `disabledCredentialRetentionMs` | 30 days | Retention for disabled credential records. |
|
|
520
|
+
| `consumingTimeoutMs` | 10 minutes | Age after which an abandoned in-progress consumption can be recovered or quarantined safely. |
|
|
521
|
+
|
|
522
|
+
The result reports what the pass did through named counters — `expiredChallenges`, `recoveredVerifications`, `quarantinedConsumptions`, `reconciledTokens`, `releasedReservations`, `finalizedReservations`, `removedChallenges`, `expiredEnrollments`, `removedCredentials`, and `removedRecoveryCodes` — so schedules can alert on unexpected movement. Expired enrollments are not deleted immediately: they become disabled with their secrets cleared, and physical removal happens once `disabledCredentialRetentionMs` has passed. Cleanup is safe to schedule on more than one application instance because state transitions use compare-and-swap checks, although one designated worker reduces unnecessary load.
|
|
523
|
+
|
|
524
|
+
User deletion also removes associated private authentication state. If authentication security is configured but unavailable, deletion fails rather than leaving orphaned security state silently.
|
|
525
|
+
|
|
526
|
+
## Audit and monitoring
|
|
527
|
+
|
|
528
|
+
The service emits these lifecycle audit events:
|
|
529
|
+
|
|
530
|
+
| Event | Meaning |
|
|
531
|
+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
532
|
+
| `twoFactorChallengeCreated` | An external login challenge was created. |
|
|
533
|
+
| `twoFactorEnrollmentStarted` | A pending credential and enrollment secret were created. |
|
|
534
|
+
| `twoFactorEnabled` | Enrollment was confirmed and the credential became active. |
|
|
535
|
+
| `twoFactorRecoveryCodesRegenerated` | The active recovery-code set was replaced. |
|
|
536
|
+
| `twoFactorDisabled` | The credential was disabled. |
|
|
537
|
+
| `twoFactorTokenRevocationPending` | A token was issued but the operation could not safely complete or revoke it; operator reconciliation is required. |
|
|
538
|
+
| `twoFactorEncryptionRotated` | A model completed one re-encryption pass and reports migrated, conflicting, and remaining records. |
|
|
539
|
+
| `twoFactorChallengeInvalidationError` | Password change committed, but physical challenge expiration failed. Binding still rejects old challenges; operators must investigate cleanup. |
|
|
540
|
+
|
|
541
|
+
Monitor `authenticationSecurityError` during boot and `authenticationSecurityCleanupError` during maintenance.
|
|
542
|
+
|
|
543
|
+
At minimum, alert on:
|
|
544
|
+
|
|
545
|
+
- repeated `AUTHENTICATION_SECURITY_UNAVAILABLE` or `AUTHENTICATION_RATE_LIMIT_UNAVAILABLE`;
|
|
546
|
+
- unusual enrollment, disablement, or recovery-code regeneration volume;
|
|
547
|
+
- sustained source-level blocking;
|
|
548
|
+
- pending or failed token revocation;
|
|
549
|
+
- cleanup backlog and expired in-progress operations;
|
|
550
|
+
- uniqueness-constraint or compare-and-swap failures.
|
|
551
|
+
|
|
552
|
+
Avoid logging TOTP secrets, recovery codes, submitted codes, password material, encrypted context, or raw rate-limit subjects.
|
|
553
|
+
|
|
554
|
+
## Key rotation
|
|
555
|
+
|
|
556
|
+
### Encryption keys
|
|
557
|
+
|
|
558
|
+
Deploy the new key to every instance, then make it current. Re-encrypt
|
|
559
|
+
credentials and live challenges with the framework migration:
|
|
560
|
+
|
|
561
|
+
```js
|
|
562
|
+
const result = await app.rotateAuthenticationSecurityEncryption({
|
|
563
|
+
batchSize: 100,
|
|
564
|
+
maxBatches: 10_000,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
for (const [modelName, modelResult] of Object.entries(result)) {
|
|
568
|
+
if (modelResult.remainingCredentials || modelResult.remainingChallenges) {
|
|
569
|
+
throw new Error(`Encryption rotation is incomplete for ${modelName}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
`batchSize` accepts 1–1000 records per pass (default `100`), and `maxBatches`
|
|
575
|
+
caps how many batches one invocation processes (default and maximum `10_000`).
|
|
576
|
+
Each phase stops early when a full batch makes no progress, so rerunning the
|
|
577
|
+
operation converges even under concurrent credential changes. The operation
|
|
578
|
+
uses compare-and-set against the original encrypted envelope and
|
|
579
|
+
key ID. It is safe to rerun and reports conflicts instead of overwriting a
|
|
580
|
+
concurrent credential or challenge transition. It covers enabled credentials
|
|
581
|
+
and live challenges; terminal challenges are never re-encrypted. Keep every old key until all
|
|
582
|
+
instances use the new configuration, the result reports zero remaining
|
|
583
|
+
records, and expired terminal challenges have passed their retention period.
|
|
584
|
+
Never remove an old key merely because `currentKeyId` changed.
|
|
585
|
+
|
|
586
|
+
### Recovery-code HMAC keys
|
|
587
|
+
|
|
588
|
+
Existing recovery codes reference the key that created their HMAC identifier. Keep the old key until affected users regenerate their recovery codes, then verify no live records reference it before removal.
|
|
589
|
+
|
|
590
|
+
### Principal-binding HMAC keys
|
|
591
|
+
|
|
592
|
+
Retain old keys for at least the maximum challenge lifetime, verification lease, deployment skew, and cleanup margin. Removing them early invalidates outstanding challenges.
|
|
593
|
+
|
|
594
|
+
Deploy key additions everywhere before switching the current ID, and remove old keys only after all instances and persisted state are safe.
|
|
595
|
+
|
|
596
|
+
For users who have neither their authenticator nor a recovery code, follow the
|
|
597
|
+
[lost-authenticator recovery runbook](./two-factor-authenticator-loss-runbook.md).
|
|
598
|
+
Password reset intentionally does not disable two-factor authentication.
|
|
599
|
+
|
|
600
|
+
## Deployment checklist
|
|
601
|
+
|
|
602
|
+
- Create private durable models and physical uniqueness constraints.
|
|
603
|
+
- Apply required schema migrations before enabling the feature.
|
|
604
|
+
- Load three independent random keyrings from a secret manager.
|
|
605
|
+
- Configure the concrete `User` model and initialize before listening.
|
|
606
|
+
- Start with `enroll-and-verify` only when enrollment endpoints and client UX are ready.
|
|
607
|
+
- Confirm token revocation works for every configured session policy.
|
|
608
|
+
- Configure trusted proxy handling or override the authentication-source resolver.
|
|
609
|
+
- Schedule cleanup and monitor security and audit events.
|
|
610
|
+
- Test TOTP and recovery login, challenge expiry, replay, concurrent verification, password changes, disabled users, rate-limit boundaries, datastore failures, token revocation, and key rotation.
|
|
611
|
+
- Use `verify-only`, not `disabled`, for a rollback that must continue enforcing existing credentials.
|