jevstrudel.git / worker / src / oauth.ts
oauth.tsannotatedoauth.tssource398 lines · 20.3 KB · raw

OAuth 2.1 for the hosted MCP (hosted-mcp.ts), per MCP's authorization spec, with Cloudflare's @cloudflare/workers-oauth-provider: this Worker is both the authorization server and the protected resource.

GET /.well-known/oauth-protected-resource/jev/mcp RFC 9728: where to authorize } the GET /.well-known/oauth-authorization-server RFC 8414: the endpoints } library's POST /jev/oauth/token code and refresh exchange, and RFC 7009 revocation } POST /jev/oauth/register RFC 7591 dynamic registration, for clients without } Client ID Metadata Documents (which are preferred) } GET /jev/oauth/authorize ours: sign in with a passkey, then the consent page POST /jev/oauth/authorize ours: the consent form's answer POST /jev/mcp the MCP itself, with a bearer token (hosted-mcp.ts) GET /jev/me/apps the signed-in user's connected apps (grants) DELETE /jev/me/apps/<id> disconnect one: its tokens stop working

The library keeps clients, grants and tokens in the OAUTH_KV namespace: tokens, codes and client secrets only as hashes, the grant's props ({ userId }, and the app's name) encrypted with a key wrapped by the token; a grant's user id and metadata are in the clear, since listing a user's grants needs them (README.md, Storage).

The issuer and the resource are the request's own origin, as the passkey relying party is (auth.ts): https://jevstrudel.deizel.workers.dev in production, http://localhost:<port> in dev (the library allows http on loopback only). One pair of servers per origin, built once.

Sign-in is this site's passkeys and nothing else: the authorize page is served from the site's origin, so the session cookie and the passkey's relying party are the site's own, and a user who is not signed in signs in (or makes an account) on that page with the same ceremonies the header's panel runs.

32import {
33  AuthorizationError,
34  CimdFetchError,
35  OAuthAuthorizationServer,
36  OAuthResourceServer,
37  type AuthRequest,
38  type ClientInfo,
39  type OAuthHelpers,
40} from '@cloudflare/workers-oauth-provider';
41import { d1Accounts } from './accounts-store';
42import { AUTH_LIMIT, signedIn } from './auth';
43import type { Env } from './env';
44import { hostedMcp, MCP_PATH, SCOPES, type McpProps, type Scope } from './hosted-mcp';
46export const OAUTH_PREFIX = '/jev/oauth/';
47export const AUTHORIZE_PATH = '/jev/oauth/authorize';
48export const TOKEN_PATH = '/jev/oauth/token';
49export const REGISTER_PATH = '/jev/oauth/register';
50export const APPS_PATH = '/jev/me/apps';
51export const TABS_PATH = '/jev/me/tabs';
52const WELL_KNOWN = '/.well-known/oauth-';

An access token lives an hour; a grant as long as it keeps being used, and 30 days after its last refresh.

56const ACCESS_TTL_S = 3600;
57const GRANT_TTL_S = 30 * 24 * 3600;

A user's connected apps listed at once; nobody connects this many.

59const MAX_APPS = 100;
61type Servers = { as: OAuthAuthorizationServer<Env>; rs: OAuthResourceServer<Env, McpProps> };
62const byOrigin = new Map<string, Servers>();
63
64export function servers(origin: string): Servers {
65  let s = byOrigin.get(origin);
66  if (s) return s;
67  const resource = `${origin}${MCP_PATH}`;
68  const as = new OAuthAuthorizationServer<Env>({
69    issuer: origin,
70    resources: [resource],
71    authorizeEndpoint: AUTHORIZE_PATH,
72    tokenEndpoint: TOKEN_PATH,
73    clientRegistrationEndpoint: REGISTER_PATH,
74    scopesSupported: Object.keys(SCOPES),
75    clientIdMetadataDocumentEnabled: true,
76    accessTokenTTL: ACCESS_TTL_S,
77    refreshTokenTTL: GRANT_TTL_S,
78    refreshTokenIdleTTL: GRANT_TTL_S,
79    // what failed, never the request's contents, a token or a client's URLs
80    onError: ({ code, status, internal }) => {
81      console.warn({ event: 'jev.oauth', code, status, category: internal.category, reason: internal.reason });
82    },
83  });
84  const rs = new OAuthResourceServer<Env, McpProps>({
85    resourceMetadata: {
86      resource,
87      authorization_servers: [origin],
88      scopes_supported: Object.keys(SCOPES),
89      bearer_methods_supported: ['header'],
90      resource_name: 'jevstrudel',
91    },
92    validateToken: (env) => (res, token) => as.validateToken<McpProps>(res, token, env),
93    handler: { fetch: hostedMcp },
94  });
95  s = { as, rs };
96  byOrigin.set(origin, s);
97  return s;
98}

