@hilbras/keystone 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +423 -0
  2. package/README.md +85 -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 +17 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/auth.d.ts +2 -0
  10. package/dist/plugins/auth.d.ts.map +1 -1
  11. package/dist/plugins/auth.js +21 -7
  12. package/dist/plugins/auth.js.map +1 -1
  13. package/dist/plugins/machinePrincipal.d.ts +28 -0
  14. package/dist/plugins/machinePrincipal.d.ts.map +1 -0
  15. package/dist/plugins/machinePrincipal.js +45 -0
  16. package/dist/plugins/machinePrincipal.js.map +1 -0
  17. package/dist/plugins/rateLimit.d.ts +10 -7
  18. package/dist/plugins/rateLimit.d.ts.map +1 -1
  19. package/dist/plugins/rateLimit.js +75 -36
  20. package/dist/plugins/rateLimit.js.map +1 -1
  21. package/dist/routes/admin/organizations.d.ts.map +1 -1
  22. package/dist/routes/admin/organizations.js +2 -0
  23. package/dist/routes/admin/organizations.js.map +1 -1
  24. package/dist/routes/admin/platform.d.ts.map +1 -1
  25. package/dist/routes/admin/platform.js +14 -1
  26. package/dist/routes/admin/platform.js.map +1 -1
  27. package/dist/routes/apiKeys.d.ts.map +1 -1
  28. package/dist/routes/apiKeys.js +37 -5
  29. package/dist/routes/apiKeys.js.map +1 -1
  30. package/dist/routes/auth.d.ts.map +1 -1
  31. package/dist/routes/auth.js +104 -3
  32. package/dist/routes/auth.js.map +1 -1
  33. package/dist/routes/emailVerification.d.ts.map +1 -1
  34. package/dist/routes/emailVerification.js +2 -0
  35. package/dist/routes/emailVerification.js.map +1 -1
  36. package/dist/routes/federation.d.ts.map +1 -1
  37. package/dist/routes/federation.js +8 -2
  38. package/dist/routes/federation.js.map +1 -1
  39. package/dist/routes/magicLinks.d.ts.map +1 -1
  40. package/dist/routes/magicLinks.js +2 -0
  41. package/dist/routes/magicLinks.js.map +1 -1
  42. package/dist/routes/oauth2.d.ts.map +1 -1
  43. package/dist/routes/oauth2.js +24 -4
  44. package/dist/routes/oauth2.js.map +1 -1
  45. package/dist/routes/password.d.ts.map +1 -1
  46. package/dist/routes/password.js +2 -0
  47. package/dist/routes/password.js.map +1 -1
  48. package/dist/routes/profile.js +2 -2
  49. package/dist/routes/profile.js.map +1 -1
  50. package/dist/routes/scim.d.ts.map +1 -1
  51. package/dist/routes/scim.js +2 -0
  52. package/dist/routes/scim.js.map +1 -1
  53. package/dist/routes/serviceAccounts.d.ts.map +1 -1
  54. package/dist/routes/serviceAccounts.js +17 -1
  55. package/dist/routes/serviceAccounts.js.map +1 -1
  56. package/dist/routes/sessions.js +3 -3
  57. package/dist/routes/sessions.js.map +1 -1
  58. package/dist/routes/smsOtp.d.ts.map +1 -1
  59. package/dist/routes/smsOtp.js +10 -0
  60. package/dist/routes/smsOtp.js.map +1 -1
  61. package/dist/routes/totp.d.ts.map +1 -1
  62. package/dist/routes/totp.js +38 -6
  63. package/dist/routes/totp.js.map +1 -1
  64. package/dist/routes/webauthn.d.ts.map +1 -1
  65. package/dist/routes/webauthn.js +8 -2
  66. package/dist/routes/webauthn.js.map +1 -1
  67. package/dist/services/configuration/profiles.d.ts +26 -0
  68. package/dist/services/configuration/profiles.d.ts.map +1 -1
  69. package/dist/services/configuration/profiles.js +80 -1
  70. package/dist/services/configuration/profiles.js.map +1 -1
  71. package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
  72. package/dist/services/events/subscribers/auditLog.js +38 -3
  73. package/dist/services/events/subscribers/auditLog.js.map +1 -1
  74. package/dist/services/events/types.d.ts +3 -1
  75. package/dist/services/events/types.d.ts.map +1 -1
  76. package/dist/services/events/validate.d.ts +1 -0
  77. package/dist/services/events/validate.d.ts.map +1 -1
  78. package/dist/services/events/validate.js +5 -1
  79. package/dist/services/events/validate.js.map +1 -1
  80. package/dist/services/localRateLimit.d.ts +44 -0
  81. package/dist/services/localRateLimit.d.ts.map +1 -0
  82. package/dist/services/localRateLimit.js +86 -0
  83. package/dist/services/localRateLimit.js.map +1 -0
  84. package/dist/services/refreshTokenState.d.ts +5 -0
  85. package/dist/services/refreshTokenState.d.ts.map +1 -0
  86. package/dist/services/refreshTokenState.js +30 -0
  87. package/dist/services/refreshTokenState.js.map +1 -0
  88. package/dist/services/scopes.d.ts +113 -0
  89. package/dist/services/scopes.d.ts.map +1 -0
  90. package/dist/services/scopes.js +138 -0
  91. package/dist/services/scopes.js.map +1 -0
  92. package/dist/services/setup/token.d.ts +12 -0
  93. package/dist/services/setup/token.d.ts.map +1 -1
  94. package/dist/services/setup/token.js +27 -3
  95. package/dist/services/setup/token.js.map +1 -1
  96. package/dist/services/tokens.d.ts +1 -0
  97. package/dist/services/tokens.d.ts.map +1 -1
  98. package/dist/services/tokens.js +1 -1
  99. package/dist/services/tokens.js.map +1 -1
  100. package/dist/services/trustedProxies.d.ts +24 -0
  101. package/dist/services/trustedProxies.d.ts.map +1 -1
  102. package/dist/services/trustedProxies.js +19 -0
  103. package/dist/services/trustedProxies.js.map +1 -1
  104. package/dist/services/webhooks.d.ts +16 -0
  105. package/dist/services/webhooks.d.ts.map +1 -1
  106. package/dist/services/webhooks.js +48 -3
  107. package/dist/services/webhooks.js.map +1 -1
  108. package/dist/setup-server.js +47 -3
  109. package/dist/setup-server.js.map +1 -1
  110. package/docs/API-REVIEW.md +121 -0
  111. package/docs/API.md +457 -0
  112. package/docs/ARCHITECTURE.md +142 -0
  113. package/docs/CONTRIBUTING.md +61 -0
  114. package/docs/DEPLOYMENT.md +257 -0
  115. package/docs/INTEGRATION.md +336 -0
  116. package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
  117. package/docs/MIGRATION-1.7.md +70 -0
  118. package/docs/MIGRATION-1.8.md +183 -0
  119. package/docs/MIGRATION-1.9.md +200 -0
  120. package/docs/MIGRATION-2.0.md +203 -0
  121. package/docs/MIGRATION-2.4.md +185 -0
  122. package/docs/PERFORMANCE.md +155 -0
  123. package/docs/RBAC.md +100 -0
  124. package/docs/RE-AUDIT.md +72 -0
  125. package/docs/README.md +54 -0
  126. package/docs/RELEASE-1.7.md +53 -0
  127. package/docs/ROADMAP.md +41 -0
  128. package/docs/SECURITY.md +143 -0
  129. package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
  130. package/docs/adrs/002-versioned-event-bus.md +30 -0
  131. package/docs/adrs/003-bullmq-for-background-work.md +20 -0
  132. package/docs/adrs/004-argon2id-password-hashing.md +19 -0
  133. package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
  134. package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
  135. package/docs/security/audit.md +82 -0
  136. package/docs/security/configuration.md +60 -0
  137. package/docs/security/enterprise-sso.md +193 -0
  138. package/docs/security/mtls.md +132 -0
  139. package/docs/security/proxy-security.md +128 -0
  140. package/docs/security/rate-limiting.md +79 -0
  141. package/docs/security/registry-exceptions.md +34 -0
  142. package/docs/security/registry.json +649 -0
  143. package/docs/security/registry.md +657 -0
  144. package/docs/security/scopes.md +45 -0
  145. package/docs/security/supply-chain.md +49 -0
  146. package/docs/security/trust-boundaries.md +111 -0
  147. package/package.json +15 -6
