@cyanmycelium/mcp-broker 0.4.0 → 1.2.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.
Files changed (151) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
  3. package/.mcp-broker.example/README.md +8 -3
  4. package/.mcp-broker.example/config.json +86 -0
  5. package/README.md +122 -3
  6. package/dist/bin.d.ts +0 -1
  7. package/dist/bin.js +180 -208
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-FTDKH2C4.js +3670 -0
  10. package/dist/chunk-FTDKH2C4.js.map +1 -0
  11. package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
  12. package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
  13. package/dist/index.d.ts +1790 -18
  14. package/dist/index.js +2 -14
  15. package/dist/index.js.map +1 -1
  16. package/package.json +14 -8
  17. package/scripts/copy-assets.mjs +16 -10
  18. package/scripts/gen-cert.mjs +5 -5
  19. package/scripts/pack-mcpb.mjs +5 -5
  20. package/scripts/sign-bundle.mjs +6 -6
  21. package/src/auth/auth.config.ts +98 -0
  22. package/src/auth/auth.types.ts +120 -0
  23. package/src/auth/http.auth.ts +128 -0
  24. package/src/auth/index.ts +34 -0
  25. package/src/auth/jwt.validator.ts +63 -0
  26. package/src/auth/provider.auth.ts +114 -0
  27. package/src/auth/resource.metadata.ts +34 -0
  28. package/src/authorization/audit.ts +35 -0
  29. package/src/authorization/capability.classifier.ts +114 -0
  30. package/src/authorization/index.ts +37 -0
  31. package/src/authorization/policy.engine.ts +212 -0
  32. package/src/authorization/policy.types.ts +105 -0
  33. package/src/authorization/resource.path.ts +126 -0
  34. package/src/authorization/runtime.ts +56 -0
  35. package/src/authorization/slot.resource.ts +50 -0
  36. package/src/authorization/subject.mapper.ts +81 -0
  37. package/src/bin.ts +90 -7
  38. package/src/broker/adapters/broker.adapter.info.ts +3 -3
  39. package/src/broker/adapters/broker.adapter.providers.ts +3 -3
  40. package/src/broker/aggregate/aggregate.catalog.ts +49 -21
  41. package/src/broker/aggregate/aggregate.server.ts +149 -19
  42. package/src/broker/aggregate/provider.client.session.ts +22 -22
  43. package/src/broker/behaviors/broker.behavior.info.ts +4 -4
  44. package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
  45. package/src/broker/broker.context.ts +10 -4
  46. package/src/broker/broker.grammars.ts +16 -13
  47. package/src/broker/broker.server.ts +12 -9
  48. package/src/broker/grammars/claude/en.json +1 -1
  49. package/src/broker/grammars/claude/fr.json +1 -1
  50. package/src/broker/index.ts +9 -9
  51. package/src/config.ts +81 -8
  52. package/src/index.ts +121 -20
  53. package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
  54. package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
  55. package/src/remote.transports.ts +11 -8
  56. package/src/remote.upstream.ts +11 -8
  57. package/src/stdio.upstream.ts +9 -6
  58. package/src/upstream.ts +6 -3
  59. package/src/ws/ws.interfaces.ts +357 -0
  60. package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
  61. package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
  62. package/web/README.md +94 -0
  63. package/web/assets/logo.png +0 -0
  64. package/web/broker-self-mcp.html +338 -0
  65. package/web/css/styles.css +580 -0
  66. package/web/demos/DemoPlaceholder.html +258 -0
  67. package/web/demos/broker-explorer/css/app.css +393 -0
  68. package/web/demos/broker-explorer/index.html +94 -0
  69. package/web/demos/broker-explorer/js/app.js +271 -0
  70. package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
  71. package/web/demos/oauth-lab/README.md +94 -0
  72. package/web/demos/oauth-lab/config.json +117 -0
  73. package/web/demos/oauth-lab/css/app.css +1097 -0
  74. package/web/demos/oauth-lab/index.html +323 -0
  75. package/web/demos/oauth-lab/js/app.js +654 -0
  76. package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
  77. package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
  78. package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
  79. package/web/demos/oauth-lab/server/start.mjs +106 -0
  80. package/web/demos/provider-tunnel/css/app.css +384 -0
  81. package/web/demos/provider-tunnel/index.html +99 -0
  82. package/web/demos/provider-tunnel/js/app.js +226 -0
  83. package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
  84. package/web/index.html +558 -0
  85. package/web/js/lib/broker-tunnel.js +173 -0
  86. package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
  87. package/dist/broker/adapters/broker.adapter.info.js +0 -43
  88. package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
  89. package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
  90. package/dist/broker/adapters/broker.adapter.providers.js +0 -61
  91. package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
  92. package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
  93. package/dist/broker/aggregate/aggregate.catalog.js +0 -105
  94. package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
  95. package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
  96. package/dist/broker/aggregate/aggregate.server.js +0 -151
  97. package/dist/broker/aggregate/aggregate.server.js.map +0 -1
  98. package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
  99. package/dist/broker/aggregate/provider.client.session.js +0 -140
  100. package/dist/broker/aggregate/provider.client.session.js.map +0 -1
  101. package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
  102. package/dist/broker/behaviors/broker.behavior.info.js +0 -41
  103. package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
  104. package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
  105. package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
  106. package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
  107. package/dist/broker/broker.context.d.ts +0 -59
  108. package/dist/broker/broker.context.js +0 -2
  109. package/dist/broker/broker.context.js.map +0 -1
  110. package/dist/broker/broker.grammars.d.ts +0 -130
  111. package/dist/broker/broker.grammars.js +0 -229
  112. package/dist/broker/broker.grammars.js.map +0 -1
  113. package/dist/broker/broker.server.d.ts +0 -66
  114. package/dist/broker/broker.server.js +0 -73
  115. package/dist/broker/broker.server.js.map +0 -1
  116. package/dist/broker/index.d.ts +0 -9
  117. package/dist/broker/index.js +0 -7
  118. package/dist/broker/index.js.map +0 -1
  119. package/dist/config.d.ts +0 -136
  120. package/dist/config.js +0 -61
  121. package/dist/config.js.map +0 -1
  122. package/dist/mcpb.loader.d.ts +0 -24
  123. package/dist/mcpb.loader.js +0 -161
  124. package/dist/mcpb.loader.js.map +0 -1
  125. package/dist/mcpb.unzip.d.ts +0 -6
  126. package/dist/mcpb.unzip.js +0 -95
  127. package/dist/mcpb.unzip.js.map +0 -1
  128. package/dist/remote.transports.d.ts +0 -16
  129. package/dist/remote.transports.js +0 -297
  130. package/dist/remote.transports.js.map +0 -1
  131. package/dist/remote.upstream.d.ts +0 -36
  132. package/dist/remote.upstream.js +0 -52
  133. package/dist/remote.upstream.js.map +0 -1
  134. package/dist/stdio.upstream.d.ts +0 -45
  135. package/dist/stdio.upstream.js +0 -85
  136. package/dist/stdio.upstream.js.map +0 -1
  137. package/dist/upstream.d.ts +0 -33
  138. package/dist/upstream.js +0 -2
  139. package/dist/upstream.js.map +0 -1
  140. package/dist/version.d.ts +0 -2
  141. package/dist/version.js +0 -9
  142. package/dist/version.js.map +0 -1
  143. package/dist/ws.tunnel.builder.d.ts +0 -139
  144. package/dist/ws.tunnel.builder.js +0 -205
  145. package/dist/ws.tunnel.builder.js.map +0 -1
  146. package/dist/ws.tunnel.d.ts +0 -373
  147. package/dist/ws.tunnel.js +0 -1090
  148. package/dist/ws.tunnel.js.map +0 -1
  149. /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
  150. /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
  151. /package/dist/{broker/grammars → grammars}/default/zh.json +0 -0
