@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/oauth.js CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
- * Minimal OAuth 2.0 Authorization Server for Claude.ai MCP integration.
2
+ * OAuth 2.0 Authorization Server for AssetLab MCP.
3
3
  *
4
- * Implements just enough of OAuth 2.0 + PKCE to satisfy Claude.ai's connector flow:
5
- * 1. Dynamic client registration (RFC 7591)
6
- * 2. Authorization endpoint (shows "paste your API key" page)
7
- * 3. Token endpoint (exchanges auth code for access token)
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 becomes the OAuth access_token, so the existing
10
- * MCP handler works unchanged (it reads Bearer token from Authorization header).
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 CORS = {
55
- 'Access-Control-Allow-Origin': '*',
56
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
57
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, MCP-Protocol-Version, Accept',
58
- };
59
- function json(body, status = 200, extra = {}) {
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: { 'Content-Type': 'application/json', ...CORS, ...extra },
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
- const body = await request.json();
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: body.client_name ?? 'Claude.ai',
93
- redirect_uris: body.redirect_uris ?? [],
94
- grant_types: body.grant_types ?? ['authorization_code'],
95
- response_types: body.response_types ?? ['code'],
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 state = url.searchParams.get('state') ?? '';
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 clientId = url.searchParams.get('client_id') ?? '';
303
+ const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? '';
106
304
  const scope = url.searchParams.get('scope') ?? '';
107
- const html = `<!DOCTYPE html>
108
- <html lang="en">
109
- <head>
110
- <meta charset="utf-8"/>
111
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
112
- <title>Connect to AssetLab</title>
113
- <link rel="preconnect" href="https://fonts.googleapis.com"/>
114
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
115
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Lexend:wght@400;500;600&display=swap" rel="stylesheet"/>
116
- <style>
117
- *{margin:0;padding:0;box-sizing:border-box}
118
- body{font-family:'Inter',system-ui,sans-serif;background:rgb(3,6,22);color:#fff;
119
- min-height:100vh;display:flex;flex-direction:column;align-items:center;
120
- justify-content:center;padding:1rem;position:relative;overflow:hidden}
121
- /* base grid - white, faded on edges */
122
- .grid-base{position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.04;
123
- background-image:linear-gradient(rgba(255,255,255,.4) 1px,transparent 1px),
124
- linear-gradient(90deg,rgba(255,255,255,.4) 1px,transparent 1px);
125
- background-size:60px 60px;
126
- -webkit-mask-image:radial-gradient(ellipse 60% 60% at 50% 50%,black 0%,transparent 100%);
127
- mask-image:radial-gradient(ellipse 60% 60% at 50% 50%,black 0%,transparent 100%)}
128
- /* mouse hover glow on grid */
129
- .grid-glow{position:fixed;inset:0;pointer-events:none;z-index:0;
130
- background-image:linear-gradient(rgba(106,208,157,.3) 1px,transparent 1px),
131
- linear-gradient(90deg,rgba(106,208,157,.3) 1px,transparent 1px);
132
- background-size:60px 60px;transition:opacity .3s;
133
- -webkit-mask-image:radial-gradient(circle 120px at 0px 0px,black 0%,transparent 100%);
134
- mask-image:radial-gradient(circle 120px at 0px 0px,black 0%,transparent 100%)}
135
- .logo{display:flex;align-items:center;gap:.625rem;margin-bottom:2rem;position:relative;z-index:1}
136
- .logo svg{width:48px;height:48px}
137
- .logo-text{font-family:'Lexend',sans-serif;font-size:1.5rem;font-weight:500;letter-spacing:-.025em}
138
- .logo-text .lab{color:#45c28b}
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, secret) {
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
- console.log('[authorize POST]', JSON.stringify({ hasApiKey: !!apiKey, state, redirectUri, codeChallenge, scope }));
247
- if (!apiKey || !redirectUri) {
248
- return json({ error: 'missing_parameters' }, 400);
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), secret);
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, secret) {
267
- const refreshPayload = { apiKey, scope: scope || 'mcp', type: 'refresh' };
268
- const refreshToken = await encrypt(JSON.stringify(refreshPayload), secret);
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: apiKey,
387
+ access_token: accessToken,
271
388
  token_type: 'Bearer',
272
- expires_in: 86400,
273
- scope: scope || 'mcp',
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, secret) {
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
- console.log('[token]', JSON.stringify({ grantType, contentType: ct }));
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, secret));
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
- console.log('[token] refresh success, issuing new tokens');
307
- return issueTokens(payload.apiKey, payload.scope, secret);
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, secret));
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
- // Verify PKCE
342
- if (payload.codeChallenge && codeVerifier) {
343
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
344
- const expected = base64url(digest);
345
- console.log('[token] PKCE check:', expected, '===', payload.codeChallenge, '→', expected === payload.codeChallenge);
346
- if (expected !== payload.codeChallenge) {
347
- return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400);
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
- console.log('[token] success, returning access_token with refresh_token');
351
- return issueTokens(payload.apiKey, payload.scope, secret);
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) {