@thotischner/observability-mcp 3.0.0 → 3.1.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 (93) hide show
  1. package/dist/analysis/history.d.ts +36 -2
  2. package/dist/analysis/history.js +60 -2
  3. package/dist/analysis/history.test.js +46 -0
  4. package/dist/audit/sinks/s3.d.ts +61 -0
  5. package/dist/audit/sinks/s3.js +179 -0
  6. package/dist/audit/sinks/s3.test.d.ts +1 -0
  7. package/dist/audit/sinks/s3.test.js +175 -0
  8. package/dist/auth/csrf.d.ts +6 -0
  9. package/dist/auth/csrf.js +4 -0
  10. package/dist/auth/csrf.test.js +22 -0
  11. package/dist/auth/lockout.d.ts +72 -0
  12. package/dist/auth/lockout.js +134 -0
  13. package/dist/auth/lockout.test.d.ts +1 -0
  14. package/dist/auth/lockout.test.js +133 -0
  15. package/dist/auth/middleware.d.ts +5 -0
  16. package/dist/auth/middleware.js +6 -1
  17. package/dist/auth/middleware.test.js +31 -0
  18. package/dist/auth/password-policy.d.ts +52 -0
  19. package/dist/auth/password-policy.js +125 -0
  20. package/dist/auth/password-policy.test.d.ts +1 -0
  21. package/dist/auth/password-policy.test.js +111 -0
  22. package/dist/auth/policy/batch-dry-run.js +15 -0
  23. package/dist/auth/revocation.d.ts +93 -0
  24. package/dist/auth/revocation.js +193 -0
  25. package/dist/auth/revocation.test.d.ts +1 -0
  26. package/dist/auth/revocation.test.js +136 -0
  27. package/dist/auth/session.d.ts +7 -0
  28. package/dist/auth/session.js +6 -0
  29. package/dist/auth/session.test.js +21 -0
  30. package/dist/connectors/interface.d.ts +5 -1
  31. package/dist/connectors/loader.d.ts +8 -0
  32. package/dist/connectors/loader.js +49 -0
  33. package/dist/connectors/loki.d.ts +45 -1
  34. package/dist/connectors/loki.js +141 -8
  35. package/dist/connectors/loki.test.js +171 -1
  36. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  37. package/dist/connectors/manifest-hooks.test.js +206 -0
  38. package/dist/federation/registry.d.ts +27 -5
  39. package/dist/federation/registry.js +49 -4
  40. package/dist/federation/registry.test.js +79 -3
  41. package/dist/federation/upstream.d.ts +32 -6
  42. package/dist/federation/upstream.js +60 -12
  43. package/dist/federation/upstream.test.d.ts +1 -0
  44. package/dist/federation/upstream.test.js +118 -0
  45. package/dist/index.js +522 -67
  46. package/dist/metrics/self.d.ts +1 -0
  47. package/dist/metrics/self.js +8 -0
  48. package/dist/openapi.js +39 -0
  49. package/dist/openapi.test.js +1 -0
  50. package/dist/policy/redact.js +1 -1
  51. package/dist/postmortem/store.d.ts +34 -0
  52. package/dist/postmortem/store.js +113 -0
  53. package/dist/postmortem/store.test.d.ts +1 -0
  54. package/dist/postmortem/store.test.js +118 -0
  55. package/dist/scim/compliance.test.d.ts +1 -0
  56. package/dist/scim/compliance.test.js +169 -0
  57. package/dist/scim/factory.test.d.ts +1 -0
  58. package/dist/scim/factory.test.js +54 -0
  59. package/dist/scim/patch-ops.test.d.ts +1 -0
  60. package/dist/scim/patch-ops.test.js +100 -0
  61. package/dist/scim/redis-store.d.ts +38 -0
  62. package/dist/scim/redis-store.js +178 -0
  63. package/dist/scim/redis-store.test.d.ts +1 -0
  64. package/dist/scim/redis-store.test.js +138 -0
  65. package/dist/scim/routes.d.ts +27 -2
  66. package/dist/scim/routes.js +161 -15
  67. package/dist/scim/store.d.ts +40 -1
  68. package/dist/scim/store.js +23 -5
  69. package/dist/sdk/hook-wrappers.d.ts +39 -0
  70. package/dist/sdk/hook-wrappers.js +113 -0
  71. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  72. package/dist/sdk/hook-wrappers.test.js +204 -0
  73. package/dist/sdk/index.d.ts +13 -0
  74. package/dist/security/csp.d.ts +64 -0
  75. package/dist/security/csp.js +135 -0
  76. package/dist/security/csp.test.d.ts +1 -0
  77. package/dist/security/csp.test.js +97 -0
  78. package/dist/tools/detect-anomalies.d.ts +12 -1
  79. package/dist/tools/detect-anomalies.js +22 -2
  80. package/dist/tools/query-logs.d.ts +40 -0
  81. package/dist/tools/query-logs.js +69 -3
  82. package/dist/tools/topology.js +23 -5
  83. package/dist/tools/topology.test.js +45 -0
  84. package/dist/tools/validation.d.ts +13 -0
  85. package/dist/tools/validation.js +74 -0
  86. package/dist/tools/validation.test.js +54 -1
  87. package/dist/transport/transportSessionMap.d.ts +70 -0
  88. package/dist/transport/transportSessionMap.js +128 -0
  89. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  90. package/dist/transport/transportSessionMap.test.js +111 -0
  91. package/dist/types.d.ts +48 -0
  92. package/dist/ui/index.html +898 -116
  93. package/package.json +1 -1