Every path of OAuth's and the hosted MCP's, or null when the path is another's.

101export async function oauth(request: Request, env: Env, ctx: ExecutionContext): Promise<Response | null> {
102  const url = new URL(request.url);
103  const { pathname } = url;
104  const mcp = pathname === MCP_PATH || pathname.startsWith(`${MCP_PATH}/`);
105  const wellKnown = pathname.startsWith(WELL_KNOWN);
106  const ours = pathname.startsWith(OAUTH_PREFIX) || pathname === APPS_PATH || pathname.startsWith(`${APPS_PATH}/`) || pathname === TABS_PATH;
107  if (!mcp && !wellKnown && !ours) return null;
108  const { as, rs } = servers(url.origin);

Registering, authorizing and swapping codes share the passkey ceremonies' per-visitor rate (AUTH_LIMIT): a connection is a handful of these, and each writes to OAUTH_KV, whose writes a day are few on the free plan (README.md, The hosted MCP).

114  if (pathname.startsWith(OAUTH_PREFIX)) {
115    const visitor = request.headers.get('CF-Connecting-IP') ?? 'local';
116    const { success } = await env.AUTH_LIMIT.limit({ key: visitor });
117    if (!success) {
118      return new Response('too many attempts; try again in a minute', {
119        status: 429,
120        headers: { 'Retry-After': String(AUTH_LIMIT.period), ...noStore },
121      });
122    }
123  }
125  if (mcp || pathname.startsWith(`${WELL_KNOWN}protected-resource`)) return rs.fetch(request, env, ctx);
126  if (pathname === AUTHORIZE_PATH) return authorize(request, env, as.getOAuthApi(env));
127  if (pathname === APPS_PATH || pathname.startsWith(`${APPS_PATH}/`)) return apps(request, env, as.getOAuthApi(env));
128  if (pathname === TABS_PATH) return tabs(request, env);
129  return as.fetch(request, env, ctx);
130}
131
132const noStore = { 'Cache-Control': 'no-store' };
133const text = (status: number, body: string) =>
134  new Response(body, { status, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...noStore } });

── a tab joining its user's hub ────────────────── Only from this site's own pages (signedIn refuses a cookie from any other origin, and a browser's WebSocket always sends its page's Origin), and only into the hub of the user signed in: the hub is named by the account.

140async function tabs(request: Request, env: Env): Promise<Response> {
141  if (request.method !== 'GET') return text(405, 'GET only');
142  const user = await signedIn(request, d1Accounts(env.DB));
143  if (!user) return text(401, 'sign in to let your AI play in this tab');
144  return env.TAB_HUB.get(env.TAB_HUB.idFromName(user.id)).fetch(request);
145}

── connected apps ────────────────────────────────

148type AppMetadata = { app: string; redirectHost: string; published: string | null };

A user's connected apps, as that user sees them (never a token): here, and on their own account page in the data tab (data.ts).

152export async function appsOf(helpers: OAuthHelpers, userId: string) {
153  const { items } = await helpers.listUserGrants(userId, { limit: MAX_APPS });
154  return items.map((g) => {
155    const m = (g.metadata ?? {}) as Partial<AppMetadata>;
156    return {
157      id: g.id,
158      app: typeof m.app === 'string' ? m.app : g.clientId,
159      redirectHost: m.redirectHost ?? null,
160      published: m.published ?? null,
161      scope: g.scope,
162      createdAt: g.createdAt,
163      expiresAt: g.expiresAt ?? null,
164    };
165  });
166}

