|
| 1 | +import { checkGitHubOAuthRateLimit } from './github-oauth-rate-limit.js'; |
| 2 | + |
| 3 | +// Dedicated GitHub App grant broker. No storage bindings, community sessions, |
| 4 | +// user content, billing permissions, or token/body logging are used here. |
| 5 | +const ORIGIN = 'https://link.pythonide.xin'; |
| 6 | +const PREFIX = '/mcp-oauth/github'; |
| 7 | +const CALLBACK = `${ORIGIN}/mcp-oauth/callback`; |
| 8 | +const TOKEN_ENDPOINT = 'https://github.com/login/oauth/access_token'; |
| 9 | +const USER_ENDPOINT = 'https://api.github.com/user'; |
| 10 | +const MAX_BYTES = 16_384; |
| 11 | + |
| 12 | +function jsonResponse(status, body, extraHeaders = {}) { |
| 13 | + return new Response(JSON.stringify(body), { |
| 14 | + status, |
| 15 | + headers: { |
| 16 | + 'Content-Type': 'application/json; charset=utf-8', |
| 17 | + 'Cache-Control': 'no-store', |
| 18 | + Pragma: 'no-cache', |
| 19 | + 'Referrer-Policy': 'no-referrer', |
| 20 | + 'X-Content-Type-Options': 'nosniff', |
| 21 | + ...extraHeaders, |
| 22 | + }, |
| 23 | + }); |
| 24 | +} |
| 25 | + |
| 26 | +async function readJSON(message, limit) { |
| 27 | + const length = message.headers.get('content-length'); |
| 28 | + if (length !== null && (!/^\d+$/.test(length) || Number(length) > limit)) throw new Error('invalid_body'); |
| 29 | + if (!message.body) throw new Error('invalid_body'); |
| 30 | + const reader = message.body.getReader(); |
| 31 | + const chunks = []; |
| 32 | + let size = 0; |
| 33 | + try { |
| 34 | + while (true) { |
| 35 | + const { value, done } = await reader.read(); |
| 36 | + if (done) break; |
| 37 | + size += value.byteLength; |
| 38 | + if (size > limit) throw new Error('invalid_body'); |
| 39 | + chunks.push(value); |
| 40 | + } |
| 41 | + } finally { |
| 42 | + await reader.cancel().catch(() => {}); |
| 43 | + } |
| 44 | + const bytes = new Uint8Array(size); |
| 45 | + let offset = 0; |
| 46 | + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } |
| 47 | + const result = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); |
| 48 | + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('invalid_body'); |
| 49 | + return result; |
| 50 | +} |
| 51 | + |
| 52 | +function credential(value) { |
| 53 | + return typeof value === 'string' && /^[\x21-\x7e]{1,4096}$/.test(value); |
| 54 | +} |
| 55 | + |
| 56 | +function lifetime(value) { |
| 57 | + return value === undefined || (Number.isFinite(value) && value > 0 && value <= 31_536_000); |
| 58 | +} |
| 59 | + |
| 60 | +async function upstreamJSON(fetcher, url, options) { |
| 61 | + const controller = new AbortController(); |
| 62 | + const timer = setTimeout(() => controller.abort(), 10_000); |
| 63 | + try { |
| 64 | + const response = await fetcher(url, { ...options, redirect: 'error', signal: controller.signal }); |
| 65 | + if (response.status >= 300 && response.status < 400) throw new Error('upstream_redirect'); |
| 66 | + const body = await readJSON(response, MAX_BYTES); |
| 67 | + return { status: response.status, body }; |
| 68 | + } finally { clearTimeout(timer); } |
| 69 | +} |
| 70 | + |
| 71 | +async function handleGitHubOAuth(request, env, fetcher = fetch) { |
| 72 | + const url = new URL(request.url); |
| 73 | + if (url.origin !== ORIGIN || url.search || ![`${PREFIX}/config`, `${PREFIX}/token`].includes(url.pathname)) { |
| 74 | + return jsonResponse(404, { error: 'not_found' }); |
| 75 | + } |
| 76 | + const isConfig = url.pathname.endsWith('/config'); |
| 77 | + if (request.method !== (isConfig ? 'GET' : 'POST')) { |
| 78 | + return jsonResponse(405, { error: 'method_not_allowed' }, { Allow: isConfig ? 'GET' : 'POST' }); |
| 79 | + } |
| 80 | + const clientID = env?.GITHUB_MCP_CLIENT_ID; |
| 81 | + const clientSecret = env?.GITHUB_MCP_CLIENT_SECRET; |
| 82 | + if (typeof clientID !== 'string' || !/^[A-Za-z0-9_]{1,128}$/.test(clientID) |
| 83 | + || !credential(clientSecret) || !env?.GITHUB_MCP_RATE_LIMIT?.fetch) { |
| 84 | + return jsonResponse(503, { error: 'oauth_not_configured' }); |
| 85 | + } |
| 86 | + if (isConfig) return jsonResponse(200, { schema_version: 1, client_id: clientID }); |
| 87 | + if (!/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') || '')) { |
| 88 | + return jsonResponse(415, { error: 'invalid_request' }); |
| 89 | + } |
| 90 | + let body; |
| 91 | + try { body = await readJSON(request, 8_192); } |
| 92 | + catch { return jsonResponse(400, { error: 'invalid_request' }); } |
| 93 | + const isCode = body.grant_type === 'authorization_code'; |
| 94 | + const isRefresh = body.grant_type === 'refresh_token'; |
| 95 | + const allowed = new Set(isCode |
| 96 | + ? ['grant_type', 'client_id', 'code', 'code_verifier'] |
| 97 | + : ['grant_type', 'client_id', 'refresh_token']); |
| 98 | + if ((!isCode && !isRefresh) || body.client_id !== clientID |
| 99 | + || Object.keys(body).some((key) => !allowed.has(key)) |
| 100 | + || (isCode && (!credential(body.code) || body.code.length > 1_024 |
| 101 | + || typeof body.code_verifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(body.code_verifier))) |
| 102 | + || (isRefresh && !credential(body.refresh_token))) { |
| 103 | + return jsonResponse(400, { error: 'invalid_request' }); |
| 104 | + } |
| 105 | + try { |
| 106 | + const admission = await checkGitHubOAuthRateLimit(request, env.GITHUB_MCP_RATE_LIMIT); |
| 107 | + if (admission === 'limited') return jsonResponse(429, { error: 'rate_limited' }, { 'Retry-After': '60' }); |
| 108 | + if (admission !== true) return jsonResponse(503, { error: 'authorization_unavailable' }); |
| 109 | + } catch { return jsonResponse(503, { error: 'authorization_unavailable' }); } |
| 110 | + const parameters = new URLSearchParams({ client_id: clientID, client_secret: clientSecret }); |
| 111 | + if (isCode) { |
| 112 | + parameters.set('code', body.code); |
| 113 | + parameters.set('code_verifier', body.code_verifier); |
| 114 | + parameters.set('redirect_uri', CALLBACK); |
| 115 | + } else { |
| 116 | + parameters.set('grant_type', 'refresh_token'); |
| 117 | + parameters.set('refresh_token', body.refresh_token); |
| 118 | + } |
| 119 | + try { |
| 120 | + const result = await upstreamJSON(fetcher, TOKEN_ENDPOINT, { |
| 121 | + method: 'POST', |
| 122 | + headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 123 | + body: parameters.toString(), |
| 124 | + }); |
| 125 | + if (['bad_verification_code', 'bad_refresh_token', 'expired_refresh_token', 'invalid_grant'].includes(result.body.error)) { |
| 126 | + return jsonResponse(400, { error: 'reauthentication_required' }); |
| 127 | + } |
| 128 | + const token = result.body; |
| 129 | + if (result.status !== 200 || token.error || !credential(token.access_token) |
| 130 | + || String(token.token_type).toLowerCase() !== 'bearer' |
| 131 | + || !lifetime(token.expires_in) |
| 132 | + || (token.refresh_token !== undefined && !credential(token.refresh_token))) { |
| 133 | + return jsonResponse(502, { error: 'token_exchange_failed' }); |
| 134 | + } |
| 135 | + // Validate the actual token's identity on every grant. Never trust an |
| 136 | + // account name/ID supplied by the caller or echoed through the callback. |
| 137 | + const identity = await upstreamJSON(fetcher, USER_ENDPOINT, { |
| 138 | + headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token.access_token}`, |
| 139 | + 'User-Agent': 'PythonIDE-MCP-OAuth' }, |
| 140 | + }); |
| 141 | + const accountID = String(identity.body.id); |
| 142 | + if (identity.status !== 200 || !/^[1-9][0-9]{0,19}$/.test(accountID) |
| 143 | + || (typeof identity.body.id === 'number' && !Number.isSafeInteger(identity.body.id))) { |
| 144 | + return jsonResponse(502, { error: 'identity_validation_failed' }); |
| 145 | + } |
| 146 | + return jsonResponse(200, { |
| 147 | + client_id: clientID, |
| 148 | + access_token: token.access_token, |
| 149 | + token_type: 'bearer', |
| 150 | + ...(token.expires_in !== undefined ? { expires_in: token.expires_in } : {}), |
| 151 | + ...(token.refresh_token !== undefined ? { refresh_token: token.refresh_token } : {}), |
| 152 | + account_id: accountID, |
| 153 | + }); |
| 154 | + } catch { |
| 155 | + // Codes and rotating refresh tokens are never retried after an ambiguous |
| 156 | + // upstream outcome. The client recovers with a fresh authorization. |
| 157 | + return jsonResponse(502, { error: 'token_exchange_failed' }); |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +export { handleGitHubOAuth }; |
0 commit comments