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