@thotischner/observability-mcp 1.7.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/products.yaml.example +48 -0
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +108 -0
- package/dist/audit/log.js +200 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +168 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +64 -0
- package/dist/auth/policy/engine.js +87 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +45 -0
- package/dist/auth/policy/loader.js +137 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +45 -1
- package/dist/context.js +40 -1
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +2124 -73
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/net/egress-policy.js +2 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +3083 -88
- package/package.json +32 -5
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildSessionAttacher, buildRequireSession } from "./middleware.js";
|
|
4
|
+
import { issueSession } from "./session.js";
|
|
5
|
+
const secret = "x".repeat(48);
|
|
6
|
+
const sessionCfg = { secret };
|
|
7
|
+
function mkReq(opts = {}) {
|
|
8
|
+
return {
|
|
9
|
+
path: opts.path ?? "/api/sources",
|
|
10
|
+
headers: { cookie: opts.cookieHeader || "" },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function mkRes() {
|
|
14
|
+
let statusCode = 0;
|
|
15
|
+
let body = null;
|
|
16
|
+
return {
|
|
17
|
+
status(c) { statusCode = c; return this; },
|
|
18
|
+
json(b) { body = b; return this; },
|
|
19
|
+
get statusCode() { return statusCode; },
|
|
20
|
+
get body() { return body; },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
test("session attacher — anonymous passes through unchanged, no session", () => {
|
|
24
|
+
const attach = buildSessionAttacher({ mode: "anonymous" });
|
|
25
|
+
const req = mkReq();
|
|
26
|
+
let called = false;
|
|
27
|
+
attach(req, mkRes(), () => { called = true; });
|
|
28
|
+
assert.equal(called, true);
|
|
29
|
+
assert.equal(req.session, undefined);
|
|
30
|
+
});
|
|
31
|
+
test("session attacher — basic mode without cookie still flows, no session attached", () => {
|
|
32
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg });
|
|
33
|
+
const req = mkReq();
|
|
34
|
+
let called = false;
|
|
35
|
+
attach(req, mkRes(), () => { called = true; });
|
|
36
|
+
assert.equal(called, true);
|
|
37
|
+
assert.equal(req.session, undefined);
|
|
38
|
+
});
|
|
39
|
+
test("session attacher — basic mode WITH valid cookie attaches session", () => {
|
|
40
|
+
const { cookie } = issueSession({ sub: "alice", name: "Alice", roles: ["operator"] }, sessionCfg);
|
|
41
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg });
|
|
42
|
+
const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
|
|
43
|
+
let called = false;
|
|
44
|
+
attach(req, mkRes(), () => { called = true; });
|
|
45
|
+
assert.equal(called, true);
|
|
46
|
+
assert.ok(req.session);
|
|
47
|
+
assert.equal(req.session?.sub, "alice");
|
|
48
|
+
});
|
|
49
|
+
test("session attacher — tampered cookie leaves session undefined and still flows", () => {
|
|
50
|
+
const { cookie } = issueSession({ sub: "alice", name: "Alice" }, sessionCfg);
|
|
51
|
+
const tampered = cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A"));
|
|
52
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg });
|
|
53
|
+
const req = mkReq({ cookieHeader: `omcp_session=${tampered}` });
|
|
54
|
+
let called = false;
|
|
55
|
+
attach(req, mkRes(), () => { called = true; });
|
|
56
|
+
assert.equal(called, true);
|
|
57
|
+
assert.equal(req.session, undefined);
|
|
58
|
+
});
|
|
59
|
+
test("require-session — anonymous always allows", () => {
|
|
60
|
+
const gate = buildRequireSession({ mode: "anonymous" });
|
|
61
|
+
const req = mkReq();
|
|
62
|
+
const res = mkRes();
|
|
63
|
+
let called = false;
|
|
64
|
+
gate(req, res, () => { called = true; });
|
|
65
|
+
assert.equal(called, true);
|
|
66
|
+
assert.equal(res.statusCode, 0);
|
|
67
|
+
});
|
|
68
|
+
test("require-session — basic mode without session returns 401", () => {
|
|
69
|
+
const runtime = { mode: "basic", session: sessionCfg };
|
|
70
|
+
const gate = buildRequireSession(runtime);
|
|
71
|
+
const req = mkReq();
|
|
72
|
+
const res = mkRes();
|
|
73
|
+
let called = false;
|
|
74
|
+
gate(req, res, () => { called = true; });
|
|
75
|
+
assert.equal(called, false);
|
|
76
|
+
assert.equal(res.statusCode, 401);
|
|
77
|
+
const body = res.body;
|
|
78
|
+
assert.equal(body.code, "OMCP_AUTH_REQUIRED");
|
|
79
|
+
});
|
|
80
|
+
test("require-session — basic mode WITH attached session flows through", () => {
|
|
81
|
+
const runtime = { mode: "basic", session: sessionCfg };
|
|
82
|
+
const gate = buildRequireSession(runtime);
|
|
83
|
+
const req = mkReq();
|
|
84
|
+
req.session = { sub: "alice", name: "Alice", iat: 0, exp: Date.now() / 1000 + 60 };
|
|
85
|
+
const res = mkRes();
|
|
86
|
+
let called = false;
|
|
87
|
+
gate(req, res, () => { called = true; });
|
|
88
|
+
assert.equal(called, true);
|
|
89
|
+
assert.equal(res.statusCode, 0);
|
|
90
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level OIDC client: glues discovery + JWKS + JWT verify + the
|
|
3
|
+
* auth-code+PKCE token exchange behind a single small surface.
|
|
4
|
+
*
|
|
5
|
+
* Designed to be wired into the existing session middleware in a
|
|
6
|
+
* later slice. This module stays HTTP-framework agnostic — the caller
|
|
7
|
+
* decides how to ferry the state/nonce/code_verifier between the
|
|
8
|
+
* `start()` redirect and the `complete()` callback (we recommend a
|
|
9
|
+
* short-lived signed cookie).
|
|
10
|
+
*/
|
|
11
|
+
import { type DiscoveryDocument, type Fetcher } from "./discovery.js";
|
|
12
|
+
import { type JwtPayload } from "./jwt.js";
|
|
13
|
+
export interface OidcConfig {
|
|
14
|
+
/** Issuer URL — what the IdP advertises in its discovery `issuer` field. */
|
|
15
|
+
issuer: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
/** Confidential clients only. Public/SPA clients omit and rely on PKCE. */
|
|
18
|
+
clientSecret?: string;
|
|
19
|
+
/** Absolute callback URL registered with the IdP. */
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
/** Space-delimited scopes. Default: "openid profile email". */
|
|
22
|
+
scopes?: string;
|
|
23
|
+
/** Custom fetcher (tests). */
|
|
24
|
+
fetcher?: Fetcher;
|
|
25
|
+
/** Test clock. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
export interface StartResult {
|
|
29
|
+
/** Where to 302 the browser. */
|
|
30
|
+
authorizeUrl: string;
|
|
31
|
+
/** Caller stores these in a short-lived cookie until /callback. */
|
|
32
|
+
flow: {
|
|
33
|
+
state: string;
|
|
34
|
+
nonce: string;
|
|
35
|
+
codeVerifier: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface CompleteOpts {
|
|
39
|
+
/** Authorization code from the callback URL. */
|
|
40
|
+
code: string;
|
|
41
|
+
/** State returned by the IdP — must match the cookie's flow.state. */
|
|
42
|
+
state: string;
|
|
43
|
+
/** The flow object the caller stashed in the cookie at start(). */
|
|
44
|
+
flow: {
|
|
45
|
+
state: string;
|
|
46
|
+
nonce: string;
|
|
47
|
+
codeVerifier: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export interface CompleteResult {
|
|
51
|
+
/** Decoded + verified ID-token claims. */
|
|
52
|
+
claims: JwtPayload;
|
|
53
|
+
/** Raw ID token (for upstream propagation if the caller wants it). */
|
|
54
|
+
idToken: string;
|
|
55
|
+
/** Access token (opaque). */
|
|
56
|
+
accessToken?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare class OidcClient {
|
|
59
|
+
private readonly discovery;
|
|
60
|
+
private readonly jwks;
|
|
61
|
+
private readonly cfg;
|
|
62
|
+
private readonly fetcher;
|
|
63
|
+
private readonly now;
|
|
64
|
+
constructor(cfg: OidcConfig);
|
|
65
|
+
/** Build an authorize URL + mint the state/nonce/PKCE-verifier the
|
|
66
|
+
* caller must persist until the callback. */
|
|
67
|
+
start(): Promise<StartResult>;
|
|
68
|
+
/** Validate the callback: state match → token exchange → ID-token
|
|
69
|
+
* signature + claim verification. Throws on any failure. */
|
|
70
|
+
complete(opts: CompleteOpts): Promise<CompleteResult>;
|
|
71
|
+
/** Verify a standalone ID token (refresh flows, replay checks). */
|
|
72
|
+
verify(idToken: string, doc?: DiscoveryDocument, nonce?: string): Promise<JwtPayload>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level OIDC client: glues discovery + JWKS + JWT verify + the
|
|
3
|
+
* auth-code+PKCE token exchange behind a single small surface.
|
|
4
|
+
*
|
|
5
|
+
* Designed to be wired into the existing session middleware in a
|
|
6
|
+
* later slice. This module stays HTTP-framework agnostic — the caller
|
|
7
|
+
* decides how to ferry the state/nonce/code_verifier between the
|
|
8
|
+
* `start()` redirect and the `complete()` callback (we recommend a
|
|
9
|
+
* short-lived signed cookie).
|
|
10
|
+
*/
|
|
11
|
+
import { DiscoveryClient } from "./discovery.js";
|
|
12
|
+
import { JwksClient } from "./jwks.js";
|
|
13
|
+
import { generatePkcePair } from "./pkce.js";
|
|
14
|
+
import { verifyIdToken } from "./jwt.js";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
export class OidcClient {
|
|
17
|
+
discovery;
|
|
18
|
+
jwks;
|
|
19
|
+
cfg;
|
|
20
|
+
fetcher;
|
|
21
|
+
now;
|
|
22
|
+
constructor(cfg) {
|
|
23
|
+
this.cfg = cfg;
|
|
24
|
+
this.fetcher = cfg.fetcher ?? ((u, i) => fetch(u, i));
|
|
25
|
+
this.now = cfg.now ?? Date.now;
|
|
26
|
+
this.discovery = new DiscoveryClient({ fetcher: this.fetcher, now: this.now });
|
|
27
|
+
this.jwks = new JwksClient({ fetcher: this.fetcher, now: this.now });
|
|
28
|
+
}
|
|
29
|
+
/** Build an authorize URL + mint the state/nonce/PKCE-verifier the
|
|
30
|
+
* caller must persist until the callback. */
|
|
31
|
+
async start() {
|
|
32
|
+
const doc = await this.discovery.discover(this.cfg.issuer);
|
|
33
|
+
const pkce = generatePkcePair();
|
|
34
|
+
const state = base64url(randomBytes(24));
|
|
35
|
+
const nonce = base64url(randomBytes(24));
|
|
36
|
+
const params = new URLSearchParams({
|
|
37
|
+
response_type: "code",
|
|
38
|
+
client_id: this.cfg.clientId,
|
|
39
|
+
redirect_uri: this.cfg.redirectUri,
|
|
40
|
+
scope: this.cfg.scopes ?? "openid profile email",
|
|
41
|
+
state,
|
|
42
|
+
nonce,
|
|
43
|
+
code_challenge: pkce.challenge,
|
|
44
|
+
code_challenge_method: pkce.method,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
authorizeUrl: `${doc.authorization_endpoint}?${params.toString()}`,
|
|
48
|
+
flow: { state, nonce, codeVerifier: pkce.verifier },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Validate the callback: state match → token exchange → ID-token
|
|
52
|
+
* signature + claim verification. Throws on any failure. */
|
|
53
|
+
async complete(opts) {
|
|
54
|
+
if (opts.state !== opts.flow.state)
|
|
55
|
+
throw new Error("OIDC callback: state mismatch");
|
|
56
|
+
const doc = await this.discovery.discover(this.cfg.issuer);
|
|
57
|
+
const body = new URLSearchParams({
|
|
58
|
+
grant_type: "authorization_code",
|
|
59
|
+
code: opts.code,
|
|
60
|
+
redirect_uri: this.cfg.redirectUri,
|
|
61
|
+
client_id: this.cfg.clientId,
|
|
62
|
+
code_verifier: opts.flow.codeVerifier,
|
|
63
|
+
});
|
|
64
|
+
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
65
|
+
if (this.cfg.clientSecret) {
|
|
66
|
+
const basic = Buffer.from(`${encodeURIComponent(this.cfg.clientId)}:${encodeURIComponent(this.cfg.clientSecret)}`).toString("base64");
|
|
67
|
+
headers.authorization = `Basic ${basic}`;
|
|
68
|
+
}
|
|
69
|
+
const res = await this.fetcher(doc.token_endpoint, { method: "POST", headers, body: body.toString() });
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const text = await res.text().catch(() => "");
|
|
72
|
+
throw new Error(`OIDC token exchange failed: HTTP ${res.status} ${text}`);
|
|
73
|
+
}
|
|
74
|
+
const tokens = (await res.json());
|
|
75
|
+
if (!tokens.id_token)
|
|
76
|
+
throw new Error("OIDC token response missing id_token");
|
|
77
|
+
const claims = await this.verify(tokens.id_token, doc, opts.flow.nonce);
|
|
78
|
+
return { claims, idToken: tokens.id_token, accessToken: tokens.access_token };
|
|
79
|
+
}
|
|
80
|
+
/** Verify a standalone ID token (refresh flows, replay checks). */
|
|
81
|
+
async verify(idToken, doc, nonce) {
|
|
82
|
+
const d = doc ?? (await this.discovery.discover(this.cfg.issuer));
|
|
83
|
+
const header = parseHeader(idToken);
|
|
84
|
+
const key = await this.jwks.findKey(d.jwks_uri, header.kid);
|
|
85
|
+
if (!key)
|
|
86
|
+
throw new Error(`OIDC: no JWKS key for kid=${header.kid ?? "?"}`);
|
|
87
|
+
return verifyIdToken(idToken, [key], {
|
|
88
|
+
issuer: this.cfg.issuer,
|
|
89
|
+
audience: this.cfg.clientId,
|
|
90
|
+
nonce,
|
|
91
|
+
now: this.now,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function base64url(buf) {
|
|
96
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
97
|
+
}
|
|
98
|
+
function parseHeader(jwt) {
|
|
99
|
+
const [h] = jwt.split(".");
|
|
100
|
+
if (!h)
|
|
101
|
+
throw new Error("malformed JWT");
|
|
102
|
+
const pad = h.length % 4 === 0 ? "" : "=".repeat(4 - (h.length % 4));
|
|
103
|
+
return JSON.parse(Buffer.from(h.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8"));
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generateKeyPairSync, createPublicKey, createSign } from "node:crypto";
|
|
4
|
+
import { OidcClient } from "./client.js";
|
|
5
|
+
function b64u(s) {
|
|
6
|
+
const b = typeof s === "string" ? Buffer.from(s, "utf8") : s;
|
|
7
|
+
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
// PEM-encoded sign — see jwt.test.ts for the Node-version-stability
|
|
10
|
+
// reason this avoids the raw KeyObject destructure path.
|
|
11
|
+
function signRs256(payload, privateKeyPem, kid) {
|
|
12
|
+
const header = b64u(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
|
|
13
|
+
const body = b64u(JSON.stringify(payload));
|
|
14
|
+
const signer = createSign("RSA-SHA256");
|
|
15
|
+
signer.update(`${header}.${body}`);
|
|
16
|
+
signer.end();
|
|
17
|
+
return `${header}.${body}.${b64u(signer.sign(privateKeyPem))}`;
|
|
18
|
+
}
|
|
19
|
+
function rsaKey() {
|
|
20
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
21
|
+
modulusLength: 2048,
|
|
22
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
23
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
24
|
+
});
|
|
25
|
+
const jwk = createPublicKey(publicKey).export({ format: "jwk" });
|
|
26
|
+
jwk.kid = "test-kid";
|
|
27
|
+
return { jwk, privateKeyPem: privateKey };
|
|
28
|
+
}
|
|
29
|
+
function makeFetcher(handlers) {
|
|
30
|
+
return async (url, init) => {
|
|
31
|
+
for (const [pattern, handler] of Object.entries(handlers)) {
|
|
32
|
+
if (url === pattern)
|
|
33
|
+
return Promise.resolve(handler(init));
|
|
34
|
+
}
|
|
35
|
+
return new Response("not found", { status: 404 });
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const ISSUER = "https://idp.test";
|
|
39
|
+
const DISCOVERY = {
|
|
40
|
+
issuer: ISSUER,
|
|
41
|
+
authorization_endpoint: `${ISSUER}/auth`,
|
|
42
|
+
token_endpoint: `${ISSUER}/token`,
|
|
43
|
+
jwks_uri: `${ISSUER}/jwks`,
|
|
44
|
+
};
|
|
45
|
+
test("OidcClient.start — builds authorize URL with PKCE + nonce + state", async () => {
|
|
46
|
+
const fetcher = makeFetcher({
|
|
47
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
48
|
+
});
|
|
49
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
50
|
+
const r = await client.start();
|
|
51
|
+
const u = new URL(r.authorizeUrl);
|
|
52
|
+
assert.equal(u.origin + u.pathname, `${ISSUER}/auth`);
|
|
53
|
+
assert.equal(u.searchParams.get("response_type"), "code");
|
|
54
|
+
assert.equal(u.searchParams.get("client_id"), "c-1");
|
|
55
|
+
assert.equal(u.searchParams.get("redirect_uri"), "https://app.test/cb");
|
|
56
|
+
assert.equal(u.searchParams.get("code_challenge_method"), "S256");
|
|
57
|
+
assert.ok(u.searchParams.get("code_challenge"));
|
|
58
|
+
assert.equal(u.searchParams.get("state"), r.flow.state);
|
|
59
|
+
assert.equal(u.searchParams.get("nonce"), r.flow.nonce);
|
|
60
|
+
assert.ok(r.flow.codeVerifier.length >= 43);
|
|
61
|
+
});
|
|
62
|
+
test("OidcClient.complete — verifies state, exchanges code, verifies id_token", async () => {
|
|
63
|
+
const { jwk, privateKeyPem } = rsaKey();
|
|
64
|
+
const now = 1_700_000_000;
|
|
65
|
+
const flow = { state: "S", nonce: "N", codeVerifier: "V_43charsminimum_______________________________________________" };
|
|
66
|
+
const idToken = signRs256({ iss: ISSUER, aud: "c-1", sub: "alice", exp: now + 60, iat: now, nonce: "N" }, privateKeyPem, jwk.kid);
|
|
67
|
+
const fetcher = makeFetcher({
|
|
68
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
69
|
+
[`${ISSUER}/jwks`]: () => new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
|
70
|
+
[`${ISSUER}/token`]: () => new Response(JSON.stringify({ id_token: idToken, access_token: "AT" }), { status: 200 }),
|
|
71
|
+
});
|
|
72
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher, now: () => now * 1000 });
|
|
73
|
+
const r = await client.complete({ code: "ABC", state: "S", flow });
|
|
74
|
+
assert.equal(r.claims.sub, "alice");
|
|
75
|
+
assert.equal(r.accessToken, "AT");
|
|
76
|
+
});
|
|
77
|
+
test("OidcClient.complete — rejects state mismatch", async () => {
|
|
78
|
+
const fetcher = makeFetcher({
|
|
79
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
80
|
+
});
|
|
81
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
82
|
+
await assert.rejects(client.complete({ code: "x", state: "wrong", flow: { state: "real", nonce: "n", codeVerifier: "v" } }), /state mismatch/);
|
|
83
|
+
});
|
|
84
|
+
test("OidcClient.complete — surfaces token-endpoint failures", async () => {
|
|
85
|
+
const fetcher = makeFetcher({
|
|
86
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
87
|
+
[`${ISSUER}/token`]: () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }),
|
|
88
|
+
});
|
|
89
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
90
|
+
await assert.rejects(client.complete({ code: "x", state: "S", flow: { state: "S", nonce: "n", codeVerifier: "v" } }), /HTTP 400/);
|
|
91
|
+
});
|
|
92
|
+
test("OidcClient.complete — rejects missing id_token in token response", async () => {
|
|
93
|
+
const fetcher = makeFetcher({
|
|
94
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () => new Response(JSON.stringify(DISCOVERY), { status: 200 }),
|
|
95
|
+
[`${ISSUER}/token`]: () => new Response(JSON.stringify({ access_token: "AT" }), { status: 200 }),
|
|
96
|
+
});
|
|
97
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", redirectUri: "https://app.test/cb", fetcher });
|
|
98
|
+
await assert.rejects(client.complete({ code: "x", state: "S", flow: { state: "S", nonce: "n", codeVerifier: "v" } }), /missing id_token/);
|
|
99
|
+
});
|
|
100
|
+
test("OidcClient.complete — uses Basic auth when clientSecret set", async () => {
|
|
101
|
+
const { jwk, privateKeyPem } = rsaKey();
|
|
102
|
+
const now = 1_700_000_000;
|
|
103
|
+
const idToken = signRs256({ iss: ISSUER, aud: "c-1", sub: "alice", exp: now + 60, iat: now, nonce: "n" }, privateKeyPem, jwk.kid);
|
|
104
|
+
let captured;
|
|
105
|
+
const fetcher = async (url, init) => {
|
|
106
|
+
if (url === `${ISSUER}/.well-known/openid-configuration`)
|
|
107
|
+
return new Response(JSON.stringify(DISCOVERY), { status: 200 });
|
|
108
|
+
if (url === `${ISSUER}/jwks`)
|
|
109
|
+
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
|
110
|
+
if (url === `${ISSUER}/token`) {
|
|
111
|
+
captured = (init?.headers).authorization;
|
|
112
|
+
return new Response(JSON.stringify({ id_token: idToken }), { status: 200 });
|
|
113
|
+
}
|
|
114
|
+
return new Response("nf", { status: 404 });
|
|
115
|
+
};
|
|
116
|
+
const client = new OidcClient({ issuer: ISSUER, clientId: "c-1", clientSecret: "shh", redirectUri: "https://app.test/cb", fetcher, now: () => now * 1000 });
|
|
117
|
+
await client.complete({ code: "x", state: "S", flow: { state: "S", nonce: "n", codeVerifier: "v" } });
|
|
118
|
+
assert.ok(captured?.startsWith("Basic "));
|
|
119
|
+
const decoded = Buffer.from(captured.slice("Basic ".length), "base64").toString();
|
|
120
|
+
assert.equal(decoded, "c-1:shh");
|
|
121
|
+
});
|
|
@@ -0,0 +1,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 {};
|