excalidrop 0.2.9 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "excalidrop",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Remote GitHub-backed Excalidraw canvas + MCP server — no local server, one-command setup",
5
5
  "main": "dist/mcp.js",
6
6
  "type": "module",
@@ -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
- if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
89
- return json(oauthMetadata(url.host), 200, cors);
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
@@ -561,7 +561,14 @@ export async function handleMcp(req, env, cors) {
561
561
 
562
562
  const ip = req.headers.get('cf-connecting-ip') || 'unknown';
563
563
  const token = (req.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '');
564
- if (!token) return new Response(JSON.stringify(rpcError(null, -32001, 'missing Bearer token — connect with GitHub OAuth (repo scope)')), { status: 401, headers });
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
+ }
565
572
  if (!rateOk(`mcp:${hashToken(token)}`, 60, 60000) || !rateOk(`mcpip:${ip}`, 120, 60000)) {
566
573
  return new Response(JSON.stringify(rpcError(null, -32002, 'rate limited (60/min per token). Retry shortly.')), { status: 429, headers });
567
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);
@@ -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"]