@ggui-ai/mcp-server 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oauth.d.ts CHANGED
@@ -282,13 +282,14 @@ export declare function handleProtectedResourceMetadata(req: Request, res: Respo
282
282
  * describing this auth server's capabilities + endpoints.
283
283
  */
284
284
  export declare function handleAuthorizationServerMetadata(req: Request, res: Response, config: OAuthConfig): void;
285
- /**
286
- * `POST /oauth/register` — RFC 7591 Dynamic Client Registration. Issues
287
- * a random `client_id`. No `client_secret` (PKCE-only). Accepts arbitrary
288
- * `redirect_uris` from the client without validation against an allowlist
289
- * — the trade-off matches the MCP spec's pragmatism: any client willing
290
- * to do PKCE + paste-key gets registered.
291
- */
285
+ type RedirectUriVerdict = {
286
+ readonly ok: readonly string[];
287
+ } | {
288
+ readonly error: 'invalid_redirect_uri' | 'invalid_client_metadata';
289
+ readonly description: string;
290
+ };
291
+ /** Every rule the door applies to `redirect_uris`; refuses the WHOLE list on the first bad entry. */
292
+ export declare function validateRedirectUris(raw: unknown): RedirectUriVerdict;
292
293
  export declare function handleRegister(req: Request, res: Response, config: OAuthConfig, storage: OAuthStorage): Promise<void>;
293
294
  /**
294
295
  * `GET /oauth/authorize` — render the paste-key form. Query params
@@ -345,4 +346,5 @@ export declare function handleAuthorizePost(req: Request, res: Response, config:
345
346
  * 400 status code.
346
347
  */
347
348
  export declare function handleToken(req: Request, res: Response, storage: OAuthStorage): Promise<void>;
349
+ export {};
348
350
  //# sourceMappingURL=oauth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/oauth.js CHANGED
@@ -60,7 +60,7 @@
60
60
  * once per install + caches the `client_id`. A server restart drops all
61
61
  * registrations; clients re-register transparently on next failure.
62
62
  */
63
- import { createHash, randomBytes } from 'node:crypto';
63
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
64
64
  import { isRecord } from '@ggui-ai/protocol';
65
65
  import { resolveIdentityFromHeaders, UnauthenticatedError } from './auth.js';
66
66
  export class InMemoryOAuthStorage {
@@ -181,8 +181,15 @@ function sha256Base64Url(input) {
181
181
  return createHash('sha256').update(input).digest('base64url');
182
182
  }
183
183
  function verifyPkce(verifier, challenge) {
184
- const computed = sha256Base64Url(verifier);
185
- return computed === challenge;
184
+ // Constant-time compare (ggui#1174). Both sides are base64url SHA-256
185
+ // digests when honest (43 chars); a length mismatch is refused before
186
+ // the compare because `timingSafeEqual` throws on unequal lengths, and
187
+ // the length of a digest is public anyway.
188
+ const computed = Buffer.from(sha256Base64Url(verifier));
189
+ const presented = Buffer.from(challenge);
190
+ if (computed.length !== presented.length)
191
+ return false;
192
+ return timingSafeEqual(computed, presented);
186
193
  }
187
194
  // =============================================================================
188
195
  // Route handlers
@@ -230,31 +237,119 @@ export function handleAuthorizationServerMetadata(req, res, config) {
230
237
  }
231
238
  /**
232
239
  * `POST /oauth/register` — RFC 7591 Dynamic Client Registration. Issues
233
- * a random `client_id`. No `client_secret` (PKCE-only). Accepts arbitrary
234
- * `redirect_uris` from the client without validation against an allowlist
235
- * — the trade-off matches the MCP spec's pragmatism: any client willing
236
- * to do PKCE + paste-key gets registered.
240
+ * a random `client_id`. No `client_secret` (PKCE-only). Registration is
241
+ * OPEN — there is no allowlist of redirect URIs, because the MCP
242
+ * authorization spec (2025-06-18) expects any host to register itself
243
+ * before its user has ever seen this server. What keeps an open door
244
+ * from being an open redirect (ggui#1174) is not the door but the
245
+ * controls around it, each of them pinned in `oauth-dcr.test.ts`:
246
+ *
247
+ * - `redirect_uris` are SHAPE-checked here ({@link validateRedirectUris}):
248
+ * absolute; no fragment; `https`, or `http` on a loopback host
249
+ * (RFC 8252 §7.3), or a private-use scheme (RFC 8252 §7.1 — the
250
+ * shape desktop MCP hosts register) that a browser cannot execute;
251
+ * no wildcard host; at most {@link MAX_REDIRECT_URIS}; one bad entry
252
+ * refuses the whole registration (`invalid_redirect_uri`).
253
+ * - `client_name` is bounded and printable (`invalid_client_metadata`)
254
+ * because the consent page prints it.
255
+ * - `/oauth/authorize` accepts only a redirect URI the client registered,
256
+ * by exact string match, and only PKCE `S256`.
257
+ * - `/oauth/token` binds the code to (client, redirect URI, challenge),
258
+ * single-use, 5-minute TTL, constant-time verifier check.
259
+ * - The consent page names the client and the redirect host BEFORE it
260
+ * asks for a key, so the user is the last control and an informed one.
237
261
  */
262
+ const MAX_REDIRECT_URIS = 10;
263
+ const MAX_REDIRECT_URI_LENGTH = 2048;
264
+ const MAX_CLIENT_NAME_LENGTH = 100;
265
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']);
266
+ /** Schemes a browser will EXECUTE rather than navigate to — never a redirect target. */
267
+ const EXECUTABLE_SCHEMES = new Set([
268
+ 'javascript:',
269
+ 'data:',
270
+ 'blob:',
271
+ 'file:',
272
+ 'about:',
273
+ 'vbscript:',
274
+ ]);
275
+ // eslint-disable-next-line no-control-regex -- the point is to find control characters
276
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
277
+ /** The one reason a URI is refused, or `undefined` when it is a redirect target. */
278
+ function redirectUriProblem(uri) {
279
+ if (uri.length > MAX_REDIRECT_URI_LENGTH)
280
+ return 'longer than 2048 characters';
281
+ if (uri.includes('#'))
282
+ return 'carries a fragment';
283
+ let url;
284
+ try {
285
+ url = new URL(uri);
286
+ }
287
+ catch {
288
+ return 'is not an absolute URL';
289
+ }
290
+ if (url.hostname.includes('*'))
291
+ return 'names a wildcard host';
292
+ if (url.protocol === 'https:')
293
+ return undefined;
294
+ if (url.protocol === 'http:') {
295
+ return LOOPBACK_HOSTS.has(url.hostname)
296
+ ? undefined
297
+ : 'uses `http` on a non-loopback host (only 127.0.0.1, [::1] and localhost may be plain http)';
298
+ }
299
+ if (EXECUTABLE_SCHEMES.has(url.protocol))
300
+ return `uses the \`${url.protocol}\` scheme`;
301
+ return undefined;
302
+ }
303
+ /** Every rule the door applies to `redirect_uris`; refuses the WHOLE list on the first bad entry. */
304
+ export function validateRedirectUris(raw) {
305
+ if (!Array.isArray(raw) || raw.length === 0) {
306
+ return { error: 'invalid_redirect_uri', description: '`redirect_uris` array is required' };
307
+ }
308
+ if (raw.length > MAX_REDIRECT_URIS) {
309
+ return {
310
+ error: 'invalid_client_metadata',
311
+ description: `\`redirect_uris\` carries ${raw.length} entries; at most ${MAX_REDIRECT_URIS} are accepted`,
312
+ };
313
+ }
314
+ const out = [];
315
+ for (const entry of raw) {
316
+ if (typeof entry !== 'string' || entry.length === 0) {
317
+ return { error: 'invalid_redirect_uri', description: 'every `redirect_uris` entry must be a non-empty string' };
318
+ }
319
+ const problem = redirectUriProblem(entry);
320
+ if (problem !== undefined) {
321
+ return { error: 'invalid_redirect_uri', description: `redirect URI ${problem}: ${entry.slice(0, 200)}` };
322
+ }
323
+ out.push(entry);
324
+ }
325
+ return { ok: out };
326
+ }
238
327
  export async function handleRegister(req, res, config, storage) {
239
328
  const body = isRecord(req.body) ? req.body : {};
240
- const redirectUrisRaw = body['redirect_uris'];
241
- if (!Array.isArray(redirectUrisRaw) || redirectUrisRaw.length === 0) {
329
+ const verdict = validateRedirectUris(body['redirect_uris']);
330
+ if ('error' in verdict) {
331
+ res.status(400).json({ error: verdict.error, error_description: verdict.description });
332
+ return;
333
+ }
334
+ const redirectUris = verdict.ok;
335
+ const clientNameRaw = body['client_name'];
336
+ if (clientNameRaw !== undefined && typeof clientNameRaw !== 'string') {
242
337
  res.status(400).json({
243
- error: 'invalid_redirect_uri',
244
- error_description: '`redirect_uris` array is required',
338
+ error: 'invalid_client_metadata',
339
+ error_description: '`client_name` must be a string when present',
245
340
  });
246
341
  return;
247
342
  }
248
- const redirectUris = redirectUrisRaw.filter((u) => typeof u === 'string' && u.length > 0);
249
- if (redirectUris.length === 0) {
343
+ if (typeof clientNameRaw === 'string' &&
344
+ (clientNameRaw.length > MAX_CLIENT_NAME_LENGTH || CONTROL_CHARS.test(clientNameRaw))) {
250
345
  res.status(400).json({
251
- error: 'invalid_redirect_uri',
252
- error_description: '`redirect_uris` must contain at least one non-empty string',
346
+ error: 'invalid_client_metadata',
347
+ error_description: `\`client_name\` must be at most ${MAX_CLIENT_NAME_LENGTH} printable characters`,
253
348
  });
254
349
  return;
255
350
  }
351
+ const clientName = clientNameRaw;
256
352
  const clientId = `mcp_client_${randomBytes(16).toString('base64url')}`;
257
- const clientName = typeof body['client_name'] === 'string' ? body['client_name'] : undefined;
258
353
  await storage.putClient({
259
354
  clientId,
260
355
  redirectUris,
@@ -305,12 +400,19 @@ export async function handleAuthorizeGet(req, res, config, storage) {
305
400
  target.searchParams.set(k, val);
306
401
  }
307
402
  target.searchParams.set('mcp_origin', resolveIssuerUrl(req, config.issuerUrl));
403
+ // ggui#1174 — the hosted consent page names the client too. `client_name`
404
+ // is DISPLAY-ONLY and self-asserted at DCR (a stranger chose it); the
405
+ // honest signal on any consent page is `redirect_uri`, which the server
406
+ // binds by exact match to what that client registered.
407
+ if (v.client.clientName !== undefined) {
408
+ target.searchParams.set('client_name', v.client.clientName);
409
+ }
308
410
  res.redirect(302, target.toString());
309
411
  return;
310
412
  }
311
413
  // Forward all params back to the form so POST handler has them.
312
414
  // Includes `code_challenge`, `state`, etc.
313
- res.type('html').send(renderAuthorizePage(params));
415
+ res.type('html').send(renderAuthorizePage(params, v.client));
314
416
  }
315
417
  /**
316
418
  * `POST /oauth/authorize` — handle paste-key form submission. Validates
@@ -357,7 +459,7 @@ export async function handleAuthorizePost(req, res, config, storage, auth, pairi
357
459
  res
358
460
  .status(401)
359
461
  .type('html')
360
- .send(renderAuthorizePage(params, 'Pair code expired or invalid — restart the server for a fresh code.'));
462
+ .send(renderAuthorizePage(params, validation.client, 'Pair code expired or invalid — restart the server for a fresh code.'));
361
463
  return;
362
464
  }
363
465
  }
@@ -365,7 +467,7 @@ export async function handleAuthorizePost(req, res, config, storage, auth, pairi
365
467
  apiKey = pastedKey;
366
468
  }
367
469
  else {
368
- res.status(400).type('html').send(renderAuthorizePage(params, 'Pair code or API key required'));
470
+ res.status(400).type('html').send(renderAuthorizePage(params, validation.client, 'Pair code or API key required'));
369
471
  return;
370
472
  }
371
473
  // Validate the key by running the adapter's auth path. Using the
@@ -382,7 +484,7 @@ export async function handleAuthorizePost(req, res, config, storage, auth, pairi
382
484
  res
383
485
  .status(401)
384
486
  .type('html')
385
- .send(renderAuthorizePage(params, 'Invalid API key — try again.'));
487
+ .send(renderAuthorizePage(params, validation.client, 'Invalid API key — try again.'));
386
488
  return;
387
489
  }
388
490
  throw err;
@@ -570,7 +672,7 @@ async function validateAuthorizeParams(params, storage, config, issuer) {
570
672
  }
571
673
  }
572
674
  }
573
- return { valid: true };
675
+ return { valid: true, client };
574
676
  }
575
677
  // =============================================================================
576
678
  // HTML rendering
@@ -667,7 +769,28 @@ ${bodyHtml}
667
769
  </body>
668
770
  </html>`;
669
771
  }
670
- function renderAuthorizePage(params, errorMessage) {
772
+ /** What the consent page prints as the redirect target: the host when the URI has one, else the scheme. */
773
+ function describeRedirectTarget(uri) {
774
+ if (!uri)
775
+ return 'an unknown destination';
776
+ try {
777
+ const url = new URL(uri);
778
+ return url.host.length > 0 ? url.host : `a \`${url.protocol}\` app`;
779
+ }
780
+ catch {
781
+ return 'an unknown destination';
782
+ }
783
+ }
784
+ function renderAuthorizePage(params, client, errorMessage) {
785
+ // ggui#1174 — the user is the last control on an OPEN registration
786
+ // door, so the page says WHO registered (the DCR client_name, which a
787
+ // stranger chose — escaped, never trusted) and WHERE the code will be
788
+ // sent, before it asks for a key.
789
+ const who = client.clientName
790
+ ? `<strong>${escapeHtml(client.clientName)}</strong> <span class="ggui-code">${escapeHtml(client.clientId)}</span>`
791
+ : `an unnamed client <span class="ggui-code">${escapeHtml(client.clientId)}</span>`;
792
+ const whereTo = escapeHtml(describeRedirectTarget(params['redirect_uri']));
793
+ const requester = `<p class="ggui-muted">${who} is asking to connect. If you authorize, a one-time code is sent to <strong>${whereTo}</strong>. If you do not recognise either, close this page.</p>`;
671
794
  // Forward every OAuth param as a hidden input so the POST handler
672
795
  // sees the same shape as /GET — no client-side state stash needed.
673
796
  const hiddenFields = [
@@ -691,6 +814,7 @@ function renderAuthorizePage(params, errorMessage) {
691
814
  : '';
692
815
  const body = `<div class="ggui-card"><form method="POST" action="/oauth/authorize" class="ggui-stack">
693
816
  ${errorCallout}
817
+ ${requester}
694
818
  <label for="pair_code" class="ggui-label">Pair code</label>
695
819
  <div class="ggui-field"><input type="text" id="pair_code" name="pair_code" placeholder="000000" inputmode="numeric" autocomplete="off" autofocus pattern="[0-9]{6}" maxlength="6"></div>
696
820
  <p class="ggui-muted">The 6-digit code printed on your terminal when you ran <span class="ggui-code">ggui serve</span>. One-shot — restart the server for a fresh code.</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/mcp-server",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Self-hosted MCP server for the ggui protocol. An HTTP/MCP binding layer over @ggui-ai/mcp-server-handlers and the reference adapters from @ggui-ai/mcp-server-core — run it via `ggui serve`.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -32,17 +32,17 @@
32
32
  "resend": "^6.12.2",
33
33
  "ws": "^8.20.1",
34
34
  "zod": "^4.3.6",
35
- "@ggui-ai/console": "0.18.0",
36
- "@ggui-ai/iframe-runtime": "0.18.0",
37
- "@ggui-ai/design": "0.18.0",
38
- "@ggui-ai/mcp-server-core": "0.18.0",
39
- "@ggui-ai/mcp-server-handlers": "0.18.0",
40
- "@ggui-ai/negotiator": "0.18.0",
41
- "@ggui-ai/preview-a2ui": "0.18.0",
42
- "@ggui-ai/project-config": "0.18.0",
43
- "@ggui-ai/protocol": "0.18.0",
44
- "@ggui-ai/ui-gen": "0.18.0",
45
- "@ggui-ai/ui-registry": "0.18.0"
35
+ "@ggui-ai/console": "0.20.0",
36
+ "@ggui-ai/design": "0.20.0",
37
+ "@ggui-ai/mcp-server-core": "0.20.0",
38
+ "@ggui-ai/iframe-runtime": "0.20.0",
39
+ "@ggui-ai/mcp-server-handlers": "0.20.0",
40
+ "@ggui-ai/negotiator": "0.20.0",
41
+ "@ggui-ai/project-config": "0.20.0",
42
+ "@ggui-ai/preview-a2ui": "0.20.0",
43
+ "@ggui-ai/ui-gen": "0.20.0",
44
+ "@ggui-ai/protocol": "0.20.0",
45
+ "@ggui-ai/ui-registry": "0.20.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/express": "^5.0.0",
@@ -51,7 +51,7 @@
51
51
  "@types/ws": "^8.5.10",
52
52
  "typescript": "^5.0.0",
53
53
  "vitest": "^3.2.6",
54
- "@ggui-ai/protocol-conformance": "0.18.0"
54
+ "@ggui-ai/protocol-conformance": "0.20.0"
55
55
  },
56
56
  "repository": {
57
57
  "type": "git",