File size: 16,598 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
/**
 * POST /api/internal/mcp-grant-mint
 *
 * Apex-domain edge function. Bridges the Clerk session at
 * `worldmonitor.app/mcp-grant` to the api-subdomain consent flow at
 * `api.worldmonitor.app/oauth/authorize-pro` (U5).
 *
 * Flow:
 *   1. Caller is the Clerk-authenticated apex page; sends Bearer JWT +
 *      JSON `{nonce}` (nonce minted by `api/oauth/authorize.js` and
 *      stored at `oauth:nonce:<n>`).
 *   2. Resolve userId via Clerk JWKS verify. 401 on miss.
 *   3. Validate `oauth:nonce:<n>` exists (DO NOT consume β€” U5 consumes).
 *   4. Validate `oauth:client:<client_id>` exists.
 *   5. Re-validate the client's `redirect_uri` against the same
 *      allowlist DCR enforced (defense-in-depth).
 *   6. Check `getEntitlements(userId).features.tier >= 1` (Pro gate).
 *   7. Mint a HMAC-signed grant token over `{userId, nonce, exp:+5min}`
 *      and store a one-shot `mcp-grant:<n>` Redis record with the same
 *      `{userId, exp}` payload (5-min TTL).
 *   8. Return JSON `{redirect: '<fixed url>?nonce=<n>&grant=<token>'}`.
 *
 * The redirect URL host is FIXED to `https://api.worldmonitor.app` β€”
 * never user-controllable β€” to defeat the consent-phishing class
 * (see plan Risks: "Cross-subdomain CSRF / consent phishing").
 *
 * All responses set Cache-Control: no-store. Errors return structured
 * JSON `{error, error_description}` with stable error codes:
 *   - UNAUTHENTICATED              401  no/invalid Clerk JWT
 *   - INVALID_REQUEST              400  malformed body / missing nonce
 *   - INVALID_NONCE                400  Redis nonce miss / expired
 *   - UNKNOWN_CLIENT               400  Redis client miss
 *   - INVALID_REDIRECT_URI         400  client redirect_uri no longer allowlisted
 *   - INSUFFICIENT_TIER            403  user tier < 1, no mcpAccess, expired,
 *                                       or a provider-CONFIRMED lapse (which
 *                                       additionally sets X-Billing-Verification)
 *   - NONCE_CLAIMED_BY_OTHER_USER  403  nonce already claimed by a different
 *                                       Clerk userId (anti-hijack β€” see F2)
 *   - TIER_VERIFICATION_UNAVAILABLE 503 entitlement could not be VERIFIED, or a
 *                                       renewal re-check is in flight. Retryable:
 *                                       carries Retry-After +
 *                                       X-Billing-Verification (#5622). Distinct
 *                                       from INSUFFICIENT_TIER on purpose β€”
 *                                       flattening the two is #5600.
 *   - SERVICE_UNAVAILABLE          503  Redis SETEX failure
 *   - CONFIGURATION_ERROR          500  MCP_PRO_GRANT_HMAC_SECRET unset
 *
 * F2 (U7+U8 review pass) β€” anti-hijack semantics:
 *   `oauth:nonce:<n>` carries no Clerk userId binding. Without F2, any
 *   Pro user could mint a grant for any nonce, then deliver the redirect
 *   URL to a victim, who would exchange it for a bearer bound to the
 *   ATTACKER's userId. F2 introduces a SET-NX-style claim on
 *   `mcp-grant:<n>` itself: the FIRST mint atomically claims the nonce
 *   and writes the userId into the record; subsequent mints from a
 *   DIFFERENT userId are refused with NONCE_CLAIMED_BY_OTHER_USER. Mints
 *   from the SAME userId (multi-tab) succeed idempotently. The
 *   companion check at `/api/internal/mcp-grant-context` returns the
 *   same 403 so the apex page also refuses to render context for a
 *   hijacked nonce.
 */

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

