@hilbras/keystone 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +423 -0
  2. package/README.md +85 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +7 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +17 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/auth.d.ts +2 -0
  10. package/dist/plugins/auth.d.ts.map +1 -1
  11. package/dist/plugins/auth.js +21 -7
  12. package/dist/plugins/auth.js.map +1 -1
  13. package/dist/plugins/machinePrincipal.d.ts +28 -0
  14. package/dist/plugins/machinePrincipal.d.ts.map +1 -0
  15. package/dist/plugins/machinePrincipal.js +45 -0
  16. package/dist/plugins/machinePrincipal.js.map +1 -0
  17. package/dist/plugins/rateLimit.d.ts +10 -7
  18. package/dist/plugins/rateLimit.d.ts.map +1 -1
  19. package/dist/plugins/rateLimit.js +75 -36
  20. package/dist/plugins/rateLimit.js.map +1 -1
  21. package/dist/routes/admin/organizations.d.ts.map +1 -1
  22. package/dist/routes/admin/organizations.js +2 -0
  23. package/dist/routes/admin/organizations.js.map +1 -1
  24. package/dist/routes/admin/platform.d.ts.map +1 -1
  25. package/dist/routes/admin/platform.js +14 -1
  26. package/dist/routes/admin/platform.js.map +1 -1
  27. package/dist/routes/apiKeys.d.ts.map +1 -1
  28. package/dist/routes/apiKeys.js +37 -5
  29. package/dist/routes/apiKeys.js.map +1 -1
  30. package/dist/routes/auth.d.ts.map +1 -1
  31. package/dist/routes/auth.js +104 -3
  32. package/dist/routes/auth.js.map +1 -1
  33. package/dist/routes/emailVerification.d.ts.map +1 -1
  34. package/dist/routes/emailVerification.js +2 -0
  35. package/dist/routes/emailVerification.js.map +1 -1
  36. package/dist/routes/federation.d.ts.map +1 -1
  37. package/dist/routes/federation.js +8 -2
  38. package/dist/routes/federation.js.map +1 -1
  39. package/dist/routes/magicLinks.d.ts.map +1 -1
  40. package/dist/routes/magicLinks.js +2 -0
  41. package/dist/routes/magicLinks.js.map +1 -1
  42. package/dist/routes/oauth2.d.ts.map +1 -1
  43. package/dist/routes/oauth2.js +24 -4
  44. package/dist/routes/oauth2.js.map +1 -1
  45. package/dist/routes/password.d.ts.map +1 -1
  46. package/dist/routes/password.js +2 -0
  47. package/dist/routes/password.js.map +1 -1
  48. package/dist/routes/profile.js +2 -2
  49. package/dist/routes/profile.js.map +1 -1
  50. package/dist/routes/scim.d.ts.map +1 -1
  51. package/dist/routes/scim.js +2 -0
  52. package/dist/routes/scim.js.map +1 -1
  53. package/dist/routes/serviceAccounts.d.ts.map +1 -1
  54. package/dist/routes/serviceAccounts.js +17 -1
  55. package/dist/routes/serviceAccounts.js.map +1 -1
  56. package/dist/routes/sessions.js +3 -3
  57. package/dist/routes/sessions.js.map +1 -1
  58. package/dist/routes/smsOtp.d.ts.map +1 -1
  59. package/dist/routes/smsOtp.js +10 -0
  60. package/dist/routes/smsOtp.js.map +1 -1
  61. package/dist/routes/totp.d.ts.map +1 -1
  62. package/dist/routes/totp.js +38 -6
  63. package/dist/routes/totp.js.map +1 -1
  64. package/dist/routes/webauthn.d.ts.map +1 -1
  65. package/dist/routes/webauthn.js +8 -2
  66. package/dist/routes/webauthn.js.map +1 -1
  67. package/dist/services/configuration/profiles.d.ts +26 -0
  68. package/dist/services/configuration/profiles.d.ts.map +1 -1
  69. package/dist/services/configuration/profiles.js +80 -1
  70. package/dist/services/configuration/profiles.js.map +1 -1
  71. package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
  72. package/dist/services/events/subscribers/auditLog.js +38 -3
  73. package/dist/services/events/subscribers/auditLog.js.map +1 -1
  74. package/dist/services/events/types.d.ts +3 -1
  75. package/dist/services/events/types.d.ts.map +1 -1
  76. package/dist/services/events/validate.d.ts +1 -0
  77. package/dist/services/events/validate.d.ts.map +1 -1
  78. package/dist/services/events/validate.js +5 -1
  79. package/dist/services/events/validate.js.map +1 -1
  80. package/dist/services/localRateLimit.d.ts +44 -0
  81. package/dist/services/localRateLimit.d.ts.map +1 -0
  82. package/dist/services/localRateLimit.js +86 -0
  83. package/dist/services/localRateLimit.js.map +1 -0
  84. package/dist/services/refreshTokenState.d.ts +5 -0
  85. package/dist/services/refreshTokenState.d.ts.map +1 -0
  86. package/dist/services/refreshTokenState.js +30 -0
  87. package/dist/services/refreshTokenState.js.map +1 -0
  88. package/dist/services/scopes.d.ts +113 -0
  89. package/dist/services/scopes.d.ts.map +1 -0
  90. package/dist/services/scopes.js +138 -0
  91. package/dist/services/scopes.js.map +1 -0
  92. package/dist/services/setup/token.d.ts +12 -0
  93. package/dist/services/setup/token.d.ts.map +1 -1
  94. package/dist/services/setup/token.js +27 -3
  95. package/dist/services/setup/token.js.map +1 -1
  96. package/dist/services/tokens.d.ts +1 -0
  97. package/dist/services/tokens.d.ts.map +1 -1
  98. package/dist/services/tokens.js +1 -1
  99. package/dist/services/tokens.js.map +1 -1
  100. package/dist/services/trustedProxies.d.ts +24 -0
  101. package/dist/services/trustedProxies.d.ts.map +1 -1
  102. package/dist/services/trustedProxies.js +19 -0
  103. package/dist/services/trustedProxies.js.map +1 -1
  104. package/dist/services/webhooks.d.ts +16 -0
  105. package/dist/services/webhooks.d.ts.map +1 -1
  106. package/dist/services/webhooks.js +48 -3
  107. package/dist/services/webhooks.js.map +1 -1
  108. package/dist/setup-server.js +47 -3
  109. package/dist/setup-server.js.map +1 -1
  110. package/docs/API-REVIEW.md +121 -0
  111. package/docs/API.md +457 -0
  112. package/docs/ARCHITECTURE.md +142 -0
  113. package/docs/CONTRIBUTING.md +61 -0
  114. package/docs/DEPLOYMENT.md +257 -0
  115. package/docs/INTEGRATION.md +336 -0
  116. package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
  117. package/docs/MIGRATION-1.7.md +70 -0
  118. package/docs/MIGRATION-1.8.md +183 -0
  119. package/docs/MIGRATION-1.9.md +200 -0
  120. package/docs/MIGRATION-2.0.md +203 -0
  121. package/docs/MIGRATION-2.4.md +185 -0
  122. package/docs/PERFORMANCE.md +155 -0
  123. package/docs/RBAC.md +100 -0
  124. package/docs/RE-AUDIT.md +72 -0
  125. package/docs/README.md +54 -0
  126. package/docs/RELEASE-1.7.md +53 -0
  127. package/docs/ROADMAP.md +41 -0
  128. package/docs/SECURITY.md +143 -0
  129. package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
  130. package/docs/adrs/002-versioned-event-bus.md +30 -0
  131. package/docs/adrs/003-bullmq-for-background-work.md +20 -0
  132. package/docs/adrs/004-argon2id-password-hashing.md +19 -0
  133. package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
  134. package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
  135. package/docs/security/audit.md +82 -0
  136. package/docs/security/configuration.md +60 -0
  137. package/docs/security/enterprise-sso.md +193 -0
  138. package/docs/security/mtls.md +132 -0
  139. package/docs/security/proxy-security.md +128 -0
  140. package/docs/security/rate-limiting.md +79 -0
  141. package/docs/security/registry-exceptions.md +34 -0
  142. package/docs/security/registry.json +649 -0
  143. package/docs/security/registry.md +657 -0
  144. package/docs/security/scopes.md +45 -0
  145. package/docs/security/supply-chain.md +49 -0
  146. package/docs/security/trust-boundaries.md +111 -0
  147. package/package.json +15 -6
