@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.
- package/package.json +2 -1
- 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.
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
|
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
|
|
176
|
-
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
245
|
+
// HTML-escape for text and attribute positions.
|
|
246
|
+
function escapeHtml(value) {
|
|
247
|
+
return String(value)
|
|
248
|
+
.replace(/&/g, "&")
|
|
249
|
+
.replace(/</g, "<")
|
|
250
|
+
.replace(/>/g, ">")
|
|
251
|
+
.replace(/"/g, """)
|
|
252
|
+
.replace(/'/g, "'");
|
|
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
|
|
207
|
-
<
|
|
208
|
-
<
|
|
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(
|
|
316
|
+
const r = await fetch(POLL_URL);
|
|
214
317
|
const d = await r.json();
|
|
215
|
-
if (d.status === "ready") {
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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,
|