How many apps each account has connected, from the grants' key names alone (grant:<userId>:<grantId>, the library's; README.md, Storage): no grant is read, so no app name, token hash or encrypted props. For the data tab (data.ts), which shows counts to anyone. At most pages KV lists of 1000 keys; complete says whether that was all of them.

173export async function grantCounts(kv: KVNamespace, pages = 5) {
174  const byUser = new Map<string, number>();
175  let cursor: string | undefined;
176  let complete = false;
177  let grants = 0;
178  for (let i = 0; i < pages; i++) {
179    const list = await kv.list({ prefix: 'grant:', cursor });
180    for (const { name } of list.keys) {
181      const user = name.split(':')[1];
182      if (!user) continue;
183      grants += 1;
184      byUser.set(user, (byUser.get(user) ?? 0) + 1);
185    }
186    if (list.list_complete) {
187      complete = true;
188      break;
189    }
190    cursor = list.cursor;
191  }
192  return { grants, byUser, complete };
193}
195async function apps(request: Request, env: Env, helpers: OAuthHelpers): Promise<Response> {
196  const user = await signedIn(request, d1Accounts(env.DB));
197  if (!user) return Response.json({ error: 'sign in to see your connected apps' }, { status: 401, headers: noStore });
198  const id = new URL(request.url).pathname.slice(APPS_PATH.length + 1);
199  if (!id) {
200    if (request.method !== 'GET') return text(405, 'GET only');
201    return Response.json({ apps: await appsOf(helpers, user.id) }, { headers: noStore });
202  }
203  if (request.method !== 'DELETE') return text(405, 'DELETE only');
204  if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) return text(404, 'no such app');
205  const { items } = await helpers.listUserGrants(user.id, { limit: MAX_APPS });
206  if (!items.some((g) => g.id === id)) return text(404, 'no such app');
207  await helpers.revokeGrant(id, user.id);
208  return new Response(null, { status: 204, headers: noStore });
209}

── the authorize page ────────────────────────────

212const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
213const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/;

Where a client's name comes from: a CIMD client's id is a URL on its own domain; a registered client named itself.

217const publishedBy = (client: ClientInfo) =>
218  /^https:\/\//.test(client.clientId) ? new URL(client.clientId).hostname : null;
219const appName = (client: ClientInfo) => (client.clientName?.trim() || publishedBy(client) || 'an MCP client').slice(0, 80);

The consent form's redirect goes to the client: CSP's form-action must allow it.

222function formTarget(redirectUri: string): string {
223  const u = new URL(redirectUri);
224  return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : u.protocol;
225}
227function page(title: string, body: string, nonce: string): string {
228  return `<!doctype html>
229<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
230<meta name="robots" content="noindex"><title>${escape(title)} · jevstrudel</title>
231<style nonce="${nonce}">
232body{margin:0;background:#161616;color:#eee;font:15px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
233main{max-width:34rem;margin:3rem auto;padding:0 1rem}
234h1{font-size:1.2rem;color:#ffcc00}
235.box{border:1px solid #ffcc00;border-radius:6px;padding:1rem;margin:1rem 0}
236.warn{border-color:#f87171;color:#fca5a5}
237label{display:block;margin:.4rem 0}
238button{font:inherit;background:none;color:#eee;border:1px solid #888;border-radius:4px;padding:.3rem .8rem;margin:.3rem .3rem 0 0;cursor:pointer}
239button.go{border-color:#ffcc00;color:#ffcc00}
240input[type=text]{font:inherit;background:#222;color:#eee;border:1px solid #555;border-radius:4px;padding:.3rem}
241small,.muted{opacity:.75}
242#err{color:#fca5a5}
243</style></head><body><main>${body}</main></body></html>`;
244}
245
246function htmlResponse(html: string, nonce: string, headers: Headers, formAction: string, status = 200): Response {
247  headers.set('Content-Type', 'text/html; charset=utf-8');
248  headers.set('Cache-Control', 'no-store');
249  // not no-referrer: under it Chrome sends the consent form's POST with
250  // `Origin: null`, which refuseCrossOrigin (rightly) refuses. same-origin
251  // keeps the Origin on the form and sends the client no referrer.
252  headers.set('Referrer-Policy', 'same-origin');
253  headers.set('X-Content-Type-Options', 'nosniff');
254  headers.set('X-Frame-Options', 'DENY');
255  headers.append(
256    'Content-Security-Policy',
257    `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; form-action 'self' ${formAction}; frame-ancestors 'none'; base-uri 'none'`,
258  );
259  return new Response(html, { status, headers });
260}
261
262const newNonce = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));

