@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
|
@@ -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;
|