@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.
- 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/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -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 +144 -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.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -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 +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- 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/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -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/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -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/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -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/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -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 +15 -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 +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- 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 +33 -11
- package/dist/tools/topology.test.js +45 -0
- 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/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -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 +2529 -145
- 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".
|
|
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 });
|
|
@@ -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) ??
|
|
73
|
-
rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ??
|
|
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
|
}
|