@hilbras/keystone 2.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +350 -0
  2. package/README.md +72 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +7 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/rateLimit.d.ts +10 -7
  10. package/dist/plugins/rateLimit.d.ts.map +1 -1
  11. package/dist/plugins/rateLimit.js +75 -36
  12. package/dist/plugins/rateLimit.js.map +1 -1
  13. package/dist/routes/admin/organizations.d.ts.map +1 -1
  14. package/dist/routes/admin/organizations.js +2 -0
  15. package/dist/routes/admin/organizations.js.map +1 -1
  16. package/dist/routes/admin/platform.d.ts.map +1 -1
  17. package/dist/routes/admin/platform.js +14 -1
  18. package/dist/routes/admin/platform.js.map +1 -1
  19. package/dist/routes/apiKeys.d.ts.map +1 -1
  20. package/dist/routes/apiKeys.js +15 -1
  21. package/dist/routes/apiKeys.js.map +1 -1
  22. package/dist/routes/auth.d.ts.map +1 -1
  23. package/dist/routes/auth.js +104 -3
  24. package/dist/routes/auth.js.map +1 -1
  25. package/dist/routes/emailVerification.d.ts.map +1 -1
  26. package/dist/routes/emailVerification.js +2 -0
  27. package/dist/routes/emailVerification.js.map +1 -1
  28. package/dist/routes/magicLinks.d.ts.map +1 -1
  29. package/dist/routes/magicLinks.js +2 -0
  30. package/dist/routes/magicLinks.js.map +1 -1
  31. package/dist/routes/oauth2.d.ts.map +1 -1
  32. package/dist/routes/oauth2.js +16 -2
  33. package/dist/routes/oauth2.js.map +1 -1
  34. package/dist/routes/password.d.ts.map +1 -1
  35. package/dist/routes/password.js +2 -0
  36. package/dist/routes/password.js.map +1 -1
  37. package/dist/routes/scim.d.ts.map +1 -1
  38. package/dist/routes/scim.js +2 -0
  39. package/dist/routes/scim.js.map +1 -1
  40. package/dist/routes/smsOtp.d.ts.map +1 -1
  41. package/dist/routes/smsOtp.js +4 -0
  42. package/dist/routes/smsOtp.js.map +1 -1
  43. package/dist/routes/totp.d.ts.map +1 -1
  44. package/dist/routes/totp.js +18 -1
  45. package/dist/routes/totp.js.map +1 -1
  46. package/dist/services/configuration/profiles.d.ts +26 -0
  47. package/dist/services/configuration/profiles.d.ts.map +1 -1
  48. package/dist/services/configuration/profiles.js +80 -1
  49. package/dist/services/configuration/profiles.js.map +1 -1
  50. package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
  51. package/dist/services/events/subscribers/auditLog.js +38 -3
  52. package/dist/services/events/subscribers/auditLog.js.map +1 -1
  53. package/dist/services/events/types.d.ts +3 -1
  54. package/dist/services/events/types.d.ts.map +1 -1
  55. package/dist/services/events/validate.d.ts +1 -0
  56. package/dist/services/events/validate.d.ts.map +1 -1
  57. package/dist/services/events/validate.js +5 -1
  58. package/dist/services/events/validate.js.map +1 -1
  59. package/dist/services/localRateLimit.d.ts +44 -0
  60. package/dist/services/localRateLimit.d.ts.map +1 -0
  61. package/dist/services/localRateLimit.js +86 -0
  62. package/dist/services/localRateLimit.js.map +1 -0
  63. package/dist/services/refreshTokenState.d.ts +5 -0
  64. package/dist/services/refreshTokenState.d.ts.map +1 -0
  65. package/dist/services/refreshTokenState.js +30 -0
  66. package/dist/services/refreshTokenState.js.map +1 -0
  67. package/dist/services/setup/token.d.ts +12 -0
  68. package/dist/services/setup/token.d.ts.map +1 -1
  69. package/dist/services/setup/token.js +27 -3
  70. package/dist/services/setup/token.js.map +1 -1
  71. package/dist/services/tokens.d.ts +1 -0
  72. package/dist/services/tokens.d.ts.map +1 -1
  73. package/dist/services/tokens.js +1 -1
  74. package/dist/services/tokens.js.map +1 -1
  75. package/dist/services/trustedProxies.d.ts +24 -0
  76. package/dist/services/trustedProxies.d.ts.map +1 -1
  77. package/dist/services/trustedProxies.js +19 -0
  78. package/dist/services/trustedProxies.js.map +1 -1
  79. package/dist/services/webhooks.d.ts +16 -0
  80. package/dist/services/webhooks.d.ts.map +1 -1
  81. package/dist/services/webhooks.js +48 -3
  82. package/dist/services/webhooks.js.map +1 -1
  83. package/dist/setup-server.js +47 -3
  84. package/dist/setup-server.js.map +1 -1
  85. package/docs/API-REVIEW.md +121 -0
  86. package/docs/API.md +457 -0
  87. package/docs/ARCHITECTURE.md +142 -0
  88. package/docs/CONTRIBUTING.md +61 -0
  89. package/docs/DEPLOYMENT.md +257 -0
  90. package/docs/INTEGRATION.md +336 -0
  91. package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
  92. package/docs/MIGRATION-1.7.md +70 -0
  93. package/docs/MIGRATION-1.8.md +183 -0
  94. package/docs/MIGRATION-1.9.md +200 -0
  95. package/docs/MIGRATION-2.0.md +203 -0
  96. package/docs/MIGRATION-2.4.md +185 -0
  97. package/docs/PERFORMANCE.md +155 -0
  98. package/docs/RBAC.md +100 -0
  99. package/docs/RE-AUDIT.md +72 -0
  100. package/docs/README.md +54 -0
  101. package/docs/RELEASE-1.7.md +53 -0
  102. package/docs/ROADMAP.md +41 -0
  103. package/docs/SECURITY.md +143 -0
  104. package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
  105. package/docs/adrs/002-versioned-event-bus.md +30 -0
  106. package/docs/adrs/003-bullmq-for-background-work.md +20 -0
  107. package/docs/adrs/004-argon2id-password-hashing.md +19 -0
  108. package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
  109. package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
  110. package/docs/security/audit.md +82 -0
  111. package/docs/security/configuration.md +60 -0
  112. package/docs/security/enterprise-sso.md +193 -0
  113. package/docs/security/mtls.md +132 -0
  114. package/docs/security/proxy-security.md +128 -0
  115. package/docs/security/rate-limiting.md +79 -0
  116. package/docs/security/registry-exceptions.md +34 -0
  117. package/docs/security/registry.json +649 -0
  118. package/docs/security/registry.md +657 -0
  119. package/docs/security/scopes.md +45 -0
  120. package/docs/security/supply-chain.md +49 -0
  121. package/docs/security/trust-boundaries.md +111 -0
  122. package/package.json +15 -6
