@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.
Files changed (105) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/audit/log.d.ts +99 -0
  3. package/dist/audit/log.js +180 -0
  4. package/dist/audit/log.test.d.ts +1 -0
  5. package/dist/audit/log.test.js +147 -0
  6. package/dist/audit/middleware.d.ts +20 -0
  7. package/dist/audit/middleware.js +50 -0
  8. package/dist/auth/credentials.d.ts +18 -0
  9. package/dist/auth/credentials.js +26 -1
  10. package/dist/auth/credentials.test.js +26 -1
  11. package/dist/auth/local-users.d.ts +62 -0
  12. package/dist/auth/local-users.js +143 -0
  13. package/dist/auth/local-users.test.d.ts +1 -0
  14. package/dist/auth/local-users.test.js +80 -0
  15. package/dist/auth/middleware.d.ts +48 -0
  16. package/dist/auth/middleware.js +65 -0
  17. package/dist/auth/middleware.test.d.ts +1 -0
  18. package/dist/auth/middleware.test.js +90 -0
  19. package/dist/auth/oidc/client.d.ts +73 -0
  20. package/dist/auth/oidc/client.js +104 -0
  21. package/dist/auth/oidc/client.test.d.ts +1 -0
  22. package/dist/auth/oidc/client.test.js +121 -0
  23. package/dist/auth/oidc/discovery.d.ts +38 -0
  24. package/dist/auth/oidc/discovery.js +48 -0
  25. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  26. package/dist/auth/oidc/discovery.test.js +68 -0
  27. package/dist/auth/oidc/endpoints.d.ts +20 -0
  28. package/dist/auth/oidc/endpoints.js +124 -0
  29. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  30. package/dist/auth/oidc/endpoints.test.js +304 -0
  31. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  32. package/dist/auth/oidc/flow-cookie.js +142 -0
  33. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  34. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  35. package/dist/auth/oidc/index.d.ts +7 -0
  36. package/dist/auth/oidc/index.js +6 -0
  37. package/dist/auth/oidc/jwks.d.ts +36 -0
  38. package/dist/auth/oidc/jwks.js +69 -0
  39. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  40. package/dist/auth/oidc/jwks.test.js +65 -0
  41. package/dist/auth/oidc/jwt.d.ts +62 -0
  42. package/dist/auth/oidc/jwt.js +113 -0
  43. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  44. package/dist/auth/oidc/jwt.test.js +141 -0
  45. package/dist/auth/oidc/pkce.d.ts +19 -0
  46. package/dist/auth/oidc/pkce.js +43 -0
  47. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  48. package/dist/auth/oidc/pkce.test.js +55 -0
  49. package/dist/auth/oidc/runtime.d.ts +63 -0
  50. package/dist/auth/oidc/runtime.js +129 -0
  51. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  52. package/dist/auth/oidc/runtime.test.js +180 -0
  53. package/dist/auth/policy/engine.d.ts +48 -0
  54. package/dist/auth/policy/engine.js +73 -0
  55. package/dist/auth/policy/engine.test.d.ts +1 -0
  56. package/dist/auth/policy/engine.test.js +98 -0
  57. package/dist/auth/policy/loader.d.ts +35 -0
  58. package/dist/auth/policy/loader.js +100 -0
  59. package/dist/auth/policy/opa.d.ts +69 -0
  60. package/dist/auth/policy/opa.js +162 -0
  61. package/dist/auth/policy/opa.test.d.ts +1 -0
  62. package/dist/auth/policy/opa.test.js +158 -0
  63. package/dist/auth/rbac.d.ts +40 -0
  64. package/dist/auth/rbac.js +120 -0
  65. package/dist/auth/rbac.test.d.ts +1 -0
  66. package/dist/auth/rbac.test.js +121 -0
  67. package/dist/auth/session.d.ts +66 -0
  68. package/dist/auth/session.js +146 -0
  69. package/dist/auth/session.test.d.ts +1 -0
  70. package/dist/auth/session.test.js +90 -0
  71. package/dist/catalog/loader.d.ts +67 -0
  72. package/dist/catalog/loader.js +122 -0
  73. package/dist/catalog/loader.test.d.ts +1 -0
  74. package/dist/catalog/loader.test.js +108 -0
  75. package/dist/context.d.ts +13 -1
  76. package/dist/context.js +5 -1
  77. package/dist/index.js +1012 -29
  78. package/dist/net/egress-policy.js +2 -0
  79. package/dist/openapi.js +440 -0
  80. package/dist/openapi.test.d.ts +1 -0
  81. package/dist/openapi.test.js +64 -0
  82. package/dist/policy/redact.d.ts +44 -0
  83. package/dist/policy/redact.js +144 -0
  84. package/dist/policy/redact.test.d.ts +1 -0
  85. package/dist/policy/redact.test.js +172 -0
  86. package/dist/products/loader.d.ts +84 -0
  87. package/dist/products/loader.js +216 -0
  88. package/dist/products/loader.test.d.ts +1 -0
  89. package/dist/products/loader.test.js +168 -0
  90. package/dist/quota/limiter.d.ts +72 -0
  91. package/dist/quota/limiter.js +105 -0
  92. package/dist/quota/limiter.test.d.ts +1 -0
  93. package/dist/quota/limiter.test.js +119 -0
  94. package/dist/quota/token-budget.d.ts +119 -0
  95. package/dist/quota/token-budget.js +297 -0
  96. package/dist/quota/token-budget.test.d.ts +1 -0
  97. package/dist/quota/token-budget.test.js +215 -0
  98. package/dist/tenancy/context.d.ts +45 -0
  99. package/dist/tenancy/context.js +97 -0
  100. package/dist/tenancy/context.test.d.ts +1 -0
  101. package/dist/tenancy/context.test.js +72 -0
  102. package/dist/tenancy/migration.test.d.ts +7 -0
  103. package/dist/tenancy/migration.test.js +75 -0
  104. package/dist/ui/index.html +1454 -88
  105. package/package.json +20 -3
