Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ const AuthCallbackSearch = Schema.Struct({
state: Schema.optional(Schema.String),
});

// Where to send the user after the callback completes (the /login page passes
// the path the SSR auth gate captured). Validated server-side to same-origin
// relative paths before use.
const AuthLoginSearch = Schema.Struct({
returnTo: Schema.optional(Schema.String),
});

const PendingInvitationInviter = Schema.Struct({
email: Schema.String,
name: Schema.NullOr(Schema.String),
Expand Down Expand Up @@ -143,7 +150,7 @@ const McpApprovalErrors = [

/** Public auth endpoints — no authentication required */
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
.add(HttpApiEndpoint.get("login", "/auth/login"))
.add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
.add(
HttpApiEndpoint.get("callback", "/auth/callback", {
query: AuthCallbackSearch,
Expand Down
12 changes: 12 additions & 0 deletions apps/cloud/src/auth/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// The one request-header cookie parser (workos.ts, edge/marketing.ts, and the
// SSR auth gate all need it). Pure string code — safe in any bundle.

export const parseCookie = (cookieHeader: string | null, name: string): string | null => {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${name}=`));
if (!match) return null;
return match.slice(name.length + 1) || null;
};
49 changes: 40 additions & 9 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
McpSessionForbiddenError,
} from "./api";
import { NoOrganization } from "@executor-js/api/server";
// Pure constants/codec module (no React) — safe in the backend graph.
import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";
import { SessionContext, SessionCookies } from "./middleware";
import { safeReturnTo } from "./return-to";
import { UserStoreService } from "./context";
import { env } from "cloudflare:workers";
import { WorkOSError } from "./errors";
Expand All @@ -36,6 +39,10 @@ const COOKIE_OPTIONS = {
};

const STATE_COOKIE = "wos-login-state";
// Where the user was headed before login (set by /auth/login from its
// validated returnTo query, read once by the callback). Same lifetime and
// flags as the CSRF state it travels with.
const RETURN_TO_COOKIE = "wos-login-return-to";
const STATE_COOKIE_OPTIONS = {
path: "/",
httpOnly: true,
Expand Down Expand Up @@ -143,7 +150,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
"cloudAuthPublic",
(handlers) =>
handlers
.handleRaw("login", () =>
.handleRaw("login", ({ query }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
// Use the explicit public site URL — in dev, the request's Host
Expand All @@ -152,12 +159,23 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
const origin = env.VITE_PUBLIC_SITE_URL ?? "";
const state = randomState();
const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state);
return setResponseCookie(
const returnTo = safeReturnTo(query.returnTo);
const withState = setResponseCookie(
HttpServerResponse.redirect(url, { status: 302 }),
STATE_COOKIE,
state,
RESPONSE_STATE_COOKIE_OPTIONS,
);
// Raw value: HttpServerResponse.setCookieUnsafe URI-encodes it for
// the wire and `request.cookies` decodes it back on the callback.
return returnTo
? setResponseCookie(
withState,
RETURN_TO_COOKIE,
returnTo,
RESPONSE_STATE_COOKIE_OPTIONS,
)
: withState;
}),
)
.handleRaw("callback", ({ request, query }) =>
Expand Down Expand Up @@ -211,14 +229,21 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
return HttpServerResponse.text("Failed to create session", { status: 500 });
}

// Resume where the SSR gate interrupted them; the cookie value is
// browser-writable, so it gets the same validation as the query.
const returnTo = safeReturnTo(request.cookies[RETURN_TO_COOKIE]) ?? "/";

return deleteResponseCookie(
setResponseCookie(
HttpServerResponse.redirect("/", { status: 302 }),
"wos-session",
sealedSession,
RESPONSE_COOKIE_OPTIONS,
deleteResponseCookie(
setResponseCookie(
HttpServerResponse.redirect(returnTo, { status: 302 }),
"wos-session",
sealedSession,
RESPONSE_COOKIE_OPTIONS,
),
STATE_COOKIE,
),
STATE_COOKIE,
RETURN_TO_COOKIE,
);
}),
),
Expand Down Expand Up @@ -253,7 +278,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
)
.handleRaw("logout", () =>
Effect.succeed(
deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
// The auth-hint travels with the session: leaving it behind would
// make the next page load optimistically paint the app shell for a
// signed-out browser.
deleteResponseCookie(
deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
AUTH_HINT_COOKIE,
),
),
)
.handle("organizations", () =>
Expand Down
59 changes: 59 additions & 0 deletions apps/cloud/src/auth/return-to.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "@effect/vitest";

import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to";

// Guards the login round-trip channel (SSR gate → /login → /api/auth/login →
// state cookie → callback redirect). Everything here crosses a trust boundary,
// so the validator is what stands between "resume where you were" and an open
// redirect.
describe("isSafeReturnTo", () => {
const safe = ["/", "/tools", "/integrations/sentry?addAccount=1", "/billing/plans"];
for (const path of safe) {
it(`allows ${path}`, () => {
expect(isSafeReturnTo(path)).toBe(true);
});
}

const unsafe = [
"https://evil.example", // absolute URL — off-origin redirect
"//evil.example", // protocol-relative — same thing in disguise
"/api/auth/logout", // API endpoints are never a landing page
"/api", // bare /api too
"javascript:alert(1)", // not a path at all
"tools", // relative paths resolve unpredictably
"", // empty
];
for (const path of unsafe) {
it(`rejects ${JSON.stringify(path)}`, () => {
expect(isSafeReturnTo(path)).toBe(false);
});
}

// /api-keys is a React page, not an API path — the prefix check must not
// swallow it.
it("allows /api-keys (page, not API)", () => {
expect(isSafeReturnTo("/api-keys")).toBe(true);
});
});

describe("safeReturnTo", () => {
it("passes a safe path through", () => {
expect(safeReturnTo("/tools")).toBe("/tools");
});
it("nulls unsafe and absent values", () => {
expect(safeReturnTo("https://evil.example")).toBeNull();
expect(safeReturnTo(null)).toBeNull();
expect(safeReturnTo(undefined)).toBeNull();
});
});

describe("loginPath", () => {
it("omits returnTo for the root (the default destination)", () => {
expect(loginPath("/")).toBe("/login");
});
it("carries deep links URI-encoded", () => {
expect(loginPath("/integrations/sentry?addAccount=1")).toBe(
"/login?returnTo=%2Fintegrations%2Fsentry%3FaddAccount%3D1",
);
});
});
23 changes: 23 additions & 0 deletions apps/cloud/src/auth/return-to.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// ---------------------------------------------------------------------------
// returnTo — the "send me back where I was" path carried through the login
// flow (SSR gate → /login → /api/auth/login → state cookie → callback).
//
// The value crosses trust boundaries (query params, a cookie the browser can
// rewrite), so every consumer validates with `isSafeReturnTo` before using
// it: same-origin relative paths only — no absolute/protocol-relative URLs
// (open redirect) and nothing under /api (bouncing a fresh login into an API
// endpoint is never what the user meant).
//
// Pure string code — imported by server handlers and the login page alike.
// ---------------------------------------------------------------------------

export const isSafeReturnTo = (path: string): boolean =>
path.startsWith("/") && !path.startsWith("//") && !/^\/api(\/|$)/.test(path);

/** The validated returnTo, or null when absent/unsafe. */
export const safeReturnTo = (path: string | null | undefined): string | null =>
path && isSafeReturnTo(path) ? path : null;

/** The /login URL that comes back to `returnTo` ("/" needs no parameter). */
export const loginPath = (returnTo: string): string =>
returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`;
131 changes: 131 additions & 0 deletions apps/cloud/src/auth/ssr-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// ---------------------------------------------------------------------------
// SSR auth gate — the server-side session check for DOCUMENT requests.
//
// Before this gate, every signed-out visitor was served the SPA, whose root
// AuthGate SSRs the AUTHENTICATED app-shell skeleton until a client-side
// `/account/me` round trip 401s — the "bad skeleton on unauthed state" flash.
// The sealed `wos-session` cookie can be verified right here in the worker
// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip
// except token refresh), so signed-out visitors are 302'd to /login before any
// app HTML exists, and signed-in visitors proceed knowing the session is real.
//
// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
// accept), excluding app-owned paths (/api, /mcp — they answer for themselves
// earlier in the middleware chain). Everything else passes through untouched.
// ---------------------------------------------------------------------------

import { createMiddleware } from "@tanstack/react-start";
import { Effect, Exit, ManagedRuntime } from "effect";

import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";

import { isAppOwnedPath } from "../app-paths";
import { parseCookie } from "./cookies";
import { loginPath, safeReturnTo } from "./return-to";
import { WorkOSClient } from "./workos";

const SESSION_COOKIE = "wos-session";
/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7;

const isDocumentRequest = (request: Request): boolean => {
if (request.method !== "GET" && request.method !== "HEAD") return false;
// Browsers label navigations explicitly; non-browser clients fall back to
// content negotiation. Anything that isn't asking for a page (vite module
// requests, JSON fetches, health probes) passes through ungated.
const dest = request.headers.get("sec-fetch-dest");
if (dest !== null) return dest === "document";
return request.headers.get("accept")?.includes("text/html") ?? false;
};

// Lazy for the same reason start.ts instantiates the app handler lazily: this
// module reaches workers-only imports (cloudflare:workers via ./workos), which
// must stay behind the stripped `.server()` callback so the client bundle
// never pulls them in. One runtime per isolate — the WorkOS client holds no
// sockets, just config and a JWKS cache, so sharing it across requests is
// exactly what the unified app handler already does.
let runtime: ManagedRuntime.ManagedRuntime<WorkOSClient, unknown> | undefined;
const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default));

type VerifiedSession = { readonly refreshedSession?: string | undefined };

// EVERY failure collapses to "signed out" — WorkOS errors inside the effect
// and layer-construction errors like a bad cookie password (runPromiseExit
// carries those in its Exit too) — so the login flow surfaces the real
// problem instead of 500ing every page.
const verifySession = async (sealed: string): Promise<VerifiedSession | null> => {
const exit = await getRuntime().runPromiseExit(
Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)),
);
return Exit.isSuccess(exit) ? exit.value : null;
};

const sessionSetCookie = (sealed: string) =>
`${SESSION_COOKIE}=${sealed}; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=${SESSION_MAX_AGE}`;

const redirect = (
location: string,
options?: {
/** Drop the (invalid) session + auth-hint cookies along the way. */
readonly clearSession?: boolean;
/** Persist a WorkOS-rotated sealed session (refresh tokens are single-use). */
readonly refreshedSession?: string | undefined;
},
): Response => {
const headers = new Headers({ location });
if (options?.clearSession) {
headers.append("set-cookie", `${SESSION_COOKIE}=; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=0`);
headers.append("set-cookie", `${AUTH_HINT_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`);
}
if (options?.refreshedSession) {
headers.append("set-cookie", sessionSetCookie(options.refreshedSession));
}
return new Response(null, { status: 302, headers });
};

export const authGateMiddleware = createMiddleware({ type: "request" }).server(
async ({ pathname, request, next }) => {
if (isAppOwnedPath(pathname) || !isDocumentRequest(request)) return next();

const sealed = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
const url = new URL(request.url);

// /login is the one page signed-out visitors are FOR; a signed-in visitor
// landing here is bounced straight back to where they were headed.
if (pathname === "/login") {
const session = sealed ? await verifySession(sealed) : null;
if (!session) return next();
return redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", {
refreshedSession: session.refreshedSession,
});
}

// Marketing CTAs link to /cloud, which is not a route — it's "open the
// app". Send it to the root (the gate below decides app vs login).
const returnTo = pathname === "/cloud" ? "/" : `${pathname}${url.search}`;

if (!sealed) return redirect(loginPath(returnTo));

const session = await verifySession(sealed);
if (!session) {
// A cookie that doesn't verify is worse than none: on executor.sh its
// mere presence keeps routing / into the app instead of marketing.
return redirect(loginPath(returnTo), { clearSession: true });
}

if (pathname === "/cloud") {
return redirect("/", { refreshedSession: session.refreshedSession });
}

const result = await next();
if (session.refreshedSession) {
// WorkOS refresh tokens are single-use: the rotated sealed session MUST
// reach the browser or the next expiry logs the user out.
const response = new Response(result.response.body, result.response);
response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession));
return response;
}
return result;
},
);
11 changes: 1 addition & 10 deletions apps/cloud/src/auth/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { env } from "cloudflare:workers";
import { Context, Data, Effect, Layer, Option, Schema } from "effect";
import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
import { parseCookie } from "./cookies";
import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";

