// --------------------------------------------------------------------------- // Document auth gate — the server-side session check for DOCUMENT requests. // // The sealed `wos-session` cookie is verified right here in the worker // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip // except token refresh), so by the time the SPA shell is served the server // KNOWS who it's serving: // // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists // - org-less → 302 /create-org (onboarding owns those sessions) // - signed in → the prerendered shell is served, and the auth-hint cookie is // minted when the browser doesn't hold a current one — the client seeds its // auth state from that cookie right after mount (there is no per-request // render to dehydrate it into). The hint is display-only; /account/me // remains the authority and the client keeps it fresh from then on. // // Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS // refresh tokens are single-use, and a verify that rotates the sealed session // must deliver the new cookie to the browser or the next expiry logs the user // out. Document navigations are where that rotation lands. // // 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, Layer, ManagedRuntime } from "effect"; import { isValidOrgSlug } from "@executor-js/api"; import { AUTH_HINT_COOKIE, AUTH_HINT_MAX_AGE_SECONDS, decodeAuthHint, encodeAuthHint, type AuthHint, } from "@executor-js/react/multiplayer/auth-hint"; import { isAppOwnedPath } from "../app-paths"; import { makeDbLayer } from "../db/db"; import { makeUserStoreLayer, UserStoreService } from "./context"; import { parseCookie } from "./cookies"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { sealedSessionDisplayName } from "./middleware"; import { authorizeOrganizationSelector } from "./organization"; import { loginPath, safeReturnTo } from "./return-to"; import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; 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; /** Same attributes the client write uses — minus HttpOnly: the SPA reads it. */ const HINT_COOKIE_ATTRIBUTES = "Path=/; Secure; SameSite=Lax"; 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 | undefined; const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default)); type VerifiedSession = { readonly userId: string; readonly email: string; readonly name: string | null; readonly avatarUrl: string | null; readonly organizationId: string | null; 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 => { const exit = await getRuntime().runPromiseExit( Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)), ); if (!Exit.isSuccess(exit) || exit.value === null) return null; const result = exit.value; return { userId: result.userId, email: result.email, name: sealedSessionDisplayName(result), avatarUrl: result.avatarUrl ?? null, organizationId: result.organizationId ?? null, refreshedSession: result.refreshedSession, }; }; // ── Auth hint ──────────────────────────────────────────────────────────────── /** * The hint this request should be served with: the browser's own cookie when * it already matches the verified identity, else one minted fresh from the * session. `mint` is set when the cookie must also be (re)written — identity * data freshness (a renamed user/org) is the CLIENT's job via /account/me, * so the gate only steps in when the ids are wrong, never to rewrite display * fields (which would ping-pong with the client's authoritative write). */ const resolveAuthHint = async ( session: VerifiedSession, cookieHeader: string | null, ): Promise<{ hint: AuthHint; mint: boolean }> => { const existing = decodeAuthHint(parseCookie(cookieHeader, AUTH_HINT_COOKIE)); if ( existing && existing.user.id === session.userId && (existing.organization?.id ?? null) === session.organizationId ) { return { hint: existing, mint: false }; } return { hint: { v: 1, user: { id: session.userId, email: session.email, name: session.name, avatarUrl: session.avatarUrl, }, organization: session.organizationId ? { id: session.organizationId, ...(await organizationDisplay(session.organizationId)), } : null, }, mint: true, }; }; // The sealed session carries the org ID but not its name/slug; the local // mirror has both (every org row is born with a slug — see // `upsertOrganization`). Only consulted when minting the hint (absent/mismatched // cookie) — never on the steady-state path — and over per-request layers, // because a connection cached in the shared runtime would be reused across // requests, which Cloudflare forbids. A miss or failure reads as empty strings // — display-only, corrected by the client's /account/me write. const organizationDisplay = async ( organizationId: string, ): Promise<{ name: string; slug: string }> => { const exit = await getRuntime().runPromiseExit( Effect.flatMap(UserStoreService.asEffect(), (users) => users.use("getOrganization", (store) => store.getOrganization(organizationId)), ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), ); return Exit.isSuccess(exit) ? { name: exit.value?.name ?? "", slug: exit.value?.slug ?? "" } : { name: "", slug: "" }; }; // Live membership check for the last-org cookie's slug. Same authorize path // as any org selector — the cookie is a preference, so a slug the user can't // access (stale after removal/deletion, or forged) resolves to null and the // bare path falls through to today's canonicalize-onto-session-org behavior. // Per-request store layers for the same reason as organizationDisplay. const authorizeLastOrgSlug = async ( userId: string, slug: string, ): Promise<{ readonly id: string } | null> => { const exit = await getRuntime().runPromiseExit( authorizeOrganizationSelector(userId, slug).pipe( Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())), ), ); return Exit.isSuccess(exit) ? exit.value : null; }; const hintSetCookie = (hint: AuthHint) => `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; 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 cookieHeader = request.headers.get("cookie"); const sealed = parseCookie(cookieHeader, SESSION_COOKIE); const url = new URL(request.url); // Public pages are what signed-out visitors are FOR; a signed-in visitor // landing on /login is bounced straight back to where they were headed. if (PUBLIC_PATHS.has(pathname)) { 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 }); } // A session with no organization belongs in onboarding — same decision // the client AuthGate makes mid-session, made here before the document // exists so the app shell is never painted for an org-less session. if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) { return redirect("/create-org", { refreshedSession: session.refreshedSession }); } // A BARE console path (no org slug in the URL) canonicalizes onto the org // this browser last worked in (the last-org cookie), not the org pinned in // the session at login time — a multi-org user re-entering at `/` lands // where they left off. Slugged URLs never enter here (the URL names its // own scope), so this costs nothing on the steady state; the not-found // contract is untouched because an unknown-but-valid slug in the URL reads // as slugged, not bare. When the cookie matches the session's own org (the // overwhelmingly common single-org case) the client-side OrgSlugGate // already canonicalizes onto it, so skip the live membership check and the // redirect entirely. const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE); const firstSegment = pathname.split("/")[1] ?? ""; if ( session.organizationId && lastOrgSlug && isValidOrgSlug(lastOrgSlug) && !isValidOrgSlug(firstSegment) && !ONBOARDING_PATHS.has(pathname) ) { const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug; if (lastOrgSlug !== sessionOrgSlug) { // The cookie is a preference, not an authority: only redirect onto an // org the caller actively belongs to (stale/forged values fall through // to today's session-org canonicalization). const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug); if (lastOrg) { return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, { refreshedSession: session.refreshedSession, }); } } } // Serve the shell, minting the auth-hint cookie when the browser lacks a // current one so the client's post-mount cookie read seeds the verified // identity. Set-cookie writes ride on the shell response. const { hint, mint } = await resolveAuthHint(session, cookieHeader); const result = await next(); if (!mint && !session.refreshedSession) return result; const response = new Response(result.response.body, result.response); if (mint) { // The browser holds no current hint — mint one so the NEXT load (and // any client-side read) sees the same identity this render used. response.headers.append("set-cookie", hintSetCookie(hint)); } 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. response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession)); } return { ...result, response }; }, );