@assetlab/mcp-server 1.19.6 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -20
- package/dist/client.js +34 -13
- package/dist/client.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +52 -15
- package/dist/oauth.js +548 -205
- package/dist/oauth.js.map +1 -1
- package/dist/response-shaping.d.ts +28 -0
- package/dist/response-shaping.js +53 -0
- package/dist/response-shaping.js.map +1 -0
- package/dist/tools-write.js +1150 -255
- package/dist/tools-write.js.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +383 -222
- package/dist/tools.js.map +1 -1
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +83 -28
- package/dist/worker.js.map +1 -1
- package/package.json +44 -41
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,161 @@ 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))
|
|
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
|
+
}
|
|
53
171
|
// ── CORS ─────────────────────────────────────────────────────────────────────
|
|
54
|
-
const
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
|
|
59
|
-
function
|
|
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) {
|
|
60
190
|
return new Response(JSON.stringify(body), {
|
|
61
191
|
status,
|
|
62
|
-
headers: {
|
|
192
|
+
headers: {
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
...(corsFor ? corsHeaders(corsFor) : {}),
|
|
195
|
+
...extra,
|
|
196
|
+
},
|
|
63
197
|
});
|
|
64
198
|
}
|
|
65
199
|
// ── Discovery endpoints ─────────────────────────────────────────────────────
|
|
66
|
-
export function protectedResourceMetadata(origin) {
|
|
200
|
+
export function protectedResourceMetadata(origin, request) {
|
|
67
201
|
const resource = origin.endsWith('/') ? origin : `${origin}/`;
|
|
68
202
|
return json({
|
|
69
203
|
resource,
|
|
70
204
|
authorization_servers: [origin],
|
|
71
|
-
});
|
|
205
|
+
}, 200, {}, request);
|
|
72
206
|
}
|
|
73
|
-
export function authServerMetadata(origin) {
|
|
207
|
+
export function authServerMetadata(origin, request) {
|
|
74
208
|
return json({
|
|
75
209
|
issuer: origin,
|
|
76
210
|
authorization_endpoint: `${origin}/authorize`,
|
|
@@ -81,211 +215,196 @@ export function authServerMetadata(origin) {
|
|
|
81
215
|
scopes_supported: ['claudeai', 'mcp'],
|
|
82
216
|
code_challenge_methods_supported: ['S256'],
|
|
83
217
|
token_endpoint_auth_methods_supported: ['none'],
|
|
84
|
-
});
|
|
218
|
+
}, 200, {}, request);
|
|
85
219
|
}
|
|
86
220
|
// ── Dynamic client registration (RFC 7591) ──────────────────────────────────
|
|
87
|
-
export async function handleRegister(request) {
|
|
88
|
-
|
|
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';
|
|
89
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;
|
|
90
267
|
return json({
|
|
91
268
|
client_id: clientId,
|
|
92
|
-
client_name:
|
|
93
|
-
redirect_uris:
|
|
94
|
-
grant_types:
|
|
95
|
-
response_types:
|
|
269
|
+
client_name: name,
|
|
270
|
+
redirect_uris: uris,
|
|
271
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
272
|
+
response_types: ['code'],
|
|
96
273
|
token_endpoint_auth_method: 'none',
|
|
274
|
+
scope: rawScope,
|
|
275
|
+
registration_access_token: regToken,
|
|
276
|
+
registration_client_uri: `${origin}/oauth/register/${clientId}`,
|
|
97
277
|
}, 201);
|
|
98
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
|
+
}
|
|
99
298
|
// ── Authorization endpoint ──────────────────────────────────────────────────
|
|
100
|
-
export function handleAuthorizeGet(request) {
|
|
299
|
+
export async function handleAuthorizeGet(request, kv) {
|
|
101
300
|
const url = new URL(request.url);
|
|
102
|
-
const
|
|
301
|
+
const clientId = url.searchParams.get('client_id') ?? '';
|
|
103
302
|
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
|
|
303
|
+
const responseType = url.searchParams.get('response_type') ?? 'code';
|
|
104
304
|
const codeChallenge = url.searchParams.get('code_challenge') ?? '';
|
|
105
|
-
const
|
|
305
|
+
const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? '';
|
|
106
306
|
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 },
|
|
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,
|
|
237
339
|
});
|
|
238
340
|
}
|
|
239
|
-
export async function handleAuthorizePost(request,
|
|
341
|
+
export async function handleAuthorizePost(request, env) {
|
|
240
342
|
const form = await request.formData();
|
|
241
343
|
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');
|
|
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') ?? '';
|
|
245
348
|
const scope = form.get('scope') ?? '';
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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);
|
|
249
361
|
}
|
|
250
362
|
const payload = {
|
|
251
363
|
apiKey,
|
|
252
364
|
codeChallenge,
|
|
253
365
|
redirectUri,
|
|
254
366
|
scope,
|
|
367
|
+
clientId,
|
|
255
368
|
exp: Date.now() + 600_000, // 10 minutes
|
|
256
369
|
};
|
|
257
|
-
const code = await encrypt(JSON.stringify(payload),
|
|
370
|
+
const code = await encrypt(JSON.stringify(payload), env.OAUTH_SECRET);
|
|
258
371
|
const target = new URL(redirectUri);
|
|
259
372
|
target.searchParams.set('code', code);
|
|
260
373
|
if (state)
|
|
261
374
|
target.searchParams.set('state', state);
|
|
262
|
-
console.log('[authorize POST] redirecting to', target.toString().slice(0, 100));
|
|
263
375
|
return Response.redirect(target.toString(), 302);
|
|
264
376
|
}
|
|
265
377
|
// ── Token endpoint ──────────────────────────────────────────────────────────
|
|
266
|
-
async function issueTokens(apiKey, scope,
|
|
267
|
-
const
|
|
268
|
-
const
|
|
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);
|
|
269
388
|
return json({
|
|
270
|
-
access_token:
|
|
389
|
+
access_token: accessToken,
|
|
271
390
|
token_type: 'Bearer',
|
|
272
|
-
expires_in:
|
|
273
|
-
scope:
|
|
391
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
392
|
+
scope: effectiveScope,
|
|
274
393
|
refresh_token: refreshToken,
|
|
275
394
|
});
|
|
276
395
|
}
|
|
277
|
-
export async function handleToken(request,
|
|
396
|
+
export async function handleToken(request, env) {
|
|
278
397
|
let params;
|
|
279
398
|
const ct = request.headers.get('Content-Type') ?? '';
|
|
280
399
|
if (ct.includes('application/json')) {
|
|
281
|
-
const body = await request.json();
|
|
400
|
+
const body = (await request.json());
|
|
282
401
|
params = new URLSearchParams(body);
|
|
283
402
|
}
|
|
284
403
|
else {
|
|
285
404
|
params = new URLSearchParams(await request.text());
|
|
286
405
|
}
|
|
287
406
|
const grantType = params.get('grant_type');
|
|
288
|
-
|
|
407
|
+
const requestClientId = params.get('client_id') ?? '';
|
|
289
408
|
// ── Refresh token grant ──────────────────────────────────────────────────
|
|
290
409
|
if (grantType === 'refresh_token') {
|
|
291
410
|
const refreshToken = params.get('refresh_token');
|
|
@@ -294,7 +413,7 @@ export async function handleToken(request, secret) {
|
|
|
294
413
|
}
|
|
295
414
|
let payload;
|
|
296
415
|
try {
|
|
297
|
-
payload = JSON.parse(await decrypt(refreshToken,
|
|
416
|
+
payload = JSON.parse(await decrypt(refreshToken, env.OAUTH_SECRET));
|
|
298
417
|
}
|
|
299
418
|
catch (err) {
|
|
300
419
|
console.log('[token] refresh decrypt failed:', err instanceof Error ? err.message : err);
|
|
@@ -303,55 +422,279 @@ export async function handleToken(request, secret) {
|
|
|
303
422
|
if (payload.type !== 'refresh') {
|
|
304
423
|
return json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400);
|
|
305
424
|
}
|
|
306
|
-
|
|
307
|
-
|
|
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);
|
|
308
435
|
}
|
|
309
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
|
+
}
|
|
310
443
|
const code = params.get('code');
|
|
311
444
|
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
|
-
}));
|
|
445
|
+
const redirectUri = params.get('redirect_uri') ?? '';
|
|
316
446
|
if (!code)
|
|
317
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
|
+
}
|
|
318
455
|
let payload;
|
|
319
456
|
try {
|
|
320
|
-
payload = JSON.parse(await decrypt(code,
|
|
457
|
+
payload = JSON.parse(await decrypt(code, env.OAUTH_SECRET));
|
|
321
458
|
}
|
|
322
459
|
catch (err) {
|
|
323
460
|
console.log('[token] decrypt failed:', err instanceof Error ? err.message : err);
|
|
324
461
|
return json({ error: 'invalid_grant', error_description: 'Invalid or expired code' }, 400);
|
|
325
462
|
}
|
|
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
463
|
if (Date.now() > payload.exp) {
|
|
334
|
-
console.log('[token] code expired');
|
|
335
464
|
return json({ error: 'invalid_grant', error_description: 'Code expired' }, 400);
|
|
336
465
|
}
|
|
337
466
|
if (redirectUri && payload.redirectUri !== redirectUri) {
|
|
338
|
-
console.log('[token] redirect URI mismatch:', payload.redirectUri, '!=', redirectUri);
|
|
339
467
|
return json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' }, 400);
|
|
340
468
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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());
|
|
348
521
|
}
|
|
349
522
|
}
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
});
|
|
352
691
|
}
|
|
353
692
|
// ── Utility ─────────────────────────────────────────────────────────────────
|
|
354
693
|
function escapeHtml(s) {
|
|
355
|
-
return s
|
|
694
|
+
return s
|
|
695
|
+
.replace(/&/g, '&')
|
|
696
|
+
.replace(/"/g, '"')
|
|
697
|
+
.replace(/</g, '<')
|
|
698
|
+
.replace(/>/g, '>');
|
|
356
699
|
}
|
|
357
700
|
//# sourceMappingURL=oauth.js.map
|