History

apps: authenticate the HTTP surface behind Better Auth

The apps HTTP routes (publish/invoke/workflow/ui/SSE) were mounted as an extension route outside the protected API and never authenticated. Thread the host's Better Auth identity check into the apps handler (session cookie / bearer / bearer-as-api-key), 401 unauthenticated including SSE. The subsystem singleton reads a live auth ref per request and is fail-closed until it is set.

Rhys Sullivan committed 4 months ago

Commit d4ec45e1 parent 191124e5 files · +211 −22Browse files

Unified · Split

5 files
  1. apps/host-selfhost/src/app.ts +26 −1
  2. apps/host-selfhost/src/apps.node.test.ts +79 −17
  3. apps/host-selfhost/src/apps.ts +35 −1
  4. packages/plugins/apps/src/http/routes.ts +42 −1
  5. packages/plugins/apps/src/plugin/self-host.ts +29 −2
apps/host-selfhost/src/app.ts+26−1
@@ -75,7 +75,32 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 74 unmodified lines
7575 // closes over). Its HTTP surface mounts under /api/apps/*; published tools are
7676 // catalog citizens through its source plugin; its extra MCP surface (publish
7777 // door, skills, ui:// resources) is registered on the real MCP server below.
78− const apps = getSelfHostAppsSubsystem();
78+ //
79+ // The HTTP surface (publish/invoke/workflows/ui/SSE) mutates per-scope state
80+ // and reaches real integrations, so it is authenticated with the SAME Better
81+ // Auth credential the rest of `/api` requires. We resolve a session from the
82+ // same three credential shapes the identity seam accepts (session cookie,
83+ // Bearer session token, Bearer value retried as an x-api-key), matching
84+ // `betterAuthIdentityLayer` so a token that works on `/api` works here too.
85+ const appsAuthenticate = async (request: Request): Promise<boolean> => {
86+ const auth = betterAuth.auth;
87+ const session = await auth.api.getSession({ headers: request.headers }).catch(() => null);
88+ if (session) return true;
89+ const authorization = request.headers.get("authorization");
90+ const token =
91+ authorization && authorization.toLowerCase().startsWith("bearer ")
92+ ? authorization.slice(7).trim()
93+ : undefined;
94+ if (!token) return false;
95+ const apiKeySession = await auth.api
96+ .getSession({ headers: new Headers({ "x-api-key": token }) })
97+ .catch(() => null);
98+ return apiKeySession != null;
99+ };
100+ const apps = getSelfHostAppsSubsystem({
101+ authenticate: appsAuthenticate,
102+ webBaseUrl: config.webBaseUrl,
103+ });
79104
80105 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
81106 // Pass the pinned public origin so browser-approval URLs are reachable behind
apps/host-selfhost/src/apps.node.test.ts+79−17
@@ -4,11 +4,30 @@ import { join } from "node:path"; · 3 unmodified lines
44
55 import { afterAll, beforeAll, expect, test } from "@effect/vitest";
66
7+import { mintInviteCode } from "./testing/mint-invite";
8+
79 // Point config at a throwaway data dir before importing the app graph.
810 process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-"));
11+process.env.BETTER_AUTH_SECRET = "apps-node-secret-0123456789-abcdefghij-klmnop";
12+process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-node.test";
13+process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
914
1015 let handler!: (request: Request) => Promise<Response>;
1116 let dispose: () => Promise<void> = async () => {};
17+let token = "";
18+
19+// A JSON fetch that carries the bearer (the apps HTTP surface is authenticated).
20+const authed = (path: string, init?: RequestInit) =>
21+ handler(
22+ new Request(`http://localhost${path}`, {
23+ ...init,
24+ headers: {
25+ authorization: `Bearer ${token}`,
26+ "content-type": "application/json",
27+ ...(init?.headers ?? {}),
28+ },
29+ }),
30+ );
1231
1332 beforeAll(async () => {
1433 // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps
@@ -17,9 +36,53 @@ beforeAll(async () => { · 2 unmodified lines
1736 const app = await makeSelfHostApiHandler();
1837 handler = app.handler;
1938 dispose = app.dispose;
39+
40+ // Sign up to get a bearer token (the apps surface is behind Better Auth).
41+ const inviteCode = await mintInviteCode(handler);
42+ const su = await handler(
43+ new Request("http://localhost/api/auth/sign-up/email", {
44+ method: "POST",
45+ headers: { "content-type": "application/json" },
46+ body: JSON.stringify({
47+ email: "u@apps-node.test",
48+ password: "password-12345678",
49+ name: "U",
50+ inviteCode,
51+ }),
52+ }),
53+ );
54+ token = su.headers.get("set-auth-token") ?? "";
55+ expect(token).not.toBe("");
2056 });
2157 afterAll(() => dispose());
2258
59+// Fix 1: the apps HTTP surface rejects unauthenticated requests with 401.
60+test("apps HTTP surface requires auth (401 without a credential)", async () => {
61+ // publish / invoke / workflow-start / SSE all 401 unauthenticated.
62+ const publish = await handler(
63+ new Request("http://localhost/api/apps/default/publish", {
64+ method: "POST",
65+ headers: { "content-type": "application/json" },
66+ body: JSON.stringify({ files: FILES }),
67+ }),
68+ );
69+ expect(publish.status).toBe(401);
70+
71+ const invoke = await handler(
72+ new Request("http://localhost/api/apps/default/tools/note", {
73+ method: "POST",
74+ headers: { "content-type": "application/json" },
75+ body: JSON.stringify({ args: { text: "x" }, bindings: {} }),
76+ }),
77+ );
78+ expect(invoke.status).toBe(401);
79+
80+ const sse = await handler(
81+ new Request("http://localhost/api/apps/default/live", { method: "GET" }),
82+ );
83+ expect(sse.status).toBe(401);
84+}, 30_000);
85+
2386 // A minimal published app: one tool that writes the scope db, and a ui view.
2487 const FILES = {
2588 "tools/note.ts":
@@ -32,14 +95,12 @@ const FILES = { · 6 unmodified lines
3295 `export default function App() { return null; }`,
3396 };
3497
35−test("apps HTTP surface is mounted: publish then serve the ui bundle", async () => {
36− // Publish over the booted server's /api/apps/:scope/publish route.
37− const publishRes = await handler(
38− new Request("http://localhost/api/apps/default/publish", {
39− method: "POST",
40− body: JSON.stringify({ files: FILES }),
41− }),
42− );
98+test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => {
99+ // Publish over the booted server's /api/apps/:scope/publish route (authed).
100+ const publishRes = await authed("/api/apps/default/publish", {
101+ method: "POST",
102+ body: JSON.stringify({ files: FILES }),
103+ });
43104 expect(publishRes.status).toBe(200);
44105 const published = (await publishRes.json()) as {
45106 descriptor: { tools: { name: string }[]; ui: { name: string }[] };
@@ -48,20 +109,21 @@ test("apps HTTP surface is mounted: publish then serve the ui bundle", async () · 2 unmodified lines
48109 expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]);
49110
50111 // The ui bundle is served (compiled JS) with its title header.
51− const uiRes = await handler(
52− new Request("http://localhost/api/apps/default/ui/board", { method: "GET" }),
53− );
112+ const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" });
54113 expect(uiRes.status).toBe(200);
55114 expect(uiRes.headers.get("content-type")).toContain("javascript");
56115 expect(uiRes.headers.get("x-ui-title")).toBe("Board");
57116
117+ // The HTML document variant (Fix 10 fallback target) is served behind auth.
118+ const docRes = await authed("/api/apps/default/ui/board?document=html", { method: "GET" });
119+ expect(docRes.status).toBe(200);
120+ expect(docRes.headers.get("content-type")).toContain("text/html");
121+
58122 // Invoke the tool (scope-db path is live in the running server).
59− const invokeRes = await handler(
60− new Request("http://localhost/api/apps/default/tools/note", {
61− method: "POST",
62− body: JSON.stringify({ args: { text: "hello" }, bindings: {} }),
63− }),
64− );
123+ const invokeRes = await authed("/api/apps/default/tools/note", {
124+ method: "POST",
125+ body: JSON.stringify({ args: { text: "hello" }, bindings: {} }),
126+ });
65127 expect(invokeRes.status).toBe(200);
66128 const invoked = (await invokeRes.json()) as { result: { count: number } };
67129 expect(invoked.result.count).toBe(1);
apps/host-selfhost/src/apps.ts+35−1
@@ -54,11 +54,36 @@ const notImplementedResolver: ClientResolver = { · 53 unmodified lines
5454 ),
5555 };
5656
57+/** Options for the apps subsystem singleton. `authenticate` gates the HTTP
58+ * surface; supplied by `app.ts` from the resolved Better Auth instance.
59+ * `webBaseUrl` is the public origin used to build the `apps_open_ui` HTTP
60+ * fallback URL. */
61+export interface SelfHostAppsSubsystemOptions {
62+ readonly authenticate?: (request: Request) => Promise<boolean>;
63+ readonly webBaseUrl?: string;
64+}
65+
5766 // The boot-time singleton. Built lazily on first access so importing this module
5867 // (which executor.config.ts does) does not do filesystem work at import time.
5968 let subsystem: SelfHostApps | undefined;
6069
61−export const getSelfHostAppsSubsystem = (): SelfHostApps => {
70+// The live authenticate + fallback-URL refs. The subsystem is a singleton and is
71+// FIRST built by executor.config.ts (which has no auth), then app.ts calls again
72+// WITH auth. So we can't bake auth into the http handler at build time — instead
73+// the handler reads these mutable refs per request, and app.ts sets them once the
74+// Better Auth instance is resolved. Until set, the surface DENIES all requests
75+// (fail-closed), so a misconfigured boot never exposes the surface unauthed.
76+let authenticateRef: ((request: Request) => Promise<boolean>) | undefined;
77+let webBaseUrlRef: string | undefined;
78+
79+export const getSelfHostAppsSubsystem = (
80+ options: SelfHostAppsSubsystemOptions = {},
81+): SelfHostApps => {
82+ // Apply any newly-supplied config to the live refs (app.ts's call carries the
83+ // real auth even though config.ts built the singleton first).
84+ if (options.authenticate) authenticateRef = options.authenticate;
85+ if (options.webBaseUrl) webBaseUrlRef = options.webBaseUrl;
86+
6287 if (!subsystem) {
6388 subsystem = makeSelfHostApps({
6489 dataDir: resolveDataDir(),
@@ -71,6 +96,13 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 6 unmodified lines
7196 // so external integration calls resolve the user's connection + credential
7297 // at the boundary and dispatch over the request's HttpClient.
7398 makeResolver: ({ ctx }) => makeCtxResolver(ctx),
99+ // Read the live auth ref per request. Fail-closed: if app.ts hasn't wired
100+ // the Better Auth check yet, deny (never expose the surface unauthed).
101+ authenticate: (request) =>
102+ authenticateRef ? authenticateRef(request) : Promise.resolve(false),
103+ // Read the live web base URL ref (used for the apps_open_ui fallback URL).
104+ webBaseUrl: undefined,
105+ webBaseUrlFn: () => webBaseUrlRef,
74106 });
75107 }
76108 return subsystem;
@@ -79,6 +111,8 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 2 unmodified lines
79111 /** Reset the singleton (tests build fresh instances per data dir). */
80112 export const resetSelfHostAppsSubsystem = (): void => {
81113 subsystem = undefined;
114+ authenticateRef = undefined;
115+ webBaseUrlRef = undefined;
82116 };
83117
84118 /** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */
packages/plugins/apps/src/http/routes.ts+42−1
@@ -23,6 +23,18 @@ export interface AppsHttpDeps { · 22 unmodified lines
2323 readonly runtime: AppsRuntime;
2424 /** Mount prefix (default "/api/apps"). */
2525 readonly prefix?: string;
26+ /**
27+ * Authenticate an inbound request. Returns `true` when the caller is
28+ * authorized and `false` otherwise; the handler answers `false` with a 401.
29+ *
30+ * The apps surface (publish / invoke / workflow lifecycle / SSE) mutates
31+ * per-scope state and reaches real integrations, so it MUST be behind the same
32+ * credential the rest of `/api` requires. This seam lets the host thread its
33+ * own identity check (self-host's Better Auth session/bearer/api-key) in
34+ * without this package importing the host's auth stack. When omitted (tests
35+ * that drive the runtime directly) every request is allowed.
36+ */
37+ readonly authenticate?: (request: Request) => Promise<boolean>;
2638 }
2739
2840 const json = (body: unknown, status = 200): Response =>
@@ -42,6 +54,20 @@ export const makeAppsHttpRoutes = ( · 13 unmodified lines
4254 const handler = async (request: Request): Promise<Response> => {
4355 const url = new URL(request.url);
4456 if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 });
57+
58+ // Auth gate: the whole apps surface (publish/invoke/workflows/ui/SSE) is
59+ // behind the host's identity check. An unauthenticated caller gets a 401
60+ // BEFORE any route logic runs — including the SSE stream.
61+ if (deps.authenticate) {
62+ const ok = await deps.authenticate(request).catch(() => false);
63+ if (!ok) {
64+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
65+ status: 401,
66+ headers: { "content-type": "application/json" },
67+ });
68+ }
69+ }
70+
4571 const rest = url.pathname.slice(prefix.length).replace(/^\//, "");
4672 const parts = rest.split("/").filter(Boolean);
4773 // parts[0] = scope
@@ -134,8 +160,23 @@ export const makeAppsHttpRoutes = ( · 86 unmodified lines
134160 return json({ runs });
135161 }
136162
137− // GET :scope/ui/:name -> compiled JS bundle
163+ // GET :scope/ui/:name -> compiled JS bundle, OR the self-booting HTML
164+ // document when `?document=html` (Fix 10: the fallback a non-UI MCP client
165+ // opens in a browser). Both are behind the same auth gate as the rest of
166+ // the surface.
138167 if (parts[1] === "ui" && parts[2] && request.method === "GET") {
168+ if (url.searchParams.get("document") === "html") {
169+ const doc = await run(runtime.getUiDocument(scope, parts[2]));
170+ if (!doc) return new Response("ui not found", { status: 404 });
171+ return new Response(doc.html, {
172+ status: 200,
173+ headers: {
174+ "content-type": "text/html; charset=utf-8",
175+ "x-ui-title": doc.title ?? "",
176+ "x-ui-max-height": String(doc.maxHeight ?? ""),
177+ },
178+ });
179+ }
139180 const bundle = await run(runtime.getUiBundle(scope, parts[2]));
140181 if (!bundle) return new Response("ui not found", { status: 404 });
141182 return new Response(bundle.code, {
packages/plugins/apps/src/plugin/self-host.ts+29−2
@@ -28,6 +28,18 @@ export interface SelfHostAppsOptions { · 27 unmodified lines
2828 /** Optional per-request resolver factory for the catalog invoke path (built
2929 * from the invoking executor context). */
3030 readonly makeResolver?: AppsPluginOptions["makeResolver"];
31+ /** Authenticate the apps HTTP surface (publish/invoke/workflows/ui/SSE). The
32+ * host threads its identity check here so this package doesn't import the
33+ * host's auth stack. Omit to leave the surface open (tests). */
34+ readonly authenticate?: (request: Request) => Promise<boolean>;
35+ /** Absolute base URL (e.g. `https://host`) used to build the HTTP fallback URL
36+ * for `apps_open_ui` when the MCP client can't render UI inline. Omit to
37+ * serve no fallback URL (the tool returns `fallback_unavailable`). */
38+ readonly webBaseUrl?: string;
39+ /** Lazy variant of `webBaseUrl` for hosts whose base URL is resolved AFTER the
40+ * subsystem singleton is built (self-host: config.ts builds it, app.ts sets
41+ * the URL). Read per registration; takes precedence over `webBaseUrl`. */
42+ readonly webBaseUrlFn?: () => string | undefined;
3143 }
3244
3345 export interface SelfHostApps {
@@ -48,7 +60,10 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 14 unmodified lines
4860 store,
4961 resolver: options.resolver,
5062 });
51− const http = makeAppsHttpRoutes({ runtime: host.runtime });
63+ const http = makeAppsHttpRoutes({
64+ runtime: host.runtime,
65+ authenticate: options.authenticate,
66+ });
5267 const plugin = appsPlugin({
5368 runtime: host.runtime,
5469 resolveBindings: options.resolveBindings,
@@ -58,7 +73,19 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 3 unmodified lines
5873 runtime: host.runtime,
5974 http,
6075 registerMcp: (server) =>
61− registerAppsMcp(server, { runtime: host.runtime, scope: options.scope }),
76+ registerAppsMcp(server, {
77+ runtime: host.runtime,
78+ scope: options.scope,
79+ // Read the base URL lazily so a host that resolves it after building the
80+ // subsystem singleton still gets a working fallback URL.
81+ uiDocumentUrl: (scope, name) => {
82+ const base = options.webBaseUrlFn?.() ?? options.webBaseUrl;
83+ if (!base) return undefined;
84+ return `${base.replace(/\/$/, "")}/api/apps/${encodeURIComponent(
85+ scope,
86+ )}/ui/${encodeURIComponent(name)}?document=html`;
87+ },
88+ }),
6289 plugin,
6390 close: host.close,
6491 };