| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const ent = await deps.getEntitlements(userId); |
| const gate = checkProMcpAccess(ent, deps.now(), { |
| backendConfigured: isEntitlementBackendConfigured(), |
| }); |
| if (gate) return proMcpGateDenialResponse(gate); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| 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(), |
| }); |
| } |
|
|