const COOKIE_NAME = "wos-session";
Expand Down Expand Up @@ -442,13 +443,3 @@ export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientServ
// keep `@tanstack/react-start` out of the DO bundle; that coupling is gone now
// that `handlers.ts` no longer imports react-start, so the alias moved home.)
export const CoreSharedServices = WorkOSClient.Default;

const parseCookie = (cookieHeader: string | null, name: string): string | null => {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${name}=`));
if (!match) return null;
return match.slice(name.length + 1) || null;
};
11 changes: 2 additions & 9 deletions apps/cloud/src/edge/marketing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import { env } from "cloudflare:workers";
import { createMiddleware } from "@tanstack/react-start";

import { parseCookie } from "../auth/cookies";

const MARKETING_PATHS = [
"/home",
"/setup",
Expand All @@ -27,15 +29,6 @@ const isMarketingPath = (pathname: string) =>

const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined;

const parseCookie = (cookieHeader: string | null, name: string): string | null => {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((v) => v.trim())
.find((v) => v.startsWith(`${name}=`));
return match ? match.slice(name.length + 1) || null : null;
};

export const marketingMiddleware = createMiddleware({ type: "request" }).server(
async ({ pathname, request, next }) => {
// Only proxy to the marketing worker on the production domain. In local
Expand Down
Loading
Loading