@hilbras/keystone 1.9.0 → 2.5.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 (127) hide show
  1. package/CHANGELOG.md +386 -0
  2. package/README.md +150 -32
  3. package/dist/config.d.ts +8 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +8 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/db/migrations/0015_chief_vargas.sql +7 -0
  8. package/dist/db/migrations/0016_gorgeous_charles_xavier.sql +5 -0
  9. package/dist/db/migrations/0017_flashy_nekra.sql +1 -0
  10. package/dist/db/migrations/meta/0015_snapshot.json +4340 -0
  11. package/dist/db/migrations/meta/0016_snapshot.json +4363 -0
  12. package/dist/db/migrations/meta/0017_snapshot.json +4370 -0
  13. package/dist/db/migrations/meta/_journal.json +21 -0
  14. package/dist/db/schema.d.ts +138 -1
  15. package/dist/db/schema.d.ts.map +1 -1
  16. package/dist/db/schema.js +39 -1
  17. package/dist/db/schema.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/plugins/headerSanitization.d.ts +15 -0
  22. package/dist/plugins/headerSanitization.d.ts.map +1 -0
  23. package/dist/plugins/headerSanitization.js +39 -0
  24. package/dist/plugins/headerSanitization.js.map +1 -0
  25. package/dist/plugins/mtls.d.ts +21 -2
  26. package/dist/plugins/mtls.d.ts.map +1 -1
  27. package/dist/plugins/mtls.js +94 -47
  28. package/dist/plugins/mtls.js.map +1 -1
  29. package/dist/plugins/rateLimit.d.ts +5 -4
  30. package/dist/plugins/rateLimit.d.ts.map +1 -1
  31. package/dist/plugins/rateLimit.js +11 -8
  32. package/dist/plugins/rateLimit.js.map +1 -1
  33. package/dist/repositories/application.d.ts +1 -1
  34. package/dist/repositories/application.d.ts.map +1 -1
  35. package/dist/repositories/application.js +8 -2
  36. package/dist/repositories/application.js.map +1 -1
  37. package/dist/repositories/session.d.ts +1 -0
  38. package/dist/repositories/session.d.ts.map +1 -1
  39. package/dist/repositories/types.d.ts +5 -1
  40. package/dist/repositories/types.d.ts.map +1 -1
  41. package/dist/routes/admin/organizations.d.ts.map +1 -1
  42. package/dist/routes/admin/organizations.js +31 -2
  43. package/dist/routes/admin/organizations.js.map +1 -1
  44. package/dist/routes/oauth.d.ts.map +1 -1
  45. package/dist/routes/oauth.js +38 -2
  46. package/dist/routes/oauth.js.map +1 -1
  47. package/dist/routes/oauth2.d.ts.map +1 -1
  48. package/dist/routes/oauth2.js +84 -11
  49. package/dist/routes/oauth2.js.map +1 -1
  50. package/dist/routes/saml.d.ts +24 -0
  51. package/dist/routes/saml.d.ts.map +1 -1
  52. package/dist/routes/saml.js +32 -3
  53. package/dist/routes/saml.js.map +1 -1
  54. package/dist/routes/serviceAccounts.d.ts.map +1 -1
  55. package/dist/routes/serviceAccounts.js +61 -1
  56. package/dist/routes/serviceAccounts.js.map +1 -1
  57. package/dist/routes/totp.d.ts.map +1 -1
  58. package/dist/routes/totp.js +4 -5
  59. package/dist/routes/totp.js.map +1 -1
  60. package/dist/sdk/index.d.ts.map +1 -1
  61. package/dist/sdk/index.js.map +1 -1
  62. package/dist/sdk/types.d.ts +4 -1
  63. package/dist/sdk/types.d.ts.map +1 -1
  64. package/dist/services/application/organization.d.ts +5 -1
  65. package/dist/services/application/organization.d.ts.map +1 -1
  66. package/dist/services/application/organization.js.map +1 -1
  67. package/dist/services/connectors/google.d.ts +18 -1
  68. package/dist/services/connectors/google.d.ts.map +1 -1
  69. package/dist/services/connectors/google.js +25 -3
  70. package/dist/services/connectors/google.js.map +1 -1
  71. package/dist/services/connectors/oidc.d.ts +14 -2
  72. package/dist/services/connectors/oidc.d.ts.map +1 -1
  73. package/dist/services/connectors/oidc.js +29 -3
  74. package/dist/services/connectors/oidc.js.map +1 -1
  75. package/dist/services/connectors/types.d.ts +13 -1
  76. package/dist/services/connectors/types.d.ts.map +1 -1
  77. package/dist/services/domain/authentication.d.ts.map +1 -1
  78. package/dist/services/domain/authentication.js +36 -19
  79. package/dist/services/domain/authentication.js.map +1 -1
  80. package/dist/services/domain/organization.d.ts +5 -1
  81. package/dist/services/domain/organization.d.ts.map +1 -1
  82. package/dist/services/domain/organization.js.map +1 -1
  83. package/dist/services/events/types.d.ts +1 -1
  84. package/dist/services/events/types.d.ts.map +1 -1
  85. package/dist/services/events/validate.d.ts.map +1 -1
  86. package/dist/services/events/validate.js +6 -0
  87. package/dist/services/events/validate.js.map +1 -1
  88. package/dist/services/magicLinks.d.ts.map +1 -1
  89. package/dist/services/magicLinks.js +13 -9
  90. package/dist/services/magicLinks.js.map +1 -1
  91. package/dist/services/oauth2.d.ts +72 -2
  92. package/dist/services/oauth2.d.ts.map +1 -1
  93. package/dist/services/oauth2.js +106 -6
  94. package/dist/services/oauth2.js.map +1 -1
  95. package/dist/services/redirectUri.d.ts +56 -0
  96. package/dist/services/redirectUri.d.ts.map +1 -0
  97. package/dist/services/redirectUri.js +129 -0
  98. package/dist/services/redirectUri.js.map +1 -0
  99. package/dist/services/serviceAccounts.d.ts +20 -0
  100. package/dist/services/serviceAccounts.d.ts.map +1 -1
  101. package/dist/services/serviceAccounts.js +47 -1
  102. package/dist/services/serviceAccounts.js.map +1 -1
  103. package/dist/services/sessionRevocation.d.ts +76 -0
  104. package/dist/services/sessionRevocation.d.ts.map +1 -0
  105. package/dist/services/sessionRevocation.js +86 -0
  106. package/dist/services/sessionRevocation.js.map +1 -0
  107. package/dist/services/singleUse.d.ts +68 -0
  108. package/dist/services/singleUse.d.ts.map +1 -0
  109. package/dist/services/singleUse.js +54 -0
  110. package/dist/services/singleUse.js.map +1 -0
  111. package/dist/services/smsOtp.d.ts.map +1 -1
  112. package/dist/services/smsOtp.js +12 -8
  113. package/dist/services/smsOtp.js.map +1 -1
  114. package/dist/services/tokens.d.ts +2 -0
  115. package/dist/services/tokens.d.ts.map +1 -1
  116. package/dist/services/tokens.js +5 -0
  117. package/dist/services/tokens.js.map +1 -1
  118. package/dist/services/trustedProxies.d.ts +90 -0
  119. package/dist/services/trustedProxies.d.ts.map +1 -0
  120. package/dist/services/trustedProxies.js +283 -0
  121. package/dist/services/trustedProxies.js.map +1 -0
  122. package/dist/setup-server.js +2 -1
  123. package/dist/setup-server.js.map +1 -1
  124. package/dist/types.d.ts +2 -2
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js.map +1 -1
  127. package/package.json +4 -5