The passkey ceremonies of the header's panel (website/src/jev/account.mjs), without its library: the Worker's own /jev/auth/* routes, and the browser's WebAuthn with base64url turned into bytes and back.

267const SIGN_IN_SCRIPT = `
268const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
269const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
270const err = document.getElementById('err');
271async function call(path, body) {
272  const res = await fetch('/jev/auth/' + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), credentials: 'same-origin' });
273  const text = await res.text();
274  let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch {}
275  if (!res.ok) throw new Error((parsed && parsed.error) || ('the site answered ' + res.status));
276  return parsed;
277}
278const creds = (list) => (list || []).map((c) => ({ ...c, id: bytes(c.id) }));
279async function signIn() {
280  const o = await call('login/options', {});
281  const c = await navigator.credentials.get({ publicKey: { ...o, challenge: bytes(o.challenge), allowCredentials: creds(o.allowCredentials) } });
282  const r = c.response;
283  await call('login/verify', { id: c.id, rawId: b64(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment ?? undefined, clientExtensionResults: c.getClientExtensionResults(),
284    response: { clientDataJSON: b64(r.clientDataJSON), authenticatorData: b64(r.authenticatorData), signature: b64(r.signature), userHandle: r.userHandle ? b64(r.userHandle) : undefined } });
285}
286async function create(displayName) {
287  const o = await call('register/options', { displayName });
288  const c = await navigator.credentials.create({ publicKey: { ...o, challenge: bytes(o.challenge), user: { ...o.user, id: bytes(o.user.id) }, excludeCredentials: creds(o.excludeCredentials) } });
289  const r = c.response;
290  await call('register/verify', { id: c.id, rawId: b64(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment ?? undefined, clientExtensionResults: c.getClientExtensionResults(),
291    response: { clientDataJSON: b64(r.clientDataJSON), attestationObject: b64(r.attestationObject), transports: r.getTransports ? r.getTransports() : [] } });
292}
293const run = (f) => async (e) => {
294  e.preventDefault(); err.textContent = '';
295  try { await f(); location.reload(); }
296  catch (x) { err.textContent = x && x.name === 'NotAllowedError' ? 'the passkey prompt was closed or timed out' : (x && x.message) || String(x); }
297};
298document.getElementById('signin').addEventListener('click', run(signIn));
299document.getElementById('create').addEventListener('submit', run(() => create(document.getElementById('name').value)));
300`;
302function about(client: ClientInfo, req: AuthRequest): string {
303  const name = escape(appName(client));
304  const redirectHost = new URL(req.redirectUri).hostname;
305  const published = publishedBy(client);
306  const origin = published
307    ? `Published by <b>${escape(published)}</b>.`
308    : 'This app registered itself; its name is not verified.';
309  const local = LOOPBACK.test(redirectHost)
310    ? '<p class="box warn">This sends access to an app on your computer (<b>localhost</b>). Continue only if you just started connecting from it, e.g. <code>claude mcp add</code> or <code>/mcp</code> in Claude Code.</p>'
311    : '';
312  return `<h1>Connect <b>${name}</b> to jevstrudel?</h1>
313<p>${origin} Access will be sent to <b>${escape(redirectHost)}</b>.</p>${local}`;
314}
315
316async function authorize(request: Request, env: Env, helpers: OAuthHelpers): Promise<Response> {
317  const nonce = newNonce();
318  try {
319    if (request.method === 'POST') return await approve(request, env, helpers);
320    if (request.method !== 'GET') return text(405, 'GET, POST only');
321    const req = await helpers.parseAuthRequest(request);
322    const client = await helpers.lookupClient(req.clientId);
323    if (!client) return text(400, 'Unknown OAuth client');
324    const user = await signedIn(request, d1Accounts(env.DB));
325    const action = formTarget(req.redirectUri);
326    if (!user) {
327      const body = `${about(client, req)}
328<div class="box"><p>Sign in to jevstrudel first: the app will act as your account.</p>
329<button class="go" id="signin">sign in with a passkey</button>
330<form id="create"><p class="muted">New here? Pick a name; your device keeps a passkey for this site. No password, no email. Your name, radio history, Jev budget use and everything you write are public on the site's data tab; your passkey and session never are.</p>
331<input type="text" id="name" maxlength="40" placeholder="display name" autocomplete="nickname" required> <button type="submit">create an account</button></form>
332<p id="err"></p></div>
333<script nonce="${nonce}">${SIGN_IN_SCRIPT}</script>`;
334      return htmlResponse(page('Sign in', body, nonce), nonce, new Headers(), action);
335    }
336    const consent = await helpers.beginConsent(req);
337    const asked = req.scope.length ? req.scope : Object.keys(SCOPES);
338    const scopes = (Object.keys(SCOPES) as Scope[])
339      .map(
340        (s) =>
341          `<label><input type="checkbox" name="scope" value="${s}"${asked.includes(s) ? ' checked' : ''}> <b>${s}</b>: ${escape(SCOPES[s])}</label>`,
342      )
343      .join('');
344    const body = `${about(client, req)}
345<form method="post" class="box">
346<p>Signed in as <b>${escape(user.displayName)}</b>. Let this app:</p>
347<input type="hidden" name="handle" value="${escape(consent.handle)}">${scopes}
348<p class="muted"><small>Code it plays runs in your tab's sandbox, apart from the page and your account; Jev's calls from it, and screening what it publishes, count against your daily Jev budget. It can reach only your own tabs. Disconnect it any time from your account panel (connected apps).</small></p>
349<button class="go" name="decision" value="approve">allow</button><button name="decision" value="deny">deny</button>
350</form>`;
351    return htmlResponse(page('Connect an app', body, nonce), nonce, consent.headers, action);
352  } catch (e) {
353    return authorizeError(e);
354  }
355}
356
357async function approve(request: Request, env: Env, helpers: OAuthHelpers): Promise<Response> {
358  const user = await signedIn(request, d1Accounts(env.DB));
359  if (!user) return text(401, 'sign in first, then start connecting again from your app');
360  const form = await request.formData();
361  const handle = String(form.get('handle') ?? '');
362  const scope = form.getAll('scope').map(String).filter((s): s is Scope => s in SCOPES);
363  if (form.get('decision') !== 'approve' || !scope.length) {
364    const denied = await helpers.denyConsent(request, handle);
365    return new Response(null, { status: 302, headers: denied.headers });
366  }
367  const approved = await helpers.approveConsent(request, handle, { scope });
368  const client = await helpers.lookupClient(approved.request.clientId);
369  const metadata: AppMetadata = {
370    app: client ? appName(client) : 'an MCP client',
371    redirectHost: new URL(approved.request.redirectUri).hostname,
372    published: client ? publishedBy(client) : null,
373  };
374  const { redirectTo } = await helpers.completeAuthorization({
375    request: approved.request,
376    userId: user.id,
377    metadata,
378    scope: approved.request.scope.filter((s) => s in SCOPES),
379    props: { userId: user.id, app: metadata.app } satisfies McpProps,
380  });
381  approved.headers.set('Location', redirectTo);
382  return new Response(null, { status: 302, headers: approved.headers });
383}
384
385function authorizeError(e: unknown): Response {
386  if (e instanceof AuthorizationError && e.redirectUri) {
387    const redirect = new URL(e.redirectUri);
388    redirect.searchParams.set('error', e.code);
389    redirect.searchParams.set('error_description', e.description);
390    if (e.state) redirect.searchParams.set('state', e.state);
391    if (e.issuer) redirect.searchParams.set('iss', e.issuer);
392    return Response.redirect(redirect.href, 302);
393  }
394  if (e instanceof AuthorizationError) return text(400, e.description);
395  if (e instanceof CimdFetchError) return text(400, 'This app could not be verified: its metadata document could not be read.');
396  throw e;
397}