@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,654 @@
1
+ const AUTHORIZATION_SERVER = "http://127.0.0.1:4100";
2
+ const BROKER_ORIGIN = "http://127.0.0.1:3001";
3
+ const CLIENT_ID = "mcp-oauth-lab";
4
+ const TOKEN_CACHE_KEY = "mcp-oauth-lab:token-cache";
5
+ const LEGACY_SESSION_KEY = "mcp-oauth-lab:session";
6
+ const PENDING_KEY = "mcp-oauth-lab:pending";
7
+ const SELECTED_SLOT_KEY = "mcp-oauth-lab:selected-slot";
8
+
9
+ const state = {
10
+ slot: sessionStorage.getItem(SELECTED_SLOT_KEY) ?? "motor-7",
11
+ metadata: null,
12
+ authorizationMetadata: null,
13
+ tokenCache: readStoredTokenCache(),
14
+ session: null,
15
+ // Streamable HTTP session ids, keyed by `<slot>:<jti>`. `initialize` mints
16
+ // one and hands it back in `Mcp-Session-Id`; every later request on that
17
+ // slot must send it or the broker answers 400.
18
+ mcpSessions: new Map(),
19
+ auditFingerprint: "",
20
+ };
21
+
22
+ const elements = {
23
+ brokerStatus: document.getElementById("broker-status"),
24
+ issuerStatus: document.getElementById("issuer-status"),
25
+ discoverButton: document.getElementById("discover-btn"),
26
+ metadataCard: document.getElementById("metadata-card"),
27
+ signInButton: document.getElementById("sign-in-btn"),
28
+ signOutButton: document.getElementById("sign-out-btn"),
29
+ reauthorizeButton: document.getElementById("reauthorize-btn"),
30
+ audienceNotice: document.getElementById("audience-notice"),
31
+ audienceNoticeCopy: document.getElementById("audience-notice-copy"),
32
+ sessionEmpty: document.getElementById("session-empty"),
33
+ sessionActive: document.getElementById("session-active"),
34
+ identityAvatar: document.getElementById("identity-avatar"),
35
+ identityName: document.getElementById("identity-name"),
36
+ identitySubject: document.getElementById("identity-subject"),
37
+ claimAudience: document.getElementById("claim-aud"),
38
+ claimGroups: document.getElementById("claim-groups"),
39
+ claimScope: document.getElementById("claim-scope"),
40
+ claimExpiration: document.getElementById("claim-exp"),
41
+ cacheSummary: document.getElementById("cache-summary"),
42
+ claimsOutput: document.getElementById("claims-output"),
43
+ targetSlot: document.getElementById("target-slot"),
44
+ requestOutput: document.getElementById("request-output"),
45
+ responseOutput: document.getElementById("response-output"),
46
+ consoleStatus: document.getElementById("console-status"),
47
+ consoleDuration: document.getElementById("console-duration"),
48
+ consoleDot: document.getElementById("console-dot"),
49
+ auditList: document.getElementById("audit-list"),
50
+ auditCount: document.getElementById("audit-count"),
51
+ clearAuditButton: document.getElementById("clear-audit-btn"),
52
+ toast: document.getElementById("toast"),
53
+ };
54
+
55
+ function isUsableSession(value) {
56
+ return !!value?.accessToken && !!value?.claims && typeof value.resource === "string" && (typeof value.claims.exp !== "number" || value.claims.exp * 1000 > Date.now());
57
+ }
58
+
59
+ function readStoredTokenCache() {
60
+ const usable = {};
61
+ try {
62
+ const stored = JSON.parse(sessionStorage.getItem(TOKEN_CACHE_KEY) ?? "{}");
63
+ for (const value of Object.values(stored ?? {})) {
64
+ if (isUsableSession(value)) usable[value.resource] = value;
65
+ }
66
+ } catch {
67
+ sessionStorage.removeItem(TOKEN_CACHE_KEY);
68
+ }
69
+
70
+ try {
71
+ const legacy = JSON.parse(sessionStorage.getItem(LEGACY_SESSION_KEY) ?? "null");
72
+ if (isUsableSession(legacy)) usable[legacy.resource] = legacy;
73
+ } catch {
74
+ // Ignore invalid data from the previous single-token implementation.
75
+ } finally {
76
+ sessionStorage.removeItem(LEGACY_SESSION_KEY);
77
+ }
78
+
79
+ if (Object.keys(usable).length > 0) sessionStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(usable));
80
+ return usable;
81
+ }
82
+
83
+ function selectedSlot() {
84
+ return document.querySelector('input[name="slot"]:checked')?.value ?? state.slot;
85
+ }
86
+
87
+ function metadataUrl(slot) {
88
+ return `${window.location.origin}/.well-known/oauth-protected-resource/${encodeURIComponent(slot)}/mcp`;
89
+ }
90
+
91
+ function resourceUrl(slot) {
92
+ return `${window.location.origin}/${encodeURIComponent(slot)}/mcp`;
93
+ }
94
+
95
+ function cachedSession(slot) {
96
+ return state.tokenCache[resourceUrl(slot)] ?? null;
97
+ }
98
+
99
+ function cachedSessions() {
100
+ return Object.values(state.tokenCache);
101
+ }
102
+
103
+ function syncActiveSession() {
104
+ state.session = cachedSession(selectedSlot()) ?? cachedSessions()[0] ?? null;
105
+ }
106
+
107
+ function persistTokenCache() {
108
+ if (cachedSessions().length === 0) {
109
+ sessionStorage.removeItem(TOKEN_CACHE_KEY);
110
+ return;
111
+ }
112
+ sessionStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(state.tokenCache));
113
+ }
114
+
115
+ function tokenPreview(token) {
116
+ return `${token.slice(0, 8)}...${token.slice(-4)}`;
117
+ }
118
+
119
+ function base64Url(bytes) {
120
+ let binary = "";
121
+ for (const byte of bytes) binary += String.fromCharCode(byte);
122
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
123
+ }
124
+
125
+ function randomBase64Url(length = 32) {
126
+ const bytes = new Uint8Array(length);
127
+ crypto.getRandomValues(bytes);
128
+ return base64Url(bytes);
129
+ }
130
+
131
+ async function sha256Base64Url(value) {
132
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
133
+ return base64Url(new Uint8Array(digest));
134
+ }
135
+
136
+ function decodeJwt(token) {
137
+ const payload = token.split(".")[1];
138
+ if (!payload) throw new Error("The access token is not a JWT.");
139
+ const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
140
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
141
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(padded), (character) => character.charCodeAt(0))));
142
+ }
143
+
144
+ function initials(name) {
145
+ return String(name ?? "?")
146
+ .split(/\s+/u)
147
+ .filter(Boolean)
148
+ .map((part) => part[0])
149
+ .join("")
150
+ .slice(0, 2)
151
+ .toUpperCase();
152
+ }
153
+
154
+ function escapeHtml(value) {
155
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
156
+ }
157
+
158
+ function showToast(message) {
159
+ elements.toast.textContent = message;
160
+ elements.toast.classList.add("show");
161
+ window.clearTimeout(showToast.timer);
162
+ showToast.timer = window.setTimeout(() => elements.toast.classList.remove("show"), 3000);
163
+ }
164
+
165
+ function setServiceStatus(element, online, title) {
166
+ element.classList.remove("checking", "online", "offline");
167
+ element.classList.add(online ? "online" : "offline");
168
+ element.title = title;
169
+ }
170
+
171
+ async function checkServices() {
172
+ const checks = await Promise.allSettled([fetch(window.location.origin, { cache: "no-store" }), fetch(`${AUTHORIZATION_SERVER}/health`, { cache: "no-store" })]);
173
+ const brokerOnline = window.location.origin === BROKER_ORIGIN && checks[0].status === "fulfilled" && checks[0].value.ok;
174
+ const issuerOnline = checks[1].status === "fulfilled" && checks[1].value.ok;
175
+ setServiceStatus(elements.brokerStatus, brokerOnline, brokerOnline ? "Broker online" : "Start this page with npm run demo:oauth");
176
+ setServiceStatus(elements.issuerStatus, issuerOnline, issuerOnline ? "Authorization server online" : "Authorization server unavailable");
177
+ }
178
+
179
+ async function discover() {
180
+ elements.discoverButton.disabled = true;
181
+ elements.discoverButton.textContent = "Loading";
182
+ try {
183
+ const slot = selectedSlot();
184
+ const response = await fetch(metadataUrl(slot), { cache: "no-store" });
185
+ if (!response.ok) throw new Error(`Metadata request returned HTTP ${response.status}.`);
186
+ const metadata = await response.json();
187
+ const issuer = metadata.authorization_servers?.[0];
188
+ if (!issuer) throw new Error("The metadata does not advertise an authorization server.");
189
+ const authorizationResponse = await fetch(`${issuer}/.well-known/oauth-authorization-server`, { cache: "no-store" });
190
+ if (!authorizationResponse.ok) throw new Error(`Authorization metadata returned HTTP ${authorizationResponse.status}.`);
191
+ state.metadata = metadata;
192
+ state.authorizationMetadata = await authorizationResponse.json();
193
+ elements.metadataCard.innerHTML = `
194
+ <dl class="metadata-values">
195
+ <div><dt>resource</dt><dd>${escapeHtml(metadata.resource)}</dd></div>
196
+ <div><dt>issuer</dt><dd>${escapeHtml(issuer)}</dd></div>
197
+ <div><dt>scopes</dt><dd>${escapeHtml((metadata.scopes_supported ?? []).join(" "))}</dd></div>
198
+ <div><dt>bearer</dt><dd>${escapeHtml((metadata.bearer_methods_supported ?? []).join(", "))}</dd></div>
199
+ </dl>`;
200
+ document.getElementById("flow-discovery").classList.add("complete");
201
+ showToast("Protected resource discovered");
202
+ return metadata;
203
+ } catch (error) {
204
+ elements.metadataCard.innerHTML = `
205
+ <div class="metadata-empty">
206
+ <span class="metadata-mark">!</span>
207
+ <p>${escapeHtml(error instanceof Error ? error.message : String(error))}</p>
208
+ </div>`;
209
+ throw error;
210
+ } finally {
211
+ elements.discoverButton.disabled = false;
212
+ elements.discoverButton.textContent = "Discover";
213
+ }
214
+ }
215
+
216
+ async function beginAuthorization(silent = cachedSessions().length > 0) {
217
+ elements.signInButton.disabled = true;
218
+ elements.reauthorizeButton.disabled = true;
219
+ try {
220
+ const metadata = state.metadata?.resource === resourceUrl(selectedSlot()) ? state.metadata : await discover();
221
+ const verifier = randomBase64Url(48);
222
+ const challenge = await sha256Base64Url(verifier);
223
+ const oauthState = randomBase64Url(24);
224
+ const redirectUri = `${window.location.origin}${window.location.pathname}`;
225
+ const pending = {
226
+ verifier,
227
+ state: oauthState,
228
+ redirectUri,
229
+ resource: metadata.resource,
230
+ slot: selectedSlot(),
231
+ tokenEndpoint: state.authorizationMetadata.token_endpoint,
232
+ createdAt: Date.now(),
233
+ silent,
234
+ };
235
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
236
+ const authorizationUrl = new URL(state.authorizationMetadata.authorization_endpoint);
237
+ authorizationUrl.searchParams.set("response_type", "code");
238
+ authorizationUrl.searchParams.set("client_id", CLIENT_ID);
239
+ authorizationUrl.searchParams.set("redirect_uri", redirectUri);
240
+ authorizationUrl.searchParams.set("scope", "mcp:call broker:admin");
241
+ authorizationUrl.searchParams.set("resource", metadata.resource);
242
+ authorizationUrl.searchParams.set("state", oauthState);
243
+ authorizationUrl.searchParams.set("code_challenge", challenge);
244
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
245
+ authorizationUrl.searchParams.set("prompt", silent ? "none" : "select_account");
246
+ document.getElementById("flow-login").classList.add("complete");
247
+ window.location.assign(authorizationUrl);
248
+ } catch (error) {
249
+ showToast(error instanceof Error ? error.message : String(error));
250
+ elements.signInButton.disabled = false;
251
+ elements.reauthorizeButton.disabled = false;
252
+ }
253
+ }
254
+
255
+ async function completeAuthorization() {
256
+ const url = new URL(window.location.href);
257
+ const code = url.searchParams.get("code");
258
+ const oauthError = url.searchParams.get("error");
259
+ const returnedState = url.searchParams.get("state");
260
+ if (!code && !oauthError) return;
261
+
262
+ let pending;
263
+ try {
264
+ pending = JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? "null");
265
+ } catch {
266
+ pending = null;
267
+ }
268
+ if (!pending || returnedState !== pending.state || Date.now() - pending.createdAt > 5 * 60 * 1000) {
269
+ throw new Error("OAuth state validation failed. Start the sign-in flow again.");
270
+ }
271
+ if (oauthError) {
272
+ sessionStorage.removeItem(PENDING_KEY);
273
+ window.history.replaceState({}, "", window.location.pathname);
274
+ if (oauthError === "login_required" && pending.silent) {
275
+ showToast("Identity session expired. Choose the identity again.");
276
+ await beginAuthorization(false);
277
+ return;
278
+ }
279
+ throw new Error(`OAuth authorization failed: ${oauthError}.`);
280
+ }
281
+
282
+ const tokenResponse = await fetch(pending.tokenEndpoint, {
283
+ method: "POST",
284
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
285
+ body: new URLSearchParams({
286
+ grant_type: "authorization_code",
287
+ code,
288
+ client_id: CLIENT_ID,
289
+ redirect_uri: pending.redirectUri,
290
+ code_verifier: pending.verifier,
291
+ resource: pending.resource,
292
+ }),
293
+ });
294
+ const tokenBody = await tokenResponse.json();
295
+ if (!tokenResponse.ok || !tokenBody.access_token) {
296
+ throw new Error(tokenBody.error_description ?? tokenBody.error ?? "Token exchange failed.");
297
+ }
298
+ const claims = decodeJwt(tokenBody.access_token);
299
+ const session = {
300
+ accessToken: tokenBody.access_token,
301
+ claims,
302
+ scope: tokenBody.scope ?? "",
303
+ resource: pending.resource,
304
+ slot: pending.slot,
305
+ };
306
+ const cachedPersona = cachedSessions()[0]?.claims?.demo_persona;
307
+ if (cachedPersona && cachedPersona !== claims.demo_persona) {
308
+ state.tokenCache = {};
309
+ state.mcpSessions.clear();
310
+ }
311
+ state.tokenCache[pending.resource] = session;
312
+ state.slot = pending.slot;
313
+ persistTokenCache();
314
+ syncActiveSession();
315
+ sessionStorage.setItem(SELECTED_SLOT_KEY, state.slot);
316
+ sessionStorage.removeItem(PENDING_KEY);
317
+ window.history.replaceState({}, "", window.location.pathname);
318
+ document.getElementById("flow-login").classList.add("complete");
319
+ document.getElementById("flow-token").classList.add("complete");
320
+ showToast(`Token ${tokenPreview(tokenBody.access_token)} added to cache for ${pending.slot}`);
321
+ }
322
+
323
+ function clearTokenCache() {
324
+ state.tokenCache = {};
325
+ state.session = null;
326
+ state.mcpSessions.clear();
327
+ sessionStorage.removeItem(TOKEN_CACHE_KEY);
328
+ sessionStorage.removeItem(LEGACY_SESSION_KEY);
329
+ renderSession();
330
+ renderExpectations();
331
+ showToast("Token cache cleared");
332
+ }
333
+
334
+ function renderSession() {
335
+ const session = state.session;
336
+ elements.sessionEmpty.classList.toggle("hidden", !!session);
337
+ elements.sessionActive.classList.toggle("hidden", !session);
338
+ elements.signOutButton.classList.toggle("hidden", !session);
339
+ if (!session) {
340
+ document.getElementById("flow-token").classList.remove("complete");
341
+ return;
342
+ }
343
+
344
+ const claims = session.claims;
345
+ elements.identityAvatar.textContent = initials(claims.name ?? claims.sub);
346
+ elements.identityName.textContent = claims.name ?? claims.sub ?? "Unknown subject";
347
+ elements.identitySubject.textContent = `user:${claims.sub ?? "unknown"} | client:${claims.client_id ?? "unknown"}`;
348
+ elements.claimAudience.textContent = Array.isArray(claims.aud) ? claims.aud.join(", ") : (claims.aud ?? "");
349
+ elements.claimGroups.textContent = Array.isArray(claims.groups) ? claims.groups.join(", ") : (claims.groups ?? "");
350
+ elements.claimScope.textContent = claims.scope ?? "";
351
+ elements.claimExpiration.textContent = typeof claims.exp === "number" ? new Date(claims.exp * 1000).toLocaleTimeString() : "unknown";
352
+ elements.cacheSummary.textContent = `${cachedSessions().length} of 4 resource tokens`;
353
+ elements.claimsOutput.textContent = JSON.stringify(claims, null, 2);
354
+ renderAudienceState();
355
+ document.getElementById("flow-login").classList.add("complete");
356
+ document.getElementById("flow-token").classList.add("complete");
357
+ }
358
+
359
+ function renderAudienceState() {
360
+ const pill = document.querySelector(".token-valid");
361
+ if (!pill || !state.session) return;
362
+ const matches = !!cachedSession(selectedSlot());
363
+ pill.textContent = matches ? "cache hit" : "token missing";
364
+ pill.classList.toggle("mismatch", !matches);
365
+ elements.audienceNotice.classList.toggle("hidden", matches);
366
+ elements.reauthorizeButton.disabled = false;
367
+ if (!matches) {
368
+ const identity = state.session.claims?.name ?? state.session.claims?.sub ?? "This identity";
369
+ elements.audienceNoticeCopy.textContent = `No token is cached for ${selectedSlot()}. The authorization server can add one silently for ${identity}, without showing the identity page again.`;
370
+ elements.reauthorizeButton.textContent = `Add ${selectedSlot()} token to cache`;
371
+ }
372
+ }
373
+
374
+ function expected(action) {
375
+ if (action === "unauthenticated" || action === "wrong-audience") return { allowed: false, label: "expect 401" };
376
+ const slot = selectedSlot();
377
+ const session = cachedSession(slot);
378
+ const persona = session?.claims?.demo_persona;
379
+ if (!persona) return { allowed: null, label: cachedSessions().length > 0 ? "cache token first" : "sign in first" };
380
+ if (slot === "_broker") {
381
+ return persona === "picard" ? { allowed: true, label: "expect allow" } : { allowed: false, label: "expect scope 403" };
382
+ }
383
+ if (persona === "picard") return { allowed: true, label: "expect allow" };
384
+ if (persona === "seven") {
385
+ return ["list", "read"].includes(action) ? { allowed: true, label: "expect allow" } : { allowed: false, label: "expect policy deny" };
386
+ }
387
+ if (slot === "site-energy") return { allowed: false, label: "expect policy deny" };
388
+ if (persona === "la-forge") {
389
+ const allowed = ["list", "read", "diagnose"].includes(action) || (action === "reset" && slot !== "critical-furnace");
390
+ const explicit = action === "reset" && slot === "critical-furnace";
391
+ return { allowed, label: allowed ? "expect allow" : explicit ? "explicit policy deny" : "expect policy deny" };
392
+ }
393
+ if (persona === "worf") {
394
+ const allowed = ["list", "read", "start"].includes(action);
395
+ return { allowed, label: allowed ? "expect allow" : "expect policy deny" };
396
+ }
397
+ return { allowed: false, label: "expect policy deny" };
398
+ }
399
+
400
+ function renderExpectations() {
401
+ for (const element of document.querySelectorAll("[data-expect]")) {
402
+ const result = expected(element.dataset.expect);
403
+ element.textContent = result.label;
404
+ element.classList.toggle("allow", result.allowed === true);
405
+ element.classList.toggle("deny", result.allowed === false);
406
+ }
407
+ }
408
+
409
+ function requestForAction(action) {
410
+ if (action === "list" || action === "unauthenticated" || action === "wrong-audience") {
411
+ return { method: "tools/list", params: {} };
412
+ }
413
+ if (action === "read") {
414
+ return { method: "tools/call", params: { name: "get_electrical_state", arguments: {} } };
415
+ }
416
+ if (action === "diagnose") {
417
+ return { method: "tools/call", params: { name: "diagnose_machine", arguments: { depth: "full" } } };
418
+ }
419
+ if (action === "reset") {
420
+ return {
421
+ method: "tools/call",
422
+ params: { name: "reset_baseline", arguments: { reason: "OAuth Policy Lab demonstration" } },
423
+ };
424
+ }
425
+ if (action === "start") {
426
+ return { method: "tools/call", params: { name: "start_machine", arguments: { confirmation: true } } };
427
+ }
428
+ return { method: "tools/list", params: {} };
429
+ }
430
+
431
+ async function sendRpc(slot, method, params, accessToken, mcpSessionId) {
432
+ const id = Math.floor(Date.now() + Math.random() * 1000);
433
+ const message = { jsonrpc: "2.0", id, method, params };
434
+ const headers = {
435
+ "Content-Type": "application/json",
436
+ Accept: "application/json, text/event-stream",
437
+ };
438
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
439
+ if (mcpSessionId) headers["Mcp-Session-Id"] = mcpSessionId;
440
+ const startedAt = performance.now();
441
+ const response = await fetch(resourceUrl(slot), {
442
+ method: "POST",
443
+ headers,
444
+ body: JSON.stringify(message),
445
+ });
446
+ const text = await response.text();
447
+ let body = text;
448
+ try {
449
+ body = text ? JSON.parse(text) : null;
450
+ } catch {
451
+ // Keep non-JSON responses as text.
452
+ }
453
+ return {
454
+ request: {
455
+ url: resourceUrl(slot),
456
+ authorization: accessToken ? "Bearer [redacted]" : "(none)",
457
+ mcpSessionId: mcpSessionId ?? "(none)",
458
+ body: message,
459
+ },
460
+ response: {
461
+ status: response.status,
462
+ wwwAuthenticate: response.headers.get("www-authenticate"),
463
+ mcpSessionId: response.headers.get("mcp-session-id"),
464
+ body,
465
+ },
466
+ duration: performance.now() - startedAt,
467
+ };
468
+ }
469
+
470
+ /** The session key a slot and caller share. */
471
+ function mcpSessionKey(slot, claims) {
472
+ return `${slot}:${claims?.jti ?? "anonymous"}`;
473
+ }
474
+
475
+ /**
476
+ * Opens a Streamable HTTP session on `slot` if this caller has none yet, and
477
+ * returns its id. The id is what every later request on that slot must carry.
478
+ */
479
+ async function ensureInitialized(slot, accessToken, claims) {
480
+ const key = mcpSessionKey(slot, claims);
481
+ const known = state.mcpSessions.get(key);
482
+ if (known) return known;
483
+
484
+ const initialized = await sendRpc(
485
+ slot,
486
+ "initialize",
487
+ {
488
+ protocolVersion: "2024-11-05",
489
+ capabilities: {},
490
+ clientInfo: { name: "oauth-policy-lab", version: "1.0.0" },
491
+ },
492
+ accessToken
493
+ );
494
+ if (initialized.response.status >= 400 || initialized.response.body?.error) return null;
495
+
496
+ const sessionId = initialized.response.mcpSessionId;
497
+ await sendRpc(slot, "notifications/initialized", {}, accessToken, sessionId);
498
+ if (sessionId) state.mcpSessions.set(key, sessionId);
499
+ return sessionId;
500
+ }
501
+
502
+ function alternateSlot(slot) {
503
+ const slots = ["motor-7", "critical-furnace", "site-energy", "_broker"];
504
+ return slots.find((candidate) => candidate !== slot) ?? "motor-7";
505
+ }
506
+
507
+ function setConsole(result, label) {
508
+ const successful = result.response.status < 400 && !result.response.body?.error;
509
+ elements.consoleStatus.textContent = `${label} | HTTP ${result.response.status}`;
510
+ elements.consoleDuration.textContent = `${Math.round(result.duration)} ms`;
511
+ elements.consoleDot.classList.toggle("success", successful);
512
+ elements.consoleDot.classList.toggle("failure", !successful);
513
+ elements.requestOutput.textContent = JSON.stringify(result.request, null, 2);
514
+ elements.responseOutput.textContent = JSON.stringify(result.response, null, 2);
515
+ if (result.response.status === 401 || result.response.status === 403) {
516
+ elements.responseOutput.scrollTop = 0;
517
+ }
518
+ }
519
+
520
+ async function runAction(action, button) {
521
+ const selected = selectedSlot();
522
+ const session = action === "wrong-audience" ? state.session : cachedSession(selected);
523
+ if (!session && action !== "unauthenticated") {
524
+ showToast(cachedSessions().length > 0 ? `Add a token to the cache for ${selected}` : "Sign in before running this action");
525
+ return;
526
+ }
527
+
528
+ button.disabled = true;
529
+ const target = action === "wrong-audience" ? alternateSlot(session?.slot ?? selected) : selected;
530
+ const accessToken = action === "unauthenticated" ? null : session?.accessToken;
531
+ const request = requestForAction(action);
532
+ try {
533
+ // `wrong-audience` and `unauthenticated` are meant to be turned away by
534
+ // the token guard, which runs before any session exists, so neither
535
+ // opens one.
536
+ let mcpSessionId = null;
537
+ if (accessToken && action !== "wrong-audience") {
538
+ mcpSessionId = await ensureInitialized(target, accessToken, session?.claims);
539
+ }
540
+ const result = await sendRpc(target, request.method, request.params, accessToken, mcpSessionId);
541
+ setConsole(result, action);
542
+ if (accessToken && action !== "wrong-audience") {
543
+ document.getElementById("flow-policy").classList.add("complete");
544
+ }
545
+ await refreshAudit();
546
+ } catch (error) {
547
+ elements.consoleStatus.textContent = "Request failed";
548
+ elements.consoleDuration.textContent = "";
549
+ elements.consoleDot.classList.remove("success");
550
+ elements.consoleDot.classList.add("failure");
551
+ elements.responseOutput.textContent = error instanceof Error ? (error.stack ?? error.message) : String(error);
552
+ } finally {
553
+ button.disabled = false;
554
+ }
555
+ }
556
+
557
+ function renderAudit(events) {
558
+ const fingerprint = JSON.stringify(events);
559
+ if (fingerprint === state.auditFingerprint) return;
560
+ state.auditFingerprint = fingerprint;
561
+ elements.auditCount.textContent = `${events.length} event${events.length === 1 ? "" : "s"}`;
562
+ if (events.length === 0) {
563
+ elements.auditList.innerHTML = '<div class="audit-empty">Run an authenticated MCP action to generate the first decision.</div>';
564
+ return;
565
+ }
566
+ elements.auditList.innerHTML = events
567
+ .map(
568
+ (event) => `
569
+ <div class="audit-event ${event.allowed ? "" : "denied"}">
570
+ <span class="audit-time">${escapeHtml(new Date(event.timestamp).toLocaleTimeString())}</span>
571
+ <span class="decision">${event.allowed ? "allowed" : "denied"}</span>
572
+ <span class="audit-capability">${escapeHtml(event.capability ?? "(unclassified)")}</span>
573
+ <span class="audit-resource" title="${escapeHtml(event.resource ?? event.slot)}">${escapeHtml(event.resource ?? event.slot)}</span>
574
+ <span class="audit-reason">${escapeHtml(event.reason)}</span>
575
+ </div>`
576
+ )
577
+ .join("");
578
+ }
579
+
580
+ async function refreshAudit() {
581
+ try {
582
+ const response = await fetch(`${AUTHORIZATION_SERVER}/demo/audit`, { cache: "no-store" });
583
+ if (!response.ok) return;
584
+ const body = await response.json();
585
+ renderAudit(body.events ?? []);
586
+ } catch {
587
+ // Audit is auxiliary; service status reports an unavailable issuer.
588
+ }
589
+ }
590
+
591
+ async function clearAudit() {
592
+ await fetch(`${AUTHORIZATION_SERVER}/demo/audit/clear`, { method: "POST" });
593
+ state.auditFingerprint = "";
594
+ await refreshAudit();
595
+ showToast("Audit log cleared");
596
+ }
597
+
598
+ function applySelectedSlot() {
599
+ const input = document.querySelector(`input[name="slot"][value="${CSS.escape(state.slot)}"]`);
600
+ if (input) input.checked = true;
601
+ elements.targetSlot.textContent = selectedSlot();
602
+ renderExpectations();
603
+ }
604
+
605
+ for (const input of document.querySelectorAll('input[name="slot"]')) {
606
+ input.addEventListener("change", () => {
607
+ state.slot = selectedSlot();
608
+ state.metadata = null;
609
+ state.authorizationMetadata = null;
610
+ sessionStorage.setItem(SELECTED_SLOT_KEY, state.slot);
611
+ elements.targetSlot.textContent = state.slot;
612
+ elements.metadataCard.innerHTML = `
613
+ <div class="metadata-empty">
614
+ <span class="metadata-mark">?</span>
615
+ <p>Run discovery to load RFC 9728 metadata without a token.</p>
616
+ </div>`;
617
+ document.getElementById("flow-discovery").classList.remove("complete");
618
+ syncActiveSession();
619
+ renderSession();
620
+ renderExpectations();
621
+ });
622
+ }
623
+
624
+ elements.discoverButton.addEventListener("click", () => {
625
+ void discover().catch((error) => showToast(error instanceof Error ? error.message : String(error)));
626
+ });
627
+ elements.signInButton.addEventListener("click", () => void beginAuthorization(false));
628
+ elements.reauthorizeButton.addEventListener("click", () => void beginAuthorization(true));
629
+ elements.signOutButton.addEventListener("click", clearTokenCache);
630
+ elements.clearAuditButton.addEventListener("click", () => void clearAudit());
631
+
632
+ for (const button of document.querySelectorAll("[data-action]")) {
633
+ button.addEventListener("click", () => void runAction(button.dataset.action, button));
634
+ }
635
+
636
+ async function initializePage() {
637
+ applySelectedSlot();
638
+ try {
639
+ await completeAuthorization();
640
+ } catch (error) {
641
+ sessionStorage.removeItem(PENDING_KEY);
642
+ window.history.replaceState({}, "", window.location.pathname);
643
+ showToast(error instanceof Error ? error.message : String(error));
644
+ }
645
+ applySelectedSlot();
646
+ syncActiveSession();
647
+ renderSession();
648
+ renderExpectations();
649
+ await Promise.all([checkServices(), refreshAudit()]);
650
+ window.setInterval(() => void refreshAudit(), 1500);
651
+ window.setInterval(() => void checkServices(), 10000);
652
+ }
653
+
654
+ void initializePage();