package/CHANGELOG.md CHANGED
@@ -5,6 +5,392 @@ All notable changes to Hilbras Keystone are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.5.0] - 2026-09-27
9
+
10
+ ### Security
11
+
12
+ - **The OIDC nonce never reached the Google connector.**
13
+ `GoogleConnector.exchangeCode` overrode the base method and called
14
+ `super.exchangeCode(code, redirectUri)` without forwarding its options, so the
15
+ nonce added in 2.4.0 was discarded. Every other OIDC provider validated the
16
+ nonce; Google — the default, and therefore the most likely to be deployed — did
17
+ not. An ID token minted for a different user or session would have been
18
+ accepted on that path.
19
+ - **The unsigned SAML `Issuer` was never validated.** The response-level
20
+ `<saml:Issuer>` sits outside both signed regions, so rewriting it does not
21
+ invalidate the signature, and neither samlify nor Keystone compared it to the
22
+ registered IdP. SAML 2.0 §2.5.1.5 requires a relying party to verify an
23
+ unsigned issuer against trusted metadata. An assertion could claim to have been
24
+ issued by a different identity provider. The assertion's own issuer is inside
25
+ the signed region and was always covered; this closes the element the signature
26
+ cannot.
27
+
28
+ ### Fixed
29
+
30
+ - `verifyRelayState` returned by throwing on a missing or non-string signature,
31
+ turning a malformed RelayState — a bad request an attacker fully controls —
32
+ into a 500 rather than a 400. It now returns false for anything malformed.
33
+ - A missing `userinfoEndpoint` was passed to the fetcher behind a non-null
34
+ assertion, producing `userinfoEndpoint must be a valid URL` for a URL that was
35
+ never configured. It is now reported as unconfigured so enrichment is skipped.
36
+
37
+ ### Added
38
+
39
+ - 24 adversarial SAML tests covering tampered signatures, untrusted signing keys,
40
+ rotated-out certificates, unsigned assertions, XML signature wrapping, issuer
41
+ and audience substitution, destination and recipient prefix / superstring / case
42
+ variants, expired assertions, `NotBefore` violations, `InResponseTo` mismatch,
43
+ transaction replay, and five RelayState tampering scenarios.
44
+ - 10 tests for OIDC userinfo endpoint resolution, Google nonce forwarding, and
45
+ organization-scoped membership.
46
+ - `docs/security/enterprise-sso.md` — SAML and OIDC setup, every check applied to
47
+ an assertion or ID token, certificate rotation, endpoint SSRF policy,
48
+ organization scoping, and recommendations.
49
+
50
+ ### Already sound, verified rather than assumed
51
+
52
+ Membership is keyed on `(orgId, userId)` throughout, with a unique constraint on
53
+ that pair and provisioning via `ON CONFLICT DO NOTHING` — so two simultaneous
54
+ logins cannot create duplicate memberships. SAML connections are resolved by
55
+ `(connectionId, orgId)`, the transaction is consumed atomically for replay
56
+ protection, RelayState is HMAC-signed and bound to a browser nonce compared in
57
+ constant time, and assertions and messages are both required to be signed. A test
58
+ now pins the membership behaviour rather than leaving it to inspection.
59
+
60
+ ## [2.4.0] - 2026-09-27
61
+
62
+ ### Security
63
+
64
+ - **The `authorization_code` grant did not authenticate the client.** It looked
65
+ the application up by `client_id` and went straight to redeeming the code,
66
+ never calling `verifyClientSecret`. RFC 6749 §3.2.1 requires a confidential
67
+ client to authenticate at the token endpoint. The code and its PKCE verifier
68
+ were the only factors, so an intercepted code was redeemable by whoever
69
+ intercepted it. Confidential clients must now present `client_secret`; public
70
+ clients are exempt because they have none, and PKCE is what authenticates them.
71
+ - **Redirect URIs accepted script-bearing schemes.** Registration validated with
72
+ `z.string().url()`, which accepts anything the URL parser accepts — verified to
73
+ include `javascript:alert(1)` and
74
+ `data:text/html,<script>alert(1)</script>`. A redirect URI becomes a `Location`
75
+ header that the identity provider itself emits, so an organization admin could
76
+ register one and hand any user who authorized their application a redirect
77
+ toward script execution on the auth domain. Browser policy against top-level
78
+ `javascript:` navigation limits the practical impact, but on an identity
79
+ provider this is not an acceptable input. Registration now also rejects
80
+ wildcards, fragments, embedded credentials, and plaintext HTTP to non-loopback
81
+ hosts. A test records that `z.string().url()` accepted each of these.
82
+ - **OIDC federation sent no nonce and verified none.** `state` proved the callback
83
+ belonged to a login this browser started, but nothing bound the returned ID
84
+ token to that login. Any ID token the provider considered valid was accepted,
85
+ including one minted for a different user or session. A nonce is now generated
86
+ per authorization request, kept in an httpOnly cookie, sent to the provider, and
87
+ required to match.
88
+ - **ID token verification inferred rather than required.** Algorithms are now
89
+ pinned to RS256/ES256/PS256 instead of being derived from the key material, and
90
+ `exp`, `iat`, `iss`, `aud`, `sub` are required rather than validated only when
91
+ present — a token with no expiry was previously accepted indefinitely.
92
+
93
+ ### Changed
94
+
95
+ - **Effective scopes are intersected, not trusted.** The client's `scope`
96
+ parameter was stored verbatim, with consent as the only filter. The effective
97
+ set is now registered ∩ requested ∩ consented, and a scope outside the
98
+ registration is refused with `invalid_scope` rather than silently dropped, so a
99
+ client asking for authority it was never granted is visible instead of quietly
100
+ downgraded. An empty `allowed_scopes` preserves existing behaviour.
101
+ - **Public clients.** A `client_type` column distinguishes `confidential` from
102
+ `public`; a public client is issued no secret rather than a secret it is
103
+ expected to ignore, and a check constraint keeps the two halves consistent.
104
+ `client_secret_hash` is now nullable. PKCE is mandatory for a secretless client
105
+ at both `/authorize` and `/token`; the `verifyPKCE` branch that returned true
106
+ when no challenge was registered is gone.
107
+ - **Redirect URIs are compared with one shared helper** at registration and at
108
+ use, so the two cannot drift. Exact string comparison throughout — no prefix
109
+ matching, no normalization, no case folding. The token endpoint's dead
110
+ `redirect_uri IS NULL` tolerance was removed: `redirect_uri` is required at
111
+ `/authorize`, so the branch was unreachable, and it would have accepted any
112
+ redirect URI had the field ever become optional.
113
+ - **Refresh tokens carry the granted scope set** in a new `scopes` column, so the
114
+ authorization context survives rotation instead of being dropped at the first
115
+ refresh. A refresh may narrow the grant but never widen it.
116
+ - **PKCE comparison is constant-time**, so a verifier cannot be recovered byte by
117
+ byte.
118
+
119
+ ### Already sound, verified rather than assumed
120
+
121
+ Authorization code consumption was already atomic — a conditional `UPDATE` with
122
+ `used_at IS NULL` and a required returned row — and is now covered by a
123
+ concurrency test (20 parallel redemptions, exactly one winner). PKCE was already
124
+ required at `/authorize` by the request schema, so the dead branch in
125
+ `verifyPKCE` was a latent weakness rather than a live bypass. The refresh grant
126
+ already validated client binding, MFA context, and organization membership.
127
+
128
+ ### Added
129
+
130
+ - 42 tests: redirect URI registration and exact matching, PKCE verification,
131
+ scope intersection, client authentication at the token endpoint, public client
132
+ invariants, atomic code consumption, ID token verification against a locally
133
+ signed key (missing / wrong / replayed / expired nonce, no-expiry, wrong issuer,
134
+ wrong audience, foreign signing key), and scope preservation across rotation.
135
+
136
+ ## [2.3.0] - 2026-09-27
137
+
138
+ ### Security
139
+
140
+ **Completing a password reset did not remove existing access.** It changed the
141
+ password and left every session, every refresh token, and every other
142
+ outstanding reset token working. A password reset is the standard response to a
143
+ suspected compromise, so the previous behaviour defeated its own purpose: an
144
+ attacker who triggered the reset kept their session and kept their access, while
145
+ the victim believed they had locked the intruder out.
146
+
147
+ - A successful reset now invalidates all sessions, all refresh tokens, and all
148
+ outstanding recovery credentials for the account.
149
+ - Reset tokens issued alongside the one used are now spent, so a reset email
150
+ captured earlier cannot be completed after the user has already recovered.
151
+ - Revocation is centralized in `src/services/sessionRevocation.ts`
152
+ (`revokeUserSessions`, `revokeRefreshTokens`,
153
+ `revokeAuthenticationSessions`, `revokeRecoveryCredentials`). It was
154
+ previously open-coded at each call site, which is how the most important site
155
+ came to omit it. The MFA-enablement path now routes through the same function,
156
+ so the rule cannot drift between the two.
157
+ - Revocation is scoped to one user, is idempotent, and honours an exclusion for
158
+ a change the user makes to their own account.
159
+
160
+ API keys are deliberately **not** revoked by a password reset. They are
161
+ separately issued, long-lived credentials belonging to integrations rather than
162
+ to the person, and killing them silently breaks deployments. The residual gap is
163
+ real — a key minted by an attacker who already held the password survives — and
164
+ key expiry and rotation is the right answer rather than coupling key lifetime to
165
+ a human's password.
166
+
167
+ ### Already sound, verified rather than assumed
168
+
169
+ - Recovery credentials are 384-bit `crypto.randomBytes`, stored only as a SHA-256
170
+ digest, single-use (since 2.2.0), valid for one hour, rate-limited to 5 per 15
171
+ minutes, and audited.
172
+ - `POST /auth/forgot-password` returns `{ success: true }` on both the found and
173
+ not-found paths, so the response does not disclose whether an account exists.
174
+ (A residual timing difference remains, since the found path sends mail.)
175
+
176
+ ### Added
177
+
178
+ - 9 tests, including that an attacker's session and refresh token do not survive
179
+ a reset, that an intercepted earlier reset token is dead, that a bystander's
180
+ credentials are untouched, and that the recovered user can still log in.
181
+
182
+ ## [2.2.0] - 2026-09-26
183
+
184
+ ### Security
185
+
186
+ Three single-use credentials were validated with a conditional `SELECT` and then
187
+ marked used with an **unconditional** `UPDATE`:
188
+
189
+ ```text
190
+ SELECT ... WHERE used_at IS NULL <- conditional
191
+ if (!row) return
192
+ UPDATE ... SET used_at = now() <- UNCONDITIONAL: the race
193
+ ```
194
+
195
+ Between those two statements, any number of concurrent requests pass the same
196
+ check. Every one of them then succeeds.
197
+
198
+ - **Magic links** could be redeemed by any number of parallel requests, each
199
+ producing a full login. A link that was meant to be usable once was usable
200
+ indefinitely under concurrency.
201
+ - **Password reset tokens** could be spent by parallel requests, each writing a
202
+ different password, last writer winning. This was the most consequential of
203
+ the three: whoever won the race held the account, and an attacker racing the
204
+ legitimate user could take it over.
205
+ - **SMS OTP codes** could be verified more than once concurrently, so a
206
+ six-digit code was not single-use.
207
+
208
+ The fix is to make the write the gate rather than a follow-up:
209
+
210
+ ```text
211
+ UPDATE ... SET used_at = now()
212
+ WHERE token_hash = ? AND expires_at > now() AND used_at IS NULL
213
+ RETURNING ...
214
+ ```
215
+
216
+ PostgreSQL evaluates that predicate while holding a row lock, so exactly one
217
+ transaction updates the row and observes a returned row. The claim and the
218
+ validation become one statement with no window between them.
219
+
220
+ The four other single-use credentials in scope were already atomic and were
221
+ verified rather than assumed: refresh token rotation, MFA challenges, OAuth2
222
+ authorization codes, and TOTP backup codes all perform a conditional update and
223
+ require a returned row.
224
+
225
+ ### Added
226
+
227
+ - `src/services/singleUse.ts` — one atomic claim and refusal-classification
228
+ primitive, used by all three credentials. Consumption now lives in one place,
229
+ so a credential cannot drift back into a hand-rolled read-then-write.
230
+ - Replay detection. A credential presented after it was already spent now emits
231
+ `magic_link_replayed`, `sms_otp_replayed`, or
232
+ `password_reset_token_replayed`, all of which reach the audit log through the
233
+ event bus. Previously a replay was indistinguishable from a typo, so a leaked
234
+ token returning was invisible to an operator. An **expired** credential is
235
+ deliberately not reported as a replay, because that is not a leak.
236
+ - 25 tests, including the plan's 10 / 50 / 100 concurrent-request levels against
237
+ every affected credential, and the same levels through the service entry points
238
+ a route actually calls.
239
+
240
+ ### Changed
241
+
242
+ - `resetPasswordWithToken` now spends the token before doing any work, and
243
+ reports an expired link distinctly from an invalid one. Spending other live
244
+ reset tokens for the same user after a successful reset, since any were issued
245
+ alongside the one just used.
246
+
247
+ ## [2.1.0] - 2026-09-26
248
+
249
+ ### Security
250
+
251
+ Four moderate advisories in the development tree, from `drizzle-kit` pulling
252
+ `@esbuild-kit/esm-loader`, which pinned its own copy of `esbuild@0.18.20`
253
+ (`GHSA-67mh-4wv8-2f99`, fixed in 0.25.0).
254
+
255
+ The advisory allows a website to send requests to an esbuild **dev server** and
256
+ read the response. Keystone never calls esbuild's `serve()` API, the packages
257
+ are `devDependencies`, and `npm audit --omit=dev` was already clean, so this was
258
+ not exploitable here. It was still a real advisory in the tree that builds and
259
+ publishes the artifact, and `npm audit fix --force` offered only a downgrade of
260
+ `drizzle-kit` to 0.18.1, which is a breaking change.
261
+
262
+ Resolved with an `overrides` entry forcing `esbuild >= 0.25.0`, which collapses
263
+ all three copies to 0.28.2 and clears the audit for production and development
264
+ trees alike. Verified that `db:generate`, `db:migrate`, and `db:seed` all still
265
+ work against the forced version, and that no non-dev package resolves esbuild.
266
+
267
+ ### Fixed
268
+
269
+ - The published container image shipped 8 HIGH-severity advisories that no
270
+ JavaScript scanner can detect. `npm audit` and OSV both read
271
+ `package-lock.json` and correctly reported zero, because the vulnerable
272
+ packages are not in Keystone's dependency tree: they are the ones bundled
273
+ inside the base image's `npm@10.9.9` (`brace-expansion@2.0.2`,
274
+ `ip-address@10.1.0`, `pacote@19.0.2`, `picomatch@4.0.3`, `sigstore@3.1.0`).
275
+ The runtime image never invokes npm — `CMD` is `node dist/index.js`, and the
276
+ development compose override builds the `builder` target, which keeps its own
277
+ npm — so it is now removed from the production stage. This clears all 8 and
278
+ reduces the image from 600 MB to 550 MB.
279
+
280
+ Only container scanning finds this class of problem, which is why the gate
281
+ exists. Keystones own `brace-expansion@5.0.12` is already above the fixed
282
+ version and was never affected.
283
+
284
+ ### Added
285
+
286
+ - `.github/dependabot.yml` — weekly updates for npm (root and frontend), GitHub
287
+ Actions, and Docker. Routine patches are grouped; security updates are not, so
288
+ a compromised package lands alone and identifiable.
289
+ - `.github/workflows/supply-chain.yml` — OSV scanning (independent advisory
290
+ source from npm's), enforced `npm audit` over both trees, dependency review on
291
+ pull requests, SBOM generation, container scanning, and a license gate.
292
+ - `npm run verify:release` — fails on a version that disagrees between
293
+ `package.json` and the lockfile, a dependency in one and not the other, a
294
+ missing or malformed license, or a missing `repository` field. Wired into both
295
+ CI and the release workflow so a bad artifact cannot be published.
296
+
297
+ ### Fixed
298
+
299
+ - `package.json` declared no `license` field, despite shipping an MIT `LICENSE`
300
+ file. The published package carried no machine-readable terms.
301
+
302
+ ### Changed
303
+
304
+ - Fastify is at 5.12.5 and `fast-uri` resolves to 3.1.8, both already above the
305
+ 5.12.2 / 3.1.7 targets. No upgrade was required.
306
+ - The license allowlist permits only permissive terms, with an explicit
307
+ exception for the pre-SPDX `MIT*` identifier that older packages emit.
308
+
309
+ ## [2.0.0] - 2026-09-26
310
+
311
+ ### Security
312
+
313
+ The mTLS trust boundary trusted whatever the request said about itself. Any
314
+ client that could reach Keystone could name a service account in a header and
315
+ become it, and could set its own IP address to escape every rate limit. This
316
+ release makes identity come only from values a client cannot forge.
317
+
318
+ - **`x-service-account-id` no longer authenticates.** It previously resolved a
319
+ service account on its own, with no certificate and no credential, so anyone
320
+ who knew or guessed an account ID became that account. It is now read only as a
321
+ hint alongside a valid certificate, and only when the account it names is the
322
+ one that certificate is bound to. A mismatch is refused, not fallen back from.
323
+ - **Client identity is bound to a certificate fingerprint.** A new unique
324
+ `service_accounts.cert_fingerprint` column pins a SHA-256 fingerprint to
325
+ exactly one account. Fingerprints are stored canonicalized, so the hex and
326
+ colon-separated spellings of one certificate cannot become two bindings, and
327
+ malformed values are rejected before reaching the database. Service accounts
328
+ are resolved by fingerprint rather than by their operator-chosen name.
329
+ - **Identity headers are stripped from untrusted peers.** An `onRequest` hook
330
+ registered before every plugin and route removes
331
+ `x-forwarded-client-cert`, `x-client-cert-fingerprint`,
332
+ `x-forwarded-client-cert-chain`, `x-service-account-id`, `x-forwarded-for`,
333
+ `x-real-ip`, and `forwarded` unless the peer is a configured trusted proxy.
334
+ Stripping rather than ignoring means a route added later cannot read a
335
+ spoofed identity by accident.
336
+ - **Rate limits can no longer be escaped.** The server was created with
337
+ `trustProxy: true` and the limiter read `x-forwarded-for` unconditionally, so
338
+ any client could present a fresh address per request and never be limited —
339
+ including against login, password reset, MFA verification, and SCIM. Limit keys
340
+ now come from the peer address unless a trusted proxy forwarded one.
341
+ - **Trust decisions do not use `request.ip`.** With `trustProxy` enabled that
342
+ value is derived from the attacker-controlled header, so the trusted-proxy
343
+ check uses the socket peer address, the only value a client cannot set.
344
+ - **Forwarded values are validated before use.** Certificate headers are
345
+ length-capped, and a fingerprint must be a well-formed SHA-256 digest, so
346
+ garbage cannot be used as a lookup key.
347
+ - **Inactive and revoked service accounts cannot authenticate by certificate.**
348
+
349
+ ### Added
350
+
351
+ - `KEYSTONE_TRUSTED_PROXIES` — comma-separated proxy IPs, IPv4 CIDRs, or IPv6
352
+ prefixes permitted to set client-identity headers. Unset by default, which
353
+ trusts nothing. IPv4-mapped IPv6 peers are normalized before matching, and
354
+ unrecognized input fails closed.
355
+ - `PUT /v1/admin/organizations/:id/service-accounts/:accountId/certificate` —
356
+ bind or clear a client-certificate fingerprint, auditing
357
+ `service_account_certificate_bound` / `service_account_certificate_unbound`.
358
+ A certificate already held by another account returns `409`.
359
+ - `POST /v1/admin/organizations/:id/service-accounts/:accountId/revoke` —
360
+ permanently stop an account authenticating, auditing
361
+ `service_account_revoked`. Revoking an already-revoked account returns `409`
362
+ rather than a silent success.
363
+ - `docs/security/trust-boundaries.md`, `docs/security/proxy-security.md`, and
364
+ `docs/security/mtls.md` — the trust model, proxy requirements with working
365
+ nginx and ALB configuration, and the mTLS identity rules.
366
+ - `docs/MIGRATION-2.0.md` — migration instructions, including the failure mode
367
+ that presents as unrelated clients sharing a rate-limit budget.
368
+
369
+ ### Fixed
370
+
371
+ - The SCIM token-hash unique indexes introduced in 1.9.0 were declared in the
372
+ Drizzle schema but never emitted as a migration, so they did not exist in any
373
+ deployed database. They are created by migration `0015`.
374
+ - The documented nginx configuration used `$proxy_add_x_forwarded_for`, which
375
+ appends to a client-supplied value and lets a client prepend a forged address.
376
+ Corrected to `$remote_addr`, with inbound identity headers stripped.
377
+
378
+ ### Changed
379
+
380
+ - `trustProxy` is derived from `KEYSTONE_TRUSTED_PROXIES` instead of being
381
+ unconditionally `true`.
382
+ - `requireMTLS` distinguishes an untrusted peer (`401 MTLS_UNTRUSTED_PEER`) from
383
+ a missing certificate (`401 MTLS_CERTIFICATE_MISSING`), so a misconfiguration
384
+ is distinguishable from an attack.
385
+
386
+ ### Breaking
387
+
388
+ - mTLS clients that authenticated with `x-service-account-id` alone must bind a
389
+ certificate fingerprint instead.
390
+ - Deployments behind a reverse proxy must set `KEYSTONE_TRUSTED_PROXIES`.
391
+ Without it, forwarded headers are stripped and every client shares one
392
+ rate-limit budget, so unrelated users can rate-limit each other.
393
+
8
394
  ## [1.9.0] - 2026-09-25
9
395
 
10
396
  ### Security