@@ -0,0 +1,45 @@
1
+ # API key scopes and machine principals
2
+
3
+ Covers SEC-023, SEC-024 and SEC-025.
4
+
5
+ ## The scope registry is the authority
6
+
7
+ `src/services/scopes.ts` holds every scope the API recognises, who may hold it,
8
+ and which principals are permitted to exercise it. Nothing outside that file
9
+ decides what a credential may do.
10
+
11
+ ## There is no wildcard
12
+
13
+ The registry contained a `service_account` entry that any key carrying could
14
+ present against every scope check. A narrowly scoped key therefore behaved as a
15
+ fully privileged one — the scope list on the key was advisory rather than
16
+ binding.
17
+
18
+ It is gone. Every scope must be named, and `hasScopes` compares explicitly.
19
+
20
+ ## Enforcement fails closed
21
+
22
+ `requireScopes` returned early when `request.apiKeyId` was absent, so any
23
+ authenticated path reaching a scoped route without going through key
24
+ authentication skipped the check entirely. A missing key is now treated as *no
25
+ authority*, not as *no restriction*.
26
+
27
+ ## Some operations are human-only
28
+
29
+ A service account is a machine. It has no password, no second factor and no
30
+ browser, so operations that require a person present are refused to it:
31
+ `profile:*` and `mfa:manage`.
32
+
33
+ The guard is `requireHumanPrincipal` in `src/plugins/machinePrincipal.ts`.
34
+
35
+ > **Placement matters.** It must come **after** `app.authenticate` in a
36
+ > `preHandler`, because that is what populates `request.serviceAccount`. Placed
37
+ > before, it never sees a service account and silently permits everything — the
38
+ > guard appears to work and does nothing.
39
+
40
+ ## Migration from 2.4.0
41
+
42
+ Scope names changed in v2.6.0. The `service_account` wildcard no longer exists,
43
+ so a key that relied on it must be reissued with explicit scopes. Keys whose
44
+ scopes are no longer recognised are rejected at creation rather than accepted and
45
+ silently ignored.
@@ -0,0 +1,49 @@
1
+ # Supply chain
2
+
3
+ Covers SEC-011.
4
+
5
+ ## What runs on every push and pull request
6
+
7
+ `.github/workflows/supply-chain.yml`:
8
+
9
+ | Job | Tool | Gate |
10
+ | --- | --- | --- |
11
+ | OSV vulnerability scan | osv-scanner | fails on any advisory |
12
+ | npm audit | npm | fails at `high` |
13
+ | Dependency review | GitHub action | fails on a new advisory in the diff |
14
+ | SBOM | CycloneDX | artefact, always produced |
15
+ | Container scan | Trivy | image, filesystem and secret scanning |
16
+ | License report | license-checker | fails on an unknown or disallowed licence |
17
+
18
+ `ci.yml` runs the same dependency and licence gates so a branch cannot be green
19
+ locally and red on the merge.
20
+
21
+ ## The image finding that no scanner could see
22
+
23
+ The production Dockerfile installed npm so it could prune dev dependencies. That
24
+ npm carried **eight high-severity advisories** — and `npm audit` and the OSV
25
+ scanner both read the lockfile, not the image. Every configured gate was
26
+ satisfied while the shipped artefact was the vulnerable one.
27
+
28
+ npm is removed from the runtime stage. Pruning happens at build time in a stage
29
+ that is not part of the published image.
30
+
31
+ This is worth stating plainly because it is not a scanner gap that more
32
+ scanning would close: the tools were all present, all passing, and all reading a
33
+ different artefact from the one being shipped. Finding it required looking at what
34
+ was actually in the image.
35
+
36
+ ## Release gate
37
+
38
+ `release.yml` refuses to publish unless every one of the following holds:
39
+
40
+ - a critical or high advisory exists with no documented exception
41
+ - the security suite passes
42
+ - typecheck, lint and build pass
43
+ - the dependency audit passes
44
+ - secret scanning passes
45
+ - the security registry validates
46
+ - the release metadata is consistent
47
+
48
+ Exceptions live in `docs/security/registry-exceptions.md` and must name the
49
+ advisory, the reason, and an expiry. An undocumented exception fails the gate.
@@ -0,0 +1,111 @@
1
+ # Trust Boundaries
2
+
3
+ Keystone's security model rests on one distinction: **which values a client
4
+ cannot forge**. This document names those values, states where they come from,
5
+ and lists what is deliberately not trusted.
6
+
7
+ ## The deployment model
8
+
9
+ ```text
10
+ ┌──────────────────────────────────────────┐
11
+ │ Internet │
12
+ └───────────────────┬──────────────────────┘
13
+ │ attacker-controlled
14
+ ┌───────────────────▼──────────────────────┐
15
+ │ Trusted reverse proxy (LB) │
16
+ │ - terminates TLS │
17
+ │ - validates client certificates (mTLS) │
18
+ │ - overwrites forwarded identity headers │
19
+ └───────────────────┬──────────────────────┘
20
+ │ peer address is the proxy's
21
+ ┌───────────────────▼──────────────────────┐
22
+ │ Keystone │
23
+ │ trusts forwarded headers ONLY from │
24
+ │ KEYSTONE_TRUSTED_PROXIES │
25
+ └──────────────────────────────────────────┘
26
+ ```
27
+
28
+ Keystone may also run with no proxy at all. Every rule below holds in both
29
+ layouts; only the trusted-proxy list changes.
30
+
31
+ ## Unforgeable values
32
+
33
+ | Value | Source | Trustworthy because |
34
+ | --- | --- | --- |
35
+ | Peer address | TCP connection (`socket.remoteAddress`) | Established by the network stack; a client cannot set it |
36
+ | Session cookie / bearer token | Cryptographic check against stored state | Requires a secret the client must already hold |
37
+ | API key | Hashed lookup against a stored secret | Same |
38
+ | Client certificate | Validated by the proxy, forwarded as a fingerprint | Only believed from a trusted peer, and bound to a service account in the database |
39
+
40
+ ## Forged values
41
+
42
+ These arrive in request headers and are attacker-controlled unless the peer is a
43
+ configured trusted proxy:
44
+
45
+ - `x-forwarded-for`, `x-real-ip`, `forwarded` — the apparent client address
46
+ - `x-forwarded-client-cert`, `x-client-cert-fingerprint` — the client certificate
47
+ - `x-service-account-id` — a service account identifier
48
+
49
+ When the peer is **not** a trusted proxy, Keystone deletes these headers in an
50
+ `onRequest` hook before routing, authentication, or any handler runs. Stripping
51
+ rather than ignoring them means a route added later cannot read a spoofed
52
+ identity by accident.
53
+
54
+ ## Trust decisions and where they are made
55
+
56
+ | Decision | Made in | Rule |
57
+ | --- | --- | --- |
58
+ | Is this peer a trusted proxy? | `isFromTrustedProxy()` | Peer address is in `KEYSTONE_TRUSTED_PROXIES` |
59
+ | What is the client address? | `clientAddress()` | Forwarded headers only when the peer is trusted; otherwise the peer |
60
+ | Is this certificate identity real? | `extractClientCert()` | Requires a trusted peer **and** a well-formed SHA-256 fingerprint |
61
+ | Which service account is it? | `resolveByFingerprint()` | The fingerprint is bound to exactly one account in the database |
62
+
63
+ All four live in `src/services/trustedProxies.ts` and `src/plugins/mtls.ts`.
64
+
65
+ ## Identity must come from a credential
66
+
67
+ A service account authenticates in one of two ways, and never a third:
68
+
69
+ ```text
70
+ validated client certificate (fingerprint bound to the account)
71
+ OR
72
+ authenticated credential (API key, session, or bearer token)
73
+ ```
74
+
75
+ There is deliberately **no** header-only path. `x-service-account-id` is accepted
76
+ only as a hint alongside a valid certificate, and only when the account it names
77
+ is the account bound to the presented certificate. A mismatch fails rather than
78
+ falling back — a disagreement is treated as a spoofing signal.
79
+
80
+ ## Threat assumptions
81
+
82
+ These are believed, and a deployment that violates them is not supported:
83
+
84
+ 1. **The proxy is operated by you.** If an attacker controls a host inside the
85
+ trusted range, they control the identity headers.
86
+ 2. **The proxy overwrites, not appends.** It must set `x-forwarded-for` itself.
87
+ A proxy that appends to a client-supplied value lets the client prepend.
88
+ 3. **The trusted range is as narrow as the deployment allows.** Trusting a
89
+ whole `0.0.0.0/0` or a large shared-VPC range disables the boundary.
90
+ 4. **TLS is terminated before Keystone.** Certificate validity is the proxy's
91
+ responsibility; Keystone only compares a fingerprint it was told about.
92
+ 5. **Database access is trusted.** Whoever can write to `service_accounts` can
93
+ bind a certificate to an account.
94
+
95
+ ### What is *not* protected here
96
+
97
+ - **Certificate chain validation** is the proxy's job. Keystone verifies that a
98
+ fingerprint is well-formed and bound to an account; it does not re-validate the
99
+ chain or the CA. See [mtls.md](./mtls.md).
100
+ - **A trusted proxy that is compromised** is a full identity bypass by design.
101
+ This is inherent to the mTLS deployment model, not a Keystone defect.
102
+ - **Spoofing against a shared egress IP** cannot be distinguished by peer
103
+ address. Deploy per-service egress ranges if callers share a NAT.
104
+
105
+ ## Consequences of a misconfiguration
106
+
107
+ | Misconfiguration | Result |
108
+ | --- | --- |
109
+ | `KEYSTONE_TRUSTED_PROXIES` unset while behind a proxy | All forwarded headers stripped; rate limiting keys on the proxy's address, so **all** clients share one budget. Clients see rate limits they should not. |
110
+ | Range too broad (e.g. a shared VPC CIDR) | Any host in that range can assert any client identity, including a certificate fingerprint. |
111
+ | Keystone exposed directly to the internet while a proxy range is configured | An attacker's direct request is not from a trusted peer, so it is still safe — but the proxy path is the only one that can carry identity. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hilbras/keystone",
3
- "version": "2.6.0",
3
+ "version": "3.0.0",
4
4
  "license": "MIT",
