@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/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,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 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 = {}) {
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: { 'Content-Type': 'application/json', ...CORS, ...extra },
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
- const body = await request.json();
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: 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'],
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 state = url.searchParams.get('state') ?? '';
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 clientId = url.searchParams.get('client_id') ?? '';
305
+ const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? '';
106
306
  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 },
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, secret) {
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
- console.log('[authorize POST]', JSON.stringify({ hasApiKey: !!apiKey, state, redirectUri, codeChallenge, scope }));
247
- if (!apiKey || !redirectUri) {
248
- return json({ error: 'missing_parameters' }, 400);
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), secret);
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, secret) {
267
- const refreshPayload = { apiKey, scope: scope || 'mcp', type: 'refresh' };
268
- const refreshToken = await encrypt(JSON.stringify(refreshPayload), secret);
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: apiKey,
389
+ access_token: accessToken,
271
390
  token_type: 'Bearer',
272
- expires_in: 86400,
273
- scope: scope || 'mcp',
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, secret) {
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
- console.log('[token]', JSON.stringify({ grantType, contentType: ct }));
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, secret));
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
- console.log('[token] refresh success, issuing new tokens');
307
- return issueTokens(payload.apiKey, payload.scope, secret);
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, secret));
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
- // 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);
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
- console.log('[token] success, returning access_token with refresh_token');
351
- return issueTokens(payload.apiKey, payload.scope, secret);
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.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
694
+ return s
695
+ .replace(/&/g, '&amp;')
696
+ .replace(/"/g, '&quot;')
697
+ .replace(/</g, '&lt;')
698
+ .replace(/>/g, '&gt;');
356
699
  }
357
700
  //# sourceMappingURL=oauth.js.map