Unified · Split
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | import { makeSelfHostMcpSeams } from "./mcp"; |
| 22 | 22 | import { selfHostPlugins } from "./plugins"; |
| 23 | 23 | import { ErrorCaptureLive } from "./observability"; |
| 24 | +import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; | |
| 24 | 25 | |
| 25 | 26 | // =========================================================================== |
| 26 | 27 | // The self-hosted Executor app, as ONE `ExecutorApp.make` call. |
| @@ -113,6 +114,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 86 unmodified lines | ||
| 113 | 114 | // (it can't prove each host's resolution); self-host narrows it here. |
| 114 | 115 | AppLayer: appLayer as Layer.Layer<never>, |
| 115 | 116 | toWebHandler, |
| 117 | + betterAuth, | |
| 116 | 118 | closeDb: async () => { |
| 117 | 119 | await mcp.close(); |
| 118 | 120 | await dbHandle.close(); |
| @@ -132,10 +134,14 @@ export interface SelfHostApiHandler { · 13 unmodified lines | ||
| 132 | 134 | export const makeSelfHostApiHandler = async ( |
| 133 | 135 | options: MakeSelfHostAppOptions = {}, |
| 134 | 136 | ): Promise<SelfHostApiHandler> => { |
| 135 | − const { toWebHandler, closeDb } = await makeSelfHostApp(options); | |
| 137 | + const { toWebHandler, betterAuth, closeDb } = await makeSelfHostApp(options); | |
| 136 | 138 | const web = toWebHandler(); |
| 137 | 139 | return { |
| 138 | − handler: web.handler, | |
| 140 | + handler: async (request) => { | |
| 141 | + const location = await oauthCallbackSignInRedirectLocation(request, betterAuth.auth); | |
| 142 | + if (location) return new Response(null, { status: 302, headers: { location } }); | |
| 143 | + return web.handler(request); | |
| 144 | + }, | |
| 139 | 145 | dispose: async () => { |
| 140 | 146 | await web.dispose(); |
| 141 | 147 | await closeDb(); |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { oauthCallbackSignInRedirectLocation } from "./oauth-callback-login"; | |
| 4 | + | |
| 5 | +const auth = (session: unknown | null) => ({ | |
| 6 | + api: { | |
| 7 | + getSession: async () => session, | |
| 8 | + }, | |
| 9 | +}); | |
| 10 | + | |
| 11 | +describe("oauthCallbackSignInRedirectLocation", () => { | |
| 12 | + it("redirects a signed-out OAuth callback to login with returnTo", async () => { | |
| 13 | + await expect( | |
| 14 | + oauthCallbackSignInRedirectLocation( | |
| 15 | + new Request("http://selfhost.test/api/oauth/callback?state=s1&code=c1"), | |
| 16 | + auth(null), | |
| 17 | + ), | |
| 18 | + ).resolves.toBe("/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fstate%3Ds1%26code%3Dc1"); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("does not redirect a signed-in OAuth callback", async () => { | |
| 22 | + await expect( | |
| 23 | + oauthCallbackSignInRedirectLocation( | |
| 24 | + new Request("http://selfhost.test/api/oauth/callback?state=s1&code=c1"), | |
| 25 | + auth({ user: { id: "user_1" } }), | |
| 26 | + ), | |
| 27 | + ).resolves.toBeNull(); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("ignores other paths and non-browser methods", async () => { | |
| 31 | + await expect( | |
| 32 | + oauthCallbackSignInRedirectLocation( | |
| 33 | + new Request("http://selfhost.test/api/oauth/callback?state=s1", { method: "POST" }), | |
| 34 | + auth(null), | |
| 35 | + ), | |
| 36 | + ).resolves.toBeNull(); | |
| 37 | + await expect( | |
| 38 | + oauthCallbackSignInRedirectLocation( | |
| 39 | + new Request("http://selfhost.test/api/oauth/callback/extra?state=s1"), | |
| 40 | + auth(null), | |
| 41 | + ), | |
| 42 | + ).resolves.toBeNull(); | |
| 43 | + }); | |
| 44 | +}); | |
| @@ -0,0 +1,23 @@ | ||
| 1 | +import { loginPath } from "./return-to"; | |
| 2 | + | |
| 3 | +export const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; | |
| 4 | + | |
| 5 | +interface SessionAuth { | |
| 6 | + readonly api: { | |
| 7 | + readonly getSession: (input: { readonly headers: Headers }) => Promise<unknown | null>; | |
| 8 | + }; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export const oauthCallbackSignInRedirectLocation = async ( | |
| 12 | + request: Request, | |
| 13 | + auth: SessionAuth, | |
| 14 | +): Promise<string | null> => { | |
| 15 | + if (request.method !== "GET" && request.method !== "HEAD") return null; | |
| 16 | + const url = new URL(request.url); | |
| 17 | + if (url.pathname !== OAUTH_CALLBACK_PATH) return null; | |
| 18 | + | |
| 19 | + const session = await auth.api.getSession({ headers: request.headers }); | |
| 20 | + if (session) return null; | |
| 21 | + | |
| 22 | + return loginPath(`${url.pathname}${url.search}`); | |
| 23 | +}; | |
| @@ -0,0 +1,58 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to"; | |
| 4 | + | |
| 5 | +describe("isSafeReturnTo", () => { | |
| 6 | + const safe = [ | |
| 7 | + "/", | |
| 8 | + "/tools", | |
| 9 | + "/integrations/sentry?addAccount=1", | |
| 10 | + "/api-keys", | |
| 11 | + "/api/oauth/callback?state=oauth-state&code=provider-code", | |
| 12 | + ]; | |
| 13 | + for (const path of safe) { | |
| 14 | + it(`allows ${path}`, () => { | |
| 15 | + expect(isSafeReturnTo(path)).toBe(true); | |
| 16 | + }); | |
| 17 | + } | |
| 18 | + | |
| 19 | + const unsafe = [ | |
| 20 | + "https://evil.example", | |
| 21 | + "//evil.example", | |
| 22 | + "/api/auth/logout", | |
| 23 | + "/api/oauth/callback/extra?state=oauth-state", | |
| 24 | + "/api", | |
| 25 | + "javascript:alert(1)", | |
| 26 | + "tools", | |
| 27 | + "", | |
| 28 | + ]; | |
| 29 | + for (const path of unsafe) { | |
| 30 | + it(`rejects ${JSON.stringify(path)}`, () => { | |
| 31 | + expect(isSafeReturnTo(path)).toBe(false); | |
| 32 | + }); | |
| 33 | + } | |
| 34 | +}); | |
| 35 | + | |
| 36 | +describe("safeReturnTo", () => { | |
| 37 | + it("passes a safe path through", () => { | |
| 38 | + expect(safeReturnTo("/tools")).toBe("/tools"); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("nulls unsafe and absent values", () => { | |
| 42 | + expect(safeReturnTo("https://evil.example")).toBeNull(); | |
| 43 | + expect(safeReturnTo(null)).toBeNull(); | |
| 44 | + expect(safeReturnTo(undefined)).toBeNull(); | |
| 45 | + }); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +describe("loginPath", () => { | |
| 49 | + it("omits returnTo for the root", () => { | |
| 50 | + expect(loginPath("/")).toBe("/login"); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it("carries OAuth callback resumes URI-encoded", () => { | |
| 54 | + expect(loginPath("/api/oauth/callback?state=oauth-state&code=provider-code")).toBe( | |
| 55 | + "/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fstate%3Doauth-state%26code%3Dprovider-code", | |
| 56 | + ); | |
| 57 | + }); | |
| 58 | +}); | |
| @@ -0,0 +1,14 @@ | ||
| 1 | +const pathPart = (path: string): string => path.split(/[?#]/, 1)[0] ?? ""; | |
| 2 | + | |
| 3 | +const isOAuthCallbackReturnTo = (path: string): boolean => pathPart(path) === "/api/oauth/callback"; | |
| 4 | + | |
| 5 | +export const isSafeReturnTo = (path: string): boolean => | |
| 6 | + path.startsWith("/") && | |
| 7 | + !path.startsWith("//") && | |
| 8 | + (!/^\/api(\/|$)/.test(path) || isOAuthCallbackReturnTo(path)); | |
| 9 | + | |
| 10 | +export const safeReturnTo = (path: string | null | undefined): string | null => | |
| 11 | + path && isSafeReturnTo(path) ? path : null; | |
| 12 | + | |
| 13 | +export const loginPath = (returnTo: string): string => | |
| 14 | + returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`; | |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | HttpMiddleware, |
| 20 | 20 | HttpRouter, |
| 21 | 21 | HttpServerRequest, |
| 22 | + HttpServerResponse, | |
| 22 | 23 | HttpStaticServer, |
| 23 | 24 | } from "effect/unstable/http"; |
| 24 | 25 | import { BunFileSystem, BunHttpServer, BunPath, BunRuntime } from "@effect/platform-bun"; |
| @@ -26,6 +27,11 @@ import { Effect, Layer } from "effect"; · 1 unmodified line | ||
| 26 | 27 | |
| 27 | 28 | import { makeSelfHostApp } from "./app"; |
| 28 | 29 | import { loadConfig } from "./config"; |
| 30 | +import type { BetterAuthHandle } from "./auth"; | |
| 31 | +import { | |
| 32 | + OAUTH_CALLBACK_PATH, | |
| 33 | + oauthCallbackSignInRedirectLocation, | |
| 34 | +} from "./auth/oauth-callback-login"; | |
| 29 | 35 | import { stripMcpOrgSegment } from "./mcp/org-path"; |
| 30 | 36 | |
| 31 | 37 | const distDir = fileURLToPath(new URL("../dist/", import.meta.url)); |
| @@ -33,24 +39,37 @@ const distDir = fileURLToPath(new URL("../dist/", import.meta.url)); · 1 unmodified line | ||
| 33 | 39 | // Rewrite `/<org>/mcp` (and its OAuth discovery path) to the bare path before |
| 34 | 40 | // routing, so the "Connect an agent" card's org-pinned URL reaches the real |
| 35 | 41 | // `/mcp` route — see ./mcp/org-path. A no-op for every other request. |
| 36 | −const mcpOrgPathRewrite = HttpMiddleware.make((httpApp) => | |
| 37 | − Effect.gen(function* () { | |
| 38 | − const request = yield* HttpServerRequest.HttpServerRequest; | |
| 39 | − const url = new URL(request.url, "http://host.internal"); | |
| 40 | − const rewritten = stripMcpOrgSegment(url.pathname); | |
| 41 | − if (rewritten === null) return yield* httpApp; | |
| 42 | − return yield* httpApp.pipe( | |
| 43 | − Effect.provideService( | |
| 44 | − HttpServerRequest.HttpServerRequest, | |
| 45 | − request.modify({ url: `${rewritten}${url.search}` }), | |
| 46 | − ), | |
| 47 | − ); | |
| 48 | − }), | |
| 49 | −); | |
| 42 | +const selfHostHttpMiddleware = (betterAuth: BetterAuthHandle) => | |
| 43 | + HttpMiddleware.make((httpApp) => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 46 | + const url = new URL(request.url, "http://host.internal"); | |
| 47 | + if ( | |
| 48 | + url.pathname === OAUTH_CALLBACK_PATH && | |
| 49 | + (request.method === "GET" || request.method === "HEAD") | |
| 50 | + ) { | |
| 51 | + const headers = new Headers(request.headers as Record<string, string>); | |
| 52 | + const webRequest = new Request(url, { method: request.method, headers }); | |
| 53 | + const location = yield* Effect.promise(() => | |
| 54 | + oauthCallbackSignInRedirectLocation(webRequest, betterAuth.auth), | |
| 55 | + ); | |
| 56 | + if (location) return HttpServerResponse.redirect(location, { status: 302 }); | |
| 57 | + } | |
| 58 | + | |
| 59 | + const rewritten = stripMcpOrgSegment(url.pathname); | |
| 60 | + if (rewritten === null) return yield* httpApp; | |
| 61 | + return yield* httpApp.pipe( | |
| 62 | + Effect.provideService( | |
| 63 | + HttpServerRequest.HttpServerRequest, | |
| 64 | + request.modify({ url: `${rewritten}${url.search}` }), | |
| 65 | + ), | |
| 66 | + ); | |
| 67 | + }), | |
| 68 | + ); | |
| 50 | 69 | |
| 51 | 70 | export const startServer = async (): Promise<void> => { |
| 52 | 71 | const config = loadConfig(); |
| 53 | − const { AppLayer } = await makeSelfHostApp(); | |
| 72 | + const { AppLayer, betterAuth } = await makeSelfHostApp(); | |
| 54 | 73 | |
| 55 | 74 | // Serve the built SPA. Specific API/docs/auth/mcp routes take precedence; |
| 56 | 75 | // `spa: true` falls back to index.html for any other path (client routing). |
| @@ -60,7 +79,7 @@ export const startServer = async (): Promise<void> => { · 3 unmodified lines | ||
| 60 | 79 | ); |
| 61 | 80 | |
| 62 | 81 | const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, StaticLive), { |
| 63 | − middleware: mcpOrgPathRewrite, | |
| 82 | + middleware: selfHostHttpMiddleware(betterAuth), | |
| 64 | 83 | }).pipe( |
| 65 | 84 | Layer.provide( |
| 66 | 85 | BunHttpServer.layer({ hostname: config.host, port: config.port, idleTimeout: 0 }), |
| @@ -5,6 +5,7 @@ import { Input } from "@executor-js/react/components/input"; · 4 unmodified lines | ||
| 5 | 5 | import { Label } from "@executor-js/react/components/label"; |
| 6 | 6 | |
| 7 | 7 | import { authClient } from "./auth-client"; |
| 8 | +import { safeReturnTo } from "../src/auth/return-to"; | |
| 8 | 9 | |
| 9 | 10 | // Self-host login: email + password sign-in via Better Auth. On success we |
| 10 | 11 | // reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps |
| @@ -15,6 +16,7 @@ import { authClient } from "./auth-client"; · 4 unmodified lines | ||
| 15 | 16 | // redeeming an invite — either the full /join/<code> link, or by entering the |
| 16 | 17 | // code here ("Have an invite code?"), which forwards to the same join page. |
| 17 | 18 | export const LoginPage = () => { |
| 19 | + const returnTo = safeReturnTo(new URLSearchParams(window.location.search).get("returnTo")) ?? "/"; | |
| 18 | 20 | const [mode, setMode] = useState<"signin" | "code">("signin"); |
| 19 | 21 | const [email, setEmail] = useState(""); |
| 20 | 22 | const [password, setPassword] = useState(""); |
| @@ -32,7 +34,7 @@ export const LoginPage = () => { · 11 unmodified lines | ||
| 32 | 34 | setError(result.error.message ?? "Sign in failed"); |
| 33 | 35 | return; |
| 34 | 36 | } |
| 35 | − window.location.href = "/"; | |
| 37 | + window.location.href = returnTo; | |
| 36 | 38 | }; |
| 37 | 39 | |
| 38 | 40 | const redeem = (event: FormEvent) => { |
| @@ -0,0 +1,146 @@ | ||
| 1 | +import { randomBytes } from "node:crypto"; | |
| 2 | + | |
| 3 | +import { expect } from "@effect/vitest"; | |
| 4 | +import { Effect } from "effect"; | |
| 5 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 6 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 7 | +import { | |
| 8 | + AuthTemplateSlug, | |
| 9 | + ConnectionName, | |
| 10 | + IntegrationSlug, | |
| 11 | + OAuthClientSlug, | |
| 12 | +} from "@executor-js/sdk/shared"; | |
| 13 | +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | |
| 14 | + | |
| 15 | +import { scenario } from "../src/scenario"; | |
| 16 | +import { Api, Browser, Target } from "../src/services"; | |
| 17 | + | |
| 18 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 19 | + | |
| 20 | +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | |
| 21 | + | |
| 22 | +const oauthIntegrationSpec = (oauth: { | |
| 23 | + readonly authorizationEndpoint: string; | |
| 24 | + readonly tokenEndpoint: string; | |
| 25 | +}) => | |
| 26 | + ({ | |
| 27 | + spec: { | |
| 28 | + kind: "blob" as const, | |
| 29 | + value: JSON.stringify({ | |
| 30 | + openapi: "3.0.3", | |
| 31 | + info: { title: "OAuth-protected API", version: "1.0.0" }, | |
| 32 | + paths: { | |
| 33 | + "/me": { | |
| 34 | + get: { | |
| 35 | + operationId: "getMe", | |
| 36 | + tags: ["default"], | |
| 37 | + responses: { "200": { description: "the caller" } }, | |
| 38 | + }, | |
| 39 | + }, | |
| 40 | + }, | |
| 41 | + }), | |
| 42 | + }, | |
| 43 | + baseUrl: "http://127.0.0.1:59999", | |
| 44 | + authenticationTemplate: [ | |
| 45 | + { | |
| 46 | + slug: "oauth", | |
| 47 | + kind: "oauth2" as const, | |
| 48 | + authorizationUrl: oauth.authorizationEndpoint, | |
| 49 | + tokenUrl: oauth.tokenEndpoint, | |
| 50 | + scopes: ["read"], | |
| 51 | + }, | |
| 52 | + ], | |
| 53 | + }) as const; | |
| 54 | + | |
| 55 | +scenario( | |
| 56 | + "OAuth callback · a signed-out self-host callback uses login returnTo and resumes the connection", | |
| 57 | + {}, | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const target = yield* Target; | |
| 60 | + const { client: makeApiClient } = yield* Api; | |
| 61 | + const browser = yield* Browser; | |
| 62 | + const oauth = yield* serveOAuthTestServer(); | |
| 63 | + const identity = yield* target.newIdentity(); | |
| 64 | + const client = yield* makeApiClient(api, identity); | |
| 65 | + | |
| 66 | + const integration = IntegrationSlug.make(unique("selfhostsignedoutcb")); | |
| 67 | + yield* client.openapi.addSpec({ | |
| 68 | + payload: { ...oauthIntegrationSpec(oauth), slug: integration }, | |
| 69 | + }); | |
| 70 | + | |
| 71 | + const clientSlug = OAuthClientSlug.make(unique("selfhostsignedoutc")); | |
| 72 | + yield* client.oauth.createClient({ | |
| 73 | + payload: { | |
| 74 | + owner: "org", | |
| 75 | + slug: clientSlug, | |
| 76 | + authorizationUrl: oauth.authorizationEndpoint, | |
| 77 | + tokenUrl: oauth.tokenEndpoint, | |
| 78 | + grant: "authorization_code", | |
| 79 | + clientId: "test-client", | |
| 80 | + clientSecret: "test-secret", | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | + | |
| 84 | + const started = yield* client.oauth.start({ | |
| 85 | + payload: { | |
| 86 | + client: clientSlug, | |
| 87 | + clientOwner: "org", | |
| 88 | + owner: "org", | |
| 89 | + name: ConnectionName.make("main"), | |
| 90 | + integration, | |
| 91 | + template: AuthTemplateSlug.make("oauth"), | |
| 92 | + }, | |
| 93 | + }); | |
| 94 | + expect(started.status, "oauth.start begins at the provider").toBe("redirect"); | |
| 95 | + const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; | |
| 96 | + | |
| 97 | + const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" })); | |
| 98 | + expect(authorize.status, "the provider asks the user to log in").toBe(302); | |
| 99 | + const consent = yield* Effect.promise(() => | |
| 100 | + fetch(authorize.headers.get("location") ?? "", { | |
| 101 | + method: "POST", | |
| 102 | + redirect: "manual", | |
| 103 | + headers: { | |
| 104 | + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, | |
| 105 | + }, | |
| 106 | + }), | |
| 107 | + ); | |
| 108 | + expect(consent.status, "provider consent redirects back to Executor").toBe(302); | |
| 109 | + const callback = new URL(consent.headers.get("location") ?? ""); | |
| 110 | + const callbackPath = `${callback.pathname}${callback.search}`; | |
| 111 | + | |
| 112 | + yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { | |
| 113 | + await step("Provider sends a signed-out browser to the OAuth callback", async () => { | |
| 114 | + const response = await page.goto(callbackPath, { waitUntil: "networkidle" }); | |
| 115 | + expect(response?.status(), "the callback redirects into the login flow").toBe(200); | |
| 116 | + await page.getByText("Sign in to your instance").waitFor(); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + const loginUrl = new URL(page.url()); | |
| 120 | + expect(loginUrl.pathname, "the signed-out callback lands on the sign-in page").toBe("/login"); | |
| 121 | + expect( | |
| 122 | + loginUrl.searchParams.get("returnTo"), | |
| 123 | + "login preserves the callback so it can resume after sign-in", | |
| 124 | + ).toBe(callbackPath); | |
| 125 | + | |
| 126 | + await step("Sign in resumes the original OAuth callback", async () => { | |
| 127 | + await page.getByLabel("Email").fill(identity.credentials!.email); | |
| 128 | + await page.getByLabel("Password").fill(identity.credentials!.password); | |
| 129 | + await page.getByRole("button", { name: "Sign in" }).click(); | |
| 130 | + await page.waitForURL((url) => url.pathname === "/api/oauth/callback", { | |
| 131 | + timeout: 30_000, | |
| 132 | + }); | |
| 133 | + await page.waitForFunction(() => document.body.innerText.includes("Connected"), null, { | |
| 134 | + timeout: 30_000, | |
| 135 | + }); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + const body = (await page.locator("body").textContent())?.trim() ?? ""; | |
| 139 | + expect(new URL(page.url()).pathname, "the login returnTo lands back on the callback").toBe( | |
| 140 | + "/api/oauth/callback", | |
| 141 | + ); | |
| 142 | + expect(body, "the callback completes after the sign-in recovery").toContain("Connected"); | |
| 143 | + expect(body, "the raw protected API response is not shown").not.toContain("Unauthorized"); | |
| 144 | + }); | |
| 145 | + }).pipe(Effect.scoped), | |
| 146 | +); | |