@cloudgrid-io/mcp 0.21.23 → 0.21.24

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 (2) hide show
  1. package/package.json +2 -1
  2. package/src/oauth.js +167 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.21.23",
3
+ "version": "0.21.24",
4
4
  "description": "MCP access to CloudGrid — the live runtime environment where the user's apps run WITH the infrastructure they need out of the box: managed MongoDB, Redis (cache/queues/pub-sub), a pgvector store, persistent disk, and built-in AI with no API keys, in any language or stack, no Docker or Kubernetes to manage. Brainstorm, build, plug, and improve apps, games, dashboards, and tools; instant single-page publishes and full runtime-app deploys with a live URL — for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -79,6 +79,7 @@
79
79
  "test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
80
80
  "test:oauth-token-exchange": "node --test test/oauth-token-exchange.test.mjs",
81
81
  "test:oauth-resource-indicators": "node --test test/oauth-resource-indicators.test.mjs",
82
+ "test:oauth-redirect-consent": "node --test test/oauth-redirect-consent.test.mjs",
82
83
  "test:oauth-observability": "node --test test/oauth-flow-observability.test.mjs",
83
84
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
84
85
  "test:mcp-transport-observability": "node --test test/mcp-transport-observability.test.mjs",
package/src/oauth.js CHANGED
@@ -117,10 +117,10 @@ function normalizeResource(uri) {
117
117
  // 3. Later, DROP the old key. client_ids minted under it stop verifying and
118
118
  // those users re-add the connector ONCE — the deliberate-rotation cost,
119
119
  // versus today's every-deploy breakage.
120
- // An attacker who obtains the secret can forge a client_id for an arbitrary
121
- // redirect set — but /oauth/register already mints one for any redirect set
122
- // unauthenticated, so the secret gates INTEGRITY, not registration; PKCE +
123
- // CloudGrid sign-in still gate getting a token.
120
+ // Whoever holds the secret can forge a client_id for an arbitrary redirect set,
121
+ // so /oauth/authorize re-applies the redirect URI rules to every client_id it
122
+ // verifies rather than trusting the signed set alone; PKCE + CloudGrid sign-in
123
+ // still gate getting a token.
124
124
  const CLIENT_ID_PREFIX = "cg1"; // versioned so the scheme can evolve
125
125
 
126
126
  // A client_id CARRIES its redirect set and travels in a query string, so the set
@@ -147,6 +147,58 @@ function canonicalRedirectUris(uris) {
147
147
  return JSON.stringify([...new Set(uris.map((u) => String(u)))].sort());
148
148
  }
149
149
 
150
+ // The signed payload. Without a client_name it is the bare redirect array — the
151
+ // original format, so every client_id minted before client_name was carried
152
+ // still verifies unchanged. With one, it is { redirect_uris, client_name }.
153
+ function signedPayload(canonical, clientName) {
154
+ if (!clientName) return canonical;
155
+ return JSON.stringify({ redirect_uris: JSON.parse(canonical), client_name: clientName });
156
+ }
157
+
158
+ // A registered client_name is shown on the consent step, so it is bounded and
159
+ // stripped of control characters. It is self-asserted by the client: the consent
160
+ // step shows the redirect HOST alongside it, and the host is what the user can
161
+ // actually check.
162
+ const MAX_CLIENT_NAME_LENGTH = 80;
163
+ function cleanClientName(name) {
164
+ if (typeof name !== "string") return "";
165
+ return name.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_CLIENT_NAME_LENGTH);
166
+ }
167
+
168
+ // --- Redirect URI rules -------------------------------------------------------
169
+ // A redirect URI must be `https:`, or `http:` on a loopback address (local
170
+ // clients run a callback listener on 127.0.0.1 / [::1] / localhost). Anything
171
+ // else — other schemes, http to a non-loopback host, a fragment (RFC 6749
172
+ // §3.1.2), embedded credentials — is refused. Enforced at registration
173
+ // (invalid_redirect_uri, RFC 7591 §3.2.2) and again at authorize, so a client_id
174
+ // minted before these rules cannot be used with a URI that breaks them.
175
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
176
+
177
+ // Returns null when the URI is acceptable, else a reason for error_description.
178
+ export function redirectUriProblem(uri) {
179
+ if (typeof uri !== "string" || uri === "") return "redirect_uri must be a non-empty string.";
180
+ let u;
181
+ try {
182
+ u = new URL(uri);
183
+ } catch {
184
+ return "redirect_uri must be an absolute URI.";
185
+ }
186
+ if (uri.includes("#")) return "redirect_uri must not contain a fragment.";
187
+ if (u.username || u.password) return "redirect_uri must not contain user credentials.";
188
+ if (u.protocol === "https:" && u.hostname) return null;
189
+ if (u.protocol === "http:" && LOOPBACK_HOSTS.has(u.hostname)) return null;
190
+ return "redirect_uri must use https, or http on a loopback address (127.0.0.1, [::1], localhost).";
191
+ }
192
+
193
+ function isHttpUrl(uri) {
194
+ try {
195
+ const { protocol } = new URL(String(uri));
196
+ return protocol === "https:" || protocol === "http:";
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
150
202
  function signClientId(canonical, secret) {
151
203
  const payload = b64url(Buffer.from(canonical, "utf8"));
152
204
  const signingInput = `${CLIENT_ID_PREFIX}.${payload}`;
@@ -154,7 +206,8 @@ function signClientId(canonical, secret) {
154
206
  return `${signingInput}.${sig}`;
155
207
  }
156
208
 
157
- // Verify a client_id against any accepted secret; returns { redirect_uris } or null.
209
+ // Verify a client_id against any accepted secret; returns
210
+ // { redirect_uris, client_name } or null.
158
211
  function verifyClientId(clientId, secrets) {
159
212
  const parts = String(clientId).split(".");
160
213
  if (parts.length !== 3 || parts[0] !== CLIENT_ID_PREFIX) return null;
@@ -172,8 +225,12 @@ function verifyClientId(clientId, secrets) {
172
225
  if (!ok) return null;
173
226
  try {
174
227
  const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
175
- const uris = JSON.parse(json);
176
- return Array.isArray(uris) ? { redirect_uris: uris } : null;
228
+ const parsed = JSON.parse(json);
229
+ if (Array.isArray(parsed)) return { redirect_uris: parsed, client_name: "" };
230
+ if (parsed && Array.isArray(parsed.redirect_uris)) {
231
+ return { redirect_uris: parsed.redirect_uris, client_name: cleanClientName(parsed.client_name) };
232
+ }
233
+ return null;
177
234
  } catch {
178
235
  return null;
179
236
  }
@@ -185,39 +242,96 @@ function corsOk(res) {
185
242
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-protocol-version");
186
243
  }
187
244
 
188
- // The §23-voice interstitial. Opens the CloudGrid sign-in in a new tab and polls
189
- // until it completes, then returns the browser to the client app.
190
- function interstitialHtml(sid, loginUrl) {
245
+ // HTML-escape for text and attribute positions.
246
+ function escapeHtml(value) {
247
+ return String(value)
248
+ .replace(/&/g, "&")
249
+ .replace(/</g, "&lt;")
250
+ .replace(/>/g, "&gt;")
251
+ .replace(/"/g, "&quot;")
252
+ .replace(/'/g, "&#39;");
253
+ }
254
+
255
+ // JSON for embedding inside an inline <script>: `<` and the JS line
256
+ // separators are escaped so the value cannot close the script element.
257
+ function scriptJson(value) {
258
+ return JSON.stringify(value)
259
+ .replace(/</g, "\\u003c")
260
+ .replace(/>/g, "\\u003e")
261
+ .replace(/&/g, "\\u0026")
262
+ .replace(/\u2028/g, "\\u2028")
263
+ .replace(/\u2029/g, "\\u2029");
264
+ }
265
+
266
+ // Every OAuth HTML response refuses to be framed, and does not leak its URL
267
+ // (which carries client_id and state) as a referrer.
268
+ function oauthPageHeaders(res) {
269
+ res.setHeader("X-Frame-Options", "DENY");
270
+ res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
271
+ res.setHeader("Referrer-Policy", "no-referrer");
272
+ }
273
+
274
+ function sendOAuthError(res, message) {
275
+ oauthPageHeaders(res);
276
+ res.status(400).type("text/plain; charset=utf-8").send(message);
277
+ }
278
+
279
+ // The §23-voice interstitial. First a consent step naming the client and the
280
+ // host the browser returns to; nothing starts until the user continues. Then it
281
+ // opens the CloudGrid sign-in in a new tab and polls until it completes, and
282
+ // returns the browser to the client app.
283
+ function interstitialHtml({ sid, loginUrl, clientName, redirectHost }) {
284
+ const who = clientName
285
+ ? `<strong>${escapeHtml(clientName)}</strong> (<strong>${escapeHtml(redirectHost)}</strong>)`
286
+ : `An application at <strong>${escapeHtml(redirectHost)}</strong>`;
191
287
  return `<!doctype html>
192
288
  <html lang="en">
193
289
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
290
+ <meta name="referrer" content="no-referrer">
194
291
  <title>Connect CloudGrid</title>
195
292
  <link rel="icon" type="image/png" href="/favicon.png">
196
293
  <style>
197
294
  body { margin:0; min-height:100vh; display:grid; place-items:center;
198
295
  font-family: Inter, system-ui, sans-serif; background:#0d0d0f; color:#fafafa; }
199
296
  main { max-width:420px; padding:2.5rem; text-align:center; }
297
+ p.host { margin-top:1rem; font-size:.95rem; }
200
298
  a.btn { display:inline-block; margin-top:1.25rem; padding:.75rem 1.5rem; border-radius:8px;
201
299
  background:#fafafa; color:#0d0d0f; text-decoration:none; font-weight:600; }
202
300
  p.status { margin-top:1.5rem; font-size:.9rem; opacity:.7; }
301
+ [hidden] { display:none !important; }
203
302
  </style></head>
204
303
  <body><main>
205
304
  <h1>Connect CloudGrid</h1>
206
- <p>Sign in with your CloudGrid account. This page returns to the app when you finish.</p>
207
- <a class="btn" href="${loginUrl}" target="_blank" rel="noopener">Sign in to CloudGrid</a>
208
- <p class="status" id="st">Waiting for sign-in.</p>
305
+ <p id="consent">${who} wants to connect to your CloudGrid account.</p>
306
+ <p class="host">After sign-in you will be returned to <strong>${escapeHtml(redirectHost)}</strong>. If you did not start this, close this page.</p>
307
+ <a class="btn" id="go" href="${escapeHtml(loginUrl)}" target="_blank" rel="noopener noreferrer">Continue to sign in</a>
308
+ <p class="status" id="st" hidden>Waiting for sign-in.</p>
209
309
  </main>
210
310
  <script>
311
+ const POLL_URL = ${scriptJson(`/oauth/authorize/poll?sid=${sid}`)};
312
+ const st = document.getElementById("st");
313
+ let started = false;
211
314
  async function tick() {
212
315
  try {
213
- const r = await fetch("/oauth/authorize/poll?sid=${sid}");
316
+ const r = await fetch(POLL_URL);
214
317
  const d = await r.json();
215
- if (d.status === "ready") { location.href = d.redirect; return; }
216
- if (d.status === "expired") { document.getElementById("st").textContent = "The sign-in window expired. Close this page and connect again."; return; }
318
+ if (d.status === "ready") {
319
+ let ok = false;
320
+ try { const u = new URL(d.redirect); ok = u.protocol === "https:" || u.protocol === "http:"; } catch {}
321
+ if (ok) { location.href = d.redirect; return; }
322
+ st.textContent = "This connection cannot be completed. Close this page and connect again.";
323
+ return;
324
+ }
325
+ if (d.status === "expired") { st.textContent = "The sign-in window expired. Close this page and connect again."; return; }
217
326
  } catch {}
218
327
  setTimeout(tick, 2000);
219
328
  }
220
- tick();
329
+ document.getElementById("go").addEventListener("click", () => {
330
+ st.hidden = false;
331
+ if (started) return;
332
+ started = true;
333
+ tick();
334
+ });
221
335
  </script></body></html>`;
222
336
  }
223
337
 
@@ -325,6 +439,13 @@ export function mountOAuth(app, publicBase, opts = {}) {
325
439
  });
326
440
  return;
327
441
  }
442
+ for (const uri of redirectUris) {
443
+ const problem = redirectUriProblem(uri);
444
+ if (problem) {
445
+ res.status(400).json({ error: "invalid_redirect_uri", error_description: problem });
446
+ return;
447
+ }
448
+ }
328
449
  const canonical = canonicalRedirectUris(redirectUris);
329
450
  if (Buffer.byteLength(canonical, "utf8") > MAX_REDIRECT_URIS_BYTES) {
330
451
  res.status(400).json({
@@ -333,9 +454,11 @@ export function mountOAuth(app, publicBase, opts = {}) {
333
454
  });
334
455
  return;
335
456
  }
336
- const clientId = signClientId(canonical, clientSecrets.secrets[0]);
457
+ const clientName = cleanClientName(req.body?.client_name);
458
+ const clientId = signClientId(signedPayload(canonical, clientName), clientSecrets.secrets[0]);
337
459
  res.status(201).json({
338
460
  client_id: clientId,
461
+ ...(clientName ? { client_name: clientName } : {}),
339
462
  token_endpoint_auth_method: "none",
340
463
  grant_types: ["authorization_code"],
341
464
  response_types: ["code"],
@@ -362,11 +485,19 @@ export function mountOAuth(app, publicBase, opts = {}) {
362
485
  );
363
486
  const client = verifyClientId(client_id, clientSecrets.secrets);
364
487
  if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
365
- res.status(400).send("Unknown client or redirect_uri. Re-add the connector and try again.");
488
+ sendOAuthError(res, "Unknown client or redirect_uri. Re-add the connector and try again.");
489
+ return;
490
+ }
491
+ // Re-checked here, not only at registration: a client_id signed before the
492
+ // redirect rules existed still carries its original set. Rendered, never
493
+ // redirected to.
494
+ if (redirectUriProblem(String(redirect_uri))) {
495
+ console.error(`[oauth] authorize refused: redirect_uri not allowed redirect_host=${hostOf(redirect_uri)}`);
496
+ sendOAuthError(res, "This redirect_uri is not allowed. Re-add the connector and try again.");
366
497
  return;
367
498
  }
368
499
  if (response_type !== "code" || code_challenge_method !== "S256" || !code_challenge) {
369
- res.status(400).send("This server requires response_type=code with PKCE (S256).");
500
+ sendOAuthError(res, "This server requires response_type=code with PKCE (S256).");
370
501
  return;
371
502
  }
372
503
  // RFC 8707 (#329): if a `resource` is present it must be THIS server's
@@ -413,8 +544,16 @@ export function mountOAuth(app, publicBase, opts = {}) {
413
544
  resource: boundResource,
414
545
  created: Date.now(),
415
546
  });
547
+ oauthPageHeaders(res);
416
548
  res.setHeader("Content-Type", "text/html; charset=utf-8");
417
- res.send(interstitialHtml(sid, buildLoginUrl(cgCode)));
549
+ res.send(
550
+ interstitialHtml({
551
+ sid,
552
+ loginUrl: buildLoginUrl(cgCode),
553
+ clientName: client.client_name,
554
+ redirectHost: new URL(String(redirect_uri)).host,
555
+ }),
556
+ );
418
557
  });
419
558
 
420
559
  // The interstitial's poll — bridges to CloudGrid /auth/status.
@@ -433,6 +572,13 @@ export function mountOAuth(app, publicBase, opts = {}) {
433
572
  return;
434
573
  }
435
574
  if (upstream.status === "authenticated" && upstream.jwt) {
575
+ if (!isHttpUrl(sess.redirect_uri)) {
576
+ // Unreachable while authorize enforces the redirect rules; kept so a
577
+ // code is never handed to a non-http(s) target.
578
+ authSessions.delete(String(req.query.sid));
579
+ res.json({ status: "expired" });
580
+ return;
581
+ }
436
582
  const code = b64url(Buffer.from(randomUUID()));
437
583
  authCodes.set(code, {
438
584
  jwt: upstream.jwt,