@thotischner/observability-mcp 1.7.1 → 1.8.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.
- package/config/products.yaml.example +48 -0
- package/dist/audit/log.d.ts +99 -0
- package/dist/audit/log.js +180 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/auth/credentials.d.ts +18 -0
- package/dist/auth/credentials.js +26 -1
- package/dist/auth/credentials.test.js +26 -1
- package/dist/auth/local-users.d.ts +62 -0
- package/dist/auth/local-users.js +143 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +80 -0
- package/dist/auth/middleware.d.ts +48 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +124 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/runtime.d.ts +63 -0
- package/dist/auth/oidc/runtime.js +129 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +180 -0
- package/dist/auth/policy/engine.d.ts +48 -0
- package/dist/auth/policy/engine.js +73 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +35 -0
- package/dist/auth/policy/loader.js +100 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +162 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +158 -0
- package/dist/auth/rbac.d.ts +40 -0
- package/dist/auth/rbac.js +120 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +121 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/context.d.ts +13 -1
- package/dist/context.js +5 -1
- package/dist/index.js +1012 -29
- package/dist/net/egress-policy.js +2 -0
- package/dist/openapi.js +440 -0
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +64 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/products/loader.d.ts +84 -0
- package/dist/products/loader.js +216 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +168 -0
- package/dist/quota/limiter.d.ts +72 -0
- package/dist/quota/limiter.js +105 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +119 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/ui/index.html +1454 -88
- package/package.json +20 -3
|
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
|
|
|
11
11
|
it("parses name:token and bare token", () => {
|
|
12
12
|
const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
|
|
13
13
|
assert.equal(creds.length, 2);
|
|
14
|
-
assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined });
|
|
14
|
+
assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, tenant: undefined });
|
|
15
15
|
assert.equal(creds[1].name, "key");
|
|
16
16
|
assert.equal(creds[1].token, "tok_bare");
|
|
17
17
|
});
|
|
@@ -23,6 +23,31 @@ describe("single-tenant auth primitive", () => {
|
|
|
23
23
|
assert.deepEqual(creds[0].allowedSources, ["prom-prod", "loki-prod"]);
|
|
24
24
|
assert.deepEqual(creds[1].allowedSources, ["prom-staging"]);
|
|
25
25
|
});
|
|
26
|
+
it("parses OMCP_KEY_BYPASS_REDACTION → flags only the listed names", () => {
|
|
27
|
+
const creds = loadCredentials({
|
|
28
|
+
OMCP_API_KEYS: "agent:tok1,ci:tok2,unprivileged:tok3",
|
|
29
|
+
OMCP_KEY_BYPASS_REDACTION: "agent, ci",
|
|
30
|
+
});
|
|
31
|
+
assert.equal(creds.find((c) => c.name === "agent")?.bypassRedaction, true);
|
|
32
|
+
assert.equal(creds.find((c) => c.name === "ci")?.bypassRedaction, true);
|
|
33
|
+
// Unlisted keys MUST be undefined (not false) so JSON serialisation
|
|
34
|
+
// omits the field — keeps the audit log payload tidy.
|
|
35
|
+
assert.equal(creds.find((c) => c.name === "unprivileged")?.bypassRedaction, undefined);
|
|
36
|
+
});
|
|
37
|
+
it("OMCP_KEY_BYPASS_REDACTION absent → no key bypasses (least privilege default)", () => {
|
|
38
|
+
const creds = loadCredentials({ OMCP_API_KEYS: "agent:tok1,ci:tok2" });
|
|
39
|
+
for (const c of creds)
|
|
40
|
+
assert.equal(c.bypassRedaction, undefined);
|
|
41
|
+
});
|
|
42
|
+
it("parses OMCP_KEY_TENANTS → assigns tenant to named keys; unlisted stays undefined (default)", () => {
|
|
43
|
+
const creds = loadCredentials({
|
|
44
|
+
OMCP_API_KEYS: "agent:tok1,ci:tok2,nobody:tok3",
|
|
45
|
+
OMCP_KEY_TENANTS: "agent=acme;ci=BigCorp",
|
|
46
|
+
});
|
|
47
|
+
assert.equal(creds.find((c) => c.name === "agent")?.tenant, "acme");
|
|
48
|
+
assert.equal(creds.find((c) => c.name === "ci")?.tenant, "bigcorp", "lowercased");
|
|
49
|
+
assert.equal(creds.find((c) => c.name === "nobody")?.tenant, undefined);
|
|
50
|
+
});
|
|
26
51
|
it("extractToken handles Bearer and X-API-Key", () => {
|
|
27
52
|
assert.equal(extractToken({ authorization: "Bearer abc" }), "abc");
|
|
28
53
|
assert.equal(extractToken({ authorization: "bearer xyz " }), "xyz");
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed local user store for the management-plane "basic" auth mode.
|
|
3
|
+
*
|
|
4
|
+
* Password verification uses node's built-in `scrypt` so the server has no
|
|
5
|
+
* extra runtime dependency. The on-disk format is a small JSON document:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "users": [
|
|
9
|
+
* {
|
|
10
|
+
* "username": "alice",
|
|
11
|
+
* "name": "Alice Operator",
|
|
12
|
+
* "roles": ["operator"],
|
|
13
|
+
* "passwordHash": "scrypt$N$r$p$<salt-b64>$<hash-b64>"
|
|
14
|
+
* },
|
|
15
|
+
* ...
|
|
16
|
+
* ]
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The `passwordHash` field uses the PHC-like format `scrypt$N$r$p$salt$hash`,
|
|
20
|
+
* which encodes the cost parameters alongside the digest so operators can
|
|
21
|
+
* rotate them without breaking existing entries.
|
|
22
|
+
*
|
|
23
|
+
* Use `hashPassword()` to mint a new entry, e.g. from a one-shot CLI helper.
|
|
24
|
+
*/
|
|
25
|
+
export interface LocalUser {
|
|
26
|
+
username: string;
|
|
27
|
+
name: string;
|
|
28
|
+
roles?: string[];
|
|
29
|
+
/** Optional tenant assignment. Missing → DEFAULT_TENANT. */
|
|
30
|
+
tenant?: string;
|
|
31
|
+
passwordHash: string;
|
|
32
|
+
}
|
|
33
|
+
export interface LocalUsersFile {
|
|
34
|
+
users: LocalUser[];
|
|
35
|
+
}
|
|
36
|
+
/** Default scrypt cost — N=2^15, r=8, p=1. Matches OWASP 2023 baseline. */
|
|
37
|
+
export declare const DEFAULT_SCRYPT_N: number;
|
|
38
|
+
export declare const DEFAULT_SCRYPT_R = 8;
|
|
39
|
+
export declare const DEFAULT_SCRYPT_P = 1;
|
|
40
|
+
/** Produce a `scrypt$…` formatted hash for the given plaintext. */
|
|
41
|
+
export declare function hashPassword(plaintext: string, opts?: {
|
|
42
|
+
N?: number;
|
|
43
|
+
r?: number;
|
|
44
|
+
p?: number;
|
|
45
|
+
}): string;
|
|
46
|
+
/** Upper bounds on scrypt cost parameters accepted during verify.
|
|
47
|
+
* The users file is operator-controlled, but an accidental typo
|
|
48
|
+
* ("N=21474836480") shouldn't be able to hang the auth path. The
|
|
49
|
+
* caps are well above any realistic production setting. */
|
|
50
|
+
export declare const MAX_SCRYPT_N: number;
|
|
51
|
+
export declare const MAX_SCRYPT_R = 16;
|
|
52
|
+
export declare const MAX_SCRYPT_P = 4;
|
|
53
|
+
/** Constant-time verify of a plaintext against a `scrypt$…` hash. */
|
|
54
|
+
export declare function verifyPassword(plaintext: string, encoded: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Read + parse the users file. Returns `null` (not throws) when the file
|
|
57
|
+
* doesn't exist or the JSON is malformed so the caller can fall through to
|
|
58
|
+
* anonymous mode cleanly.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readUsersFile(path: string): Promise<LocalUsersFile | null>;
|
|
61
|
+
/** Find a user by username (case-sensitive) and verify the supplied password. */
|
|
62
|
+
export declare function authenticate(username: string, password: string, store: LocalUsersFile): LocalUser | null;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed local user store for the management-plane "basic" auth mode.
|
|
3
|
+
*
|
|
4
|
+
* Password verification uses node's built-in `scrypt` so the server has no
|
|
5
|
+
* extra runtime dependency. The on-disk format is a small JSON document:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "users": [
|
|
9
|
+
* {
|
|
10
|
+
* "username": "alice",
|
|
11
|
+
* "name": "Alice Operator",
|
|
12
|
+
* "roles": ["operator"],
|
|
13
|
+
* "passwordHash": "scrypt$N$r$p$<salt-b64>$<hash-b64>"
|
|
14
|
+
* },
|
|
15
|
+
* ...
|
|
16
|
+
* ]
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The `passwordHash` field uses the PHC-like format `scrypt$N$r$p$salt$hash`,
|
|
20
|
+
* which encodes the cost parameters alongside the digest so operators can
|
|
21
|
+
* rotate them without breaking existing entries.
|
|
22
|
+
*
|
|
23
|
+
* Use `hashPassword()` to mint a new entry, e.g. from a one-shot CLI helper.
|
|
24
|
+
*/
|
|
25
|
+
import { promises as fs } from "node:fs";
|
|
26
|
+
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
27
|
+
/** Default scrypt cost — N=2^15, r=8, p=1. Matches OWASP 2023 baseline. */
|
|
28
|
+
export const DEFAULT_SCRYPT_N = 1 << 15;
|
|
29
|
+
export const DEFAULT_SCRYPT_R = 8;
|
|
30
|
+
export const DEFAULT_SCRYPT_P = 1;
|
|
31
|
+
const HASH_KEYLEN = 32;
|
|
32
|
+
/** Produce a `scrypt$…` formatted hash for the given plaintext. */
|
|
33
|
+
export function hashPassword(plaintext, opts = {}) {
|
|
34
|
+
const N = opts.N ?? DEFAULT_SCRYPT_N;
|
|
35
|
+
const r = opts.r ?? DEFAULT_SCRYPT_R;
|
|
36
|
+
const p = opts.p ?? DEFAULT_SCRYPT_P;
|
|
37
|
+
const salt = randomBytes(16);
|
|
38
|
+
const hash = scryptSync(plaintext, salt, HASH_KEYLEN, { N, r, p, maxmem: 64 * 1024 * 1024 });
|
|
39
|
+
return `scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
40
|
+
}
|
|
41
|
+
/** Upper bounds on scrypt cost parameters accepted during verify.
|
|
42
|
+
* The users file is operator-controlled, but an accidental typo
|
|
43
|
+
* ("N=21474836480") shouldn't be able to hang the auth path. The
|
|
44
|
+
* caps are well above any realistic production setting. */
|
|
45
|
+
export const MAX_SCRYPT_N = 1 << 20; // 1 048 576 — ~1 second on a modern core
|
|
46
|
+
export const MAX_SCRYPT_R = 16;
|
|
47
|
+
export const MAX_SCRYPT_P = 4;
|
|
48
|
+
/** Constant-time verify of a plaintext against a `scrypt$…` hash. */
|
|
49
|
+
export function verifyPassword(plaintext, encoded) {
|
|
50
|
+
const parts = encoded.split("$");
|
|
51
|
+
if (parts.length !== 6 || parts[0] !== "scrypt")
|
|
52
|
+
return false;
|
|
53
|
+
const N = Number(parts[1]);
|
|
54
|
+
const r = Number(parts[2]);
|
|
55
|
+
const p = Number(parts[3]);
|
|
56
|
+
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p))
|
|
57
|
+
return false;
|
|
58
|
+
if (N <= 0 || r <= 0 || p <= 0)
|
|
59
|
+
return false;
|
|
60
|
+
if (N > MAX_SCRYPT_N || r > MAX_SCRYPT_R || p > MAX_SCRYPT_P)
|
|
61
|
+
return false;
|
|
62
|
+
let salt;
|
|
63
|
+
let expected;
|
|
64
|
+
try {
|
|
65
|
+
salt = Buffer.from(parts[4], "base64");
|
|
66
|
+
expected = Buffer.from(parts[5], "base64");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (expected.length === 0)
|
|
72
|
+
return false;
|
|
73
|
+
let candidate;
|
|
74
|
+
try {
|
|
75
|
+
candidate = scryptSync(plaintext, salt, expected.length, { N, r, p, maxmem: 256 * 1024 * 1024 });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (candidate.length !== expected.length)
|
|
81
|
+
return false;
|
|
82
|
+
return timingSafeEqual(candidate, expected);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read + parse the users file. Returns `null` (not throws) when the file
|
|
86
|
+
* doesn't exist or the JSON is malformed so the caller can fall through to
|
|
87
|
+
* anonymous mode cleanly.
|
|
88
|
+
*/
|
|
89
|
+
export async function readUsersFile(path) {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await fs.readFile(path, "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!isUsersFile(parsed))
|
|
105
|
+
return null;
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
function isUsersFile(v) {
|
|
109
|
+
if (!v || typeof v !== "object")
|
|
110
|
+
return false;
|
|
111
|
+
const o = v;
|
|
112
|
+
if (!Array.isArray(o.users))
|
|
113
|
+
return false;
|
|
114
|
+
return o.users.every((u) => {
|
|
115
|
+
if (!u || typeof u !== "object")
|
|
116
|
+
return false;
|
|
117
|
+
const r = u;
|
|
118
|
+
if (typeof r.username !== "string" || !r.username)
|
|
119
|
+
return false;
|
|
120
|
+
if (typeof r.name !== "string")
|
|
121
|
+
return false;
|
|
122
|
+
if (typeof r.passwordHash !== "string")
|
|
123
|
+
return false;
|
|
124
|
+
if (r.roles !== undefined && !(Array.isArray(r.roles) && r.roles.every((x) => typeof x === "string")))
|
|
125
|
+
return false;
|
|
126
|
+
if (r.tenant !== undefined && typeof r.tenant !== "string")
|
|
127
|
+
return false;
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Find a user by username (case-sensitive) and verify the supplied password. */
|
|
132
|
+
export function authenticate(username, password, store) {
|
|
133
|
+
const u = store.users.find((x) => x.username === username);
|
|
134
|
+
if (!u) {
|
|
135
|
+
// Spend roughly the same time as a real verify so a missing username
|
|
136
|
+
// isn't trivially distinguishable by response timing.
|
|
137
|
+
verifyPassword(password, "scrypt$32768$8$1$AAAA$AAAA");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!verifyPassword(password, u.passwordHash))
|
|
141
|
+
return null;
|
|
142
|
+
return u;
|
|
143
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { hashPassword, verifyPassword, readUsersFile, authenticate, } from "./local-users.js";
|
|
7
|
+
// Use a smaller N so the test suite stays under a second even on slow CI runners.
|
|
8
|
+
const fastOpts = { N: 1 << 10, r: 8, p: 1 };
|
|
9
|
+
test("hashPassword + verifyPassword — accepts correct password", () => {
|
|
10
|
+
const hash = hashPassword("hunter2", fastOpts);
|
|
11
|
+
assert.match(hash, /^scrypt\$1024\$8\$1\$/);
|
|
12
|
+
assert.equal(verifyPassword("hunter2", hash), true);
|
|
13
|
+
});
|
|
14
|
+
test("verifyPassword — rejects wrong password", () => {
|
|
15
|
+
const hash = hashPassword("hunter2", fastOpts);
|
|
16
|
+
assert.equal(verifyPassword("hunter3", hash), false);
|
|
17
|
+
});
|
|
18
|
+
test("verifyPassword — rejects malformed hash", () => {
|
|
19
|
+
assert.equal(verifyPassword("anything", ""), false);
|
|
20
|
+
assert.equal(verifyPassword("anything", "plain-text"), false);
|
|
21
|
+
assert.equal(verifyPassword("anything", "argon2$x$y$z"), false);
|
|
22
|
+
assert.equal(verifyPassword("anything", "scrypt$$$$$" /* empty fields */), false);
|
|
23
|
+
assert.equal(verifyPassword("anything", "scrypt$1024$8$1$AAAA$"), false);
|
|
24
|
+
});
|
|
25
|
+
test("verifyPassword — rejects absurd scrypt cost params (DoS guard)", () => {
|
|
26
|
+
// Far above our MAX_SCRYPT_N / R / P caps. Should fail fast (no hash work).
|
|
27
|
+
assert.equal(verifyPassword("x", "scrypt$1073741824$8$1$AAAA$AAAA"), false); // N too big
|
|
28
|
+
assert.equal(verifyPassword("x", "scrypt$32768$1024$1$AAAA$AAAA"), false); // r too big
|
|
29
|
+
assert.equal(verifyPassword("x", "scrypt$32768$8$1024$AAAA$AAAA"), false); // p too big
|
|
30
|
+
});
|
|
31
|
+
test("readUsersFile — returns null when the file is missing or malformed", async () => {
|
|
32
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-users-"));
|
|
33
|
+
try {
|
|
34
|
+
assert.equal(await readUsersFile(join(dir, "nope.json")), null);
|
|
35
|
+
const bad = join(dir, "bad.json");
|
|
36
|
+
await writeFile(bad, "not json", "utf8");
|
|
37
|
+
assert.equal(await readUsersFile(bad), null);
|
|
38
|
+
const wrongShape = join(dir, "wrong.json");
|
|
39
|
+
await writeFile(wrongShape, JSON.stringify({ users: "string-not-array" }), "utf8");
|
|
40
|
+
assert.equal(await readUsersFile(wrongShape), null);
|
|
41
|
+
const missingFields = join(dir, "missing.json");
|
|
42
|
+
await writeFile(missingFields, JSON.stringify({ users: [{ username: "alice" }] }), "utf8");
|
|
43
|
+
assert.equal(await readUsersFile(missingFields), null);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await rm(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
test("authenticate — returns user on correct credentials", () => {
|
|
50
|
+
const store = {
|
|
51
|
+
users: [
|
|
52
|
+
{
|
|
53
|
+
username: "alice",
|
|
54
|
+
name: "Alice",
|
|
55
|
+
roles: ["operator"],
|
|
56
|
+
passwordHash: hashPassword("hunter2", fastOpts),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
const u = authenticate("alice", "hunter2", store);
|
|
61
|
+
assert.ok(u);
|
|
62
|
+
assert.equal(u.username, "alice");
|
|
63
|
+
assert.deepEqual(u.roles, ["operator"]);
|
|
64
|
+
});
|
|
65
|
+
test("authenticate — returns null for unknown user", () => {
|
|
66
|
+
const store = { users: [] };
|
|
67
|
+
assert.equal(authenticate("nobody", "x", store), null);
|
|
68
|
+
});
|
|
69
|
+
test("authenticate — returns null for wrong password", () => {
|
|
70
|
+
const store = {
|
|
71
|
+
users: [
|
|
72
|
+
{
|
|
73
|
+
username: "alice",
|
|
74
|
+
name: "Alice",
|
|
75
|
+
passwordHash: hashPassword("hunter2", fastOpts),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
assert.equal(authenticate("alice", "wrong", store), null);
|
|
80
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware wiring for the management-plane auth mode.
|
|
3
|
+
*
|
|
4
|
+
* Split into two pieces by design — the cookie-parsing middleware always
|
|
5
|
+
* runs (so identity-aware handlers like /api/me always see req.session),
|
|
6
|
+
* and the protected-route gate is mounted explicitly on the routes that
|
|
7
|
+
* need it. There is no `if (publicPath) next()` shortcut anywhere — the
|
|
8
|
+
* decision of "what is public" is encoded by which middleware Express
|
|
9
|
+
* registers on which route, not by a string match at request time.
|
|
10
|
+
*
|
|
11
|
+
* When `OMCP_AUTH` is unset or "anonymous" (the default) both middlewares
|
|
12
|
+
* are no-ops and every existing handler behaves exactly as before.
|
|
13
|
+
*/
|
|
14
|
+
import type { Request, RequestHandler } from "express";
|
|
15
|
+
import { type SessionPayload, type SessionConfig } from "./session.js";
|
|
16
|
+
export type AuthMode = "anonymous" | "basic" | "oidc";
|
|
17
|
+
export interface AuthRuntime {
|
|
18
|
+
mode: AuthMode;
|
|
19
|
+
/** Present when mode is "basic" or "oidc" — both mint OMCP session
|
|
20
|
+
* cookies the same way, just sourced from local creds vs. an IdP. */
|
|
21
|
+
session?: SessionConfig;
|
|
22
|
+
/** When true and `secret` not provided, the server generated one for this
|
|
23
|
+
* process — sessions will not survive a restart. The wire-up code logs a
|
|
24
|
+
* warning once when this happens. */
|
|
25
|
+
secretEphemeral?: boolean;
|
|
26
|
+
/** OIDC runtime, present only when mode === "oidc". Opaque to this
|
|
27
|
+
* module — the OIDC HTTP endpoints in src/index.ts consume it.
|
|
28
|
+
* Typed as `unknown` here to avoid importing the OIDC sub-module
|
|
29
|
+
* and pulling its node:crypto dependency into the middleware
|
|
30
|
+
* surface. The OIDC wire-up casts on the way in. */
|
|
31
|
+
oidc?: unknown;
|
|
32
|
+
}
|
|
33
|
+
export interface AuthedRequest extends Request {
|
|
34
|
+
session?: SessionPayload;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort cookie resolver. Attaches `req.session` when present and
|
|
38
|
+
* valid; otherwise leaves it undefined. Always calls `next()`. Mount this
|
|
39
|
+
* globally so every handler can read the identity.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildSessionAttacher(runtime: AuthRuntime): RequestHandler;
|
|
42
|
+
/**
|
|
43
|
+
* Gate. Rejects requests that lack a valid session with HTTP 401 + a JSON
|
|
44
|
+
* body the UI's fetch wrapper recognises. Mount this on each protected
|
|
45
|
+
* route or router, NOT globally — paths the operator wants public
|
|
46
|
+
* (login, /api/me, /api/info, /healthz, ...) simply don't register it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildRequireSession(runtime: AuthRuntime): RequestHandler;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware wiring for the management-plane auth mode.
|
|
3
|
+
*
|
|
4
|
+
* Split into two pieces by design — the cookie-parsing middleware always
|
|
5
|
+
* runs (so identity-aware handlers like /api/me always see req.session),
|
|
6
|
+
* and the protected-route gate is mounted explicitly on the routes that
|
|
7
|
+
* need it. There is no `if (publicPath) next()` shortcut anywhere — the
|
|
8
|
+
* decision of "what is public" is encoded by which middleware Express
|
|
9
|
+
* registers on which route, not by a string match at request time.
|
|
10
|
+
*
|
|
11
|
+
* When `OMCP_AUTH` is unset or "anonymous" (the default) both middlewares
|
|
12
|
+
* are no-ops and every existing handler behaves exactly as before.
|
|
13
|
+
*/
|
|
14
|
+
import { readCookie, verifySession } from "./session.js";
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort cookie resolver. Attaches `req.session` when present and
|
|
17
|
+
* valid; otherwise leaves it undefined. Always calls `next()`. Mount this
|
|
18
|
+
* globally so every handler can read the identity.
|
|
19
|
+
*/
|
|
20
|
+
export function buildSessionAttacher(runtime) {
|
|
21
|
+
const sessionCfg = runtime.session;
|
|
22
|
+
// In anonymous mode the secret is meaningless — verifySession would
|
|
23
|
+
// throw on an empty secret — so install a true no-op middleware and
|
|
24
|
+
// skip every per-request branch.
|
|
25
|
+
if (runtime.mode === "anonymous" || !sessionCfg) {
|
|
26
|
+
return function noopAttacher(_req, _res, next) {
|
|
27
|
+
next();
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return function sessionAttacher(req, _res, next) {
|
|
31
|
+
// verifySession is a pure parse + HMAC verify. It safely returns
|
|
32
|
+
// null on empty / null / malformed input, so call it unconditionally
|
|
33
|
+
// and let the result speak for itself — no attacker-controlled
|
|
34
|
+
// branch decides whether the check runs.
|
|
35
|
+
const raw = readCookie(req.headers.cookie || "");
|
|
36
|
+
const payload = verifySession(raw, sessionCfg);
|
|
37
|
+
if (payload)
|
|
38
|
+
req.session = payload;
|
|
39
|
+
next();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Gate. Rejects requests that lack a valid session with HTTP 401 + a JSON
|
|
44
|
+
* body the UI's fetch wrapper recognises. Mount this on each protected
|
|
45
|
+
* route or router, NOT globally — paths the operator wants public
|
|
46
|
+
* (login, /api/me, /api/info, /healthz, ...) simply don't register it.
|
|
47
|
+
*/
|
|
48
|
+
export function buildRequireSession(runtime) {
|
|
49
|
+
return function requireSession(req, res, next) {
|
|
50
|
+
if (runtime.mode === "anonymous" || !runtime.session) {
|
|
51
|
+
next();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (req.session) {
|
|
55
|
+
next();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.status(401).json({
|
|
59
|
+
error: "authentication required",
|
|
60
|
+
mode: runtime.mode,
|
|
61
|
+
// Recognised by the UI's fetch wrapper to trigger the login modal.
|
|
62
|
+
code: "OMCP_AUTH_REQUIRED",
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildSessionAttacher, buildRequireSession } from "./middleware.js";
|
|
4
|
+
import { issueSession } from "./session.js";
|
|
5
|
+
const secret = "x".repeat(48);
|
|
6
|
+
const sessionCfg = { secret };
|
|
7
|
+
function mkReq(opts = {}) {
|
|
8
|
+
return {
|
|
9
|
+
path: opts.path ?? "/api/sources",
|
|
10
|
+
headers: { cookie: opts.cookieHeader || "" },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function mkRes() {
|
|
14
|
+
let statusCode = 0;
|
|
15
|
+
let body = null;
|
|
16
|
+
return {
|
|
17
|
+
status(c) { statusCode = c; return this; },
|
|
18
|
+
json(b) { body = b; return this; },
|
|
19
|
+
get statusCode() { return statusCode; },
|
|
20
|
+
get body() { return body; },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
test("session attacher — anonymous passes through unchanged, no session", () => {
|
|
24
|
+
const attach = buildSessionAttacher({ mode: "anonymous" });
|
|
25
|
+
const req = mkReq();
|
|
26
|
+
let called = false;
|
|
27
|
+
attach(req, mkRes(), () => { called = true; });
|
|
28
|
+
assert.equal(called, true);
|
|
29
|
+
assert.equal(req.session, undefined);
|
|
30
|
+
});
|
|
31
|
+
test("session attacher — basic mode without cookie still flows, no session attached", () => {
|
|
32
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg });
|
|
33
|
+
const req = mkReq();
|
|
34
|
+
let called = false;
|
|
35
|
+
attach(req, mkRes(), () => { called = true; });
|
|
36
|
+
assert.equal(called, true);
|
|
37
|
+
assert.equal(req.session, undefined);
|
|
38
|
+
});
|
|
39
|
+
test("session attacher — basic mode WITH valid cookie attaches session", () => {
|
|
40
|
+
const { cookie } = issueSession({ sub: "alice", name: "Alice", roles: ["operator"] }, sessionCfg);
|
|
41
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg });
|
|
42
|
+
const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
|
|
43
|
+
let called = false;
|
|
44
|
+
attach(req, mkRes(), () => { called = true; });
|
|
45
|
+
assert.equal(called, true);
|
|
46
|
+
assert.ok(req.session);
|
|
47
|
+
assert.equal(req.session?.sub, "alice");
|
|
48
|
+
});
|
|
49
|
+
test("session attacher — tampered cookie leaves session undefined and still flows", () => {
|
|
50
|
+
const { cookie } = issueSession({ sub: "alice", name: "Alice" }, sessionCfg);
|
|
51
|
+
const tampered = cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A"));
|
|
52
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg });
|
|
53
|
+
const req = mkReq({ cookieHeader: `omcp_session=${tampered}` });
|
|
54
|
+
let called = false;
|
|
55
|
+
attach(req, mkRes(), () => { called = true; });
|
|
56
|
+
assert.equal(called, true);
|
|
57
|
+
assert.equal(req.session, undefined);
|
|
58
|
+
});
|
|
59
|
+
test("require-session — anonymous always allows", () => {
|
|
60
|
+
const gate = buildRequireSession({ mode: "anonymous" });
|
|
61
|
+
const req = mkReq();
|
|
62
|
+
const res = mkRes();
|
|
63
|
+
let called = false;
|
|
64
|
+
gate(req, res, () => { called = true; });
|
|
65
|
+
assert.equal(called, true);
|
|
66
|
+
assert.equal(res.statusCode, 0);
|
|
67
|
+
});
|
|
68
|
+
test("require-session — basic mode without session returns 401", () => {
|
|
69
|
+
const runtime = { mode: "basic", session: sessionCfg };
|
|
70
|
+
const gate = buildRequireSession(runtime);
|
|
71
|
+
const req = mkReq();
|
|
72
|
+
const res = mkRes();
|
|
73
|
+
let called = false;
|
|
74
|
+
gate(req, res, () => { called = true; });
|
|
75
|
+
assert.equal(called, false);
|
|
76
|
+
assert.equal(res.statusCode, 401);
|
|
77
|
+
const body = res.body;
|
|
78
|
+
assert.equal(body.code, "OMCP_AUTH_REQUIRED");
|
|
79
|
+
});
|
|
80
|
+
test("require-session — basic mode WITH attached session flows through", () => {
|
|
81
|
+
const runtime = { mode: "basic", session: sessionCfg };
|
|
82
|
+
const gate = buildRequireSession(runtime);
|
|
83
|
+
const req = mkReq();
|
|
84
|
+
req.session = { sub: "alice", name: "Alice", iat: 0, exp: Date.now() / 1000 + 60 };
|
|
85
|
+
const res = mkRes();
|
|
86
|
+
let called = false;
|
|
87
|
+
gate(req, res, () => { called = true; });
|
|
88
|
+
assert.equal(called, true);
|
|
89
|
+
assert.equal(res.statusCode, 0);
|
|
90
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level OIDC client: glues discovery + JWKS + JWT verify + the
|
|
3
|
+
* auth-code+PKCE token exchange behind a single small surface.
|
|
4
|
+
*
|
|
5
|
+
* Designed to be wired into the existing session middleware in a
|
|
6
|
+
* later slice. This module stays HTTP-framework agnostic — the caller
|
|
7
|
+
* decides how to ferry the state/nonce/code_verifier between the
|
|
8
|
+
* `start()` redirect and the `complete()` callback (we recommend a
|
|
9
|
+
* short-lived signed cookie).
|
|
10
|
+
*/
|
|
11
|
+
import { type DiscoveryDocument, type Fetcher } from "./discovery.js";
|
|
12
|
+
import { type JwtPayload } from "./jwt.js";
|
|
13
|
+
export interface OidcConfig {
|
|
14
|
+
/** Issuer URL — what the IdP advertises in its discovery `issuer` field. */
|
|
15
|
+
issuer: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
/** Confidential clients only. Public/SPA clients omit and rely on PKCE. */
|
|
18
|
+
clientSecret?: string;
|
|
19
|
+
/** Absolute callback URL registered with the IdP. */
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
/** Space-delimited scopes. Default: "openid profile email". */
|
|
22
|
+
scopes?: string;
|
|
23
|
+
/** Custom fetcher (tests). */
|
|
24
|
+
fetcher?: Fetcher;
|
|
25
|
+
/** Test clock. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
export interface StartResult {
|
|
29
|
+
/** Where to 302 the browser. */
|
|
30
|
+
authorizeUrl: string;
|
|
31
|
+
/** Caller stores these in a short-lived cookie until /callback. */
|
|
32
|
+
flow: {
|
|
33
|
+
state: string;
|
|
34
|
+
nonce: string;
|
|
35
|
+
codeVerifier: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface CompleteOpts {
|
|
39
|
+
/** Authorization code from the callback URL. */
|
|
40
|
+
code: string;
|
|
41
|
+
/** State returned by the IdP — must match the cookie's flow.state. */
|
|
42
|
+
state: string;
|
|
43
|
+
/** The flow object the caller stashed in the cookie at start(). */
|
|
44
|
+
flow: {
|
|
45
|
+
state: string;
|
|
46
|
+
nonce: string;
|
|
47
|
+
codeVerifier: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export interface CompleteResult {
|
|
51
|
+
/** Decoded + verified ID-token claims. */
|
|
52
|
+
claims: JwtPayload;
|
|
53
|
+
/** Raw ID token (for upstream propagation if the caller wants it). */
|
|
54
|
+
idToken: string;
|
|
55
|
+
/** Access token (opaque). */
|
|
56
|
+
accessToken?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare class OidcClient {
|
|
59
|
+
private readonly discovery;
|
|
60
|
+
private readonly jwks;
|
|
61
|
+
private readonly cfg;
|
|
62
|
+
private readonly fetcher;
|
|
63
|
+
private readonly now;
|
|
64
|
+
constructor(cfg: OidcConfig);
|
|
65
|
+
/** Build an authorize URL + mint the state/nonce/PKCE-verifier the
|
|
66
|
+
* caller must persist until the callback. */
|
|
67
|
+
start(): Promise<StartResult>;
|
|
68
|
+
/** Validate the callback: state match → token exchange → ID-token
|
|
69
|
+
* signature + claim verification. Throws on any failure. */
|
|
70
|
+
complete(opts: CompleteOpts): Promise<CompleteResult>;
|
|
71
|
+
/** Verify a standalone ID token (refresh flows, replay checks). */
|
|
72
|
+
verify(idToken: string, doc?: DiscoveryDocument, nonce?: string): Promise<JwtPayload>;
|
|
73
|
+
}
|