File size: 8,099 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
/**
 * GET /api/internal/mcp-grant-context?nonce=<n>
 *
 * Read-only companion to `api/internal/mcp-grant-mint.ts` (U3). The apex
 * `/mcp-grant` SPA calls this on mount to render the REAL `client_name`
 * + `redirect_host` extracted from the registered OAuth client metadata,
 * so the user can spot consent-phishing attempts (a malicious client
 * cannot lie about its display name to a Pro user).
 *
 * Validation matches the mint endpoint exactly — a tier-0 caller MUST
 * not receive client metadata they could exfiltrate, and a missing
 * nonce MUST return the same INVALID_NONCE shape the mint emits. This
 * keeps the SPA's error handling on a single canonical contract.
 *
 * Errors:
 *   - UNAUTHENTICATED              401  no/invalid Clerk JWT
 *   - INVALID_REQUEST              400  missing nonce
 *   - INVALID_NONCE                400  Redis nonce miss / expired
 *   - UNKNOWN_CLIENT               400  Redis client miss
 *   - INSUFFICIENT_TIER            403  user tier < 1, no mcpAccess, expired, or
 *                                       a provider-CONFIRMED lapse (which also
 *                                       sets X-Billing-Verification). Do NOT
 *                                       leak client_name to non-Pro callers.
 *   - NONCE_CLAIMED_BY_OTHER_USER  403  the nonce has been claimed by a
 *                                       different Clerk userId (F2 — the
 *                                       apex page must NOT render context
 *                                       for a hijacked nonce).
 *   - TIER_VERIFICATION_UNAVAILABLE 503 entitlement unverifiable / renewal
 *                                       re-check in flight. Retryable, with
 *                                       Retry-After + X-Billing-Verification
 *                                       (#5622). Leaks nothing either.
 *   - SERVICE_UNAVAILABLE          503  Redis transport failure
 *
 * Cache-Control: no-store on every path.
 */

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';

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;
}

interface ClientData {
  client_name?: string;
}

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; }
}

export interface ContextDeps {
  resolveUserId: (req: Request) => Promise<string | null>;
  redisGet: (key: string) => Promise<unknown | null>;
  getEntitlements: (userId: string) => Promise<ProMcpEntitlement | null>;
  now: () => number;
}

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

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

  const url = new URL(req.url);
  const nonce = url.searchParams.get('nonce') ?? '';
  if (!nonce) {
    return jsonError('INVALID_REQUEST', 'Missing `nonce` query parameter.', 400);
  }

  // Pro gate BEFORE leaking any client_name — a tier-0 caller probing the
  // endpoint must not see whether a given nonce/client exists.
  //
  // The decision is shared with mcp-grant-mint.ts and oauth/authorize-pro.ts
  // (server/_shared/pro-mcp-gate.ts) so the gates cannot drift: both tier >= 1
  // AND mcpAccess === true are required — mirroring the downstream MCP-edge gate
  // (api/mcp.ts runProPreChecks), because gating on tier alone lets a tier-1
  // user without mcpAccess complete OAuth, get a tokenId, then have every
  // tools/call fail at the gateway — and an entitlement that could not be
  // VERIFIED answers a retryable 503 instead of the terminal INSUFFICIENT_TIER
  // (#5622). Both paths still reveal nothing about the nonce or client.
  const ent = await deps.getEntitlements(userId);
  const gate = checkProMcpAccess(ent, deps.now(), {
    backendConfigured: isEntitlementBackendConfigured(),
  });
  if (gate) return proMcpGateDenialResponse(gate);

  // F2 (U7+U8 review pass): if `mcp-grant:<n>` exists with a userId that
  // doesn't match the Clerk session's userId, the nonce has been claimed
  // by another user. The apex SPA MUST refuse to render context for a
  // hijacked nonce — otherwise the SPA would happily display the
  // attacker-mintable client_name and let the victim "Approve". We
  // surface 403 NONCE_CLAIMED_BY_OTHER_USER so the SPA can show a clear
  // anti-hijack message rather than the normal consent UI. Absence of
  // the record (no claim yet) is acceptable — the victim's page renders
  // the consent UI normally and the FIRST mint (theirs) will claim the
  // nonce.
  let claim: { userId?: unknown } | null;
  try {
    claim = (await deps.redisGet(`mcp-grant:${nonce}`)) as { userId?: unknown } | null;
  } catch {
    return jsonError('SERVICE_UNAVAILABLE', 'Authorization storage is temporarily unavailable.', 503);
  }
  if (claim && typeof claim.userId === 'string' && claim.userId !== userId) {
    return jsonError(
      'NONCE_CLAIMED_BY_OTHER_USER',
      'This authorization request has already been claimed by another account.',
      403,
    );
  }

  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);
  }

  let redirectHost = '';
  try {
    redirectHost = new URL(nonceData.redirect_uri).hostname;
  } catch {
    // Already validated at registration time; defense-in-depth catch.
    return jsonError('INVALID_REDIRECT_URI', 'The registered redirect URI is malformed.', 400);
  }

  const client_name = typeof clientData.client_name === 'string' && clientData.client_name.length > 0
    ? clientData.client_name
    : 'Unknown Client';

  return new Response(JSON.stringify({ client_name, redirect_host: redirectHost }), {
    status: 200, headers: NO_STORE_JSON,
  });
}

export default async function handler(req: Request): Promise<Response> {
  return grantContextHandler(req, {
    resolveUserId: async (r) => (await resolveClerkSession(r))?.userId ?? null,
    redisGet: rawRedisGet,
    getEntitlements: (userId) => getEntitlements(userId),
    now: () => Date.now(),
  });
}