File size: 4,701 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
// @ts-expect-error — JS module, no declaration file
import { getPublicCorsHeaders } from '../_cors.js';
// @ts-expect-error — JS module, no declaration file
import { getClientIp } from '../_rate-limit.js';
// @ts-expect-error — JS module, no declaration file
import { jsonResponse } from '../_json-response.js';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

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

const CLIENT_TTL_SECONDS = 90 * 24 * 3600; // 90 days sliding

// Allowlisted redirect URI prefixes — DCR is not open to arbitrary HTTPS URIs
const ALLOWED_REDIRECT_PREFIXES = [
  'https://claude.ai/api/mcp/auth_callback',
  'https://claude.com/api/mcp/auth_callback',
];

// Exported so `api/internal/mcp-grant-mint.ts` (U3) can re-validate the
// registered client's redirect URIs as a defense-in-depth check before
// minting a Pro-MCP grant. Re-uses the SAME allowlist that DCR enforces
// at registration time — no parallel implementation drift.
export function isAllowedRedirectUri(uri) {
  if (ALLOWED_REDIRECT_PREFIXES.includes(uri)) return true;
  // localhost / 127.0.0.1 any port (Claude Code, MCP inspector)
  try {
    const u = new URL(uri);
    return (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && u.protocol === 'http:';
  } catch { return false; }
}

function jsonResp(body, status = 200) {
  return jsonResponse(body, status, getPublicCorsHeaders('POST, OPTIONS'));
}

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(5, '60 s'),
    prefix: 'rl:oauth-register',
    analytics: false,
  });
  return _rl;
}

async function storeClient(clientId, metadata) {
  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', `oauth:client:${clientId}`, JSON.stringify(metadata), 'EX', CLIENT_TTL_SECONDS],
      ]),
      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; }
}

export default async function handler(req) {
  const corsHeaders = getPublicCorsHeaders('POST, OPTIONS');

  if (req.method === 'OPTIONS') {
    return new Response(null, { status: 204, headers: corsHeaders });
  }
  if (req.method !== 'POST') {
    return jsonResp({ error: 'method_not_allowed' }, 405);
  }

  const rl = getRatelimit();
  if (rl) {
    try {
      const { success } = await rl.limit(`ip:${getClientIp(req)}`);
      if (!success) {
        return jsonResp({ error: 'rate_limit_exceeded', error_description: 'Too many registration requests.' }, 429);
      }
    } catch { /* graceful degradation */ }
  }

  let body;
  try {
    body = await req.json();
  } catch {
    return jsonResp({ error: 'invalid_request', error_description: 'Invalid JSON body' }, 400);
  }

  const { client_name, redirect_uris } = body ?? {};

  if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
    return jsonResp({ error: 'invalid_request', error_description: 'redirect_uris is required' }, 400);
  }
  if (redirect_uris.length > 3) {
    return jsonResp({ error: 'invalid_request', error_description: 'Maximum 3 redirect_uris allowed' }, 400);
  }
  for (const uri of redirect_uris) {
    if (typeof uri !== 'string' || !isAllowedRedirectUri(uri)) {
      return jsonResp({
        error: 'invalid_redirect_uri',
        error_description: `Redirect URI not allowed: ${uri}. Allowed: claude.ai/claude.com callbacks and localhost.`,
      }, 400);
    }
  }

  const clientId = crypto.randomUUID();
  const metadata = {
    client_name: typeof client_name === 'string' ? client_name.slice(0, 100) : 'Unknown Client',
    redirect_uris,
    created_at: Date.now(),
  };

  const stored = await storeClient(clientId, metadata);
  if (!stored) {
    return jsonResp({ error: 'server_error', error_description: 'Client registration storage failed' }, 500);
  }

  return jsonResp({
    client_id: clientId,
    client_name: metadata.client_name,
    redirect_uris,
    grant_types: ['authorization_code', 'refresh_token'],
    response_types: ['code'],
    token_endpoint_auth_method: 'none',
  }, 201);
}