@@ -0,0 +1,426 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolve } from "node:path";
5
+ import { exportJWK, generateKeyPair, SignJWT } from "jose";
6
+
7
+ const ISSUER = "http://127.0.0.1:4100";
8
+ const BROKER_ORIGIN = "http://127.0.0.1:3001";
9
+ const CLIENT_ID = "mcp-oauth-lab";
10
+ const KEY_ID = "oauth-lab-signing-key";
11
+ const IDENTITY_SESSION_COOKIE = "oauth_lab_identity";
12
+
13
+ const PERSONAS = Object.freeze({
14
+ "la-forge": {
15
+ id: "la-forge",
16
+ name: "Geordi La Forge",
17
+ role: "Chief Engineer",
18
+ sub: "geordi-la-forge",
19
+ groups: ["starfleet-engineering"],
20
+ scopes: ["mcp:call"],
21
+ summary: "Can inspect, diagnose, and reconfigure Area A, except the protected critical furnace.",
22
+ },
23
+ worf: {
24
+ id: "worf",
25
+ name: "Worf",
26
+ role: "Chief Security Officer",
27
+ sub: "worf",
28
+ groups: ["starfleet-security"],
29
+ scopes: ["mcp:call"],
30
+ summary: "Can inspect and operate Area A machines, but cannot perform engineering actions.",
31
+ },
32
+ seven: {
33
+ id: "seven",
34
+ name: "Seven of Nine",
35
+ role: "Astrometrics Specialist",
36
+ sub: "seven-of-nine",
37
+ groups: ["astrometrics"],
38
+ scopes: ["mcp:call"],
39
+ summary: "Has read-only visibility across the Paris site.",
40
+ },
41
+ picard: {
42
+ id: "picard",
43
+ name: "Jean-Luc Picard",
44
+ role: "Captain",
45
+ sub: "jean-luc-picard",
46
+ groups: ["starfleet-command"],
47
+ scopes: ["mcp:call", "broker:admin"],
48
+ summary: "Can access every resource and the reserved broker administration slot.",
49
+ },
50
+ });
51
+
52
+ function writeJson(res, status, body, extraHeaders = {}) {
53
+ res.writeHead(status, {
54
+ "Content-Type": "application/json; charset=utf-8",
55
+ "Cache-Control": "no-store",
56
+ "Access-Control-Allow-Origin": BROKER_ORIGIN,
57
+ "Access-Control-Allow-Headers": "Content-Type",
58
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
59
+ ...extraHeaders,
60
+ });
61
+ res.end(JSON.stringify(body));
62
+ }
63
+
64
+ function readBody(req) {
65
+ return new Promise((resolveBody, reject) => {
66
+ let body = "";
67
+ req.setEncoding("utf8");
68
+ req.on("data", (chunk) => {
69
+ body += chunk;
70
+ if (body.length > 64 * 1024) {
71
+ reject(new Error("Request body is too large."));
72
+ req.destroy();
73
+ }
74
+ });
75
+ req.on("end", () => resolveBody(body));
76
+ req.on("error", reject);
77
+ });
78
+ }
79
+
80
+ function escapeHtml(value) {
81
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
82
+ }
83
+
84
+ function isAllowedRedirect(value) {
85
+ try {
86
+ const url = new URL(value);
87
+ return url.origin === BROKER_ORIGIN && url.pathname.startsWith("/demos/oauth-lab/");
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function isAllowedResource(value) {
94
+ try {
95
+ const url = new URL(value);
96
+ return url.origin === BROKER_ORIGIN && url.pathname.endsWith("/mcp");
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function readCookie(req, name) {
103
+ const cookieHeader = req.headers.cookie ?? "";
104
+ for (const cookie of cookieHeader.split(";")) {
105
+ const separator = cookie.indexOf("=");
106
+ if (separator < 0) continue;
107
+ if (cookie.slice(0, separator).trim() === name) {
108
+ try {
109
+ return decodeURIComponent(cookie.slice(separator + 1).trim());
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ function identitySessionCookie(sessionId) {
119
+ return `${IDENTITY_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; HttpOnly; SameSite=Lax; Path=/authorize; Max-Age=3600`;
120
+ }
121
+
122
+ function isValidAuthorizationRequest(params) {
123
+ return (
124
+ params.get("response_type") === "code" &&
125
+ params.get("client_id") === CLIENT_ID &&
126
+ isAllowedRedirect(params.get("redirect_uri") ?? "") &&
127
+ isAllowedResource(params.get("resource") ?? "") &&
128
+ !!params.get("state") &&
129
+ !!params.get("code_challenge") &&
130
+ params.get("code_challenge_method") === "S256"
131
+ );
132
+ }
133
+
134
+ function authorizationPage(params) {
135
+ const hidden = [...params.entries()].map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("");
136
+ const cards = Object.values(PERSONAS)
137
+ .map(
138
+ (persona) => `
139
+ <button class="persona" type="submit" name="persona" value="${persona.id}">
140
+ <span class="avatar">${persona.name
141
+ .split(" ")
142
+ .map((part) => part[0])
143
+ .join("")
144
+ .slice(0, 2)}</span>
145
+ <span class="persona-copy">
146
+ <strong>${escapeHtml(persona.name)}</strong>
147
+ <span class="role">${escapeHtml(persona.role)}</span>
148
+ <span class="summary">${escapeHtml(persona.summary)}</span>
149
+ </span>
150
+ <span class="choose">Continue</span>
151
+ </button>`
152
+ )
153
+ .join("");
154
+
155
+ return `<!doctype html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="utf-8">
159
+ <meta name="viewport" content="width=device-width, initial-scale=1">
160
+ <title>Sign in | OAuth Policy Lab</title>
161
+ <style>
162
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
163
+ * { box-sizing: border-box; }
164
+ body { margin: 0; min-height: 100vh; background: #090d13; color: #e8edf5; display: grid; place-items: center; padding: 32px 18px; }
165
+ main { width: min(720px, 100%); }
166
+ .issuer { color: #79a7ff; font: 600 12px ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
167
+ h1 { margin: 12px 0 8px; font: 520 clamp(28px, 5vw, 44px) ui-monospace, monospace; letter-spacing: -.04em; }
168
+ .intro { margin: 0 0 28px; color: #99a6b8; line-height: 1.6; }
169
+ .request { margin-bottom: 18px; padding: 12px 14px; border: 1px solid #243044; border-radius: 8px; background: #101722; color: #aab7c9; font: 12px ui-monospace, monospace; overflow-wrap: anywhere; }
170
+ form { display: grid; gap: 10px; }
171
+ .persona { width: 100%; appearance: none; border: 1px solid #253147; border-radius: 10px; background: #101722; color: inherit; padding: 16px; display: grid; grid-template-columns: 44px 1fr auto; gap: 14px; align-items: center; text-align: left; cursor: pointer; transition: border-color .16s, background .16s, transform .16s; }
172
+ .persona:hover, .persona:focus-visible { border-color: #4f87ff; background: #131d2c; transform: translateY(-1px); outline: none; }
173
+ .avatar { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 8px; background: #1d355f; color: #8fb5ff; font: 700 13px ui-monospace, monospace; }
174
+ .persona-copy { display: grid; gap: 2px; }
175
+ strong { font-size: 15px; }
176
+ .role { color: #80aaff; font-size: 12px; }
177
+ .summary { color: #8795a8; font-size: 12px; line-height: 1.45; margin-top: 3px; }
178
+ .choose { color: #8fb5ff; font: 11px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .08em; }
179
+ footer { margin-top: 22px; color: #657286; font: 11px ui-monospace, monospace; line-height: 1.5; }
180
+ @media (max-width: 560px) { .persona { grid-template-columns: 40px 1fr; } .choose { display: none; } }
181
+ </style>
182
+ </head>
183
+ <body>
184
+ <main>
185
+ <div class="issuer">Local authorization server</div>
186
+ <h1>Choose a demo identity</h1>
187
+ <p class="intro">Each identity receives a signed, audience-bound JWT. The broker will map its claims to subjects and evaluate the same request against different policies.</p>
188
+ <div class="request">resource: ${escapeHtml(params.get("resource") ?? "")}</div>
189
+ <form method="post" action="/authorize/decision">
190
+ ${hidden}
191
+ ${cards}
192
+ </form>
193
+ <footer>Development-only identity provider. No password is requested and no external account is used.</footer>
194
+ </main>
195
+ </body>
196
+ </html>`;
197
+ }
198
+
199
+ export async function startAuthorizationServer(options = {}) {
200
+ const port = options.port ?? 4100;
201
+ const auditEvents = options.auditEvents ?? [];
202
+ const codes = new Map();
203
+ const identitySessions = new Map();
204
+ const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true });
205
+ const publicJwk = await exportJWK(publicKey);
206
+ const jwks = {
207
+ keys: [
208
+ {
209
+ ...publicJwk,
210
+ kid: KEY_ID,
211
+ alg: "RS256",
212
+ use: "sig",
213
+ },
214
+ ],
215
+ };
216
+
217
+ const metadata = {
218
+ issuer: ISSUER,
219
+ authorization_endpoint: `${ISSUER}/authorize`,
220
+ token_endpoint: `${ISSUER}/token`,
221
+ jwks_uri: `${ISSUER}/jwks.json`,
222
+ response_types_supported: ["code"],
223
+ grant_types_supported: ["authorization_code"],
224
+ code_challenge_methods_supported: ["S256"],
225
+ token_endpoint_auth_methods_supported: ["none"],
226
+ scopes_supported: ["mcp:call", "broker:admin"],
227
+ };
228
+
229
+ function redirectWithAuthorizationCode(res, params, persona, extraHeaders = {}) {
230
+ const redirectUri = params.get("redirect_uri") ?? "";
231
+ const code = randomUUID();
232
+ codes.set(code, {
233
+ persona,
234
+ clientId: CLIENT_ID,
235
+ redirectUri,
236
+ resource: params.get("resource") ?? "",
237
+ requestedScope: params.get("scope") ?? "",
238
+ codeChallenge: params.get("code_challenge"),
239
+ expiresAt: Date.now() + 2 * 60 * 1000,
240
+ });
241
+ const redirect = new URL(redirectUri);
242
+ redirect.searchParams.set("code", code);
243
+ redirect.searchParams.set("state", params.get("state") ?? "");
244
+ res.writeHead(302, {
245
+ Location: redirect.toString(),
246
+ "Cache-Control": "no-store",
247
+ ...extraHeaders,
248
+ });
249
+ res.end();
250
+ }
251
+
252
+ function redirectWithAuthorizationError(res, params, error) {
253
+ const redirect = new URL(params.get("redirect_uri") ?? "");
254
+ redirect.searchParams.set("error", error);
255
+ redirect.searchParams.set("state", params.get("state") ?? "");
256
+ res.writeHead(302, {
257
+ Location: redirect.toString(),
258
+ "Cache-Control": "no-store",
259
+ });
260
+ res.end();
261
+ }
262
+
263
+ const server = createServer(async (req, res) => {
264
+ const requestUrl = new URL(req.url ?? "/", ISSUER);
265
+
266
+ if (req.method === "OPTIONS") {
267
+ writeJson(res, 204, {});
268
+ return;
269
+ }
270
+
271
+ if (req.method === "GET" && requestUrl.pathname === "/health") {
272
+ writeJson(res, 200, { status: "ok", issuer: ISSUER });
273
+ return;
274
+ }
275
+
276
+ if (req.method === "GET" && (requestUrl.pathname === "/.well-known/oauth-authorization-server" || requestUrl.pathname === "/.well-known/openid-configuration")) {
277
+ writeJson(res, 200, metadata);
278
+ return;
279
+ }
280
+
281
+ if (req.method === "GET" && requestUrl.pathname === "/jwks.json") {
282
+ writeJson(res, 200, jwks);
283
+ return;
284
+ }
285
+
286
+ if (req.method === "GET" && requestUrl.pathname === "/demo/personas") {
287
+ writeJson(
288
+ res,
289
+ 200,
290
+ Object.values(PERSONAS).map(({ sub, groups, scopes, ...persona }) => ({
291
+ ...persona,
292
+ subject: sub,
293
+ groups,
294
+ scopes,
295
+ }))
296
+ );
297
+ return;
298
+ }
299
+
300
+ if (req.method === "GET" && requestUrl.pathname === "/demo/audit") {
301
+ writeJson(res, 200, { events: auditEvents.slice(-80).reverse() });
302
+ return;
303
+ }
304
+
305
+ if (req.method === "POST" && requestUrl.pathname === "/demo/audit/clear") {
306
+ auditEvents.splice(0, auditEvents.length);
307
+ writeJson(res, 200, { cleared: true });
308
+ return;
309
+ }
310
+
311
+ if (req.method === "GET" && requestUrl.pathname === "/authorize") {
312
+ const params = requestUrl.searchParams;
313
+ if (!isValidAuthorizationRequest(params)) {
314
+ writeJson(res, 400, { error: "invalid_request", error_description: "The demo authorization request is incomplete or invalid." });
315
+ return;
316
+ }
317
+ if (params.get("prompt") === "none") {
318
+ const sessionId = readCookie(req, IDENTITY_SESSION_COOKIE);
319
+ const persona = sessionId ? identitySessions.get(sessionId) : undefined;
320
+ if (!persona) {
321
+ redirectWithAuthorizationError(res, params, "login_required");
322
+ return;
323
+ }
324
+ redirectWithAuthorizationCode(res, params, persona);
325
+ return;
326
+ }
327
+ res.writeHead(200, {
328
+ "Content-Type": "text/html; charset=utf-8",
329
+ "Cache-Control": "no-store",
330
+ });
331
+ res.end(authorizationPage(params));
332
+ return;
333
+ }
334
+
335
+ if (req.method === "POST" && requestUrl.pathname === "/authorize/decision") {
336
+ const body = new URLSearchParams(await readBody(req));
337
+ const persona = PERSONAS[body.get("persona")];
338
+ if (!persona || !isValidAuthorizationRequest(body)) {
339
+ writeJson(res, 400, { error: "invalid_request" });
340
+ return;
341
+ }
342
+ const identitySessionId = randomUUID();
343
+ identitySessions.set(identitySessionId, persona);
344
+ redirectWithAuthorizationCode(res, body, persona, {
345
+ "Set-Cookie": identitySessionCookie(identitySessionId),
346
+ });
347
+ return;
348
+ }
349
+
350
+ if (req.method === "POST" && requestUrl.pathname === "/token") {
351
+ const body = new URLSearchParams(await readBody(req));
352
+ const code = body.get("code") ?? "";
353
+ const pending = codes.get(code);
354
+ codes.delete(code);
355
+ const verifier = body.get("code_verifier") ?? "";
356
+ const actualChallenge = createHash("sha256").update(verifier).digest("base64url");
357
+ if (
358
+ body.get("grant_type") !== "authorization_code" ||
359
+ body.get("client_id") !== CLIENT_ID ||
360
+ !pending ||
361
+ pending.expiresAt < Date.now() ||
362
+ body.get("redirect_uri") !== pending.redirectUri ||
363
+ body.get("resource") !== pending.resource ||
364
+ actualChallenge !== pending.codeChallenge
365
+ ) {
366
+ writeJson(res, 400, {
367
+ error: "invalid_grant",
368
+ error_description: "The authorization code or PKCE verifier is invalid.",
369
+ });
370
+ return;
371
+ }
372
+
373
+ const requested = new Set(pending.requestedScope.split(/\s+/).filter(Boolean));
374
+ const grantedScopes = pending.persona.scopes.filter((scope) => requested.has(scope));
375
+ const now = Math.floor(Date.now() / 1000);
376
+ const accessToken = await new SignJWT({
377
+ groups: pending.persona.groups,
378
+ scope: grantedScopes.join(" "),
379
+ client_id: CLIENT_ID,
380
+ name: pending.persona.name,
381
+ demo_persona: pending.persona.id,
382
+ })
383
+ .setProtectedHeader({ alg: "RS256", kid: KEY_ID, typ: "at+jwt" })
384
+ .setIssuer(ISSUER)
385
+ .setSubject(pending.persona.sub)
386
+ .setAudience(pending.resource)
387
+ .setIssuedAt(now)
388
+ .setExpirationTime(now + 15 * 60)
389
+ .setJti(randomUUID())
390
+ .sign(privateKey);
391
+
392
+ writeJson(res, 200, {
393
+ access_token: accessToken,
394
+ token_type: "Bearer",
395
+ expires_in: 900,
396
+ scope: grantedScopes.join(" "),
397
+ });
398
+ return;
399
+ }
400
+
401
+ writeJson(res, 404, { error: "not_found" });
402
+ });
403
+
404
+ await new Promise((resolveListen, reject) => {
405
+ server.once("error", reject);
406
+ server.listen(port, "127.0.0.1", () => {
407
+ server.off("error", reject);
408
+ resolveListen();
409
+ });
410
+ });
411
+
412
+ return {
413
+ server,
414
+ issuer: ISSUER,
415
+ close: () =>
416
+ new Promise((resolveClose, reject) => {
417
+ server.close((error) => (error ? reject(error) : resolveClose()));
418
+ }),
419
+ };
420
+ }
421
+
422
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
423
+ if (isMain) {
424
+ const instance = await startAuthorizationServer();
425
+ process.stdout.write(`[oauth-lab] authorization server listening at ${instance.issuer}\n`);
426
+ }
@@ -0,0 +1,269 @@
1
+ import { createInterface } from "node:readline";
2
+
3
+ function argument(name, fallback) {
4
+ const index = process.argv.indexOf(name);
5
+ return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
6
+ }
7
+
8
+ const slot = argument("--slot", "factory-machine");
9
+ const label = argument("--label", slot);
10
+ const startedAt = new Date().toISOString();
11
+ let running = false;
12
+ let baselineRevision = 3;
13
+
14
+ const tools = [
15
+ {
16
+ name: "get_electrical_state",
17
+ description: `Read live electrical measurements for ${label}.`,
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {},
21
+ additionalProperties: false,
22
+ },
23
+ },
24
+ {
25
+ name: "diagnose_machine",
26
+ description: `Run a non-destructive diagnostic on ${label}.`,
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ depth: {
31
+ type: "string",
32
+ enum: ["quick", "full"],
33
+ default: "quick",
34
+ },
35
+ },
36
+ additionalProperties: false,
37
+ },
38
+ },
39
+ {
40
+ name: "reset_baseline",
41
+ description: `Recompute the analysis baseline for ${label}.`,
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ reason: {
46
+ type: "string",
47
+ description: "Human-readable change reason.",
48
+ },
49
+ },
50
+ required: ["reason"],
51
+ additionalProperties: false,
52
+ },
53
+ },
54
+ {
55
+ name: "start_machine",
56
+ description: `Start ${label}. This represents an operational action.`,
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ confirmation: {
61
+ type: "boolean",
62
+ const: true,
63
+ },
64
+ },
65
+ required: ["confirmation"],
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ ];
70
+
71
+ function result(value) {
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: JSON.stringify(value, null, 2),
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ function handleTool(name, args) {
83
+ if (name === "get_electrical_state") {
84
+ return result({
85
+ slot,
86
+ label,
87
+ voltage: slot === "site-energy" ? 20000 : 400,
88
+ current: running ? 63.8 : 8.4,
89
+ powerKw: running ? 38.1 : 2.7,
90
+ running,
91
+ baselineRevision,
92
+ sampledAt: new Date().toISOString(),
93
+ });
94
+ }
95
+ if (name === "diagnose_machine") {
96
+ return result({
97
+ slot,
98
+ depth: args.depth ?? "quick",
99
+ health: slot === "critical-furnace" ? "attention" : "nominal",
100
+ findings: slot === "critical-furnace" ? ["Safety interlock active", "Thermal drift within limit"] : ["No blocking anomaly"],
101
+ });
102
+ }
103
+ if (name === "reset_baseline") {
104
+ if (typeof args.reason !== "string" || !args.reason.trim()) {
105
+ throw new Error("A non-empty reason is required.");
106
+ }
107
+ baselineRevision += 1;
108
+ return result({
109
+ slot,
110
+ changed: true,
111
+ baselineRevision,
112
+ reason: args.reason.trim(),
113
+ });
114
+ }
115
+ if (name === "start_machine") {
116
+ if (args.confirmation !== true) {
117
+ throw new Error("confirmation must be true.");
118
+ }
119
+ running = true;
120
+ return result({
121
+ slot,
122
+ running,
123
+ startedAt: new Date().toISOString(),
124
+ });
125
+ }
126
+ throw new Error(`Unknown tool: ${name}`);
127
+ }
128
+
129
+ function reply(id, body) {
130
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
131
+ }
132
+
133
+ function handle(message) {
134
+ const id = message.id;
135
+ if (id == null) return;
136
+
137
+ if (message.method === "initialize") {
138
+ reply(id, {
139
+ result: {
140
+ protocolVersion: "2024-11-05",
141
+ serverInfo: {
142
+ name: `oauth-lab-${slot}`,
143
+ version: "1.0.0",
144
+ },
145
+ capabilities: {
146
+ tools: {},
147
+ resources: {},
148
+ prompts: {},
149
+ },
150
+ },
151
+ });
152
+ return;
153
+ }
154
+
155
+ if (message.method === "ping") {
156
+ reply(id, { result: {} });
157
+ return;
158
+ }
159
+
160
+ if (message.method === "tools/list") {
161
+ reply(id, { result: { tools } });
162
+ return;
163
+ }
164
+
165
+ if (message.method === "tools/call") {
166
+ try {
167
+ const name = message.params?.name;
168
+ const args = message.params?.arguments ?? {};
169
+ reply(id, { result: handleTool(name, args) });
170
+ } catch (error) {
171
+ reply(id, {
172
+ error: {
173
+ code: -32602,
174
+ message: error instanceof Error ? error.message : "Invalid tool arguments",
175
+ },
176
+ });
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (message.method === "resources/list") {
182
+ reply(id, {
183
+ result: {
184
+ resources: [
185
+ {
186
+ uri: `factory://${slot}/state`,
187
+ name: `${label} state`,
188
+ mimeType: "application/json",
189
+ },
190
+ ],
191
+ },
192
+ });
193
+ return;
194
+ }
195
+
196
+ if (message.method === "resources/read") {
197
+ reply(id, {
198
+ result: {
199
+ contents: [
200
+ {
201
+ uri: `factory://${slot}/state`,
202
+ mimeType: "application/json",
203
+ text: JSON.stringify({
204
+ slot,
205
+ label,
206
+ running,
207
+ baselineRevision,
208
+ providerStartedAt: startedAt,
209
+ }),
210
+ },
211
+ ],
212
+ },
213
+ });
214
+ return;
215
+ }
216
+
217
+ if (message.method === "prompts/list") {
218
+ reply(id, {
219
+ result: {
220
+ prompts: [
221
+ {
222
+ name: "inspection_brief",
223
+ description: `Build an inspection brief for ${label}.`,
224
+ },
225
+ ],
226
+ },
227
+ });
228
+ return;
229
+ }
230
+
231
+ if (message.method === "prompts/get") {
232
+ reply(id, {
233
+ result: {
234
+ description: `Inspection brief for ${label}`,
235
+ messages: [
236
+ {
237
+ role: "user",
238
+ content: {
239
+ type: "text",
240
+ text: `Review the current state of ${label} in slot ${slot}.`,
241
+ },
242
+ },
243
+ ],
244
+ },
245
+ });
246
+ return;
247
+ }
248
+
249
+ reply(id, {
250
+ error: {
251
+ code: -32601,
252
+ message: `Method not found: ${message.method ?? "(none)"}`,
253
+ },
254
+ });
255
+ }
256
+
257
+ const lines = createInterface({
258
+ input: process.stdin,
259
+ crlfDelay: Infinity,
260
+ });
261
+
262
+ lines.on("line", (line) => {
263
+ if (!line.trim()) return;
264
+ try {
265
+ handle(JSON.parse(line));
266
+ } catch {
267
+ process.stderr.write(`[oauth-lab:${slot}] ignored malformed JSON-RPC input\n`);
268
+ }
269
+ });