@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.
- package/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- 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, '&')
|
|
552
|
+
.replace(/</g, '<')
|
|
553
|
+
.replace(/>/g, '>')
|
|
554
|
+
.replace(/"/g, '"')
|
|
555
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|