excalidrop 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +1 -1
- package/worker/src/index.js +11 -18
- package/worker/src/mcp.js +25 -5
- package/worker/src/oauth.js +218 -0
- package/worker/test/oauth.test.mjs +146 -0
- package/worker/wrangler.toml +7 -0
package/README.md
CHANGED
|
@@ -324,6 +324,16 @@ Notes:
|
|
|
324
324
|
- OAuth discovery for clients: `https://excalidrop.wtf403.workers.dev/.well-known/oauth-authorization-server`.
|
|
325
325
|
- Browser login is bounce-free: hosts outside the OAuth callback list redirect through the central login host straight to GitHub (target allowlist-checked, no confirm modal).
|
|
326
326
|
|
|
327
|
+
### One-click OAuth for chat clients (DCR)
|
|
328
|
+
|
|
329
|
+
The worker is its own OAuth Authorization Server (spec `2026-07-28`, RFC 7591 + PKCE):
|
|
330
|
+
|
|
331
|
+
- `POST /register` mints a client (`excc_*`/`excs_*`); `GET /authorize` bounces to GitHub; `/oauth/callback` swaps the code server-side (app secret never leaves the worker); `POST /token` returns the GitHub user token as `access_token`.
|
|
332
|
+
- `GET /.well-known/oauth-protected-resource` + `WWW-Authenticate` challenge on `/mcp` 401s drive automatic discovery.
|
|
333
|
+
- Contract tests: `node worker/test/oauth.test.mjs` (30 checks, stubbed KV + GitHub).
|
|
334
|
+
- The demo OAuth App is third-party: for full self-ownership, register your own GitHub OAuth App with callback `https://<your-worker>/oauth/callback`, then set `MCP_GITHUB_CLIENT_ID` (wrangler.toml) + `wrangler secret put MCP_GITHUB_CLIENT_SECRET` and redeploy. Until then the shared id works for login but chat-client flows stop at GitHub's "Invalid Redirect URI" page.
|
|
335
|
+
- Org repos with SAML SSO: authorize the OAuth App under the org's SSO settings, or API calls 404 despite a valid token.
|
|
336
|
+
|
|
327
337
|
## Testing
|
|
328
338
|
|
|
329
339
|
### Status
|
package/package.json
CHANGED
package/worker/src/index.js
CHANGED
|
@@ -43,22 +43,7 @@ function originAllowed(origin, allowlist) {
|
|
|
43
43
|
|
|
44
44
|
export { RelayRoom } from './relay.js';
|
|
45
45
|
import { handleMcp } from './mcp.js';
|
|
46
|
-
|
|
47
|
-
// Client metadata (CIMD-style discovery) so chat clients can discover the
|
|
48
|
-
// GitHub OAuth endpoints for this MCP server without Dynamic Client
|
|
49
|
-
// Registration. The worker itself never mints tokens — GitHub does.
|
|
50
|
-
function oauthMetadata(host) {
|
|
51
|
-
return {
|
|
52
|
-
issuer: 'https://github.com',
|
|
53
|
-
authorization_endpoint: 'https://github.com/login/oauth/authorize',
|
|
54
|
-
token_endpoint: 'https://github.com/login/oauth/access_token',
|
|
55
|
-
response_types_supported: ['code'],
|
|
56
|
-
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
57
|
-
scopes_supported: ['repo', 'read:user'],
|
|
58
|
-
mcp_endpoint: `https://${host}/mcp`,
|
|
59
|
-
mcp_protocol_version: '2026-07-28',
|
|
60
|
-
};
|
|
61
|
-
}
|
|
46
|
+
import { handleOAuth } from './oauth.js';
|
|
62
47
|
|
|
63
48
|
export default {
|
|
64
49
|
async fetch(req, env) {
|
|
@@ -85,8 +70,16 @@ export default {
|
|
|
85
70
|
if (url.pathname === '/mcp' || url.pathname === '/mcp/') {
|
|
86
71
|
return handleMcp(req, env, cors);
|
|
87
72
|
}
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
// OAuth Authorization Server (DCR + authorize/callback/token) and
|
|
74
|
+
// RFC 9728 resource discovery. Replaces the old GitHub-pointing metadata
|
|
75
|
+
// doc, which could never complete an automated flow (no DCR on github.com).
|
|
76
|
+
if (
|
|
77
|
+
url.pathname === '/register' || url.pathname === '/authorize' ||
|
|
78
|
+
url.pathname === '/oauth/callback' || url.pathname === '/token' ||
|
|
79
|
+
url.pathname === '/.well-known/oauth-authorization-server' ||
|
|
80
|
+
url.pathname === '/.well-known/oauth-protected-resource'
|
|
81
|
+
) {
|
|
82
|
+
return handleOAuth(req, env, cors);
|
|
90
83
|
}
|
|
91
84
|
if (url.pathname === '/relay' || url.pathname.startsWith('/rpc/')) {
|
|
92
85
|
if (!env.RELAY) return json({ error: 'relay not configured (missing durable object binding)' }, 500, cors);
|
package/worker/src/mcp.js
CHANGED
|
@@ -13,6 +13,19 @@ const CANVAS_BRANCH = 'excalidrop';
|
|
|
13
13
|
const GH_API = 'https://api.github.com';
|
|
14
14
|
const TOOL_LIST_TTL_MS = 3600000;
|
|
15
15
|
|
|
16
|
+
// --- base64 helpers (Workers have no Node Buffer)
|
|
17
|
+
function b64encode(str) {
|
|
18
|
+
const bytes = new TextEncoder().encode(str);
|
|
19
|
+
let bin = '';
|
|
20
|
+
const CH = 0x8000;
|
|
21
|
+
for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode(...bytes.subarray(i, i + CH));
|
|
22
|
+
return btoa(bin);
|
|
23
|
+
}
|
|
24
|
+
function b64decode(b64) {
|
|
25
|
+
const bin = atob(String(b64).replace(/\n/g, ''));
|
|
26
|
+
return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
// --- best-effort quotas (per isolate; resets on evict — honest 429 either way)
|
|
17
30
|
const DAY = () => new Date().toISOString().slice(0, 10);
|
|
18
31
|
let dailyCount = 0;
|
|
@@ -90,10 +103,10 @@ async function getScene(repo, token) {
|
|
|
90
103
|
b64 = (await blob.json()).content || null;
|
|
91
104
|
}
|
|
92
105
|
if (!b64) throw new Error('scene returned no content');
|
|
93
|
-
return { doc: JSON.parse(
|
|
106
|
+
return { doc: JSON.parse(b64decode(b64)), sha: j.sha };
|
|
94
107
|
}
|
|
95
108
|
async function putScene(repo, token, doc, message, sha) {
|
|
96
|
-
const body = { message, content:
|
|
109
|
+
const body = { message, content: b64encode(JSON.stringify(doc, null, 2)), branch: CANVAS_BRANCH };
|
|
97
110
|
if (sha) body.sha = sha;
|
|
98
111
|
const r = await fetch(`${GH_API}/repos/${repo}/contents/${SCENE_PATH}`, { method: 'PUT', headers: ghHeaders(token), body: JSON.stringify(body) });
|
|
99
112
|
if (r.status === 404) throw new Error(`branch ${CANVAS_BRANCH} missing — open the canvas viewer once to initialize it`);
|
|
@@ -483,7 +496,7 @@ async function callTool(env, name, args, token) {
|
|
|
483
496
|
const elements = doc?.elements || [];
|
|
484
497
|
const r = await fetch(`${GH_API}/repos/${repo}/contents/snapshots/${a.name}.json`, {
|
|
485
498
|
method: 'PUT', headers: ghHeaders(token),
|
|
486
|
-
body: JSON.stringify({ message: `excalidrop: snapshot ${a.name} (${elements.length} elements)`, content:
|
|
499
|
+
body: JSON.stringify({ message: `excalidrop: snapshot ${a.name} (${elements.length} elements)`, content: b64encode(JSON.stringify({ name: a.name, elements, createdAt: new Date().toISOString() }, null, 2)), branch: CANVAS_BRANCH }),
|
|
487
500
|
});
|
|
488
501
|
if (!r.ok) throw new Error(`snapshot write ${r.status}: ${(await r.text()).slice(0, 150)}`);
|
|
489
502
|
return textResult(`Snapshot "${a.name}" saved (${elements.length} elements) on ${repo}.`);
|
|
@@ -494,7 +507,7 @@ async function callTool(env, name, args, token) {
|
|
|
494
507
|
if (r.status === 404) throw new Error(`Snapshot "${a.name}" not found`);
|
|
495
508
|
if (!r.ok) throw new Error(`snapshot read ${r.status}`);
|
|
496
509
|
const j = await r.json();
|
|
497
|
-
const snap = JSON.parse(
|
|
510
|
+
const snap = JSON.parse(b64decode(j.content));
|
|
498
511
|
const els = (snap.elements || []).map((e) => ({ ...e, id: e.id || generateId() }));
|
|
499
512
|
await mutateScene(repo, token, `excalidrop: restore ${a.name}`, (elements) => { elements.length = 0; for (const e of els) elements.push(e); });
|
|
500
513
|
return textResult(`Snapshot "${a.name}" restored (${els.length} elements) on ${repo}.`);
|
|
@@ -548,7 +561,14 @@ export async function handleMcp(req, env, cors) {
|
|
|
548
561
|
|
|
549
562
|
const ip = req.headers.get('cf-connecting-ip') || 'unknown';
|
|
550
563
|
const token = (req.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '');
|
|
551
|
-
if (!token)
|
|
564
|
+
if (!token) {
|
|
565
|
+
// RFC 9728 discovery trigger: clients fetch resource_metadata → AS metadata → DCR.
|
|
566
|
+
const origin = new URL(req.url).origin;
|
|
567
|
+
return new Response(JSON.stringify(rpcError(null, -32001, 'missing Bearer token — register at POST /register, then OAuth authorize (see /.well-known/oauth-protected-resource)')), {
|
|
568
|
+
status: 401,
|
|
569
|
+
headers: { ...headers, 'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"` },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
552
572
|
if (!rateOk(`mcp:${hashToken(token)}`, 60, 60000) || !rateOk(`mcpip:${ip}`, 120, 60000)) {
|
|
553
573
|
return new Response(JSON.stringify(rpcError(null, -32002, 'rate limited (60/min per token). Retry shortly.')), { status: 429, headers });
|
|
554
574
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// OAuth Authorization Server for remote MCP clients (spec 2026-07-28,
|
|
2
|
+
// RFC 7591 DCR + RFC 7636 PKCE). The worker fronts the GitHub OAuth App:
|
|
3
|
+
// clients register with US, users log in at github.com, and /token hands
|
|
4
|
+
// back the GitHub user token as the access token. The app secret never
|
|
5
|
+
// leaves the worker. Viewer /exchange flow is untouched.
|
|
6
|
+
//
|
|
7
|
+
// State lives in the OAUTH KV binding (fail closed when missing):
|
|
8
|
+
// oauth:client:{id} {secret, redirect_uris[], name} 90d
|
|
9
|
+
// oauth:session:{sid} {client_id, redirect_uri, scope, challenge, client_state} 10min
|
|
10
|
+
// oauth:code:{code} {client_id, redirect_uri, scope, github_token, challenge} 5min, single-use
|
|
11
|
+
|
|
12
|
+
// App credentials for the AS flow. Prefer dedicated MCP_* vars (your own
|
|
13
|
+
// GitHub OAuth App, where you register /oauth/callback); fall back to the
|
|
14
|
+
// shared viewer-app vars.
|
|
15
|
+
function appCreds(env) {
|
|
16
|
+
return {
|
|
17
|
+
id: env.MCP_GITHUB_CLIENT_ID || env.GITHUB_CLIENT_ID,
|
|
18
|
+
secret: env.MCP_GITHUB_CLIENT_SECRET || env.GITHUB_CLIENT_SECRET,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// oauth:client:{id} {secret, redirect_uris[], name} 90d
|
|
22
|
+
// oauth:session:{sid} {client_id, redirect_uri, scope, challenge, client_state} 10min
|
|
23
|
+
// oauth:code:{code} {client_id, redirect_uri, scope, github_token, challenge} 5min, single-use
|
|
24
|
+
|
|
25
|
+
function rnd(prefix, bytes = 24) {
|
|
26
|
+
const b = new Uint8Array(bytes);
|
|
27
|
+
crypto.getRandomValues(b);
|
|
28
|
+
let bin = '';
|
|
29
|
+
for (const x of b) bin += String.fromCharCode(x);
|
|
30
|
+
return prefix + btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sha256B64url(str) {
|
|
34
|
+
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
|
35
|
+
let bin = '';
|
|
36
|
+
for (const x of new Uint8Array(d)) bin += String.fromCharCode(x);
|
|
37
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function err(status, error, description, cors) {
|
|
41
|
+
return new Response(JSON.stringify({ error, ...(description ? { error_description: description } : {}) }), {
|
|
42
|
+
status, headers: { 'Content-Type': 'application/json', ...cors },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// https anywhere; http only for loopback (RFC 8252 §7.3).
|
|
47
|
+
function redirectAllowed(u) {
|
|
48
|
+
let p;
|
|
49
|
+
try { p = new URL(u); } catch { return false; }
|
|
50
|
+
if (p.protocol === 'https:') return true;
|
|
51
|
+
if (p.protocol === 'http:') return p.hostname === 'localhost' || p.hostname === '127.0.0.1' || p.hostname === '[::1]';
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clientAuth(req, params) {
|
|
56
|
+
// client_secret_basic or client_secret_post (RFC 6749 §2.3).
|
|
57
|
+
const h = req.headers.get('Authorization') || '';
|
|
58
|
+
const m = /^Basic\s+(.+)$/i.exec(h);
|
|
59
|
+
if (m) {
|
|
60
|
+
try {
|
|
61
|
+
const [id, secret] = atob(m[1]).split(':');
|
|
62
|
+
return { id: id || params.client_id, secret };
|
|
63
|
+
} catch { /* fall through */ }
|
|
64
|
+
}
|
|
65
|
+
return { id: params.client_id, secret: params.client_secret };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function handleOAuth(req, env, cors) {
|
|
69
|
+
const url = new URL(req.url);
|
|
70
|
+
const origin = url.origin;
|
|
71
|
+
const kv = env.OAUTH;
|
|
72
|
+
|
|
73
|
+
if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
|
|
74
|
+
return new Response(JSON.stringify({
|
|
75
|
+
issuer: origin,
|
|
76
|
+
authorization_endpoint: origin + '/authorize',
|
|
77
|
+
token_endpoint: origin + '/token',
|
|
78
|
+
registration_endpoint: origin + '/register',
|
|
79
|
+
response_types_supported: ['code'],
|
|
80
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
81
|
+
response_modes_supported: ['query'],
|
|
82
|
+
scopes_supported: ['repo'],
|
|
83
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
84
|
+
code_challenge_methods_supported: ['S256', 'plain'],
|
|
85
|
+
}), { headers: { 'Content-Type': 'application/json', ...cors } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {
|
|
89
|
+
return new Response(JSON.stringify({
|
|
90
|
+
resource: origin + '/mcp',
|
|
91
|
+
authorization_servers: [origin],
|
|
92
|
+
scopes_supported: ['repo'],
|
|
93
|
+
bearer_methods_supported: ['header'],
|
|
94
|
+
}), { headers: { 'Content-Type': 'application/json', ...cors } });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!kv) return err(500, 'server_error', 'oauth store not configured on this deployment', cors);
|
|
98
|
+
|
|
99
|
+
// ---- RFC 7591 Dynamic Client Registration
|
|
100
|
+
if (url.pathname === '/register') {
|
|
101
|
+
if (req.method !== 'POST') return err(405, 'invalid_request', 'use POST', cors);
|
|
102
|
+
let body;
|
|
103
|
+
try { body = await req.json(); } catch { return err(400, 'invalid_request', 'bad json', cors); }
|
|
104
|
+
const uris = body?.redirect_uris;
|
|
105
|
+
if (!Array.isArray(uris) || !uris.length || !uris.every((u) => typeof u === 'string' && redirectAllowed(u))) {
|
|
106
|
+
return err(400, 'invalid_redirect_uri', 'redirect_uris must be non-empty; https required (http allowed for localhost only)', cors);
|
|
107
|
+
}
|
|
108
|
+
const client_id = rnd('excc_');
|
|
109
|
+
const client_secret = rnd('excs_', 32);
|
|
110
|
+
await kv.put(`oauth:client:${client_id}`, JSON.stringify({ secret: client_secret, redirect_uris: uris, name: String(body.client_name || '').slice(0, 100), created: Date.now() }), { expirationTtl: 90 * 86400 });
|
|
111
|
+
return new Response(JSON.stringify({
|
|
112
|
+
client_id, client_secret, redirect_uris: uris,
|
|
113
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
114
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
115
|
+
}), { status: 201, headers: { 'Content-Type': 'application/json', ...cors } });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- Authorize: validate, stash session, bounce to GitHub
|
|
119
|
+
if (url.pathname === '/authorize') {
|
|
120
|
+
if (req.method !== 'GET') return err(405, 'invalid_request', 'use GET', cors);
|
|
121
|
+
const q = url.searchParams;
|
|
122
|
+
if (q.get('response_type') !== 'code') return err(400, 'unsupported_response_type', 'only code is supported', cors);
|
|
123
|
+
const rawClient = await kv.get(`oauth:client:${q.get('client_id') || ''}`);
|
|
124
|
+
if (!rawClient) return err(400, 'invalid_request', 'unknown client_id (register at POST /register first)', cors);
|
|
125
|
+
const client = JSON.parse(rawClient);
|
|
126
|
+
const redirect_uri = q.get('redirect_uri') || '';
|
|
127
|
+
if (!client.redirect_uris.includes(redirect_uri)) return err(400, 'invalid_request', 'redirect_uri mismatch (must exactly match a registered URI)', cors);
|
|
128
|
+
const app = appCreds(env);
|
|
129
|
+
if (!app.id || !app.secret) return err(500, 'server_error', 'github app not configured', cors);
|
|
130
|
+
const method = q.get('code_challenge_method') || 'plain';
|
|
131
|
+
if (q.get('code_challenge') && method !== 'S256' && method !== 'plain') return err(400, 'invalid_request', 'code_challenge_method must be S256 or plain', cors);
|
|
132
|
+
const sid = rnd('excsess_');
|
|
133
|
+
await kv.put(`oauth:session:${sid}`, JSON.stringify({
|
|
134
|
+
client_id: q.get('client_id'), redirect_uri,
|
|
135
|
+
scope: String(q.get('scope') || 'repo').slice(0, 200),
|
|
136
|
+
challenge: q.get('code_challenge') || null, challenge_method: method,
|
|
137
|
+
client_state: q.get('state') || '',
|
|
138
|
+
}), { expirationTtl: 600 });
|
|
139
|
+
const gh = new URL('https://github.com/login/oauth/authorize');
|
|
140
|
+
gh.searchParams.set('client_id', app.id);
|
|
141
|
+
gh.searchParams.set('redirect_uri', origin + '/oauth/callback');
|
|
142
|
+
gh.searchParams.set('scope', 'repo');
|
|
143
|
+
gh.searchParams.set('state', sid);
|
|
144
|
+
return new Response(null, { status: 302, headers: { Location: gh.toString(), ...cors } });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Callback from GitHub: swap code, mint our code, return to client
|
|
148
|
+
if (url.pathname === '/oauth/callback') {
|
|
149
|
+
if (req.method !== 'GET') return err(405, 'invalid_request', 'use GET', cors);
|
|
150
|
+
const q = url.searchParams;
|
|
151
|
+
const rawSess = q.get('state') ? await kv.get(`oauth:session:${q.get('state')}`) : null;
|
|
152
|
+
if (!rawSess || !q.get('code')) return err(400, 'invalid_request', 'unknown or expired session (restart login)', cors);
|
|
153
|
+
const sess = JSON.parse(rawSess);
|
|
154
|
+
const r = await fetch('https://github.com/login/oauth/access_token', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
client_id: appCreds(env).id, client_secret: appCreds(env).secret,
|
|
159
|
+
code: q.get('code'), redirect_uri: origin + '/oauth/callback',
|
|
160
|
+
}),
|
|
161
|
+
}).catch(() => null);
|
|
162
|
+
const j = await r?.json().catch(() => null);
|
|
163
|
+
if (!r || !r.ok || !j?.access_token) {
|
|
164
|
+
return err(400, 'access_denied', j?.error_description || j?.error || 'github exchange failed', cors);
|
|
165
|
+
}
|
|
166
|
+
const code = rnd('excode_');
|
|
167
|
+
await kv.put(`oauth:code:${code}`, JSON.stringify({
|
|
168
|
+
client_id: sess.client_id, redirect_uri: sess.redirect_uri, scope: 'repo',
|
|
169
|
+
github_token: j.access_token, challenge: sess.challenge, challenge_method: sess.challenge_method,
|
|
170
|
+
}), { expirationTtl: 300 });
|
|
171
|
+
await kv.delete(`oauth:session:${q.get('state')}`);
|
|
172
|
+
const back = new URL(sess.redirect_uri);
|
|
173
|
+
back.searchParams.set('code', code);
|
|
174
|
+
if (sess.client_state) back.searchParams.set('state', sess.client_state);
|
|
175
|
+
return new Response(null, { status: 302, headers: { Location: back.toString(), ...cors } });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- Token endpoint
|
|
179
|
+
if (url.pathname === '/token') {
|
|
180
|
+
if (req.method !== 'POST') return err(405, 'invalid_request', 'use POST', cors);
|
|
181
|
+
const ct = req.headers.get('Content-Type') || '';
|
|
182
|
+
let params = {};
|
|
183
|
+
try {
|
|
184
|
+
if (ct.includes('application/json')) params = await req.json();
|
|
185
|
+
else params = Object.fromEntries(new URLSearchParams(await req.text()));
|
|
186
|
+
} catch { return err(400, 'invalid_request', 'bad body', cors); }
|
|
187
|
+
if (params.grant_type === 'refresh_token') {
|
|
188
|
+
return err(400, 'unsupported_grant_type', 'tokens do not expire; re-authorize if needed', cors);
|
|
189
|
+
}
|
|
190
|
+
if (params.grant_type !== 'authorization_code') return err(400, 'unsupported_grant_type', 'only authorization_code is supported', cors);
|
|
191
|
+
const rawCode = params.code ? await kv.get(`oauth:code:${params.code}`) : null;
|
|
192
|
+
if (!rawCode) return err(400, 'invalid_grant', 'unknown or expired code', cors);
|
|
193
|
+
const rec = JSON.parse(rawCode);
|
|
194
|
+
const { id, secret } = clientAuth(req, params);
|
|
195
|
+
if (!id || id !== rec.client_id) return err(401, 'invalid_client', 'client mismatch', cors);
|
|
196
|
+
if (params.redirect_uri && params.redirect_uri !== rec.redirect_uri) return err(400, 'invalid_grant', 'redirect_uri mismatch', cors);
|
|
197
|
+
const rawClient = await kv.get(`oauth:client:${id}`);
|
|
198
|
+
const client = rawClient ? JSON.parse(rawClient) : null;
|
|
199
|
+
if (!client) return err(401, 'invalid_client', 'unknown client', cors);
|
|
200
|
+
if (secret !== undefined && secret !== null && secret !== '') {
|
|
201
|
+
if (secret !== client.secret) return err(401, 'invalid_client', 'bad client_secret', cors);
|
|
202
|
+
} else if (rec.challenge) {
|
|
203
|
+
const verifier = params.code_verifier || '';
|
|
204
|
+
const ok = rec.challenge_method === 'plain'
|
|
205
|
+
? verifier === rec.challenge
|
|
206
|
+
: verifier && (await sha256B64url(verifier)) === rec.challenge;
|
|
207
|
+
if (!ok) return err(400, 'invalid_grant', 'PKCE verification failed', cors);
|
|
208
|
+
} else {
|
|
209
|
+
return err(401, 'invalid_client', 'client authentication required (secret or PKCE)', cors);
|
|
210
|
+
}
|
|
211
|
+
await kv.delete(`oauth:code:${params.code}`);
|
|
212
|
+
return new Response(JSON.stringify({
|
|
213
|
+
access_token: rec.github_token, token_type: 'Bearer', scope: 'repo',
|
|
214
|
+
}), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', Pragma: 'no-cache', ...cors } });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return err(404, 'not_found', 'unknown oauth route', cors);
|
|
218
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// OAuth Authorization Server contract tests (worker HTTP seam).
|
|
2
|
+
// Run: node worker/test/oauth.test.mjs (fails until src/oauth.js exists)
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const failures = [];
|
|
6
|
+
function check(cond, msg) {
|
|
7
|
+
if (cond) console.log(' ok:', msg);
|
|
8
|
+
else { failures.push(msg); console.log(' FAIL:', msg); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// --- stub env: in-memory KV with TTL, stubbed github fetch
|
|
12
|
+
function makeKv() {
|
|
13
|
+
const m = new Map();
|
|
14
|
+
return {
|
|
15
|
+
async get(k) { const e = m.get(k); if (!e || e.exp < Date.now()) return null; return e.v; },
|
|
16
|
+
async put(k, v, opts = {}) { m.set(k, { v, exp: Date.now() + (opts.expirationTtl || 3600) * 1000 }); },
|
|
17
|
+
async delete(k) { m.delete(k); },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const GITHUB_CODE = 'gh_code_abc';
|
|
21
|
+
const GITHUB_TOKEN = 'ghu_test123';
|
|
22
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
23
|
+
const u = String(url);
|
|
24
|
+
if (u === 'https://github.com/login/oauth/access_token') {
|
|
25
|
+
const body = JSON.parse(init.body);
|
|
26
|
+
if (body.code === GITHUB_CODE && body.client_secret === 'TEST_SECRET') {
|
|
27
|
+
return Response.json({ access_token: GITHUB_TOKEN, scope: 'repo', token_type: 'bearer' });
|
|
28
|
+
}
|
|
29
|
+
return Response.json({ error: 'bad_verification_code' }, { status: 200 });
|
|
30
|
+
}
|
|
31
|
+
throw new Error('unexpected fetch: ' + u);
|
|
32
|
+
};
|
|
33
|
+
const env = { OAUTH: makeKv(), GITHUB_CLIENT_ID: 'TEST_APP_ID', GITHUB_CLIENT_SECRET: 'TEST_SECRET' };
|
|
34
|
+
const HOST = 'https://relay.example.test';
|
|
35
|
+
|
|
36
|
+
const { handleOAuth } = await import('../src/oauth.js');
|
|
37
|
+
const call = (path, opts = {}) => handleOAuth(new Request(HOST + path, opts), env, {});
|
|
38
|
+
|
|
39
|
+
// RFC7636 example pair (independent of implementation)
|
|
40
|
+
const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
41
|
+
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
|
|
42
|
+
|
|
43
|
+
// 1. AS metadata points at US, advertises DCR + PKCE
|
|
44
|
+
{
|
|
45
|
+
const r = await call('/.well-known/oauth-authorization-server');
|
|
46
|
+
const j = await r.json();
|
|
47
|
+
check(r.status === 200, 'metadata 200');
|
|
48
|
+
check(j.issuer === HOST, 'metadata issuer is worker origin');
|
|
49
|
+
check(j.authorization_endpoint === HOST + '/authorize', 'metadata authorize endpoint');
|
|
50
|
+
check(j.token_endpoint === HOST + '/token', 'metadata token endpoint');
|
|
51
|
+
check(j.registration_endpoint === HOST + '/register', 'metadata registration endpoint (DCR)');
|
|
52
|
+
check((j.code_challenge_methods_supported || []).includes('S256'), 'metadata advertises S256');
|
|
53
|
+
}
|
|
54
|
+
// 2. protected-resource doc
|
|
55
|
+
{
|
|
56
|
+
const r = await call('/.well-known/oauth-protected-resource');
|
|
57
|
+
const j = await r.json();
|
|
58
|
+
check(r.status === 200, 'protected-resource 200');
|
|
59
|
+
check(j.resource === HOST + '/mcp', 'protected resource is /mcp');
|
|
60
|
+
check((j.authorization_servers || []).includes(HOST), 'protected resource lists our AS');
|
|
61
|
+
}
|
|
62
|
+
// 3. register validation
|
|
63
|
+
{
|
|
64
|
+
const bad = await call('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['http://evil.com/cb'] }) });
|
|
65
|
+
check(bad.status === 400, 'register rejects non-https redirect');
|
|
66
|
+
const ok = await call('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['https://app.example/cb'], client_name: 't' }) });
|
|
67
|
+
const j = await ok.json();
|
|
68
|
+
check(ok.status === 201 && !!j.client_id && !!j.client_secret, 'register returns client_id + client_secret');
|
|
69
|
+
var CLIENT = j;
|
|
70
|
+
const local = await call('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['http://localhost:8787/cb'] }) });
|
|
71
|
+
check(local.status === 201, 'register allows http localhost');
|
|
72
|
+
}
|
|
73
|
+
// 4. authorize validation + github redirect
|
|
74
|
+
{
|
|
75
|
+
const unknown = await call(`/authorize?response_type=code&client_id=nope&redirect_uri=${encodeURIComponent('https://app.example/cb')}`);
|
|
76
|
+
check(unknown.status === 400, 'authorize rejects unknown client');
|
|
77
|
+
const mismatch = await call(`/authorize?response_type=code&client_id=${CLIENT.client_id}&redirect_uri=${encodeURIComponent('https://evil.com/cb')}&state=s1`);
|
|
78
|
+
check(mismatch.status === 400, 'authorize rejects redirect mismatch');
|
|
79
|
+
const ok = await call(`/authorize?response_type=code&client_id=${CLIENT.client_id}&redirect_uri=${encodeURIComponent('https://app.example/cb')}&scope=repo&state=s1&code_challenge=${CHALLENGE}&code_challenge_method=S256`);
|
|
80
|
+
check(ok.status === 302, 'authorize 302s to github');
|
|
81
|
+
const loc = new URL(ok.headers.get('location'));
|
|
82
|
+
check(loc.hostname === 'github.com' && loc.pathname === '/login/oauth/authorize', 'authorize target is github');
|
|
83
|
+
check(loc.searchParams.get('client_id') === 'TEST_APP_ID', 'authorize uses app client_id (secret stays server-side)');
|
|
84
|
+
check(loc.searchParams.get('redirect_uri') === HOST + '/oauth/callback', 'authorize callback is worker endpoint');
|
|
85
|
+
check(loc.searchParams.get('scope') === 'repo', 'authorize requests repo scope');
|
|
86
|
+
check(!!loc.searchParams.get('state'), 'authorize carries session state');
|
|
87
|
+
var SESSION = loc.searchParams.get('state');
|
|
88
|
+
}
|
|
89
|
+
// 5. callback: bad state, then happy path
|
|
90
|
+
{
|
|
91
|
+
const bad = await call(`/oauth/callback?code=${GITHUB_CODE}&state=bogus`);
|
|
92
|
+
check(bad.status === 400, 'callback rejects unknown session');
|
|
93
|
+
const ok = await call(`/oauth/callback?code=${GITHUB_CODE}&state=${SESSION}`);
|
|
94
|
+
check(ok.status === 302, 'callback 302s back to client');
|
|
95
|
+
const loc = new URL(ok.headers.get('location'));
|
|
96
|
+
check(loc.origin === 'https://app.example' && loc.pathname === '/cb', 'callback returns to registered redirect');
|
|
97
|
+
check(!!loc.searchParams.get('code') && loc.searchParams.get('state') === 's1', 'callback carries code + client state');
|
|
98
|
+
var CODE = loc.searchParams.get('code');
|
|
99
|
+
}
|
|
100
|
+
// 6. token: bad code, happy path with secret, single-use
|
|
101
|
+
{
|
|
102
|
+
const bad = await call('/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: 'bogus', redirect_uri: 'https://app.example/cb', client_id: CLIENT.client_id, client_secret: CLIENT.client_secret }) });
|
|
103
|
+
check(bad.status === 400 && (await bad.json()).error === 'invalid_grant', 'token rejects bad code');
|
|
104
|
+
const ok = await call('/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: CODE, redirect_uri: 'https://app.example/cb', client_id: CLIENT.client_id, client_secret: CLIENT.client_secret }) });
|
|
105
|
+
const j = await ok.json();
|
|
106
|
+
check(ok.status === 200 && j.access_token === GITHUB_TOKEN && j.token_type === 'Bearer', 'token returns github user token as access_token');
|
|
107
|
+
const reuse = await call('/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: CODE, redirect_uri: 'https://app.example/cb', client_id: CLIENT.client_id, client_secret: CLIENT.client_secret }) });
|
|
108
|
+
check(reuse.status === 400, 'code is single-use');
|
|
109
|
+
}
|
|
110
|
+
// 7. PKCE-only public client (no secret presented)
|
|
111
|
+
{
|
|
112
|
+
const ok = await call(`/authorize?response_type=code&client_id=${CLIENT.client_id}&redirect_uri=${encodeURIComponent('https://app.example/cb')}&state=s2&code_challenge=${CHALLENGE}&code_challenge_method=S256`);
|
|
113
|
+
const sid = new URL(ok.headers.get('location')).searchParams.get('state');
|
|
114
|
+
const cb = await call(`/oauth/callback?code=${GITHUB_CODE}&state=${sid}`);
|
|
115
|
+
const code = new URL(cb.headers.get('location')).searchParams.get('code');
|
|
116
|
+
const good = await call('/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: 'https://app.example/cb', client_id: CLIENT.client_id, code_verifier: VERIFIER }) });
|
|
117
|
+
check(good.status === 200, 'PKCE verifier without secret succeeds');
|
|
118
|
+
const ok2 = await call(`/authorize?response_type=code&client_id=${CLIENT.client_id}&redirect_uri=${encodeURIComponent('https://app.example/cb')}&state=s3&code_challenge=${CHALLENGE}&code_challenge_method=S256`);
|
|
119
|
+
const sid2 = new URL(ok2.headers.get('location')).searchParams.get('state');
|
|
120
|
+
const cb2 = await call(`/oauth/callback?code=${GITHUB_CODE}&state=${sid2}`);
|
|
121
|
+
const code2 = new URL(cb2.headers.get('location')).searchParams.get('code');
|
|
122
|
+
const wrong = await call('/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code2, redirect_uri: 'https://app.example/cb', client_id: CLIENT.client_id, code_verifier: 'wrong' }) });
|
|
123
|
+
check(wrong.status === 400, 'wrong PKCE verifier rejected');
|
|
124
|
+
}
|
|
125
|
+
// 8. wrong secret rejected
|
|
126
|
+
{
|
|
127
|
+
const ok = await call(`/authorize?response_type=code&client_id=${CLIENT.client_id}&redirect_uri=${encodeURIComponent('https://app.example/cb')}&state=s4&code_challenge=${CHALLENGE}&code_challenge_method=S256`);
|
|
128
|
+
const sid = new URL(ok.headers.get('location')).searchParams.get('state');
|
|
129
|
+
const cb = await call(`/oauth/callback?code=${GITHUB_CODE}&state=${sid}`);
|
|
130
|
+
const code = new URL(cb.headers.get('location')).searchParams.get('code');
|
|
131
|
+
const bad = await call('/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: 'https://app.example/cb', client_id: CLIENT.client_id, client_secret: 'wrong' }) });
|
|
132
|
+
check(bad.status === 401, 'wrong client_secret rejected');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 9. dedicated MCP app credentials override the shared viewer-app ones
|
|
136
|
+
{
|
|
137
|
+
const env2 = { ...env, OAUTH: makeKv(), MCP_GITHUB_CLIENT_ID: 'OWN_APP', MCP_GITHUB_CLIENT_SECRET: 'OWN_SECRET' };
|
|
138
|
+
const reg = await handleOAuth(new Request(HOST + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['https://app.example/cb'] }) }), env2, {});
|
|
139
|
+
const cid = (await reg.json()).client_id;
|
|
140
|
+
const auth = await handleOAuth(new Request(HOST + `/authorize?response_type=code&client_id=${cid}&redirect_uri=${encodeURIComponent('https://app.example/cb')}`), env2, {});
|
|
141
|
+
const loc = new URL(auth.headers.get('location'));
|
|
142
|
+
check(loc.searchParams.get('client_id') === 'OWN_APP', 'authorize prefers MCP_GITHUB_CLIENT_ID over shared id');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(failures.length ? `\n${failures.length} FAILURES` : '\nALL GREEN');
|
|
146
|
+
process.exit(failures.length ? 1 : 0);
|
package/worker/wrangler.toml
CHANGED
|
@@ -15,6 +15,13 @@ ALLOWED_REDIRECT_ORIGINS = "*.github.io,*.pages.dev"
|
|
|
15
15
|
name = "RELAY"
|
|
16
16
|
class_name = "RelayRoom"
|
|
17
17
|
|
|
18
|
+
# OAuth Authorization Server store (DCR clients, login sessions, one-time
|
|
19
|
+
# codes — all short-TTL). Absent on self-host deploys → oauth routes 500,
|
|
20
|
+
# relay + /mcp with static tokens keep working.
|
|
21
|
+
[[kv_namespaces]]
|
|
22
|
+
binding = "OAUTH"
|
|
23
|
+
id = "f956647497954a68a84d1c275d3b9343"
|
|
24
|
+
|
|
18
25
|
[[migrations]]
|
|
19
26
|
tag = "v1"
|
|
20
27
|
new_sqlite_classes = ["RelayRoom"]
|