@thotischner/observability-mcp 1.7.1 → 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 (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -0,0 +1,143 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { buildCsrfIssuer, buildCsrfEnforcer, newCsrfToken, csrfBypassFromEnv, constantTimeStringEquals, CSRF_COOKIE, CSRF_HEADER, } from "./csrf.js";
5
+ class MockRes extends EventEmitter {
6
+ status_ = 200;
7
+ headers = {};
8
+ body;
9
+ status(code) {
10
+ this.status_ = code;
11
+ return this;
12
+ }
13
+ json(body) {
14
+ this.body = body;
15
+ return this;
16
+ }
17
+ setHeader(name, value) {
18
+ this.headers[name] = value;
19
+ }
20
+ getHeader(name) {
21
+ return this.headers[name];
22
+ }
23
+ }
24
+ function call(mw, req) {
25
+ const res = new MockRes();
26
+ let nexted = false;
27
+ mw(req, res, () => {
28
+ nexted = true;
29
+ });
30
+ return { res, nexted };
31
+ }
32
+ function defaultCfg(overrides = {}) {
33
+ return {
34
+ bypassBearer: overrides.bypassBearer ?? true,
35
+ secureCookie: () => false,
36
+ };
37
+ }
38
+ test("newCsrfToken: returns base64url, 32-byte (43-44 char) string", () => {
39
+ const t = newCsrfToken();
40
+ assert.match(t, /^[A-Za-z0-9_-]+$/);
41
+ assert.ok(t.length >= 42 && t.length <= 44, `unexpected length ${t.length}`);
42
+ assert.notEqual(newCsrfToken(), newCsrfToken(), "tokens must differ");
43
+ });
44
+ test("constantTimeStringEquals: matches equal, rejects different lengths + values", () => {
45
+ assert.equal(constantTimeStringEquals("abc", "abc"), true);
46
+ assert.equal(constantTimeStringEquals("abc", "abd"), false);
47
+ assert.equal(constantTimeStringEquals("abc", "abcd"), false);
48
+ assert.equal(constantTimeStringEquals("", ""), true);
49
+ });
50
+ test("csrfBypassFromEnv: defaults true, only literal off values opt out", () => {
51
+ assert.equal(csrfBypassFromEnv({}), true);
52
+ assert.equal(csrfBypassFromEnv({ OMCP_CSRF_BYPASS_BEARER: "true" }), true);
53
+ for (const v of ["0", "false", "no", "off", "FALSE", "Off"]) {
54
+ assert.equal(csrfBypassFromEnv({ OMCP_CSRF_BYPASS_BEARER: v }), false, v);
55
+ }
56
+ });
57
+ test("issuer: sets cookie when missing, no-op when present", () => {
58
+ const mw = buildCsrfIssuer(defaultCfg());
59
+ // Missing cookie -> set
60
+ const r1 = call(mw, { headers: {} });
61
+ assert.equal(r1.nexted, true);
62
+ const set = r1.res.getHeader("Set-Cookie");
63
+ assert.match(set, /^omcp-csrf=[A-Za-z0-9_-]+;/);
64
+ assert.match(set, /Path=\//);
65
+ assert.match(set, /SameSite=Lax/);
66
+ assert.doesNotMatch(set, /HttpOnly/);
67
+ // Present cookie -> no Set-Cookie emitted
68
+ const r2 = call(mw, { headers: { cookie: "omcp-csrf=abc" } });
69
+ assert.equal(r2.nexted, true);
70
+ assert.equal(r2.res.getHeader("Set-Cookie"), undefined);
71
+ });
72
+ test("issuer: Secure flag honors secureCookie callback", () => {
73
+ const mw = buildCsrfIssuer({ bypassBearer: true, secureCookie: () => true });
74
+ const r = call(mw, { headers: {} });
75
+ assert.match(r.res.getHeader("Set-Cookie"), /Secure/);
76
+ });
77
+ test("enforcer: GET/HEAD/OPTIONS always pass", () => {
78
+ const mw = buildCsrfEnforcer(defaultCfg());
79
+ for (const m of ["GET", "HEAD", "OPTIONS"]) {
80
+ const r = call(mw, { method: m, headers: {} });
81
+ assert.equal(r.nexted, true, m);
82
+ }
83
+ });
84
+ test("enforcer: bearer auth bypasses CSRF when bypassBearer=true", () => {
85
+ const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
86
+ const r = call(mw, {
87
+ method: "POST",
88
+ headers: { authorization: "Bearer abc.def.ghi" },
89
+ });
90
+ assert.equal(r.nexted, true);
91
+ });
92
+ test("enforcer: X-API-Key also bypasses when bypassBearer=true", () => {
93
+ const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
94
+ const r = call(mw, {
95
+ method: "POST",
96
+ headers: { "x-api-key": "abc" },
97
+ });
98
+ assert.equal(r.nexted, true);
99
+ });
100
+ test("enforcer: bypassBearer=false requires CSRF even for bearer clients", () => {
101
+ const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: false }));
102
+ const r = call(mw, {
103
+ method: "POST",
104
+ headers: { authorization: "Bearer abc" },
105
+ });
106
+ assert.equal(r.nexted, false);
107
+ assert.equal(r.res.status_, 403);
108
+ });
109
+ test("enforcer: cookie-session POST without header is rejected with 403", () => {
110
+ const mw = buildCsrfEnforcer(defaultCfg());
111
+ const r = call(mw, {
112
+ method: "POST",
113
+ headers: { cookie: "omcp-csrf=tok123" },
114
+ });
115
+ assert.equal(r.nexted, false);
116
+ assert.equal(r.res.status_, 403);
117
+ });
118
+ test("enforcer: cookie + matching header passes", () => {
119
+ const mw = buildCsrfEnforcer(defaultCfg());
120
+ const r = call(mw, {
121
+ method: "POST",
122
+ headers: { cookie: `${CSRF_COOKIE}=tok123`, [CSRF_HEADER]: "tok123" },
123
+ });
124
+ assert.equal(r.nexted, true);
125
+ });
126
+ test("enforcer: header != cookie is rejected (token mismatch attack)", () => {
127
+ const mw = buildCsrfEnforcer(defaultCfg());
128
+ const r = call(mw, {
129
+ method: "POST",
130
+ headers: { cookie: `${CSRF_COOKIE}=cookie-token`, [CSRF_HEADER]: "header-token" },
131
+ });
132
+ assert.equal(r.nexted, false);
133
+ assert.equal(r.res.status_, 403);
134
+ });
135
+ test("enforcer: missing cookie + header is rejected (no token at all)", () => {
136
+ const mw = buildCsrfEnforcer(defaultCfg());
137
+ const r = call(mw, {
138
+ method: "POST",
139
+ headers: {},
140
+ });
141
+ assert.equal(r.nexted, false);
142
+ assert.equal(r.res.status_, 403);
143
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * File-backed local user store for the management-plane "basic" auth mode.
3
+ *
4
+ * Password verification uses node's built-in `scrypt` so the server has no
5
+ * extra runtime dependency. The on-disk format is a small JSON document:
6
+ *
7
+ * {
8
+ * "users": [
9
+ * {
10
+ * "username": "alice",
11
+ * "name": "Alice Operator",
12
+ * "roles": ["operator"],
13
+ * "passwordHash": "scrypt$N$r$p$<salt-b64>$<hash-b64>"
14
+ * },
15
+ * ...
16
+ * ]
17
+ * }
18
+ *
19
+ * The `passwordHash` field uses the PHC-like format `scrypt$N$r$p$salt$hash`,
20
+ * which encodes the cost parameters alongside the digest so operators can
21
+ * rotate them without breaking existing entries.
22
+ *
23
+ * Use `hashPassword()` to mint a new entry, e.g. from a one-shot CLI helper.
24
+ */
25
+ export interface LocalUser {
26
+ username: string;
27
+ name: string;
28
+ roles?: string[];
29
+ /** Optional tenant assignment. Missing → DEFAULT_TENANT. */
30
+ tenant?: string;
31
+ passwordHash: string;
32
+ }
33
+ export interface LocalUsersFile {
34
+ users: LocalUser[];
35
+ }
36
+ /** Default scrypt cost — N=2^15, r=8, p=1. Matches OWASP 2023 baseline. */
37
+ export declare const DEFAULT_SCRYPT_N: number;
38
+ export declare const DEFAULT_SCRYPT_R = 8;
39
+ export declare const DEFAULT_SCRYPT_P = 1;
40
+ /** Produce a `scrypt$…` formatted hash for the given plaintext. */
41
+ export declare function hashPassword(plaintext: string, opts?: {
42
+ N?: number;
43
+ r?: number;
44
+ p?: number;
45
+ }): string;
46
+ /** Upper bounds on scrypt cost parameters accepted during verify.
47
+ * The users file is operator-controlled, but an accidental typo
48
+ * ("N=21474836480") shouldn't be able to hang the auth path. The
49
+ * caps are well above any realistic production setting. */
50
+ export declare const MAX_SCRYPT_N: number;
51
+ export declare const MAX_SCRYPT_R = 16;
52
+ export declare const MAX_SCRYPT_P = 4;
53
+ /** Constant-time verify of a plaintext against a `scrypt$…` hash. */
54
+ export declare function verifyPassword(plaintext: string, encoded: string): boolean;
55
+ /**
56
+ * Read + parse the users file. Returns `null` (not throws) when the file
57
+ * doesn't exist or the JSON is malformed so the caller can fall through to
58
+ * anonymous mode cleanly.
59
+ */
60
+ export declare function readUsersFile(path: string): Promise<LocalUsersFile | null>;
61
+ /** Atomic write of the users file. Same tmp+rename pattern the
62
+ * products + token-budget snapshot writers use, so a crash mid-write
63
+ * leaves the previous file intact — never zero-byte. The file is
64
+ * the only persistent source of basic-mode credentials, so a
65
+ * half-write would lock every user out. */
66
+ export declare function writeUsersFile(path: string, file: LocalUsersFile): Promise<void>;
67
+ /** Find a user by username (case-sensitive) and verify the supplied password. */
68
+ export declare function authenticate(username: string, password: string, store: LocalUsersFile): LocalUser | null;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * File-backed local user store for the management-plane "basic" auth mode.
3
+ *
4
+ * Password verification uses node's built-in `scrypt` so the server has no
5
+ * extra runtime dependency. The on-disk format is a small JSON document:
6
+ *
7
+ * {
8
+ * "users": [
9
+ * {
10
+ * "username": "alice",
11
+ * "name": "Alice Operator",
12
+ * "roles": ["operator"],
13
+ * "passwordHash": "scrypt$N$r$p$<salt-b64>$<hash-b64>"
14
+ * },
15
+ * ...
16
+ * ]
17
+ * }
18
+ *
19
+ * The `passwordHash` field uses the PHC-like format `scrypt$N$r$p$salt$hash`,
20
+ * which encodes the cost parameters alongside the digest so operators can
21
+ * rotate them without breaking existing entries.
22
+ *
23
+ * Use `hashPassword()` to mint a new entry, e.g. from a one-shot CLI helper.
24
+ */
25
+ import { promises as fs } from "node:fs";
26
+ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
27
+ /** Default scrypt cost — N=2^15, r=8, p=1. Matches OWASP 2023 baseline. */
28
+ export const DEFAULT_SCRYPT_N = 1 << 15;
29
+ export const DEFAULT_SCRYPT_R = 8;
30
+ export const DEFAULT_SCRYPT_P = 1;
31
+ const HASH_KEYLEN = 32;
32
+ /** Produce a `scrypt$…` formatted hash for the given plaintext. */
33
+ export function hashPassword(plaintext, opts = {}) {
34
+ const N = opts.N ?? DEFAULT_SCRYPT_N;
35
+ const r = opts.r ?? DEFAULT_SCRYPT_R;
36
+ const p = opts.p ?? DEFAULT_SCRYPT_P;
37
+ const salt = randomBytes(16);
38
+ const hash = scryptSync(plaintext, salt, HASH_KEYLEN, { N, r, p, maxmem: 64 * 1024 * 1024 });
39
+ return `scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString("base64")}`;
40
+ }
41
+ /** Upper bounds on scrypt cost parameters accepted during verify.
42
+ * The users file is operator-controlled, but an accidental typo
43
+ * ("N=21474836480") shouldn't be able to hang the auth path. The
44
+ * caps are well above any realistic production setting. */
45
+ export const MAX_SCRYPT_N = 1 << 20; // 1 048 576 — ~1 second on a modern core
46
+ export const MAX_SCRYPT_R = 16;
47
+ export const MAX_SCRYPT_P = 4;
48
+ /** Constant-time verify of a plaintext against a `scrypt$…` hash. */
49
+ export function verifyPassword(plaintext, encoded) {
50
+ const parts = encoded.split("$");
51
+ if (parts.length !== 6 || parts[0] !== "scrypt")
52
+ return false;
53
+ const N = Number(parts[1]);
54
+ const r = Number(parts[2]);
55
+ const p = Number(parts[3]);
56
+ if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p))
57
+ return false;
58
+ if (N <= 0 || r <= 0 || p <= 0)
59
+ return false;
60
+ if (N > MAX_SCRYPT_N || r > MAX_SCRYPT_R || p > MAX_SCRYPT_P)
61
+ return false;
62
+ let salt;
63
+ let expected;
64
+ try {
65
+ salt = Buffer.from(parts[4], "base64");
66
+ expected = Buffer.from(parts[5], "base64");
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ if (expected.length === 0)
72
+ return false;
73
+ let candidate;
74
+ try {
75
+ candidate = scryptSync(plaintext, salt, expected.length, { N, r, p, maxmem: 256 * 1024 * 1024 });
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ if (candidate.length !== expected.length)
81
+ return false;
82
+ return timingSafeEqual(candidate, expected);
83
+ }
84
+ /**
85
+ * Read + parse the users file. Returns `null` (not throws) when the file
86
+ * doesn't exist or the JSON is malformed so the caller can fall through to
87
+ * anonymous mode cleanly.
88
+ */
89
+ export async function readUsersFile(path) {
90
+ let raw;
91
+ try {
92
+ raw = await fs.readFile(path, "utf8");
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ if (!isUsersFile(parsed))
105
+ return null;
106
+ return parsed;
107
+ }
108
+ /** Atomic write of the users file. Same tmp+rename pattern the
109
+ * products + token-budget snapshot writers use, so a crash mid-write
110
+ * leaves the previous file intact — never zero-byte. The file is
111
+ * the only persistent source of basic-mode credentials, so a
112
+ * half-write would lock every user out. */
113
+ export async function writeUsersFile(path, file) {
114
+ const text = JSON.stringify(file, null, 2) + "\n";
115
+ const tmp = path + ".tmp";
116
+ await fs.writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
117
+ await fs.rename(tmp, path);
118
+ }
119
+ function isUsersFile(v) {
120
+ if (!v || typeof v !== "object")
121
+ return false;
122
+ const o = v;
123
+ if (!Array.isArray(o.users))
124
+ return false;
125
+ return o.users.every((u) => {
126
+ if (!u || typeof u !== "object")
127
+ return false;
128
+ const r = u;
129
+ if (typeof r.username !== "string" || !r.username)
130
+ return false;
131
+ if (typeof r.name !== "string")
132
+ return false;
133
+ if (typeof r.passwordHash !== "string")
134
+ return false;
135
+ if (r.roles !== undefined && !(Array.isArray(r.roles) && r.roles.every((x) => typeof x === "string")))
136
+ return false;
137
+ if (r.tenant !== undefined && typeof r.tenant !== "string")
138
+ return false;
139
+ return true;
140
+ });
141
+ }
142
+ /** Find a user by username (case-sensitive) and verify the supplied password. */
143
+ export function authenticate(username, password, store) {
144
+ const u = store.users.find((x) => x.username === username);
145
+ if (!u) {
146
+ // Spend roughly the same time as a real verify so a missing username
147
+ // isn't trivially distinguishable by response timing.
148
+ verifyPassword(password, "scrypt$32768$8$1$AAAA$AAAA");
149
+ return null;
150
+ }
151
+ if (!verifyPassword(password, u.passwordHash))
152
+ return null;
153
+ return u;
154
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { hashPassword, verifyPassword, readUsersFile, authenticate, } from "./local-users.js";
7
+ // Use a smaller N so the test suite stays under a second even on slow CI runners.
8
+ const fastOpts = { N: 1 << 10, r: 8, p: 1 };
9
+ test("hashPassword + verifyPassword — accepts correct password", () => {
10
+ const hash = hashPassword("hunter2", fastOpts);
11
+ assert.match(hash, /^scrypt\$1024\$8\$1\$/);
12
+ assert.equal(verifyPassword("hunter2", hash), true);
13
+ });
14
+ test("verifyPassword — rejects wrong password", () => {
15
+ const hash = hashPassword("hunter2", fastOpts);
16
+ assert.equal(verifyPassword("hunter3", hash), false);
17
+ });
18
+ test("verifyPassword — rejects malformed hash", () => {
19
+ assert.equal(verifyPassword("anything", ""), false);
20
+ assert.equal(verifyPassword("anything", "plain-text"), false);
21
+ assert.equal(verifyPassword("anything", "argon2$x$y$z"), false);
22
+ assert.equal(verifyPassword("anything", "scrypt$$$$$" /* empty fields */), false);
23
+ assert.equal(verifyPassword("anything", "scrypt$1024$8$1$AAAA$"), false);
24
+ });
25
+ test("verifyPassword — rejects absurd scrypt cost params (DoS guard)", () => {
26
+ // Far above our MAX_SCRYPT_N / R / P caps. Should fail fast (no hash work).
27
+ assert.equal(verifyPassword("x", "scrypt$1073741824$8$1$AAAA$AAAA"), false); // N too big
28
+ assert.equal(verifyPassword("x", "scrypt$32768$1024$1$AAAA$AAAA"), false); // r too big
29
+ assert.equal(verifyPassword("x", "scrypt$32768$8$1024$AAAA$AAAA"), false); // p too big
30
+ });
31
+ test("readUsersFile — returns null when the file is missing or malformed", async () => {
32
+ const dir = await mkdtemp(join(tmpdir(), "omcp-users-"));
33
+ try {
34
+ assert.equal(await readUsersFile(join(dir, "nope.json")), null);
35
+ const bad = join(dir, "bad.json");
36
+ await writeFile(bad, "not json", "utf8");
37
+ assert.equal(await readUsersFile(bad), null);
38
+ const wrongShape = join(dir, "wrong.json");
39
+ await writeFile(wrongShape, JSON.stringify({ users: "string-not-array" }), "utf8");
40
+ assert.equal(await readUsersFile(wrongShape), null);
41
+ const missingFields = join(dir, "missing.json");
42
+ await writeFile(missingFields, JSON.stringify({ users: [{ username: "alice" }] }), "utf8");
43
+ assert.equal(await readUsersFile(missingFields), null);
44
+ }
45
+ finally {
46
+ await rm(dir, { recursive: true, force: true });
47
+ }
48
+ });
49
+ test("authenticate — returns user on correct credentials", () => {
50
+ const store = {
51
+ users: [
52
+ {
53
+ username: "alice",
54
+ name: "Alice",
55
+ roles: ["operator"],
56
+ passwordHash: hashPassword("hunter2", fastOpts),
57
+ },
58
+ ],
59
+ };
60
+ const u = authenticate("alice", "hunter2", store);
61
+ assert.ok(u);
62
+ assert.equal(u.username, "alice");
63
+ assert.deepEqual(u.roles, ["operator"]);
64
+ });
65
+ test("authenticate — returns null for unknown user", () => {
66
+ const store = { users: [] };
67
+ assert.equal(authenticate("nobody", "x", store), null);
68
+ });
69
+ test("authenticate — returns null for wrong password", () => {
70
+ const store = {
71
+ users: [
72
+ {
73
+ username: "alice",
74
+ name: "Alice",
75
+ passwordHash: hashPassword("hunter2", fastOpts),
76
+ },
77
+ ],
78
+ };
79
+ assert.equal(authenticate("alice", "wrong", store), null);
80
+ });
81
+ import { writeUsersFile } from "./local-users.js";
82
+ test("writeUsersFile — atomic round-trip preserves shape", async () => {
83
+ const { mkdtemp, rm } = await import("node:fs/promises");
84
+ const { tmpdir } = await import("node:os");
85
+ const { join } = await import("node:path");
86
+ const dir = await mkdtemp(join(tmpdir(), "omcp-users-"));
87
+ try {
88
+ const path = join(dir, "users.json");
89
+ const file = {
90
+ users: [
91
+ { username: "alice", name: "Alice", roles: ["operator", "viewer"], tenant: "acme", passwordHash: "scrypt$dummy" },
92
+ { username: "bob", name: "Bob", passwordHash: "scrypt$dummy2" },
93
+ ],
94
+ };
95
+ await writeUsersFile(path, file);
96
+ const back = await readUsersFile(path);
97
+ assert.ok(back);
98
+ assert.equal(back.users.length, 2);
99
+ assert.deepEqual(back.users[0].roles, ["operator", "viewer"]);
100
+ assert.equal(back.users[0].tenant, "acme");
101
+ assert.equal(back.users[1].passwordHash, "scrypt$dummy2");
102
+ }
103
+ finally {
104
+ await rm(dir, { recursive: true, force: true });
105
+ }
106
+ });
107
+ test("writeUsersFile — no .tmp leftover after success (atomic rename)", async () => {
108
+ const { mkdtemp, rm, readdir } = await import("node:fs/promises");
109
+ const { tmpdir } = await import("node:os");
110
+ const { join } = await import("node:path");
111
+ const dir = await mkdtemp(join(tmpdir(), "omcp-users-tmp-"));
112
+ try {
113
+ const path = join(dir, "users.json");
114
+ await writeUsersFile(path, { users: [{ username: "u", name: "U", passwordHash: "scrypt$x" }] });
115
+ const entries = await readdir(dir);
116
+ assert.deepEqual(entries.sort(), ["users.json"]);
117
+ }
118
+ finally {
119
+ await rm(dir, { recursive: true, force: true });
120
+ }
121
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Express middleware wiring for the management-plane auth mode.
3
+ *
4
+ * Split into two pieces by design — the cookie-parsing middleware always
5
+ * runs (so identity-aware handlers like /api/me always see req.session),
6
+ * and the protected-route gate is mounted explicitly on the routes that
7
+ * need it. There is no `if (publicPath) next()` shortcut anywhere — the
8
+ * decision of "what is public" is encoded by which middleware Express
9
+ * registers on which route, not by a string match at request time.
10
+ *
11
+ * When `OMCP_AUTH` is unset or "anonymous" (the default) both middlewares
12
+ * are no-ops and every existing handler behaves exactly as before.
13
+ */
14
+ import type { Request, RequestHandler } from "express";
15
+ import { type SessionPayload, type SessionConfig } from "./session.js";
16
+ import type { OidcRuntime } from "./oidc/runtime.js";
17
+ export type AuthMode = "anonymous" | "basic" | "oidc";
18
+ export interface AuthRuntime {
19
+ mode: AuthMode;
20
+ /** Present when mode is "basic" or "oidc" — both mint OMCP session
21
+ * cookies the same way, just sourced from local creds vs. an IdP. */
22
+ session?: SessionConfig;
23
+ /** When true and `secret` not provided, the server generated one for this
24
+ * process — sessions will not survive a restart. The wire-up code logs a
25
+ * warning once when this happens. */
26
+ secretEphemeral?: boolean;
27
+ /** OIDC runtime, present only when mode === "oidc". The OIDC HTTP
28
+ * endpoints in src/index.ts consume it. The import above is
29
+ * type-only and erased at compile time, so this typing adds zero
30
+ * runtime coupling — middleware.ts still doesn't depend on the
31
+ * OIDC sub-module's node:crypto path. */
32
+ oidc?: OidcRuntime;
33
+ }
34
+ export interface AuthedRequest extends Request {
35
+ session?: SessionPayload;
36
+ }
37
+ /**
38
+ * Best-effort cookie resolver. Attaches `req.session` when present and
39
+ * valid; otherwise leaves it undefined. Always calls `next()`. Mount this
40
+ * globally so every handler can read the identity.
41
+ */
42
+ export declare function buildSessionAttacher(runtime: AuthRuntime): RequestHandler;
43
+ /**
44
+ * Gate. Rejects requests that lack a valid session with HTTP 401 + a JSON
45
+ * body the UI's fetch wrapper recognises. Mount this on each protected
46
+ * route or router, NOT globally — paths the operator wants public
47
+ * (login, /api/me, /api/info, /healthz, ...) simply don't register it.
48
+ */
49
+ export declare function buildRequireSession(runtime: AuthRuntime): RequestHandler;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Express middleware wiring for the management-plane auth mode.
3
+ *
4
+ * Split into two pieces by design — the cookie-parsing middleware always
5
+ * runs (so identity-aware handlers like /api/me always see req.session),
6
+ * and the protected-route gate is mounted explicitly on the routes that
7
+ * need it. There is no `if (publicPath) next()` shortcut anywhere — the
8
+ * decision of "what is public" is encoded by which middleware Express
9
+ * registers on which route, not by a string match at request time.
10
+ *
11
+ * When `OMCP_AUTH` is unset or "anonymous" (the default) both middlewares
12
+ * are no-ops and every existing handler behaves exactly as before.
13
+ */
14
+ import { readCookie, verifySession } from "./session.js";
15
+ /**
16
+ * Best-effort cookie resolver. Attaches `req.session` when present and
17
+ * valid; otherwise leaves it undefined. Always calls `next()`. Mount this
18
+ * globally so every handler can read the identity.
19
+ */
20
+ export function buildSessionAttacher(runtime) {
21
+ const sessionCfg = runtime.session;
22
+ // In anonymous mode the secret is meaningless — verifySession would
23
+ // throw on an empty secret — so install a true no-op middleware and
24
+ // skip every per-request branch.
25
+ if (runtime.mode === "anonymous" || !sessionCfg) {
26
+ return function noopAttacher(_req, _res, next) {
27
+ next();
28
+ };
29
+ }
30
+ return function sessionAttacher(req, _res, next) {
31
+ // verifySession is a pure parse + HMAC verify. It safely returns
32
+ // null on empty / null / malformed input, so call it unconditionally
33
+ // and let the result speak for itself — no attacker-controlled
34
+ // branch decides whether the check runs.
35
+ const raw = readCookie(req.headers.cookie || "");
36
+ const payload = verifySession(raw, sessionCfg);
37
+ if (payload)
38
+ req.session = payload;
39
+ next();
40
+ };
41
+ }
42
+ /**
43
+ * Gate. Rejects requests that lack a valid session with HTTP 401 + a JSON
44
+ * body the UI's fetch wrapper recognises. Mount this on each protected
45
+ * route or router, NOT globally — paths the operator wants public
46
+ * (login, /api/me, /api/info, /healthz, ...) simply don't register it.
47
+ */
48
+ export function buildRequireSession(runtime) {
49
+ return function requireSession(req, res, next) {
50
+ if (runtime.mode === "anonymous" || !runtime.session) {
51
+ next();
52
+ return;
53
+ }
54
+ if (req.session) {
55
+ next();
56
+ return;
57
+ }
58
+ res.status(401).json({
59
+ error: "authentication required",
60
+ mode: runtime.mode,
61
+ // Recognised by the UI's fetch wrapper to trigger the login modal.
62
+ code: "OMCP_AUTH_REQUIRED",
63
+ });
64
+ };
65
+ }
@@ -0,0 +1 @@
1
+ export {};