@hilbras/keystone 2.6.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 +350 -0
- package/README.md +72 -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 +15 -11
- package/dist/index.js.map +1 -1
- 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 +15 -1
- 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/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 +16 -2
- 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/scim.d.ts.map +1 -1
- package/dist/routes/scim.js +2 -0
- package/dist/routes/scim.js.map +1 -1
- package/dist/routes/smsOtp.d.ts.map +1 -1
- package/dist/routes/smsOtp.js +4 -0
- package/dist/routes/smsOtp.js.map +1 -1
- package/dist/routes/totp.d.ts.map +1 -1
- package/dist/routes/totp.js +18 -1
- package/dist/routes/totp.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/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,200 @@
|
|
|
1
|
+
# Migrating to Keystone 1.9.0
|
|
2
|
+
|
|
3
|
+
Keystone 1.9.0 makes SCIM provisioning organization-scoped. In 1.8.x, SCIM was a
|
|
4
|
+
pair of environment variables, which allowed exactly one organization in a
|
|
5
|
+
deployment to be provisioned, stored the bearer token in plaintext, and reached
|
|
6
|
+
through global user methods.
|
|
7
|
+
|
|
8
|
+
Review this page before upgrading a production deployment.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. SCIM credentials are now per organization
|
|
13
|
+
|
|
14
|
+
| 1.8.x | 1.9.0 |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `SCIM_BEARER_TOKEN` — one plaintext token, process-wide | A `scim_connections` row per organization, storing a SHA-256 digest |
|
|
17
|
+
| `SCIM_ORG_ID` — one organization, process-wide | `org_id` on the connection, enforced by a foreign key |
|
|
18
|
+
| No rotation, revocation, or expiry | Rotate, revoke, and expire through the connection API |
|
|
19
|
+
| Token compared with `!==` in application code | Token resolved by digest lookup, so no timing signal |
|
|
20
|
+
|
|
21
|
+
### What happens to your existing configuration automatically
|
|
22
|
+
|
|
23
|
+
If `SCIM_BEARER_TOKEN` and `SCIM_ORG_ID` are still set at startup, Keystone adopts
|
|
24
|
+
them **once** into a connection for `SCIM_ORG_ID` and logs a deprecation notice.
|
|
25
|
+
Your existing identity provider keeps working with no downtime.
|
|
26
|
+
|
|
27
|
+
Adoption is one-time. Once a connection exists for the organization, the
|
|
28
|
+
environment variables are ignored — including after a revocation, so a restart
|
|
29
|
+
cannot resurrect a credential you revoked.
|
|
30
|
+
|
|
31
|
+
### Move to the connection API
|
|
32
|
+
|
|
33
|
+
Remove the environment variables once the connection exists, then manage
|
|
34
|
+
credentials through the API or the admin dashboard:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
ORG=<organization-id>
|
|
38
|
+
ADMIN_TOKEN=<platform owner access token>
|
|
39
|
+
|
|
40
|
+
# Create (returns the bearer token once)
|
|
41
|
+
curl -X POST "https://keystone.example.com/v1/admin/organizations/$ORG/scim-connections" \
|
|
42
|
+
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
43
|
+
-H "Content-Type: application/json" \
|
|
44
|
+
-d '{"name":"Okta","expiresInDays":365}'
|
|
45
|
+
|
|
46
|
+
# Rotate
|
|
47
|
+
curl -X POST "https://keystone.example.com/v1/admin/organizations/$ORG/scim-connections/$ID/rotate" \
|
|
48
|
+
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
49
|
+
-H "Content-Type: application/json" -d '{}'
|
|
50
|
+
|
|
51
|
+
# Revoke
|
|
52
|
+
curl -X DELETE "https://keystone.example.com/v1/admin/organizations/$ORG/scim-connections/$ID" \
|
|
53
|
+
-H "Authorization: Bearer $ADMIN_TOKEN"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Creating, rotating, and revoking are **owner-only**. A SCIM token can provision
|
|
57
|
+
and deactivate tenant users, so a mere admin or member cannot mint one.
|
|
58
|
+
|
|
59
|
+
The bearer token is shown exactly once. It is not recoverable afterwards, and
|
|
60
|
+
listings show a four-character hint.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 2. Deprovisioning no longer deactivates shared accounts
|
|
65
|
+
|
|
66
|
+
This is the most important behavioural change for multi-tenant deployments.
|
|
67
|
+
|
|
68
|
+
A user row is global. In 1.8.x, `DELETE /scim/v2/Users/:id` called the global
|
|
69
|
+
`deactivate`, which revoked that person's sessions, refresh tokens, and API keys
|
|
70
|
+
**in every organization they belonged to** — including organizations that
|
|
71
|
+
credential had no relationship with.
|
|
72
|
+
|
|
73
|
+
In 1.9.0:
|
|
74
|
+
|
|
75
|
+
| Situation | Result |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| The organization was the user's only membership | Membership removed, account deactivated, sessions revoked |
|
|
78
|
+
| The user also belongs to another organization | Membership removed from **this** organization only; the shared account is untouched |
|
|
79
|
+
|
|
80
|
+
If your process relied on SCIM deletion disabling an account everywhere, you now
|
|
81
|
+
need to remove the membership in each organization, or have a platform owner
|
|
82
|
+
deactivate the account.
|
|
83
|
+
|
|
84
|
+
### Writing global attributes of a shared user
|
|
85
|
+
|
|
86
|
+
A user who belongs to more than one organization cannot have their global
|
|
87
|
+
attributes changed through SCIM, because that would change what the other
|
|
88
|
+
organizations see without their authorization. `PUT`, `PATCH`, and reactivation
|
|
89
|
+
return `409` with `scimType: "mutability"`.
|
|
90
|
+
|
|
91
|
+
Remove the membership from this organization instead.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 3. Response codes
|
|
96
|
+
|
|
97
|
+
| Case | 1.8.x | 1.9.0 |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| Target belongs to another organization | `404` | `404` |
|
|
100
|
+
| Target is a platform owner | `409` | `409` |
|
|
101
|
+
| `userName` already exists elsewhere | `409` (named the other organization) | `409` (generic) |
|
|
102
|
+
| Target is the last owner of the organization | `204` | `409` |
|
|
103
|
+
| Target is a shared user being modified | `200` | `409` |
|
|
104
|
+
| Malformed id in the path | `500` | `404` |
|
|
105
|
+
| Invalid body | generic Fastify error | SCIM `Error` object |
|
|
106
|
+
|
|
107
|
+
The conflict message no longer says which organization holds the account, so the
|
|
108
|
+
endpoint is not a tenant oracle.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 4. New endpoints
|
|
113
|
+
|
|
114
|
+
### Users
|
|
115
|
+
|
|
116
|
+
`PATCH /scim/v2/Users/:userId` and `POST /scim/v2/Users/.search` are new.
|
|
117
|
+
`GET /Users` and `POST /Users/.search` accept `startIndex` and `count`, and
|
|
118
|
+
`filter` supports the single-attribute `eq` form.
|
|
119
|
+
|
|
120
|
+
`POST /Users` is now create-or-update: provisioning an existing member of the
|
|
121
|
+
organization applies the profile fields in the body, which 1.8.x silently
|
|
122
|
+
dropped.
|
|
123
|
+
|
|
124
|
+
An unsupported filter is rejected with `400 invalidFilter` rather than ignored.
|
|
125
|
+
Supported attributes are `userName` for users, and `displayName` and `externalId`
|
|
126
|
+
for groups. `externalId` is **not** supported for users.
|
|
127
|
+
|
|
128
|
+
### Groups
|
|
129
|
+
|
|
130
|
+
`GET /scim/v2/Groups` previously returned a synthetic projection of organization
|
|
131
|
+
roles, with ids of the form `<orgId>:<role>`. Those endpoints are removed.
|
|
132
|
+
|
|
133
|
+
Groups are now real records scoped to the organization, with:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
GET /scim/v2/Groups
|
|
137
|
+
POST /scim/v2/Groups
|
|
138
|
+
GET /scim/v2/Groups/:groupId
|
|
139
|
+
PUT /scim/v2/Groups/:groupId
|
|
140
|
+
PATCH /scim/v2/Groups/:groupId
|
|
141
|
+
DELETE /scim/v2/Groups/:groupId
|
|
142
|
+
GET /scim/v2/Groups/:groupId/members
|
|
143
|
+
POST /scim/v2/Groups/:groupId/members
|
|
144
|
+
DELETE /scim/v2/Groups/:groupId/members/:userId
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
If an identity provider was reading the old role projection, it must be
|
|
148
|
+
reconfigured to push its own groups.
|
|
149
|
+
|
|
150
|
+
### Service discovery
|
|
151
|
+
|
|
152
|
+
`GET /scim/v2/ServiceProviderConfig` and `GET /scim/v2/ResourceTypes` are new.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 5. Configuration
|
|
157
|
+
|
|
158
|
+
| Variable | Default | Purpose |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| `SCIM_ROTATION_GRACE_SECONDS` | `0` | Grace window for a rotated token |
|
|
161
|
+
| `SCIM_RATE_LIMIT_MAX` | `600` | Requests per credential per window |
|
|
162
|
+
| `SCIM_RATE_LIMIT_WINDOW_SECONDS` | `60` | Window for the above |
|
|
163
|
+
| `SCIM_AUTH_FAILURE_MAX` | `60` | Unauthenticated requests per address per window |
|
|
164
|
+
| `SCIM_AUTH_FAILURE_WINDOW_SECONDS` | `60` | Window for the above |
|
|
165
|
+
|
|
166
|
+
`SCIM_BEARER_TOKEN` and `SCIM_ORG_ID` are deprecated and can be removed.
|
|
167
|
+
|
|
168
|
+
Rotation revokes the previous token immediately. `rotationGraceSeconds` keeps it
|
|
169
|
+
valid for a window, which avoids dropping in-flight provisioning — but it is not
|
|
170
|
+
a revocation mechanism, so do not use a grace window when rotating in response to
|
|
171
|
+
a leak. Revoke instead.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 6. Operational notes
|
|
176
|
+
|
|
177
|
+
- The connection's `lastUsedAt` is updated on every authenticated SCIM request,
|
|
178
|
+
which makes an unused or abandoned credential visible.
|
|
179
|
+
- Revoked connections are retained as an audit trail and do not block a
|
|
180
|
+
replacement; a partial unique index enforces at most one live connection per
|
|
181
|
+
organization.
|
|
182
|
+
- Every mutation records the connection id and organization in the audit row.
|
|
183
|
+
Credential lifecycle transitions emit `scim_connection_created`,
|
|
184
|
+
`scim_connection_rotated`, and `scim_connection_revoked`.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Deployment checklist
|
|
189
|
+
|
|
190
|
+
- [ ] Run `npm run db:migrate` before starting the new version.
|
|
191
|
+
- [ ] Confirm SCIM still works with the automatically adopted connection.
|
|
192
|
+
- [ ] Create a managed connection, move your identity provider onto it, then
|
|
193
|
+
remove `SCIM_BEARER_TOKEN` and `SCIM_ORG_ID`.
|
|
194
|
+
- [ ] Set `expiresInDays` so credentials rotate on a schedule.
|
|
195
|
+
- [ ] Check whether any process relied on SCIM deletion disabling an account
|
|
196
|
+
across all of that user's organizations.
|
|
197
|
+
- [ ] Reconfigure any identity provider that read the old role-based group
|
|
198
|
+
projection.
|
|
199
|
+
- [ ] Confirm your identity provider's filter attributes are supported
|
|
200
|
+
(`userName` for users; `displayName` and `externalId` for groups).
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Migrating to Keystone 2.0.0
|
|
2
|
+
|
|
3
|
+
Keystone 2.0.0 reworks the mTLS trust boundary. Before this release, any client
|
|
4
|
+
that could reach Keystone could name a service account in a request header and
|
|
5
|
+
become it, and could set its own IP address to escape every rate limit. Both are
|
|
6
|
+
closed.
|
|
7
|
+
|
|
8
|
+
**This is a breaking release.** Two behaviours change in ways that will break a
|
|
9
|
+
deployment that relied on the old ones, and one of them fails silently until
|
|
10
|
+
traffic arrives.
|
|
11
|
+
|
|
12
|
+
Review this page before upgrading a production deployment.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Summary
|
|
17
|
+
|
|
18
|
+
| # | Change | Impact |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| 1 | `x-service-account-id` no longer authenticates | **Breaking** — mTLS clients stop working until a certificate is bound |
|
|
21
|
+
| 2 | `trustProxy` and `x-forwarded-for` are no longer believed by default | **Breaking, silent** — all clients behind a proxy share one rate-limit budget |
|
|
22
|
+
| 3 | Service accounts bind to a certificate fingerprint | New: `cert_fingerprint` column, unique |
|
|
23
|
+
| 4 | Service accounts can be revoked | New: `revoked_at` column, `POST .../revoke` |
|
|
24
|
+
| 5 | Identity headers are stripped from untrusted peers | New: hardens any route reading them |
|
|
25
|
+
|
|
26
|
+
A database migration runs automatically and is additive — no data is rewritten
|
|
27
|
+
or dropped.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 1. `x-service-account-id` no longer authenticates
|
|
32
|
+
|
|
33
|
+
**Breaking.** Any mTLS integration that authenticated by sending only
|
|
34
|
+
`x-service-account-id` stops working immediately.
|
|
35
|
+
|
|
36
|
+
In 1.x, that header was sufficient on its own:
|
|
37
|
+
|
|
38
|
+
```http
|
|
39
|
+
GET /some/protected/route
|
|
40
|
+
X-Service-Account-Id: 6f1c...-...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
No certificate, no credential. Anyone who knew or guessed a service account ID
|
|
44
|
+
became that account.
|
|
45
|
+
|
|
46
|
+
In 2.0.0, a header never establishes identity. A service account must present
|
|
47
|
+
either a client certificate whose fingerprint is bound to it, or an
|
|
48
|
+
authenticated credential (API key, session, or bearer token).
|
|
49
|
+
`x-service-account-id` is still read, but only as a hint alongside a valid
|
|
50
|
+
certificate, and only when the account it names is the one the certificate is
|
|
51
|
+
bound to. A mismatch is refused rather than falling back.
|
|
52
|
+
|
|
53
|
+
### What to do
|
|
54
|
+
|
|
55
|
+
If you were using this header, you need a client certificate and a binding:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 1. Fingerprint of the caller's certificate
|
|
59
|
+
openssl x509 -in client.pem -outform DER | openssl dgst -sha256 -hex
|
|
60
|
+
|
|
61
|
+
# 2. Bind it to the service account
|
|
62
|
+
curl -X PUT https://keystone.example/v1/admin/organizations/$ORG/service-accounts/$ACCOUNT/certificate \
|
|
63
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
64
|
+
-d '{"fingerprint":"abcd...9f"}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
If your callers use API keys instead of certificates, nothing changes — that
|
|
68
|
+
path was always credential-based.
|
|
69
|
+
|
|
70
|
+
See [security/mtls.md](./security/mtls.md).
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 2. `x-forwarded-for` is no longer believed by default
|
|
75
|
+
|
|
76
|
+
**Breaking, and it fails silently.** Read this one carefully.
|
|
77
|
+
|
|
78
|
+
In 1.x the server was created with `trustProxy: true`, and the rate limiter read
|
|
79
|
+
`x-forwarded-for` unconditionally. Two consequences:
|
|
80
|
+
|
|
81
|
+
- Every client behind a proxy was attributed to its real address — which sounds
|
|
82
|
+
right, but was indistinguishable from a client claiming one.
|
|
83
|
+
- Any client could send a fresh `x-forwarded-for` on every request and never hit
|
|
84
|
+
a rate limit. Authenticated endpoints with rate limits — login, password
|
|
85
|
+
reset, MFA verification, SCIM — were all reachable at full speed.
|
|
86
|
+
|
|
87
|
+
In 2.0.0, forwarded headers are believed only when the request arrived from an
|
|
88
|
+
address in `KEYSTONE_TRUSTED_PROXIES`. That variable is **unset by default**.
|
|
89
|
+
|
|
90
|
+
**The failure mode:** behind a proxy with the variable unset, every request is
|
|
91
|
+
attributed to the proxy's own address. All of your clients now share a single
|
|
92
|
+
rate-limit budget. Login attempts across the whole deployment count against the
|
|
93
|
+
same counter, so a burst of legitimate traffic from unrelated users will start
|
|
94
|
+
returning `429` to everyone.
|
|
95
|
+
|
|
96
|
+
This is deliberate — fail-closed, and the previous default was exploitable. But
|
|
97
|
+
it must be configured for a proxied deployment.
|
|
98
|
+
|
|
99
|
+
### What to do
|
|
100
|
+
|
|
101
|
+
If Keystone sits behind a proxy, set the proxy's addresses:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
KEYSTONE_TRUSTED_PROXIES="10.0.0.0/8,192.168.1.1"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Comma-separated exact IPs, IPv4 CIDRs, or IPv6 prefixes. Keep it as narrow as
|
|
108
|
+
your deployment allows — trusting a shared CIDR lets any host in it assert any
|
|
109
|
+
client identity. Leave it unset only when Keystone is genuinely exposed directly
|
|
110
|
+
to clients.
|
|
111
|
+
|
|
112
|
+
Then confirm your proxy **overwrites** rather than appends `x-forwarded-for`, and
|
|
113
|
+
strips inbound identity headers. Both are required; see
|
|
114
|
+
[security/proxy-security.md](./security/proxy-security.md).
|
|
115
|
+
|
|
116
|
+
### Verifying
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# From a host outside the trusted range: this must NOT be believed.
|
|
120
|
+
curl -sS -H 'X-Forwarded-For: 198.51.100.9' https://keystone.example/health
|
|
121
|
+
|
|
122
|
+
# Repeated logins from two "different" addresses must still share one budget.
|
|
123
|
+
for ip in 198.51.100.1 198.51.100.2 198.51.100.3; do
|
|
124
|
+
curl -sS -o /dev/null -w '%{http_code} ' -H "X-Forwarded-For: $ip" \
|
|
125
|
+
https://keystone.example/auth/login -d '{}' -H 'Content-Type: application/json'
|
|
126
|
+
done
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Also watch for `429` spikes on `auth/login` after upgrading. That symptom means
|
|
130
|
+
the trusted-proxy list is not set correctly.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 3. Certificates are bound to a fingerprint
|
|
135
|
+
|
|
136
|
+
New column `service_accounts.cert_fingerprint`, with a unique index. A
|
|
137
|
+
certificate can map to at most one service account.
|
|
138
|
+
|
|
139
|
+
Fingerprints are stored canonicalized, so the hex and colon-separated spellings
|
|
140
|
+
of one certificate cannot become two bindings. A malformed fingerprint is
|
|
141
|
+
rejected rather than stored.
|
|
142
|
+
|
|
143
|
+
Existing service accounts have `cert_fingerprint = NULL` and continue to work
|
|
144
|
+
with API keys. Only certificate authentication is affected.
|
|
145
|
+
|
|
146
|
+
## 4. Service accounts can be revoked
|
|
147
|
+
|
|
148
|
+
New column `service_accounts.revoked_at`, and:
|
|
149
|
+
|
|
150
|
+
```http
|
|
151
|
+
POST /v1/admin/organizations/:id/service-accounts/:accountId/revoke
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Sets `is_active = false` and stamps `revoked_at`, stopping both certificate and
|
|
155
|
+
API-key authentication. Revoking an already-revoked account returns `409`.
|
|
156
|
+
|
|
157
|
+
Separate from `is_active` so that re-enabling does not silently restore access.
|
|
158
|
+
|
|
159
|
+
## 5. Identity headers are stripped from untrusted peers
|
|
160
|
+
|
|
161
|
+
A new `onRequest` hook deletes `x-forwarded-for`, `x-real-ip`, `forwarded`,
|
|
162
|
+
`x-forwarded-client-cert`, `x-client-cert-fingerprint`, `x-service-account-id`,
|
|
163
|
+
and `x-forwarded-client-cert-chain` from any request whose peer is not a trusted
|
|
164
|
+
proxy.
|
|
165
|
+
|
|
166
|
+
This runs before routing and before authentication, so a route cannot read a
|
|
167
|
+
spoofed identity even by accident.
|
|
168
|
+
|
|
169
|
+
**If you have custom middleware that reads these headers**, it will now see
|
|
170
|
+
`undefined` for direct requests. That is intended. Such middleware was reading
|
|
171
|
+
attacker-controlled data.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## New configuration
|
|
176
|
+
|
|
177
|
+
| Variable | Default | Purpose |
|
|
178
|
+
| --- | --- | --- |
|
|
179
|
+
| `KEYSTONE_TRUSTED_PROXIES` | unset (trust nothing) | Proxies permitted to set client-identity headers |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Deployment checklist
|
|
184
|
+
|
|
185
|
+
- [ ] Read [security/proxy-security.md](./security/proxy-security.md)
|
|
186
|
+
- [ ] Set `KEYSTONE_TRUSTED_PROXIES` if Keystone is behind a proxy
|
|
187
|
+
- [ ] Confirm the proxy **overwrites** `x-forwarded-for` and strips inbound identity headers
|
|
188
|
+
- [ ] Confirm Keystone is not directly reachable from untrusted networks
|
|
189
|
+
- [ ] Bind a certificate fingerprint to every mTLS service account (or confirm none use mTLS)
|
|
190
|
+
- [ ] Verify logins are not being rate-limited across unrelated clients
|
|
191
|
+
- [ ] Review any custom middleware that reads identity headers
|
|
192
|
+
- [ ] Run the database migration (automatic on startup)
|
|
193
|
+
- [ ] After upgrading, watch for `429` on `/auth/login` and `MTLS_UNTRUSTED_PEER` / `MTLS_UNTRUSTED_PEER`-adjacent `401`s
|
|
194
|
+
|
|
195
|
+
## Rolling back
|
|
196
|
+
|
|
197
|
+
The migration is additive — `cert_fingerprint` and `revoked_at` are nullable
|
|
198
|
+
columns, and the new unique index is partial (non-null values only). Rolling
|
|
199
|
+
back to 1.9.x leaves both columns in place and unused; nothing needs undoing
|
|
200
|
+
manually.
|
|
201
|
+
|
|
202
|
+
The application rollback is straightforward, but note that 1.9.x reintroduces the
|
|
203
|
+
`x-service-account-id` bypass. Prefer fixing the configuration forward.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Migrating to Keystone 2.4.0
|
|
2
|
+
|
|
3
|
+
Keystone 2.4.0 hardens the OAuth 2.0 / OIDC implementation. The most important
|
|
4
|
+
change is that **the `authorization_code` grant now authenticates the client**,
|
|
5
|
+
which is a breaking change for any integration that was not sending its secret.
|
|
6
|
+
|
|
7
|
+
Review this page before upgrading a production deployment.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. The authorization code grant requires a client secret
|
|
12
|
+
|
|
13
|
+
**Breaking.**
|
|
14
|
+
|
|
15
|
+
In 2.3.x the `authorization_code` grant looked the application up by
|
|
16
|
+
`client_id` and redeemed the code without ever checking a client secret. RFC
|
|
17
|
+
6749 §3.2.1 requires a confidential client to authenticate at the token
|
|
18
|
+
endpoint, so the code and its PKCE verifier were the only factors protecting the
|
|
19
|
+
exchange.
|
|
20
|
+
|
|
21
|
+
From 2.4.0, a confidential client must present its `client_secret`:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Before — accepted
|
|
25
|
+
curl -X POST https://auth.example.com/oauth2/token \
|
|
26
|
+
-d grant_type=authorization_code -d code=... -d client_id=... -d redirect_uri=... -d code_verifier=...
|
|
27
|
+
|
|
28
|
+
# After — 401 invalid_client
|
|
29
|
+
# After — accepted
|
|
30
|
+
curl -X POST https://auth.example.com/oauth2/token \
|
|
31
|
+
-d grant_type=authorization_code -d code=... -d client_id=... -d client_secret=... \
|
|
32
|
+
-d redirect_uri=... -d code_verifier=...
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The secret may be sent in the body or via HTTP Basic authentication, as before.
|
|
36
|
+
|
|
37
|
+
### What to do
|
|
38
|
+
|
|
39
|
+
Add `client_secret` to your token request. If you are using Keystone's own SDK
|
|
40
|
+
or drop-in, no change is needed — they already send it.
|
|
41
|
+
|
|
42
|
+
### Public clients
|
|
43
|
+
|
|
44
|
+
A client registered as `public` is **not** required to send a secret, because it
|
|
45
|
+
has none. PKCE is mandatory for such a client at both `/authorize` and
|
|
46
|
+
`/token`; a request without `code_challenge` is refused with
|
|
47
|
+
`invalid_request`, and one without `code_verifier` with the same.
|
|
48
|
+
|
|
49
|
+
Existing applications are unaffected: they are all `confidential` and keep
|
|
50
|
+
their secrets.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 2. Redirect URI registration is stricter
|
|
55
|
+
|
|
56
|
+
**Potentially breaking** if you registered any of the rejected forms.
|
|
57
|
+
|
|
58
|
+
Registration was validated with `z.string().url()`, which accepts anything the
|
|
59
|
+
URL parser accepts. Now rejected:
|
|
60
|
+
|
|
61
|
+
| Rejected | Why |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` | Would execute script or inline a document on the auth domain |
|
|
64
|
+
| `https://*.example.com/cb`, any URI containing `*` | Can never match exactly; registered in the belief that it works |
|
|
65
|
+
| `https://example.com/cb#token` | Fragments are never sent to the server, so an exact match can never succeed |
|
|
66
|
+
| `https://user:pass@example.com/cb` | Embedded credentials are a phishing primitive |
|
|
67
|
+
| `http://example.com/cb` | Plaintext HTTP downgrade. Still allowed on loopback for development |
|
|
68
|
+
|
|
69
|
+
### What to do
|
|
70
|
+
|
|
71
|
+
Check every registered redirect URI. If one is a wildcard, register each
|
|
72
|
+
callback explicitly. If one uses plaintext HTTP against a real host, move it to
|
|
73
|
+
HTTPS.
|
|
74
|
+
|
|
75
|
+
**A validation that used to pass will now fail.** For example, registering
|
|
76
|
+
`javascript:alert(1)` returned 201 before and returns 400 now, with a message
|
|
77
|
+
naming the reason.
|
|
78
|
+
|
|
79
|
+
Matching itself is unchanged and remains exact string comparison — no prefix
|
|
80
|
+
matching, no normalization, no case folding. This was already the behaviour; it
|
|
81
|
+
is now enforced through one shared helper so registration-time and use-time
|
|
82
|
+
rules cannot drift.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 3. Scope requests are validated against a registration
|
|
87
|
+
|
|
88
|
+
**New, opt-in.** Applications with no registered scopes are unaffected.
|
|
89
|
+
|
|
90
|
+
The effective scope set is now:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
registered scopes ∩ requested scopes ∩ consented scopes
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A scope outside the registration is **refused** with `invalid_scope`, not
|
|
97
|
+
silently dropped:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{ "error": "invalid_scope", "error_description": "Scope \"admin:all\" is not available to this client" }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
An empty `allowed_scopes` means "unrestricted", which preserves the previous
|
|
104
|
+
behaviour for every existing application.
|
|
105
|
+
|
|
106
|
+
### What to do
|
|
107
|
+
|
|
108
|
+
Nothing, unless you want the extra control. To adopt it:
|
|
109
|
+
|
|
110
|
+
```http
|
|
111
|
+
PATCH /v1/admin/organizations/:orgId/applications/:appId
|
|
112
|
+
|
|
113
|
+
{ "allowedScopes": ["openid", "profile", "email", "api:read"] }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
From then on, requesting a scope outside that list is refused rather than
|
|
117
|
+
granted. Scope names may contain letters, digits, `.`, `_`, `:`, `*`, and `-`.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 4. OIDC federation now sends and verifies a nonce
|
|
122
|
+
|
|
123
|
+
**Behavioural, no configuration needed.** Relevant only if you operate an
|
|
124
|
+
external OIDC or SAML identity provider that Keystone federates with.
|
|
125
|
+
|
|
126
|
+
Keystone now generates a nonce per authorization request, stores it in an
|
|
127
|
+
httpOnly `oauth_nonce` cookie, sends it to the provider, and requires the
|
|
128
|
+
returned ID token to carry the same value.
|
|
129
|
+
|
|
130
|
+
If your identity provider does not echo the nonce into its ID token,
|
|
131
|
+
federated logins will fail with a nonce mismatch. Well-behaved providers —
|
|
132
|
+
anything implementing OpenID Connect Discovery, which requires nonce support when
|
|
133
|
+
a nonce is sent — echo it correctly.
|
|
134
|
+
|
|
135
|
+
Two related changes to ID token verification: the algorithm set is now pinned
|
|
136
|
+
(RS256, ES256, PS256) and `exp`, `iat`, `iss`, `aud`, `sub` are required rather
|
|
137
|
+
than validated only when present. A provider issuing tokens without an `iat` or
|
|
138
|
+
without a `sub` will now be refused.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 5. Refresh tokens carry their granted scopes
|
|
143
|
+
|
|
144
|
+
**New column, no action required.**
|
|
145
|
+
|
|
146
|
+
`refresh_tokens.scopes` records the scope set granted at the authorization step,
|
|
147
|
+
and rotation carries it forward. Previously the context was dropped at the first
|
|
148
|
+
refresh.
|
|
149
|
+
|
|
150
|
+
A refresh may narrow the grant but never widen it: the stored set is
|
|
151
|
+
authoritative, and nothing a caller supplies at the token endpoint can extend it.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## New API surface
|
|
156
|
+
|
|
157
|
+
| Field / parameter | Where | Notes |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| `clientType` | `POST`/`PATCH` application | `"confidential"` (default) or `"public"`. A public client is issued no secret |
|
|
160
|
+
| `allowedScopes` | `POST`/`PATCH` application | Registration of requestable scopes. Empty means unrestricted |
|
|
161
|
+
| `clientSecret` | `POST` application response | Now `null` for a public client |
|
|
162
|
+
|
|
163
|
+
## Migration order
|
|
164
|
+
|
|
165
|
+
The database migration (`0016`, `0017`) is additive: `client_secret_hash`
|
|
166
|
+
becomes nullable, and `client_type`, `allowed_scopes`, and
|
|
167
|
+
`refresh_tokens.scopes` are added with defaults. Existing rows are unaffected —
|
|
168
|
+
every existing application stays `confidential` with its secret intact.
|
|
169
|
+
|
|
170
|
+
- [ ] Add `client_secret` to every authorization-code token request
|
|
171
|
+
- [ ] Audit registered redirect URIs against the new rules
|
|
172
|
+
- [ ] Optionally register `allowedScopes` on your applications
|
|
173
|
+
- [ ] Confirm your external identity provider echoes the OIDC nonce and issues `iat` and `sub`
|
|
174
|
+
- [ ] Run the database migrations (automatic on startup)
|
|
175
|
+
- [ ] Watch for `invalid_client` at the token endpoint and `invalid_scope` at `/authorize`
|
|
176
|
+
|
|
177
|
+
## Rolling back
|
|
178
|
+
|
|
179
|
+
The migration is additive and every new column is nullable or has a default, so
|
|
180
|
+
rolling back to 2.3.x leaves them in place and unused. Nothing needs undoing
|
|
181
|
+
manually.
|
|
182
|
+
|
|
183
|
+
The application rollback is straightforward, but 2.3.x reintroduces both the
|
|
184
|
+
missing client authentication and the `javascript:` redirect URI acceptance.
|
|
185
|
+
Prefer fixing the integration forward.
|