@thotischner/observability-mcp 3.0.1 → 3.1.1

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 (46) 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/auth/csrf.d.ts +6 -0
  5. package/dist/auth/csrf.js +4 -0
  6. package/dist/auth/csrf.test.js +22 -0
  7. package/dist/auth/lockout.d.ts +72 -0
  8. package/dist/auth/lockout.js +134 -0
  9. package/dist/auth/lockout.test.d.ts +1 -0
  10. package/dist/auth/lockout.test.js +133 -0
  11. package/dist/auth/middleware.d.ts +5 -0
  12. package/dist/auth/middleware.js +6 -1
  13. package/dist/auth/middleware.test.js +31 -0
  14. package/dist/auth/password-policy.d.ts +52 -0
  15. package/dist/auth/password-policy.js +125 -0
  16. package/dist/auth/password-policy.test.d.ts +1 -0
  17. package/dist/auth/password-policy.test.js +111 -0
  18. package/dist/auth/revocation.d.ts +93 -0
  19. package/dist/auth/revocation.js +193 -0
  20. package/dist/auth/revocation.test.d.ts +1 -0
  21. package/dist/auth/revocation.test.js +136 -0
  22. package/dist/auth/session.d.ts +7 -0
  23. package/dist/auth/session.js +6 -0
  24. package/dist/auth/session.test.js +21 -0
  25. package/dist/conformance/mcp-2025-11-25.test.js +14 -0
  26. package/dist/connectors/interface.d.ts +5 -1
  27. package/dist/connectors/loki.d.ts +45 -1
  28. package/dist/connectors/loki.js +141 -8
  29. package/dist/connectors/loki.test.js +171 -1
  30. package/dist/index.js +244 -4
  31. package/dist/openapi.js +39 -0
  32. package/dist/openapi.test.js +1 -0
  33. package/dist/security/csp.d.ts +64 -0
  34. package/dist/security/csp.js +135 -0
  35. package/dist/security/csp.test.d.ts +1 -0
  36. package/dist/security/csp.test.js +97 -0
  37. package/dist/tools/query-logs-schema.test.d.ts +1 -0
  38. package/dist/tools/query-logs-schema.test.js +38 -0
  39. package/dist/tools/query-logs.d.ts +40 -0
  40. package/dist/tools/query-logs.js +69 -3
  41. package/dist/tools/validation.d.ts +13 -0
  42. package/dist/tools/validation.js +74 -0
  43. package/dist/tools/validation.test.js +54 -1
  44. package/dist/types.d.ts +48 -0
  45. package/dist/ui/index.html +42 -15
  46. package/package.json +1 -1
@@ -27,6 +27,14 @@ export interface AnomalyHistoryConfig {
27
27
  requestTimeoutMs?: number;
28
28
  /** Inject fetch for tests. */
29
29
  fetchImpl?: typeof fetch;
30
+ /** In-process ring retention window (ms). Default 3 600 000 (1h).
31
+ * Powers the Health-tab sparkline; independent of remote-write. */
32
+ retentionMs?: number;
33
+ /** Hard cap on ring entries (defends memory under a score storm).
34
+ * Default 5 000. */
35
+ ringMax?: number;
36
+ /** Clock injection for tests. Returns epoch ms. */
37
+ now?: () => number;
30
38
  }
31
39
  export declare function fromEnv(env?: NodeJS.ProcessEnv): AnomalyHistoryConfig;
32
40
  /**
@@ -42,7 +50,14 @@ export declare class AnomalyHistory {
42
50
  private readonly maxBufferSize;
43
51
  private readonly requestTimeoutMs;
44
52
  private readonly fetchImpl;
53
+ private readonly retentionMs;
54
+ private readonly ringMax;
55
+ private readonly nowFn;
45
56
  private buffer;
57
+ /** Bounded, time-windowed in-process tier of the sink — powers the
58
+ * Health-tab sparkline. Captured on every record() regardless of
59
+ * remote-write so the sparkline works out of the box. */
60
+ private ring;
46
61
  private timer?;
47
62
  private flushing;
48
63
  constructor(cfg?: AnomalyHistoryConfig);