@@ -89,6 +89,28 @@ test("enforcer: bearer auth bypasses CSRF when bypassBearer=true", () => {
89
89
  });
90
90
  assert.equal(r.nexted, true);
91
91
  });
92
+ test("enforcer: skip predicate exempts a matching request (no token needed)", () => {
93
+ // Mirror the production predicate, which checks BOTH req.path and
94
+ // req.originalUrl — under `app.use("/api", ...)` Express strips the
95
+ // mount prefix from req.path (→ "/csp-violations"), so originalUrl is
96
+ // what actually matches at runtime.
97
+ const skip = (r) => r.method === "POST" &&
98
+ (r.path === "/api/csp-violations" || (r.originalUrl || "").split("?")[0] === "/api/csp-violations");
99
+ const mw = buildCsrfEnforcer({ bypassBearer: false, secureCookie: () => false, skip });
100
+ // Mounted shape: path stripped to "/csp-violations", originalUrl intact.
101
+ const mounted = call(mw, { method: "POST", path: "/csp-violations", originalUrl: "/api/csp-violations", headers: {} });
102
+ assert.equal(mounted.nexted, true, "originalUrl match must exempt under the /api mount");
103
+ // Query string can't widen the match.
104
+ const withQuery = call(mw, { method: "POST", path: "/csp-violations", originalUrl: "/api/csp-violations?x=1", headers: {} });
105
+ assert.equal(withQuery.nexted, true);
106
+ // A different path is still enforced (rejected without a token).
107
+ const other = call(mw, { method: "POST", path: "/settings", originalUrl: "/api/settings", headers: {} });
108
+ assert.equal(other.nexted, false);
109
+ assert.equal(other.res.status_, 403);
110
+ // GET is exempt anyway (safe method) regardless of skip.
111
+ const get = call(mw, { method: "GET", path: "/settings", originalUrl: "/api/settings", headers: {} });
112
+ assert.equal(get.nexted, true);
113
+ });
92
114
  test("enforcer: X-API-Key also bypasses when bypassBearer=true", () => {
93
115
  const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
94
116
  const r = call(mw, {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Local-account lockout for the management-plane "basic" auth login.
3
+ *
4
+ * The per-IP rate limiter on /api/auth/login (20/min) blunts a noisy
5
+ * brute-force, but it's keyed on the source IP — a distributed attempt, or
6
+ * a slow drip under the IP cap, can still grind a single account's
7
+ * password. This adds a per-username failed-login counter with progressive
8
+ * backoff: after N failures inside a sliding window the account is
9
+ * temporarily locked, and each subsequent lock lasts longer
10
+ * (base · 2^(level-1), capped). A successful login clears the streak.
11
+ *
12
+ * State lives in the shared {@link SessionStore} so a Redis-backed
13
+ * deployment locks consistently across replicas (and self-cleans via TTL).
14
+ * The in-memory default keeps the single-process behaviour with no new dep.
15
+ *
16
+ * Lockout is tracked by the *submitted* username, whether or not it exists,
17
+ * so it can't be used as a user-enumeration oracle and a known username
18
+ * can't be singled out. Read-modify-write is best-effort under concurrency
19
+ * (matching the store's eventually-consistent contract) — the worst case is
20
+ * a couple of extra attempts slipping past the threshold, which the IP rate
21
+ * limiter still bounds. It is never a lockout bypass for the *locked* state:
22
+ * once `lockedUntil` is written, every replica that reads it honours it.
23
+ */
24
+ import type { SessionStore } from "../transport/sessionStore.js";
25
+ export interface LockoutConfig {
26
+ /** Consecutive failures within the window before a lock triggers. */
27
+ maxFailures: number;
28
+ /** Sliding window (seconds) over which failures accumulate. */
29
+ windowSeconds: number;
30
+ /** First lock duration (seconds). Doubles each subsequent lock. */
31
+ baseLockSeconds: number;
32
+ /** Upper bound on a single lock duration (seconds). */
33
+ maxLockSeconds: number;
34
+ }
35
+ export declare const DEFAULT_LOCKOUT_CONFIG: LockoutConfig;
36
+ export interface LockoutStatus {
37
+ locked: boolean;
38
+ /** Seconds the caller must wait, when locked. */
39
+ retryAfterSeconds?: number;
40
+ /** Attempts left before a lock, when not locked. */
41
+ remainingAttempts?: number;
42
+ }
43
+ /** Resolve config from env, falling back to the defaults. */
44
+ export declare function lockoutConfigFromEnv(env?: NodeJS.ProcessEnv): LockoutConfig;
45
+ /** True when OMCP_AUTH_LOCKOUT_DISABLED is set to a truthy value. */
46
+ export declare function lockoutDisabledFromEnv(env?: NodeJS.ProcessEnv): boolean;
47
+ export declare class AccountLockout {
48
+ private readonly store;
49
+ private readonly cfg;
50
+ private readonly now;
51
+ /** TTL applied to every persisted entry so stale streaks self-clean. */
52
+ private readonly ttlSeconds;
53
+ constructor(store: SessionStore, cfg?: Partial<LockoutConfig>, now?: () => number);
54
+ private key;
55
+ /** Lock duration for a given (1-based) lock level, capped. */
56
+ private lockDuration;
57
+ /**
58
+ * Inspect lock state without mutating it. Call before verifying the
59
+ * password — a locked account should never reach the (expensive) hash
60
+ * comparison.
61
+ */
62
+ check(username: string): Promise<LockoutStatus>;
63
+ /** Failures still inside the sliding window (0 if the window lapsed). */
64
+ private activeFailures;
65
+ /**
66
+ * Record a failed login. Returns the resulting status — `locked: true`
67
+ * when this failure tripped (or fell inside) a lock.
68
+ */
69
+ recordFailure(username: string): Promise<LockoutStatus>;
70
+ /** Clear the streak on a successful login. Keeps no history. */
71
+ recordSuccess(username: string): Promise<void>;
72
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Local-account lockout for the management-plane "basic" auth login.
3
+ *
4
+ * The per-IP rate limiter on /api/auth/login (20/min) blunts a noisy
5
+ * brute-force, but it's keyed on the source IP — a distributed attempt, or
6
+ * a slow drip under the IP cap, can still grind a single account's
7
+ * password. This adds a per-username failed-login counter with progressive
8
+ * backoff: after N failures inside a sliding window the account is
9
+ * temporarily locked, and each subsequent lock lasts longer
10
+ * (base · 2^(level-1), capped). A successful login clears the streak.
11
+ *
12
+ * State lives in the shared {@link SessionStore} so a Redis-backed
13
+ * deployment locks consistently across replicas (and self-cleans via TTL).
14
+ * The in-memory default keeps the single-process behaviour with no new dep.
15
+ *
16
+ * Lockout is tracked by the *submitted* username, whether or not it exists,
17
+ * so it can't be used as a user-enumeration oracle and a known username
18
+ * can't be singled out. Read-modify-write is best-effort under concurrency
19
+ * (matching the store's eventually-consistent contract) — the worst case is
20
+ * a couple of extra attempts slipping past the threshold, which the IP rate
21
+ * limiter still bounds. It is never a lockout bypass for the *locked* state:
22
+ * once `lockedUntil` is written, every replica that reads it honours it.
23
+ */
24
+ export const DEFAULT_LOCKOUT_CONFIG = {
25
+ maxFailures: 5,
26
+ windowSeconds: 15 * 60,
27
+ baseLockSeconds: 60,
28
+ maxLockSeconds: 60 * 60,
29
+ };
30
+ function nowSeconds() {
31
+ return Math.floor(Date.now() / 1000);
32
+ }
33
+ /** Resolve config from env, falling back to the defaults. */
34
+ export function lockoutConfigFromEnv(env = process.env) {
35
+ const num = (raw, fallback) => {
36
+ if (raw === undefined)
37
+ return fallback;
38
+ const n = Number(raw);
39
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
40
+ };
41
+ return {
42
+ maxFailures: num(env.OMCP_AUTH_LOCKOUT_MAX_FAILURES, DEFAULT_LOCKOUT_CONFIG.maxFailures),
43
+ windowSeconds: num(env.OMCP_AUTH_LOCKOUT_WINDOW, DEFAULT_LOCKOUT_CONFIG.windowSeconds),
44
+ baseLockSeconds: num(env.OMCP_AUTH_LOCKOUT_BASE, DEFAULT_LOCKOUT_CONFIG.baseLockSeconds),
45
+ maxLockSeconds: num(env.OMCP_AUTH_LOCKOUT_MAX, DEFAULT_LOCKOUT_CONFIG.maxLockSeconds),
46
+ };
47
+ }
48
+ /** True when OMCP_AUTH_LOCKOUT_DISABLED is set to a truthy value. */
49
+ export function lockoutDisabledFromEnv(env = process.env) {
50
+ const v = env.OMCP_AUTH_LOCKOUT_DISABLED?.trim().toLowerCase();
51
+ return v === "1" || v === "true" || v === "yes";
52
+ }
53
+ export class AccountLockout {
54
+ store;
55
+ cfg;
56
+ now;
57
+ /** TTL applied to every persisted entry so stale streaks self-clean. */
58
+ ttlSeconds;
59
+ constructor(store, cfg = {}, now = nowSeconds) {
60
+ this.store = store;
61
+ this.cfg = { ...DEFAULT_LOCKOUT_CONFIG, ...cfg };
62
+ this.now = now;
63
+ // Outlast both the streak window and the longest possible lock so an
64
+ // abandoned entry always expires, but a live lock never does early.
65
+ this.ttlSeconds = Math.max(this.cfg.windowSeconds, this.cfg.maxLockSeconds) + 60;
66
+ }
67
+ key(username) {
68
+ return `lockout:${username}`;
69
+ }
70
+ /** Lock duration for a given (1-based) lock level, capped. */
71
+ lockDuration(level) {
72
+ const exp = this.cfg.baseLockSeconds * Math.pow(2, Math.max(0, level - 1));
73
+ return Math.min(this.cfg.maxLockSeconds, Math.floor(exp));
74
+ }
75
+ /**
76
+ * Inspect lock state without mutating it. Call before verifying the
77
+ * password — a locked account should never reach the (expensive) hash
78
+ * comparison.
79
+ */
80
+ async check(username) {
81
+ const state = await this.store.get(this.key(username));
82
+ const now = this.now();
83
+ if (state?.lockedUntil && state.lockedUntil > now) {
84
+ return { locked: true, retryAfterSeconds: state.lockedUntil - now };
85
+ }
86
+ const failures = this.activeFailures(state, now);
87
+ return { locked: false, remainingAttempts: Math.max(0, this.cfg.maxFailures - failures) };
88
+ }
89
+ /** Failures still inside the sliding window (0 if the window lapsed). */
90
+ activeFailures(state, now) {
91
+ if (!state)
92
+ return 0;
93
+ if (now - state.firstFailureAt > this.cfg.windowSeconds)
94
+ return 0;
95
+ return state.failures;
96
+ }
97
+ /**
98
+ * Record a failed login. Returns the resulting status — `locked: true`
99
+ * when this failure tripped (or fell inside) a lock.
100
+ */
101
+ async recordFailure(username) {
102
+ const k = this.key(username);
103
+ const now = this.now();
104
+ const prev = await this.store.get(k);
105
+ // If an existing lock is still active, just report it — don't extend
106
+ // it on every blocked attempt (that would let an attacker grow the
107
+ // legitimate user's lock unboundedly).
108
+ if (prev?.lockedUntil && prev.lockedUntil > now) {
109
+ return { locked: true, retryAfterSeconds: prev.lockedUntil - now };
110
+ }
111
+ // Continue the streak only if the window is still open; otherwise reset.
112
+ const windowOpen = prev && now - prev.firstFailureAt <= this.cfg.windowSeconds;
113
+ const next = windowOpen
114
+ ? { ...prev, failures: prev.failures + 1 }
115
+ : { failures: 1, firstFailureAt: now, lockLevel: prev?.lockLevel ?? 0 };
116
+ if (next.failures >= this.cfg.maxFailures) {
117
+ // Trip a lock: escalate the level, set the deadline, reset the
118
+ // counter so the next batch of failures earns a longer lock.
119
+ next.lockLevel += 1;
120
+ const duration = this.lockDuration(next.lockLevel);
121
+ next.lockedUntil = now + duration;
122
+ next.failures = 0;
123
+ next.firstFailureAt = now;
124
+ await this.store.setEx(k, this.ttlSeconds, next);
125
+ return { locked: true, retryAfterSeconds: duration };
126
+ }
127
+ await this.store.setEx(k, this.ttlSeconds, next);
128
+ return { locked: false, remainingAttempts: this.cfg.maxFailures - next.failures };
129
+ }
130
+ /** Clear the streak on a successful login. Keeps no history. */
131
+ async recordSuccess(username) {
132
+ await this.store.del(this.key(username));
133
+ }
134
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { InMemorySessionStore } from "../transport/sessionStore.js";
4
+ import { AccountLockout, lockoutConfigFromEnv, lockoutDisabledFromEnv, DEFAULT_LOCKOUT_CONFIG, } from "./lockout.js";
5
+ const cfg = { maxFailures: 3, windowSeconds: 100, baseLockSeconds: 60, maxLockSeconds: 600 };
6
+ function mk(now) {
7
+ return new AccountLockout(new InMemorySessionStore(), cfg, () => now.t);
8
+ }
9
+ test("unknown account is not locked and reports full remaining attempts", async () => {
10
+ const now = { t: 1000 };
11
+ const lock = mk(now);
12
+ const status = await lock.check("alice");
13
+ assert.equal(status.locked, false);
14
+ assert.equal(status.remainingAttempts, 3);
15
+ });
16
+ test("failures below the threshold decrement remaining attempts", async () => {
17
+ const now = { t: 1000 };
18
+ const lock = mk(now);
19
+ assert.deepEqual(await lock.recordFailure("alice"), { locked: false, remainingAttempts: 2 });
20
+ assert.deepEqual(await lock.recordFailure("alice"), { locked: false, remainingAttempts: 1 });
21
+ });
22
+ test("the Nth failure trips a lock with the base duration", async () => {
23
+ const now = { t: 1000 };
24
+ const lock = mk(now);
25
+ await lock.recordFailure("alice");
26
+ await lock.recordFailure("alice");
27
+ const tripped = await lock.recordFailure("alice");
28
+ assert.equal(tripped.locked, true);
29
+ assert.equal(tripped.retryAfterSeconds, 60);
30
+ // check() reflects the lock and counts down with the clock.
31
+ now.t += 10;
32
+ const status = await lock.check("alice");
33
+ assert.equal(status.locked, true);
34
+ assert.equal(status.retryAfterSeconds, 50);
35
+ });
36
+ test("a lapsed lock clears and lets attempts resume", async () => {
37
+ const now = { t: 1000 };
38
+ const lock = mk(now);
39
+ await lock.recordFailure("alice");
40
+ await lock.recordFailure("alice");
41
+ await lock.recordFailure("alice"); // locked for 60s
42
+ now.t += 61;
43
+ const status = await lock.check("alice");
44
+ assert.equal(status.locked, false);
45
+ });
46
+ test("progressive backoff doubles each lock, capped at maxLockSeconds", async () => {
47
+ const now = { t: 0 };
48
+ const lock = mk(now);
49
+ async function tripLock() {
50
+ let last = { locked: false };
51
+ for (let i = 0; i < cfg.maxFailures; i++)
52
+ last = await lock.recordFailure("bob");
53
+ return last.retryAfterSeconds;
54
+ }
55
+ // Level 1 → 60, level 2 → 120, level 3 → 240, level 4 → 480, level 5 → 600 (capped).
56
+ const durations = [];
57
+ for (let lvl = 0; lvl < 5; lvl++) {
58
+ const d = await tripLock();
59
+ durations.push(d);
60
+ now.t += d + 1; // wait out the lock before the next streak
61
+ }
62
+ assert.deepEqual(durations, [60, 120, 240, 480, 600]);
63
+ });
64
+ test("a blocked attempt during an active lock does not extend it", async () => {
65
+ const now = { t: 1000 };
66
+ const lock = mk(now);
67
+ await lock.recordFailure("alice");
68
+ await lock.recordFailure("alice");
69
+ const tripped = await lock.recordFailure("alice"); // locked 60s at t=1000
70
+ assert.equal(tripped.retryAfterSeconds, 60);
71
+ now.t += 30;
72
+ const blocked = await lock.recordFailure("alice"); // still locked, t=1030
73
+ assert.equal(blocked.locked, true);
74
+ // Deadline unchanged (1060) → 30s left, NOT re-extended to 60.
75
+ assert.equal(blocked.retryAfterSeconds, 30);
76
+ });
77
+ test("failures outside the window reset the streak", async () => {
78
+ const now = { t: 1000 };
79
+ const lock = mk(now);
80
+ await lock.recordFailure("alice"); // failures=1, firstFailureAt=1000
81
+ await lock.recordFailure("alice"); // failures=2
82
+ now.t += cfg.windowSeconds + 1; // window lapsed
83
+ const status = await lock.recordFailure("alice"); // resets to failures=1
84
+ assert.equal(status.locked, false);
85
+ assert.equal(status.remainingAttempts, 2);
86
+ });
87
+ test("recordSuccess clears the streak", async () => {
88
+ const now = { t: 1000 };
89
+ const lock = mk(now);
90
+ await lock.recordFailure("alice");
91
+ await lock.recordFailure("alice");
92
+ await lock.recordSuccess("alice");
93
+ const status = await lock.check("alice");
94
+ assert.equal(status.locked, false);
95
+ assert.equal(status.remainingAttempts, 3);
96
+ });
97
+ test("lockout is tracked per username — one account's lock doesn't touch another", async () => {
98
+ const now = { t: 1000 };
99
+ const lock = mk(now);
100
+ await lock.recordFailure("alice");
101
+ await lock.recordFailure("alice");
102
+ await lock.recordFailure("alice"); // alice locked
103
+ assert.equal((await lock.check("alice")).locked, true);
104
+ assert.equal((await lock.check("bob")).locked, false);
105
+ assert.equal((await lock.check("bob")).remainingAttempts, 3);
106
+ });
107
+ test("state persists with a TTL so it self-cleans", async () => {
108
+ const store = new InMemorySessionStore();
109
+ const now = { t: 1000 };
110
+ const lock = new AccountLockout(store, cfg, () => now.t);
111
+ await lock.recordFailure("alice");
112
+ // One key written under the lockout: prefix.
113
+ const keys = await store.keys("lockout:");
114
+ assert.deepEqual(keys, ["lockout:alice"]);
115
+ });
116
+ test("lockoutConfigFromEnv parses overrides and falls back on bad input", () => {
117
+ const parsed = lockoutConfigFromEnv({
118
+ OMCP_AUTH_LOCKOUT_MAX_FAILURES: "10",
119
+ OMCP_AUTH_LOCKOUT_WINDOW: "300",
120
+ OMCP_AUTH_LOCKOUT_BASE: "nonsense",
121
+ OMCP_AUTH_LOCKOUT_MAX: "-5",
122
+ });
123
+ assert.equal(parsed.maxFailures, 10);
124
+ assert.equal(parsed.windowSeconds, 300);
125
+ assert.equal(parsed.baseLockSeconds, DEFAULT_LOCKOUT_CONFIG.baseLockSeconds); // bad → default
126
+ assert.equal(parsed.maxLockSeconds, DEFAULT_LOCKOUT_CONFIG.maxLockSeconds); // negative → default
127
+ });
128
+ test("lockoutDisabledFromEnv recognises truthy values", () => {
129
+ assert.equal(lockoutDisabledFromEnv({ OMCP_AUTH_LOCKOUT_DISABLED: "true" }), true);
130
+ assert.equal(lockoutDisabledFromEnv({ OMCP_AUTH_LOCKOUT_DISABLED: "1" }), true);
131
+ assert.equal(lockoutDisabledFromEnv({ OMCP_AUTH_LOCKOUT_DISABLED: "no" }), false);
132
+ assert.equal(lockoutDisabledFromEnv({}), false);
133
+ });
@@ -14,6 +14,7 @@
14
14
  import type { Request, RequestHandler } from "express";
15
15
  import { type SessionPayload, type SessionConfig } from "./session.js";
16
16
  import type { OidcRuntime } from "./oidc/runtime.js";
17
+ import type { RevocationStore } from "./revocation.js";
17
18
  export type AuthMode = "anonymous" | "basic" | "oidc";
18
19
  export interface AuthRuntime {
19
20
  mode: AuthMode;
@@ -30,6 +31,10 @@ export interface AuthRuntime {
30
31
  * runtime coupling — middleware.ts still doesn't depend on the
31
32
  * OIDC sub-module's node:crypto path. */
32
33
  oidc?: OidcRuntime;
34
+ /** Session revocation blocklist. Present in basic / oidc mode; the
35
+ * session attacher consults it on every request so a revoked but
36
+ * not-yet-expired cookie is treated as logged out. */
37
+ revocation?: RevocationStore;
33
38
  }
34
39
  export interface AuthedRequest extends Request {
35
40
  session?: SessionPayload;
@@ -34,8 +34,13 @@ export function buildSessionAttacher(runtime) {
34
34
  // branch decides whether the check runs.
35
35
  const raw = readCookie(req.headers.cookie || "");
36
36
  const payload = verifySession(raw, sessionCfg);
37
- if (payload)
37
+ // A valid signature is necessary but not sufficient: a session whose
38
+ // sid (or whose subject, before the revocation cutoff) is on the
39
+ // blocklist is treated as absent. isRevoked is a synchronous in-memory
40
+ // lookup, so this stays a no-await hot path.
41
+ if (payload && !runtime.revocation?.isRevoked(payload)) {
38
42
  req.session = payload;
43
+ }
39
44
  next();
40
45
  };
41
46
  }
@@ -2,6 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { buildSessionAttacher, buildRequireSession } from "./middleware.js";
4
4
  import { issueSession } from "./session.js";
5
+ import { RevocationStore } from "./revocation.js";
5
6
  const secret = "x".repeat(48);
6
7
  const sessionCfg = { secret };
7
8
  function mkReq(opts = {}) {
@@ -56,6 +57,36 @@ test("session attacher — tampered cookie leaves session undefined and still fl
56
57
  assert.equal(called, true);
57
58
  assert.equal(req.session, undefined);
58
59
  });
60
+ test("session attacher — a revoked session (by sid) is not attached", async () => {
61
+ const { cookie, payload } = issueSession({ sub: "alice", name: "Alice" }, sessionCfg);
62
+ const revocation = await RevocationStore.create();
63
+ await revocation.revokeSession(payload.sid);
64
+ const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg, revocation });
65
+ const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
66
+ let called = false;
67
+ attach(req, mkRes(), () => { called = true; });
68
+ assert.equal(called, true);
69
+ assert.equal(req.session, undefined);
70
+ });
71
+ test("session attacher — a subject-revoked session is not attached", async () => {
72
+ const { cookie } = issueSession({ sub: "bob", name: "Bob" }, sessionCfg);
73
+ const revocation = await RevocationStore.create();
74
+ // Revoke into the future so the just-issued session's iat is <= cutoff.
75
+ await revocation.revokeSubject("bob", { reason: "offboarded" });
76
+ const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg, revocation });
77
+ const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
78
+ attach(req, mkRes(), () => { });
79
+ assert.equal(req.session, undefined);
80
+ });
81
+ test("session attacher — an unrevoked session still attaches when a blocklist is present", async () => {
82
+ const { cookie } = issueSession({ sub: "carol", name: "Carol" }, sessionCfg);
83
+ const revocation = await RevocationStore.create();
84
+ await revocation.revokeSession("some-other-sid");
85
+ const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg, revocation });
86
+ const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
87
+ attach(req, mkRes(), () => { });
88
+ assert.equal(req.session?.sub, "carol");
89
+ });
59
90
  test("require-session — anonymous always allows", () => {
60
91
  const gate = buildRequireSession({ mode: "anonymous" });
61
92
  const req = mkReq();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Password policy for the management-plane "basic" auth mode.
3
+ *
4
+ * Where this is enforced: the only point in the system that ever sees a
5
+ * management password in plaintext is where one is *minted* — the
6
+ * `scripts/hash-password.mjs` helper. The users file stores scrypt
7
+ * hashes only, so password strength cannot be re-evaluated at load time
8
+ * (there is no plaintext to check), and there is no runtime endpoint that
9
+ * accepts a plaintext password to set. This module is therefore the
10
+ * canonical, dependency-free policy that the minting path enforces — and
11
+ * that any future "change my password" endpoint should call before
12
+ * hashing. Login (`verifyPassword`) never re-checks policy: that would
13
+ * lock out users whose passwords predate a tightened policy.
14
+ *
15
+ * "Basic" ruleset: a minimum length, a minimum number of character
16
+ * classes, a small builtin common-password denylist, and a guard against
17
+ * passwords that just echo the username. Deliberately small — this is a
18
+ * footgun guard for self-hosted operators, not a compliance engine.
19
+ */
20
+ export interface PasswordPolicy {
21
+ /** Minimum length in Unicode code points. */
22
+ minLength: number;
23
+ /** Maximum length — a sanity bound so a megabyte "password" can't be
24
+ * fed into scrypt. */
25
+ maxLength: number;
26
+ /** How many of the four classes (lower/upper/digit/symbol) are required. */
27
+ minClasses: number;
28
+ /** When true, reject passwords on the builtin common-password list. */
29
+ denylistEnabled: boolean;
30
+ }
31
+ export declare const DEFAULT_PASSWORD_POLICY: PasswordPolicy;
32
+ /**
33
+ * A small list of the most-abused passwords + obvious app-specific ones.
34
+ * Not exhaustive — the real defenders are length + classes. Lowercased;
35
+ * comparison is case-insensitive.
36
+ */
37
+ export declare const COMMON_PASSWORD_DENYLIST: ReadonlySet<string>;
38
+ export interface PasswordCheckResult {
39
+ ok: boolean;
40
+ /** Human-readable reasons the password was rejected; empty when ok. */
41
+ errors: string[];
42
+ }
43
+ /**
44
+ * Validate a plaintext password against the policy. `username`, when
45
+ * given, additionally rejects passwords that are (or merely contain) the
46
+ * username — the single most common weak choice.
47
+ */
48
+ export declare function validatePassword(password: string, policy?: PasswordPolicy, username?: string): PasswordCheckResult;
49
+ /** Resolve the policy from env, falling back to the defaults. */
50
+ export declare function passwordPolicyFromEnv(env?: NodeJS.ProcessEnv): PasswordPolicy;
51
+ /** True when the policy is turned off entirely via env. */
52
+ export declare function passwordPolicyDisabledFromEnv(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Password policy for the management-plane "basic" auth mode.
3
+ *
4
+ * Where this is enforced: the only point in the system that ever sees a
5
+ * management password in plaintext is where one is *minted* — the
6
+ * `scripts/hash-password.mjs` helper. The users file stores scrypt
7
+ * hashes only, so password strength cannot be re-evaluated at load time
8
+ * (there is no plaintext to check), and there is no runtime endpoint that
9
+ * accepts a plaintext password to set. This module is therefore the
10
+ * canonical, dependency-free policy that the minting path enforces — and
11
+ * that any future "change my password" endpoint should call before
12
+ * hashing. Login (`verifyPassword`) never re-checks policy: that would
13
+ * lock out users whose passwords predate a tightened policy.
14
+ *
15
+ * "Basic" ruleset: a minimum length, a minimum number of character
16
+ * classes, a small builtin common-password denylist, and a guard against
17
+ * passwords that just echo the username. Deliberately small — this is a
18
+ * footgun guard for self-hosted operators, not a compliance engine.
19
+ */
20
+ export const DEFAULT_PASSWORD_POLICY = {
21
+ minLength: 12,
22
+ maxLength: 1024,
23
+ minClasses: 3,
24
+ denylistEnabled: true,
25
+ };
26
+ /**
27
+ * A small list of the most-abused passwords + obvious app-specific ones.
28
+ * Not exhaustive — the real defenders are length + classes. Lowercased;
29
+ * comparison is case-insensitive.
30
+ */
31
+ export const COMMON_PASSWORD_DENYLIST = new Set([
32
+ "password", "password1", "password123", "passw0rd", "p@ssw0rd", "p@ssword",
33
+ "123456", "1234567", "12345678", "123456789", "1234567890", "12345",
34
+ "qwerty", "qwerty123", "qwertyuiop", "asdfghjkl", "1q2w3e4r", "1qaz2wsx",
35
+ "letmein", "welcome", "welcome1", "admin", "admin123", "administrator",
36
+ "root", "toor", "changeme", "default", "guest", "iloveyou", "monkey",
37
+ "dragon", "sunshine", "princess", "football", "baseball", "abc123",
38
+ "654321", "111111", "000000", "superman", "trustno1", "master",
39
+ "hello123", "secret", "test", "test123", "user", "login", "passport",
40
+ "observability", "observability-mcp", "prometheus", "grafana", "loki",
41
+ ]);
42
+ /**
43
+ * Count distinct character classes present. Classification is ASCII-based:
44
+ * a-z, A-Z, 0-9, and "everything else" (symbol). Non-ASCII letters (é, ü,
45
+ * emoji, CJK) therefore count as the symbol class, never lower/upper. This
46
+ * is deliberately conservative — it can only ever under-count classes, so
47
+ * it never lets a weaker password through than the rule implies.
48
+ */
49
+ function countClasses(pw) {
50
+ let lower = false, upper = false, digit = false, symbol = false;
51
+ for (const ch of pw) {
52
+ if (ch >= "a" && ch <= "z")
53
+ lower = true;
54
+ else if (ch >= "A" && ch <= "Z")
55
+ upper = true;
56
+ else if (ch >= "0" && ch <= "9")
57
+ digit = true;
58
+ else
59
+ symbol = true;
60
+ }
61
+ return Number(lower) + Number(upper) + Number(digit) + Number(symbol);
62
+ }
63
+ /** Length in code points (so emoji / multibyte count as one). */
64
+ function codePointLength(s) {
65
+ let n = 0;
66
+ for (const _ of s)
67
+ n++;
68
+ return n;
69
+ }
70
+ /**
71
+ * Validate a plaintext password against the policy. `username`, when
72
+ * given, additionally rejects passwords that are (or merely contain) the
73
+ * username — the single most common weak choice.
74
+ */
75
+ export function validatePassword(password, policy = DEFAULT_PASSWORD_POLICY, username) {
76
+ const errors = [];
77
+ const len = codePointLength(password);
78
+ if (len < policy.minLength) {
79
+ errors.push(`must be at least ${policy.minLength} characters (got ${len})`);
80
+ }
81
+ if (len > policy.maxLength) {
82
+ errors.push(`must be at most ${policy.maxLength} characters`);
83
+ }
84
+ if (policy.minClasses > 1) {
85
+ const classes = countClasses(password);
86
+ if (classes < policy.minClasses) {
87
+ errors.push(`must mix at least ${policy.minClasses} of: lowercase, uppercase, digit, symbol (got ${classes})`);
88
+ }
89
+ }
90
+ if (policy.denylistEnabled && COMMON_PASSWORD_DENYLIST.has(password.toLowerCase())) {
91
+ errors.push("is on the common-password denylist");
92
+ }
93
+ if (username) {
94
+ const u = username.toLowerCase();
95
+ const p = password.toLowerCase();
96
+ if (u.length >= 3 && p.includes(u)) {
97
+ errors.push("must not contain the username");
98
+ }
99
+ }
100
+ return { ok: errors.length === 0, errors };
101
+ }
102
+ /** Resolve the policy from env, falling back to the defaults. */
103
+ export function passwordPolicyFromEnv(env = process.env) {
104
+ const num = (raw, fallback) => {
105
+ if (raw === undefined)
106
+ return fallback;
107
+ const n = Number(raw);
108
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
109
+ };
110
+ const truthy = (raw) => {
111
+ const v = raw?.trim().toLowerCase();
112
+ return v === "1" || v === "true" || v === "yes";
113
+ };
114
+ return {
115
+ minLength: num(env.OMCP_PASSWORD_MIN_LENGTH, DEFAULT_PASSWORD_POLICY.minLength),
116
+ maxLength: DEFAULT_PASSWORD_POLICY.maxLength,
117
+ minClasses: num(env.OMCP_PASSWORD_MIN_CLASSES, DEFAULT_PASSWORD_POLICY.minClasses),
118
+ denylistEnabled: !truthy(env.OMCP_PASSWORD_DENYLIST_DISABLED),
119
+ };
120
+ }
121
+ /** True when the policy is turned off entirely via env. */
122
+ export function passwordPolicyDisabledFromEnv(env = process.env) {
123
+ const v = env.OMCP_PASSWORD_POLICY_DISABLED?.trim().toLowerCase();
124
+ return v === "1" || v === "true" || v === "yes";
125
+ }
@@ -0,0 +1 @@
1
+ export {};