File size: 21,615 Bytes
3464008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
// @ts-expect-error — JS module, no declaration file
import { getClientIp } from '../_rate-limit.js';
// @ts-expect-error — JS module, no declaration file
import { timingSafeIncludes, sha256Hex } from '../_crypto.js';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

export const config = { runtime: 'edge' };

const CODE_TTL_SECONDS = 600;
const CLIENT_TTL_SECONDS = 90 * 24 * 3600; // 90-day sliding reset

// First-party origin allowlist for the consent POST: the worldmonitor.app apex
// plus any single-label subdomain (www, api, tech, finance, …), matching the
// host-derived OAuth metadata (api/_agent-metadata.ts resolveMetadataOrigin).
// The consent page is served host-derived, so its native form submit (action is
// api.worldmonitor.app) and its JS same-origin fetch both arrive with a
// first-party Origin — which, before this, only matched api.worldmonitor.app and
// 403'd the www/apex discovery flow. Foreign origins are still rejected; the
// single-use CSRF nonce (server-stored, the source of every authoritative value)
// is the primary protection, this is defense-in-depth. Anchored, so it rejects
// worldmonitor.app.evil.example, evilworldmonitor.app, and any :port.
const WM_ORIGIN = /^https:\/\/(?:[a-z0-9-]+\.)?worldmonitor\.app$/;

let _rl = null;
function getRatelimit() {
  if (_rl) return _rl;
  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) return null;
  _rl = new Ratelimit({
    redis: new Redis({ url, token }),
    limiter: Ratelimit.slidingWindow(10, '60 s'),
    prefix: 'rl:oauth-authorize',
    analytics: false,
  });
  return _rl;
}

function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

// Atomic GETDEL — returns null on genuine key-miss; throws on transport/HTTP failure.
async function redisGetDel(key) {
  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) throw new Error('Redis not configured');
  const resp = await fetch(`${url}/getdel/${encodeURIComponent(key)}`, {
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(3_000),
  });
  if (!resp.ok) throw new Error(`Redis HTTP ${resp.status}`);
  const data = await resp.json();
  if (!data?.result) return null; // key did not exist
  try { return JSON.parse(data.result); } catch { return null; }
}

// Returns null on genuine key-miss; throws on transport/HTTP failure
// so callers can distinguish "key not found" from "storage unavailable".
async function redisGet(key) {
  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) throw new Error('Redis not configured');
  const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(3_000),
  });
  if (!resp.ok) throw new Error(`Redis HTTP ${resp.status}`);
  const data = await resp.json();
  if (!data?.result) return null; // key did not exist
  try { return JSON.parse(data.result); } catch { return null; }
}

async function redisSet(key, value, exSeconds) {
  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) return false;
  try {
    const resp = await fetch(`${url}/pipeline`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify([['SET', key, JSON.stringify(value), 'EX', exSeconds]]),
      signal: AbortSignal.timeout(3_000),
    });
    if (!resp.ok) return false;
    const results = await resp.json().catch(() => null);
    return Array.isArray(results) && results[0]?.result === 'OK';
  } catch { return false; }
}

const GLOBE_SVG = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';

const PAGE_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'X-Frame-Options': 'DENY', 'Cache-Control': 'no-store', 'Pragma': 'no-cache' };

