@assetlab/mcp-server 2.3.0 → 2.5.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.js DELETED
@@ -1,700 +0,0 @@
1
- /**
2
- * OAuth 2.0 Authorization Server for AssetLab MCP.
3
- *
4
- * Implements the subset of OAuth 2.0 + PKCE + RFC 7591/7592 needed to satisfy
5
- * MCP connectors (Claude.ai, ChatGPT):
6
- * - /oauth/register POST — dynamic client registration with KV-backed allow-list
7
- * - /oauth/register/{id} DELETE — authenticated client deletion
8
- * - /authorize GET — consent page (validates client + redirect_uri)
9
- * - /authorize POST — issues an encrypted authorization code
10
- * - /token POST — code/refresh exchange (PKCE S256 mandatory)
11
- *
12
- * The user's AssetLab API key is still returned as the OAuth access_token
13
- * (P0 hardening keeps this; the opaque-token swap is tracked as P1).
14
- */
15
- // ── Helpers ──────────────────────────────────────────────────────────────────
16
- function base64url(buf) {
17
- const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
18
- let binary = '';
19
- for (const b of bytes)
20
- binary += String.fromCharCode(b);
21
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
- }
23
- function fromBase64url(s) {
24
- const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - (s.length % 4)) % 4);
25
- const binary = atob(padded);
26
- const bytes = new Uint8Array(binary.length);
27
- for (let i = 0; i < binary.length; i++)
28
- bytes[i] = binary.charCodeAt(i);
29
- return bytes;
30
- }
31
- async function deriveKey(secret) {
32
- const raw = new TextEncoder().encode(secret);
33
- const hash = await crypto.subtle.digest('SHA-256', raw);
34
- return crypto.subtle.importKey('raw', hash, 'AES-GCM', false, ['encrypt', 'decrypt']);
35
- }
36
- async function encrypt(payload, secret) {
37
- const key = await deriveKey(secret);
38
- const iv = crypto.getRandomValues(new Uint8Array(12));
39
- const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(payload));
40
- const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
41
- combined.set(iv);
42
- combined.set(new Uint8Array(ciphertext), iv.length);
43
- return base64url(combined);
44
- }
45
- async function decrypt(code, secret) {
46
- const data = fromBase64url(code);
47
- const iv = data.slice(0, 12);
48
- const ciphertext = data.slice(12);
49
- const key = await deriveKey(secret);
50
- const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
51
- return new TextDecoder().decode(decrypted);
52
- }
53
- async function sha256Hex(s) {
54
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
55
- return Array.from(new Uint8Array(buf))
56
- .map(b => b.toString(16).padStart(2, '0'))
57
- .join('');
58
- }
59
- function safeEqual(a, b) {
60
- if (a.length !== b.length)
61
- return false;
62
- let diff = 0;
63
- for (let i = 0; i < a.length; i++)
64
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
65
- return diff === 0;
66
- }
67
- // Opaque access tokens are prefixed so the Worker can distinguish them from
68
- // legacy API-key-as-Bearer requests (which start with al_live_ / al_test_).
69
- const ACCESS_TOKEN_PREFIX = 'mcp_at_';
70
- const ACCESS_TOKEN_TTL_SECONDS = 86_400; // 24h — matches the `expires_in` in /token response
71
- // ── KV helpers ───────────────────────────────────────────────────────────────
72
- const CLIENT_KEY = (id) => `oauth_client:${id}`;
73
- const TOKEN_KEY = (hash) => `oauth_token:${hash}`;
74
- const CONSUMED_CODE_KEY = (hash) => `consumed_code:${hash}`;
75
- async function getClient(kv, id) {
76
- if (!id)
77
- return null;
78
- const raw = await kv.get(CLIENT_KEY(id));
79
- if (!raw)
80
- return null;
81
- try {
82
- return JSON.parse(raw);
83
- }
84
- catch {
85
- return null;
86
- }
87
- }
88
- async function putClient(kv, client) {
89
- await kv.put(CLIENT_KEY(client.client_id), JSON.stringify(client));
90
- }
91
- async function deleteClient(kv, id) {
92
- await kv.delete(CLIENT_KEY(id));
93
- }
94
- async function mintAccessToken(kv, apiKey, scope, clientId) {
95
- const bytes = crypto.getRandomValues(new Uint8Array(32));
96
- const token = ACCESS_TOKEN_PREFIX + base64url(bytes);
97
- const hash = await sha256Hex(token);
98
- const record = {
99
- api_key: apiKey,
100
- scope,
101
- client_id: clientId,
102
- created_at: Date.now(),
103
- };
104
- await kv.put(TOKEN_KEY(hash), JSON.stringify(record), {
105
- expirationTtl: ACCESS_TOKEN_TTL_SECONDS,
106
- });
107
- return token;
108
- }
109
- /**
110
- * Resolve a Bearer token presented to the MCP transport. Called once per
111
- * incoming request from the Worker.
112
- *
113
- * - `mcp_at_*` tokens are looked up in KV. Miss → `null` (Worker returns 401).
114
- * - Legacy `al_live_*` / `al_test_*` tokens (issued before opaque-token swap
115
- * or used by direct CLI integrations) pass through unchanged so the API
116
- * gateway can validate them.
117
- * - Anything else is rejected (`null`) — the API gateway shouldn't see garbage.
118
- */
119
- export async function resolveAccessToken(kv, token) {
120
- if (!token)
121
- return null;
122
- if (token.startsWith(ACCESS_TOKEN_PREFIX)) {
123
- const hash = await sha256Hex(token);
124
- const raw = await kv.get(TOKEN_KEY(hash));
125
- if (!raw)
126
- return null;
127
- try {
128
- const rec = JSON.parse(raw);
129
- return { apiKey: rec.api_key, scope: rec.scope, clientId: rec.client_id };
130
- }
131
- catch {
132
- return null;
133
- }
134
- }
135
- if (token.startsWith('al_live_') || token.startsWith('al_test_')) {
136
- // Legacy passthrough — API gateway validates. Grace path for pre-swap sessions.
137
- return { apiKey: token };
138
- }
139
- return null;
140
- }
141
- async function deleteAccessToken(kv, token) {
142
- if (!token.startsWith(ACCESS_TOKEN_PREFIX))
143
- return;
144
- const hash = await sha256Hex(token);
145
- await kv.delete(TOKEN_KEY(hash));
146
- }
147
- // ── Redirect URI validation ──────────────────────────────────────────────────
148
- //
149
- // RFC 8252 §7.3: allow loopback http for native apps; everything else must be
150
- // https. Reject anything that could exfiltrate the auth code via a non-HTTP
151
- // transport (javascript:, data:, file:, ws://, etc.).
152
- function isValidRedirectUri(uri) {
153
- if (typeof uri !== 'string' || uri.length > 2000)
154
- return false;
155
- let parsed;
156
- try {
157
- parsed = new URL(uri);
158
- }
159
- catch {
160
- return false;
161
- }
162
- if (parsed.protocol === 'https:')
163
- return true;
164
- if (parsed.protocol === 'http:') {
165
- return (parsed.hostname === 'localhost' ||
166
- parsed.hostname === '127.0.0.1' ||
167
- parsed.hostname === '[::1]');
168
- }
169
- return false;
170
- }
171
- // ── CORS ─────────────────────────────────────────────────────────────────────
172
- const ALLOWED_ORIGINS = new Set([
173
- 'https://claude.ai',
174
- 'https://chat.openai.com',
175
- 'https://chatgpt.com',
176
- ]);
177
- export function corsHeaders(request) {
178
- const origin = request.headers.get('origin') ?? '';
179
- if (!ALLOWED_ORIGINS.has(origin))
180
- return {};
181
- return {
182
- 'Access-Control-Allow-Origin': origin,
183
- 'Access-Control-Allow-Credentials': 'true',
184
- Vary: 'Origin',
185
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
186
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, MCP-Protocol-Version, Accept',
187
- };
188
- }
189
- function json(body, status = 200, extra = {}, corsFor) {
190
- return new Response(JSON.stringify(body), {
191
- status,
192
- headers: {
193
- 'Content-Type': 'application/json',
194
- ...(corsFor ? corsHeaders(corsFor) : {}),
195
- ...extra,
196
- },
197
- });
198
- }
199
- // ── Discovery endpoints ─────────────────────────────────────────────────────
200
- export function protectedResourceMetadata(origin, request) {
201
- const resource = origin.endsWith('/') ? origin : `${origin}/`;
202
- return json({
203
- resource,
204
- authorization_servers: [origin],
205
- }, 200, {}, request);
206
- }
207
- export function authServerMetadata(origin, request) {
208
- return json({
209
- issuer: origin,
210
- authorization_endpoint: `${origin}/authorize`,
211
- token_endpoint: `${origin}/token`,
212
- registration_endpoint: `${origin}/oauth/register`,
213
- response_types_supported: ['code'],
214
- grant_types_supported: ['authorization_code', 'refresh_token'],
215
- scopes_supported: ['claudeai', 'mcp'],
216
- code_challenge_methods_supported: ['S256'],
217
- token_endpoint_auth_methods_supported: ['none'],
218
- }, 200, {}, request);
219
- }
220
- // ── Dynamic client registration (RFC 7591) ──────────────────────────────────
221
- export async function handleRegister(request, kv) {
222
- let body;
223
- try {
224
- body = (await request.json());
225
- }
226
- catch {
227
- return json({ error: 'invalid_client_metadata', error_description: 'Body must be valid JSON' }, 400);
228
- }
229
- const uris = body.redirect_uris;
230
- if (!Array.isArray(uris) || uris.length === 0) {
231
- return json({
232
- error: 'invalid_redirect_uri',
233
- error_description: 'redirect_uris must be a non-empty array',
234
- }, 400);
235
- }
236
- if (uris.length > 10) {
237
- return json({ error: 'invalid_redirect_uri', error_description: 'Too many redirect_uris (max 10)' }, 400);
238
- }
239
- for (const u of uris) {
240
- if (!isValidRedirectUri(u)) {
241
- return json({
242
- error: 'invalid_redirect_uri',
243
- error_description: 'All redirect_uris must use https:// (http:// is only allowed for localhost loopback)',
244
- }, 400);
245
- }
246
- }
247
- const name = typeof body.client_name === 'string' && body.client_name.trim().length > 0
248
- ? body.client_name.trim().slice(0, 200)
249
- : 'MCP Client';
250
- // Scope is stored as-is (≤200 chars). The /authorize and /token endpoints
251
- // intersect requested scope with the supported set at issue time.
252
- const rawScope = typeof body.scope === 'string' && body.scope.length <= 200 ? body.scope : 'mcp';
253
- const clientId = crypto.randomUUID();
254
- const regTokenBytes = crypto.getRandomValues(new Uint8Array(32));
255
- const regToken = base64url(regTokenBytes);
256
- const regTokenHash = await sha256Hex(regToken);
257
- const client = {
258
- client_id: clientId,
259
- client_name: name,
260
- redirect_uris: uris,
261
- scope: rawScope,
262
- registration_access_token_hash: regTokenHash,
263
- created_at: Date.now(),
264
- };
265
- await putClient(kv, client);
266
- const origin = new URL(request.url).origin;
267
- return json({
268
- client_id: clientId,
269
- client_name: name,
270
- redirect_uris: uris,
271
- grant_types: ['authorization_code', 'refresh_token'],
272
- response_types: ['code'],
273
- token_endpoint_auth_method: 'none',
274
- scope: rawScope,
275
- registration_access_token: regToken,
276
- registration_client_uri: `${origin}/oauth/register/${clientId}`,
277
- }, 201);
278
- }
279
- // ── Authenticated client deletion (RFC 7592) ────────────────────────────────
280
- export async function handleDelete(request, kv, clientId) {
281
- const auth = request.headers.get('Authorization') ?? '';
282
- if (!auth.startsWith('Bearer ')) {
283
- return json({ error: 'invalid_token', error_description: 'Registration access token required' }, 401, { 'WWW-Authenticate': 'Bearer' });
284
- }
285
- const token = auth.slice(7).trim();
286
- const client = await getClient(kv, clientId);
287
- // Return 401 (not 404) for unknown client_ids so we don't leak which IDs exist.
288
- if (!token || !client) {
289
- return json({ error: 'invalid_token', error_description: 'Invalid registration access token' }, 401);
290
- }
291
- const providedHash = await sha256Hex(token);
292
- if (!safeEqual(providedHash, client.registration_access_token_hash)) {
293
- return json({ error: 'invalid_token', error_description: 'Invalid registration access token' }, 401);
294
- }
295
- await deleteClient(kv, clientId);
296
- return new Response(null, { status: 204 });
297
- }
298
- // ── Authorization endpoint ──────────────────────────────────────────────────
299
- export async function handleAuthorizeGet(request, kv) {
300
- const url = new URL(request.url);
301
- const clientId = url.searchParams.get('client_id') ?? '';
302
- const redirectUri = url.searchParams.get('redirect_uri') ?? '';
303
- const responseType = url.searchParams.get('response_type') ?? 'code';
304
- const codeChallenge = url.searchParams.get('code_challenge') ?? '';
305
- const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? '';
306
- const scope = url.searchParams.get('scope') ?? '';
307
- const state = url.searchParams.get('state') ?? '';
308
- if (responseType !== 'code') {
309
- return errorPage(`Unsupported response_type "${responseType}". Only the authorization code flow is supported.`, 400);
310
- }
311
- if (!codeChallenge || codeChallengeMethod !== 'S256') {
312
- return errorPage('This server requires PKCE: code_challenge and code_challenge_method=S256 are mandatory.', 400);
313
- }
314
- if (!clientId) {
315
- return errorPage('Missing client_id.', 400);
316
- }
317
- const client = await getClient(kv, clientId);
318
- if (!client) {
319
- return errorPage('Unknown client_id. Re-register the application and try again.', 400);
320
- }
321
- if (!redirectUri || !client.redirect_uris.includes(redirectUri)) {
322
- return errorPage('redirect_uri does not match any URI registered for this client.', 400);
323
- }
324
- let redirectHost = '';
325
- try {
326
- redirectHost = new URL(redirectUri).host;
327
- }
328
- catch {
329
- redirectHost = '';
330
- }
331
- return renderConsentPage({
332
- clientName: client.client_name,
333
- redirectHost,
334
- state,
335
- redirectUri,
336
- codeChallenge,
337
- clientId,
338
- scope,
339
- });
340
- }
341
- export async function handleAuthorizePost(request, env) {
342
- const form = await request.formData();
343
- const apiKey = form.get('api_key')?.trim();
344
- const state = form.get('state') ?? '';
345
- const redirectUri = form.get('redirect_uri') ?? '';
346
- const codeChallenge = form.get('code_challenge') ?? '';
347
- const clientId = form.get('client_id') ?? '';
348
- const scope = form.get('scope') ?? '';
349
- if (!apiKey)
350
- return errorPage('API key is required.', 400);
351
- if (!clientId)
352
- return errorPage('Missing client_id.', 400);
353
- if (!codeChallenge)
354
- return errorPage('Missing PKCE code_challenge.', 400);
355
- // Re-validate against KV — guards against tampering with hidden form fields.
356
- const client = await getClient(env.OAUTH_CLIENTS, clientId);
357
- if (!client)
358
- return errorPage('Unknown client_id.', 400);
359
- if (!redirectUri || !client.redirect_uris.includes(redirectUri)) {
360
- return errorPage('redirect_uri does not match any URI registered for this client.', 400);
361
- }
362
- const payload = {
363
- apiKey,
364
- codeChallenge,
365
- redirectUri,
366
- scope,
367
- clientId,
368
- exp: Date.now() + 600_000, // 10 minutes
369
- };
370
- const code = await encrypt(JSON.stringify(payload), env.OAUTH_SECRET);
371
- const target = new URL(redirectUri);
372
- target.searchParams.set('code', code);
373
- if (state)
374
- target.searchParams.set('state', state);
375
- return Response.redirect(target.toString(), 302);
376
- }
377
- // ── Token endpoint ──────────────────────────────────────────────────────────
378
- async function issueTokens(apiKey, scope, clientId, env) {
379
- const effectiveScope = scope || 'mcp';
380
- const accessToken = await mintAccessToken(env.OAUTH_CLIENTS, apiKey, effectiveScope, clientId);
381
- const refreshPayload = {
382
- apiKey,
383
- scope: effectiveScope,
384
- clientId,
385
- type: 'refresh',
386
- };
387
- const refreshToken = await encrypt(JSON.stringify(refreshPayload), env.OAUTH_SECRET);
388
- return json({
389
- access_token: accessToken,
390
- token_type: 'Bearer',
391
- expires_in: ACCESS_TOKEN_TTL_SECONDS,
392
- scope: effectiveScope,
393
- refresh_token: refreshToken,
394
- });
395
- }
396
- export async function handleToken(request, env) {
397
- let params;
398
- const ct = request.headers.get('Content-Type') ?? '';
399
- if (ct.includes('application/json')) {
400
- const body = (await request.json());
401
- params = new URLSearchParams(body);
402
- }
403
- else {
404
- params = new URLSearchParams(await request.text());
405
- }
406
- const grantType = params.get('grant_type');
407
- const requestClientId = params.get('client_id') ?? '';
408
- // ── Refresh token grant ──────────────────────────────────────────────────
409
- if (grantType === 'refresh_token') {
410
- const refreshToken = params.get('refresh_token');
411
- if (!refreshToken) {
412
- return json({ error: 'invalid_request', error_description: 'Missing refresh_token' }, 400);
413
- }
414
- let payload;
415
- try {
416
- payload = JSON.parse(await decrypt(refreshToken, env.OAUTH_SECRET));
417
- }
418
- catch (err) {
419
- console.log('[token] refresh decrypt failed:', err instanceof Error ? err.message : err);
420
- return json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400);
421
- }
422
- if (payload.type !== 'refresh') {
423
- return json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400);
424
- }
425
- // Refresh tokens minted before this rewrite have no clientId — accept them
426
- // so existing Claude.ai / ChatGPT sessions survive the deploy. New tokens
427
- // always carry clientId and are validated against KV.
428
- if (payload.clientId) {
429
- const client = await getClient(env.OAUTH_CLIENTS, payload.clientId);
430
- if (!client) {
431
- return json({ error: 'invalid_grant', error_description: 'Client no longer registered' }, 400);
432
- }
433
- }
434
- return issueTokens(payload.apiKey, payload.scope, payload.clientId, env);
435
- }
436
- // ── Authorization code grant ─────────────────────────────────────────────
437
- if (grantType && grantType !== 'authorization_code') {
438
- return json({
439
- error: 'unsupported_grant_type',
440
- error_description: `Unsupported grant_type "${grantType}"`,
441
- }, 400);
442
- }
443
- const code = params.get('code');
444
- const codeVerifier = params.get('code_verifier');
445
- const redirectUri = params.get('redirect_uri') ?? '';
446
- if (!code)
447
- return json({ error: 'invalid_request', error_description: 'Missing code' }, 400);
448
- if (!codeVerifier) {
449
- return json({ error: 'invalid_request', error_description: 'Missing code_verifier (PKCE is required)' }, 400);
450
- }
451
- // F-017 — RFC 6749 §3.2.1: public clients MUST send client_id on /token.
452
- if (!requestClientId) {
453
- return json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400);
454
- }
455
- let payload;
456
- try {
457
- payload = JSON.parse(await decrypt(code, env.OAUTH_SECRET));
458
- }
459
- catch (err) {
460
- console.log('[token] decrypt failed:', err instanceof Error ? err.message : err);
461
- return json({ error: 'invalid_grant', error_description: 'Invalid or expired code' }, 400);
462
- }
463
- if (Date.now() > payload.exp) {
464
- return json({ error: 'invalid_grant', error_description: 'Code expired' }, 400);
465
- }
466
- if (redirectUri && payload.redirectUri !== redirectUri) {
467
- return json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' }, 400);
468
- }
469
- if (requestClientId !== payload.clientId) {
470
- return json({ error: 'invalid_client', error_description: 'client_id mismatch' }, 400);
471
- }
472
- // F-015 — RFC 6749 §4.1.2: authorization codes MUST be single-use. Hash of
473
- // the raw code (not the code itself) is stored so the KV value isn't a
474
- // credential. Sentinel TTL matches the code's remaining lifetime.
475
- const codeHash = await sha256Hex(code);
476
- if (await env.OAUTH_CLIENTS.get(CONSUMED_CODE_KEY(codeHash))) {
477
- return json({ error: 'invalid_grant', error_description: 'Authorization code already used' }, 400);
478
- }
479
- const client = await getClient(env.OAUTH_CLIENTS, payload.clientId);
480
- if (!client) {
481
- return json({ error: 'invalid_client', error_description: 'Unknown client' }, 401);
482
- }
483
- if (!client.redirect_uris.includes(payload.redirectUri)) {
484
- return json({ error: 'invalid_grant', error_description: 'Redirect URI no longer registered' }, 400);
485
- }
486
- // PKCE verification — mandatory.
487
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
488
- const expected = base64url(digest);
489
- if (!safeEqual(expected, payload.codeChallenge)) {
490
- return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400);
491
- }
492
- // Mark code consumed before issuing tokens. KV expirationTtl is in seconds
493
- // and clamped at 60s minimum by Cloudflare.
494
- const remainingTtl = Math.max(60, Math.ceil((payload.exp - Date.now()) / 1000));
495
- await env.OAUTH_CLIENTS.put(CONSUMED_CODE_KEY(codeHash), '1', {
496
- expirationTtl: remainingTtl,
497
- });
498
- return issueTokens(payload.apiKey, payload.scope, payload.clientId, env);
499
- }
500
- // ── Token revocation (RFC 7009) ─────────────────────────────────────────────
501
- /**
502
- * POST /oauth/revoke — revoke an opaque access token.
503
- *
504
- * Refresh tokens are encrypted blobs (not KV-backed), so per-token refresh
505
- * revocation is not supported in this iteration; rotating the underlying
506
- * AssetLab API key in Settings is the way to fully terminate a session.
507
- *
508
- * Per RFC 7009 §2.2 we return 200 for unknown tokens too — clients shouldn't
509
- * be able to probe token validity via this endpoint.
510
- */
511
- export async function handleRevoke(request, env) {
512
- let params;
513
- const ct = request.headers.get('Content-Type') ?? '';
514
- try {
515
- if (ct.includes('application/json')) {
516
- const body = (await request.json());
517
- params = new URLSearchParams(body);
518
- }
519
- else {
520
- params = new URLSearchParams(await request.text());
521
- }
522
- }
523
- catch {
524
- return json({ error: 'invalid_request', error_description: 'Malformed body' }, 400);
525
- }
526
- const token = params.get('token') ?? '';
527
- if (!token) {
528
- return json({ error: 'invalid_request', error_description: 'Missing token' }, 400);
529
- }
530
- // Best-effort: only opaque access tokens are revocable here. The 200 is
531
- // returned regardless to avoid leaking which tokens exist.
532
- if (token.startsWith(ACCESS_TOKEN_PREFIX)) {
533
- await deleteAccessToken(env.OAUTH_CLIENTS, token);
534
- }
535
- return new Response(null, { status: 200 });
536
- }
537
- function renderConsentPage(data) {
538
- const html = `<!DOCTYPE html>
539
- <html lang="en">
540
- <head>
541
- <meta charset="utf-8"/>
542
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
543
- <title>Connect to AssetLab</title>
544
- <link rel="preconnect" href="https://fonts.googleapis.com"/>
545
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
546
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Lexend:wght@400;500;600&display=swap" rel="stylesheet"/>
547
- <style>
548
- *{margin:0;padding:0;box-sizing:border-box}
549
- body{font-family:'Inter',system-ui,sans-serif;background:rgb(3,6,22);color:#fff;
550
- min-height:100vh;display:flex;flex-direction:column;align-items:center;
551
- justify-content:center;padding:1rem;position:relative;overflow:hidden}
552
- .grid-base{position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.04;
553
- background-image:linear-gradient(rgba(255,255,255,.4) 1px,transparent 1px),
554
- linear-gradient(90deg,rgba(255,255,255,.4) 1px,transparent 1px);
555
- background-size:60px 60px;
556
- -webkit-mask-image:radial-gradient(ellipse 60% 60% at 50% 50%,black 0%,transparent 100%);
557
- mask-image:radial-gradient(ellipse 60% 60% at 50% 50%,black 0%,transparent 100%)}
558
- .grid-glow{position:fixed;inset:0;pointer-events:none;z-index:0;
559
- background-image:linear-gradient(rgba(106,208,157,.3) 1px,transparent 1px),
560
- linear-gradient(90deg,rgba(106,208,157,.3) 1px,transparent 1px);
561
- background-size:60px 60px;transition:opacity .3s;
562
- -webkit-mask-image:radial-gradient(circle 120px at 0px 0px,black 0%,transparent 100%);
563
- mask-image:radial-gradient(circle 120px at 0px 0px,black 0%,transparent 100%)}
564
- .logo{display:flex;align-items:center;gap:.625rem;margin-bottom:2rem;position:relative;z-index:1}
565
- .logo svg{width:48px;height:48px}
566
- .logo-text{font-family:'Lexend',sans-serif;font-size:1.5rem;font-weight:500;letter-spacing:-.025em}
567
- .logo-text .lab{color:#45c28b}
568
- .card{background:transparent;backdrop-filter:blur(4px);border-radius:1rem;
569
- border:1px solid rgba(255,255,255,.2);max-width:420px;width:100%;
570
- padding:2rem;position:relative;z-index:1}
571
- h1{font-family:'Lexend',sans-serif;font-size:1.25rem;font-weight:500;margin-bottom:.25rem;
572
- letter-spacing:-.025em}
573
- .sub{color:#d1d5db;font-size:.875rem;margin-bottom:1.5rem}
574
- .sub strong{color:#fff;font-weight:600}
575
- label{display:flex;align-items:center;gap:.5rem;color:#e5e7eb;font-weight:500;
576
- font-size:.875rem;margin-bottom:.375rem}
577
- label svg{width:16px;height:16px;color:#9ca3af;flex-shrink:0}
578
- input[type="password"]{width:100%;padding:.625rem .75rem;background:transparent;
579
- border:1px solid rgba(255,255,255,.2);border-radius:.5rem;font-size:.875rem;
580
- font-family:'JetBrains Mono',monospace;color:#fff;outline:none;transition:border-color .15s,box-shadow .15s}
581
- input[type="password"]::placeholder{color:#6b7280}
582
- input[type="password"]:focus{border-color:rgb(106,208,157);
583
- box-shadow:0 0 0 3px rgba(106,208,157,.15)}
584
- .help{color:#6b7280;font-size:.75rem;margin-top:.375rem}
585
- button{width:100%;margin-top:1.25rem;padding:.625rem;
586
- background:rgb(106,208,157);color:rgb(3,6,22);border:none;border-radius:.5rem;
587
- font-family:'Inter',sans-serif;font-size:.875rem;font-weight:600;cursor:pointer;
588
- transition:background .15s}
589
- button:hover{background:rgb(86,188,137)}
590
- button:disabled{background:#4b5563;color:#9ca3af;cursor:not-allowed}
591
- .footer{text-align:center;margin-top:1rem;font-size:.75rem;color:#6b7280}
592
- .footer .host{color:#9ca3af;font-family:'JetBrains Mono',monospace}
593
- .glow{position:fixed;width:400px;height:400px;border-radius:50%;
594
- background:radial-gradient(circle,rgba(106,208,157,.08),transparent 70%);
595
- top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;z-index:0}
596
- </style>
597
- </head>
598
- <body>
599
- <div class="grid-base"></div>
600
- <div class="grid-glow" id="gridGlow"></div>
601
- <div class="glow"></div>
602
- <div class="logo">
603
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 292.3 292.3">
604
- <g transform="translate(95.414,86.487)">
605
- <circle fill="#45c28b" cx="-74.77" cy="-51.34" r="20"/>
606
- <circle fill="#45c28b" cx="-74.53" cy="1.19" r="20"/>
607
- <circle fill="#45c28b" cx="-22.24" cy="-28.91" r="20"/>
608
- <circle fill="#fbd04e" cx="-74.67" cy="56.37" r="20"/>
609
- <circle fill="#fbd04e" cx="-74.58" cy="120.34" r="20"/>
610
- <circle fill="#fbd04e" cx="-74.49" cy="172.05" r="20"/>
611
- <circle fill="#fbd04e" cx="-23.77" cy="26.56" r="20"/>
612
- <circle fill="#fbd04e" cx="22.72" cy="-7.36" r="15"/>
613
- <circle fill="#fbd04e" cx="-21.38" cy="89.52" r="17"/>
614
- <circle fill="#fbd04e" cx="30.77" cy="51.30" r="20"/>
615
- <circle fill="#fbd04e" cx="-21.89" cy="159.66" r="17"/>
616
- <circle fill="#fbd04e" cx="22.92" cy="173.41" r="15"/>
617
- <circle fill="#fbd04e" cx="35.15" cy="119.00" r="20"/>
618
- <circle fill="#fbd04e" cx="79.51" cy="79.64" r="15"/>
619
- <circle fill="#f78265" cx="72.59" cy="161.68" r="20"/>
620
- <circle fill="#f78265" cx="110.66" cy="131.00" r="12"/>
621
- <circle fill="#f78265" cx="121.37" cy="86.82" r="12"/>
622
- <circle fill="#f78265" cx="155.50" cy="110.89" r="15"/>
623
- <circle fill="#f78265" cx="136.27" cy="172.43" r="15"/>
624
- <circle fill="#f78265" cx="182.53" cy="154.52" r="15"/>
625
- </g>
626
- </svg>
627
- <span class="logo-text">Asset<span class="lab">Lab</span></span>
628
- </div>
629
- <div class="card">
630
- <h1>Connect to AssetLab</h1>
631
- <p class="sub"><strong>${escapeHtml(data.clientName)}</strong> wants to access your AssetLab workspace via MCP.</p>
632
- <form method="POST" action="/authorize" id="form">
633
- <input type="hidden" name="state" value="${escapeHtml(data.state)}"/>
634
- <input type="hidden" name="redirect_uri" value="${escapeHtml(data.redirectUri)}"/>
635
- <input type="hidden" name="code_challenge" value="${escapeHtml(data.codeChallenge)}"/>
636
- <input type="hidden" name="client_id" value="${escapeHtml(data.clientId)}"/>
637
- <input type="hidden" name="scope" value="${escapeHtml(data.scope)}"/>
638
- <label for="api_key">
639
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
640
- API Key
641
- </label>
642
- <input type="password" id="api_key" name="api_key" placeholder="al_live_..." required
643
- pattern="al_(live|test)_.+" title="Must start with al_live_ or al_test_"/>
644
- <p class="help">Find this in AssetLab → Settings → API Keys</p>
645
- <button type="submit" id="btn">Authorize</button>
646
- </form>
647
- <p class="footer">After authorizing you'll be redirected to <span class="host">${escapeHtml(data.redirectHost)}</span>.</p>
648
- </div>
649
- <script>
650
- document.getElementById('form').addEventListener('submit',()=>{
651
- document.getElementById('btn').disabled=true;
652
- document.getElementById('btn').textContent='Connecting\u2026';
653
- });
654
- (function(){
655
- var g=document.getElementById('gridGlow');
656
- document.addEventListener('mousemove',function(e){
657
- var m='radial-gradient(circle 120px at '+e.clientX+'px '+e.clientY+'px,black 0%,transparent 100%)';
658
- g.style.webkitMaskImage=m;g.style.maskImage=m;
659
- });
660
- })();
661
- </script>
662
- </body>
663
- </html>`;
664
- return new Response(html, {
665
- status: 200,
666
- headers: {
667
- 'Content-Type': 'text/html; charset=utf-8',
668
- 'X-Frame-Options': 'DENY',
669
- 'Content-Security-Policy': "frame-ancestors 'none'",
670
- },
671
- });
672
- }
673
- function errorPage(message, status = 400) {
674
- const html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/>
675
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
676
- <title>Authorization error</title>
677
- <style>body{font-family:system-ui,-apple-system,sans-serif;background:rgb(3,6,22);color:#fff;
678
- display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;
679
- padding:1rem;text-align:center}.card{border:1px solid rgba(255,255,255,.2);border-radius:1rem;
680
- padding:2rem;max-width:480px}h1{font-size:1.25rem;margin-bottom:.5rem;color:#f78265;font-weight:500}
681
- p{color:#d1d5db;font-size:.875rem;line-height:1.5}</style></head>
682
- <body><div class="card"><h1>Authorization failed</h1><p>${escapeHtml(message)}</p></div></body></html>`;
683
- return new Response(html, {
684
- status,
685
- headers: {
686
- 'Content-Type': 'text/html; charset=utf-8',
687
- 'X-Frame-Options': 'DENY',
688
- 'Content-Security-Policy': "frame-ancestors 'none'",
689
- },
690
- });
691
- }
692
- // ── Utility ─────────────────────────────────────────────────────────────────
693
- function escapeHtml(s) {
694
- return s
695
- .replace(/&/g, '&amp;')
696
- .replace(/"/g, '&quot;')
697
- .replace(/</g, '&lt;')
698
- .replace(/>/g, '&gt;');
699
- }
700
- //# sourceMappingURL=oauth.js.map