@@ -0,0 +1,306 @@
1
+ # Connect Your Login/Signup Form to Keystone
2
+
3
+ If you already have a login/signup page with email/password and a "Login with Google" button, this guide shows you exactly how to wire it to Keystone.
4
+
5
+ ---
6
+
7
+ ## The big picture
8
+
9
+ Your frontend does **not** talk directly to Google. It talks to Keystone. Keystone handles Google, passwords, tokens, and sessions.
10
+
11
+ ```
12
+ User
13
+ │
14
+ ▼
15
+ Your React login page
16
+ │
17
+ ├── email/password ──► POST /auth/login
18
+ │ │
19
+ │ ├── MFA enabled ──► 401 MFA_REQUIRED + challenge
20
+ │ │ │
21
+ │ │ └── POST /auth/mfa/verify ──► tokens
22
+ │ │
23
+ │ └── no MFA ─────────► session cookie
24
+ │
25
+ └── Google button ───► GET /auth/oauth/google
26
+ │
27
+ ▼
28
+ Keystone
29
+ │
30
+ ▼
31
+ Google OAuth
32
+ │
33
+ ▼
34
+ Keystone creates/updates user
35
+ │
36
+ ▼
37
+ Redirects back to your app
38
+ │
39
+ ▼
40
+ Your app calls GET /auth/me
41
+ ```
42
+
43
+ After either login method, Keystone sets an HTTP-only session cookie. Your frontend uses that cookie to identify the user.
44
+
45
+ ---
46
+
47
+ ## Step 1 — Configure Keystone
48
+
49
+ Edit Keystone's `.env` file:
50
+
51
+ ```env
52
+ # Database and Redis (already set by the setup wizard)
53
+ DATABASE_URL=postgresql://...
54
+ REDIS_URL=redis://localhost:6379
55
+
56
+ # Google OAuth credentials from https://console.cloud.google.com/
57
+ GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
58
+ GOOGLE_CLIENT_SECRET=your-client-secret
59
+
60
+ # Cookie settings — very important
61
+ COOKIE_DOMAIN=localhost
62
+ COOKIE_SECURE=false
63
+
64
+ # Your frontend URL
65
+ ALLOWED_ORIGINS=http://localhost:5173
66
+ AUTH_API_PUBLIC_URL=http://localhost:4001
67
+
68
+ # Optional: where to send users after Google login
69
+ CLIENT_APP_URL=http://localhost:5173
70
+ ```
71
+
72
+ Restart Keystone after changing `.env`.
73
+
74
+ ### Why `COOKIE_DOMAIN=localhost`?
75
+
76
+ Your React app runs on `http://localhost:5173` and Keystone on `http://localhost:4001`. Browsers treat these as the same site only if the cookie domain is `localhost`. For production, put both apps under the same root domain (e.g. `app.yoursite.com` and `api.yoursite.com`) and set `COOKIE_DOMAIN=.yoursite.com`.
77
+
78
+ ---
79
+
80
+ ## Step 2 — Choose how to integrate
81
+
82
+ ### Option A: One-line CDN script (fastest — nothing in your project folder)
83
+
84
+ Add one script tag to your page. The script is served directly by Keystone, so you do not copy any files into your project.
85
+
86
+ ```html
87
+ <script
88
+ src="http://localhost:4001/sdk/keystone-dropin.js"
89
+ data-keystone-url="http://localhost:4001"
90
+ data-keystone-after-login="/dashboard"
91
+ ></script>
92
+ ```
93
+
94
+ Then add IDs/classes to your existing form:
95
+
96
+ ```html
97
+ <form id="keystone-login-form">
98
+ <input class="keystone-email" type="email" />
99
+ <input class="keystone-password" type="password" />
100
+ <button type="submit">Login</button>
101
+ </form>
102
+
103
+ <button id="keystone-google-btn">Login with Google</button>
104
+ ```
105
+
106
+ No other code needed. The script auto-wires the forms and talks to Keystone for you.
107
+
108
+ To connect/register your project with Keystone, add one more line:
109
+
110
+ ```html
111
+ <script>
112
+ Keystone.connect("my-project", "http://localhost:5173/callback")
113
+ .then((c) => console.log("Connected:", c.clientId));
114
+ </script>
115
+ ```
116
+
117
+ ### Option B: Self-hosted drop-in script
118
+
119
+ If you prefer to host the script yourself, copy `examples/drop-in-login/dropin.js` into your project and include it:
120
+
121
+ ```html
122
+ <script>
123
+ window.KEYSTONE_URL = "http://localhost:4001";
124
+ window.KEYSTONE_AFTER_LOGIN = "/dashboard";
125
+ </script>
126
+ <script src="/keystone-dropin.js"></script>
127
+ ```
128
+
129
+ See `examples/drop-in-login/README.md`.
130
+
131
+ ### Option C: React helper
132
+
133
+ Create `keystone-auth.ts` in your React project and copy the code from `examples/login-form-react/keystone-auth.ts`.
134
+
135
+ The key thing: every request uses `credentials: "include"` so the browser sends the session cookie.
136
+
137
+ ---
138
+
139
+ ## Step 3 — Replace your form handlers
140
+
141
+ Before (fake example):
142
+
143
+ ```tsx
144
+ async function handleLogin(e) {
145
+ e.preventDefault();
146
+ const res = await fetch("/api/login", { ... });
147
+ }
148
+ ```
149
+
150
+ After:
151
+
152
+ ```tsx
153
+ import {
154
+ loginWithPassword,
155
+ completeMfaLogin,
156
+ MfaRequiredError,
157
+ registerAccount,
158
+ loginWithGoogle,
159
+ } from "./keystone-auth";
160
+
161
+ const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
162
+
163
+ async function handleLogin(e) {
164
+ e.preventDefault();
165
+ setMfaChallenge(null);
166
+ try {
167
+ const { user } = await loginWithPassword(email, password);
168
+ setUser(user);
169
+ } catch (err) {
170
+ // The password was correct; this account just needs a second factor.
171
+ if (err instanceof MfaRequiredError) {
172
+ setMfaChallenge(err.challenge);
173
+ return;
174
+ }
175
+ setError(err.message);
176
+ }
177
+ }
178
+
179
+ async function handleMfaSubmit(e) {
180
+ e.preventDefault();
181
+ const { user } = await completeMfaLogin(mfaChallenge, code);
182
+ setUser(user);
183
+ setMfaChallenge(null);
184
+ }
185
+
186
+ async function handleSignup(e) {
187
+ e.preventDefault();
188
+ const { user } = await registerAccount(username, email, password, name);
189
+ setUser(user);
190
+ }
191
+
192
+ // Google button just redirects:
193
+ <button onClick={loginWithGoogle}>Login with Google</button>
194
+ ```
195
+
196
+ When `mfaChallenge` is set, render a code field instead of the password form:
197
+
198
+ ```tsx
199
+ {mfaChallenge && (
200
+ <form onSubmit={handleMfaSubmit}>
201
+ <input
202
+ autoFocus
203
+ autoComplete="one-time-code"
204
+ inputMode="numeric"
205
+ value={code}
206
+ onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
207
+ />
208
+ <button type="submit">Verify</button>
209
+ </form>
210
+ )}
211
+ ```
212
+
213
+ The challenge is single-use and expires, so render it only while the user is on
214
+ the code step and send the user back to the password form on failure.
215
+
216
+ See `examples/login-form-react/LoginPage.example.tsx` for a complete working page.
217
+
218
+ ---
219
+
220
+ ## Step 4 — Check login status on page load
221
+
222
+ ```tsx
223
+ import { useEffect, useState } from "react";
224
+ import { getCurrentUser } from "./keystone-auth";
225
+
226
+ function App() {
227
+ const [user, setUser] = useState(null);
228
+
229
+ useEffect(() => {
230
+ getCurrentUser().then(setUser);
231
+ }, []);
232
+
233
+ // ...
234
+ }
235
+ ```
236
+
237
+ `GET /auth/me` returns the current user if the cookie is valid.
238
+
239
+ ---
240
+
241
+ ## Step 5 — Protect your backend API
242
+
243
+ If your backend needs to know who the user is, you have two options.
244
+
245
+ ### Option A: Send the access token to your backend
246
+
247
+ After login, Keystone also returns an `accessToken` in the response body (for `/auth/token-login`) or in cookies. Your frontend can read it and send:
248
+
249
+ ```ts
250
+ fetch("/api/profile", {
251
+ headers: {
252
+ Authorization: `Bearer ${accessToken}`,
253
+ },
254
+ });
255
+ ```
256
+
257
+ Your backend verifies the JWT using Keystone's public keys:
258
+
259
+ ```ts
260
+ import { jwtVerify, createLocalJWKSet } from "jose";
261
+
262
+ const jwks = createLocalJWKSet(await fetch("http://localhost:4001/.well-known/jwks.json").then(r => r.json()));
263
+ const { payload } = await jwtVerify(token, jwks, { issuer: "http://localhost:4001" });
264
+ // payload.sub = user id, payload.email = email
265
+ ```
266
+
267
+ See `examples/login-form-react/backend-example.ts`.
268
+
269
+ ### Option B: Use API keys for server-to-server calls
270
+
271
+ For microservices or scripts, create an API key in Keystone and send:
272
+
273
+ ```ts
274
+ fetch("/api/admin/users", {
275
+ headers: { "X-API-Key": "keystone_xxxxxxxx" },
276
+ });
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Common problems
282
+
283
+ ### CORS error
284
+
285
+ Make sure `ALLOWED_ORIGINS` includes your frontend URL exactly, including the port.
286
+
287
+ ### Cookie not sent
288
+
289
+ Make sure every fetch uses `credentials: "include"` and `COOKIE_DOMAIN` is set correctly.
290
+
291
+ ### Google login fails
292
+
293
+ - Check that the Google OAuth redirect URI is exactly: `http://localhost:4001/auth/callback/google`
294
+ - Make sure `AUTH_API_PUBLIC_URL` matches the URL Google redirects to.
295
+
296
+ ### "Invalid OAuth state"
297
+
298
+ This usually means cookies are blocked. Check the cookie domain and `COOKIE_SECURE` settings.
299
+
300
+ ---
301
+
302
+ ## Next steps
303
+
304
+ - Add roles and permissions in the Keystone admin portal.
305
+ - Use `/v1/authz/check` to ask Keystone "can this user do X?" from your backend.
306
+ - Turn on audit logs and webhooks to track logins.
@@ -0,0 +1,70 @@
1
+ # Migration Guide: v1.6.x to v1.7.0
2
+
3
+ ## Authorization boundary changes
4
+
5
+ ### Platform roles
6
+
7
+ Platform roles are now limited to `owner` and `user`. Use:
8
+
9
+ ```http
10
+ PATCH /v1/admin/platform/users/<userId>/role
11
+ Authorization: Bearer <platform-owner-token>
12
+ Content-Type: application/json
13
+
14
+ {"role":"user"}
15
+ ```
16
+
17
+ The generic platform user PATCH endpoint rejects a `role` field. A platform owner cannot demote the final platform owner.
18
+
19
+ ### Organization membership
20
+
21
+ Organization membership roles are `owner`, `admin`, and `member`. Use:
22
+
23
+ ```http
24
+ PATCH /v1/admin/organizations/<orgId>/members/<userId>
25
+ ```
26
+
27
+ The old organization user PATCH/DELETE endpoints return a migration response (`410`) and cannot change global user state. Organization user read responses are redacted and no longer include password hashes, TOTP secrets, or sensitive metadata.
28
+
29
+ ### Authorization checks
30
+
31
+ Add the organization being evaluated to every `/v1/authz/check` request:
32
+
33
+ ```json
34
+ {
35
+ "organizationId": "<orgId>",
36
+ "resource": "application",
37
+ "action": "read"
38
+ }
39
+ ```
40
+
41
+ The actor must be an authenticated member of that organization.
42
+
43
+ ### Workflows
44
+
45
+ Remove `assign_role`, `add_membership`, `add_app_membership`, `create_organization`, plugin-alias, and arbitrary webhook steps from tenant workflows before deploying v1.7.0. These steps are rejected at creation and blocked at execution for existing definitions. Replace them with an explicitly authorized administrative workflow or an application service call.
46
+
47
+ ## Deployment checklist
48
+
49
+ - [ ] Run `npm ci` and rebuild backend/frontend artifacts.
50
+ - [ ] Run `npm run build` before `npm run db:reencrypt-oidc-secrets -- --allow-unmarked-plaintext`; the packaged migration command runs the compiled `dist` helper and requires an explicit review flag for unmarked legacy values.
51
+ - [ ] Run the security regression suite against PostgreSQL and Redis.
52
+ - [ ] Review the role-constraint migration; legacy invalid platform roles are normalized to `user` and invalid organization roles to `member` before constraints are applied.
53
+ - [ ] Remove unsafe legacy workflow definitions.
54
+ - [ ] Configure a stable high-entropy `KEYSTONE_INTERNAL_API_KEY` for SAML transaction binding.
55
+ - [ ] Configure a stable `KEYSTONE_ENCRYPTION_KEY`; run `npm run db:reencrypt-oidc-secrets -- --allow-unmarked-plaintext` after reviewing legacy rows, then verify encrypted OIDC secrets. The command executes the compiled `dist/db/reencryptOidcSecrets.js` helper.
56
+ - [ ] Review quarantined legacy accounts: migration `0010` marks ambiguous pre-v1.7 unverified accounts `account_review_required` and inactive; do not bulk-reactivate them without review. Platform owners can resolve an individual account through `/v1/admin/platform/users/<userId>/account-review`.
57
+ - [ ] Update SAML/OIDC initiation and metadata URLs to include `organizationId`; callback state is organization-bound and existing enterprise users must already be organization members.
58
+ - [ ] Update clients using `/v1/authz/check` to send `organizationId`.
59
+ - [ ] Ensure users are organization members before using organization-bound OAuth/OIDC clients; cross-tenant client context no longer adds an organization claim.
60
+ - [ ] Send the bound `client_id` when rotating application-bound refresh tokens; mismatches and inactive/unauthorized applications are rejected.
61
+ - [ ] Keep OIDC endpoints on approved public HTTPS hosts. Private endpoints require the explicit `ALLOW_PRIVATE_SSO_ENDPOINTS=true` deployment decision.
62
+ - [ ] Configure `SCIM_BEARER_TOKEN` together with `SCIM_ORG_ID`; the bearer credential is scoped to that one organization and cannot administer platform users globally.
63
+ - [ ] Use the production-safe cookie defaults from `.env.example` (`__Host-` name, no domain, `Secure=true`); override them only for local HTTP development.
64
+ - [ ] Existing users must have an explicit enterprise SSO identity link before tenant SSO can issue a token; platform owners are rejected from tenant SSO.
65
+ - [ ] Move platform-role mutations to the dedicated endpoint.
66
+ - [ ] Treat platform-user deactivation as irreversible account disablement; sessions, refresh tokens, and user API keys are revoked.
67
+ - [ ] Move organization-role mutations to `/members/:userId`.
68
+ - [ ] Review audit consumers for the new authorization event names.
69
+ - [ ] Verify no client depends on internal user fields in organization responses.
70
+ - [ ] Review the documented dependency-audit exception before publishing.
@@ -0,0 +1,183 @@
1
+ # Migrating to Keystone 1.8.0
2
+
3
+ Keystone 1.8.0 makes multi-factor authentication enforceable. In 1.7.x, TOTP
4
+ was advisory: a user with `totpEnabled = true` could sign in with only a
5
+ password, and when a code was supplied it was checked *after* tokens had already
6
+ been minted. 1.8.0 removes both behaviours.
7
+
8
+ Review this page before upgrading a production deployment.
9
+
10
+ ---
11
+
12
+ ## What changes for clients
13
+
14
+ ### `POST /auth/login` and `POST /auth/token-login`
15
+
16
+ The request body is unchanged, except that the undocumented `totp_code` field is
17
+ gone and is now ignored.
18
+
19
+ For accounts **without** MFA, the response is unchanged.
20
+
21
+ For accounts **with** MFA, the response changes from `200` to `401`:
22
+
23
+ ```diff
24
+ - 200 OK
25
+ - { "user": { ... } }
26
+ + 401 Unauthorized
27
+ + {
28
+ + "error": "Multi-factor authentication required",
29
+ + "code": "MFA_REQUIRED",
30
+ + "mfaRequired": true,
31
+ + "challenge": "0hV3...",
32
+ + "expiresAt": "2026-01-01T00:05:00.000Z",
33
+ + "methods": ["totp", "backup_code"]
34
+ + }
35
+ ```
36
+
37
+ No access token, refresh token, or session cookie is issued at this point.
38
+
39
+ ### `POST /auth/mfa/verify` (new)
40
+
41
+ ```http
42
+ POST /auth/mfa/verify
43
+ { "challenge": "0hV3...", "code": "123456", "factor": "totp" }
44
+ ```
45
+
46
+ On success it returns the same shape as a completed login, plus the factor that
47
+ was used. From a `login` flow, session cookies are also set.
48
+
49
+ | Code | Meaning |
50
+ | --- | --- |
51
+ | `MFA_CHALLENGE_INVALID` | Unknown challenge |
52
+ | `MFA_CHALLENGE_EXPIRED` | Past `expiresAt` |
53
+ | `MFA_CHALLENGE_REPLAYED` | Already consumed |
54
+ | `MFA_CHALLENGE_LOCKED` | Attempt budget exhausted |
55
+ | `MFA_INVALID_CODE` | Factor did not match |
56
+ | `MFA_NOT_REQUIRED` | Factor disabled while the challenge was open |
57
+
58
+ ### `POST /auth/totp/*`
59
+
60
+ | Endpoint | 1.7.x | 1.8.0 |
61
+ | --- | --- | --- |
62
+ | `POST /auth/totp/enroll` | `{ }` | `{ password }` — step-up required |
63
+ | `POST /auth/totp/verify` | `{ code }` | `{ password, code }` — step-up required; also revokes existing sessions and refresh tokens |
64
+ | `POST /auth/totp/backup` | Consumed a backup code | **Regenerates** backup codes; requires `{ password, code }` |
65
+ | `POST /auth/totp/disable` | `{ code }` | `{ password, code }` — step-up required; also destroys backup codes |
66
+ | `POST /auth/totp/backup/verify` | — | New. Consumes a backup code without establishing a session |
67
+ | `POST /auth/webauthn/register/verify` | `{ response }` | `{ response, password? }` — password required when TOTP is enabled |
68
+
69
+ ### Step-up on factor management
70
+
71
+ Every endpoint that changes how an account proves its identity now requires the
72
+ account **password** in the body in addition to a valid session. This closes a
73
+ gap where a leaked 15-minute access token was enough to enroll an attacker's own
74
+ authenticator and take permanent control of the account's second factor.
75
+
76
+ Step-up failures return `401 STEP_UP_REQUIRED` (no password supplied) or
77
+ `401 INVALID_CREDENTIALS` (wrong password), and count toward the account lockout.
78
+
79
+ Update any UI that enrolls or disables MFA to prompt for the password.
80
+
81
+ If your integration used `POST /auth/totp/backup` to burn a recovery code, move
82
+ to `POST /auth/totp/backup/verify`.
83
+
84
+ ### Access-token claims
85
+
86
+ Sessions that completed MFA carry three additional claims:
87
+
88
+ ```json
89
+ {
90
+ "mfa_verified": true,
91
+ "mfa_factor": "totp",
92
+ "amr": ["password", "totp"]
93
+ }
94
+ ```
95
+
96
+ `mfa_factor` is one of `totp`, `backup_code`, `webauthn`, or `session`.
97
+ Claims are only additive; existing verifiers that ignore unknown claims are
98
+ unaffected.
99
+
100
+ ---
101
+
102
+ ## What changes for operators
103
+
104
+ ### Enforcing MFA on existing accounts
105
+
106
+ Migrations do **not** enable MFA automatically. To require a second factor for
107
+ an account:
108
+
109
+ 1. The user enrolls via `POST /auth/totp/enroll` and confirms with
110
+ `POST /auth/totp/verify`.
111
+ 2. Confirming revokes every existing refresh token and session for that account.
112
+
113
+ Existing access tokens remain valid until they expire
114
+ (`ACCESS_TOKEN_TTL_SECONDS`, default 900s). Shorten that value before a rollout
115
+ if you need immediate revocation.
116
+
117
+ ### New configuration
118
+
119
+ | Variable | Default | Purpose |
120
+ | --- | --- | --- |
121
+ | `MFA_CHALLENGE_TTL_SECONDS` | `300` | Lifetime of a login MFA challenge |
122
+ | `MFA_MAX_ATTEMPTS` | `5` | Factor attempts allowed per challenge |
123
+ | `TOTP_BACKUP_CODE_TTL_SECONDS` | `7776000` (90 days) | Backup-code lifetime |
124
+
125
+ ### Behavior of alternate login methods
126
+
127
+ | Method | Result for an MFA-enabled account |
128
+ | --- | --- |
129
+ | Password (`/auth/login`, `/auth/token-login`) | Challenge required |
130
+ | WebAuthn / passkey | Succeeds; the assertion is itself a possession factor |
131
+ | Magic link | `403 MFA_REQUIRED` — does not downgrade to mailbox-only access |
132
+ | SAML ACS | `403 mfa_required`; use password + factor |
133
+ | Enterprise OIDC, federation, social OAuth | `mfa_required` error; use password + factor |
134
+ | Passkey registered after TOTP was enabled | `403 MFA_REQUIRED` — treated as a single factor |
135
+ | OAuth2 code exchange | `mfa_required` if the approving session had no recorded factor |
136
+
137
+ A session created **before** MFA was enabled carries no factor, so it cannot be
138
+ refreshed into a new session. Users must complete a fresh password + factor
139
+ login.
140
+
141
+ ### Backup codes
142
+
143
+ Backup codes are regenerated in a new format on the next enrollment or
144
+ regeneration: 20 hex characters grouped as `XXXXX-XXXXX-XXXXX-XXXXX` (80 bits),
145
+ stored as a keyed (peppered) hash, and expiring after 90 days.
146
+
147
+ Existing 8-character backup codes were stored as a bare SHA-256 digest and
148
+ cannot be read by the new keyed lookup. Users must regenerate their codes:
149
+
150
+ ```bash
151
+ curl -X POST https://keystone.example.com/auth/totp/backup \\
152
+ -H "Authorization: Bearer $TOKEN" \\
153
+ -H "Content-Type: application/json" \\
154
+ -d '{"code":"123456"}'
155
+ ```
156
+
157
+ If your deployment used the default encryption key, plan a re-enrollment
158
+ window. `KEYSTONE_TOTP_ENCRYPTION_KEY` (or `KEYSTONE_INTERNAL_API_KEY`) is the
159
+ key for both TOTP secrets and the backup-code pepper; **changing it invalidates
160
+ all enrolled authenticators and backup codes**.
161
+
162
+ ### TOTP secret encryption
163
+
164
+ New secrets are written with AES-256-GCM. Secrets written by earlier versions
165
+ used AES-256-CBC and remain readable, so no action is required. Secrets are
166
+ re-encrypted to the GCM format the next time they are written.
167
+
168
+ ---
169
+
170
+ ## Deployment checklist
171
+
172
+ - [ ] Run `npm run db:migrate` before starting the new version.
173
+ - [ ] Review `MFA_CHALLENGE_TTL_SECONDS` and `MFA_MAX_ATTEMPTS` for your
174
+ threat model.
175
+ - [ ] Confirm `KEYSTONE_TOTP_ENCRYPTION_KEY` (or `KEYSTONE_INTERNAL_API_KEY`) is
176
+ set and stable. Without it, Keystone derives a predictable default.
177
+ - [ ] Update clients that call `/auth/login`, `/auth/token-login`, or
178
+ `/auth/totp/backup`.
179
+ - [ ] Ask existing TOTP users to regenerate their backup codes.
180
+ - [ ] Confirm no alternate sign-in path is expected to work for
181
+ MFA-enabled accounts (SAML, OIDC, federation, magic link).
182
+ - [ ] Shorten `ACCESS_TOKEN_TTL_SECONDS` if you need pre-enrollment access
183
+ tokens to die quickly.