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