Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-mcp/src/oauth.ts
Snapshot at 34e3175
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 "${escape(details.clientName)}" 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