Skip to content

Commit 5da9220

Browse files
Add registered GitHub MCP authorization and private rate admission [skip ci]
1 parent 93d2629 commit 5da9220

8 files changed

Lines changed: 420 additions & 1 deletion

‎README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,12 @@ Redirect URI: https://link.pythonide.xin/ai-oauth/callback
137137
On iOS 16.2–17.3, the callback page forwards the query losslessly to
138138
`pythonide://oauth/custom-ai`. Keep the AI and MCP paths separate even though
139139
they intentionally share the same visual treatment.
140+
141+
142+
## GitHub MCP authorization
143+
144+
The registered [PythonIDE MCP GitHub App](https://github.com/apps/pythonide-mcp) is owned by Python-IDE. The Pages production environment holds `GITHUB_MCP_CLIENT_ID` and the encrypted `GITHUB_MCP_CLIENT_SECRET`; never commit a secret. The two broker endpoints are `/mcp-oauth/github/config` and `/mcp-oauth/github/token`. Existing static OAuth callbacks and share routes keep their original owners.
145+
146+
`edge/github-oauth.js` validates fixed code/refresh grants and the resulting GitHub identity. A private service binding named `GITHUB_MCP_RATE_LIMIT` targets `pythonide-mcp-auth-limiter`, whose configuration is `edge/wrangler.github-oauth-rate-limit.json`. That service has no public route and receives only a digest for native rate admission. The configured 120 requests/60 seconds applies approximately per Cloudflare location; it is not a strict worldwide quota. No account/content/billing database bindings are added.
147+
148+
Deploy the private limiter first, configure the Pages production service binding, then deploy this complete Pages candidate. Run `node --test edge/tests/*.test.js tests/*.test.js` before publishing. The native App is released separately.

‎edge/github-oauth-rate-limit.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Pages has no native RateLimit binding. This private service is only the
2+
// compatibility boundary to Workers' native per-location rate limiter.
3+
// It receives a digest, never an IP address, token, code or client secret.
4+
const SERVICE_URL = 'https://pythonide-mcp-auth-limiter.internal/consume';
5+
6+
function result(status) {
7+
return new Response(null, { status, headers: {
8+
'Cache-Control': 'no-store',
9+
...(status === 429 ? { 'Retry-After': '60' } : {}),
10+
} });
11+
}
12+
13+
async function checkGitHubOAuthRateLimit(request, binding) {
14+
if (!binding?.fetch) return false;
15+
const ip = request.headers.get('CF-Connecting-IP');
16+
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) return false;
17+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`pythonide-github:${ip}`));
18+
const key = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('');
19+
const response = await binding.fetch(new Request(SERVICE_URL, {
20+
method: 'POST', headers: { 'X-PythonIDE-Limit-Key': `github-mcp:${key}` }, redirect: 'error',
21+
}));
22+
if (response.status === 429) return 'limited';
23+
return response.status === 204;
24+
}
25+
26+
async function handleGitHubOAuthRateLimit(request, env) {
27+
if (request.url !== SERVICE_URL || request.method !== 'POST') return result(404);
28+
const key = request.headers.get('X-PythonIDE-Limit-Key') || '';
29+
if (!/^github-mcp:[0-9a-f]{64}$/.test(key)) return result(400);
30+
if (!env?.GITHUB_MCP_AUTH_LIMIT?.limit) return result(503);
31+
try {
32+
const { success } = await env.GITHUB_MCP_AUTH_LIMIT.limit({ key });
33+
return result(success ? 204 : 429);
34+
} catch { return result(503); }
35+
}
36+
37+
export { checkGitHubOAuthRateLimit, handleGitHubOAuthRateLimit };
38+
export default { fetch: handleGitHubOAuthRateLimit };

‎edge/github-oauth.js‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 };
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { checkGitHubOAuthRateLimit, handleGitHubOAuthRateLimit } from '../github-oauth-rate-limit.js';
4+
5+
const serviceURL = 'https://pythonide-mcp-auth-limiter.internal/consume';
6+
const key = `github-mcp:${'a'.repeat(64)}`;
7+
8+
test('private limiter receives only an IP digest, with no credentials or body', async () => {
9+
let captured;
10+
const decision = await checkGitHubOAuthRateLimit(new Request('https://link.pythonide.xin/mcp-oauth/github/token', {
11+
method: 'POST', headers: { 'CF-Connecting-IP': '192.0.2.10', Authorization: 'must-not-forward' }, body: 'must-not-forward',
12+
}), { fetch: async (request) => { captured = request; return new Response(null, { status: 204 }); } });
13+
assert.equal(decision, true);
14+
assert.equal(captured.url, serviceURL);
15+
assert.match(captured.headers.get('X-PythonIDE-Limit-Key'), /^github-mcp:[0-9a-f]{64}$/);
16+
assert.equal(captured.headers.has('Authorization'), false);
17+
assert.equal(captured.headers.has('CF-Connecting-IP'), false);
18+
assert.equal(await captured.text(), '');
19+
});
20+
21+
test('limiter rejects unrelated routes, invalid keys, missing bindings and provider failures', async () => {
22+
const request = (url = serviceURL, suppliedKey = key) => new Request(url, { method: 'POST', headers: { 'X-PythonIDE-Limit-Key': suppliedKey } });
23+
assert.equal((await handleGitHubOAuthRateLimit(request('https://evil.example/consume'), {})).status, 404);
24+
assert.equal((await handleGitHubOAuthRateLimit(request(serviceURL, 'raw-ip'), {})).status, 400);
25+
assert.equal((await handleGitHubOAuthRateLimit(request(), {})).status, 503);
26+
for (const success of [true, false]) {
27+
const response = await handleGitHubOAuthRateLimit(request(), { GITHUB_MCP_AUTH_LIMIT: {
28+
limit: async (input) => { assert.deepEqual(input, { key }); return { success }; },
29+
} });
30+
assert.equal(response.status, success ? 204 : 429);
31+
if (!success) assert.equal(response.headers.get('Retry-After'), '60');
32+
}
33+
assert.equal((await handleGitHubOAuthRateLimit(request(), { GITHUB_MCP_AUTH_LIMIT: {
34+
limit: async () => { throw new Error('no details should escape'); },
35+
} })).status, 503);
36+
});

0 commit comments

Comments
 (0)