@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.
- package/dist/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/policy/batch-dry-run.js +15 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +522 -67
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +898 -116
- package/package.json +1 -1
package/dist/auth/csrf.test.js
CHANGED
|
@@ -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;
|
package/dist/auth/middleware.js
CHANGED
|
@@ -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
|
-
|
|
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 {};
|