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
75 // closes over). Its HTTP surface mounts under /api/apps/*; published tools are75 // closes over). Its HTTP surface mounts under /api/apps/*; published tools are
76 // catalog citizens through its source plugin; its extra MCP surface (publish76 // catalog citizens through its source plugin; its extra MCP surface (publish
77 // door, skills, ui:// resources) is registered on the real MCP server below.77 // 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 });
79 104
80 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------105 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
81 // Pass the pinned public origin so browser-approval URLs are reachable behind106 // 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
4 4
5import { afterAll, beforeAll, expect, test } from "@effect/vitest";5import { afterAll, beforeAll, expect, test } from "@effect/vitest";
6 6
7import { mintInviteCode } from "./testing/mint-invite";
8
7// Point config at a throwaway data dir before importing the app graph.9// Point config at a throwaway data dir before importing the app graph.
8process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-"));10process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-"));
11process.env.BETTER_AUTH_SECRET = "apps-node-secret-0123456789-abcdefghij-klmnop";
12process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-node.test";
13process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
9 14
10let handler!: (request: Request) => Promise<Response>;15let handler!: (request: Request) => Promise<Response>;
11let dispose: () => Promise<void> = async () => {};16let dispose: () => Promise<void> = async () => {};
17let token = "";
18
19// A JSON fetch that carries the bearer (the apps HTTP surface is authenticated).
20const 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 );
12 31
13beforeAll(async () => {32beforeAll(async () => {
14 // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps33 // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps
@@ -17,9 +36,53 @@ beforeAll(async () => { · 2 unmodified lines
17 const app = await makeSelfHostApiHandler();36 const app = await makeSelfHostApiHandler();
18 handler = app.handler;37 handler = app.handler;
19 dispose = app.dispose;38 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("");
20});56});
21afterAll(() => dispose());57afterAll(() => dispose());
22 58
59// Fix 1: the apps HTTP surface rejects unauthenticated requests with 401.
60test("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
23// A minimal published app: one tool that writes the scope db, and a ui view.86// A minimal published app: one tool that writes the scope db, and a ui view.
24const FILES = {87const FILES = {
25 "tools/note.ts":88 "tools/note.ts":
@@ -32,14 +95,12 @@ const FILES = { · 6 unmodified lines
32 `export default function App() { return null; }`,95 `export default function App() { return null; }`,
33};96};
34 97
35test("apps HTTP surface is mounted: publish then serve the ui bundle", async () => {98test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => {
36 // Publish over the booted server's /api/apps/:scope/publish route.99 // Publish over the booted server's /api/apps/:scope/publish route (authed).
37 const publishRes = await handler(100 const publishRes = await authed("/api/apps/default/publish", {
38 new Request("http://localhost/api/apps/default/publish", {101 method: "POST",
39 method: "POST",102 body: JSON.stringify({ files: FILES }),
40 body: JSON.stringify({ files: FILES }),103 });
41 }),
42 );
43 expect(publishRes.status).toBe(200);104 expect(publishRes.status).toBe(200);
44 const published = (await publishRes.json()) as {105 const published = (await publishRes.json()) as {
45 descriptor: { tools: { name: string }[]; ui: { name: string }[] };106 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
48 expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]);109 expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]);
49 110
50 // The ui bundle is served (compiled JS) with its title header.111 // The ui bundle is served (compiled JS) with its title header.
51 const uiRes = await handler(112 const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" });
52 new Request("http://localhost/api/apps/default/ui/board", { method: "GET" }),
53 );
54 expect(uiRes.status).toBe(200);113 expect(uiRes.status).toBe(200);
55 expect(uiRes.headers.get("content-type")).toContain("javascript");114 expect(uiRes.headers.get("content-type")).toContain("javascript");
56 expect(uiRes.headers.get("x-ui-title")).toBe("Board");115 expect(uiRes.headers.get("x-ui-title")).toBe("Board");
57 116
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
58 // Invoke the tool (scope-db path is live in the running server).122 // Invoke the tool (scope-db path is live in the running server).
59 const invokeRes = await handler(123 const invokeRes = await authed("/api/apps/default/tools/note", {
60 new Request("http://localhost/api/apps/default/tools/note", {124 method: "POST",
61 method: "POST",125 body: JSON.stringify({ args: { text: "hello" }, bindings: {} }),
62 body: JSON.stringify({ args: { text: "hello" }, bindings: {} }),126 });
63 }),
64 );
65 expect(invokeRes.status).toBe(200);127 expect(invokeRes.status).toBe(200);
66 const invoked = (await invokeRes.json()) as { result: { count: number } };128 const invoked = (await invokeRes.json()) as { result: { count: number } };
67 expect(invoked.result.count).toBe(1);129 expect(invoked.result.count).toBe(1);
apps/host-selfhost/src/apps.ts+35−1
@@ -54,11 +54,36 @@ const notImplementedResolver: ClientResolver = { · 53 unmodified lines
54 ),54 ),
55};55};
56 56
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. */
61export interface SelfHostAppsSubsystemOptions {
62 readonly authenticate?: (request: Request) => Promise<boolean>;
63 readonly webBaseUrl?: string;
64}
65
57// The boot-time singleton. Built lazily on first access so importing this module66// The boot-time singleton. Built lazily on first access so importing this module
58// (which executor.config.ts does) does not do filesystem work at import time.67// (which executor.config.ts does) does not do filesystem work at import time.
59let subsystem: SelfHostApps | undefined;68let subsystem: SelfHostApps | undefined;
60 69
61export 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.
76let authenticateRef: ((request: Request) => Promise<boolean>) | undefined;
77let webBaseUrlRef: string | undefined;
78
79export 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
62 if (!subsystem) {87 if (!subsystem) {
63 subsystem = makeSelfHostApps({88 subsystem = makeSelfHostApps({
64 dataDir: resolveDataDir(),89 dataDir: resolveDataDir(),
@@ -71,6 +96,13 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 6 unmodified lines
71 // so external integration calls resolve the user's connection + credential96 // so external integration calls resolve the user's connection + credential
72 // at the boundary and dispatch over the request's HttpClient.97 // at the boundary and dispatch over the request's HttpClient.
73 makeResolver: ({ ctx }) => makeCtxResolver(ctx),98 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,
74 });106 });
75 }107 }
76 return subsystem;108 return subsystem;
@@ -79,6 +111,8 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 2 unmodified lines
79/** Reset the singleton (tests build fresh instances per data dir). */111/** Reset the singleton (tests build fresh instances per data dir). */
80export const resetSelfHostAppsSubsystem = (): void => {112export const resetSelfHostAppsSubsystem = (): void => {
81 subsystem = undefined;113 subsystem = undefined;
114 authenticateRef = undefined;
115 webBaseUrlRef = undefined;
82};116};
83 117
84/** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */118/** @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
23 readonly runtime: AppsRuntime;23 readonly runtime: AppsRuntime;
24 /** Mount prefix (default "/api/apps"). */24 /** Mount prefix (default "/api/apps"). */
25 readonly prefix?: string;25 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>;
26}38}
27 39
28const json = (body: unknown, status = 200): Response =>40const json = (body: unknown, status = 200): Response =>
@@ -42,6 +54,20 @@ export const makeAppsHttpRoutes = ( · 13 unmodified lines
42 const handler = async (request: Request): Promise<Response> => {54 const handler = async (request: Request): Promise<Response> => {
43 const url = new URL(request.url);55 const url = new URL(request.url);
44 if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 });56 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
45 const rest = url.pathname.slice(prefix.length).replace(/^\//, "");71 const rest = url.pathname.slice(prefix.length).replace(/^\//, "");
46 const parts = rest.split("/").filter(Boolean);72 const parts = rest.split("/").filter(Boolean);
47 // parts[0] = scope73 // parts[0] = scope
@@ -134,8 +160,23 @@ export const makeAppsHttpRoutes = ( · 86 unmodified lines
134 return json({ runs });160 return json({ runs });
135 }161 }
136 162
137 // GET :scope/ui/:name -> compiled JS bundle163 // 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.
138 if (parts[1] === "ui" && parts[2] && request.method === "GET") {167 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 }
139 const bundle = await run(runtime.getUiBundle(scope, parts[2]));180 const bundle = await run(runtime.getUiBundle(scope, parts[2]));
140 if (!bundle) return new Response("ui not found", { status: 404 });181 if (!bundle) return new Response("ui not found", { status: 404 });
141 return new Response(bundle.code, {182 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
28 /** Optional per-request resolver factory for the catalog invoke path (built28 /** Optional per-request resolver factory for the catalog invoke path (built
29 * from the invoking executor context). */29 * from the invoking executor context). */
30 readonly makeResolver?: AppsPluginOptions["makeResolver"];30 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;
31}43}
32 44
33export interface SelfHostApps {45export interface SelfHostApps {
@@ -48,7 +60,10 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 14 unmodified lines
48 store,60 store,
49 resolver: options.resolver,61 resolver: options.resolver,
50 });62 });
51 const http = makeAppsHttpRoutes({ runtime: host.runtime });63 const http = makeAppsHttpRoutes({
64 runtime: host.runtime,
65 authenticate: options.authenticate,
66 });
52 const plugin = appsPlugin({67 const plugin = appsPlugin({
53 runtime: host.runtime,68 runtime: host.runtime,
54 resolveBindings: options.resolveBindings,69 resolveBindings: options.resolveBindings,
@@ -58,7 +73,19 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 3 unmodified lines
58 runtime: host.runtime,73 runtime: host.runtime,
59 http,74 http,
60 registerMcp: (server) =>75 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 }),
62 plugin,89 plugin,
63 close: host.close,90 close: host.close,
64 };91 };