@@ -0,0 +1,65 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { JwksClient } from "./jwks.js";
4
+ const jwks1 = { keys: [{ kty: "RSA", kid: "k-1", n: "abc", e: "AQAB" }] };
5
+ const jwks12 = { keys: [
6
+ { kty: "RSA", kid: "k-1", n: "abc", e: "AQAB" },
7
+ { kty: "RSA", kid: "k-2", n: "def", e: "AQAB" },
8
+ ] };
9
+ test("JwksClient.get — fetches and caches", async () => {
10
+ let calls = 0;
11
+ const client = new JwksClient({
12
+ fetcher: async () => { calls++; return new Response(JSON.stringify(jwks1), { status: 200 }); },
13
+ ttlMs: 60_000,
14
+ });
15
+ await client.get("https://idp.test/jwks");
16
+ await client.get("https://idp.test/jwks");
17
+ assert.equal(calls, 1);
18
+ });
19
+ test("JwksClient.findKey — returns key by kid on first try", async () => {
20
+ const client = new JwksClient({ fetcher: async () => new Response(JSON.stringify(jwks12), { status: 200 }) });
21
+ const key = await client.findKey("https://idp.test/jwks", "k-2");
22
+ assert.equal(key?.kid, "k-2");
23
+ });
24
+ test("JwksClient.findKey — refreshes once on unknown kid (key rotation)", async () => {
25
+ let calls = 0;
26
+ const responses = [jwks1, jwks12]; // First response missing k-2, second has it.
27
+ const client = new JwksClient({
28
+ fetcher: async () => { const body = responses[Math.min(calls, responses.length - 1)]; calls++; return new Response(JSON.stringify(body), { status: 200 }); },
29
+ refreshCooldownMs: 0,
30
+ });
31
+ const key = await client.findKey("https://idp.test/jwks", "k-2");
32
+ assert.equal(calls, 2, "expected one forced refresh after cache miss");
33
+ assert.equal(key?.kid, "k-2");
34
+ });
35
+ test("JwksClient.findKey — respects refresh cooldown on repeated misses", async () => {
36
+ let calls = 0;
37
+ let now = 1_000_000;
38
+ const client = new JwksClient({
39
+ fetcher: async () => { calls++; return new Response(JSON.stringify(jwks1), { status: 200 }); },
40
+ refreshCooldownMs: 60_000,
41
+ now: () => now,
42
+ });
43
+ // First miss → fetch + forced refresh = 2 calls
44
+ await client.findKey("https://idp.test/jwks", "k-unknown");
45
+ assert.equal(calls, 2);
46
+ // Same miss inside cooldown → no further fetch
47
+ await client.findKey("https://idp.test/jwks", "k-unknown");
48
+ assert.equal(calls, 2);
49
+ // After cooldown expires, one more forced refresh
50
+ now += 61_000;
51
+ await client.findKey("https://idp.test/jwks", "k-unknown");
52
+ assert.equal(calls, 3);
53
+ });
54
+ test("JwksClient.get — rejects malformed JWKS body", async () => {
55
+ const client = new JwksClient({
56
+ fetcher: async () => new Response(JSON.stringify({ not: "a jwks" }), { status: 200 }),
57
+ });
58
+ await assert.rejects(client.get("https://idp.test/jwks"), /not a valid JWKS/);
59
+ });
60
+ test("JwksClient.get — rejects HTTP failure", async () => {
61
+ const client = new JwksClient({
62
+ fetcher: async () => new Response("nope", { status: 500 }),
63
+ });
64
+ await assert.rejects(client.get("https://idp.test/jwks"), /HTTP 500/);
65
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Minimal RFC 7519 JWT verifier scoped to OIDC ID tokens.
3
+ *
4
+ * Supports the two signature algorithms every real-world OIDC IdP
5
+ * speaks: RS256 and ES256. HS256 is intentionally excluded — for an
6
+ * OIDC code flow the client never shares an HMAC secret with the IdP.
7
+ * "none" is rejected as a matter of basic hygiene.
8
+ *
9
+ * The verifier does *not* fetch JWKS — it expects a JWK keyset
10
+ * already cached by the caller. See `./jwks.ts` for the cache.
11
+ */
12
+ import { type KeyObject } from "node:crypto";
13
+ export interface Jwk {
14
+ kty: string;
15
+ kid?: string;
16
+ alg?: string;
17
+ use?: string;
18
+ n?: string;
19
+ e?: string;
20
+ crv?: string;
21
+ x?: string;
22
+ y?: string;
23
+ }
24
+ export interface JwtHeader {
25
+ alg: string;
26
+ kid?: string;
27
+ typ?: string;
28
+ }
29
+ export interface JwtPayload {
30
+ iss?: string;
31
+ sub?: string;
32
+ aud?: string | string[];
33
+ exp?: number;
34
+ iat?: number;
35
+ nbf?: number;
36
+ nonce?: string;
37
+ [k: string]: unknown;
38
+ }
39
+ export interface VerifyOpts {
40
+ /** Expected `iss` claim — exact match. */
41
+ issuer: string;
42
+ /** Expected `aud` claim — match if `aud` is a string equal to this,
43
+ * or an array including this. */
44
+ audience: string;
45
+ /** Expected `nonce` — must match the value tied to the auth-code
46
+ * flow's state cookie. */
47
+ nonce?: string;
48
+ /** Clock-skew tolerance in seconds; default 30. */
49
+ clockSkewSec?: number;
50
+ /** Override `now` for tests (ms since epoch). */
51
+ now?: () => number;
52
+ }
53
+ export declare class JwtVerifyError extends Error {
54
+ constructor(msg: string);
55
+ }
56
+ /** Decode base64url to a Buffer (handles missing padding). */
57
+ export declare function b64urlDecode(s: string): Buffer;
58
+ /** Convert a JWK to a node KeyObject for verification. */
59
+ export declare function jwkToKey(jwk: Jwk): KeyObject;
60
+ /** Verify a compact-serialised JWT against a keyset (JWKS `keys` array)
61
+ * and return its payload. Throws JwtVerifyError on any failure. */
62
+ export declare function verifyIdToken(jwt: string, keys: Jwk[], opts: VerifyOpts): JwtPayload;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Minimal RFC 7519 JWT verifier scoped to OIDC ID tokens.
3
+ *
4
+ * Supports the two signature algorithms every real-world OIDC IdP
5
+ * speaks: RS256 and ES256. HS256 is intentionally excluded — for an
6
+ * OIDC code flow the client never shares an HMAC secret with the IdP.
7
+ * "none" is rejected as a matter of basic hygiene.
8
+ *
9
+ * The verifier does *not* fetch JWKS — it expects a JWK keyset
10
+ * already cached by the caller. See `./jwks.ts` for the cache.
11
+ */
12
+ import { createPublicKey, createVerify } from "node:crypto";
13
+ export class JwtVerifyError extends Error {
14
+ constructor(msg) {
15
+ super(msg);
16
+ this.name = "JwtVerifyError";
17
+ }
18
+ }
19
+ /** Decode base64url to a Buffer (handles missing padding). */
20
+ export function b64urlDecode(s) {
21
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
22
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
23
+ }
24
+ function decodeJson(b64) {
25
+ return JSON.parse(b64urlDecode(b64).toString("utf8"));
26
+ }
27
+ /** Convert a JWK to a node KeyObject for verification. */
28
+ export function jwkToKey(jwk) {
29
+ // Node accepts JWK directly via `format: "jwk"` since Node 16.
30
+ return createPublicKey({ key: jwk, format: "jwk" });
31
+ }
32
+ /** Verify a compact-serialised JWT against a keyset (JWKS `keys` array)
33
+ * and return its payload. Throws JwtVerifyError on any failure. */
34
+ export function verifyIdToken(jwt, keys, opts) {
35
+ const parts = jwt.split(".");
36
+ if (parts.length !== 3)
37
+ throw new JwtVerifyError("malformed JWT (expected 3 parts)");
38
+ const [headerB64, payloadB64, sigB64] = parts;
39
+ let header;
40
+ let payload;
41
+ try {
42
+ header = decodeJson(headerB64);
43
+ payload = decodeJson(payloadB64);
44
+ }
45
+ catch {
46
+ throw new JwtVerifyError("malformed JWT (header/payload not JSON)");
47
+ }
48
+ if (header.alg === "none" || !header.alg)
49
+ throw new JwtVerifyError(`disallowed alg: ${header.alg}`);
50
+ if (header.alg !== "RS256" && header.alg !== "ES256") {
51
+ throw new JwtVerifyError(`unsupported alg: ${header.alg}`);
52
+ }
53
+ // Pick the key by (kty, kid). When the header has a kid we require
54
+ // a matching JWK kid — accepting a kid-less JWK here would let a
55
+ // misconfigured JWKS with one untagged key validate tokens claiming
56
+ // any kid. The kid-less fallback only applies when the header
57
+ // itself doesn't carry one (single-key IdP / very old tokens).
58
+ const wantedKty = header.alg === "RS256" ? "RSA" : "EC";
59
+ let candidates = keys.filter((k) => k.kty === wantedKty);
60
+ if (header.kid)
61
+ candidates = candidates.filter((k) => k.kid === header.kid);
62
+ if (candidates.length === 0)
63
+ throw new JwtVerifyError(`no JWK matches kid=${header.kid ?? "?"} kty=${wantedKty}`);
64
+ const signingInput = Buffer.from(`${headerB64}.${payloadB64}`, "utf8");
65
+ const signature = b64urlDecode(sigB64);
66
+ let verified = false;
67
+ let lastErr;
68
+ for (const jwk of candidates) {
69
+ try {
70
+ const key = jwkToKey(jwk);
71
+ if (header.alg === "RS256") {
72
+ const v = createVerify("RSA-SHA256");
73
+ v.update(signingInput);
74
+ v.end();
75
+ if (v.verify(key, signature)) {
76
+ verified = true;
77
+ break;
78
+ }
79
+ }
80
+ else {
81
+ // ES256: signature is raw (R||S) 64 bytes. Node accepts that
82
+ // via `dsaEncoding: 'ieee-p1363'`.
83
+ const v = createVerify("SHA256");
84
+ v.update(signingInput);
85
+ v.end();
86
+ if (v.verify({ key, dsaEncoding: "ieee-p1363" }, signature)) {
87
+ verified = true;
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ catch (e) {
93
+ lastErr = e;
94
+ }
95
+ }
96
+ if (!verified)
97
+ throw new JwtVerifyError(`signature verification failed${lastErr ? `: ${lastErr.message}` : ""}`);
98
+ // Claim checks
99
+ const now = Math.floor((opts.now ? opts.now() : Date.now()) / 1000);
100
+ const skew = opts.clockSkewSec ?? 30;
101
+ if (payload.iss !== opts.issuer)
102
+ throw new JwtVerifyError(`iss mismatch (expected ${opts.issuer}, got ${payload.iss ?? "?"})`);
103
+ const audOk = Array.isArray(payload.aud) ? payload.aud.includes(opts.audience) : payload.aud === opts.audience;
104
+ if (!audOk)
105
+ throw new JwtVerifyError(`aud mismatch (expected ${opts.audience}, got ${JSON.stringify(payload.aud)})`);
106
+ if (typeof payload.exp !== "number" || now - skew > payload.exp)
107
+ throw new JwtVerifyError("token expired");
108
+ if (typeof payload.nbf === "number" && now + skew < payload.nbf)
109
+ throw new JwtVerifyError("token not yet valid");
110
+ if (opts.nonce !== undefined && payload.nonce !== opts.nonce)
111
+ throw new JwtVerifyError("nonce mismatch");
112
+ return payload;
113
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createSign, generateKeyPairSync, createPublicKey } from "node:crypto";
4
+ import { verifyIdToken, b64urlDecode, JwtVerifyError } from "./jwt.js";
5
+ function b64u(s) {
6
+ const b = typeof s === "string" ? Buffer.from(s, "utf8") : s;
7
+ return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
+ }
9
+ // Sign with a PEM-encoded private key. PEM strings work identically
10
+ // across every Node version we ship on; raw KeyObject destructuring
11
+ // of generateKeyPairSync hit a "Invalid key object type public,
12
+ // expected private" error in CI's Node 20 (private/public swap
13
+ // somewhere in the destructure result). PEM avoids the whole class.
14
+ function signRs256(payload, privateKeyPem, kid) {
15
+ const header = b64u(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
16
+ const body = b64u(JSON.stringify(payload));
17
+ const signer = createSign("RSA-SHA256");
18
+ signer.update(`${header}.${body}`);
19
+ signer.end();
20
+ const sig = b64u(signer.sign(privateKeyPem));
21
+ return `${header}.${body}.${sig}`;
22
+ }
23
+ function rsaKeypair() {
24
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
25
+ modulusLength: 2048,
26
+ publicKeyEncoding: { type: "spki", format: "pem" },
27
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
28
+ });
29
+ const jwk = createPublicKey(publicKey).export({ format: "jwk" });
30
+ jwk.kid = "test-key-1";
31
+ return { jwk, privateKeyPem: privateKey };
32
+ }
33
+ test("b64urlDecode — round-trips standard and padded inputs", () => {
34
+ assert.equal(b64urlDecode("AQ").toString("hex"), "01");
35
+ assert.equal(b64urlDecode("-_-_").toString("hex"), "fbffbf");
36
+ });
37
+ test("verifyIdToken — happy path on RS256", () => {
38
+ const { jwk, privateKeyPem } = rsaKeypair();
39
+ const now = 1_700_000_000;
40
+ const payload = { iss: "https://idp.test", aud: "client-1", sub: "alice", exp: now + 60, iat: now, nonce: "n-1" };
41
+ const jwt = signRs256(payload, privateKeyPem, jwk.kid);
42
+ const out = verifyIdToken(jwt, [jwk], { issuer: "https://idp.test", audience: "client-1", nonce: "n-1", now: () => now * 1000 });
43
+ assert.equal(out.sub, "alice");
44
+ });
45
+ test("verifyIdToken — rejects alg=none", () => {
46
+ const header = b64u(JSON.stringify({ alg: "none", typ: "JWT" }));
47
+ const body = b64u(JSON.stringify({ iss: "x", aud: "x", exp: 9_999_999_999 }));
48
+ const jwt = `${header}.${body}.`;
49
+ assert.throws(() => verifyIdToken(jwt, [], { issuer: "x", audience: "x" }), JwtVerifyError);
50
+ });
51
+ test("verifyIdToken — rejects unsupported alg (HS256)", () => {
52
+ const header = b64u(JSON.stringify({ alg: "HS256", typ: "JWT" }));
53
+ const body = b64u(JSON.stringify({ iss: "x", aud: "x", exp: 9_999_999_999 }));
54
+ const jwt = `${header}.${body}.AAAA`;
55
+ assert.throws(() => verifyIdToken(jwt, [], { issuer: "x", audience: "x" }), /unsupported alg/);
56
+ });
57
+ test("verifyIdToken — rejects expired token (beyond clock skew)", () => {
58
+ const { jwk, privateKeyPem } = rsaKeypair();
59
+ const now = 1_700_000_000;
60
+ const jwt = signRs256({ iss: "https://idp.test", aud: "c", exp: now - 1000 }, privateKeyPem, jwk.kid);
61
+ assert.throws(() => verifyIdToken(jwt, [jwk], { issuer: "https://idp.test", audience: "c", now: () => now * 1000 }), /expired/);
62
+ });
63
+ test("verifyIdToken — rejects iss / aud / nonce mismatch", () => {
64
+ const { jwk, privateKeyPem } = rsaKeypair();
65
+ const now = 1_700_000_000;
66
+ const jwt = signRs256({ iss: "https://idp.test", aud: "c", exp: now + 60, nonce: "real" }, privateKeyPem, jwk.kid);
67
+ assert.throws(() => verifyIdToken(jwt, [jwk], { issuer: "wrong", audience: "c", now: () => now * 1000 }), /iss mismatch/);
68
+ assert.throws(() => verifyIdToken(jwt, [jwk], { issuer: "https://idp.test", audience: "wrong", now: () => now * 1000 }), /aud mismatch/);
69
+ assert.throws(() => verifyIdToken(jwt, [jwk], { issuer: "https://idp.test", audience: "c", nonce: "other", now: () => now * 1000 }), /nonce mismatch/);
70
+ });
71
+ test("verifyIdToken — aud as array including expected", () => {
72
+ const { jwk, privateKeyPem } = rsaKeypair();
73
+ const now = 1_700_000_000;
74
+ const jwt = signRs256({ iss: "https://idp.test", aud: ["c", "other"], exp: now + 60 }, privateKeyPem, jwk.kid);
75
+ const out = verifyIdToken(jwt, [jwk], { issuer: "https://idp.test", audience: "c", now: () => now * 1000 });
76
+ assert.deepEqual(out.aud, ["c", "other"]);
77
+ });
78
+ test("verifyIdToken — bad signature is rejected", () => {
79
+ const { jwk, privateKeyPem } = rsaKeypair();
80
+ const now = 1_700_000_000;
81
+ const jwt = signRs256({ iss: "https://idp.test", aud: "c", exp: now + 60 }, privateKeyPem, jwk.kid);
82
+ // Flip the FIRST char of the signature. Every bit of position-0
83
+ // contributes to the decoded bytes; the LAST char of a 342-char
84
+ // signature (RSA-2048 → 256 bytes = 4·85+2) shares a 12-bit
85
+ // window encoding only 8 useful bits, so its bottom 4 bits are
86
+ // discarded — flipping A↔B there preserved the decoded byte and
87
+ // the signature still verified, making the test flaky.
88
+ const parts = jwt.split(".");
89
+ const sig = parts[2];
90
+ const first = sig.charAt(0);
91
+ const flipped = first >= "A" && first <= "Z" ? first.toLowerCase()
92
+ : first >= "a" && first <= "z" ? first.toUpperCase()
93
+ : first === "_" ? "-" : "_";
94
+ const tampered = `${parts[0]}.${parts[1]}.${flipped}${sig.slice(1)}`;
95
+ assert.throws(() => verifyIdToken(tampered, [jwk], { issuer: "https://idp.test", audience: "c", now: () => now * 1000 }), /signature verification failed/);
96
+ });
97
+ test("verifyIdToken — happy path on ES256 (P-256 EC key)", () => {
98
+ // PEM-encode for the same Node-version-stability reason as RS256
99
+ // above.
100
+ const { publicKey, privateKey } = generateKeyPairSync("ec", {
101
+ namedCurve: "P-256",
102
+ publicKeyEncoding: { type: "spki", format: "pem" },
103
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
104
+ });
105
+ const jwk = createPublicKey(publicKey).export({ format: "jwk" });
106
+ jwk.kid = "ec-key-1";
107
+ const now = 1_700_000_000;
108
+ const header = b64u(JSON.stringify({ alg: "ES256", typ: "JWT", kid: jwk.kid }));
109
+ const body = b64u(JSON.stringify({ iss: "https://idp.test", aud: "client-1", sub: "bob", exp: now + 60 }));
110
+ // Node's default ECDSA signature is DER; convert to raw R||S 64-byte
111
+ // ieee-p1363 for JWS spec compliance.
112
+ const signer = createSign("SHA256");
113
+ signer.update(`${header}.${body}`);
114
+ signer.end();
115
+ const sig = signer.sign({ key: privateKey, dsaEncoding: "ieee-p1363" });
116
+ assert.equal(sig.length, 64, "ES256 raw signature must be 64 bytes");
117
+ const jwt = `${header}.${body}.${b64u(sig)}`;
118
+ const out = verifyIdToken(jwt, [jwk], { issuer: "https://idp.test", audience: "client-1", now: () => now * 1000 });
119
+ assert.equal(out.sub, "bob");
120
+ });
121
+ test("verifyIdToken — strict kid match: header kid does not silently match kid-less JWK", () => {
122
+ const { jwk, privateKeyPem } = rsaKeypair();
123
+ // JWK with NO kid in the keyset
124
+ const untagged = { ...jwk };
125
+ delete untagged.kid;
126
+ const now = 1_700_000_000;
127
+ // Token claims kid=ghost — JWK doesn't have one, must reject.
128
+ const jwt = signRs256({ iss: "i", aud: "c", exp: now + 60 }, privateKeyPem, "ghost-kid");
129
+ assert.throws(() => verifyIdToken(jwt, [untagged], { issuer: "i", audience: "c", now: () => now * 1000 }), /no JWK matches kid=ghost-kid/);
130
+ });
131
+ test("verifyIdToken — picks key by kid when JWKS has multiple", () => {
132
+ const a = rsaKeypair();
133
+ a.jwk.kid = "k-a";
134
+ const b = rsaKeypair();
135
+ b.jwk.kid = "k-b";
136
+ const now = 1_700_000_000;
137
+ const jwt = signRs256({ iss: "i", aud: "c", exp: now + 60 }, b.privateKeyPem, "k-b");
138
+ // Both keys in the JWKS — verifier should reach for k-b.
139
+ const out = verifyIdToken(jwt, [a.jwk, b.jwk], { issuer: "i", audience: "c", now: () => now * 1000 });
140
+ assert.equal(out.iss, "i");
141
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * PKCE (RFC 7636) helpers for the OIDC authorization-code flow.
3
+ *
4
+ * The `S256` method only — `plain` is disallowed by spec for native /
5
+ * SPA clients and we have no reason to weaken it. Verifier length
6
+ * follows the RFC's recommended 43–128 unreserved-char range.
7
+ */
8
+ /** Base64url-encode a Buffer (RFC 4648 §5, no padding). */
9
+ export declare function base64url(buf: Buffer): string;
10
+ /** Generate a fresh PKCE code_verifier — 64 unreserved chars. */
11
+ export declare function generateCodeVerifier(): string;
12
+ /** Derive the S256 code_challenge from a verifier. */
13
+ export declare function challengeFromVerifier(verifier: string): string;
14
+ /** Convenience: fresh {verifier, challenge} pair. */
15
+ export declare function generatePkcePair(): {
16
+ verifier: string;
17
+ challenge: string;
18
+ method: "S256";
19
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * PKCE (RFC 7636) helpers for the OIDC authorization-code flow.
3
+ *
4
+ * The `S256` method only — `plain` is disallowed by spec for native /
5
+ * SPA clients and we have no reason to weaken it. Verifier length
6
+ * follows the RFC's recommended 43–128 unreserved-char range.
7
+ */
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ const UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
10
+ /** Base64url-encode a Buffer (RFC 4648 §5, no padding). */
11
+ export function base64url(buf) {
12
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
+ }
14
+ /** Generate a fresh PKCE code_verifier — 64 unreserved chars. */
15
+ export function generateCodeVerifier() {
16
+ // Uniform-sample one unreserved char per output position via
17
+ // rejection sampling: 256 mod 66 = 58, so bytes in [0, 198) map
18
+ // uniformly; bytes ≥ 198 are rejected and re-drawn. Plain modulo
19
+ // would over-represent the first 58 of 66 chars (CodeQL flags it
20
+ // as a high-severity finding). 64 output chars yields ~380 bits
21
+ // of entropy, well above the spec's 256-bit floor.
22
+ const N = UNRESERVED.length; // 66
23
+ const UNIFORM_CEIL = 256 - (256 % N); // 198
24
+ const out = [];
25
+ while (out.length < 64) {
26
+ const buf = randomBytes(64);
27
+ for (let i = 0; i < buf.length && out.length < 64; i++) {
28
+ const b = buf[i];
29
+ if (b < UNIFORM_CEIL)
30
+ out.push(UNRESERVED[b % N]);
31
+ }
32
+ }
33
+ return out.join("");
34
+ }
35
+ /** Derive the S256 code_challenge from a verifier. */
36
+ export function challengeFromVerifier(verifier) {
37
+ return base64url(createHash("sha256").update(verifier).digest());
38
+ }
39
+ /** Convenience: fresh {verifier, challenge} pair. */
40
+ export function generatePkcePair() {
41
+ const verifier = generateCodeVerifier();
42
+ return { verifier, challenge: challengeFromVerifier(verifier), method: "S256" };
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHash } from "node:crypto";
4
+ import { base64url, generateCodeVerifier, challengeFromVerifier, generatePkcePair } from "./pkce.js";
5
+ test("base64url — strips padding and rewrites +/", () => {
6
+ // Buffer that hits + and / under standard base64 encoding
7
+ const b = Buffer.from([0xfb, 0xff, 0xbf]);
8
+ assert.equal(b.toString("base64"), "+/+/"); // sanity
9
+ assert.equal(base64url(b), "-_-_");
10
+ // Padding case
11
+ assert.equal(base64url(Buffer.from([0x01])), "AQ");
12
+ });
13
+ test("generateCodeVerifier — only unreserved chars, length 64", () => {
14
+ const v = generateCodeVerifier();
15
+ assert.equal(v.length, 64);
16
+ assert.match(v, /^[A-Za-z0-9\-._~]+$/);
17
+ });
18
+ test("generateCodeVerifier — two calls produce distinct values", () => {
19
+ const a = generateCodeVerifier();
20
+ const b = generateCodeVerifier();
21
+ assert.notEqual(a, b);
22
+ });
23
+ test("generateCodeVerifier — distribution is roughly uniform across the 66 unreserved chars", () => {
24
+ // Rejection-sampling guarantee: every char must appear approximately
25
+ // N*64/66 ≈ 0.97 times per verifier on average. A weak smoke test:
26
+ // across 200 verifiers (12_800 chars) no character class is empty
27
+ // and the most-common / least-common counts stay within a 2x ratio.
28
+ // With biased modulo, the last 8 chars would be ~20% under-represented;
29
+ // this asserts that's no longer the case.
30
+ const counts = new Map();
31
+ for (let i = 0; i < 200; i++) {
32
+ for (const c of generateCodeVerifier())
33
+ counts.set(c, (counts.get(c) ?? 0) + 1);
34
+ }
35
+ assert.equal(counts.size, 66, "every unreserved char should appear at least once");
36
+ const all = [...counts.values()];
37
+ const min = Math.min(...all);
38
+ const max = Math.max(...all);
39
+ assert.ok(max / min < 2, `distribution too skewed (min=${min} max=${max})`);
40
+ });
41
+ test("challengeFromVerifier — matches base64url(sha256(verifier))", () => {
42
+ const v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636 §4.4 sample
43
+ const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; // RFC 7636 §4.4
44
+ assert.equal(challengeFromVerifier(v), expected);
45
+ });
46
+ test("challengeFromVerifier — deterministic", () => {
47
+ const v = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789--__";
48
+ assert.equal(challengeFromVerifier(v), challengeFromVerifier(v));
49
+ });
50
+ test("generatePkcePair — challenge matches S256(verifier) and method is S256", () => {
51
+ const p = generatePkcePair();
52
+ const expect = Buffer.from(createHash("sha256").update(p.verifier).digest()).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
53
+ assert.equal(p.challenge, expect);
54
+ assert.equal(p.method, "S256");
55
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Resolve the OIDC configuration from environment variables and turn
3
+ * it into the runtime shape the rest of the auth layer consumes.
4
+ *
5
+ * Mirrors the basic-mode resolution in src/index.ts: fail-closed on
6
+ * missing required config, allow an `OMCP_AUTH_ALLOW_FALLBACK=true`
7
+ * opt-out (handled by the caller — this module just signals via the
8
+ * `error` field).
9
+ *
10
+ * Required env (when OMCP_AUTH=oidc):
11
+ * OMCP_OIDC_ISSUER — IdP base URL (no trailing /.well-known/...)
12
+ * OMCP_OIDC_CLIENT_ID
13
+ * OMCP_OIDC_REDIRECT_URI — absolute, MUST match the registration
14
+ *
15
+ * Optional env:
16
+ * OMCP_OIDC_CLIENT_SECRET — confidential clients; public clients omit
17
+ * OMCP_OIDC_SCOPES — default "openid profile email"
18
+ * OMCP_OIDC_ROLES_CLAIM — dotted path; default "groups"
19
+ * OMCP_OIDC_ROLE_MAP — JSON {"<claim-value>": "<omcp-role>"};
20
+ * entries map directly to RBAC roles
21
+ * (viewer / operator / admin or custom).
22
+ * OMCP_OIDC_LOGOUT_REDIRECT — post-logout landing URL (default "/")
23
+ */
24
+ import { OidcClient } from "./client.js";
25
+ export interface OidcRuntimeConfig {
26
+ issuer: string;
27
+ clientId: string;
28
+ clientSecret?: string;
29
+ redirectUri: string;
30
+ scopes: string;
31
+ rolesClaim: string;
32
+ roleMap: Record<string, string>;
33
+ logoutRedirect: string;
34
+ /** Dotted claim path to read the tenant from. Empty / missing → all
35
+ * OIDC sessions land in the "default" tenant. */
36
+ tenantClaim: string;
37
+ }
38
+ export interface ResolveOidcResult {
39
+ /** Fully validated runtime config; absent when `error` is set. */
40
+ config?: OidcRuntimeConfig;
41
+ /** Human-readable misconfiguration reason; used for the boot log
42
+ * + fail-closed exit. */
43
+ error?: string;
44
+ }
45
+ /** Pure env-to-config translator. No I/O. */
46
+ export declare function resolveOidcConfig(env?: NodeJS.ProcessEnv): ResolveOidcResult;
47
+ /** Build the OidcClient + the role-resolution helper from a resolved
48
+ * runtime config. Tests can stub OidcClient by passing a custom one. */
49
+ export interface OidcRuntime {
50
+ cfg: OidcRuntimeConfig;
51
+ client: OidcClient;
52
+ /** Walk a JWT claim set, follow the rolesClaim dotted path, and
53
+ * return the OMCP role names the user inherits via roleMap.
54
+ * Unknown claim values are silently dropped (least-privilege). */
55
+ resolveRoles(claims: Record<string, unknown>): string[];
56
+ /** Walk a JWT claim set, follow the configured tenant claim path,
57
+ * and return a normalised tenant id. Empty / missing / invalid →
58
+ * DEFAULT_TENANT. */
59
+ resolveTenant(claims: Record<string, unknown>): string;
60
+ }
61
+ export declare function buildOidcRuntime(cfg: OidcRuntimeConfig, opts?: {
62
+ client?: OidcClient;
63
+ }): OidcRuntime;