@thotischner/observability-mcp 1.7.0 → 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/connectors/kubernetes.d.ts +1 -0
- package/dist/connectors/kubernetes.js +12 -2
- package/dist/connectors/topology-vocabulary.d.ts +41 -0
- package/dist/connectors/topology-vocabulary.js +120 -0
- package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
- package/dist/connectors/topology-vocabulary.test.js +63 -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,104 @@
|
|
|
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 { DiscoveryClient } from "./discovery.js";
|
|
12
|
+
import { JwksClient } from "./jwks.js";
|
|
13
|
+
import { generatePkcePair } from "./pkce.js";
|
|
14
|
+
import { verifyIdToken } from "./jwt.js";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
export class OidcClient {
|
|
17
|
+
discovery;
|
|
18
|
+
jwks;
|
|
19
|
+
cfg;
|
|
20
|
+
fetcher;
|
|
21
|
+
now;
|
|
22
|
+
constructor(cfg) {
|
|
23
|
+
this.cfg = cfg;
|
|
24
|
+
this.fetcher = cfg.fetcher ?? ((u, i) => fetch(u, i));
|
|
25
|
+
this.now = cfg.now ?? Date.now;
|
|
26
|
+
this.discovery = new DiscoveryClient({ fetcher: this.fetcher, now: this.now });
|
|
27
|
+
this.jwks = new JwksClient({ fetcher: this.fetcher, now: this.now });
|
|
28
|
+
}
|
|
29
|
+
/** Build an authorize URL + mint the state/nonce/PKCE-verifier the
|
|
30
|
+
* caller must persist until the callback. */
|
|
31
|
+
async start() {
|
|
32
|
+
const doc = await this.discovery.discover(this.cfg.issuer);
|
|
33
|
+
const pkce = generatePkcePair();
|
|
34
|
+
const state = base64url(randomBytes(24));
|
|
35
|
+
const nonce = base64url(randomBytes(24));
|
|
36
|
+
const params = new URLSearchParams({
|
|
37
|
+
response_type: "code",
|
|
38
|
+
client_id: this.cfg.clientId,
|
|
39
|
+
redirect_uri: this.cfg.redirectUri,
|
|
40
|
+
scope: this.cfg.scopes ?? "openid profile email",
|
|
41
|
+
state,
|
|
42
|
+
nonce,
|
|
43
|
+
code_challenge: pkce.challenge,
|
|
44
|
+
code_challenge_method: pkce.method,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
authorizeUrl: `${doc.authorization_endpoint}?${params.toString()}`,
|
|
48
|
+
flow: { state, nonce, codeVerifier: pkce.verifier },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Validate the callback: state match → token exchange → ID-token
|
|
52
|
+
* signature + claim verification. Throws on any failure. */
|
|
53
|
+
async complete(opts) {
|
|
54
|
+
if (opts.state !== opts.flow.state)
|
|
55
|
+
throw new Error("OIDC callback: state mismatch");
|
|
56
|
+
const doc = await this.discovery.discover(this.cfg.issuer);
|
|
57
|
+
const body = new URLSearchParams({
|
|
58
|
+
grant_type: "authorization_code",
|
|
59
|
+
code: opts.code,
|
|
60
|
+
redirect_uri: this.cfg.redirectUri,
|
|
61
|
+
client_id: this.cfg.clientId,
|
|
62
|
+
code_verifier: opts.flow.codeVerifier,
|
|
63
|
+
});
|
|
64
|
+
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
65
|
+
if (this.cfg.clientSecret) {
|
|
66
|
+
const basic = Buffer.from(`${encodeURIComponent(this.cfg.clientId)}:${encodeURIComponent(this.cfg.clientSecret)}`).toString("base64");
|
|
67
|
+
headers.authorization = `Basic ${basic}`;
|
|
68
|
+
}
|
|
69
|
+
const res = await this.fetcher(doc.token_endpoint, { method: "POST", headers, body: body.toString() });
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const text = await res.text().catch(() => "");
|
|
72
|
+
throw new Error(`OIDC token exchange failed: HTTP ${res.status} ${text}`);
|
|
73
|
+
}
|
|
74
|
+
const tokens = (await res.json());
|
|
75
|
+
if (!tokens.id_token)
|
|
76
|
+
throw new Error("OIDC token response missing id_token");
|
|
77
|
+
const claims = await this.verify(tokens.id_token, doc, opts.flow.nonce);
|
|
78
|
+
return { claims, idToken: tokens.id_token, accessToken: tokens.access_token };
|
|
79
|
+
}
|
|
80
|
+
/** Verify a standalone ID token (refresh flows, replay checks). */
|
|
81
|
+
async verify(idToken, doc, nonce) {
|
|
82
|
+
const d = doc ?? (await this.discovery.discover(this.cfg.issuer));
|
|
83
|
+
const header = parseHeader(idToken);
|
|
84
|
+
const key = await this.jwks.findKey(d.jwks_uri, header.kid);
|
|
85
|
+
if (!key)
|
|
86
|
+
throw new Error(`OIDC: no JWKS key for kid=${header.kid ?? "?"}`);
|
|
87
|
+
return verifyIdToken(idToken, [key], {
|
|
88
|
+
issuer: this.cfg.issuer,
|
|
89
|
+
audience: this.cfg.clientId,
|
|
90
|
+
nonce,
|
|
91
|
+
now: this.now,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function base64url(buf) {
|
|
96
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
97
|
+
}
|
|
98
|
+
function parseHeader(jwt) {
|
|
99
|
+
const [h] = jwt.split(".");
|
|
100
|
+
if (!h)
|
|
101
|
+
throw new Error("malformed JWT");
|
|
102
|
+
const pad = h.length % 4 === 0 ? "" : "=".repeat(4 - (h.length % 4));
|
|
103
|
+
return JSON.parse(Buffer.from(h.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8"));
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generateKeyPairSync, createPublicKey, createSign } from "node:crypto";
|
|
4
|
+
import { OidcClient } from "./client.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
|
+
// PEM-encoded sign — see jwt.test.ts for the Node-version-stability
|
|
10
|
+
// reason this avoids the raw KeyObject destructure path.
|
|
11
|
+
function signRs256(payload, privateKeyPem, kid) {
|
|
12
|
+
const header = b64u(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
|
|
13
|
+
const body = b64u(JSON.stringify(payload));
|
|
14
|
+
const signer = createSign("RSA-SHA256");
|
|
15
|
+
signer.update(`${header}.${body}`);
|
|
16
|
+
signer.end();
|
|
17
|
+
return `${header}.${body}.${b64u(signer.sign(privateKeyPem))}`;
|
|
18
|
+
}
|
|
19
|
+
function rsaKey() {
|
|
20
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
21
|
+
modulusLength: 2048,
|
|
22
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
23
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
24
|
+
});
|
|
25
|
+
const jwk = createPublicKey(publicKey).export({ format: "jwk" });
|
|
26
|
+
jwk.kid = "test-kid";
|
|
27
|
+
return { jwk, privateKeyPem: privateKey };
|
|
28
|
+
}
|
|
29
|
+
function makeFetcher(handlers) {
|
|
30
|
+
return async (url, init) => {
|
|
31
|
+
for (const [pattern, handler] of Object.entries(handlers)) {
|
|
32
|
+
if (url === pattern)
|
|
33
|
+
return Promise.resolve(handler(init));
|
|
34
|
+
}
|
|
35
|
+
return new Response("not found", { status: 404 });
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const ISSUER = "https://idp.test";
|
|
39
|
+
const DISCOVERY = {
|
|
40
|
+
issuer: ISSUER,
|
|
41
|
+
authorization_endpoint: `${ISSUER}/auth`,
|
|
42
|
+
token_endpoint: `${ISSUER}/token`,
|
|
43
|
+
jwks_uri: `${ISSUER}/jwks`,
|
|
44
|
+
};
|
|
45
|
+
test("OidcClient.start — builds authorize URL with PKCE + nonce + state", async () => {
|
|
46
|
+
const fetcher = makeFetcher({
|
|
47
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
48
|
+
});
|
|
49
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
50
|
+
const r = await client.start();
|
|
51
|
+
const u = new URL(r.authorizeUrl);
|
|
52
|
+
assert.equal(u.origin + u.pathname, `${ISSUER}/auth`);
|
|
53
|
+
assert.equal(u.searchParams.get("response_type"), "code");
|
|
54
|
+
assert.equal(u.searchParams.get("client_id"), "c-1");
|
|
55
|
+
assert.equal(u.searchParams.get("redirect_uri"), "https://app.test/cb");
|
|
56
|
+
assert.equal(u.searchParams.get("code_challenge_method"), "S256");
|
|
57
|
+
assert.ok(u.searchParams.get("code_challenge"));
|
|
58
|
+
assert.equal(u.searchParams.get("state"), r.flow.state);
|
|
59
|
+
assert.equal(u.searchParams.get("nonce"), r.flow.nonce);
|
|
60
|
+
assert.ok(r.flow.codeVerifier.length >= 43);
|
|
61
|
+
});
|
|
62
|
+
test("OidcClient.complete — verifies state, exchanges code, verifies id_token", async () => {
|
|
63
|
+
const { jwk, privateKeyPem } = rsaKey();
|
|
64
|
+
const now = 1_700_000_000;
|
|
65
|
+
const flow = { state: "S", nonce: "N", codeVerifier: "V_43charsminimum_______________________________________________" };
|
|
66
|
+
const idToken = signRs256({ iss: ISSUER, aud: "c-1", sub: "alice", exp: now + 60, iat: now, nonce: "N" }, privateKeyPem, jwk.kid);
|
|
67
|
+
const fetcher = makeFetcher({
|
|
68
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
69
|
+
[`${ISSUER}/jwks`]: () => new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
|
70
|
+
[`${ISSUER}/token`]: () => new Response(JSON.stringify({ id_token: idToken, access_token: "AT" }), { status: 200 }),
|
|
71
|
+
});
|
|
72
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher, now: () => now * 1000 });
|
|
73
|
+
const r = await client.complete({ code: "ABC", state: "S", flow });
|
|
74
|
+
assert.equal(r.claims.sub, "alice");
|
|
75
|
+
assert.equal(r.accessToken, "AT");
|
|
76
|
+
});
|
|
77
|
+
test("OidcClient.complete — rejects state mismatch", async () => {
|
|
78
|
+
const fetcher = makeFetcher({
|
|
79
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
80
|
+
});
|
|
81
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
82
|
+
await assert.rejects(client.complete({ code: "x", state: "wrong", flow: { state: "real", nonce: "n", codeVerifier: "v" } }), /state mismatch/);
|
|
83
|
+
});
|
|
84
|
+
test("OidcClient.complete — surfaces token-endpoint failures", async () => {
|
|
85
|
+
const fetcher = makeFetcher({
|
|
86
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
87
|
+
[`${ISSUER}/token`]: () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }),
|
|
88
|
+
});
|
|
89
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
90
|
+
await assert.rejects(client.complete({ code: "x", state: "S", flow: { state: "S", nonce: "n", codeVerifier: "v" } }), /HTTP 400/);
|
|
91
|
+
});
|
|
92
|
+
test("OidcClient.complete — rejects missing id_token in token response", async () => {
|
|
93
|
+
const fetcher = makeFetcher({
|
|
94
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
95
|
+
[`${ISSUER}/token`]: () => new Response(JSON.stringify({ access_token: "AT" }), { status: 200 }),
|
|
96
|
+
});
|
|
97
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
98
|
+
await assert.rejects(client.complete({ code: "x", state: "S", flow: { state: "S", nonce: "n", codeVerifier: "v" } }), /missing id_token/);
|
|
99
|
+
});
|
|
100
|
+
test("OidcClient.complete — uses Basic auth when clientSecret set", async () => {
|
|
101
|
+
const { jwk, privateKeyPem } = rsaKey();
|
|
102
|
+
const now = 1_700_000_000;
|
|
103
|
+
const idToken = signRs256({ iss: ISSUER, aud: "c-1", sub: "alice", exp: now + 60, iat: now, nonce: "n" }, privateKeyPem, jwk.kid);
|
|
104
|
+
let captured;
|
|
105
|
+
const fetcher = async (url, init) => {
|
|
106
|
+
if (url === `${ISSUER}/.well-known/openid-configuration`)
|
|
107
|
+
return new Response(JSON.stringify(DISCOVERY), { status: 200 });
|
|
108
|
+
if (url === `${ISSUER}/jwks`)
|
|
109
|
+
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
|
110
|
+
if (url === `${ISSUER}/token`) {
|
|
111
|
+
captured = (init?.headers).authorization;
|
|
112
|
+
return new Response(JSON.stringify({ id_token: idToken }), { status: 200 });
|
|
113
|
+
}
|
|
114
|
+
return new Response("nf", { status: 404 });
|
|
115
|
+
};
|
|
116
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", clientSecret: "shh", redirectUri: "https://app.test/cb", fetcher, now: () => now * 1000 });
|
|
117
|
+
await client.complete({ code: "x", state: "S", flow: { state: "S", nonce: "n", codeVerifier: "v" } });
|
|
118
|
+
assert.ok(captured?.startsWith("Basic "));
|
|
119
|
+
const decoded = Buffer.from(captured.slice("Basic ".length), "base64").toString();
|
|
120
|
+
assert.equal(decoded, "c-1:shh");
|
|
121
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC discovery-document fetcher with TTL cache.
|
|
3
|
+
*
|
|
4
|
+
* Resolves an issuer URL into the endpoint set the rest of the OIDC
|
|
5
|
+
* code-flow needs. Caches per-issuer for a configurable TTL (default
|
|
6
|
+
* 1 hour) — IdPs publish stable URLs but rotate them occasionally.
|
|
7
|
+
*
|
|
8
|
+
* `fetcher` is injectable so unit tests don't need a real network.
|
|
9
|
+
*/
|
|
10
|
+
export interface DiscoveryDocument {
|
|
11
|
+
issuer: string;
|
|
12
|
+
authorization_endpoint: string;
|
|
13
|
+
token_endpoint: string;
|
|
14
|
+
jwks_uri: string;
|
|
15
|
+
userinfo_endpoint?: string;
|
|
16
|
+
end_session_endpoint?: string;
|
|
17
|
+
response_types_supported?: string[];
|
|
18
|
+
id_token_signing_alg_values_supported?: string[];
|
|
19
|
+
scopes_supported?: string[];
|
|
20
|
+
[k: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
23
|
+
export interface DiscoveryClientOpts {
|
|
24
|
+
fetcher?: Fetcher;
|
|
25
|
+
ttlMs?: number;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
export declare class DiscoveryClient {
|
|
29
|
+
private readonly fetcher;
|
|
30
|
+
private readonly ttlMs;
|
|
31
|
+
private readonly now;
|
|
32
|
+
private readonly cache;
|
|
33
|
+
constructor(opts?: DiscoveryClientOpts);
|
|
34
|
+
/** Discover the OP metadata for the given issuer URL. */
|
|
35
|
+
discover(issuer: string): Promise<DiscoveryDocument>;
|
|
36
|
+
/** Drop the cache (test helper / manual rotation). */
|
|
37
|
+
invalidate(issuer?: string): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC discovery-document fetcher with TTL cache.
|
|
3
|
+
*
|
|
4
|
+
* Resolves an issuer URL into the endpoint set the rest of the OIDC
|
|
5
|
+
* code-flow needs. Caches per-issuer for a configurable TTL (default
|
|
6
|
+
* 1 hour) — IdPs publish stable URLs but rotate them occasionally.
|
|
7
|
+
*
|
|
8
|
+
* `fetcher` is injectable so unit tests don't need a real network.
|
|
9
|
+
*/
|
|
10
|
+
export class DiscoveryClient {
|
|
11
|
+
fetcher;
|
|
12
|
+
ttlMs;
|
|
13
|
+
now;
|
|
14
|
+
cache = new Map();
|
|
15
|
+
constructor(opts = {}) {
|
|
16
|
+
this.fetcher = opts.fetcher ?? ((u, i) => fetch(u, i));
|
|
17
|
+
this.ttlMs = opts.ttlMs ?? 3_600_000;
|
|
18
|
+
this.now = opts.now ?? Date.now;
|
|
19
|
+
}
|
|
20
|
+
/** Discover the OP metadata for the given issuer URL. */
|
|
21
|
+
async discover(issuer) {
|
|
22
|
+
const cached = this.cache.get(issuer);
|
|
23
|
+
if (cached && cached.expiresAt > this.now())
|
|
24
|
+
return cached.doc;
|
|
25
|
+
const url = issuer.replace(/\/$/, "") + "/.well-known/openid-configuration";
|
|
26
|
+
const res = await this.fetcher(url);
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${res.status}`);
|
|
29
|
+
const doc = (await res.json());
|
|
30
|
+
// Spec §4.3 — issuer in the doc MUST exactly equal the requested
|
|
31
|
+
// issuer (defends against open-redirect-style metadata swaps).
|
|
32
|
+
if (doc.issuer !== issuer) {
|
|
33
|
+
throw new Error(`OIDC discovery issuer mismatch: requested ${issuer}, document advertised ${doc.issuer}`);
|
|
34
|
+
}
|
|
35
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint || !doc.jwks_uri) {
|
|
36
|
+
throw new Error(`OIDC discovery document for ${issuer} is missing required endpoints`);
|
|
37
|
+
}
|
|
38
|
+
this.cache.set(issuer, { doc, expiresAt: this.now() + this.ttlMs });
|
|
39
|
+
return doc;
|
|
40
|
+
}
|
|
41
|
+
/** Drop the cache (test helper / manual rotation). */
|
|
42
|
+
invalidate(issuer) {
|
|
43
|
+
if (issuer)
|
|
44
|
+
this.cache.delete(issuer);
|
|
45
|
+
else
|
|
46
|
+
this.cache.clear();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DiscoveryClient } from "./discovery.js";
|
|
4
|
+
function mockFetch(map) {
|
|
5
|
+
return async (url) => {
|
|
6
|
+
const r = map[url];
|
|
7
|
+
if (!r)
|
|
8
|
+
return new Response("not found", { status: 404 });
|
|
9
|
+
return new Response(JSON.stringify(r.body), { status: r.status, headers: { "content-type": "application/json" } });
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const happyDoc = {
|
|
13
|
+
issuer: "https://idp.test",
|
|
14
|
+
authorization_endpoint: "https://idp.test/auth",
|
|
15
|
+
token_endpoint: "https://idp.test/token",
|
|
16
|
+
jwks_uri: "https://idp.test/jwks",
|
|
17
|
+
};
|
|
18
|
+
test("DiscoveryClient — fetches and returns the doc", async () => {
|
|
19
|
+
const client = new DiscoveryClient({
|
|
20
|
+
fetcher: mockFetch({ "https://idp.test/.well-known/openid-configuration": { status: 200, body: happyDoc } }),
|
|
21
|
+
});
|
|
22
|
+
const d = await client.discover("https://idp.test");
|
|
23
|
+
assert.equal(d.token_endpoint, "https://idp.test/token");
|
|
24
|
+
});
|
|
25
|
+
test("DiscoveryClient — caches within TTL, refetches after expiry", async () => {
|
|
26
|
+
let calls = 0;
|
|
27
|
+
const fetcher = async (url) => {
|
|
28
|
+
calls++;
|
|
29
|
+
return new Response(JSON.stringify(happyDoc), { status: 200 });
|
|
30
|
+
};
|
|
31
|
+
let now = 1_000_000;
|
|
32
|
+
const client = new DiscoveryClient({ fetcher, ttlMs: 5_000, now: () => now });
|
|
33
|
+
await client.discover("https://idp.test");
|
|
34
|
+
await client.discover("https://idp.test");
|
|
35
|
+
assert.equal(calls, 1, "second call within TTL should hit cache");
|
|
36
|
+
now += 6_000;
|
|
37
|
+
await client.discover("https://idp.test");
|
|
38
|
+
assert.equal(calls, 2, "after TTL expiry a fresh fetch should happen");
|
|
39
|
+
});
|
|
40
|
+
test("DiscoveryClient — rejects HTTP failure", async () => {
|
|
41
|
+
const client = new DiscoveryClient({
|
|
42
|
+
fetcher: mockFetch({ "https://idp.test/.well-known/openid-configuration": { status: 500, body: { error: "boom" } } }),
|
|
43
|
+
});
|
|
44
|
+
await assert.rejects(client.discover("https://idp.test"), /HTTP 500/);
|
|
45
|
+
});
|
|
46
|
+
test("DiscoveryClient — rejects issuer mismatch (RFC 8414 §3)", async () => {
|
|
47
|
+
const lying = { ...happyDoc, issuer: "https://other.example" };
|
|
48
|
+
const client = new DiscoveryClient({
|
|
49
|
+
fetcher: mockFetch({ "https://idp.test/.well-known/openid-configuration": { status: 200, body: lying } }),
|
|
50
|
+
});
|
|
51
|
+
await assert.rejects(client.discover("https://idp.test"), /issuer mismatch/);
|
|
52
|
+
});
|
|
53
|
+
test("DiscoveryClient — rejects missing required endpoints", async () => {
|
|
54
|
+
const broken = { issuer: "https://idp.test" };
|
|
55
|
+
const client = new DiscoveryClient({
|
|
56
|
+
fetcher: mockFetch({ "https://idp.test/.well-known/openid-configuration": { status: 200, body: broken } }),
|
|
57
|
+
});
|
|
58
|
+
await assert.rejects(client.discover("https://idp.test"), /missing required endpoints/);
|
|
59
|
+
});
|
|
60
|
+
test("DiscoveryClient — trailing slash on issuer is normalised", async () => {
|
|
61
|
+
let captured = "";
|
|
62
|
+
const client = new DiscoveryClient({
|
|
63
|
+
fetcher: async (url) => { captured = url; return new Response(JSON.stringify({ ...happyDoc, issuer: "https://idp.test/" }), { status: 200 }); },
|
|
64
|
+
});
|
|
65
|
+
// Caller passes issuer with trailing slash; URL should still be canonical.
|
|
66
|
+
await client.discover("https://idp.test/");
|
|
67
|
+
assert.equal(captured, "https://idp.test/.well-known/openid-configuration");
|
|
68
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express handlers for the OIDC code flow.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/auth/oidc/login redirect to IdP
|
|
5
|
+
* GET /api/auth/oidc/callback exchange code → mint OMCP session
|
|
6
|
+
* POST /api/auth/oidc/logout clear session (+ optional IdP RP-init logout)
|
|
7
|
+
*
|
|
8
|
+
* The handlers are HTTP-framework-aware (they use Express's Request /
|
|
9
|
+
* Response) but otherwise pure; the OIDC client + role resolver come
|
|
10
|
+
* from the runtime built in `./runtime.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import type { Application } from "express";
|
|
13
|
+
import type { OidcRuntime } from "./runtime.js";
|
|
14
|
+
import { type SessionConfig } from "../session.js";
|
|
15
|
+
export interface OidcEndpointDeps {
|
|
16
|
+
sessionCfg: SessionConfig;
|
|
17
|
+
oidc: OidcRuntime;
|
|
18
|
+
}
|
|
19
|
+
/** Register the three OIDC endpoints on the given Express app. */
|
|
20
|
+
export declare function registerOidcRoutes(app: Application, deps: OidcEndpointDeps): void;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express handlers for the OIDC code flow.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/auth/oidc/login redirect to IdP
|
|
5
|
+
* GET /api/auth/oidc/callback exchange code → mint OMCP session
|
|
6
|
+
* POST /api/auth/oidc/logout clear session (+ optional IdP RP-init logout)
|
|
7
|
+
*
|
|
8
|
+
* The handlers are HTTP-framework-aware (they use Express's Request /
|
|
9
|
+
* Response) but otherwise pure; the OIDC client + role resolver come
|
|
10
|
+
* from the runtime built in `./runtime.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { issueSession, setCookieHeader, clearCookieHeader } from "../session.js";
|
|
13
|
+
import { issueFlowCookie, verifyFlowCookie, setFlowCookieHeader, clearFlowCookieHeader, readFlowCookie, isSafeReturnTo, } from "./flow-cookie.js";
|
|
14
|
+
function isSecure(req) {
|
|
15
|
+
return req.secure || req.headers["x-forwarded-proto"] === "https";
|
|
16
|
+
}
|
|
17
|
+
/** Register the three OIDC endpoints on the given Express app. */
|
|
18
|
+
export function registerOidcRoutes(app, deps) {
|
|
19
|
+
const { sessionCfg, oidc } = deps;
|
|
20
|
+
const flowCfg = { secret: sessionCfg.secret };
|
|
21
|
+
app.get("/api/auth/oidc/login", async (req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
const requested = typeof req.query.return_to === "string" ? req.query.return_to : "/";
|
|
24
|
+
const returnTo = isSafeReturnTo(requested) ? requested : "/";
|
|
25
|
+
const start = await oidc.client.start();
|
|
26
|
+
const cookie = issueFlowCookie({ state: start.flow.state, nonce: start.flow.nonce, codeVerifier: start.flow.codeVerifier, returnTo }, flowCfg);
|
|
27
|
+
res.setHeader("Set-Cookie", setFlowCookieHeader(cookie, flowCfg, { secure: isSecure(req) }));
|
|
28
|
+
res.redirect(302, start.authorizeUrl);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
respondError(res, 502, "oidc_start_failed", e.message);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
app.get("/api/auth/oidc/callback", async (req, res) => {
|
|
35
|
+
const code = typeof req.query.code === "string" ? req.query.code : "";
|
|
36
|
+
const state = typeof req.query.state === "string" ? req.query.state : "";
|
|
37
|
+
const errParam = typeof req.query.error === "string" ? req.query.error : "";
|
|
38
|
+
if (errParam) {
|
|
39
|
+
// The IdP redirected with an error (user cancelled, consent
|
|
40
|
+
// denied, …). Surface plainly; no token exchange needed.
|
|
41
|
+
res.setHeader("Set-Cookie", clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }));
|
|
42
|
+
respondError(res, 400, "oidc_idp_error", errParam);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!code || !state) {
|
|
46
|
+
// Symmetric with the other early-return paths — once the
|
|
47
|
+
// callback aborts, the flow cookie has no further use; clearing
|
|
48
|
+
// it eagerly avoids reuse on a refresh.
|
|
49
|
+
res.setHeader("Set-Cookie", clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }));
|
|
50
|
+
respondError(res, 400, "oidc_missing_code_or_state", "callback requires both code and state query params");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const flowCookieValue = readFlowCookie(req.headers.cookie);
|
|
54
|
+
const flow = verifyFlowCookie(flowCookieValue, flowCfg);
|
|
55
|
+
if (!flow) {
|
|
56
|
+
respondError(res, 400, "oidc_flow_cookie_missing", "no valid flow cookie (expired or absent — please restart login)");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let result;
|
|
60
|
+
try {
|
|
61
|
+
result = await oidc.client.complete({
|
|
62
|
+
code,
|
|
63
|
+
state,
|
|
64
|
+
flow: { state: flow.state, nonce: flow.nonce, codeVerifier: flow.codeVerifier },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
res.setHeader("Set-Cookie", clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }));
|
|
69
|
+
respondError(res, 400, "oidc_token_exchange_failed", e.message);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const claims = result.claims;
|
|
73
|
+
const sub = sanitiseClaim(claims.sub) ?? "unknown";
|
|
74
|
+
const name = sanitiseClaim(claims.name)
|
|
75
|
+
?? sanitiseClaim(claims.preferred_username)
|
|
76
|
+
?? sanitiseClaim(claims.email)
|
|
77
|
+
?? sub;
|
|
78
|
+
// Only persist email when the IdP marked it verified — an
|
|
79
|
+
// unverified email is operator-supplied user input from the
|
|
80
|
+
// IdP's perspective and shouldn't appear next to a name in
|
|
81
|
+
// an admin UI as if it were authoritative. When the claim is
|
|
82
|
+
// absent we trust it (most IdPs default to verified for the
|
|
83
|
+
// primary identity).
|
|
84
|
+
const emailVerified = claims.email_verified === undefined || claims.email_verified === true;
|
|
85
|
+
const email = emailVerified ? sanitiseClaim(claims.email) : undefined;
|
|
86
|
+
const roles = oidc.resolveRoles(claims);
|
|
87
|
+
const tenant = oidc.resolveTenant(claims);
|
|
88
|
+
const { cookie } = issueSession({ sub, name, email, roles, tenant }, sessionCfg);
|
|
89
|
+
// Two cookies: clear the now-spent flow cookie, set the long-lived
|
|
90
|
+
// session cookie. The browser accepts both in a single response.
|
|
91
|
+
res.setHeader("Set-Cookie", [
|
|
92
|
+
clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }),
|
|
93
|
+
setCookieHeader(cookie, sessionCfg, { secure: isSecure(req) }),
|
|
94
|
+
]);
|
|
95
|
+
res.redirect(302, flow.returnTo);
|
|
96
|
+
});
|
|
97
|
+
app.post("/api/auth/oidc/logout", (req, res) => {
|
|
98
|
+
res.setHeader("Set-Cookie", clearCookieHeader(sessionCfg, { secure: isSecure(req) }));
|
|
99
|
+
// RP-initiated logout via the discovery doc's end_session_endpoint
|
|
100
|
+
// is intentionally out of scope for slice 3 (we'd need to ferry
|
|
101
|
+
// the id_token through the session payload). Operators wanting an
|
|
102
|
+
// IdP-side logout can configure `OMCP_OIDC_LOGOUT_REDIRECT` to
|
|
103
|
+
// point at their IdP's end-session URL — we 200 here and the UI
|
|
104
|
+
// navigates the user.
|
|
105
|
+
res.status(204).end();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function respondError(res, status, code, message) {
|
|
109
|
+
res.status(status).json({ error: code, message });
|
|
110
|
+
}
|
|
111
|
+
/** Normalise an IdP-provided claim before we stuff it in the session
|
|
112
|
+
* cookie: must be a non-empty string, length-capped (so a hostile
|
|
113
|
+
* IdP can't blow up the cookie), control-character-stripped (so
|
|
114
|
+
* downstream UIs that render it via innerHTML aren't a vector).
|
|
115
|
+
* Returns undefined when the claim isn't usable. */
|
|
116
|
+
function sanitiseClaim(v) {
|
|
117
|
+
if (typeof v !== "string")
|
|
118
|
+
return undefined;
|
|
119
|
+
// Strip control characters; keep printable.
|
|
120
|
+
const cleaned = v.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
121
|
+
if (cleaned.length === 0)
|
|
122
|
+
return undefined;
|
|
123
|
+
return cleaned.slice(0, 200);
|
|
124
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the three OIDC HTTP endpoints. Boots a real
|
|
3
|
+
* Express app, registers the routes against a stubbed OidcClient
|
|
4
|
+
* (mock fetcher) so the IdP round-trip is in-process, and walks
|
|
5
|
+
* through the redirect → callback → session-cookie flow end-to-end.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|