@ggui-ai/mcp-server 0.1.0-rc.1

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 (141) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/admin-blueprints-transport.d.ts +114 -0
  4. package/dist/admin-blueprints-transport.d.ts.map +1 -0
  5. package/dist/admin-blueprints-transport.js +118 -0
  6. package/dist/admin-oauth-providers-transport.d.ts +40 -0
  7. package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
  8. package/dist/admin-oauth-providers-transport.js +263 -0
  9. package/dist/auth.d.ts +39 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +75 -0
  12. package/dist/build-mcp.d.ts +128 -0
  13. package/dist/build-mcp.d.ts.map +1 -0
  14. package/dist/build-mcp.js +113 -0
  15. package/dist/code-store-fs.d.ts +19 -0
  16. package/dist/code-store-fs.d.ts.map +1 -0
  17. package/dist/code-store-fs.js +98 -0
  18. package/dist/console-auth.d.ts +139 -0
  19. package/dist/console-auth.d.ts.map +1 -0
  20. package/dist/console-auth.js +102 -0
  21. package/dist/console-cache.d.ts +78 -0
  22. package/dist/console-cache.d.ts.map +1 -0
  23. package/dist/console-cache.js +105 -0
  24. package/dist/console-headers.d.ts +124 -0
  25. package/dist/console-headers.d.ts.map +1 -0
  26. package/dist/console-headers.js +49 -0
  27. package/dist/console-llm-trace.d.ts +66 -0
  28. package/dist/console-llm-trace.d.ts.map +1 -0
  29. package/dist/console-llm-trace.js +105 -0
  30. package/dist/console-payloads.d.ts +67 -0
  31. package/dist/console-payloads.d.ts.map +1 -0
  32. package/dist/console-payloads.js +105 -0
  33. package/dist/console-theme-routes.d.ts +111 -0
  34. package/dist/console-theme-routes.d.ts.map +1 -0
  35. package/dist/console-theme-routes.js +202 -0
  36. package/dist/console-timeline.d.ts +45 -0
  37. package/dist/console-timeline.d.ts.map +1 -0
  38. package/dist/console-timeline.js +169 -0
  39. package/dist/console-validator.d.ts +67 -0
  40. package/dist/console-validator.d.ts.map +1 -0
  41. package/dist/console-validator.js +105 -0
  42. package/dist/console-welcome.d.ts +7 -0
  43. package/dist/console-welcome.d.ts.map +1 -0
  44. package/dist/console-welcome.js +221 -0
  45. package/dist/csrf-middleware.d.ts +55 -0
  46. package/dist/csrf-middleware.d.ts.map +1 -0
  47. package/dist/csrf-middleware.js +138 -0
  48. package/dist/email-login.d.ts +174 -0
  49. package/dist/email-login.d.ts.map +1 -0
  50. package/dist/email-login.js +254 -0
  51. package/dist/email-resend.d.ts +29 -0
  52. package/dist/email-resend.d.ts.map +1 -0
  53. package/dist/email-resend.js +71 -0
  54. package/dist/email-sender-from-env.d.ts +34 -0
  55. package/dist/email-sender-from-env.d.ts.map +1 -0
  56. package/dist/email-sender-from-env.js +112 -0
  57. package/dist/email-smtp.d.ts +42 -0
  58. package/dist/email-smtp.d.ts.map +1 -0
  59. package/dist/email-smtp.js +81 -0
  60. package/dist/index.d.ts +102 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +122 -0
  63. package/dist/instructions-presets.d.ts +112 -0
  64. package/dist/instructions-presets.d.ts.map +1 -0
  65. package/dist/instructions-presets.js +195 -0
  66. package/dist/llm-backed-negotiator.d.ts +178 -0
  67. package/dist/llm-backed-negotiator.d.ts.map +1 -0
  68. package/dist/llm-backed-negotiator.js +579 -0
  69. package/dist/logger.d.ts +23 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +41 -0
  72. package/dist/mcp-apps-inbound.d.ts +86 -0
  73. package/dist/mcp-apps-inbound.d.ts.map +1 -0
  74. package/dist/mcp-apps-inbound.js +278 -0
  75. package/dist/mcp-apps-outbound.d.ts +448 -0
  76. package/dist/mcp-apps-outbound.d.ts.map +1 -0
  77. package/dist/mcp-apps-outbound.js +1163 -0
  78. package/dist/mcp-mounts.d.ts +239 -0
  79. package/dist/mcp-mounts.d.ts.map +1 -0
  80. package/dist/mcp-mounts.js +222 -0
  81. package/dist/oauth-login-types.d.ts +160 -0
  82. package/dist/oauth-login-types.d.ts.map +1 -0
  83. package/dist/oauth-login-types.js +9 -0
  84. package/dist/oauth-login.d.ts +77 -0
  85. package/dist/oauth-login.d.ts.map +1 -0
  86. package/dist/oauth-login.js +455 -0
  87. package/dist/oauth-providers/github.d.ts +17 -0
  88. package/dist/oauth-providers/github.d.ts.map +1 -0
  89. package/dist/oauth-providers/github.js +89 -0
  90. package/dist/oauth-providers/google.d.ts +18 -0
  91. package/dist/oauth-providers/google.d.ts.map +1 -0
  92. package/dist/oauth-providers/google.js +59 -0
  93. package/dist/oauth-providers-store.d.ts +32 -0
  94. package/dist/oauth-providers-store.d.ts.map +1 -0
  95. package/dist/oauth-providers-store.js +291 -0
  96. package/dist/oauth.d.ts +347 -0
  97. package/dist/oauth.d.ts.map +1 -0
  98. package/dist/oauth.js +686 -0
  99. package/dist/pairing-transport.d.ts +99 -0
  100. package/dist/pairing-transport.d.ts.map +1 -0
  101. package/dist/pairing-transport.js +223 -0
  102. package/dist/rate-limit-middleware.d.ts +36 -0
  103. package/dist/rate-limit-middleware.d.ts.map +1 -0
  104. package/dist/rate-limit-middleware.js +57 -0
  105. package/dist/render-gate.d.ts +87 -0
  106. package/dist/render-gate.d.ts.map +1 -0
  107. package/dist/render-gate.js +77 -0
  108. package/dist/render-rate-limit.d.ts +59 -0
  109. package/dist/render-rate-limit.d.ts.map +1 -0
  110. package/dist/render-rate-limit.js +73 -0
  111. package/dist/render-signing.d.ts +98 -0
  112. package/dist/render-signing.d.ts.map +1 -0
  113. package/dist/render-signing.js +113 -0
  114. package/dist/request-context.d.ts +113 -0
  115. package/dist/request-context.d.ts.map +1 -0
  116. package/dist/request-context.js +154 -0
  117. package/dist/reserved-validators.d.ts +22 -0
  118. package/dist/reserved-validators.d.ts.map +1 -0
  119. package/dist/reserved-validators.js +101 -0
  120. package/dist/schema-compat.d.ts +167 -0
  121. package/dist/schema-compat.d.ts.map +1 -0
  122. package/dist/schema-compat.js +187 -0
  123. package/dist/security-headers-middleware.d.ts +38 -0
  124. package/dist/security-headers-middleware.d.ts.map +1 -0
  125. package/dist/security-headers-middleware.js +30 -0
  126. package/dist/server.d.ts +2060 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +6338 -0
  129. package/dist/session-channel.d.ts +651 -0
  130. package/dist/session-channel.d.ts.map +1 -0
  131. package/dist/session-channel.js +1756 -0
  132. package/dist/storage.d.ts +89 -0
  133. package/dist/storage.d.ts.map +1 -0
  134. package/dist/storage.js +171 -0
  135. package/dist/thread-transport.d.ts +118 -0
  136. package/dist/thread-transport.d.ts.map +1 -0
  137. package/dist/thread-transport.js +478 -0
  138. package/dist/user-session-auth.d.ts +167 -0
  139. package/dist/user-session-auth.d.ts.map +1 -0
  140. package/dist/user-session-auth.js +148 -0
  141. package/package.json +76 -0