@@ -0,0 +1,82 @@
1
+ # Audit and abuse events
2
+
3
+ Covers SEC-036, SEC-037 and SEC-038.
4
+
5
+ An event that exists in the vocabulary but is never emitted is worse than one
6
+ that does not exist: it reads as coverage. All three events below were in that
7
+ state and were added in v2.8.0.
8
+
9
+ ## `user_login_failed`
10
+
11
+ Previously defined, never emitted, on either login route. A wrong password
12
+ produced a `401` and nothing else, so credential guessing left no record beyond
13
+ whatever the rate limiter happened to observe in aggregate.
14
+
15
+ Deliberately records **no user id**. At the point of failure no credential has
16
+ been proven, and the submitted address may correspond to no account at all —
17
+ attributing the failure to a user id would both misattribute the attempt and
18
+ create a cheap way to probe which addresses exist.
19
+
20
+ ## `rate_limit_triggered`
21
+
22
+ A refused request previously produced a `429` and nothing else.
23
+
24
+ The event carries **which limiter decided** — `redis`, `local` or `none`. These
25
+ are three different operational situations, and a degraded in-process control is
26
+ a reason to go and look at Redis. Recording a local refusal in the same shape as
27
+ a healthy distributed one would hide exactly the thing worth knowing.
28
+
29
+ ## `refresh_token_replayed`
30
+
31
+ Rotation consumes a refresh token, so a second presentation failed in exactly the
32
+ same way as a token that never existed. A stolen token used twice was therefore
33
+ indistinguishable from a typo.
34
+
35
+ It is now detected by looking the token up and reading its state, and the event
36
+ records which of the two it was:
37
+
38
+ - `replayed: true` — the token exists and was already spent. This means the
39
+ token leaked, so the account's remaining credentials are revoked. Answering
40
+ only that one request would leave everything else it could mint intact.
41
+ - `replayed: false` — the token is unknown. A guess, or a stale client. Nothing
42
+ is revoked, because an attacker guessing random values must not be able to log
43
+ a user out.
44
+
45
+ ## A machine principal is not a user
46
+
47
+ A service account is represented in memory by a user object whose id is the
48
+ sentinel `sa:<uuid>`, so routes expecting `request.user` keep working without a
49
+ matching user row.
50
+
51
+ That sentinel was being passed straight into `audit_log.user_id`, which is a uuid
52
+ column. Postgres rejected the insert, the subscriber logged a failure, and the
53
+ record was lost. Nothing failed visibly — the request succeeded, and a missing
54
+ audit record is indistinguishable from a request that never happened.
55
+
56
+ So **every request authenticated by an API key or an mTLS service account left no
57
+ audit trail at all.** The privileged, non-human path was the one that was
58
+ invisible, which is precisely the wrong direction for that gap to point.
59
+
60
+ The sentinel is now stripped, `user_id` is left null, and the service account is
61
+ recorded in `metadata.serviceAccountId` — the record still identifies who acted,
62
+ without violating the column type. (SEC-046)
63
+
64
+ ## The audit export is opened in a spreadsheet
65
+
66
+ The CSV export quotes a value containing a delimiter or a quote. It also
67
+ neutralises a value whose **first character** is `=`, `+`, `-` or `@`, because a
68
+ spreadsheet evaluates such a cell as a formula when the file is opened.
69
+
70
+ Quoting alone is not enough, and several exported columns are attacker-supplied —
71
+ the user agent above all. A `User-Agent` of `=cmd|'/c calc'!A1` reached the export
72
+ intact.
73
+
74
+ The apostrophe prefix is applied *before* quoting, and a value with no formula
75
+ prefix is left alone so ordinary data is not corrupted.
76
+
77
+ ## Event versioning
78
+
79
+ Events are stored as `<name>:v1`. A query against the bare name matches nothing
80
+ and returns an empty result, which reads as "no such event occurred" — the same
81
+ vacuous pass that let SEC-037 go unnoticed. Tests that assert on audit output
82
+ must match the versioned name.
@@ -0,0 +1,60 @@
1
+ # Configuration, secrets and deployment defaults
2
+
3
+ Covers SEC-026 through SEC-032.
4
+
5
+ ## The configuration endpoint redacts by allowlist
6
+
7
+ The owner-only configuration endpoint previously matched a **denylist** of
8
+ secret-looking key names. Measured against the real configuration surface,
9
+ **12 of 24** secret-shaped keys were returned unredacted — including the signing
10
+ keys, so the endpoint disclosed the material used to sign tokens.
11
+
12
+ A denylist is the wrong shape here: it has to predict every name a secret might
13
+ take, and a new secret is unredacted until someone remembers to add it.
14
+ `EXPOSABLE_CONFIG_KEYS` in `src/services/configuration/profiles.ts` is an
15
+ allowlist, so a key that is not explicitly declared exposable is not returned.
16
+
17
+ ## CORS fails closed
18
+
19
+ An unset or empty `ALLOWED_ORIGINS` was treated as *allow all*, combined with
20
+ credentialed requests. A misconfiguration therefore produced a wildcard CORS
21
+ policy that browsers actually enforce, rather than a closed one.
22
+
23
+ `isOriginAllowed` in `src/services/trustedProxies.ts` now fails closed on an
24
+ empty allowlist. The policy lives with the rest of the address logic so the
25
+ server and the tests share one implementation — an earlier version of these tests
26
+ asserted against a copy of the rule, which meant they passed whether or not the
27
+ server enforced it.
28
+
29
+ ## Cookies are Secure by default in production
30
+
31
+ `COOKIE_SECURE` defaulted to `false`, so a production deployment that did not
32
+ set it issued session cookies over plaintext. It now defaults to `true` when
33
+ `NODE_ENV` is `production`.
34
+
35
+ ## The setup server is not exposed
36
+
37
+ Three separate ways the first-run provisioning endpoint could be reached by
38
+ someone who should not have reached it:
39
+
40
+ - **Origin.** It was configured with `origin: true` and credentials enabled, so
41
+ any page in the operator's browser could call it during setup. It is now
42
+ restricted to the server's own address.
43
+ - **Interface.** It bound `0.0.0.0`, making the unauthenticated endpoint
44
+ reachable from the network. It binds loopback by default;
45
+ `KEYSTONE_SETUP_HOST` overrides, and it warns when bound to all interfaces.
46
+ - **The token.** The token granting initial owner access was printed to stdout,
47
+ so it reached log aggregation and any log shipper — the credential intended to
48
+ bootstrap trust was the one most widely distributed. Printing now requires an
49
+ explicit `KEYSTONE_PRINT_SETUP_TOKEN`.
50
+
51
+ ## Webhook secrets are encrypted at rest
52
+
53
+ Webhook secrets were stored as issued, so a database read, a backup or an admin
54
+ query returned material that lets an attacker forge delivery attempts signed as
55
+ this installation.
56
+
57
+ They are now stored as AES-256-GCM envelopes. They are **encrypted, not hashed**,
58
+ because Keystone signs outbound deliveries with the secret and must be able to
59
+ recover it. Legacy plaintext values are still readable and are rewritten on next
60
+ rotation.
@@ -0,0 +1,193 @@
1
+ # Enterprise SSO Configuration
2
+
3
+ How to configure SAML and OIDC federation, and what Keystone checks on every
4
+ assertion or token it accepts.
5
+
6
+ Related: [SECURITY.md](../SECURITY.md),
7
+ [trust-boundaries.md](./trust-boundaries.md).
8
+
9
+ ---
10
+
11
+ ## How federation fits together
12
+
13
+ ```text
14
+ User → Keystone /auth/oauth/:provider → your IdP → callback → Keystone
15
+ User → Keystone /sso/saml/:connectionId → your IdP → ACS → Keystone
16
+ ```
17
+
18
+ Keystone is the **relying party**. Your IdP asserts who the user is; Keystone
19
+ decides whether it believes them. Everything below is a check Keystone applies
20
+ on the way back.
21
+
22
+ A platform owner cannot sign in through tenant SSO, and an account flagged for
23
+ platform review cannot either.
24
+
25
+ ---
26
+
27
+ ## SAML setup
28
+
29
+ ### What you register
30
+
31
+ | Field | Meaning | Notes |
32
+ | --- | --- | --- |
33
+ | `idpEntityId` | Your IdP's entity ID | **Must** match the `Issuer` in your assertions exactly |
34
+ | `idpSsoUrl` | Single sign-on endpoint | Where Keystone sends the `SAMLRequest` |
35
+ | `idpCertificate` | Your IdP's signing certificate, PEM | Used to verify every assertion |
36
+ | `spEntityId` | Keystone's entity ID | Returned in metadata; must equal your assertion `Audience` |
37
+ | `spAcsUrl` | Keystone's assertion consumer service URL | Must equal the assertion's `Destination` and `Recipient` |
38
+ | `attributeMapping` | Which attributes carry email and name | Falls back to a standard list |
39
+
40
+ ### Obtaining Keystone's metadata
41
+
42
+ ```bash
43
+ curl https://auth.example.com/sso/saml/:connectionId/metadata
44
+ ```
45
+
46
+ Import that into your IdP as a service provider.
47
+
48
+ ### What Keystone verifies on every assertion
49
+
50
+ | Check | Failure |
51
+ | --- | --- |
52
+ | Signature valid under `idpCertificate` | `SAML validation failed` |
53
+ | Assertion **and** message both signed | `SAML validation failed` |
54
+ | `Issuer` equals `idpEntityId` | `SAML issuer mismatch` |
55
+ | `Audience` contains `spEntityId` | `SAML audience mismatch` |
56
+ | `Destination` is exactly `spAcsUrl` | `SAML response destination mismatch` |
57
+ | Every `SubjectConfirmationData/@Recipient` is `spAcsUrl` | `SAML subject recipient mismatch` |
58
+ | `InResponseTo` matches the stored transaction's request ID | `SAML response request ID mismatch` |
59
+ | `NameID` matches the resolved external identity | `SAML subject mismatch` |
60
+ | `NotBefore` / `NotOnOrAfter` | rejected |
61
+ | RelayState HMAC valid | `Invalid RelayState` |
62
+ | Transaction cookie matches the transaction's browser nonce | `Invalid SAML transaction` |
63
+ | Transaction not already consumed | `SAML transaction already consumed` |
64
+ | Connection is active **and** belongs to the request's organization | `SAML connection not found` |
65
+
66
+ Matching is exact. A `Destination` of `https://…/acs/extra`, a different case, or
67
+ an added default port are all rejected — as is a `Recipient` that is a prefix or
68
+ superstring of the registered ACS.
69
+
70
+ ### The unsigned Issuer
71
+
72
+ The response-level `<saml:Issuer>` sits **outside** both signed regions, so
73
+ rewriting it does not invalidate the signature. Keystone therefore compares it
74
+ to `idpEntityId` directly, as SAML 2.0 §2.5.1.5 requires for an unsigned issuer.
75
+
76
+ If you see `SAML issuer mismatch`, your IdP is emitting a different entity ID
77
+ than the one registered on the connection.
78
+
79
+ ### Certificate rotation
80
+
81
+ Rotation is a configuration change, not an automatic rollover. To rotate:
82
+
83
+ 1. Add the new certificate to your IdP so it begins signing with it, **keeping
84
+ the old certificate trusted** for the overlap window.
85
+ 2. Update `idpCertificate` on the connection to the new certificate.
86
+ 3. Confirm logins succeed.
87
+ 4. Remove the old certificate from your IdP.
88
+
89
+ Step 2 is the point of no return: once Keystone holds only the new certificate,
90
+ assertions signed under the old one are rejected. There is no grace period and no
91
+ support for two certificates at once. Rotate during a quiet period, or accept a
92
+ brief window where some users must retry.
93
+
94
+ ---
95
+
96
+ ## OIDC setup
97
+
98
+ ### What you register
99
+
100
+ | Field | Meaning |
101
+ | --- | --- |
102
+ | `clientId` / `clientSecret` | Your OAuth client credentials |
103
+ | `issuer` | Expected `iss` claim. Compared exactly. |
104
+ | `authorizationEndpoint`, `tokenEndpoint`, `jwksUri` | Endpoints, or supply `discoveryUrl` and let Keystone resolve them |
105
+ | `userinfoEndpoint` | Optional. Used to enrich a profile, never to establish identity. |
106
+ | `attributeMapping` | Maps IdP claims to Keystone fields |
107
+
108
+ ### What Keystone verifies on every ID token
109
+
110
+ | Check | Enforcement |
111
+ | --- | --- |
112
+ | Signature | Verified against the JWKS, fetched through the SSRF policy |
113
+ | `iss` | Must equal the configured issuer |
114
+ | `aud` | Must contain the configured client ID |
115
+ | `alg` | Pinned to RS256, ES256, or PS256 — inferred from the key material is not trusted |
116
+ | `exp`, `iat`, `iss`, `aud`, `sub` | **Required**, not merely checked when present |
117
+ | `nonce` | Must equal the value sent in the authorization request |
118
+
119
+ A token with no `exp` is rejected rather than treated as never-expiring.
120
+
121
+ ### The nonce
122
+
123
+ Keystone generates a random nonce per authorization request, keeps it in an
124
+ httpOnly `oauth_nonce` cookie, sends it to your IdP, and requires the returned
125
+ ID token to echo it.
126
+
127
+ Your IdP **must** echo the nonce. If it does not, federation fails with a nonce
128
+ mismatch. Any provider implementing OpenID Connect Discovery does this correctly.
129
+
130
+ A callback arriving with no `oauth_nonce` cookie is rejected outright — it did
131
+ not come from a flow this browser started.
132
+
133
+ > The `nonce` option must survive any connector that overrides `exchangeCode`.
134
+ > `GoogleConnector` did not forward it until 2.5.0, which silently disabled nonce
135
+ > validation for the default provider.
136
+
137
+ ### Endpoint policy
138
+
139
+ All federation endpoints go through one policy:
140
+
141
+ - HTTPS required in production. HTTP is permitted outside production so a
142
+ developer can use a local IdP.
143
+ - Loopback and private ranges refused unless `ALLOW_PRIVATE_SSO_ENDPOINTS` is
144
+ set. This is what stops an IdP URL being pointed at `169.254.169.254` or an
145
+ internal service.
146
+ - No embedded credentials.
147
+ - Redirects are not followed, and the resolved address is pinned, so a DNS answer
148
+ that changes mid-request cannot redirect the fetch.
149
+
150
+ `ALLOW_PRIVATE_SSO_ENDPOINTS` disables the private-address rule. It exists for
151
+ development and for genuinely internal IdPs. Enabling it in production means an
152
+ SSRF target inside your network becomes reachable from a configuration value.
153
+
154
+ ---
155
+
156
+ ## Organization scoping
157
+
158
+ A membership is identified by **organization and user together**, never the user
159
+ alone. One person can belong to several organizations with a different role in
160
+ each, and no lookup made during federation can see another organization's
161
+ membership.
162
+
163
+ Membership rows carry a unique constraint on `(org_id, user_id)`, and provisioning
164
+ inserts with `ON CONFLICT DO NOTHING`, so two simultaneous logins cannot create
165
+ duplicate memberships.
166
+
167
+ An **existing** user cannot be adopted by a new connection just because their
168
+ email matches. They must be invited first, or linked explicitly. Otherwise any
169
+ IdP that can assert an email address could take over an account it does not own.
170
+
171
+ Connections are resolved by `(connectionId, orgId)`, so a connection belonging to
172
+ one tenant cannot be used to complete a login initiated in another.
173
+
174
+ ---
175
+
176
+ ## Security recommendations
177
+
178
+ 1. **Require signed assertions.** Keystone does. If your IdP offers a choice,
179
+ turn on signed assertions and message signing.
180
+ 2. **Short assertion lifetimes.** Minutes, not hours. `NotOnOrAfter` is enforced.
181
+ 3. **Do not reuse assertions across users.** Each login is bound to a
182
+ single-use transaction.
183
+ 4. **Treat the ACS URL as fixed.** Register the exact URL Keystone gives you. No
184
+ wildcards, no fragments.
185
+ 5. **Keep `ALLOW_PRIVATE_SSO_ENDPOINTS` off in production** unless your IdP is
186
+ genuinely internal, and understand what turning it on permits.
187
+ 6. **Prefer OIDC over SAML** where you have the choice: it has fewer moving parts
188
+ and its validation is stricter by default.
189
+ 7. **Rotate certificates deliberately.** There is no dual-certificate support and
190
+ no grace period.
191
+ 8. **Watch the audit log** for `saml_sso_login`, `saml_connection_created`, and
192
+ `unauthorized_access`. The last one fires on a cross-organization connection
193
+ attempt.
@@ -0,0 +1,132 @@
1
+ # mTLS Service Accounts
2
+
3
+ Mutual-TLS authentication for service accounts: how a caller proves which
4
+ service account it is, and what Keystone does and does not verify.
5
+
6
+ For the proxy and trust configuration this depends on, see
7
+ [proxy-security.md](./proxy-security.md) and
8
+ [trust-boundaries.md](./trust-boundaries.md).
9
+
10
+ ## The model
11
+
12
+ ```text
13
+ Keystone ← trusted proxy ← client with a client certificate
14
+ │
15
+ └── proxy validates the chain, then tells
16
+ Keystone the SHA-256 fingerprint
17
+ ```
18
+
19
+ Keystone receives a *fingerprint*, not a certificate. It does not validate the
20
+ certificate, the chain, or the CA — the proxy does that, and Keystone trusts its
21
+ verdict only from a configured trusted peer.
22
+
23
+ Keystone's own contribution is binding that fingerprint to exactly one service
24
+ account, and refusing to let anything else establish identity.
25
+
26
+ ## Identity sources
27
+
28
+ A service account authenticates by **either**:
29
+
30
+ - **A client certificate** whose fingerprint is bound to the account via
31
+ `service_accounts.cert_fingerprint`, presented through a trusted proxy.
32
+ - **An authenticated credential**: an API key issued to the account, or a
33
+ session/bearer token belonging to it.
34
+
35
+ There is no third source. In particular, no header establishes identity on its
36
+ own. Before v2.0.0, `x-service-account-id` alone authenticated as any service
37
+ account named in the header, with no certificate and no credential — anyone who
38
+ could reach Keystone could become any service account. That path is removed.
39
+
40
+ ## Binding a certificate
41
+
42
+ The fingerprint is a SHA-256 digest, in hex or the colon-separated form AWS ALB
43
+ emits. Keystone stores it canonicalized, so the two spellings of one certificate
44
+ cannot become two bindings.
45
+
46
+ Get the fingerprint of a client certificate:
47
+
48
+ ```bash
49
+ openssl x509 -in client.pem -noout -fingerprint -sha256
50
+ # SHA256 Fingerprint=AB:CD:...:9F (colon-separated, uppercase)
51
+ ```
52
+
53
+ ```bash
54
+ openssl x509 -in client.pem -outform DER | openssl dgst -sha256 -hex
55
+ # SHA2-256(stdin)= abcd...9f (plain hex, lowercase)
56
+ ```
57
+
58
+ Then bind it:
59
+
60
+ ```bash
61
+ curl -X PUT https://keystone.example/v1/admin/organizations/$ORG/service-accounts/$ACCOUNT/certificate \
62
+ -H "Authorization: Bearer $TOKEN" \
63
+ -H 'Content-Type: application/json' \
64
+ -d '{"fingerprint":"ABCD...9F"}'
65
+ ```
66
+
67
+ Audits as `service_account_certificate_bound`. Binding a certificate already
68
+ held by another account returns `409` — the unique index on `cert_fingerprint`
69
+ enforces one certificate per account.
70
+
71
+ Clear the binding with `{"fingerprint": null}` (audits as
72
+ `service_account_certificate_unbound`). The account then authenticates only with
73
+ an API key.
74
+
75
+ ## Revoking
76
+
77
+ ```bash
78
+ curl -X POST https://keystone.example/v1/admin/organizations/$ORG/service-accounts/$ACCOUNT/revoke \
79
+ -H "Authorization: Bearer $TOKEN"
80
+ ```
81
+
82
+ Sets `is_active = false` and stamps `revoked_at`. A revoked account cannot
83
+ authenticate by certificate or API key, and disappears from reads. Revoking an
84
+ already-revoked account returns `409` rather than a silent success, so a caller
85
+ can tell the difference.
86
+
87
+ Revocation is separate from `is_active` so that re-enabling an account does not
88
+ quietly restore access — restoring it requires deliberately clearing
89
+ `revoked_at`.
90
+
91
+ ## Responses from `requireMTLS`
92
+
93
+ | Status | Code | Meaning |
94
+ | --- | --- | --- |
95
+ | 401 | `MTLS_UNTRUSTED_PEER` | Request did not come from a configured trusted proxy. A misconfiguration, not an attack. |
96
+ | 401 | `MTLS_CERTIFICATE_MISSING` | Trusted proxy, but no certificate was forwarded |
97
+ | 401 | `MTLS_FINGERPRINT_MISSING` | Certificate forwarded, but no usable fingerprint header |
98
+ | 403 | `MTLS_CERTIFICATE_UNMAPPED` | Valid fingerprint, but no active service account is bound to it |
99
+
100
+ 401 means the request was not authenticated; 403 means it was identified but is
101
+ not permitted. A malformed fingerprint is a 401 — it never reaches the database
102
+ as a lookup key.
103
+
104
+ ## Using it on a route
105
+
106
+ `app.requireMTLS` is available as a pre-handler on any route:
107
+
108
+ ```ts
109
+ app.get("/internal/report", { preHandler: [app.requireMTLS] }, async (request) => {
110
+ // request.serviceAccount is the bound, active account.
111
+ return buildReport(request.serviceAccount.orgId);
112
+ });
113
+ ```
114
+
115
+ ## What Keystone does not verify
116
+
117
+ - **Certificate chain, expiry, revocation, or CA.** The proxy's responsibility.
118
+ If the proxy accepts an expired certificate, Keystone accepts its fingerprint.
119
+ - **That the proxy checked anything.** Keystone trusts the peer address, not the
120
+ proxy's honesty. A compromised trusted host is a full bypass by design.
121
+ - **Certificate-to-account provenance.** Keystone records a fingerprint; it
122
+ cannot tell you who issued the certificate.
123
+
124
+ ## Operational notes
125
+
126
+ - Removing a certificate from the proxy's accepted list is **not** sufficient to
127
+ revoke an account. Revoke the account, or clear the binding.
128
+ - Rotating a client certificate means a new fingerprint. Update the binding
129
+ before the old certificate expires, or the account stops authenticating at the
130
+ moment the proxy rolls the certificate.
131
+ - A fingerprint can only ever map to one account. Two accounts cannot share a
132
+ certificate; give each caller its own.
@@ -0,0 +1,128 @@
1
+ # Proxy Security
2
+
3
+ How to configure `KEYSTONE_TRUSTED_PROXIES` correctly, and what goes wrong when
4
+ you do not. For the underlying model see [trust-boundaries.md](./trust-boundaries.md).
5
+
6
+ ## Configuration
7
+
8
+ ```bash
9
+ KEYSTONE_TRUSTED_PROXIES="10.0.0.0/8,192.168.1.1,2001:db8::/32"
10
+ ```
11
+
12
+ - Comma-separated. Each entry is an exact IP, an IPv4 CIDR, or an IPv6 prefix.
13
+ - Whitespace around entries is ignored.
14
+ - IPv4-mapped IPv6 peers (`::ffff:10.1.2.3` and the hex form `::ffff:0a01:0203`)
15
+ are normalized to `10.1.2.3` before matching, so a dual-stack listener cannot
16
+ slip past an IPv4 allowlist.
17
+ - **Empty or unset means trust nothing.** This is the default and the safe
18
+ posture. Forwarded headers are stripped and the peer address is used.
19
+
20
+ Unrecognized input fails closed: a malformed address or prefix never matches, so
21
+ a typo disables trust rather than widening it.
22
+
23
+ ## How the setting is applied
24
+
25
+ The value drives two things at startup:
26
+
27
+ 1. **Fastify's `trustProxy`.** Derived from the same list. When the list is
28
+ empty, `trustProxy` is `false`, so Fastify's own `request.ip` is the peer
29
+ address and never `x-forwarded-for`.
30
+ 2. **The header-sanitization hook.** Registered before every plugin and route.
31
+
32
+ `request.ip` is *not* used to decide whether a peer is trusted, because with
33
+ `trustProxy` enabled it is derived from the attacker-controlled header. The
34
+ decision always uses the socket peer address.
35
+
36
+ ## What each header is used for
37
+
38
+ | Header | Trusted-proxy requests | Direct requests |
39
+ | --- | --- | --- |
40
+ | `x-forwarded-for` | Client address for rate limiting and logs | Stripped; peer address used |
41
+ | `x-real-ip` | Fallback when `x-forwarded-for` is absent | Stripped |
42
+ | `x-client-cert-fingerprint` | Service account identity | Stripped |
43
+ | `x-forwarded-client-cert` | Certificate subject evidence | Stripped |
44
+ | `x-service-account-id` | Hint, cross-checked against the certificate | Stripped |
45
+
46
+ Because direct requests have the headers removed, a client that reaches Keystone
47
+ bypassing the proxy gains nothing — it is treated as an untrusted peer.
48
+
49
+ ## Rate limiting
50
+
51
+ The limiter keys on `clientAddress()`:
52
+
53
+ - **Behind a trusted proxy:** the left-most `x-forwarded-for` entry, which is
54
+ the real client. Distinct clients get distinct budgets.
55
+ - **From any other peer:** the peer address. Rotating `x-forwarded-for` grants
56
+ no additional budget.
57
+
58
+ This closes a bypass present before v2.0.0, where `trustProxy: true` combined
59
+ with an unconditional read of `x-forwarded-for` let any client present a fresh
60
+ address on every request and never be limited.
61
+
62
+ ### Fail-open behavior
63
+
64
+ The limiter fails open when Redis is unavailable. That is a deliberate
65
+ availability choice, not part of the trust model, but it means rate limiting is
66
+ not an authentication control and must not be relied on as one.
67
+
68
+ ## Required proxy configuration
69
+
70
+ Your proxy must meet all of these. Each one has caused a real bypass.
71
+
72
+ ```nginx
73
+ # 1. Overwrite, never append: `$proxy_add_x_forwarded_for` would let a client
74
+ # prepend a forged address. Use only $remote_addr, or the real-client module.
75
+ proxy_set_header X-Forwarded-For $remote_addr;
76
+
77
+ # 2. Strip inbound identity headers before setting your own, so a client's copy
78
+ # cannot survive. `underscores_in_headers off` also rejects them outright.
79
+ proxy_set_header X-Forwarded-Client-Cert "";
80
+ proxy_set_header X-Client-Cert-Fingerprint "";
81
+ proxy_set_header X-Service-Account-Id "";
82
+ proxy_set_header X-Real-IP "";
83
+
84
+ # 3. Do not expose Keystone directly. It should be unreachable except from the
85
+ # proxy, or the trusted range does not mean what you think.
86
+ ```
87
+
88
+ For AWS ALB, the same requirements in Terraform:
89
+
90
+ ```hcl
91
+ resource "aws_lb_listener" "https" {
92
+ protocol = "HTTPS"
93
+ certificate_arn = var.certificate_arn
94
+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
95
+ # Client certificate validation for mTLS:
96
+ mutual_tls {
97
+ mode = "verify"
98
+ }
99
+ }
100
+ ```
101
+
102
+ ALB sets `$ssl_client_fingerprint` in colon-separated uppercase hex. Map it to
103
+ `X-Client-Cert-Fingerprint`; Keystone canonicalizes the form.
104
+
105
+ ## Verifying a deployment
106
+
107
+ From a host that is **not** in the trusted range:
108
+
109
+ ```bash
110
+ # Must not be believed: expect the peer address in logs, not 198.51.100.9.
111
+ curl -sS -H 'X-Forwarded-For: 198.51.100.9' https://keystone.example/health
112
+
113
+ # Must be stripped: expect 401, never 200.
114
+ curl -sS -H 'X-Service-Account-Id: <any-uuid>' https://keystone.example/some/protected/route
115
+ ```
116
+
117
+ From **inside** the trusted range, forwarded addresses should be honoured and
118
+ separate clients should have separate rate-limit budgets.
119
+
120
+ ## Troubleshooting
121
+
122
+ | Symptom | Cause |
123
+ | --- | --- |
124
+ | Every client shares one rate-limit budget | Proxy address not in the list, so all requests key on the proxy's peer address |
125
+ | Legitimate mTLS requests get `401 MTLS_UNTRUSTED_PEER` | Proxy not in the list, so certificate headers are stripped |
126
+ | `401 MTLS_CERTIFICATE_UNMAPPED` | Certificate presented correctly, but no active service account has that fingerprint bound |
127
+ | `403` with no certificate header reaching handlers | Fingerprint malformed; must be 64 hex characters, optionally colon-separated |
128
+ | Works locally, fails in production | Development ran with an empty list; production needs the list set |
@@ -0,0 +1,79 @@
1
+ # Rate limiting and abuse prevention
2
+
3
+ Covers SEC-033 through SEC-039.
4
+
5
+ ## Redis is the primary limiter, and there is a fallback
6
+
7
+ `isAllowed()` returned `true` whenever Redis was unavailable. During an outage,
8
+ `login`, `mfa/verify`, `sms-otp/verify` and the OAuth token exchange had **no
9
+ limit at all**.
10
+
11
+ That is backwards. An outage is exactly when unlimited attempts are worth
12
+ having — it is the moment a burst of guessing no longer looks like a burst,
13
+ because there is no single event stream showing the ramp.
14
+
15
+ Sensitive endpoints now set `emergencyLocalLimit: true`, which falls back to
16
+ `src/services/localRateLimit.ts` when Redis is down or errors mid-request.
17
+
18
+ The fallback is strictly weaker than the distributed limiter: a client gets one
19
+ budget **per instance**, so a fleet multiplies the allowance. That is a
20
+ degradation worth having. Unbounded is not.
21
+
22
+ The store is capped at 10,000 keys. An unbounded map keyed by client address is
23
+ itself a denial-of-service vector — rotating addresses would grow it without
24
+ limit — so expired windows are swept and the oldest are evicted at the cap.
25
+ Eviction only ever forgets an already-expired window, so it grants no extra
26
+ budget; there is a test for that specifically.
27
+
28
+ Endpoints that limit without `emergencyLocalLimit` still fail open. That is
29
+ deliberate: the fallback is per-instance, and applying it to high-volume
30
+ low-value endpoints would trade a real availability problem for a small
31
+ reduction in abuse resistance.
32
+
33
+ ## What each budget is keyed on
34
+
35
+ The key matters more than the number.
36
+
37
+ | Endpoint | Keyed on | Why |
38
+ | --- | --- | --- |
39
+ | `login` | address **and** submitted address | stops repeated guesses at one account |
40
+ | `login-per-address` | address only | bounds spraying, which the first budget cannot see |
41
+ | `mfa-verify` | address **and** challenge | the challenge is one login attempt |
42
+ | `totp-*` | user | a TOTP code is checked against one account's secret |
43
+ | `scim` | credential | one noisy IdP cannot exhaust everyone else's budget |
44
+
45
+ Two of these were wrong before v2.8.0, and both were found by a test suite that
46
+ had been passing for the wrong reason:
47
+
48
+ - `mfa-verify` included `body.email`, which that endpoint does not carry. So
49
+ every second-factor verification from one address shared a budget of 20. An
50
+ attacker got 20 guesses; so did an office behind a single NAT, where ordinary
51
+ traffic could lock out every legitimate second-factor login.
52
+ - `totp-*` allowed 10 attempts keyed on the address alone, on **authenticated**
53
+ routes, with the same consequence.
54
+
55
+ ## Credential spraying
56
+
57
+ The login budget is keyed on address *and* submitted address, so it stops
58
+ repeated guesses at one account and does nothing about an attacker who varies
59
+ the address on every request and guesses across a thousand accounts from one
60
+ host. A second, address-keyed budget bounds that independently.
61
+
62
+ ## A refused request is recorded
63
+
64
+ A rate-limit trip produced a `429` and nothing else. Sustained guessing at
65
+ `login` or `mfa/verify` was invisible except in aggregate — the requests that
66
+ most warranted attention were the only ones absent from the log.
67
+
68
+ `rate_limit_triggered` now records the endpoint, the client address, and
69
+ **which limiter decided**. `redis`, `local` and `none` are three distinct
70
+ operational situations, and a degraded in-process control is a reason to go and
71
+ look at Redis. Recording it as if it were the healthy path would hide exactly
72
+ the thing worth knowing.
73
+
74
+ ## Related
75
+
76
+ A failed login is audited as `user_login_failed`, and a refresh token presented
77
+ twice is detected as `refresh_token_replayed` — see
78
+ [audit.md](./audit.md). A session surviving a password change is covered in
79
+ [trust-boundaries.md](./trust-boundaries.md).