function htmlError(title, detail) {
  return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error &#x2014; WorldMonitor MCP</title>
<style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:ui-monospace,'SF Mono','Cascadia Code',monospace;background:#0a0a0a;color:#e8e8e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem}.wm-logo{display:flex;align-items:center;gap:.5rem;margin-bottom:2rem;text-decoration:none}.wm-logo svg{color:#2d8a6e}.wm-logo-text{font-size:.75rem;color:#555;letter-spacing:.1em;text-transform:uppercase}.card{width:100%;max-width:420px;background:#111;border:1px solid #1e1e1e;padding:2rem}h1{font-size:.95rem;font-weight:600;color:#ef4444;margin-bottom:.75rem;letter-spacing:.02em}p{font-size:.85rem;color:#666;line-height:1.6}.back{display:inline-block;margin-top:1.5rem;font-size:.75rem;color:#444;text-decoration:none;letter-spacing:.03em}.back:hover{color:#888}.footer{margin-top:1.5rem;font-size:.7rem;color:#2a2a2a;text-align:center}.footer a{color:#333;text-decoration:none}.footer a:hover{color:#555}</style></head>
<body><a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor MCP</span></a>
<div class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p><a href="javascript:history.back()" class="back">&#8592; go back</a></div>
<p class="footer"><a href="https://www.worldmonitor.app" target="_blank" rel="noopener">worldmonitor.app</a></p>
</body></html>`, { status: 400, headers: PAGE_HEADERS });
}

// Exported for unit tests (tests/oauth-authorize.test.mjs).
//
// Default state: API-key form is hidden behind a "Use API key instead"
// disclosure — Pro users see only the brand-green Pro CTA. The form is
// auto-revealed in two cases (handled by the inline script):
//   1. When `errorMsg` is truthy (invalid-key retry path at handler line ~302)
//      — the `<p class="error">` element renders with no inline display:none
//      and the script reveals the form whenever `#ke` is non-empty. Hiding
//      the form after a bad-key submit would be hostile to Starter+ users.
//   2. When the URL fragment is `#api-key` — Starter+ users can bookmark
//      `…/oauth/authorize?…#api-key` to skip the disclosure click.
export function consentPage(params, nonce, errorMsg = '') {
  const { client_name, redirect_uri } = params;
  const redirectHost = new URL(redirect_uri).hostname;
  // U3 contract: bridge URL is apex (no www, no return_to). Apex page reads
  // oauth:nonce:<nonce> itself to recover client metadata + mint a grant.
  const proCtaHref = `https://worldmonitor.app/mcp-grant?nonce=${encodeURIComponent(nonce)}`;
  return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize &#x2014; WorldMonitor MCP</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:ui-monospace,'SF Mono','Cascadia Code',monospace;background:#0a0a0a;color:#e8e8e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem}
.wm-logo{display:flex;align-items:center;gap:.5rem;margin-bottom:2rem;text-decoration:none}
.wm-logo svg{color:#2d8a6e}
.wm-logo-text{font-size:.75rem;color:#555;letter-spacing:.1em;text-transform:uppercase}
.card{width:100%;max-width:440px;background:#111;border:1px solid #1e1e1e;padding:2rem}
.client-hd{margin-bottom:1.25rem}
.client-name{font-size:1rem;color:#fff;font-weight:600;margin-bottom:.25rem}
.client-host{font-size:.75rem;color:#444}
hr{border:none;border-top:1px solid #1e1e1e;margin:1.25rem 0}
.scope-label{font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;color:#444;margin-bottom:.6rem}
.scope-list{list-style:none}
.scope-list li{font-size:.8rem;color:#666;padding:.2rem 0;display:flex;align-items:flex-start;gap:.5rem}
.scope-list li::before{content:'→';color:#2d8a6e;flex-shrink:0;margin-top:.05em}
label{display:block;font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;color:#444;margin-bottom:.4rem}
input[type=password]{width:100%;padding:.65rem .75rem;background:#0a0a0a;border:1px solid #2a2a2a;color:#e8e8e8;font-family:inherit;font-size:.9rem;outline:none;border-radius:0}
input[type=password]:focus{border-color:#2d8a6e}
.hint{font-size:.72rem;color:#333;margin-top:.4rem}
.hint a{color:#2d8a6e;text-decoration:none}
.hint a:hover{text-decoration:underline}
.error{color:#ef4444;font-size:.8rem;margin:.5rem 0 0}
button{width:100%;margin-top:1.25rem;padding:.75rem;background:#2563eb;color:#fff;border:none;font-family:inherit;font-size:.9rem;cursor:pointer;font-weight:500;letter-spacing:.02em;border-radius:0}
button:hover{background:#1d4ed8}
button:disabled{opacity:.5;cursor:default}
.pro-cta{display:block;width:100%;padding:.75rem;background:#2d8a6e;color:#fff;border:none;font-family:inherit;font-size:.9rem;font-weight:500;letter-spacing:.02em;text-align:center;text-decoration:none;cursor:pointer;border-radius:0}
.pro-cta:hover{background:#246e58}
.disclosure{margin-top:1rem;text-align:center}
.disclosure a{font-size:.75rem;color:#555;text-decoration:none;letter-spacing:.02em;cursor:pointer}
.disclosure a:hover{color:#888;text-decoration:underline}
.footer{font-size:.7rem;color:#2a2a2a;text-align:center;margin-top:1.25rem}
.footer a{color:#333;text-decoration:none}
.footer a:hover{color:#555}
</style></head>
<body>
<a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor MCP</span></a>
<div class="card">
<div class="client-hd">
<div class="client-name">${escapeHtml(client_name)} wants access</div>
<div class="client-host">via ${escapeHtml(redirectHost)}</div>
</div>
<hr>
<p class="scope-label">Read-only access to</p>
<ul class="scope-list">
<li>Real-time news &amp; events from 100+ global sources</li>
<li>Live flight tracking &amp; AIS vessel positions</li>
<li>Weather alerts, earthquakes &amp; natural disasters</li>
<li>Geopolitical risk indicators &amp; conflict data</li>
<li>Markets: stocks, commodities, crypto &amp; FX</li>
</ul>
<hr>
<a id="pc" class="pro-cta" href="${escapeHtml(proCtaHref)}">Sign in with WorldMonitor Pro</a>
<div class="disclosure" id="dt"><a id="tk" role="button" tabindex="0">Use API key instead</a></div>
<form id="cf" method="POST" action="https://api.worldmonitor.app/oauth/authorize" style="display:none;margin-top:1.25rem">
<input type="hidden" name="_nonce" id="nn" value="${escapeHtml(nonce)}">
<input type="hidden" name="_js" id="jf" value="">
<label for="api_key">API Key</label>
<input type="password" id="api_key" name="api_key" placeholder="wm_&#8230;" autocomplete="current-password">
<p class="hint">No key? <a href="https://www.worldmonitor.app/pro" target="_blank" rel="noopener">Get one at worldmonitor.app/pro &#x2192;</a></p>
<p class="error" id="ke"${errorMsg ? '' : ' style="display:none"'}>${errorMsg ? escapeHtml(errorMsg) : ''}</p>
<button type="submit" id="ab">Authorize</button>
</form>
</div>
<p class="footer"><a href="https://www.worldmonitor.app" target="_blank" rel="noopener">worldmonitor.app</a> &middot; <a href="https://www.worldmonitor.app/pro" target="_blank" rel="noopener">Get an API key &#x2192;</a></p>
<script>(function(){function showForm(){var f=document.getElementById('cf');if(f)f.style.display='';var d=document.getElementById('dt');if(d)d.style.display='none';var k=document.getElementById('api_key');if(k){k.required=true;try{k.focus();}catch(e){}}}var em=document.getElementById('ke');if(em&&em.textContent&&em.textContent.length>0){showForm();}if(window.location.hash==='#api-key'){showForm();}var tk=document.getElementById('tk');if(tk){tk.addEventListener('click',function(e){e.preventDefault();showForm();});tk.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();showForm();}});}var cf=document.getElementById('cf');if(cf){cf.addEventListener('submit',function(e){e.preventDefault();var jf=document.getElementById('jf');if(jf)jf.value='1';var b=document.getElementById('ab');b.disabled=true;b.textContent='Authorizing…';var d=new URLSearchParams(new FormData(e.target));fetch('/oauth/authorize',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:d}).then(function(r){var c=r.headers.get('Content-Type')||'';if(c.indexOf('json')>=0)return r.json().then(function(j){if(j.location){window.location.replace(j.location);return;}if(j.error==='invalid_key'){var n=document.getElementById('nn');if(n)n.value=j.nonce||'';var em2=document.getElementById('ke');if(em2){em2.textContent='Invalid API key. Please check and try again.';em2.style.display='';}showForm();}b.disabled=false;b.textContent='Authorize';});return r.text().then(function(h){document.open();document.write(h);document.close();});}).catch(function(){b.disabled=false;b.textContent='Authorize';});});}})();</script>
</body></html>`, { status: 200, headers: PAGE_HEADERS });
}

export default async function handler(req) {
  const method = req.method;

  if (method === 'OPTIONS') {
    return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' } });
  }

  if (method === 'GET') {
    const url = new URL(req.url);
    const p = url.searchParams;
    const client_id = p.get('client_id');
    const redirect_uri = p.get('redirect_uri');
    const response_type = p.get('response_type');
    const code_challenge = p.get('code_challenge');
    const code_challenge_method = p.get('code_challenge_method');
    const state = p.get('state') ?? '';

    if (!client_id || !redirect_uri || response_type !== 'code' || !code_challenge || code_challenge_method !== 'S256') {
      return htmlError('Invalid Authorization Request', 'Missing or invalid required parameters (client_id, redirect_uri, response_type=code, code_challenge, code_challenge_method=S256).');
    }

    // Validate code_challenge format: 43-char base64url
    if (code_challenge.length !== 43 || !/^[A-Za-z0-9\-_]+$/.test(code_challenge)) {
      return htmlError('Invalid Request', 'code_challenge must be a 43-character base64url string.');
    }

    let client;
    try {
      client = await redisGet(`oauth:client:${client_id}`);
    } catch {
      return htmlError('Service Unavailable', 'Authorization service is temporarily unavailable. Please try again shortly.');
    }
    if (!client) {
      return htmlError('Unknown Client', 'The client_id is not registered or has expired. Please re-register the client.');
    }

    const uris = Array.isArray(client.redirect_uris) ? client.redirect_uris : [];
    if (!uris.includes(redirect_uri)) {
      return htmlError('Redirect URI Mismatch', 'The redirect_uri does not match any registered redirect URI for this client.');
    }

    // Reset client TTL (sliding 90-day window)
    await redisSet(`oauth:client:${client_id}`, { ...client, last_used: Date.now() }, CLIENT_TTL_SECONDS);

    const nonce = crypto.randomUUID();
    const nonceStored = await redisSet(`oauth:nonce:${nonce}`, { client_id, redirect_uri, code_challenge, state, created_at: Date.now() }, 600);
    if (!nonceStored) {
      return htmlError('Service Unavailable', 'Authorization service is temporarily unavailable. Please try again shortly.');
    }

    return consentPage({
      client_name: client.client_name ?? 'Unknown Client',
      redirect_uri, client_id, response_type: 'code', code_challenge, code_challenge_method: 'S256', state,
    }, nonce);
  }

  if (method === 'POST') {
    // Origin validation: allow any first-party worldmonitor.app host (the consent
    // page is served host-derived — apex/www/api/variant — so a same-origin JS
    // fetch or the native form POST to api.worldmonitor.app arrives with any of
    // those Origins), plus absent origin (server/CLI) and 'null' (WebView with
    // opaque/sandboxed origin). CSRF nonce provides the actual protection.
    const origin = req.headers.get('origin');
    if (origin && origin !== 'null' && !WM_ORIGIN.test(origin)) {
      return new Response('Forbidden', { status: 403 });
    }

    const rl = getRatelimit();
    if (rl) {
      try {
        const { success } = await rl.limit(`ip:${getClientIp(req)}`);
        if (!success) {
          return new Response('Too Many Requests', { status: 429 });
        }
      } catch { /* graceful degradation */ }
    }

    let params;
    try {
      params = new URLSearchParams(await req.text());
    } catch {
      return htmlError('Bad Request', 'Could not parse form data.');
    }

    const api_key = params.get('api_key') ?? '';
    const nonce = params.get('_nonce') ?? '';
    // _js=1 is set by the inline script before building FormData — distinguishes
    // the JS/WebView path (needs JSON response) from native form submit (needs 302).
    const isXHR = params.get('_js') === '1';

    if (!nonce) {
      return htmlError('Bad Request', 'Missing session token.');
    }

    // Atomically consume CSRF nonce (GETDEL — prevents concurrent submit race).
    // All security-critical values are derived from nonceData, not from mutable
    // form fields — prevents authorization misbinding via cross-origin form POST.
    let nonceData;
    try {
      nonceData = await redisGetDel(`oauth:nonce:${nonce}`);
    } catch {
      return htmlError('Service Unavailable', 'Authorization service is temporarily unavailable. Please try again shortly.');
    }
    if (!nonceData) {
      return htmlError('Session Expired', 'Authorization session expired or is invalid. Please start over.');
    }

    // Authoritative values come exclusively from server-stored nonce.
    const { client_id, redirect_uri, code_challenge, state } = nonceData;

    let client;
    try {
      client = await redisGet(`oauth:client:${client_id}`);
    } catch {
      return htmlError('Service Unavailable', 'Authorization service is temporarily unavailable. Please try again shortly.');
    }
    if (!client) {
      return htmlError('Unknown Client', 'The client registration has expired. Please re-register.');
    }

    const uris = Array.isArray(client.redirect_uris) ? client.redirect_uris : [];
    if (!uris.includes(redirect_uri)) {
      return htmlError('Redirect URI Mismatch', 'redirect_uri does not match registered set.');
    }

    // Validate API key
    const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
    if (!await timingSafeIncludes(api_key, validKeys)) {
      // Generate and store a fresh nonce; fail closed if storage is unavailable
      const retryNonce = crypto.randomUUID();
      const retryNonceStored = await redisSet(`oauth:nonce:${retryNonce}`, { client_id, redirect_uri, code_challenge, state, created_at: Date.now() }, 600);
      if (!retryNonceStored) {
        return htmlError('Service Unavailable', 'Authorization service is temporarily unavailable. Please try again shortly.');
      }
      if (isXHR) {
        return new Response(JSON.stringify({ error: 'invalid_key', nonce: retryNonce }), {
          status: 400,
          headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
        });
      }
      return consentPage({
        client_name: client.client_name ?? 'Unknown Client',
        redirect_uri, client_id, response_type: 'code', code_challenge, code_challenge_method: 'S256', state,
      }, retryNonce, 'Invalid API key. Please check and try again.');
    }

    // Issue authorization code — all fields sourced from nonceData
    const code = crypto.randomUUID();
    const codeData = {
      client_id,
      redirect_uri,
      code_challenge,
      scope: 'mcp',
      api_key_hash: await sha256Hex(api_key),
    };
    const stored = await redisSet(`oauth:code:${code}`, codeData, CODE_TTL_SECONDS);
    if (!stored) {
      return htmlError('Server Error', 'Failed to store authorization code. Please try again.');
    }

    // Reset client TTL
    await redisSet(`oauth:client:${client_id}`, { ...client, last_used: Date.now() }, CLIENT_TTL_SECONDS);

    const redirectUrl = new URL(redirect_uri);
    redirectUrl.searchParams.set('code', code);
    if (state) redirectUrl.searchParams.set('state', state);

    // XHR (JavaScript fetch) path: return JSON so the page can navigate the WebView.
    // Native form submit path: return 302 redirect (curl, non-JS fallback).
    if (isXHR) {
      return new Response(JSON.stringify({ location: redirectUrl.toString() }), {
        status: 200,
        headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
      });
    }
    return new Response(null, {
      status: 302,
      headers: {
        Location: redirectUrl.toString(),
        'Cache-Control': 'no-store',
        'Pragma': 'no-cache',
      },
    });
  }

  return new Response(null, { status: 405, headers: { Allow: 'GET, POST, OPTIONS' } });
}