Skip to content

Commit 2bc9cbe

Browse files
Fix GitHub authorization redirect handling in workerd [skip ci]
1 parent 5da9220 commit 2bc9cbe

6 files changed

Lines changed: 95 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,14 @@ The registered [PythonIDE MCP GitHub App](https://github.com/apps/pythonide-mcp)
146146
`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.
147147

148148
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.
149+
150+
151+
### Workers runtime gate
152+
153+
Node tests alone do not verify workerd compatibility. Run the mandatory isolated runtime check with the Miniflare shipped by pinned Wrangler 4.127.1 (Miniflare 5.20260828.0-alpha):
154+
155+
```sh
156+
PYTHONIDE_MINIFLARE_MODULE=/path/to/wrangler/node_modules/miniflare node --test edge/runtime-tests/github-oauth-workerd.test.js
157+
```
158+
159+
This executes the production broker and private limiter modules in workerd, uses fake GitHub responses with no external network calls, verifies redirect rejection, and checks actual native rate admission. The gate never silently skips. Workers uses `redirect: "manual"` plus an explicit 3xx rejection; `redirect: "error"` is not supported by workerd.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async function checkGitHubOAuthRateLimit(request, binding) {
1717
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`pythonide-github:${ip}`));
1818
const key = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('');
1919
const response = await binding.fetch(new Request(SERVICE_URL, {
20-
method: 'POST', headers: { 'X-PythonIDE-Limit-Key': `github-mcp:${key}` }, redirect: 'error',
20+
method: 'POST', headers: { 'X-PythonIDE-Limit-Key': `github-mcp:${key}` }, redirect: 'manual',
2121
}));
2222
if (response.status === 429) return 'limited';
2323
return response.status === 204;

‎edge/github-oauth.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ async function upstreamJSON(fetcher, url, options) {
6161
const controller = new AbortController();
6262
const timer = setTimeout(() => controller.abort(), 10_000);
6363
try {
64-
const response = await fetcher(url, { ...options, redirect: 'error', signal: controller.signal });
64+
// Workers rejects redirect:"error" during Request construction. Manual
65+
// mode plus an explicit 3xx rejection preserves the same security policy.
66+
const response = await fetcher(url, { ...options, redirect: 'manual', signal: controller.signal });
6567
if (response.status >= 300 && response.status < 400) throw new Error('upstream_redirect');
6668
const body = await readJSON(response, MAX_BYTES);
6769
return { status: response.status, body };
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { createRequire } from 'node:module';
4+
import { fileURLToPath } from 'node:url';
5+
import path from 'node:path';
6+
7+
// Run with Wrangler's bundled Miniflare, or an installed compatible Miniflare.
8+
// Never skip: this gate must execute in workerd, not Node's fetch polyfill.
9+
const require = createRequire(import.meta.url);
10+
const { Miniflare, convertV4MiniflareOptions } = require(process.env.PYTHONIDE_MINIFLARE_MODULE || 'miniflare');
11+
const runtime = (options) => new Miniflare(convertV4MiniflareOptions(options));
12+
const root = process.env.PYTHONIDE_WORKERD_SOURCE_ROOT
13+
|| fileURLToPath(new URL('../', import.meta.url));
14+
const modules = ['worker.js', 'github-oauth.js', 'github-oauth-rate-limit.js']
15+
.map((name) => ({ type: 'ESModule', path: path.join(root, name) }));
16+
const origin = 'https://link.pythonide.xin';
17+
const headers = { 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.25' };
18+
const grant = { grant_type: 'authorization_code', client_id: 'Iv1_fixture',
19+
code: 'fixture-code', code_verifier: 'v'.repeat(43) };
20+
21+
test('real workerd crosses the private binding and rejects upstream redirects', { timeout: 30_000 }, async () => {
22+
let mode = 'success';
23+
const calls = [];
24+
const mf = runtime({
25+
workers: [
26+
{ name: 'broker', modules, modulesRoot: root, compatibilityDate: '2026-08-16',
27+
bindings: { GITHUB_MCP_CLIENT_ID: 'Iv1_fixture', GITHUB_MCP_CLIENT_SECRET: 'fixture-secret' },
28+
serviceBindings: { GITHUB_MCP_RATE_LIMIT: 'limiter' },
29+
outboundService: async (request) => {
30+
calls.push(request.url);
31+
if (request.url === 'https://github.com/login/oauth/access_token') {
32+
const body = new URLSearchParams(await request.text());
33+
assert.equal(body.get('client_secret'), 'fixture-secret');
34+
if (mode === 'redirect') return new Response(null, { status: 302, headers: { Location: 'https://evil.example/token' } });
35+
if (mode === 'invalid_code') return Response.json({ error: 'bad_verification_code' });
36+
return Response.json({ access_token: 'ghu_fixture', token_type: 'bearer', expires_in: 28_800, refresh_token: 'ghr_fixture' });
37+
}
38+
assert.equal(request.url, 'https://api.github.com/user');
39+
assert.equal(request.headers.get('Authorization'), 'Bearer ghu_fixture');
40+
return Response.json({ id: 1234 });
41+
} },
42+
{ name: 'limiter', modules: [modules[2]], modulesRoot: root, compatibilityDate: '2026-08-16',
43+
ratelimits: { GITHUB_MCP_AUTH_LIMIT: { namespace_id: '1', simple: { limit: 120, period: 60 } } } },
44+
],
45+
});
46+
try {
47+
const response = await mf.dispatchFetch(`${origin}/mcp-oauth/github/token`, {
48+
method: 'POST', headers, body: JSON.stringify(grant),
49+
});
50+
assert.equal(response.status, 200, await response.clone().text());
51+
assert.equal((await response.json()).account_id, '1234');
52+
assert.deepEqual(calls, ['https://github.com/login/oauth/access_token', 'https://api.github.com/user']);
53+
for (const nextMode of ['invalid_code', 'redirect']) {
54+
mode = nextMode;
55+
calls.length = 0;
56+
const refused = await mf.dispatchFetch(`${origin}/mcp-oauth/github/token`, {
57+
method: 'POST', headers, body: JSON.stringify(grant),
58+
});
59+
assert.equal(refused.status, nextMode === 'redirect' ? 502 : 400);
60+
assert.deepEqual(calls, ['https://github.com/login/oauth/access_token']);
61+
}
62+
} finally { await mf.dispose(); }
63+
});
64+
65+
test('real workerd enforces native admission before allowing further grants', { timeout: 30_000 }, async () => {
66+
const mf = runtime({ modules: [modules[2]], modulesRoot: root, compatibilityDate: '2026-08-16',
67+
ratelimits: { GITHUB_MCP_AUTH_LIMIT: { namespace_id: '2', simple: { limit: 2, period: 60 } } } });
68+
try {
69+
const statuses = [];
70+
for (let index = 0; index < 3; index += 1) {
71+
const response = await mf.dispatchFetch('https://pythonide-mcp-auth-limiter.internal/consume', {
72+
method: 'POST', headers: { 'X-PythonIDE-Limit-Key': `github-mcp:${'a'.repeat(64)}` },
73+
});
74+
statuses.push(response.status);
75+
}
76+
assert.deepEqual(statuses, [204, 204, 429]);
77+
} finally { await mf.dispose(); }
78+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ test('private limiter receives only an IP digest, with no credentials or body',
1212
}), { fetch: async (request) => { captured = request; return new Response(null, { status: 204 }); } });
1313
assert.equal(decision, true);
1414
assert.equal(captured.url, serviceURL);
15+
assert.equal(captured.redirect, 'manual');
1516
assert.match(captured.headers.get('X-PythonIDE-Limit-Key'), /^github-mcp:[0-9a-f]{64}$/);
1617
assert.equal(captured.headers.has('Authorization'), false);
1718
assert.equal(captured.headers.has('CF-Connecting-IP'), false);

‎edge/tests/github-oauth.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ test('code grant pins client, callback, PKCE, upstream and validates actual acco
3333
const calls = [];
3434
const fetcher = async (url, options) => {
3535
calls.push({ url, options });
36-
assert.equal(options.redirect, 'error');
36+
assert.equal(options.redirect, 'manual');
3737
if (calls.length === 1) {
3838
assert.equal(url, 'https://github.com/login/oauth/access_token');
3939
assert.equal(options.method, 'POST');

0 commit comments

Comments
 (0)