import { resolveClerkSession } from '../../server/_shared/auth-session';
import {
  getEntitlements,
  isEntitlementBackendConfigured,
} from '../../server/_shared/entitlement-check';
import {
  checkProMcpAccess,
  proMcpGateDenialResponse,
  type ProMcpEntitlement,
} from '../../server/_shared/pro-mcp-gate';
// @ts-expect-error β€” JS module, no declaration file
import { isAllowedRedirectUri } from '../oauth/register.js';
import { GrantConfigError, signGrant } from '../_mcp-grant-hmac';

// Fixed return URL β€” NOT user-controllable (anti-phishing).
const AUTHORIZE_PRO_URL = 'https://api.worldmonitor.app/oauth/authorize-pro';

/** 5-minute exp for the signed grant + matching Redis one-shot. */
const GRANT_TTL_MS = 5 * 60 * 1000;
const GRANT_TTL_SECONDS = 300;

const NO_STORE_JSON: Record<string, string> = {
  'Content-Type': 'application/json',
  'Cache-Control': 'no-store',
};

function jsonError(error: string, error_description: string, status: number): Response {
  return new Response(JSON.stringify({ error, error_description }), { status, headers: NO_STORE_JSON });
}

interface NonceData {
  client_id: string;
  redirect_uri: string;
  code_challenge: string;
  state?: string;
  created_at?: number;
}

interface ClientData {
  client_name?: string;
  redirect_uris?: unknown;
}

// ---------------------------------------------------------------------------
// Redis helpers β€” read raw `oauth:*` keys via Upstash REST. These keys are
// written by api/oauth/authorize.js and api/oauth/register.js with NO
// deployment prefix, so we MUST match their on-the-wire format.
// ---------------------------------------------------------------------------

async function rawRedisGet(key: string): Promise<unknown | null> {
  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() as { result?: string | null };
  if (!data?.result) return null;
  try { return JSON.parse(data.result); } catch { return null; }
}

async function rawRedisSetEx(key: string, value: unknown, ttlSeconds: number): Promise<boolean> {
  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', ttlSeconds]]),
      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; }
}

/**
 * Atomic SET with NX (only set if key does not exist) + EX TTL.
 *
 * F2 (U7+U8 review pass): used to claim `mcp-grant:<n>` for a userId.
 * Returns `true` if the key was claimed (SET NX returned OK), `false` if
 * the key already existed (SET NX returned nil) OR on any transport
 * failure. Caller then GETs the existing record to compare userIds.
 *
 * Upstash REST returns `{result: "OK"}` on success and `{result: null}`
 * on NX-collision. We check string equality on the result.
 */
async function rawRedisSetNxEx(key: string, value: unknown, ttlSeconds: number): Promise<boolean> {
  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', ttlSeconds, 'NX']]),
      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; }
}

// ---------------------------------------------------------------------------
// Inner handler β€” exported for unit tests with injected deps.
// ---------------------------------------------------------------------------

export interface MintDeps {
  /** Resolves the Clerk userId from the request's Bearer header. Null = unauth. */
  resolveUserId: (req: Request) => Promise<string | null>;
  /** Reads a raw `oauth:*` or `mcp-grant:*` key from Redis. Throws on transport failure. */
  redisGet: (key: string) => Promise<unknown | null>;
  /** Writes a raw `mcp-grant:*` key with TTL. Returns false on failure. */
  redisSetEx: (key: string, value: unknown, ttlSeconds: number) => Promise<boolean>;
  /**
   * F2: atomic SET NX EX of `mcp-grant:<n>`. Returns true if the key was
   * claimed (no prior record), false if a prior record exists OR on
   * transport failure. Caller decides whether to GET-and-compare or 503.
   */
  redisSetNxEx: (key: string, value: unknown, ttlSeconds: number) => Promise<boolean>;
  /**
   * Returns Pro entitlement info or null. Includes the billing-verification
   * fields, which the gate classifies (#5622) β€” a stub omitting them would
   * type-check while only ever exercising the terminal branch.
   */
  getEntitlements: (userId: string) => Promise<ProMcpEntitlement | null>;
  /** Same allowlist DCR uses. */
  isAllowedRedirectUri: (uri: string) => boolean;
  /** Signs the wire-format grant token. Throws GrantConfigError if env unset. */
  signGrant: (payload: { userId: string; nonce: string; exp: number }) => Promise<string>;
  /** Injectable for deterministic tests. */
  now: () => number;
}

