The proxy as an OAuth client of the public site's hosted MCP
(worker/src/oauth.ts), with the MCP TypeScript SDK's auth() doing the
protocol: protected-resource and authorization-server discovery, dynamic
client registration, PKCE (S256), the code exchange and refresh.
What this file adds is the native-app half (RFC 8252): a loopback listener
on 127.0.0.1 and a random port for the redirect, a state checked on the
way back, the iss the server sends checked against the discovered issuer
(RFC 9207), and revocation of the grant when the proxy exits.
Everything is in this process's memory: the registration, the tokens and the verifier. Nothing touches the disk, so a new Claude Code session signs in once, and the grant is revoked when the session ends (a proxy killed outright leaves its grant to expire, 30 days after its last use).
Dynamic registration, not a Client ID Metadata Document: a CIMD is an https URL the server fetches (and only from the public internet), which a process on a laptop cannot serve; the server takes loopback redirects on any port (RFC 8252 §7.3), so one registration serves every sign-in of the process.
25const CLIENT_NAME = 'Claude Code (jevstrudel dev proxy)';
How long a sign-in waits for the browser to come back before giving up.
27export const FLOW_MS = 10 * 60_000;
29const PAGE = (title, body) => 30 `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:15px ui-monospace,monospace;background:#161616;color:#eee;padding:3rem"><h1 style="font-size:1.2rem;color:#ffcc00">${title}</h1><p>${body}</p>`; 31const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
serverUrl: the MCP endpoint. openBrowser(url): opens it (browser.mjs).
34export function oauthSession({ serverUrl, openBrowser, flowMs = FLOW_MS, fetchFn = fetch }) { 35 const store = {}; 36 let redirect; 37 let captured; 38 let flow = null; 39 40 const provider = { 41 get redirectUrl() { 42 return redirect; 43 }, 44 get clientMetadata() { 45 return { 46 client_name: CLIENT_NAME, 47 redirect_uris: [redirect], 48 grant_types: ['authorization_code', 'refresh_token'], 49 response_types: ['code'], 50 token_endpoint_auth_method: 'none', 51 }; 52 }, 53 state: () => store.state, 54 clientInformation: () => store.client, 55 saveClientInformation: (c) => void (store.client = c), 56 tokens: () => store.tokens, 57 saveTokens: (t) => void (store.tokens = t), 58 saveCodeVerifier: (v) => void (store.verifier = v), 59 codeVerifier: () => store.verifier, 60 discoveryState: () => store.discovery, 61 saveDiscoveryState: (d) => void (store.discovery = d), 62 redirectToAuthorization: (url) => void (captured = url), 63 invalidateCredentials(scope) { 64 if (scope === 'all' || scope === 'client') store.client = undefined; 65 if (scope === 'all' || scope === 'tokens') store.tokens = undefined; 66 if (scope === 'all' || scope === 'verifier') store.verifier = undefined; 67 if (scope === 'all' || scope === 'discovery') store.discovery = undefined; 68 }, 69 }; 70 const run = (options = {}) => auth(provider, { serverUrl, fetchFn, ...options });
A new access token from the refresh token, without the browser. False when there is none or the server refused it (the grant is gone).
74 async function refresh() { 75 if (!store.tokens?.refresh_token) return false; 76 redirect ??= 'http://127.0.0.1/callback'; 77 captured = undefined; 78 try { 79 const result = await run(); 80 if (result === 'AUTHORIZED') return true; 81 } catch {} 82 store.tokens = undefined; 83 return false; 84 }
Sign in in the browser. Resolves at once with { url, done, opened } (the
flow already under way, if there is one): done settles when the browser
comes back and the code is swapped, or the flow times out; url is the
authorize page, for the user to open by hand; opened says how the
browser was launched, or why it was not.
99 async function start() { 100 const server = createServer(); 101 await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve)); 102 const { port } = server.address(); 103 redirect = `http://127.0.0.1:${port}/callback`; 104 store.state = randomBytes(24).toString('base64url'); 105 store.tokens = undefined; 106 captured = undefined; 107 let result; 108 try { 109 result = await run(); 110 } catch (e) { 111 server.close(); 112 throw e; 113 } 114 if (result === 'AUTHORIZED' || !captured) { 115 server.close(); 116 flow = null; 117 return { url: null, done: Promise.resolve(), opened: null }; 118 } 119 const url = captured.href; 120 const state = store.state; 121 let timer; 122 const done = new Promise((resolve, reject) => { 123 timer = setTimeout(() => reject(new Error(`nobody finished signing in within ${flowMs / 60000} minutes`)), flowMs); 124 server.on('request', async (req, res) => { 125 const at = new URL(req.url, redirect); 126 if (at.pathname !== '/callback') { 127 res.writeHead(404).end(); 128 return; 129 } 130 const answer = (status, title, body) => { 131 res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); 132 res.end(PAGE(title, body)); 133 }; 134 const p = at.searchParams; 135 // a stray or forged request: not this flow's, so it cannot end it 136 if (p.get('state') !== state) return answer(400, 'Not this sign-in', 'This link does not belong to the sign-in in progress.'); 137 try { 138 if (p.get('error')) throw new Error(p.get('error_description') || p.get('error')); 139 const issuer = store.discovery?.authorizationServerUrl; 140 if (p.has('iss') && issuer && new URL(p.get('iss')).origin !== new URL(issuer).origin) { 141 throw new Error(`the code came from ${p.get('iss')}, not ${issuer}`); 142 } 143 const code = p.get('code'); 144 if (!code) throw new Error('the redirect carried no code'); 145 await run({ authorizationCode: code }); 146 answer(200, 'Signed in', 'Claude Code is connected to jevstrudel. You can close this tab.'); 147 resolve(); 148 } catch (e) { 149 answer(400, 'Sign-in failed', escape(e.message)); 150 reject(e); 151 } 152 }); 153 }).finally(() => { 154 clearTimeout(timer); 155 server.close(); 156 server.closeIdleConnections(); 157 flow = null; 158 }); 159 done.catch(() => {}); 160 const opened = await openBrowser(url).then( 161 (how) => `opened with ${how}`, 162 (e) => `could not open a browser (${e.message})`, 163 ); 164 return { url, done, opened }; 165 }
RFC 7009: revoking the refresh token ends the grant, so it leaves the user's connected apps. Best effort, on exit.
169 async function revoke() { 170 const token = store.tokens?.refresh_token; 171 const endpoint = store.discovery?.authorizationServerMetadata?.revocation_endpoint; 172 store.tokens = undefined; 173 if (!token || !endpoint || !store.client) return; 174 await fetchFn(endpoint, { 175 method: 'POST', 176 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 177 body: new URLSearchParams({ token, token_type_hint: 'refresh_token', client_id: store.client.client_id }), 178 signal: AbortSignal.timeout(3000), 179 }).catch(() => {}); 180 }