package/dist/oauth.js ADDED
@@ -0,0 +1,686 @@
1
+ /**
2
+ * OAuth 2.1 + PKCE + Dynamic Client Registration for the MCP server.
3
+ *
4
+ * Implements the auth flow MCP clients (Claude Desktop, claude.ai,
5
+ * Goose, etc.) expect from a remote MCP server per spec 2025-06-18+.
6
+ *
7
+ * ## Flow
8
+ *
9
+ * 1. Client hits `/mcp` without auth → 401 with
10
+ * `WWW-Authenticate: Bearer resource_metadata="<url>"`.
11
+ * 2. Client fetches the resource metadata
12
+ * (`/.well-known/oauth-protected-resource`, RFC 9728) → discovers
13
+ * the authorization server URL.
14
+ * 3. Client fetches the auth server metadata
15
+ * (`/.well-known/oauth-authorization-server`, RFC 8414) →
16
+ * discovers `authorize` / `token` / `register` endpoints.
17
+ * 4. Client POSTs to `/oauth/register` (RFC 7591 Dynamic Client
18
+ * Registration) → server returns a random `client_id`. PKCE-only,
19
+ * so no `client_secret` is issued.
20
+ * 5. Client redirects user-agent to `/oauth/authorize?...&code_challenge=...`
21
+ * → server renders an HTML page asking the user to paste their
22
+ * `ggui_user_*` API key.
23
+ * 6. User pastes key + submits → server validates the key against the
24
+ * configured {@link AuthAdapter} (same adapter that gates `/mcp`),
25
+ * mints an authorization code, redirects back to the client's
26
+ * `redirect_uri` with `?code=...`.
27
+ * 7. Client POSTs to `/oauth/token` with `code` + `code_verifier` →
28
+ * server validates PKCE, returns the user's `ggui_user_*` key as
29
+ * the `access_token`.
30
+ * 8. Client retries `/mcp` with `Authorization: Bearer ggui_user_*`
31
+ * → existing `ApiKeyAuthAdapter` accepts it. ✅
32
+ *
33
+ * ## Why the access token IS the API key
34
+ *
35
+ * The simplest possible bridge between MCP's OAuth-required client UX
36
+ * and ggui's existing API-key auth model. No parallel token table, no
37
+ * key→token translation in the request hot path, no token-refresh
38
+ * dance. The "OAuth flow" becomes a one-time ceremony Claude Desktop
39
+ * runs to capture the user's already-minted key into its own credential
40
+ * storage. After that, every `/mcp` request is identical to a CLI call
41
+ * with `Authorization: Bearer ggui_user_*`.
42
+ *
43
+ * Trade-off: the access token TTL = the API key TTL. If the user
44
+ * revokes the key, Claude Desktop's stored token stops working at the
45
+ * next request (intended: revocation works without OAuth-specific
46
+ * machinery). No refresh token issued — re-auth means re-running the
47
+ * OAuth ceremony, which is a one-paste step.
48
+ *
49
+ * ## Storage
50
+ *
51
+ * Auth codes + DCR clients live in-memory ({@link InMemoryOAuthStorage}).
52
+ * For multi-replica deployments (e.g. `mcp.ggui.ai` with 2+ pods),
53
+ * either:
54
+ * - Use nginx-ingress sticky sessions so the same pod handles both
55
+ * `/oauth/authorize` and `/oauth/token` (current sandbox posture).
56
+ * - Plug a Redis-backed {@link OAuthStorage} via the
57
+ * `oauth.storage` config option (production posture).
58
+ *
59
+ * DCR clients are short-lived in practice — Claude Desktop registers
60
+ * once per install + caches the `client_id`. A pod restart drops all
61
+ * registrations; clients re-register transparently on next failure.
62
+ */
63
+ import { createHash, randomBytes } from 'node:crypto';
64
+ import { resolveIdentity, UnauthenticatedError } from './auth.js';
65
+ export class InMemoryOAuthStorage {
66
+ codes = new Map();
67
+ clients = new Map();
68
+ async putAuthCode(record) {
69
+ this.codes.set(record.code, record);
70
+ // Lazy GC — every put walks 1% of the map and prunes expired entries.
71
+ // Keeps the map bounded without a separate sweeper.
72
+ if (Math.random() < 0.01) {
73
+ const now = Date.now();
74
+ for (const [k, v] of this.codes) {
75
+ if (v.expiresAt < now)
76
+ this.codes.delete(k);
77
+ }
78
+ }
79
+ }
80
+ async consumeAuthCode(code) {
81
+ const record = this.codes.get(code);
82
+ if (!record)
83
+ return null;
84
+ this.codes.delete(code);
85
+ if (record.expiresAt < Date.now())
86
+ return null;
87
+ return record;
88
+ }
89
+ async putClient(record) {
90
+ this.clients.set(record.clientId, record);
91
+ }
92
+ async getClient(clientId) {
93
+ return this.clients.get(clientId) ?? null;
94
+ }
95
+ async listClients() {
96
+ // Stable oldest-first ordering — operators see "what registered
97
+ // when" in chronological order, which matches how they'd reason
98
+ // about which client is which (the most recently added is at the
99
+ // bottom, easy to spot after a fresh connector add).
100
+ return Array.from(this.clients.values()).sort((a, b) => a.createdAt - b.createdAt);
101
+ }
102
+ async deleteClient(clientId) {
103
+ // Idempotent — Map.delete returns false if the key wasn't there;
104
+ // we discard that signal so callers can DELETE freely without a
105
+ // pre-check. Matches REST conventions.
106
+ this.clients.delete(clientId);
107
+ }
108
+ }
109
+ // =============================================================================
110
+ // URL + metadata helpers
111
+ // =============================================================================
112
+ /**
113
+ * Resolve the public origin for OAuth metadata. Prefer the configured
114
+ * {@link OAuthConfig.issuerUrl} (operator-controlled, deterministic);
115
+ * fall back to deriving from the request's forwarded headers (works
116
+ * behind nginx-ingress + ALB which both set X-Forwarded-Proto / -Host).
117
+ */
118
+ export function resolveIssuerUrl(req, configured) {
119
+ if (configured)
120
+ return configured.replace(/\/$/, '');
121
+ const proto = req.headers['x-forwarded-proto'] ?? req.protocol;
122
+ const host = req.headers['x-forwarded-host'] ?? req.headers.host;
123
+ return `${proto}://${host}`;
124
+ }
125
+ /**
126
+ * Build the `WWW-Authenticate` header value pointing at the resource-
127
+ * metadata document. Per RFC 9728 §5, MCP-aware clients fetch this URL
128
+ * to discover the authorization server.
129
+ *
130
+ * `resourcePath` (default `''`) is the path prefix the per-app metadata
131
+ * lives under — e.g. `/apps/aB3kP9xY` → header points at
132
+ * `${issuer}/apps/aB3kP9xY/.well-known/oauth-protected-resource` so a
133
+ * client that 401'd on a per-app endpoint discovers the per-app
134
+ * metadata document. Empty string preserves the
135
+ * universal-only behavior — `${issuer}/.well-known/...` — used by the
136
+ * universal `/mcp` route, the auth-check probe, and OSS deployments
137
+ * without per-app routing.
138
+ *
139
+ * Caller must supply a leading slash (or empty string). Trailing slash
140
+ * is normalized off so callers can pass either `/apps/x` or
141
+ * `/apps/x/` interchangeably.
142
+ */
143
+ export function buildWwwAuthenticate(issuerUrl, resourcePath = '') {
144
+ const normalized = resourcePath.replace(/\/$/, '');
145
+ const resourceMetadataUrl = `${issuerUrl}${normalized}/.well-known/oauth-protected-resource`;
146
+ return `Bearer realm="mcp", resource_metadata="${resourceMetadataUrl}"`;
147
+ }
148
+ // =============================================================================
149
+ // PKCE
150
+ // =============================================================================
151
+ function sha256Base64Url(input) {
152
+ return createHash('sha256').update(input).digest('base64url');
153
+ }
154
+ function verifyPkce(verifier, challenge) {
155
+ const computed = sha256Base64Url(verifier);
156
+ return computed === challenge;
157
+ }
158
+ // =============================================================================
159
+ // Route handlers
160
+ // =============================================================================
161
+ /**
162
+ * `GET /.well-known/oauth-protected-resource` — RFC 9728 metadata that
163
+ * tells the client where to find the authorization server. Same origin
164
+ * in our case (we host both the resource AND the auth server).
165
+ *
166
+ * `mcpPath` (default `/mcp`) is the deployment's universal MCP route.
167
+ * Cloud `mcp.ggui.ai` mounts at the bare root `/` so URLs are short
168
+ * (the domain already says "mcp"); OSS keeps the conventional `/mcp`.
169
+ * The trailing slash is normalized off when the path is `/` so the
170
+ * resource URL is `${issuer}` (no trailing slash) rather than
171
+ * `${issuer}/`.
172
+ */
173
+ export function handleProtectedResourceMetadata(req, res, config, mcpPath = '/mcp') {
174
+ const issuer = resolveIssuerUrl(req, config.issuerUrl);
175
+ const resource = mcpPath === '/' ? issuer : `${issuer}${mcpPath}`;
176
+ res.json({
177
+ resource,
178
+ authorization_servers: [issuer],
179
+ bearer_methods_supported: ['header'],
180
+ resource_documentation: 'https://modelcontextprotocol.io/extensions/apps/overview',
181
+ });
182
+ }
183
+ /**
184
+ * `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata
185
+ * describing this auth server's capabilities + endpoints.
186
+ */
187
+ export function handleAuthorizationServerMetadata(req, res, config) {
188
+ const issuer = resolveIssuerUrl(req, config.issuerUrl);
189
+ res.json({
190
+ issuer,
191
+ authorization_endpoint: `${issuer}/oauth/authorize`,
192
+ token_endpoint: `${issuer}/oauth/token`,
193
+ registration_endpoint: `${issuer}/oauth/register`,
194
+ response_types_supported: ['code'],
195
+ grant_types_supported: ['authorization_code'],
196
+ code_challenge_methods_supported: ['S256'],
197
+ token_endpoint_auth_methods_supported: ['none'], // PKCE-only, no client_secret
198
+ scopes_supported: ['mcp'],
199
+ });
200
+ }
201
+ /**
202
+ * `POST /oauth/register` — RFC 7591 Dynamic Client Registration. Issues
203
+ * a random `client_id`. No `client_secret` (PKCE-only). Accepts arbitrary
204
+ * `redirect_uris` from the client without validation against an allowlist
205
+ * — the trade-off matches the MCP spec's pragmatism: any client willing
206
+ * to do PKCE + paste-key gets registered.
207
+ */
208
+ export async function handleRegister(req, res, config, storage) {
209
+ const body = (req.body ?? {});
210
+ const redirectUrisRaw = body.redirect_uris;
211
+ if (!Array.isArray(redirectUrisRaw) || redirectUrisRaw.length === 0) {
212
+ res.status(400).json({
213
+ error: 'invalid_redirect_uri',
214
+ error_description: '`redirect_uris` array is required',
215
+ });
216
+ return;
217
+ }
218
+ const redirectUris = redirectUrisRaw.filter((u) => typeof u === 'string' && u.length > 0);
219
+ if (redirectUris.length === 0) {
220
+ res.status(400).json({
221
+ error: 'invalid_redirect_uri',
222
+ error_description: '`redirect_uris` must contain at least one non-empty string',
223
+ });
224
+ return;
225
+ }
226
+ const clientId = `mcp_client_${randomBytes(16).toString('base64url')}`;
227
+ const clientName = typeof body.client_name === 'string' ? body.client_name : undefined;
228
+ await storage.putClient({
229
+ clientId,
230
+ redirectUris,
231
+ ...(clientName !== undefined ? { clientName } : {}),
232
+ createdAt: Date.now(),
233
+ });
234
+ res.status(201).json({
235
+ client_id: clientId,
236
+ redirect_uris: redirectUris,
237
+ grant_types: ['authorization_code'],
238
+ response_types: ['code'],
239
+ token_endpoint_auth_method: 'none',
240
+ ...(clientName !== undefined ? { client_name: clientName } : {}),
241
+ });
242
+ }
243
+ /**
244
+ * `GET /oauth/authorize` — render the paste-key form. Query params
245
+ * (per OAuth 2.1 + PKCE):
246
+ * - `client_id` — from DCR
247
+ * - `redirect_uri` — must match one registered at DCR time
248
+ * - `response_type=code`
249
+ * - `code_challenge` — base64url(SHA256(code_verifier))
250
+ * - `code_challenge_method=S256`
251
+ * - `state` — opaque, echoed back to client
252
+ * - `scope` — ignored (we don't gate by scope today)
253
+ *
254
+ * The page is intentionally minimal — server-rendered HTML, no JS
255
+ * required, no external CDN. The form POSTs back to `/oauth/authorize`
256
+ * with the user's pasted key.
257
+ */
258
+ export async function handleAuthorizeGet(req, res, config, storage) {
259
+ const params = req.query;
260
+ const issuer = resolveIssuerUrl(req, config.issuerUrl);
261
+ const v = await validateAuthorizeParams(params, storage, config, issuer);
262
+ if ('error' in v) {
263
+ res.status(400).type('html').send(renderErrorPage(v.error));
264
+ return;
265
+ }
266
+ // Delegate to external consent UI when configured. Forward every
267
+ // OAuth param verbatim — the consent UI doesn't need to know which
268
+ // params are which, just that it must echo them back on POST. We
269
+ // also forward `mcp_origin` so the consent UI knows where to POST
270
+ // back without needing per-environment build-time config.
271
+ if (config.consentUrl) {
272
+ const target = new URL(config.consentUrl);
273
+ for (const [k, val] of Object.entries(params)) {
274
+ if (typeof val === 'string')
275
+ target.searchParams.set(k, val);
276
+ }
277
+ target.searchParams.set('mcp_origin', resolveIssuerUrl(req, config.issuerUrl));
278
+ res.redirect(302, target.toString());
279
+ return;
280
+ }
281
+ // Forward all params back to the form so POST handler has them.
282
+ // Includes `code_challenge`, `state`, etc.
283
+ res.type('html').send(renderAuthorizePage(params));
284
+ }
285
+ /**
286
+ * `POST /oauth/authorize` — handle paste-key form submission. Validates
287
+ * the key against the configured {@link AuthAdapter} (same one that
288
+ * gates `/mcp`), mints an auth code, redirects to client's
289
+ * `redirect_uri` with `?code=...&state=...`.
290
+ *
291
+ * Validation flow:
292
+ * 1. Re-validate the OAuth params (defense — caller could skip /GET).
293
+ * 2. Resolve the pasted key through `auth.authenticate()` — accepts
294
+ * any key the adapter accepts. For ggui this means `ggui_user_*`
295
+ * keys validated against `GguiUserApiKey` table via the existing
296
+ * `lookupUser` binding.
297
+ * 3. On success: write {code, accessToken=key, codeChallenge,
298
+ * redirectUri, clientId} to storage. 5-minute TTL.
299
+ * 4. Redirect to `redirect_uri?code=<code>&state=<state>`.
300
+ * 5. On bad key: re-render the page with an error message (preserves
301
+ * OAuth params).
302
+ */
303
+ export async function handleAuthorizePost(req, res, config, storage, auth, pairingService) {
304
+ const params = (req.body ?? {});
305
+ const issuer = resolveIssuerUrl(req, config.issuerUrl);
306
+ const validation = await validateAuthorizeParams(params, storage, config, issuer);
307
+ if ('error' in validation) {
308
+ res.status(400).type('html').send(renderErrorPage(validation.error));
309
+ return;
310
+ }
311
+ // Two paths: operator either types the short pair code printed on the
312
+ // terminal banner, OR pastes a previously-paired bearer. Both resolve
313
+ // to the same per-server access_token used below — claude.ai never
314
+ // sees which path was used.
315
+ let apiKey;
316
+ const pairCode = params['pair_code']?.trim();
317
+ const pastedKey = params['api_key']?.trim();
318
+ if (pairCode && pairCode.length > 0 && pairingService) {
319
+ try {
320
+ const completion = await pairingService.completePairing({
321
+ code: pairCode,
322
+ deviceName: `OAuth: ${params['client_id'] ?? 'unknown-client'}`,
323
+ });
324
+ apiKey = completion.token;
325
+ }
326
+ catch {
327
+ res
328
+ .status(401)
329
+ .type('html')
330
+ .send(renderAuthorizePage(params, 'Pair code expired or invalid — restart the server for a fresh code.'));
331
+ return;
332
+ }
333
+ }
334
+ else if (pastedKey && pastedKey.length > 0) {
335
+ apiKey = pastedKey;
336
+ }
337
+ else {
338
+ res.status(400).type('html').send(renderAuthorizePage(params, 'Pair code or API key required'));
339
+ return;
340
+ }
341
+ // Validate the key by running the adapter's auth path. We synthesize a
342
+ // minimal Request shape — the adapter only reads `headers` for bearer
343
+ // extraction. Using the real `resolveIdentity()` keeps the validation
344
+ // semantics byte-identical to /mcp's auth gate.
345
+ const fakeReq = {
346
+ headers: { authorization: `Bearer ${apiKey}` },
347
+ };
348
+ try {
349
+ await resolveIdentity(auth, fakeReq);
350
+ }
351
+ catch (err) {
352
+ if (err instanceof UnauthenticatedError) {
353
+ res
354
+ .status(401)
355
+ .type('html')
356
+ .send(renderAuthorizePage(params, 'Invalid API key — try again.'));
357
+ return;
358
+ }
359
+ throw err;
360
+ }
361
+ // Mint auth code. 32 bytes random → base64url ≈ 43 chars.
362
+ const code = randomBytes(32).toString('base64url');
363
+ const FIVE_MINUTES_MS = 5 * 60 * 1000;
364
+ // RFC 8707 §2 — capture the resource indicator so /token can
365
+ // enforce the same target on exchange. Absence is permitted (the
366
+ // record's `resource` stays undefined; tokens issued under that
367
+ // code carry universal scoping).
368
+ const resource = params['resource'];
369
+ await storage.putAuthCode({
370
+ code,
371
+ accessToken: apiKey,
372
+ codeChallenge: params['code_challenge'],
373
+ redirectUri: params['redirect_uri'],
374
+ clientId: params['client_id'],
375
+ expiresAt: Date.now() + FIVE_MINUTES_MS,
376
+ ...(typeof resource === 'string' && resource.length > 0
377
+ ? { resource }
378
+ : {}),
379
+ });
380
+ // Snapshot the resource + authorize timestamp onto the client
381
+ // record (Q7 RESOLVED 2026-05-06). Lets the operator's Connected
382
+ // Apps console surface "Connected to: <App>" without the
383
+ // listClients caller having to join against the auth-code table
384
+ // (which is short-lived; codes have already been consumed by the
385
+ // time the operator opens the page). Best-effort — failure to
386
+ // snapshot doesn't fail the authorize flow; the client just
387
+ // shows "Universal" in the UI until the next authorize.
388
+ try {
389
+ const client = await storage.getClient(params['client_id']);
390
+ if (client) {
391
+ await storage.putClient({
392
+ ...client,
393
+ lastAuthorizeAt: Date.now(),
394
+ ...(typeof resource === 'string' && resource.length > 0
395
+ ? { lastResource: resource }
396
+ : {}),
397
+ });
398
+ }
399
+ }
400
+ catch {
401
+ // Swallow — auth flow stays green even if the client-record
402
+ // snapshot fails. The user gets their token; UI display is the
403
+ // only thing that lags.
404
+ }
405
+ // Build redirect URL — preserve the client's `state`.
406
+ const redirectUrl = new URL(params['redirect_uri']);
407
+ redirectUrl.searchParams.set('code', code);
408
+ if (params['state'])
409
+ redirectUrl.searchParams.set('state', params['state']);
410
+ res.redirect(302, redirectUrl.toString());
411
+ void config; // Reserved for future per-issuer overrides.
412
+ }
413
+ /**
414
+ * `POST /oauth/token` — exchange auth code for access token. RFC 6749
415
+ * §4.1.3 + RFC 7636 (PKCE).
416
+ *
417
+ * Request body (form-urlencoded or JSON):
418
+ * - `grant_type=authorization_code`
419
+ * - `code` — from the redirect
420
+ * - `redirect_uri` — must match what was passed at /authorize
421
+ * - `client_id` — from DCR
422
+ * - `code_verifier` — PKCE verifier (raw, server hashes + compares)
423
+ *
424
+ * Response:
425
+ * - `access_token` — the user's `ggui_user_*` key (stored from /authorize)
426
+ * - `token_type=Bearer`
427
+ * - `expires_in` — omitted; key TTL matches the underlying API key
428
+ *
429
+ * Errors per RFC 6749 §5.2 — JSON body with `{error, error_description}`,
430
+ * 400 status code.
431
+ */
432
+ export async function handleToken(req, res, storage) {
433
+ const body = (req.body ?? {});
434
+ if (body['grant_type'] !== 'authorization_code') {
435
+ res.status(400).json({
436
+ error: 'unsupported_grant_type',
437
+ error_description: 'only `authorization_code` is supported',
438
+ });
439
+ return;
440
+ }
441
+ const code = body['code'];
442
+ const redirectUri = body['redirect_uri'];
443
+ const clientId = body['client_id'];
444
+ const codeVerifier = body['code_verifier'];
445
+ if (!code || !redirectUri || !clientId || !codeVerifier) {
446
+ res.status(400).json({
447
+ error: 'invalid_request',
448
+ error_description: '`code`, `redirect_uri`, `client_id`, `code_verifier` all required',
449
+ });
450
+ return;
451
+ }
452
+ const record = await storage.consumeAuthCode(code);
453
+ if (!record) {
454
+ res.status(400).json({
455
+ error: 'invalid_grant',
456
+ error_description: 'code expired or already consumed',
457
+ });
458
+ return;
459
+ }
460
+ if (record.redirectUri !== redirectUri) {
461
+ res.status(400).json({
462
+ error: 'invalid_grant',
463
+ error_description: 'redirect_uri mismatch',
464
+ });
465
+ return;
466
+ }
467
+ if (record.clientId !== clientId) {
468
+ res.status(400).json({
469
+ error: 'invalid_grant',
470
+ error_description: 'client_id mismatch',
471
+ });
472
+ return;
473
+ }
474
+ if (!verifyPkce(codeVerifier, record.codeChallenge)) {
475
+ res.status(400).json({
476
+ error: 'invalid_grant',
477
+ error_description: 'PKCE verification failed',
478
+ });
479
+ return;
480
+ }
481
+ // RFC 8707 §2.2 — when the original /authorize request included a
482
+ // resource indicator, the token request MAY include it; if the
483
+ // client sends one, it MUST match the resource captured on the
484
+ // auth code. Mismatch → `invalid_target` (RFC 8707 §2). Absence on
485
+ // the request is tolerated even when the code has a resource —
486
+ // RFC 8707 only mandates the constraint when the client opts in.
487
+ const tokenResource = body['resource'];
488
+ if (typeof tokenResource === 'string' &&
489
+ tokenResource.length > 0 &&
490
+ record.resource !== undefined &&
491
+ record.resource !== tokenResource) {
492
+ res.status(400).json({
493
+ error: 'invalid_target',
494
+ error_description: '`resource` does not match the authorization request',
495
+ });
496
+ return;
497
+ }
498
+ res.json({
499
+ access_token: record.accessToken,
500
+ token_type: 'Bearer',
501
+ scope: 'mcp',
502
+ });
503
+ }
504
+ // =============================================================================
505
+ // Validation
506
+ // =============================================================================
507
+ async function validateAuthorizeParams(params, storage, config, issuer) {
508
+ if (params['response_type'] !== 'code') {
509
+ return { error: 'response_type must be `code`' };
510
+ }
511
+ const clientId = params['client_id'];
512
+ if (!clientId)
513
+ return { error: 'client_id required' };
514
+ const client = await storage.getClient(clientId);
515
+ if (!client)
516
+ return { error: 'unknown client_id (re-register via DCR)' };
517
+ const redirectUri = params['redirect_uri'];
518
+ if (!redirectUri)
519
+ return { error: 'redirect_uri required' };
520
+ if (!client.redirectUris.includes(redirectUri)) {
521
+ return { error: 'redirect_uri not registered for this client' };
522
+ }
523
+ if (!params['code_challenge'])
524
+ return { error: 'code_challenge required (PKCE)' };
525
+ if (params['code_challenge_method'] !== 'S256') {
526
+ return { error: 'code_challenge_method must be `S256`' };
527
+ }
528
+ // RFC 8707 §2 resource-indicator validation.
529
+ // Optional param — absence means universal scoping. When present
530
+ // and a `validateResource` callback is configured, run it. Reject
531
+ // with `invalid_target` (the canonical RFC 8707 error code) at
532
+ // /authorize time so the user sees the failure before consent
533
+ // rather than after PKCE exchange.
534
+ const resource = params['resource'];
535
+ if (typeof resource === 'string' && resource.length > 0) {
536
+ if (config?.validateResource && issuer) {
537
+ if (!config.validateResource(issuer, resource)) {
538
+ return {
539
+ error: 'invalid_target — `resource` does not name a known MCP endpoint on this server',
540
+ };
541
+ }
542
+ }
543
+ }
544
+ return { valid: true };
545
+ }
546
+ // =============================================================================
547
+ // HTML rendering
548
+ // =============================================================================
549
+ function escapeHtml(s) {
550
+ return s
551
+ .replace(/&/g, '&amp;')
552
+ .replace(/</g, '&lt;')
553
+ .replace(/>/g, '&gt;')
554
+ .replace(/"/g, '&quot;')
555
+ .replace(/'/g, '&#39;');
556
+ }
557
+ /**
558
+ * `renderShell` — page frame mirroring the console brand kit.
559
+ *
560
+ * Emits doctype + head + cherry-picked CSS + sticky nav (wordmark +
561
+ * "ggui mcp-server" brand chip) + a `SectionHead`-style header
562
+ * (`num / title / mute`) + the supplied body. Both
563
+ * `renderAuthorizePage` and `renderErrorPage` call into it so the
564
+ * shell + tokens stay identical across pages.
565
+ *
566
+ * The CSS is a hand-picked subset of `packages/console/src/index.css`
567
+ * (tokens, nav, section head, card, button, field, mute, code,
568
+ * callout) — the OAuth pages are server-rendered HTML and don't ship
569
+ * the console SPA bundle, so the rules must be inline. Kept under
570
+ * ~3 KB by dropping rules the OAuth flow never uses (hero, pane,
571
+ * stack, codebox, traffic dots, dark-mode overrides — the brand
572
+ * kit's Premise is paper-on-ink, no dark variant defined).
573
+ *
574
+ * The wordmark SVG is a copy of `packages/console/src/routes/Wordmark.tsx`
575
+ * (mirror variant) inlined into the markup. < 1 KB, no font dep.
576
+ */
577
+ function renderShell(sectionNum, sectionTitle, sectionMute, bodyHtml, pageTitle) {
578
+ return `<!doctype html>
579
+ <html lang="en">
580
+ <head>
581
+ <meta charset="utf-8">
582
+ <meta name="viewport" content="width=device-width,initial-scale=1">
583
+ <title>${escapeHtml(pageTitle)}</title>
584
+ <style>
585
+ :root{--ggui-ink:#292929;--ggui-ink-2:#3d3d3d;--ggui-ink-3:#5a5a5a;--ggui-ink-4:#8c8c93;--ggui-paper:#f4f3ed;--ggui-paper-2:#ebe9e1;--ggui-line-2:#d6d4cb;--ggui-signal:#d93822;--ggui-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;--ggui-mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;color-scheme:light}
586
+ *,*::before,*::after{box-sizing:border-box}
587
+ html,body{margin:0;padding:0;background:var(--ggui-paper);color:var(--ggui-ink);font-family:var(--ggui-sans)}
588
+ .ggui-shell{min-height:100vh;display:flex;flex-direction:column}
589
+ .ggui-nav{position:sticky;top:0;z-index:100;background:rgba(244,243,237,0.92);backdrop-filter:saturate(1.2) blur(8px);-webkit-backdrop-filter:saturate(1.2) blur(8px);border-bottom:1px solid var(--ggui-line-2)}
590
+ .ggui-nav__inner{display:flex;align-items:center;gap:16px;max-width:820px;margin:0 auto;padding:14px 32px}
591
+ .ggui-nav__brand{font-family:var(--ggui-mono);font-size:10px;letter-spacing:0.22em;text-transform:uppercase;padding:4px 8px;background:var(--ggui-ink);color:var(--ggui-paper);border-radius:2px;font-weight:500}
592
+ .ggui-main{flex:1;width:100%;max-width:820px;margin:0 auto;padding:0 32px}
593
+ .ggui-section{padding:56px 0}
594
+ .ggui-section__head{display:grid;grid-template-columns:100px 1fr;gap:24px;margin-bottom:32px;align-items:start}
595
+ .ggui-section__num{font-family:var(--ggui-mono);font-size:11px;letter-spacing:0.18em;color:var(--ggui-ink-4);text-transform:uppercase;padding-top:6px}
596
+ .ggui-section__title{margin:0;font-size:32px;font-weight:700;letter-spacing:-0.02em;line-height:1.05}
597
+ .ggui-mute{color:var(--ggui-ink-4);font-weight:500}
598
+ .ggui-card{border:1px solid var(--ggui-line-2);background:var(--ggui-paper);border-radius:2px;padding:24px}
599
+ .ggui-stack{display:flex;flex-direction:column;gap:14px}
600
+ .ggui-label{font-family:var(--ggui-mono);font-size:10px;letter-spacing:0.18em;text-transform:uppercase;color:var(--ggui-ink-4);font-weight:500}
601
+ .ggui-field{display:flex;align-items:stretch;border:1px solid var(--ggui-ink);background:var(--ggui-paper);border-radius:2px}
602
+ .ggui-field::before{content:'';width:4px;background:var(--ggui-ink);flex-shrink:0}
603
+ .ggui-field input{flex:1;border:0;background:transparent;padding:10px 12px;font-family:var(--ggui-mono);font-size:13px;outline:none;min-width:0;color:inherit}
604
+ .ggui-field input::placeholder{color:var(--ggui-ink-4)}
605
+ .ggui-field:focus-within{box-shadow:inset 0 0 0 1px var(--ggui-ink)}
606
+ .ggui-btn{display:inline-flex;align-items:center;gap:10px;padding:10px 18px;font-family:var(--ggui-mono);font-size:12px;letter-spacing:0.08em;text-transform:uppercase;background:var(--ggui-ink);color:var(--ggui-paper);border:1px solid var(--ggui-ink);border-radius:2px;cursor:pointer;font-weight:500;transition:background-color 0.15s ease}
607
+ .ggui-btn:hover{background:var(--ggui-ink-2);border-color:var(--ggui-ink-2)}
608
+ .ggui-btn__dot{width:6px;height:6px;background:currentColor;border-radius:50%;flex-shrink:0}
609
+ .ggui-code{font-family:var(--ggui-mono);font-size:0.9em;background:var(--ggui-paper-2);border:1px solid var(--ggui-line-2);padding:1px 6px;border-radius:2px}
610
+ .ggui-muted{margin:0;font-size:13px;line-height:1.55;color:var(--ggui-ink-3)}
611
+ .ggui-callout{display:flex;gap:10px;border:1px solid var(--ggui-signal);background:var(--ggui-paper);padding:10px 14px;border-radius:2px;font-size:13px;color:var(--ggui-ink-2)}
612
+ .ggui-callout::before{content:'';width:4px;background:var(--ggui-signal);flex-shrink:0;margin:-10px -10px -10px -14px}
613
+ .ggui-callout__label{font-family:var(--ggui-mono);font-size:10px;letter-spacing:0.18em;text-transform:uppercase;color:var(--ggui-signal);font-weight:500;margin-right:8px}
614
+ @media (max-width:620px){.ggui-section__head{grid-template-columns:1fr;gap:8px}}
615
+ </style>
616
+ </head>
617
+ <body>
618
+ <div class="ggui-shell">
619
+ <header class="ggui-nav"><div class="ggui-nav__inner">
620
+ <svg viewBox="0 0 224 50" width="84" height="19" aria-label="ggui">
621
+ <path d="M 0 0 H 50 V 25 H 25 V 50 H 0 Z" fill="#d9d9d9"/><rect x="33" y="33" width="17" height="17" fill="#292929"/>
622
+ <path d="M 58 0 H 108 V 25 H 83 V 50 H 58 Z" fill="#292929"/><rect x="91" y="33" width="17" height="17" fill="#d9d9d9"/>
623
+ <path d="M 141 50 C 154.807 50 166 38.8071 166 25 V 0 H 116 V 25 C 116 38.8071 127.193 50 141 50 Z" fill="#292929"/>
624
+ <rect x="174" y="0" width="50" height="50" fill="#d9d9d9"/>
625
+ </svg>
626
+ <span class="ggui-nav__brand" aria-label="community edition">community</span>
627
+ </div></header>
628
+ <main class="ggui-main"><section class="ggui-section">
629
+ <header class="ggui-section__head">
630
+ <div class="ggui-section__num">${escapeHtml(sectionNum)}</div>
631
+ <div>
632
+ <h2 class="ggui-section__title">${escapeHtml(sectionTitle)}<span class="ggui-mute"> ${escapeHtml(sectionMute)}</span></h2>
633
+ </div>
634
+ </header>
635
+ ${bodyHtml}
636
+ </section></main>
637
+ </div>
638
+ </body>
639
+ </html>`;
640
+ }
641
+ function renderAuthorizePage(params, errorMessage) {
642
+ // Forward every OAuth param as a hidden input so the POST handler
643
+ // sees the same shape as /GET — no client-side state stash needed.
644
+ const hiddenFields = [
645
+ 'client_id',
646
+ 'redirect_uri',
647
+ 'response_type',
648
+ 'code_challenge',
649
+ 'code_challenge_method',
650
+ 'state',
651
+ 'scope',
652
+ ]
653
+ .map((name) => {
654
+ const v = params[name];
655
+ return v
656
+ ? `<input type="hidden" name="${name}" value="${escapeHtml(v)}">`
657
+ : '';
658
+ })
659
+ .join('');
660
+ const errorCallout = errorMessage
661
+ ? `<div class="ggui-callout"><span class="ggui-callout__label">error</span><span>${escapeHtml(errorMessage)}</span></div>`
662
+ : '';
663
+ const body = `<div class="ggui-card"><form method="POST" action="/oauth/authorize" class="ggui-stack">
664
+ ${errorCallout}
665
+ <label for="pair_code" class="ggui-label">Pair code</label>
666
+ <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>
667
+ <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>
668
+ <details>
669
+ <summary class="ggui-muted" style="cursor:pointer;font-size:12px">Have an API key instead?</summary>
670
+ <div style="margin-top:10px">
671
+ <label for="api_key" class="ggui-label">API key</label>
672
+ <div class="ggui-field"><input type="password" id="api_key" name="api_key" placeholder="ggui_user_…" autocomplete="off"></div>
673
+ </div>
674
+ </details>
675
+ ${hiddenFields}
676
+ <div><button type="submit" class="ggui-btn"><span class="ggui-btn__dot"></span>Authorize</button></div>
677
+ </form></div>`;
678
+ return renderShell('01 / authorize', 'Authorize MCP connection.', 'An OAuth client is requesting access to this server.', body, 'Authorize MCP Connection');
679
+ }
680
+ function renderErrorPage(message) {
681
+ const body = `<div class="ggui-card"><div class="ggui-stack">
682
+ <div class="ggui-callout"><span class="ggui-callout__label">error</span><span>${escapeHtml(message)}</span></div>
683
+ <p class="ggui-muted">Close this tab and retry from your client. If the problem persists, the request was rejected before reaching the consent step.</p>
684
+ </div></div>`;
685
+ return renderShell('00 / error', 'OAuth error.', 'The request could not be processed.', body, 'OAuth Error');
686
+ }