@@ -52,9 +67,28 @@ export declare class AnomalyHistory {
52
67
  start(): void;
53
68
  /** Stop the flush timer + flush one last time. */
54
69
  stop(): Promise<void>;
55
- /** Add one anomaly to the buffer. Silently drops when disabled.
56
- * Triggers a synchronous flush if the buffer crosses maxBufferSize. */
70
+ /** Add one anomaly. Always captured into the in-process ring (powers
71
+ * the sparkline); additionally buffered for remote-write only when the
72
+ * sink is enabled. Triggers a synchronous flush past maxBufferSize. */
57
73
  record(entry: AnomalyRecord): Promise<void>;
74
+ /** Append to the ring + evict anything outside the retention window or
75
+ * beyond the hard cap. O(n) prune is fine — anomaly records are
76
+ * infrequent and n is bounded by ringMax. */
77
+ private pushRing;
78
+ /**
79
+ * Recent records from the in-process ring, oldest-first. Filters by
80
+ * service and/or tenant when supplied and drops anything older than the
81
+ * retention window. Returns a fresh array (safe for the caller to map).
82
+ */
83
+ recent(opts?: {
84
+ service?: string;
85
+ tenant?: string;
86
+ }): AnomalyRecord[];
87
+ /** Distinct services with at least one record in the retention window,
88
+ * optionally tenant-scoped. */
89
+ recentServices(tenant?: string): string[];
90
+ /** Retention window in ms — surfaced so the API/UI can label the range. */
91
+ get windowMs(): number;
58
92
  /** Send the current buffer to the remote-write endpoint. Drops the
59
93
  * buffer on success OR failure — history is best-effort. */
60
94
  flush(): Promise<void>;
@@ -45,7 +45,14 @@ export class AnomalyHistory {
45
45
  maxBufferSize;
46
46
  requestTimeoutMs;
47
47
  fetchImpl;
48
+ retentionMs;
49
+ ringMax;
50
+ nowFn;
48
51
  buffer = [];
52
+ /** Bounded, time-windowed in-process tier of the sink — powers the
53
+ * Health-tab sparkline. Captured on every record() regardless of
54
+ * remote-write so the sparkline works out of the box. */
55
+ ring = [];
49
56
  timer;
50
57
  flushing = false;
51
58
  constructor(cfg = {}) {
@@ -56,6 +63,9 @@ export class AnomalyHistory {
56
63
  this.maxBufferSize = cfg.maxBufferSize ?? 500;
57
64
  this.requestTimeoutMs = cfg.requestTimeoutMs ?? 5_000;
58
65
  this.fetchImpl = cfg.fetchImpl ?? fetch;
66
+ this.retentionMs = cfg.retentionMs ?? 60 * 60 * 1000;
67
+ this.ringMax = cfg.ringMax ?? 5_000;
68
+ this.nowFn = cfg.now ?? Date.now;
59
69
  }
60
70
  /** Whether the history sink is enabled (URL configured). */
61
71
  isEnabled() {
@@ -84,9 +94,11 @@ export class AnomalyHistory {
84
94
  if (this.isEnabled())
85
95
  await this.flush().catch(() => undefined);
86
96
  }
87
- /** Add one anomaly to the buffer. Silently drops when disabled.
88
- * Triggers a synchronous flush if the buffer crosses maxBufferSize. */
97
+ /** Add one anomaly. Always captured into the in-process ring (powers
98
+ * the sparkline); additionally buffered for remote-write only when the
99
+ * sink is enabled. Triggers a synchronous flush past maxBufferSize. */
89
100
  async record(entry) {
101
+ this.pushRing(entry);
90
102
  if (!this.isEnabled())
91
103
  return;
92
104
  this.buffer.push(entry);
@@ -94,6 +106,52 @@ export class AnomalyHistory {
94
106
  await this.flush().catch(() => undefined);
95
107
  }
96
108
  }
109
+ /** Append to the ring + evict anything outside the retention window or
110
+ * beyond the hard cap. O(n) prune is fine — anomaly records are
111
+ * infrequent and n is bounded by ringMax. */
112
+ pushRing(entry) {
113
+ this.ring.push(entry);
114
+ const cutoff = this.nowFn() - this.retentionMs;
115
+ this.ring = this.ring.filter((r) => {
116
+ const t = Date.parse(r.ts);
117
+ return Number.isFinite(t) && t >= cutoff;
118
+ });
119
+ if (this.ring.length > this.ringMax) {
120
+ this.ring = this.ring.slice(this.ring.length - this.ringMax);
121
+ }
122
+ }
123
+ /**
124
+ * Recent records from the in-process ring, oldest-first. Filters by
125
+ * service and/or tenant when supplied and drops anything older than the
126
+ * retention window. Returns a fresh array (safe for the caller to map).
127
+ */
128
+ recent(opts = {}) {
129
+ const cutoff = this.nowFn() - this.retentionMs;
130
+ return this.ring
131
+ .filter((r) => {
132
+ const t = Date.parse(r.ts);
133
+ if (!Number.isFinite(t) || t < cutoff)
134
+ return false;
135
+ if (opts.service && r.service !== opts.service)
136
+ return false;
137
+ if (opts.tenant && r.tenant !== opts.tenant)
138
+ return false;
139
+ return true;
140
+ })
141
+ .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
142
+ }
143
+ /** Distinct services with at least one record in the retention window,
144
+ * optionally tenant-scoped. */
145
+ recentServices(tenant) {
146
+ const seen = new Set();
147
+ for (const r of this.recent({ tenant }))
148
+ seen.add(r.service);
149
+ return [...seen];
150
+ }
151
+ /** Retention window in ms — surfaced so the API/UI can label the range. */
152
+ get windowMs() {
153
+ return this.retentionMs;
154
+ }
97
155
  /** Send the current buffer to the remote-write endpoint. Drops the
98
156
  * buffer on success OR failure — history is best-effort. */
99
157
  async flush() {
@@ -71,6 +71,52 @@ test("flush: bearer token forwarded as Authorization header", async () => {
71
71
  await h.flush();
72
72
  assert.equal(captured.headers["authorization"], "Bearer tok-abc");
73
73
  });
74
+ // --- Q21: in-process ring (Health-tab sparkline) ----------------------
75
+ const NOW = Date.parse("2026-06-06T12:00:00.000Z");
76
+ function at(msAgo) {
77
+ return new Date(NOW - msAgo).toISOString();
78
+ }
79
+ test("ring captures records even when remote-write is disabled", async () => {
80
+ const h = new AnomalyHistory({ now: () => NOW });
81
+ assert.equal(h.isEnabled(), false);
82
+ await h.record(entry({ ts: at(0), score: 0.5 }));
83
+ const recent = h.recent();
84
+ assert.equal(recent.length, 1);
85
+ assert.equal(recent[0].score, 0.5);
86
+ // ...but the remote-write buffer stays empty when disabled.
87
+ assert.equal(h.bufferSize(), 0);
88
+ });
89
+ test("recent: drops records outside the retention window", async () => {
90
+ const h = new AnomalyHistory({ now: () => NOW, retentionMs: 60 * 60 * 1000 });
91
+ await h.record(entry({ ts: at(30 * 60 * 1000) })); // 30m ago — kept
92
+ await h.record(entry({ ts: at(90 * 60 * 1000) })); // 90m ago — evicted on next push/prune
93
+ const recent = h.recent();
94
+ assert.equal(recent.length, 1);
95
+ assert.equal(recent[0].ts, at(30 * 60 * 1000));
96
+ });
97
+ test("recent: oldest-first and filterable by service + tenant", async () => {
98
+ const h = new AnomalyHistory({ now: () => NOW });
99
+ await h.record(entry({ ts: at(3000), service: "payment", tenant: "a", score: 0.1 }));
100
+ await h.record(entry({ ts: at(1000), service: "payment", tenant: "a", score: 0.3 }));
101
+ await h.record(entry({ ts: at(2000), service: "orders", tenant: "a", score: 0.2 }));
102
+ await h.record(entry({ ts: at(500), service: "payment", tenant: "b", score: 0.9 }));
103
+ const pay = h.recent({ service: "payment", tenant: "a" });
104
+ assert.deepEqual(pay.map((r) => r.score), [0.1, 0.3], "oldest-first, service+tenant filtered");
105
+ const tenantA = h.recentServices("a").sort();
106
+ assert.deepEqual(tenantA, ["orders", "payment"]);
107
+ assert.deepEqual(h.recentServices("b"), ["payment"]);
108
+ });
109
+ test("ring honours the hard cap (ringMax)", async () => {
110
+ const h = new AnomalyHistory({ now: () => NOW, ringMax: 3 });
111
+ for (let i = 0; i < 10; i++)
112
+ await h.record(entry({ ts: at(10_000 - i), score: i / 10 }));
113
+ const recent = h.recent();
114
+ assert.equal(recent.length, 3, "ring capped at ringMax");
115
+ });
116
+ test("windowMs surfaces the retention window", () => {
117
+ assert.equal(new AnomalyHistory({ retentionMs: 1234 }).windowMs, 1234);
118
+ assert.equal(new AnomalyHistory({}).windowMs, 60 * 60 * 1000);
119
+ });
74
120
  test("flush: clears the buffer on success", async () => {
75
121
  const h = new AnomalyHistory({
76
122
  url: "https://tsdb/api/v1/write",
@@ -13,6 +13,12 @@ export interface CsrfConfig {
13
13
  /** Set cookies with `Secure` flag. Default mirrors the existing
14
14
  * session-cookie behaviour: only when the request is on https. */
15
15
  secureCookie: (req: Request) => boolean;
16
+ /** Optional predicate to exempt specific requests from CSRF entirely.
17
+ * Used for unauthenticated browser-initiated POSTs that can't carry a
18
+ * token by construction — e.g. CSP violation reports, which the browser
19
+ * sends with no credentials and no custom headers. Keep this list
20
+ * minimal: an exempt endpoint must be safe to accept cross-site. */
21
+ skip?: (req: Request) => boolean;
16
22
  }
17
23
  export declare function csrfBypassFromEnv(env?: NodeJS.ProcessEnv): boolean;
18
24
  /** Issue a fresh token cookie if the request doesn't already carry a
package/dist/auth/csrf.js CHANGED
@@ -69,6 +69,10 @@ export function buildCsrfEnforcer(cfg) {
69
69
  next();
70
70
  return;
71
71
  }
72
+ if (cfg.skip?.(req)) {
73
+ next();
74
+ return;
75
+ }
72
76
  if (cfg.bypassBearer) {
73
77
  const auth = req.headers["authorization"];
74
78
  if (typeof auth === "string" && /^Bearer\s+/i.test(auth)) {
@@ -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
  }