@thotischner/observability-mcp 1.8.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -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". Opaque to this
27
- * module — the OIDC HTTP endpoints in src/index.ts consume it.
28
- * Typed as `unknown` here to avoid importing the OIDC sub-module
29
- * and pulling its node:crypto dependency into the middleware
30
- * surface. The OIDC wire-up casts on the way in. */
31
- oidc?: unknown;
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 });
@@ -0,0 +1,22 @@
1
+ export interface VendorProfile {
2
+ /** Profile id, matches OMCP_OIDC_PROFILE. */
3
+ readonly name: string;
4
+ /** Human-readable label for logs + UI. */
5
+ readonly label: string;
6
+ /** Default OAuth scopes. */
7
+ readonly scopes: string;
8
+ /** Dotted claim path the IdP puts the user's group/role list under. */
9
+ readonly rolesClaim: string;
10
+ /** Default dotted claim path for tenant identification. Empty = all
11
+ * sessions land in the default tenant (operators usually leave
12
+ * this off unless they really do multi-tenant federation). */
13
+ readonly tenantClaim: string;
14
+ /** Doc URL deep-linked from the boot log on misconfiguration. */
15
+ readonly docs: string;
16
+ }
17
+ /** Returns the profile or undefined. Case-insensitive. */
18
+ export declare function getProfile(name: string | undefined): VendorProfile | undefined;
19
+ /** All known profile names, useful for help text + the boot log. */
20
+ export declare function profileNames(): string[];
21
+ /** Default profile = generic (matches pre-F6 behaviour exactly). */
22
+ export declare const DEFAULT_PROFILE: VendorProfile;
@@ -0,0 +1,95 @@
1
+ // SSO vendor presets.
2
+ //
3
+ // Each profile preconfigures the OIDC fields that differ between
4
+ // well-known providers (scopes, claim paths for roles/groups, default
5
+ // logout URL pattern). The operator still provides issuer / clientId
6
+ // / redirectUri / clientSecret via env; the profile fills in the rest
7
+ // so a typical Entra or Okta rollout doesn't need a custom config.
8
+ //
9
+ // Explicit env vars ALWAYS override profile defaults — profiles are
10
+ // best-effort defaults, never a hard override.
11
+ const PROFILES = {
12
+ // Generic OIDC — the existing behaviour (matches Keycloak, Authentik,
13
+ // Auth0, and any compliant provider that uses standard claims).
14
+ generic: {
15
+ name: "generic",
16
+ label: "Generic OIDC",
17
+ scopes: "openid profile email",
18
+ rolesClaim: "groups",
19
+ tenantClaim: "",
20
+ docs: "docs/auth-oidc.md",
21
+ },
22
+ // Keycloak ships groups under "groups" or "realm_access.roles"
23
+ // depending on mapper config. Default to "groups" to match the
24
+ // out-of-the-box realm export the demo profile uses.
25
+ keycloak: {
26
+ name: "keycloak",
27
+ label: "Keycloak",
28
+ scopes: "openid profile email",
29
+ rolesClaim: "groups",
30
+ tenantClaim: "",
31
+ // The existing OIDC reference covers Keycloak end-to-end (the
32
+ // demo profile ships a Keycloak realm export). A dedicated
33
+ // per-vendor page would duplicate it.
34
+ docs: "docs/auth-oidc.md",
35
+ },
36
+ // GitHub does not expose groups natively in its OIDC tokens; the
37
+ // common pattern is to use the "Teams" mapper or a custom claim
38
+ // provider. We default to "groups" so an operator who sets up a
39
+ // mapper sees their roles flow through; if they use a different
40
+ // claim, OMCP_OIDC_ROLES_CLAIM still overrides.
41
+ github: {
42
+ name: "github",
43
+ label: "GitHub",
44
+ scopes: "openid profile email read:org",
45
+ rolesClaim: "groups",
46
+ tenantClaim: "",
47
+ docs: "docs/auth-oidc-providers/github.md",
48
+ },
49
+ // Google Workspace exposes group membership via the "groups" claim
50
+ // when the directory API consent is granted; otherwise treat it as
51
+ // a single-user case (no group → no role mapping → user inherits
52
+ // the OIDC default role).
53
+ google: {
54
+ name: "google",
55
+ label: "Google Workspace",
56
+ scopes: "openid profile email",
57
+ rolesClaim: "groups",
58
+ tenantClaim: "hd", // "hd" = hosted domain, useful as a tenant key
59
+ docs: "docs/auth-oidc-providers/google.md",
60
+ },
61
+ // Microsoft Entra ID (formerly Azure AD) puts group IDs (object IDs)
62
+ // under "groups". For >200 groups it switches to a graph link
63
+ // claim — operators in that case must use a custom claim mapping
64
+ // policy; documented in the per-vendor doc.
65
+ "microsoft-entra": {
66
+ name: "microsoft-entra",
67
+ label: "Microsoft Entra ID",
68
+ scopes: "openid profile email",
69
+ rolesClaim: "groups",
70
+ tenantClaim: "tid", // "tid" = tenant id (Entra-native)
71
+ docs: "docs/auth-oidc-providers/microsoft-entra.md",
72
+ },
73
+ // Okta exposes groups via the "groups" claim when an OIDC Group
74
+ // claim mapper is added (default for any non-trivial app).
75
+ okta: {
76
+ name: "okta",
77
+ label: "Okta",
78
+ scopes: "openid profile email groups",
79
+ rolesClaim: "groups",
80
+ tenantClaim: "",
81
+ docs: "docs/auth-oidc-providers/okta.md",
82
+ },
83
+ };
84
+ /** Returns the profile or undefined. Case-insensitive. */
85
+ export function getProfile(name) {
86
+ if (!name)
87
+ return undefined;
88
+ return PROFILES[name.toLowerCase()];
89
+ }
90
+ /** All known profile names, useful for help text + the boot log. */
91
+ export function profileNames() {
92
+ return Object.keys(PROFILES);
93
+ }
94
+ /** Default profile = generic (matches pre-F6 behaviour exactly). */
95
+ export const DEFAULT_PROFILE = PROFILES.generic;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getProfile, profileNames, DEFAULT_PROFILE, } from "./profiles.js";
4
+ test("profiles: getProfile returns known profiles, case-insensitive", () => {
5
+ assert.equal(getProfile("github")?.name, "github");
6
+ assert.equal(getProfile("Github")?.name, "github");
7
+ assert.equal(getProfile("MICROSOFT-ENTRA")?.name, "microsoft-entra");
8
+ });
9
+ test("profiles: getProfile returns undefined for unknown / empty", () => {
10
+ assert.equal(getProfile(undefined), undefined);
11
+ assert.equal(getProfile(""), undefined);
12
+ assert.equal(getProfile("nope"), undefined);
13
+ });
14
+ test("profiles: profileNames lists the 6 baked-in profiles", () => {
15
+ const names = profileNames();
16
+ for (const expected of [
17
+ "generic",
18
+ "keycloak",
19
+ "github",
20
+ "google",
21
+ "microsoft-entra",
22
+ "okta",
23
+ ]) {
24
+ assert.ok(names.includes(expected), `missing profile ${expected}`);
25
+ }
26
+ });
27
+ test("profiles: DEFAULT_PROFILE is generic and preserves the pre-F6 defaults", () => {
28
+ assert.equal(DEFAULT_PROFILE.name, "generic");
29
+ assert.equal(DEFAULT_PROFILE.scopes, "openid profile email");
30
+ assert.equal(DEFAULT_PROFILE.rolesClaim, "groups");
31
+ assert.equal(DEFAULT_PROFILE.tenantClaim, "");
32
+ });
33
+ test("profiles: vendor-specific tenant claims match the IdP-native key", () => {
34
+ // hd = hosted domain (Google) — useful as a tenant key for
35
+ // multi-org Workspace deployments
36
+ assert.equal(getProfile("google")?.tenantClaim, "hd");
37
+ // tid = tenant id (Entra-native)
38
+ assert.equal(getProfile("microsoft-entra")?.tenantClaim, "tid");
39
+ });
40
+ test("profiles: Okta scopes include 'groups' so the claim is actually returned", () => {
41
+ // Okta's group claim is only emitted when 'groups' is in the
42
+ // requested scope set; profile must include it as a default.
43
+ assert.match(getProfile("okta")?.scopes ?? "", /\bgroups\b/);
44
+ });
45
+ test("profiles: each profile has a docs path", () => {
46
+ for (const name of profileNames()) {
47
+ const p = getProfile(name);
48
+ assert.ok(p.docs, `profile ${name} has no docs path`);
49
+ assert.match(p.docs, /^docs\//, `profile ${name} docs should be a repo-relative path`);
50
+ }
51
+ });
@@ -34,6 +34,9 @@ export interface OidcRuntimeConfig {
34
34
  /** Dotted claim path to read the tenant from. Empty / missing → all
35
35
  * OIDC sessions land in the "default" tenant. */
36
36
  tenantClaim: string;
37
+ /** Vendor profile id (generic / github / google / microsoft-entra /
38
+ * okta / keycloak) — surfaced in /api/info for diagnostics. */
39
+ profile?: string;
37
40
  }
38
41
  export interface ResolveOidcResult {
39
42
  /** Fully validated runtime config; absent when `error` is set. */
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { OidcClient } from "./client.js";
25
25
  import { DEFAULT_TENANT, tenantFromClaim } from "../../tenancy/context.js";
26
+ import { getProfile, DEFAULT_PROFILE } from "./profiles.js";
26
27
  /** Pure env-to-config translator. No I/O. */
27
28
  export function resolveOidcConfig(env = process.env) {
28
29
  const issuer = nonEmpty(env.OMCP_OIDC_ISSUER);
@@ -63,17 +64,29 @@ export function resolveOidcConfig(env = process.env) {
63
64
  return { error: `OMCP_OIDC_ROLE_MAP is not valid JSON: ${e.message}` };
64
65
  }
65
66
  }
67
+ // Vendor profile resolves IdP-shaped defaults (scopes / rolesClaim /
68
+ // tenantClaim) when OMCP_OIDC_PROFILE is set. Explicit env vars
69
+ // always win — profiles only fill the gaps an operator chose not to
70
+ // set, so this is fully backwards-compatible with pre-F6 configs.
71
+ const profileName = nonEmpty(env.OMCP_OIDC_PROFILE);
72
+ const profile = (profileName && getProfile(profileName)) || DEFAULT_PROFILE;
73
+ if (profileName && !getProfile(profileName)) {
74
+ return {
75
+ error: `OMCP_OIDC_PROFILE=${profileName} is not a known profile (try one of: generic, keycloak, github, google, microsoft-entra, okta)`,
76
+ };
77
+ }
66
78
  return {
67
79
  config: {
68
80
  issuer: issuer.replace(/\/$/, ""),
69
81
  clientId: clientId,
70
82
  clientSecret: nonEmpty(env.OMCP_OIDC_CLIENT_SECRET),
71
83
  redirectUri: redirectUri,
72
- scopes: nonEmpty(env.OMCP_OIDC_SCOPES) ?? "openid profile email",
73
- rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ?? "groups",
84
+ scopes: nonEmpty(env.OMCP_OIDC_SCOPES) ?? profile.scopes,
85
+ rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ?? profile.rolesClaim,
74
86
  roleMap,
75
87
  logoutRedirect: nonEmpty(env.OMCP_OIDC_LOGOUT_REDIRECT) ?? "/",
76
- tenantClaim: nonEmpty(env.OMCP_OIDC_TENANT_CLAIM) ?? "",
88
+ tenantClaim: nonEmpty(env.OMCP_OIDC_TENANT_CLAIM) ?? profile.tenantClaim,
89
+ profile: profile.name,
77
90
  },
78
91
  };
79
92
  }
@@ -21,6 +21,7 @@ test("resolveOidcConfig — happy path with required vars only", () => {
21
21
  roleMap: {},
22
22
  logoutRedirect: "/",
23
23
  tenantClaim: "",
24
+ profile: "generic",
24
25
  });
25
26
  });
26
27
  test("resolveOidcConfig — honours OMCP_OIDC_TENANT_CLAIM", () => {