export async function mintGrantHandler(req: Request, deps: MintDeps): Promise<Response> {
  if (req.method !== 'POST') {
    return new Response(JSON.stringify({ error: 'METHOD_NOT_ALLOWED' }), {
      status: 405, headers: { ...NO_STORE_JSON, Allow: 'POST' },
    });
  }

  const userId = await deps.resolveUserId(req);
  if (!userId) {
    return jsonError('UNAUTHENTICATED', 'A valid Clerk session is required.', 401);
  }

  let body: unknown;
  try {
    body = await req.json();
  } catch {
    return jsonError('INVALID_REQUEST', 'Request body must be JSON.', 400);
  }
  const nonce = (body as { nonce?: unknown })?.nonce;
  if (typeof nonce !== 'string' || nonce.length === 0) {
    return jsonError('INVALID_REQUEST', 'Missing or empty `nonce`.', 400);
  }

  // Redis: read nonce + client. Both throws on transport failure are
  // surfaced as 503 β€” distinct from the 400 "no such key" miss.
  let nonceData: NonceData | null;
  try {
    nonceData = (await deps.redisGet(`oauth:nonce:${nonce}`)) as NonceData | null;
  } catch {
    return jsonError('SERVICE_UNAVAILABLE', 'Authorization storage is temporarily unavailable.', 503);
  }
  if (!nonceData || typeof nonceData.client_id !== 'string' || typeof nonceData.redirect_uri !== 'string') {
    return jsonError('INVALID_NONCE', 'The authorization nonce is missing or expired.', 400);
  }

  let clientData: ClientData | null;
  try {
    clientData = (await deps.redisGet(`oauth:client:${nonceData.client_id}`)) as ClientData | null;
  } catch {
    return jsonError('SERVICE_UNAVAILABLE', 'Authorization storage is temporarily unavailable.', 503);
  }
  if (!clientData) {
    return jsonError('UNKNOWN_CLIENT', 'The OAuth client is not registered.', 400);
  }

  // Defense-in-depth: re-validate the redirect_uri the nonce committed to
  // is still on the allowlist (DCR allowlist could be tightened post-
  // registration β€” fail closed).
  const uris = Array.isArray(clientData.redirect_uris) ? clientData.redirect_uris : [];
  if (!uris.includes(nonceData.redirect_uri) || !deps.isAllowedRedirectUri(nonceData.redirect_uri)) {
    return jsonError('INVALID_REDIRECT_URI', 'The redirect URI is no longer permitted.', 400);
  }

  const ent = await deps.getEntitlements(userId);
  const now = deps.now();
  // Shared with mcp-grant-context.ts and oauth/authorize-pro.ts
  // (server/_shared/pro-mcp-gate.ts): both tier >= 1 AND mcpAccess === true are
  // required β€” gating on tier alone lets a tier-1 user without mcpAccess get a
  // token row, then 401 every call β€” and an entitlement that could not be
  // VERIFIED answers the retryable 503 rather than the terminal
  // INSUFFICIENT_TIER (#5622).
  const gate = checkProMcpAccess(ent, now, {
    backendConfigured: isEntitlementBackendConfigured(),
  });
  if (gate) return proMcpGateDenialResponse(gate);

  // Mint the signed grant first (cheaper to fail before the Redis write).
  const exp = now + GRANT_TTL_MS;
  let grantToken: string;
  try {
    grantToken = await deps.signGrant({ userId, nonce, exp });
  } catch (err) {
    if (err instanceof GrantConfigError) {
      console.warn('[mcp-grant-mint] missing MCP_PRO_GRANT_HMAC_SECRET');
      return jsonError('CONFIGURATION_ERROR', 'Pro MCP authorization is misconfigured.', 500);
    }
    throw err;
  }

  // F2: claim the nonce atomically via SET NX. The FIRST mint that
  // reaches Redis writes `{userId, exp}` and wins. Subsequent mints
  // either:
  //   - same userId (multi-tab / retry on the same Clerk session) β†’
  //     idempotently re-issue the redirect with a fresh grant token
  //     pointing at the existing claim. The old grant exp is preserved
  //     so the existing record stays valid; we re-sign with a NEW exp
  //     (also +5min from now) β€” but the redis record already holds the
  //     claim's exp, so `/oauth/authorize-pro` strictly compares them
  //     (line 293) and would 401 a "different exp" mint. To keep the
  //     idempotent path working, we re-sign with the EXISTING claim's
  //     exp from Redis, not a fresh one.
  //   - different userId (attacker tries to mint for victim's nonce) β†’
  //     403 NONCE_CLAIMED_BY_OTHER_USER.
  //
  // The SET NX path makes this race-safe: two concurrent mints from
  // different users for the same nonce can't both win. The loser's GET
  // sees the winner's record and either succeeds (same userId) or 403s.
  const grantKey = `mcp-grant:${nonce}`;
  const claim = { userId, exp };
  const claimed = await deps.redisSetNxEx(grantKey, claim, GRANT_TTL_SECONDS);
  if (!claimed) {
    // Either NX collision or transport failure. Disambiguate via GET.
    let existing: { userId?: unknown; exp?: unknown } | null;
    try {
      existing = (await deps.redisGet(grantKey)) as { userId?: unknown; exp?: unknown } | null;
    } catch {
      return jsonError('SERVICE_UNAVAILABLE', 'Could not persist the authorization grant.', 503);
    }
    if (!existing || typeof existing.userId !== 'string' || typeof existing.exp !== 'number') {
      // No prior record + SET NX failed β†’ genuine transport failure.
      return jsonError('SERVICE_UNAVAILABLE', 'Could not persist the authorization grant.', 503);
    }
    if (existing.userId !== userId) {
      // F2 anti-hijack: the nonce has been claimed by a DIFFERENT Clerk
      // user. Refuse β€” the legitimate user must start a fresh OAuth
      // flow (which mints a fresh oauth:nonce + mcp-grant pair).
      return jsonError(
        'NONCE_CLAIMED_BY_OTHER_USER',
        'This authorization request has already been claimed by another account.',
        403,
      );
    }
    // Same userId β€” idempotent multi-tab retry. Re-sign the grant with
    // the EXISTING claim's exp so `/oauth/authorize-pro`'s strict tuple
    // equality on (userId, exp) succeeds against the Redis record.
    try {
      grantToken = await deps.signGrant({
        userId,
        nonce,
        exp: existing.exp as number,
      });
    } catch (err) {
      if (err instanceof GrantConfigError) {
        console.warn('[mcp-grant-mint] missing MCP_PRO_GRANT_HMAC_SECRET on idempotent re-sign');
        return jsonError('CONFIGURATION_ERROR', 'Pro MCP authorization is misconfigured.', 500);
      }
      throw err;
    }
  }

  const redirect = `${AUTHORIZE_PRO_URL}?nonce=${encodeURIComponent(nonce)}&grant=${encodeURIComponent(grantToken)}`;
  return new Response(JSON.stringify({ redirect }), { status: 200, headers: NO_STORE_JSON });
}

// ---------------------------------------------------------------------------
// Production handler β€” wires up the real deps.
// ---------------------------------------------------------------------------

export default async function handler(req: Request): Promise<Response> {
  return mintGrantHandler(req, {
    resolveUserId: async (r) => (await resolveClerkSession(r))?.userId ?? null,
    redisGet: rawRedisGet,
    redisSetEx: rawRedisSetEx,
    redisSetNxEx: rawRedisSetNxEx,
    getEntitlements: (userId) => getEntitlements(userId),
    isAllowedRedirectUri,
    signGrant: (payload) => signGrant(payload),
    now: () => Date.now(),
  });
}