@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.
Files changed (122) hide show
  1. package/CHANGELOG.md +350 -0
  2. package/README.md +72 -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 +15 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/rateLimit.d.ts +10 -7
  10. package/dist/plugins/rateLimit.d.ts.map +1 -1
  11. package/dist/plugins/rateLimit.js +75 -36
  12. package/dist/plugins/rateLimit.js.map +1 -1
  13. package/dist/routes/admin/organizations.d.ts.map +1 -1
  14. package/dist/routes/admin/organizations.js +2 -0
  15. package/dist/routes/admin/organizations.js.map +1 -1
  16. package/dist/routes/admin/platform.d.ts.map +1 -1
  17. package/dist/routes/admin/platform.js +14 -1
  18. package/dist/routes/admin/platform.js.map +1 -1
  19. package/dist/routes/apiKeys.d.ts.map +1 -1
  20. package/dist/routes/apiKeys.js +15 -1
  21. package/dist/routes/apiKeys.js.map +1 -1
  22. package/dist/routes/auth.d.ts.map +1 -1
  23. package/dist/routes/auth.js +104 -3
  24. package/dist/routes/auth.js.map +1 -1
  25. package/dist/routes/emailVerification.d.ts.map +1 -1
  26. package/dist/routes/emailVerification.js +2 -0
  27. package/dist/routes/emailVerification.js.map +1 -1
  28. package/dist/routes/magicLinks.d.ts.map +1 -1
  29. package/dist/routes/magicLinks.js +2 -0
  30. package/dist/routes/magicLinks.js.map +1 -1
  31. package/dist/routes/oauth2.d.ts.map +1 -1
  32. package/dist/routes/oauth2.js +16 -2
  33. package/dist/routes/oauth2.js.map +1 -1
  34. package/dist/routes/password.d.ts.map +1 -1
  35. package/dist/routes/password.js +2 -0
  36. package/dist/routes/password.js.map +1 -1
  37. package/dist/routes/scim.d.ts.map +1 -1
  38. package/dist/routes/scim.js +2 -0
  39. package/dist/routes/scim.js.map +1 -1
  40. package/dist/routes/smsOtp.d.ts.map +1 -1
  41. package/dist/routes/smsOtp.js +4 -0
  42. package/dist/routes/smsOtp.js.map +1 -1
  43. package/dist/routes/totp.d.ts.map +1 -1
  44. package/dist/routes/totp.js +18 -1
  45. package/dist/routes/totp.js.map +1 -1
  46. package/dist/services/configuration/profiles.d.ts +26 -0
  47. package/dist/services/configuration/profiles.d.ts.map +1 -1
  48. package/dist/services/configuration/profiles.js +80 -1
  49. package/dist/services/configuration/profiles.js.map +1 -1
  50. package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
  51. package/dist/services/events/subscribers/auditLog.js +38 -3
  52. package/dist/services/events/subscribers/auditLog.js.map +1 -1
  53. package/dist/services/events/types.d.ts +3 -1
  54. package/dist/services/events/types.d.ts.map +1 -1
  55. package/dist/services/events/validate.d.ts +1 -0
  56. package/dist/services/events/validate.d.ts.map +1 -1
  57. package/dist/services/events/validate.js +5 -1
  58. package/dist/services/events/validate.js.map +1 -1
  59. package/dist/services/localRateLimit.d.ts +44 -0
  60. package/dist/services/localRateLimit.d.ts.map +1 -0
  61. package/dist/services/localRateLimit.js +86 -0
  62. package/dist/services/localRateLimit.js.map +1 -0
  63. package/dist/services/refreshTokenState.d.ts +5 -0
  64. package/dist/services/refreshTokenState.d.ts.map +1 -0
  65. package/dist/services/refreshTokenState.js +30 -0
  66. package/dist/services/refreshTokenState.js.map +1 -0
  67. package/dist/services/setup/token.d.ts +12 -0
  68. package/dist/services/setup/token.d.ts.map +1 -1
  69. package/dist/services/setup/token.js +27 -3
  70. package/dist/services/setup/token.js.map +1 -1
  71. package/dist/services/tokens.d.ts +1 -0
  72. package/dist/services/tokens.d.ts.map +1 -1
  73. package/dist/services/tokens.js +1 -1
  74. package/dist/services/tokens.js.map +1 -1
  75. package/dist/services/trustedProxies.d.ts +24 -0
  76. package/dist/services/trustedProxies.d.ts.map +1 -1
  77. package/dist/services/trustedProxies.js +19 -0
  78. package/dist/services/trustedProxies.js.map +1 -1
  79. package/dist/services/webhooks.d.ts +16 -0
  80. package/dist/services/webhooks.d.ts.map +1 -1
  81. package/dist/services/webhooks.js +48 -3
  82. package/dist/services/webhooks.js.map +1 -1
  83. package/dist/setup-server.js +47 -3
  84. package/dist/setup-server.js.map +1 -1
  85. package/docs/API-REVIEW.md +121 -0
  86. package/docs/API.md +457 -0
  87. package/docs/ARCHITECTURE.md +142 -0
  88. package/docs/CONTRIBUTING.md +61 -0
  89. package/docs/DEPLOYMENT.md +257 -0
  90. package/docs/INTEGRATION.md +336 -0
  91. package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
  92. package/docs/MIGRATION-1.7.md +70 -0
  93. package/docs/MIGRATION-1.8.md +183 -0
  94. package/docs/MIGRATION-1.9.md +200 -0
  95. package/docs/MIGRATION-2.0.md +203 -0
  96. package/docs/MIGRATION-2.4.md +185 -0
  97. package/docs/PERFORMANCE.md +155 -0
  98. package/docs/RBAC.md +100 -0
  99. package/docs/RE-AUDIT.md +72 -0
  100. package/docs/README.md +54 -0
  101. package/docs/RELEASE-1.7.md +53 -0
  102. package/docs/ROADMAP.md +41 -0
  103. package/docs/SECURITY.md +143 -0
  104. package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
  105. package/docs/adrs/002-versioned-event-bus.md +30 -0
  106. package/docs/adrs/003-bullmq-for-background-work.md +20 -0
  107. package/docs/adrs/004-argon2id-password-hashing.md +19 -0
  108. package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
  109. package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
  110. package/docs/security/audit.md +82 -0
  111. package/docs/security/configuration.md +60 -0
  112. package/docs/security/enterprise-sso.md +193 -0
  113. package/docs/security/mtls.md +132 -0
  114. package/docs/security/proxy-security.md +128 -0
  115. package/docs/security/rate-limiting.md +79 -0
  116. package/docs/security/registry-exceptions.md +34 -0
  117. package/docs/security/registry.json +649 -0
  118. package/docs/security/registry.md +657 -0
  119. package/docs/security/scopes.md +45 -0
  120. package/docs/security/supply-chain.md +49 -0
  121. package/docs/security/trust-boundaries.md +111 -0
  122. 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.