5
5
  "description": "Hilbras Keystone — standalone identity platform: authentication, MFA, authorization, enterprise SSO, SCIM, and token issuance.",
6
6
  "repository": {
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "dist",
20
20
  "!dist/tests",
21
+ "docs",
21
22
  "CHANGELOG.md",
22
23
  "README.md"
23
24
  ],
@@ -28,7 +29,7 @@
28
29
  "dev": "tsx watch src/index.ts",
29
30
  "dev:setup": "KEYSTONE_SETUP_MODE=true tsx watch src/setup-server.ts",
30
31
  "dev:all": "./start.sh",
31
- "build": "tsc && npm run copy:migrations",
32
+ "build": "npm run clean && tsc && npm run copy:migrations",
32
33
  "build:sdk": "cd packages/keystone-sdk && npm run build",
33
34
  "build:all": "npm run build:sdk && npm run build && cd frontend && npm run build",
34
35
  "copy:migrations": "mkdir -p dist/db/migrations && cp -r src/db/migrations/* dist/db/migrations/",
@@ -43,11 +44,17 @@
43
44
  "db:studio": "drizzle-kit studio",
44
45
  "db:seed": "tsx src/db/seed.ts",
45
46
  "db:reencrypt-oidc-secrets": "node dist/db/reencryptOidcSecrets.js",
