@hilbras/keystone 2.6.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +350 -0
- package/README.md +72 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -11
- package/dist/index.js.map +1 -1
- package/dist/plugins/rateLimit.d.ts +10 -7
- package/dist/plugins/rateLimit.d.ts.map +1 -1
- package/dist/plugins/rateLimit.js +75 -36
- package/dist/plugins/rateLimit.js.map +1 -1
- package/dist/routes/admin/organizations.d.ts.map +1 -1
- package/dist/routes/admin/organizations.js +2 -0
- package/dist/routes/admin/organizations.js.map +1 -1
- package/dist/routes/admin/platform.d.ts.map +1 -1
- package/dist/routes/admin/platform.js +14 -1
- package/dist/routes/admin/platform.js.map +1 -1
- package/dist/routes/apiKeys.d.ts.map +1 -1
- package/dist/routes/apiKeys.js +15 -1
- package/dist/routes/apiKeys.js.map +1 -1
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +104 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/emailVerification.d.ts.map +1 -1
- package/dist/routes/emailVerification.js +2 -0
- package/dist/routes/emailVerification.js.map +1 -1
- package/dist/routes/magicLinks.d.ts.map +1 -1
- package/dist/routes/magicLinks.js +2 -0
- package/dist/routes/magicLinks.js.map +1 -1
- package/dist/routes/oauth2.d.ts.map +1 -1
- package/dist/routes/oauth2.js +16 -2
- package/dist/routes/oauth2.js.map +1 -1
- package/dist/routes/password.d.ts.map +1 -1
- package/dist/routes/password.js +2 -0
- package/dist/routes/password.js.map +1 -1
- package/dist/routes/scim.d.ts.map +1 -1
- package/dist/routes/scim.js +2 -0
- package/dist/routes/scim.js.map +1 -1
- package/dist/routes/smsOtp.d.ts.map +1 -1
- package/dist/routes/smsOtp.js +4 -0
- package/dist/routes/smsOtp.js.map +1 -1
- package/dist/routes/totp.d.ts.map +1 -1
- package/dist/routes/totp.js +18 -1
- package/dist/routes/totp.js.map +1 -1
- package/dist/services/configuration/profiles.d.ts +26 -0
- package/dist/services/configuration/profiles.d.ts.map +1 -1
- package/dist/services/configuration/profiles.js +80 -1
- package/dist/services/configuration/profiles.js.map +1 -1
- package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
- package/dist/services/events/subscribers/auditLog.js +38 -3
- package/dist/services/events/subscribers/auditLog.js.map +1 -1
- package/dist/services/events/types.d.ts +3 -1
- package/dist/services/events/types.d.ts.map +1 -1
- package/dist/services/events/validate.d.ts +1 -0
- package/dist/services/events/validate.d.ts.map +1 -1
- package/dist/services/events/validate.js +5 -1
- package/dist/services/events/validate.js.map +1 -1
- package/dist/services/localRateLimit.d.ts +44 -0
- package/dist/services/localRateLimit.d.ts.map +1 -0
- package/dist/services/localRateLimit.js +86 -0
- package/dist/services/localRateLimit.js.map +1 -0
- package/dist/services/refreshTokenState.d.ts +5 -0
- package/dist/services/refreshTokenState.d.ts.map +1 -0
- package/dist/services/refreshTokenState.js +30 -0
- package/dist/services/refreshTokenState.js.map +1 -0
- package/dist/services/setup/token.d.ts +12 -0
- package/dist/services/setup/token.d.ts.map +1 -1
- package/dist/services/setup/token.js +27 -3
- package/dist/services/setup/token.js.map +1 -1
- package/dist/services/tokens.d.ts +1 -0
- package/dist/services/tokens.d.ts.map +1 -1
- package/dist/services/tokens.js +1 -1
- package/dist/services/tokens.js.map +1 -1
- package/dist/services/trustedProxies.d.ts +24 -0
- package/dist/services/trustedProxies.d.ts.map +1 -1
- package/dist/services/trustedProxies.js +19 -0
- package/dist/services/trustedProxies.js.map +1 -1
- package/dist/services/webhooks.d.ts +16 -0
- package/dist/services/webhooks.d.ts.map +1 -1
- package/dist/services/webhooks.js +48 -3
- package/dist/services/webhooks.js.map +1 -1
- package/dist/setup-server.js +47 -3
- package/dist/setup-server.js.map +1 -1
- package/docs/API-REVIEW.md +121 -0
- package/docs/API.md +457 -0
- package/docs/ARCHITECTURE.md +142 -0
- package/docs/CONTRIBUTING.md +61 -0
- package/docs/DEPLOYMENT.md +257 -0
- package/docs/INTEGRATION.md +336 -0
- package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
- package/docs/MIGRATION-1.7.md +70 -0
- package/docs/MIGRATION-1.8.md +183 -0
- package/docs/MIGRATION-1.9.md +200 -0
- package/docs/MIGRATION-2.0.md +203 -0
- package/docs/MIGRATION-2.4.md +185 -0
- package/docs/PERFORMANCE.md +155 -0
- package/docs/RBAC.md +100 -0
- package/docs/RE-AUDIT.md +72 -0
- package/docs/README.md +54 -0
- package/docs/RELEASE-1.7.md +53 -0
- package/docs/ROADMAP.md +41 -0
- package/docs/SECURITY.md +143 -0
- package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
- package/docs/adrs/002-versioned-event-bus.md +30 -0
- package/docs/adrs/003-bullmq-for-background-work.md +20 -0
- package/docs/adrs/004-argon2id-password-hashing.md +19 -0
- package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
- package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
- package/docs/security/audit.md +82 -0
- package/docs/security/configuration.md +60 -0
- package/docs/security/enterprise-sso.md +193 -0
- package/docs/security/mtls.md +132 -0
- package/docs/security/proxy-security.md +128 -0
- package/docs/security/rate-limiting.md +79 -0
- package/docs/security/registry-exceptions.md +34 -0
- package/docs/security/registry.json +649 -0
- package/docs/security/registry.md +657 -0
- package/docs/security/scopes.md +45 -0
- package/docs/security/supply-chain.md +49 -0
- package/docs/security/trust-boundaries.md +111 -0
- package/package.json +15 -6
|
@@ -0,0 +1,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).
|