jevstrudel.git / tools / mcp / oauth.mjs
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}