@thotischner/observability-mcp 1.8.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/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 +9 -0
- package/dist/audit/log.js +20 -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 +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -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 +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- 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/endpoints.js +44 -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 +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -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 +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -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 +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -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 +32 -0
- package/dist/context.js +35 -0
- 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 +1188 -120
- 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/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 +215 -7
- package/dist/openapi.test.js +34 -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 +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- 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 +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -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/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 +1729 -100
- package/package.json +13 -3
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { buildCsrfIssuer, buildCsrfEnforcer, newCsrfToken, csrfBypassFromEnv, constantTimeStringEquals, CSRF_COOKIE, CSRF_HEADER, } from "./csrf.js";
|
|
5
|
+
class MockRes extends EventEmitter {
|
|
6
|
+
status_ = 200;
|
|
7
|
+
headers = {};
|
|
8
|
+
body;
|
|
9
|
+
status(code) {
|
|
10
|
+
this.status_ = code;
|
|
11
|
+
return this;
|
|
12
|
+
}
|
|
13
|
+
json(body) {
|
|
14
|
+
this.body = body;
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
setHeader(name, value) {
|
|
18
|
+
this.headers[name] = value;
|
|
19
|
+
}
|
|
20
|
+
getHeader(name) {
|
|
21
|
+
return this.headers[name];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function call(mw, req) {
|
|
25
|
+
const res = new MockRes();
|
|
26
|
+
let nexted = false;
|
|
27
|
+
mw(req, res, () => {
|
|
28
|
+
nexted = true;
|
|
29
|
+
});
|
|
30
|
+
return { res, nexted };
|
|
31
|
+
}
|
|
32
|
+
function defaultCfg(overrides = {}) {
|
|
33
|
+
return {
|
|
34
|
+
bypassBearer: overrides.bypassBearer ?? true,
|
|
35
|
+
secureCookie: () => false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
test("newCsrfToken: returns base64url, 32-byte (43-44 char) string", () => {
|
|
39
|
+
const t = newCsrfToken();
|
|
40
|
+
assert.match(t, /^[A-Za-z0-9_-]+$/);
|
|
41
|
+
assert.ok(t.length >= 42 && t.length <= 44, `unexpected length ${t.length}`);
|
|
42
|
+
assert.notEqual(newCsrfToken(), newCsrfToken(), "tokens must differ");
|
|
43
|
+
});
|
|
44
|
+
test("constantTimeStringEquals: matches equal, rejects different lengths + values", () => {
|
|
45
|
+
assert.equal(constantTimeStringEquals("abc", "abc"), true);
|
|
46
|
+
assert.equal(constantTimeStringEquals("abc", "abd"), false);
|
|
47
|
+
assert.equal(constantTimeStringEquals("abc", "abcd"), false);
|
|
48
|
+
assert.equal(constantTimeStringEquals("", ""), true);
|
|
49
|
+
});
|
|
50
|
+
test("csrfBypassFromEnv: defaults true, only literal off values opt out", () => {
|
|
51
|
+
assert.equal(csrfBypassFromEnv({}), true);
|
|
52
|
+
assert.equal(csrfBypassFromEnv({ OMCP_CSRF_BYPASS_BEARER: "true" }), true);
|
|
53
|
+
for (const v of ["0", "false", "no", "off", "FALSE", "Off"]) {
|
|
54
|
+
assert.equal(csrfBypassFromEnv({ OMCP_CSRF_BYPASS_BEARER: v }), false, v);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("issuer: sets cookie when missing, no-op when present", () => {
|
|
58
|
+
const mw = buildCsrfIssuer(defaultCfg());
|
|
59
|
+
// Missing cookie -> set
|
|
60
|
+
const r1 = call(mw, { headers: {} });
|
|
61
|
+
assert.equal(r1.nexted, true);
|
|
62
|
+
const set = r1.res.getHeader("Set-Cookie");
|
|
63
|
+
assert.match(set, /^omcp-csrf=[A-Za-z0-9_-]+;/);
|
|
64
|
+
assert.match(set, /Path=\//);
|
|
65
|
+
assert.match(set, /SameSite=Lax/);
|
|
66
|
+
assert.doesNotMatch(set, /HttpOnly/);
|
|
67
|
+
// Present cookie -> no Set-Cookie emitted
|
|
68
|
+
const r2 = call(mw, { headers: { cookie: "omcp-csrf=abc" } });
|
|
69
|
+
assert.equal(r2.nexted, true);
|
|
70
|
+
assert.equal(r2.res.getHeader("Set-Cookie"), undefined);
|
|
71
|
+
});
|
|
72
|
+
test("issuer: Secure flag honors secureCookie callback", () => {
|
|
73
|
+
const mw = buildCsrfIssuer({ bypassBearer: true, secureCookie: () => true });
|
|
74
|
+
const r = call(mw, { headers: {} });
|
|
75
|
+
assert.match(r.res.getHeader("Set-Cookie"), /Secure/);
|
|
76
|
+
});
|
|
77
|
+
test("enforcer: GET/HEAD/OPTIONS always pass", () => {
|
|
78
|
+
const mw = buildCsrfEnforcer(defaultCfg());
|
|
79
|
+
for (const m of ["GET", "HEAD", "OPTIONS"]) {
|
|
80
|
+
const r = call(mw, { method: m, headers: {} });
|
|
81
|
+
assert.equal(r.nexted, true, m);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
test("enforcer: bearer auth bypasses CSRF when bypassBearer=true", () => {
|
|
85
|
+
const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
|
|
86
|
+
const r = call(mw, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { authorization: "Bearer abc.def.ghi" },
|
|
89
|
+
});
|
|
90
|
+
assert.equal(r.nexted, true);
|
|
91
|
+
});
|
|
92
|
+
test("enforcer: X-API-Key also bypasses when bypassBearer=true", () => {
|
|
93
|
+
const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
|
|
94
|
+
const r = call(mw, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "x-api-key": "abc" },
|
|
97
|
+
});
|
|
98
|
+
assert.equal(r.nexted, true);
|
|
99
|
+
});
|
|
100
|
+
test("enforcer: bypassBearer=false requires CSRF even for bearer clients", () => {
|
|
101
|
+
const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: false }));
|
|
102
|
+
const r = call(mw, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { authorization: "Bearer abc" },
|
|
105
|
+
});
|
|
106
|
+
assert.equal(r.nexted, false);
|
|
107
|
+
assert.equal(r.res.status_, 403);
|
|
108
|
+
});
|
|
109
|
+
test("enforcer: cookie-session POST without header is rejected with 403", () => {
|
|
110
|
+
const mw = buildCsrfEnforcer(defaultCfg());
|
|
111
|
+
const r = call(mw, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { cookie: "omcp-csrf=tok123" },
|
|
114
|
+
});
|
|
115
|
+
assert.equal(r.nexted, false);
|
|
116
|
+
assert.equal(r.res.status_, 403);
|
|
117
|
+
});
|
|
118
|
+
test("enforcer: cookie + matching header passes", () => {
|
|
119
|
+
const mw = buildCsrfEnforcer(defaultCfg());
|
|
120
|
+
const r = call(mw, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { cookie: `${CSRF_COOKIE}=tok123`, [CSRF_HEADER]: "tok123" },
|
|
123
|
+
});
|
|
124
|
+
assert.equal(r.nexted, true);
|
|
125
|
+
});
|
|
126
|
+
test("enforcer: header != cookie is rejected (token mismatch attack)", () => {
|
|
127
|
+
const mw = buildCsrfEnforcer(defaultCfg());
|
|
128
|
+
const r = call(mw, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { cookie: `${CSRF_COOKIE}=cookie-token`, [CSRF_HEADER]: "header-token" },
|
|
131
|
+
});
|
|
132
|
+
assert.equal(r.nexted, false);
|
|
133
|
+
assert.equal(r.res.status_, 403);
|
|
134
|
+
});
|
|
135
|
+
test("enforcer: missing cookie + header is rejected (no token at all)", () => {
|
|
136
|
+
const mw = buildCsrfEnforcer(defaultCfg());
|
|
137
|
+
const r = call(mw, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {},
|
|
140
|
+
});
|
|
141
|
+
assert.equal(r.nexted, false);
|
|
142
|
+
assert.equal(r.res.status_, 403);
|
|
143
|
+
});
|
|
@@ -58,5 +58,11 @@ export declare function verifyPassword(plaintext: string, encoded: string): bool
|
|
|
58
58
|
* anonymous mode cleanly.
|
|
59
59
|
*/
|
|
60
60
|
export declare function readUsersFile(path: string): Promise<LocalUsersFile | null>;
|
|
61
|
+
/** Atomic write of the users file. Same tmp+rename pattern the
|
|
62
|
+
* products + token-budget snapshot writers use, so a crash mid-write
|
|
63
|
+
* leaves the previous file intact — never zero-byte. The file is
|
|
64
|
+
* the only persistent source of basic-mode credentials, so a
|
|
65
|
+
* half-write would lock every user out. */
|
|
66
|
+
export declare function writeUsersFile(path: string, file: LocalUsersFile): Promise<void>;
|
|
61
67
|
/** Find a user by username (case-sensitive) and verify the supplied password. */
|
|
62
68
|
export declare function authenticate(username: string, password: string, store: LocalUsersFile): LocalUser | null;
|
package/dist/auth/local-users.js
CHANGED
|
@@ -105,6 +105,17 @@ export async function readUsersFile(path) {
|
|
|
105
105
|
return null;
|
|
106
106
|
return parsed;
|
|
107
107
|
}
|
|
108
|
+
/** Atomic write of the users file. Same tmp+rename pattern the
|
|
109
|
+
* products + token-budget snapshot writers use, so a crash mid-write
|
|
110
|
+
* leaves the previous file intact — never zero-byte. The file is
|
|
111
|
+
* the only persistent source of basic-mode credentials, so a
|
|
112
|
+
* half-write would lock every user out. */
|
|
113
|
+
export async function writeUsersFile(path, file) {
|
|
114
|
+
const text = JSON.stringify(file, null, 2) + "\n";
|
|
115
|
+
const tmp = path + ".tmp";
|
|
116
|
+
await fs.writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
|
|
117
|
+
await fs.rename(tmp, path);
|
|
118
|
+
}
|
|
108
119
|
function isUsersFile(v) {
|
|
109
120
|
if (!v || typeof v !== "object")
|
|
110
121
|
return false;
|
|
@@ -78,3 +78,44 @@ test("authenticate — returns null for wrong password", () => {
|
|
|
78
78
|
};
|
|
79
79
|
assert.equal(authenticate("alice", "wrong", store), null);
|
|
80
80
|
});
|
|
81
|
+
import { writeUsersFile } from "./local-users.js";
|
|
82
|
+
test("writeUsersFile — atomic round-trip preserves shape", async () => {
|
|
83
|
+
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
84
|
+
const { tmpdir } = await import("node:os");
|
|
85
|
+
const { join } = await import("node:path");
|
|
86
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-users-"));
|
|
87
|
+
try {
|
|
88
|
+
const path = join(dir, "users.json");
|
|
89
|
+
const file = {
|
|
90
|
+
users: [
|
|
91
|
+
{ username: "alice", name: "Alice", roles: ["operator", "viewer"], tenant: "acme", passwordHash: "scrypt$dummy" },
|
|
92
|
+
{ username: "bob", name: "Bob", passwordHash: "scrypt$dummy2" },
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
await writeUsersFile(path, file);
|
|
96
|
+
const back = await readUsersFile(path);
|
|
97
|
+
assert.ok(back);
|
|
98
|
+
assert.equal(back.users.length, 2);
|
|
99
|
+
assert.deepEqual(back.users[0].roles, ["operator", "viewer"]);
|
|
100
|
+
assert.equal(back.users[0].tenant, "acme");
|
|
101
|
+
assert.equal(back.users[1].passwordHash, "scrypt$dummy2");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await rm(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test("writeUsersFile — no .tmp leftover after success (atomic rename)", async () => {
|
|
108
|
+
const { mkdtemp, rm, readdir } = await import("node:fs/promises");
|
|
109
|
+
const { tmpdir } = await import("node:os");
|
|
110
|
+
const { join } = await import("node:path");
|
|
111
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-users-tmp-"));
|
|
112
|
+
try {
|
|
113
|
+
const path = join(dir, "users.json");
|
|
114
|
+
await writeUsersFile(path, { users: [{ username: "u", name: "U", passwordHash: "scrypt$x" }] });
|
|
115
|
+
const entries = await readdir(dir);
|
|
116
|
+
assert.deepEqual(entries.sort(), ["users.json"]);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { Request, RequestHandler } from "express";
|
|
15
15
|
import { type SessionPayload, type SessionConfig } from "./session.js";
|
|
16
|
+
import type { OidcRuntime } from "./oidc/runtime.js";
|
|
16
17
|
export type AuthMode = "anonymous" | "basic" | "oidc";
|
|
17
18
|
export interface AuthRuntime {
|
|
18
19
|
mode: AuthMode;
|
|
@@ -23,12 +24,12 @@ export interface AuthRuntime {
|
|
|
23
24
|
* process — sessions will not survive a restart. The wire-up code logs a
|
|
24
25
|
* warning once when this happens. */
|
|
25
26
|
secretEphemeral?: boolean;
|
|
26
|
-
/** OIDC runtime, present only when mode === "oidc".
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
oidc?:
|
|
27
|
+
/** OIDC runtime, present only when mode === "oidc". The OIDC HTTP
|
|
28
|
+
* endpoints in src/index.ts consume it. The import above is
|
|
29
|
+
* type-only and erased at compile time, so this typing adds zero
|
|
30
|
+
* runtime coupling — middleware.ts still doesn't depend on the
|
|
31
|
+
* OIDC sub-module's node:crypto path. */
|
|
32
|
+
oidc?: OidcRuntime;
|
|
32
33
|
}
|
|
33
34
|
export interface AuthedRequest extends Request {
|
|
34
35
|
session?: SessionPayload;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface DcrRegistrationRequest {
|
|
2
|
+
client_name?: string;
|
|
3
|
+
redirect_uris?: string[];
|
|
4
|
+
grant_types?: string[];
|
|
5
|
+
response_types?: string[];
|
|
6
|
+
token_endpoint_auth_method?: string;
|
|
7
|
+
scope?: string;
|
|
8
|
+
[k: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface DcrRegistrationResponse {
|
|
11
|
+
client_id: string;
|
|
12
|
+
client_secret?: string;
|
|
13
|
+
client_id_issued_at: number;
|
|
14
|
+
client_secret_expires_at: number;
|
|
15
|
+
registration_access_token: string;
|
|
16
|
+
client_name?: string;
|
|
17
|
+
redirect_uris: string[];
|
|
18
|
+
grant_types: string[];
|
|
19
|
+
response_types: string[];
|
|
20
|
+
token_endpoint_auth_method: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface DcrStoreEntry extends DcrRegistrationResponse {
|
|
24
|
+
_meta: {
|
|
25
|
+
sourceIp: string;
|
|
26
|
+
createdAtIso: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export declare class DcrValidationError extends Error {
|
|
30
|
+
readonly error: string;
|
|
31
|
+
constructor(error: string, message: string);
|
|
32
|
+
}
|
|
33
|
+
/** Stable in-process clock for tests. */
|
|
34
|
+
export interface DcrDeps {
|
|
35
|
+
now?: () => Date;
|
|
36
|
+
randomToken?: () => string;
|
|
37
|
+
storePath?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function dcrStorePath(env?: NodeJS.ProcessEnv): string;
|
|
40
|
+
export declare function dcrEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Validate + normalise a DCR request body. RFC 7591 is permissive,
|
|
43
|
+
* so this is the minimum set the gateway insists on:
|
|
44
|
+
* - redirect_uris MUST be a non-empty array of absolute https:// URLs
|
|
45
|
+
* (http:// allowed only when host is localhost / 127.0.0.1)
|
|
46
|
+
* - grant_types / response_types default to {authorization_code} / {code}
|
|
47
|
+
* - token_endpoint_auth_method defaults to client_secret_basic
|
|
48
|
+
*
|
|
49
|
+
* Throws DcrValidationError on rejection; the route layer maps it to
|
|
50
|
+
* an RFC 7591 error JSON.
|
|
51
|
+
*/
|
|
52
|
+
export declare function validateDcrRequest(body: DcrRegistrationRequest): {
|
|
53
|
+
redirect_uris: string[];
|
|
54
|
+
grant_types: string[];
|
|
55
|
+
response_types: string[];
|
|
56
|
+
token_endpoint_auth_method: string;
|
|
57
|
+
client_name?: string;
|
|
58
|
+
scope?: string;
|
|
59
|
+
};
|
|
60
|
+
/** Mint a fresh registration. Pure compute except for the random/now
|
|
61
|
+
* hooks; the route layer is responsible for persisting + emitting
|
|
62
|
+
* the audit entry. */
|
|
63
|
+
export declare function mintRegistration(validated: ReturnType<typeof validateDcrRequest>, sourceIp: string, deps?: DcrDeps): DcrStoreEntry;
|
|
64
|
+
/** File-backed JSON store of DCR registrations. Single-file, single-
|
|
65
|
+
* process — multi-replica setups need the F8 shared session store. */
|
|
66
|
+
export declare function loadRegistrations(storePath: string): Promise<DcrStoreEntry[]>;
|
|
67
|
+
export declare function appendRegistration(storePath: string, entry: DcrStoreEntry): Promise<void>;
|
|
68
|
+
/** Surface-only representation: strips `_meta` before sending the
|
|
69
|
+
* response. */
|
|
70
|
+
export declare function toResponse(entry: DcrStoreEntry): DcrRegistrationResponse;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Dynamic Client Registration (RFC 7591) — minimal implementation.
|
|
2
|
+
//
|
|
3
|
+
// MCP clients like Claude.ai and Cursor expect to self-register at an
|
|
4
|
+
// OAuth authorization server; this endpoint accepts that shape and
|
|
5
|
+
// stores the registered metadata on disk so the gateway recognises
|
|
6
|
+
// the client on subsequent flows.
|
|
7
|
+
//
|
|
8
|
+
// Off by default (OMCP_OIDC_DCR_ENABLED=true to enable). Persisted
|
|
9
|
+
// to JSON at OMCP_OIDC_DCR_STORE (default /tmp/oidc-dcr.json). Each
|
|
10
|
+
// registration is rate-limited per source IP at the route layer to
|
|
11
|
+
// keep an unauthenticated POST endpoint from being abused.
|
|
12
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
13
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
export class DcrValidationError extends Error {
|
|
16
|
+
error;
|
|
17
|
+
constructor(error, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.error = error;
|
|
20
|
+
this.name = "DcrValidationError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const DEFAULT_STORE_PATH = "/tmp/oidc-dcr.json";
|
|
24
|
+
export function dcrStorePath(env = process.env) {
|
|
25
|
+
return env.OMCP_OIDC_DCR_STORE || DEFAULT_STORE_PATH;
|
|
26
|
+
}
|
|
27
|
+
export function dcrEnabled(env = process.env) {
|
|
28
|
+
return /^(1|true|yes|on)$/i.test(env.OMCP_OIDC_DCR_ENABLED ?? "");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate + normalise a DCR request body. RFC 7591 is permissive,
|
|
32
|
+
* so this is the minimum set the gateway insists on:
|
|
33
|
+
* - redirect_uris MUST be a non-empty array of absolute https:// URLs
|
|
34
|
+
* (http:// allowed only when host is localhost / 127.0.0.1)
|
|
35
|
+
* - grant_types / response_types default to {authorization_code} / {code}
|
|
36
|
+
* - token_endpoint_auth_method defaults to client_secret_basic
|
|
37
|
+
*
|
|
38
|
+
* Throws DcrValidationError on rejection; the route layer maps it to
|
|
39
|
+
* an RFC 7591 error JSON.
|
|
40
|
+
*/
|
|
41
|
+
export function validateDcrRequest(body) {
|
|
42
|
+
if (!body || typeof body !== "object") {
|
|
43
|
+
throw new DcrValidationError("invalid_client_metadata", "body must be a JSON object");
|
|
44
|
+
}
|
|
45
|
+
const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
|
46
|
+
if (uris.length === 0) {
|
|
47
|
+
throw new DcrValidationError("invalid_redirect_uri", "redirect_uris is required and must be a non-empty array");
|
|
48
|
+
}
|
|
49
|
+
for (const u of uris) {
|
|
50
|
+
if (typeof u !== "string") {
|
|
51
|
+
throw new DcrValidationError("invalid_redirect_uri", "redirect_uris entries must be strings");
|
|
52
|
+
}
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(u);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new DcrValidationError("invalid_redirect_uri", `redirect_uri "${u}" is not a valid URL`);
|
|
59
|
+
}
|
|
60
|
+
if (parsed.protocol === "http:") {
|
|
61
|
+
const isLoopback = parsed.hostname === "localhost" ||
|
|
62
|
+
parsed.hostname === "127.0.0.1" ||
|
|
63
|
+
parsed.hostname === "::1";
|
|
64
|
+
if (!isLoopback) {
|
|
65
|
+
throw new DcrValidationError("invalid_redirect_uri", `redirect_uri "${u}" must use https:// (http:// only allowed for localhost loopback)`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (parsed.protocol !== "https:") {
|
|
69
|
+
throw new DcrValidationError("invalid_redirect_uri", `redirect_uri "${u}" must use http:// (loopback) or https://`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const grants = Array.isArray(body.grant_types) && body.grant_types.length > 0
|
|
73
|
+
? body.grant_types
|
|
74
|
+
: ["authorization_code"];
|
|
75
|
+
for (const g of grants) {
|
|
76
|
+
if (typeof g !== "string") {
|
|
77
|
+
throw new DcrValidationError("invalid_client_metadata", "grant_types entries must be strings");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const responses = Array.isArray(body.response_types) && body.response_types.length > 0
|
|
81
|
+
? body.response_types
|
|
82
|
+
: ["code"];
|
|
83
|
+
for (const r of responses) {
|
|
84
|
+
if (typeof r !== "string") {
|
|
85
|
+
throw new DcrValidationError("invalid_client_metadata", "response_types entries must be strings");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const authMethod = typeof body.token_endpoint_auth_method === "string" && body.token_endpoint_auth_method.length > 0
|
|
89
|
+
? body.token_endpoint_auth_method
|
|
90
|
+
: "client_secret_basic";
|
|
91
|
+
return {
|
|
92
|
+
redirect_uris: uris,
|
|
93
|
+
grant_types: grants,
|
|
94
|
+
response_types: responses,
|
|
95
|
+
token_endpoint_auth_method: authMethod,
|
|
96
|
+
client_name: typeof body.client_name === "string" ? body.client_name : undefined,
|
|
97
|
+
scope: typeof body.scope === "string" ? body.scope : undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Mint a fresh registration. Pure compute except for the random/now
|
|
101
|
+
* hooks; the route layer is responsible for persisting + emitting
|
|
102
|
+
* the audit entry. */
|
|
103
|
+
export function mintRegistration(validated, sourceIp, deps = {}) {
|
|
104
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
105
|
+
const randomToken = deps.randomToken ?? (() => randomBytes(32).toString("base64url"));
|
|
106
|
+
const clientId = randomUUID();
|
|
107
|
+
// Public clients (e.g. SPAs with PKCE) typically request
|
|
108
|
+
// token_endpoint_auth_method=none — in that case we don't issue a
|
|
109
|
+
// secret, matching RFC 7591 §3.2.1.
|
|
110
|
+
const clientSecret = validated.token_endpoint_auth_method === "none"
|
|
111
|
+
? undefined
|
|
112
|
+
: randomToken();
|
|
113
|
+
return {
|
|
114
|
+
client_id: clientId,
|
|
115
|
+
client_secret: clientSecret,
|
|
116
|
+
client_id_issued_at: Math.floor(now.getTime() / 1000),
|
|
117
|
+
client_secret_expires_at: 0, // 0 = never expires per RFC 7591
|
|
118
|
+
registration_access_token: randomToken(),
|
|
119
|
+
client_name: validated.client_name,
|
|
120
|
+
redirect_uris: validated.redirect_uris,
|
|
121
|
+
grant_types: validated.grant_types,
|
|
122
|
+
response_types: validated.response_types,
|
|
123
|
+
token_endpoint_auth_method: validated.token_endpoint_auth_method,
|
|
124
|
+
scope: validated.scope,
|
|
125
|
+
_meta: {
|
|
126
|
+
sourceIp,
|
|
127
|
+
createdAtIso: now.toISOString(),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** File-backed JSON store of DCR registrations. Single-file, single-
|
|
132
|
+
* process — multi-replica setups need the F8 shared session store. */
|
|
133
|
+
export async function loadRegistrations(storePath) {
|
|
134
|
+
try {
|
|
135
|
+
const raw = await readFile(storePath, "utf8");
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err.code === "ENOENT")
|
|
141
|
+
return [];
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function appendRegistration(storePath, entry) {
|
|
146
|
+
await mkdir(dirname(storePath), { recursive: true }).catch(() => undefined);
|
|
147
|
+
const existing = await loadRegistrations(storePath);
|
|
148
|
+
existing.push(entry);
|
|
149
|
+
// Write to a tmp file and rename for atomicity.
|
|
150
|
+
const tmp = `${storePath}.tmp`;
|
|
151
|
+
await writeFile(tmp, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
152
|
+
await (await import("node:fs/promises")).rename(tmp, storePath);
|
|
153
|
+
}
|
|
154
|
+
/** Surface-only representation: strips `_meta` before sending the
|
|
155
|
+
* response. */
|
|
156
|
+
export function toResponse(entry) {
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
158
|
+
const { _meta, ...rest } = entry;
|
|
159
|
+
return rest;
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* Response) but otherwise pure; the OIDC client + role resolver come
|
|
10
10
|
* from the runtime built in `./runtime.ts`.
|
|
11
11
|
*/
|
|
12
|
+
import rateLimit from "express-rate-limit";
|
|
12
13
|
import { issueSession, setCookieHeader, clearCookieHeader } from "../session.js";
|
|
13
14
|
import { issueFlowCookie, verifyFlowCookie, setFlowCookieHeader, clearFlowCookieHeader, readFlowCookie, isSafeReturnTo, } from "./flow-cookie.js";
|
|
15
|
+
import { dcrEnabled, dcrStorePath, validateDcrRequest, mintRegistration, appendRegistration, toResponse, DcrValidationError, } from "./dcr.js";
|
|
14
16
|
function isSecure(req) {
|
|
15
17
|
return req.secure || req.headers["x-forwarded-proto"] === "https";
|
|
16
18
|
}
|
|
@@ -104,6 +106,48 @@ export function registerOidcRoutes(app, deps) {
|
|
|
104
106
|
// navigates the user.
|
|
105
107
|
res.status(204).end();
|
|
106
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
|
+
}
|
|
107
151
|
}
|
|
108
152
|
function respondError(res, status, code, message) {
|
|
109
153
|
res.status(status).json({ error: code, message });
|