1// OAuth 2.1 for the hosted MCP (hosted-mcp.ts), per MCP's authorization 2// spec, with Cloudflare's @cloudflare/workers-oauth-provider: this Worker is 3// both the authorization server and the protected resource. 4// 5// GET /.well-known/oauth-protected-resource/jev/mcp RFC 9728: where to authorize } the 6// GET /.well-known/oauth-authorization-server RFC 8414: the endpoints } library's 7// POST /jev/oauth/token code and refresh exchange, and RFC 7009 revocation } 8// POST /jev/oauth/register RFC 7591 dynamic registration, for clients without } 9// Client ID Metadata Documents (which are preferred) } 10// GET /jev/oauth/authorize ours: sign in with a passkey, then the consent page 11// POST /jev/oauth/authorize ours: the consent form's answer 12// POST /jev/mcp the MCP itself, with a bearer token (hosted-mcp.ts) 13// GET /jev/me/apps the signed-in user's connected apps (grants) 14// DELETE /jev/me/apps/<id> disconnect one: its tokens stop working 15// 16// The library keeps clients, grants and tokens in the OAUTH_KV namespace: 17// tokens, codes and client secrets only as hashes, the grant's props 18// ({ userId }, and the app's name) encrypted with a key wrapped by the 19// token; a grant's user id and metadata are in the clear, since listing a 20// user's grants needs them (README.md, Storage). 21// 22// The issuer and the resource are the request's own origin, as the passkey 23// relying party is (auth.ts): https://jevstrudel.deizel.workers.dev in 24// production, http://localhost:<port> in dev (the library allows http on 25// loopback only). One pair of servers per origin, built once. 26// 27// Sign-in is this site's passkeys and nothing else: the authorize page is 28// served from the site's origin, so the session cookie and the passkey's 29// relying party are the site's own, and a user who is not signed in signs 30// in (or makes an account) on that page with the same ceremonies the 31// 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'; 45 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-'; 53 54// An access token lives an hour; a grant as long as it keeps being used, 55// and 30 days after its last refresh. 56const ACCESS_TTL_S = 3600; 57const GRANT_TTL_S = 30 * 24 * 3600; 58// A user's connected apps listed at once; nobody connects this many. 59const MAX_APPS = 100; 60 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} 99 100// 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); 109 110 // Registering, authorizing and swapping codes share the passkey 111 // ceremonies' per-visitor rate (AUTH_LIMIT): a connection is a handful of 112 // these, and each writes to OAUTH_KV, whose writes a day are few on the 113 // 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 } 124 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 } }); 135 136// ── a tab joining its user's hub ────────────────── 137// Only from this site's own pages (signedIn refuses a cookie from any other 138// origin, and a browser's WebSocket always sends its page's Origin), and 139// 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} 146 147// ── connected apps ──────────────────────────────── 148type AppMetadata = { app: string; redirectHost: string; published: string | null }; 149 150// A user's connected apps, as that user sees them (never a token): here, 151// 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} 167 168// How many apps each account has connected, from the grants' key names 169// alone (`grant:<userId>:<grantId>`, the library's; README.md, Storage): 170// no grant is read, so no app name, token hash or encrypted props. For the 171// data tab (data.ts), which shows counts to anyone. At most `pages` KV 172// 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} 194 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} 210 211// ── the authorize page ──────────────────────────── 212const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); 213const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/; 214 215// Where a client's name comes from: a CIMD client's id is a URL on its own 216// 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); 220 221// 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} 226 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)))); 263 264// The passkey ceremonies of the header's panel (website/src/jev/account.mjs), 265// without its library: the Worker's own /jev/auth/* routes, and the 266// 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`; 301 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} 398