@thotischner/observability-mcp 1.7.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/products.yaml.example +48 -0
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +108 -0
- package/dist/audit/log.js +200 -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/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -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/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -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 +168 -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/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +64 -0
- package/dist/auth/policy/engine.js +87 -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 +45 -0
- package/dist/auth/policy/loader.js +137 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -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/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +45 -1
- package/dist/context.js +40 -1
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +2124 -73
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/net/egress-policy.js +2 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -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/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -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/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -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/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +3083 -88
- package/package.json +32 -5
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { validateDcrRequest, mintRegistration, appendRegistration, loadRegistrations, toResponse, dcrEnabled, dcrStorePath, DcrValidationError, } from "./dcr.js";
|
|
7
|
+
function tmp() {
|
|
8
|
+
return join(mkdtempSync(join(tmpdir(), "dcr-")), "dcr.json");
|
|
9
|
+
}
|
|
10
|
+
test("dcrEnabled — true/1/yes/on (any case), unset = false", () => {
|
|
11
|
+
for (const v of ["true", "1", "yes", "on", "TRUE", "Yes"]) {
|
|
12
|
+
assert.equal(dcrEnabled({ OMCP_OIDC_DCR_ENABLED: v }), true, v);
|
|
13
|
+
}
|
|
14
|
+
for (const v of ["", "false", "0", "anything-else"]) {
|
|
15
|
+
assert.equal(dcrEnabled({ OMCP_OIDC_DCR_ENABLED: v }), false, v);
|
|
16
|
+
}
|
|
17
|
+
assert.equal(dcrEnabled({}), false);
|
|
18
|
+
});
|
|
19
|
+
test("dcrStorePath — defaults to /tmp/oidc-dcr.json, env override wins", () => {
|
|
20
|
+
assert.equal(dcrStorePath({}), "/tmp/oidc-dcr.json");
|
|
21
|
+
assert.equal(dcrStorePath({ OMCP_OIDC_DCR_STORE: "/var/lib/dcr.json" }), "/var/lib/dcr.json");
|
|
22
|
+
});
|
|
23
|
+
test("validateDcrRequest — requires non-empty redirect_uris", () => {
|
|
24
|
+
assert.throws(() => validateDcrRequest({}), DcrValidationError);
|
|
25
|
+
assert.throws(() => validateDcrRequest({ redirect_uris: [] }), DcrValidationError);
|
|
26
|
+
});
|
|
27
|
+
test("validateDcrRequest — rejects http:// for non-loopback hosts", () => {
|
|
28
|
+
assert.throws(() => validateDcrRequest({ redirect_uris: ["http://example.com/cb"] }), /must use https/);
|
|
29
|
+
assert.doesNotThrow(() => validateDcrRequest({ redirect_uris: ["http://localhost:5173/cb"] }));
|
|
30
|
+
assert.doesNotThrow(() => validateDcrRequest({ redirect_uris: ["http://127.0.0.1:5173/cb"] }));
|
|
31
|
+
});
|
|
32
|
+
test("validateDcrRequest — accepts https:// always", () => {
|
|
33
|
+
const v = validateDcrRequest({
|
|
34
|
+
redirect_uris: ["https://app.example.com/oauth/callback"],
|
|
35
|
+
});
|
|
36
|
+
assert.deepEqual(v.redirect_uris, ["https://app.example.com/oauth/callback"]);
|
|
37
|
+
});
|
|
38
|
+
test("validateDcrRequest — defaults for grant_types/response_types/auth_method", () => {
|
|
39
|
+
const v = validateDcrRequest({ redirect_uris: ["https://x/cb"] });
|
|
40
|
+
assert.deepEqual(v.grant_types, ["authorization_code"]);
|
|
41
|
+
assert.deepEqual(v.response_types, ["code"]);
|
|
42
|
+
assert.equal(v.token_endpoint_auth_method, "client_secret_basic");
|
|
43
|
+
});
|
|
44
|
+
test("validateDcrRequest — preserves explicit values", () => {
|
|
45
|
+
const v = validateDcrRequest({
|
|
46
|
+
redirect_uris: ["https://x/cb"],
|
|
47
|
+
grant_types: ["refresh_token"],
|
|
48
|
+
response_types: ["code id_token"],
|
|
49
|
+
token_endpoint_auth_method: "none",
|
|
50
|
+
client_name: "Claude.ai",
|
|
51
|
+
scope: "openid profile",
|
|
52
|
+
});
|
|
53
|
+
assert.deepEqual(v.grant_types, ["refresh_token"]);
|
|
54
|
+
assert.equal(v.token_endpoint_auth_method, "none");
|
|
55
|
+
assert.equal(v.client_name, "Claude.ai");
|
|
56
|
+
assert.equal(v.scope, "openid profile");
|
|
57
|
+
});
|
|
58
|
+
test("mintRegistration — issues client_id (UUID), client_secret (base64url), no secret for 'none' auth", () => {
|
|
59
|
+
const now = new Date("2026-06-05T20:00:00Z");
|
|
60
|
+
const validated = validateDcrRequest({ redirect_uris: ["https://x/cb"] });
|
|
61
|
+
const reg = mintRegistration(validated, "10.0.0.1", { now: () => now });
|
|
62
|
+
assert.match(reg.client_id, /^[0-9a-f-]{36}$/);
|
|
63
|
+
assert.ok(reg.client_secret && reg.client_secret.length > 30);
|
|
64
|
+
assert.equal(reg.client_id_issued_at, Math.floor(now.getTime() / 1000));
|
|
65
|
+
assert.equal(reg.client_secret_expires_at, 0);
|
|
66
|
+
assert.ok(reg.registration_access_token && reg.registration_access_token.length > 30);
|
|
67
|
+
assert.equal(reg._meta.sourceIp, "10.0.0.1");
|
|
68
|
+
assert.equal(reg._meta.createdAtIso, now.toISOString());
|
|
69
|
+
// Public client (PKCE, no secret) — RFC 7591 §3.2.1
|
|
70
|
+
const pub = mintRegistration(validateDcrRequest({
|
|
71
|
+
redirect_uris: ["https://x/cb"],
|
|
72
|
+
token_endpoint_auth_method: "none",
|
|
73
|
+
}), "10.0.0.1", { now: () => now });
|
|
74
|
+
assert.equal(pub.client_secret, undefined);
|
|
75
|
+
});
|
|
76
|
+
test("appendRegistration + loadRegistrations — round-trips, file is 0600", async () => {
|
|
77
|
+
const store = tmp();
|
|
78
|
+
const validated = validateDcrRequest({ redirect_uris: ["https://x/cb"] });
|
|
79
|
+
const reg = mintRegistration(validated, "10.0.0.7");
|
|
80
|
+
await appendRegistration(store, reg);
|
|
81
|
+
const loaded = await loadRegistrations(store);
|
|
82
|
+
assert.equal(loaded.length, 1);
|
|
83
|
+
assert.equal(loaded[0]?.client_id, reg.client_id);
|
|
84
|
+
// File-mode check — DCR registrations contain secrets.
|
|
85
|
+
const mode = statSync(store).mode & 0o777;
|
|
86
|
+
assert.equal(mode, 0o600, `expected mode 0o600 got ${mode.toString(8)}`);
|
|
87
|
+
});
|
|
88
|
+
test("loadRegistrations — missing file returns []", async () => {
|
|
89
|
+
const store = tmp();
|
|
90
|
+
// No file written.
|
|
91
|
+
const loaded = await loadRegistrations(store);
|
|
92
|
+
assert.deepEqual(loaded, []);
|
|
93
|
+
});
|
|
94
|
+
test("toResponse — strips internal _meta so secrets don't leak source IP", () => {
|
|
95
|
+
const validated = validateDcrRequest({ redirect_uris: ["https://x/cb"] });
|
|
96
|
+
const reg = mintRegistration(validated, "10.0.0.7");
|
|
97
|
+
const response = toResponse(reg);
|
|
98
|
+
assert.equal(response._meta, undefined);
|
|
99
|
+
assert.equal(response.client_id, reg.client_id);
|
|
100
|
+
assert.equal(response.client_secret, reg.client_secret);
|
|
101
|
+
});
|
|
102
|
+
test("appendRegistration — atomic write (tmp+rename) survives a missing parent dir", async () => {
|
|
103
|
+
const parent = mkdtempSync(join(tmpdir(), "dcr-parent-"));
|
|
104
|
+
const store = join(parent, "sub", "nested", "dcr.json");
|
|
105
|
+
const reg = mintRegistration(validateDcrRequest({ redirect_uris: ["https://x/cb"] }), "10.0.0.7");
|
|
106
|
+
await appendRegistration(store, reg);
|
|
107
|
+
const onDisk = JSON.parse(readFileSync(store, "utf8"));
|
|
108
|
+
assert.equal(onDisk.length, 1);
|
|
109
|
+
});
|
|
@@ -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,168 @@
|
|
|
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 rateLimit from "express-rate-limit";
|
|
13
|
+
import { issueSession, setCookieHeader, clearCookieHeader } from "../session.js";
|
|
14
|
+
import { issueFlowCookie, verifyFlowCookie, setFlowCookieHeader, clearFlowCookieHeader, readFlowCookie, isSafeReturnTo, } from "./flow-cookie.js";
|
|
15
|
+
import { dcrEnabled, dcrStorePath, validateDcrRequest, mintRegistration, appendRegistration, toResponse, DcrValidationError, } from "./dcr.js";
|
|
16
|
+
function isSecure(req) {
|
|
17
|
+
return req.secure || req.headers["x-forwarded-proto"] === "https";
|
|
18
|
+
}
|
|
19
|
+
/** Register the three OIDC endpoints on the given Express app. */
|
|
20
|
+
export function registerOidcRoutes(app, deps) {
|
|
21
|
+
const { sessionCfg, oidc } = deps;
|
|
22
|
+
const flowCfg = { secret: sessionCfg.secret };
|
|
23
|
+
app.get("/api/auth/oidc/login", async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const requested = typeof req.query.return_to === "string" ? req.query.return_to : "/";
|
|
26
|
+
const returnTo = isSafeReturnTo(requested) ? requested : "/";
|
|
27
|
+
const start = await oidc.client.start();
|
|
28
|
+
const cookie = issueFlowCookie({ state: start.flow.state, nonce: start.flow.nonce, codeVerifier: start.flow.codeVerifier, returnTo }, flowCfg);
|
|
29
|
+
res.setHeader("Set-Cookie", setFlowCookieHeader(cookie, flowCfg, { secure: isSecure(req) }));
|
|
30
|
+
res.redirect(302, start.authorizeUrl);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
respondError(res, 502, "oidc_start_failed", e.message);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
app.get("/api/auth/oidc/callback", async (req, res) => {
|
|
37
|
+
const code = typeof req.query.code === "string" ? req.query.code : "";
|
|
38
|
+
const state = typeof req.query.state === "string" ? req.query.state : "";
|
|
39
|
+
const errParam = typeof req.query.error === "string" ? req.query.error : "";
|
|
40
|
+
if (errParam) {
|
|
41
|
+
// The IdP redirected with an error (user cancelled, consent
|
|
42
|
+
// denied, …). Surface plainly; no token exchange needed.
|
|
43
|
+
res.setHeader("Set-Cookie", clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }));
|
|
44
|
+
respondError(res, 400, "oidc_idp_error", errParam);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!code || !state) {
|
|
48
|
+
// Symmetric with the other early-return paths — once the
|
|
49
|
+
// callback aborts, the flow cookie has no further use; clearing
|
|
50
|
+
// it eagerly avoids reuse on a refresh.
|
|
51
|
+
res.setHeader("Set-Cookie", clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }));
|
|
52
|
+
respondError(res, 400, "oidc_missing_code_or_state", "callback requires both code and state query params");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const flowCookieValue = readFlowCookie(req.headers.cookie);
|
|
56
|
+
const flow = verifyFlowCookie(flowCookieValue, flowCfg);
|
|
57
|
+
if (!flow) {
|
|
58
|
+
respondError(res, 400, "oidc_flow_cookie_missing", "no valid flow cookie (expired or absent — please restart login)");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await oidc.client.complete({
|
|
64
|
+
code,
|
|
65
|
+
state,
|
|
66
|
+
flow: { state: flow.state, nonce: flow.nonce, codeVerifier: flow.codeVerifier },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
res.setHeader("Set-Cookie", clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }));
|
|
71
|
+
respondError(res, 400, "oidc_token_exchange_failed", e.message);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const claims = result.claims;
|
|
75
|
+
const sub = sanitiseClaim(claims.sub) ?? "unknown";
|
|
76
|
+
const name = sanitiseClaim(claims.name)
|
|
77
|
+
?? sanitiseClaim(claims.preferred_username)
|
|
78
|
+
?? sanitiseClaim(claims.email)
|
|
79
|
+
?? sub;
|
|
80
|
+
// Only persist email when the IdP marked it verified — an
|
|
81
|
+
// unverified email is operator-supplied user input from the
|
|
82
|
+
// IdP's perspective and shouldn't appear next to a name in
|
|
83
|
+
// an admin UI as if it were authoritative. When the claim is
|
|
84
|
+
// absent we trust it (most IdPs default to verified for the
|
|
85
|
+
// primary identity).
|
|
86
|
+
const emailVerified = claims.email_verified === undefined || claims.email_verified === true;
|
|
87
|
+
const email = emailVerified ? sanitiseClaim(claims.email) : undefined;
|
|
88
|
+
const roles = oidc.resolveRoles(claims);
|
|
89
|
+
const tenant = oidc.resolveTenant(claims);
|
|
90
|
+
const { cookie } = issueSession({ sub, name, email, roles, tenant }, sessionCfg);
|
|
91
|
+
// Two cookies: clear the now-spent flow cookie, set the long-lived
|
|
92
|
+
// session cookie. The browser accepts both in a single response.
|
|
93
|
+
res.setHeader("Set-Cookie", [
|
|
94
|
+
clearFlowCookieHeader(flowCfg, { secure: isSecure(req) }),
|
|
95
|
+
setCookieHeader(cookie, sessionCfg, { secure: isSecure(req) }),
|
|
96
|
+
]);
|
|
97
|
+
res.redirect(302, flow.returnTo);
|
|
98
|
+
});
|
|
99
|
+
app.post("/api/auth/oidc/logout", (req, res) => {
|
|
100
|
+
res.setHeader("Set-Cookie", clearCookieHeader(sessionCfg, { secure: isSecure(req) }));
|
|
101
|
+
// RP-initiated logout via the discovery doc's end_session_endpoint
|
|
102
|
+
// is intentionally out of scope for slice 3 (we'd need to ferry
|
|
103
|
+
// the id_token through the session payload). Operators wanting an
|
|
104
|
+
// IdP-side logout can configure `OMCP_OIDC_LOGOUT_REDIRECT` to
|
|
105
|
+
// point at their IdP's end-session URL — we 200 here and the UI
|
|
106
|
+
// navigates the user.
|
|
107
|
+
res.status(204).end();
|
|
108
|
+
});
|
|
109
|
+
// RFC 7591 Dynamic Client Registration. Off by default; flip
|
|
110
|
+
// OMCP_OIDC_DCR_ENABLED=true to accept self-registration POSTs
|
|
111
|
+
// (Claude.ai / Cursor / future MCP clients use this to introduce
|
|
112
|
+
// themselves to the gateway). Registrations land at
|
|
113
|
+
// OMCP_OIDC_DCR_STORE (default /tmp/oidc-dcr.json, mode 0600).
|
|
114
|
+
if (dcrEnabled()) {
|
|
115
|
+
const storePath = dcrStorePath();
|
|
116
|
+
// Per-source-IP throttle: 10 registrations per IP per hour. The
|
|
117
|
+
// endpoint is unauthenticated by design (RFC 7591) so without a
|
|
118
|
+
// limiter a single misbehaving client can fill the JSON store.
|
|
119
|
+
// Operators that need a different rate front the gateway with
|
|
120
|
+
// their ingress limiter and lift this floor accordingly.
|
|
121
|
+
const dcrLimiter = rateLimit({
|
|
122
|
+
windowMs: 60 * 60 * 1000,
|
|
123
|
+
max: 10,
|
|
124
|
+
standardHeaders: true,
|
|
125
|
+
legacyHeaders: false,
|
|
126
|
+
message: { error: "rate_limit_exceeded", error_description: "DCR rate limit hit; try later" },
|
|
127
|
+
});
|
|
128
|
+
app.post("/api/auth/oidc/register", dcrLimiter, async (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const validated = validateDcrRequest((req.body ?? {}));
|
|
131
|
+
const sourceIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
|
132
|
+
req.ip ||
|
|
133
|
+
"unknown";
|
|
134
|
+
const reg = mintRegistration(validated, sourceIp);
|
|
135
|
+
await appendRegistration(storePath, reg);
|
|
136
|
+
res.status(201).json(toResponse(reg));
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
if (e instanceof DcrValidationError) {
|
|
140
|
+
// RFC 7591 §3.2.2 error response shape.
|
|
141
|
+
res.status(400).json({
|
|
142
|
+
error: e.error,
|
|
143
|
+
error_description: e.message,
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
respondError(res, 500, "registration_failed", e.message);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function respondError(res, status, code, message) {
|
|
153
|
+
res.status(status).json({ error: code, message });
|
|
154
|
+
}
|
|
155
|
+
/** Normalise an IdP-provided claim before we stuff it in the session
|
|
156
|
+
* cookie: must be a non-empty string, length-capped (so a hostile
|
|
157
|
+
* IdP can't blow up the cookie), control-character-stripped (so
|
|
158
|
+
* downstream UIs that render it via innerHTML aren't a vector).
|
|
159
|
+
* Returns undefined when the claim isn't usable. */
|
|
160
|
+
function sanitiseClaim(v) {
|
|
161
|
+
if (typeof v !== "string")
|
|
162
|
+
return undefined;
|
|
163
|
+
// Strip control characters; keep printable.
|
|
164
|
+
const cleaned = v.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
165
|
+
if (cleaned.length === 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
return cleaned.slice(0, 200);
|
|
168
|
+
}
|
|
@@ -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 {};
|