1// The proxy as an OAuth client of the public site's hosted MCP 2// (worker/src/oauth.ts), with the MCP TypeScript SDK's `auth()` doing the 3// protocol: protected-resource and authorization-server discovery, dynamic 4// client registration, PKCE (S256), the code exchange and refresh. 5// 6// What this file adds is the native-app half (RFC 8252): a loopback listener 7// on 127.0.0.1 and a random port for the redirect, a `state` checked on the 8// way back, the `iss` the server sends checked against the discovered issuer 9// (RFC 9207), and revocation of the grant when the proxy exits. 10// 11// Everything is in this process's memory: the registration, the tokens and 12// the verifier. Nothing touches the disk, so a new Claude Code session signs 13// in once, and the grant is revoked when the session ends (a proxy killed 14// outright leaves its grant to expire, 30 days after its last use). 15// 16// Dynamic registration, not a Client ID Metadata Document: a CIMD is an 17// https URL the server fetches (and only from the public internet), which a 18// process on a laptop cannot serve; the server takes loopback redirects on 19// any port (RFC 8252 §7.3), so one registration serves every sign-in of the 20// process. 21import { createServer } from 'node:http'; 22import { randomBytes } from 'node:crypto'; 23import { auth } from '@modelcontextprotocol/sdk/client/auth.js'; 24 25const CLIENT_NAME = 'Claude Code (jevstrudel dev proxy)'; 26// How long a sign-in waits for the browser to come back before giving up. 27export const FLOW_MS = 10 * 60_000; 28 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)};`); 32 33// 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 }); 71 72 // A new access token from the refresh token, without the browser. False 73 // 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 } 85 86 // Sign in in the browser. Resolves at once with { url, done, opened } (the 87 // flow already under way, if there is one): `done` settles when the browser 88 // comes back and the code is swapped, or the flow times out; `url` is the 89 // authorize page, for the user to open by hand; `opened` says how the 90 // browser was launched, or why it was not. 91 function signIn() { 92 flow ??= start().catch((e) => { 93 flow = null; 94 throw e; 95 }); 96 return flow; 97 } 98 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 } 166 167 // RFC 7009: revoking the refresh token ends the grant, so it leaves the 168 // 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 } 181 182 return { 183 accessToken: () => store.tokens?.access_token, 184 refresh, 185 signIn, 186 revoke, 187 }; 188}