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