46
- "test": "node --test --test-force-exit --test-concurrency=1 --test-timeout=60000 dist/tests/*.test.js dist/tests/**/*.test.js dist/tests/**/**/*.test.js",
47
- "test:security": "node --test --test-force-exit --test-concurrency=1 --test-timeout=60000 dist/tests/security/*.test.js dist/tests/security/**/*.test.js",
47
+ "test": "node --test --test-force-exit --test-concurrency=1 --test-timeout=60000 dist/tests/*.test.js dist/tests/*/*.test.js dist/tests/*/*/*.test.js dist/tests/*/*/*/*.test.js",
48
+ "test:security": "node --test --test-force-exit --test-concurrency=1 --test-timeout=60000 dist/tests/security/*.test.js dist/tests/security/*/*.test.js dist/tests/security/*/*/*.test.js dist/tests/security/*/*/*/*.test.js",
48
49
  "test:all": "npm test && cd frontend && npm run test:e2e",
49
50
  "setup": "./install.sh",
50
- "start:all": "./start.sh"
51
+ "start:all": "./start.sh",
52
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
53
+ "registry:check": "node scripts/verify-security-registry.mjs",
54
+ "registry:render": "node scripts/render-security-registry.mjs",
55
+ "reaudit:check": "node scripts/render-reaudit-matrix.mjs --check",
56
+ "reaudit:render": "node scripts/render-reaudit-matrix.mjs",
57
+ "review:api": "node scripts/review-api-surface.mjs"
51
58
  },
52
59
  "dependencies": {
53
60
  "@authenio/samlify-node-xmllint": "^2.0.0",
@@ -87,5 +94,7 @@
87
94
  "reflect-metadata": "^0.2.2",
88
95
  "tsx": "^4.15.0",
89
96
  "typescript": "^7.0.2"
90
- }
97
+ },
98
+ "main": "./dist/index.js",
99
+ "types": "./dist/index.d.ts"
91
100
  }