Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-mcp/src/oauth.ts

Snapshot at 34e3175

Containing directory

16,845 bytes · typescriptView raw
1import { OAuthProvider, AuthorizationError } from "@cloudflare/workers-oauth-provider";
2import type {
3  OAuthHelpers,
4  OAuthProviderOptions,
5  ConsentDescription,
6} from "@cloudflare/workers-oauth-provider";
7import { z } from "zod";
8import brand from "../../../brand.json";
9import { boundedCall, MAX_BYTES, PublicError, readBounded } from "./core";
10import type { OwnerIdentity } from "./core";
11import { createMcpHandler, endpointConfig, publicFailure } from "./mcp";
12import type { McpOptions } from "./mcp";
13import { consentRedirect, requireRegisteredRedirect } from "./consent";
14
15export interface OAuthEnv {
16  OAUTH_KV: KVNamespace;
17  OAUTH_PROVIDER: OAuthHelpers;
18}
19export interface OAuthOptions extends McpOptions {
20  /** Project chosen in owner deployment configuration, never inferred from a client URL. */
21  project: string;
22  /** FH-012 must verify JWT signature, issuer, audience, expiry and owner allowlist here. */
23  authorizeOwner(request: Request, signal: AbortSignal): Promise<OwnerIdentity | null>;
24}
25const scope = "mcp:agent";
26const grant = z.object({
27  kind: z.enum(["core", "grant"]),
28  token: z.string().min(1).max(512),
29  project: z.string(),
30  agent: z.string(),
31});
32const escape = (value: string) =>
33  value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
34function consentPage(details: ConsentDescription, handle: string, project: string) {
35  // Outside the OS shell: inline token values from design/tokens.json, with system fonts only.
36  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
37<meta name="viewport" content="width=device-width, initial-scale=1">
38<title>${escape(brand.name)} authorization</title>
39<style>
40:root {
41  color-scheme: light;
42  --color-kumo-base: #fcfcfb;
43  --color-kumo-elevated: #f8f8f7;
44  --color-kumo-tint: #f3f3f1;
45  --color-kumo-brand: #ff4801;
46  --color-kumo-brand-hover: #e03f00;
47  --color-kumo-line: #1411100f;
48  --color-kumo-focus: oklch(15% 0 0);
49  --color-kumo-warning-tint: oklch(92% 0.08 85);
50  --text-color-kumo-default: #1c1a18;
51  --text-color-kumo-strong: #100f0d;
52  --text-color-kumo-subtle: oklch(52% 0.006 60);
53  --text-color-kumo-inverse: #ffffff;
54  --font-sans: "FT Kunst Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif;
55  --font-mono: "Apercu Mono Pro", "SF Mono", Menlo, Monaco, Consolas, monospace;
56  --radius-lg: 0.5rem;
57  --radius-xl: 0.75rem;
58  --spacing: 0.25rem;
59}
60@media (prefers-color-scheme: dark) {
61  :root {
62    color-scheme: dark;
63    --color-kumo-base: oklch(0.115 0.012 285);
64    --color-kumo-elevated: oklch(0.155 0.011 285);
65    --color-kumo-tint: oklch(0.225 0.025 285);
66    --color-kumo-brand: #b84e00;
67    --color-kumo-brand-hover: #a54200;
68    --color-kumo-line: oklch(0.34 0.022 285);
69    --color-kumo-focus: oklch(93.5% 0 0);
70    --color-kumo-warning-tint: oklch(0.27 0.06 84.429);
71    --text-color-kumo-default: oklch(0.92 0.01 285);
72    --text-color-kumo-strong: oklch(0.92 0.01 285);
73    --text-color-kumo-subtle: oklch(0.66 0.02 285);
74    --text-color-kumo-inverse: #ffffff;
75  }
76}
77* { box-sizing: border-box; }
78body {
79  margin: 0;
80  padding: calc(var(--spacing) * 16) calc(var(--spacing) * 3);
81  background: var(--color-kumo-elevated);
82  color: var(--text-color-kumo-default);
83  font: 14px/20px var(--font-sans);
84}
85.consent { max-width: 32rem; margin: 0 auto; }
86.lockup {
87  margin: 0 0 calc(var(--spacing) * 4);
88  text-align: center;
89  color: var(--text-color-kumo-subtle);
90  font-size: 13px;
91  font-weight: 500;
92}
93.card {
94  padding: calc(var(--spacing) * 6);
95  background: var(--color-kumo-base);
96  border: 1px solid var(--color-kumo-line);
97  border-radius: var(--radius-xl);
98  overflow-wrap: anywhere;
99}
100h1 {
101  margin: 0;
102  color: var(--text-color-kumo-strong);
103  font-size: 24px;
104  line-height: 32px;
105  font-weight: 600;
106  letter-spacing: -0.025em;
107}
108p { margin: 0; }
109.description {
110  margin-top: calc(var(--spacing) * 2);
111  color: var(--text-color-kumo-subtle);
112  font-size: 13px;
113  line-height: 18px;
114  letter-spacing: -0.25px;
115}
116.permissions { margin-top: calc(var(--spacing) * 6); }
117h2 { margin: 0; font-size: 14px; line-height: 20px; font-weight: 500; }
118ul { margin: calc(var(--spacing) * 2) 0; padding-left: calc(var(--spacing) * 5); }
119.limits { color: var(--text-color-kumo-subtle); }
120.warning {
121  display: flex;
122  gap: calc(var(--spacing) * 2);
123  margin-top: calc(var(--spacing) * 6);
124  padding: calc(var(--spacing) * 3);
125  border-radius: var(--radius-lg);
126  background: var(--color-kumo-warning-tint);
127}
128.warning svg { flex: none; margin-top: 2px; }
129.warning p { min-width: 0; }
130code { font-family: var(--font-mono); }
131.context { margin-top: calc(var(--spacing) * 3); }
132.scope { margin-top: calc(var(--spacing) * 6); }
133.scope span { margin-right: calc(var(--spacing) * 2); font-size: 12px; color: var(--text-color-kumo-subtle); }
134form { display: flex; justify-content: flex-end; gap: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 6); }
135button {
136  height: 36px;
137  padding: 0 calc(var(--spacing) * 4);
138  border: 1px solid var(--color-kumo-line);
139  border-radius: var(--radius-lg);
140  background: var(--color-kumo-base);
141  color: var(--text-color-kumo-default);
142  font: 500 14px/20px var(--font-sans);
143  cursor: pointer;
144}
145button:hover { background: var(--color-kumo-tint); }
146button[value="approve"] { background: var(--color-kumo-brand); color: var(--text-color-kumo-inverse); border-color: var(--color-kumo-brand); }
147button[value="approve"]:hover { background: var(--color-kumo-brand-hover); border-color: var(--color-kumo-brand-hover); }
148button:focus-visible { outline: 2px solid var(--color-kumo-focus); outline-offset: 2px; }
149@media (max-width: 639px) {
150  body { padding-top: calc(var(--spacing) * 6); }
151  .card { padding: calc(var(--spacing) * 4); }
152}
153</style></head><body>
154<main class="consent">
155<p class="lockup"><span aria-hidden="true">${escape(brand.mark)}</span> ${escape(brand.name)}</p>
156<section class="card" aria-labelledby="consent-title">
157<h1 id="consent-title">Allow ${escape(details.clientName)} to work on ${escape(project)}?</h1>
158<p class="description">It will join as a new agent in this project.</p>
159<div class="permissions">
160<h2>It will be able to</h2>
161<ul>${details.scope.includes(scope) ? "<li>read project state</li><li>create its own forks</li><li>claim packets</li><li>submit work and request review</li>" : ""}</ul>
162<p class="limits">It can't approve or promote. Those stay with you.</p>
163</div>
164<div class="warning" role="note">
165<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1.5 15 14H1Z" stroke-linejoin="round"/><path d="M8 5.5v4M8 11v.5"/></svg>
166<p>The name &quot;${escape(details.clientName)}&quot; is self-reported by the app. After you choose, you return to <strong><code>${escape(details.redirectHost)}</code></strong>.</p>
167</div>
168${details.clientDomain ? `<p class="context">Client domain: <code>${escape(details.clientDomain)}</code></p>` : ""}
169${details.redirectIsLoopback ? '<p class="context">This sends access to an app on your computer. Continue only if you started this login.</p>' : ""}
170<p class="scope"><span>Scope</span> <code>${details.scope.map(escape).join(", ")}</code></p>
171<form method="post"><input type="hidden" name="handle" value="${escape(handle)}">
172<button type="submit" name="decision" value="deny">Deny</button><button type="submit" name="decision" value="approve">Allow</button></form>
173</section></main></body></html>`;
174}
175
176function configureOAuth(options: OAuthOptions) {
177  // Only consent responses checked below may leave the wrapper as a 3xx.
178  const consentResponses = new WeakSet<Response>();
179  const config = endpointConfig(options);
180  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(options.project) || options.project.length > 48)
181    throw new Error("Invalid OAuth project.");
182  const mcp = createMcpHandler(options);
183  const configuration: OAuthProviderOptions<OAuthEnv> = {
184    apiRoute: config.path,
185    authorizeEndpoint: `${config.origin}/authorize`,
186    tokenEndpoint: `${config.origin}/oauth/token`,
187    clientRegistrationEndpoint: `${config.origin}/oauth/register`,
188    resourceMetadata: {
189      resource: config.resource,
190      authorization_servers: [config.origin],
191      resource_name: brand.name,
192      bearer_methods_supported: ["header"],
193    },
194    scopesSupported: [scope],
195    requiredScopes: [scope],
196    accessTokenTTL: 3600,
197    refreshTokenTTL: 86400,
198    clientRegistrationTTL: 86400,
199    clientIdMetadataDocumentEnabled: false,
200    allowTokenExchangeGrant: false,
201    // Override the provider's default error logger and keep wire errors free of internal detail.
202    onError: ({ status, code, headers }) =>
203      Response.json(
204        { error: code, error_description: "OAuth request refused." },
205        { status, headers: { ...headers, "cache-control": "no-store" } },
206      ),
207    // Opaque core API credentials are validated independently; owner tokens must be refused.
208    async resolveExternalToken({ token, request }) {
209      return boundedCall(
210        async (signal) => {
211          const identity = await options.core.authenticate(token, signal);
212          if (!identity || identity.project !== options.project) return null;
213          return { audience: config.resource, props: { kind: "core", token, ...identity } };
214        },
215        config.timeoutMs,
216        request.signal,
217      );
218    },
219    apiHandler: {
220      async fetch(request, _env, ctx) {
221        const authContext = ctx as typeof ctx & { props: unknown; auth?: { scope: string[] } };
222        const parsed = grant.safeParse(authContext.props);
223        if (!parsed.success || parsed.data.project !== options.project)
224          throw new PublicError(401, "Invalid authorization grant.");
225        if (parsed.data.kind === "grant" && !authContext.auth?.scope.includes(scope))
226          throw new PublicError(403, "Agent scope is required.");
227        const headers = new Headers(request.headers);
228        headers.set("authorization", `Bearer ${parsed.data.token}`);
229        // Revalidate the enrolled credential on every call, so core rotation revokes this grant's access.
230        return mcp(new Request(request, { headers }));
231      },
232    },
233    defaultHandler: {
234      async fetch(request, env) {
235        if (new URL(request.url).pathname !== "/authorize")
236          return new Response(null, { status: 404 });
237        return boundedCall(
238          async (signal) => {
239            const owner = await options.authorizeOwner(request, signal);
240            if (!owner?.subject) throw new PublicError(403, "Owner Access login is required.");
241            const oauth = env.OAUTH_PROVIDER;
242            if (request.method === "GET") {
243              const auth = await oauth.parseAuthRequest(request);
244              await requireRegisteredRedirect(oauth, auth);
245              if (
246                auth.codeChallengeMethod !== "S256" ||
247                !auth.codeChallenge ||
248                auth.scope.length !== 1 ||
249                auth.scope[0] !== scope
250              )
251                throw new PublicError(400, "S256 PKCE and the agent scope are required.");
252              const details = await oauth.describeConsent(auth);
253              const consent = await oauth.beginConsent(auth);
254              consent.headers.set("content-type", "text/html; charset=utf-8");
255              return new Response(consentPage(details, consent.handle, options.project), {
256                headers: consent.headers,
257              });
258            }
259            if (request.method !== "POST")
260              return new Response(null, { status: 405, headers: { allow: "GET, POST" } });
261            if (request.headers.get("origin") !== config.origin)
262              throw new PublicError(403, "Consent origin is not allowed.");
263            const form = await request.formData();
264            const handle = form.get("handle");
265            if (typeof handle !== "string" || handle.length > 1024)
266              throw new PublicError(400, "Invalid consent handle.");
267            if (form.get("decision") === "deny") {
268              const denied = await oauth.denyConsent(request, handle);
269              await requireRegisteredRedirect(oauth, denied.request);
270              const response = consentRedirect(
271                denied.request,
272                denied.redirectTo,
273                denied.headers,
274                "deny",
275              );
276              consentResponses.add(response);
277              return response;
278            }
279            if (form.get("decision") !== "approve")
280              throw new PublicError(400, "An explicit consent decision is required.");
281            const approved = await oauth.approveConsent(request, handle);
282            await requireRegisteredRedirect(oauth, approved.request);
283            if (
284              approved.request.codeChallengeMethod !== "S256" ||
285              approved.request.scope.length !== 1 ||
286              approved.request.scope[0] !== scope
287            )
288              throw new PublicError(400, "Invalid authorization scope or PKCE.");
289            signal.throwIfAborted();
290            const agent = `oauth-${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
291            const enrolled = await options.core.enrollAgent(owner, options.project, agent, signal);
292            if (enrolled.project !== options.project || enrolled.agent !== agent)
293              throw new PublicError(502, "Core enrollment identity mismatch.");
294            const props = grant.parse({
295              kind: "grant",
296              token: enrolled.apiToken,
297              project: enrolled.project,
298              agent: enrolled.agent,
299            });
300            signal.throwIfAborted();
301            const { redirectTo } = await oauth.completeAuthorization({
302              request: approved.request,
303              userId: encodeURIComponent(owner.subject),
304              metadata: { project: enrolled.project, agent: enrolled.agent },
305              scope: [scope],
306              props,
307            });
308            const response = consentRedirect(
309              approved.request,
310              redirectTo,
311              approved.headers,
312              "approve",
313            );
314            consentResponses.add(response);
315            return response;
316          },
317          config.timeoutMs,
318          request.signal,
319        );
320      },
321    },
322  };
323  return { configuration, consentResponses };
324}
325
326/** Provider configuration for the single core Worker. No Worker entrypoint or deployment is created. */
327export function createOAuthConfiguration(options: OAuthOptions): OAuthProviderOptions<OAuthEnv> {
328  return configureOAuth(options).configuration;
329}
330
331/** Bounded facade for discovery, DCR, PKCE, consent, token exchange and authenticated MCP. */
332export function createOAuthHandler(options: OAuthOptions) {
333  const config = endpointConfig(options);
334  const { configuration, consentResponses } = configureOAuth(options);
335  const provider = new OAuthProvider<OAuthEnv>(configuration);
336  return async (request: Request, env: OAuthEnv, ctx: ExecutionContext): Promise<Response> => {
337    try {
338      return await boundedCall(
339        async (signal) => {
340          const url = new URL(request.url);
341          if (url.origin !== config.origin) throw new PublicError(403, "Host is not allowed.");
342          const origin = request.headers.get("origin");
343          if (origin && ![config.origin, ...(options.allowedOrigins ?? [])].includes(origin))
344            throw new PublicError(403, "Origin is not allowed.");
345          const body = request.body
346            ? await readBounded(request.body, MAX_BYTES - 1, signal)
347            : undefined;
348          const bounded = new Request(request.url, {
349            method: request.method,
350            headers: request.headers,
351            body,
352            redirect: "manual",
353            signal,
354          });
355          // In-process dispatch: do not fetch the provider's returned Location.
356          const response = await provider.fetch(bounded, env, ctx);
357          if (response.status >= 300 && response.status < 400 && !consentResponses.has(response)) {
358            void response.body?.cancel().catch(() => {});
359            throw new PublicError(502, "OAuth redirect refused.");
360          }
361          const text = await readBounded(response.body, MAX_BYTES - 1, signal);
362          const headers = new Headers(response.headers);
363          headers.set("cache-control", "no-store");
364          return new Response(text || null, { status: response.status, headers });
365        },
366        config.timeoutMs,
367        request.signal,
368      );
369    } catch (error) {
370      // Do not redirect untrusted OAuth error text or expose provider/internal credentials.
371      return publicFailure(
372        error instanceof AuthorizationError
373          ? new PublicError(400, "Authorization request is invalid or expired.")
374          : error,
375      );
376    }
377  };
378}
379