@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.
- package/CHANGELOG.md +423 -0
- package/README.md +85 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -11
- package/dist/index.js.map +1 -1
- package/dist/plugins/auth.d.ts +2 -0
- package/dist/plugins/auth.d.ts.map +1 -1
- package/dist/plugins/auth.js +21 -7
- package/dist/plugins/auth.js.map +1 -1
- package/dist/plugins/machinePrincipal.d.ts +28 -0
- package/dist/plugins/machinePrincipal.d.ts.map +1 -0
- package/dist/plugins/machinePrincipal.js +45 -0
- package/dist/plugins/machinePrincipal.js.map +1 -0
- package/dist/plugins/rateLimit.d.ts +10 -7
- package/dist/plugins/rateLimit.d.ts.map +1 -1
- package/dist/plugins/rateLimit.js +75 -36
- package/dist/plugins/rateLimit.js.map +1 -1
- package/dist/routes/admin/organizations.d.ts.map +1 -1
- package/dist/routes/admin/organizations.js +2 -0
- package/dist/routes/admin/organizations.js.map +1 -1
- package/dist/routes/admin/platform.d.ts.map +1 -1
- package/dist/routes/admin/platform.js +14 -1
- package/dist/routes/admin/platform.js.map +1 -1
- package/dist/routes/apiKeys.d.ts.map +1 -1
- package/dist/routes/apiKeys.js +37 -5
- package/dist/routes/apiKeys.js.map +1 -1
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +104 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/emailVerification.d.ts.map +1 -1
- package/dist/routes/emailVerification.js +2 -0
- package/dist/routes/emailVerification.js.map +1 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/federation.js +8 -2
- package/dist/routes/federation.js.map +1 -1
- package/dist/routes/magicLinks.d.ts.map +1 -1
- package/dist/routes/magicLinks.js +2 -0
- package/dist/routes/magicLinks.js.map +1 -1
- package/dist/routes/oauth2.d.ts.map +1 -1
- package/dist/routes/oauth2.js +24 -4
- package/dist/routes/oauth2.js.map +1 -1
- package/dist/routes/password.d.ts.map +1 -1
- package/dist/routes/password.js +2 -0
- package/dist/routes/password.js.map +1 -1
- package/dist/routes/profile.js +2 -2
- package/dist/routes/profile.js.map +1 -1
- package/dist/routes/scim.d.ts.map +1 -1
- package/dist/routes/scim.js +2 -0
- package/dist/routes/scim.js.map +1 -1
- package/dist/routes/serviceAccounts.d.ts.map +1 -1
- package/dist/routes/serviceAccounts.js +17 -1
- package/dist/routes/serviceAccounts.js.map +1 -1
- package/dist/routes/sessions.js +3 -3
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/smsOtp.d.ts.map +1 -1
- package/dist/routes/smsOtp.js +10 -0
- package/dist/routes/smsOtp.js.map +1 -1
- package/dist/routes/totp.d.ts.map +1 -1
- package/dist/routes/totp.js +38 -6
- package/dist/routes/totp.js.map +1 -1
- package/dist/routes/webauthn.d.ts.map +1 -1
- package/dist/routes/webauthn.js +8 -2
- package/dist/routes/webauthn.js.map +1 -1
- package/dist/services/configuration/profiles.d.ts +26 -0
- package/dist/services/configuration/profiles.d.ts.map +1 -1
- package/dist/services/configuration/profiles.js +80 -1
- package/dist/services/configuration/profiles.js.map +1 -1
- package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
- package/dist/services/events/subscribers/auditLog.js +38 -3
- package/dist/services/events/subscribers/auditLog.js.map +1 -1
- package/dist/services/events/types.d.ts +3 -1
- package/dist/services/events/types.d.ts.map +1 -1
- package/dist/services/events/validate.d.ts +1 -0
- package/dist/services/events/validate.d.ts.map +1 -1
- package/dist/services/events/validate.js +5 -1
- package/dist/services/events/validate.js.map +1 -1
- package/dist/services/localRateLimit.d.ts +44 -0
- package/dist/services/localRateLimit.d.ts.map +1 -0
- package/dist/services/localRateLimit.js +86 -0
- package/dist/services/localRateLimit.js.map +1 -0
- package/dist/services/refreshTokenState.d.ts +5 -0
- package/dist/services/refreshTokenState.d.ts.map +1 -0
- package/dist/services/refreshTokenState.js +30 -0
- package/dist/services/refreshTokenState.js.map +1 -0
- package/dist/services/scopes.d.ts +113 -0
- package/dist/services/scopes.d.ts.map +1 -0
- package/dist/services/scopes.js +138 -0
- package/dist/services/scopes.js.map +1 -0
- package/dist/services/setup/token.d.ts +12 -0
- package/dist/services/setup/token.d.ts.map +1 -1
- package/dist/services/setup/token.js +27 -3
- package/dist/services/setup/token.js.map +1 -1
- package/dist/services/tokens.d.ts +1 -0
- package/dist/services/tokens.d.ts.map +1 -1
- package/dist/services/tokens.js +1 -1
- package/dist/services/tokens.js.map +1 -1
- package/dist/services/trustedProxies.d.ts +24 -0
- package/dist/services/trustedProxies.d.ts.map +1 -1
- package/dist/services/trustedProxies.js +19 -0
- package/dist/services/trustedProxies.js.map +1 -1
- package/dist/services/webhooks.d.ts +16 -0
- package/dist/services/webhooks.d.ts.map +1 -1
- package/dist/services/webhooks.js +48 -3
- package/dist/services/webhooks.js.map +1 -1
- package/dist/setup-server.js +47 -3
- package/dist/setup-server.js.map +1 -1
- package/docs/API-REVIEW.md +121 -0
- package/docs/API.md +457 -0
- package/docs/ARCHITECTURE.md +142 -0
- package/docs/CONTRIBUTING.md +61 -0
- package/docs/DEPLOYMENT.md +257 -0
- package/docs/INTEGRATION.md +336 -0
- package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
- package/docs/MIGRATION-1.7.md +70 -0
- package/docs/MIGRATION-1.8.md +183 -0
- package/docs/MIGRATION-1.9.md +200 -0
- package/docs/MIGRATION-2.0.md +203 -0
- package/docs/MIGRATION-2.4.md +185 -0
- package/docs/PERFORMANCE.md +155 -0
- package/docs/RBAC.md +100 -0
- package/docs/RE-AUDIT.md +72 -0
- package/docs/README.md +54 -0
- package/docs/RELEASE-1.7.md +53 -0
- package/docs/ROADMAP.md +41 -0
- package/docs/SECURITY.md +143 -0
- package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
- package/docs/adrs/002-versioned-event-bus.md +30 -0
- package/docs/adrs/003-bullmq-for-background-work.md +20 -0
- package/docs/adrs/004-argon2id-password-hashing.md +19 -0
- package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
- package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
- package/docs/security/audit.md +82 -0
- package/docs/security/configuration.md +60 -0
- package/docs/security/enterprise-sso.md +193 -0
- package/docs/security/mtls.md +132 -0
- package/docs/security/proxy-security.md +128 -0
- package/docs/security/rate-limiting.md +79 -0
- package/docs/security/registry-exceptions.md +34 -0
- package/docs/security/registry.json +649 -0
- package/docs/security/registry.md +657 -0
- package/docs/security/scopes.md +45 -0
- package/docs/security/supply-chain.md +49 -0
- package/docs/security/trust-boundaries.md +111 -0
- 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.
|