jevstrudel.git / tools / mcp / oauth.mjs

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.

21import { createServer } from 'node:http';
22import { randomBytes } from 'node:crypto';
23import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
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.

91  function signIn() {
92    flow ??= start().catch((e) => {
93      flow = null;
94      throw e;
95    });
96    return flow;
97  }
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  }
182  return {
183    accessToken: () => store.tokens?.access_token,
184    refresh,
185    signIn,
186    revoke,
187  };
188}