diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 973ade561..3561e11d7 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -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), @@ -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, diff --git a/apps/cloud/src/auth/cookies.ts b/apps/cloud/src/auth/cookies.ts new file mode 100644 index 000000000..2a122f097 --- /dev/null +++ b/apps/cloud/src/auth/cookies.ts @@ -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; +}; diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 5b9e12f0c..2123a4408 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -10,7 +10,11 @@ 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 { encodeLoginState, decodeLoginState } from "./login-state"; +import { safeReturnTo } from "./return-to"; import { UserStoreService } from "./context"; import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; @@ -63,7 +67,7 @@ const DELETE_COOKIE_OPTIONS = { secure: true, }; -const randomState = (): string => { +const randomNonce = (): string => { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -143,14 +147,19 @@ 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 // header points at the internal proxy target, not the public URL // WorkOS needs to redirect back to. const origin = env.VITE_PUBLIC_SITE_URL ?? ""; - const state = randomState(); + // OAuth round-trips `state` verbatim, so the validated returnTo + // rides inside it next to the CSRF nonce — no extra cookie. + const state = encodeLoginState({ + nonce: randomNonce(), + returnTo: safeReturnTo(query.returnTo) ?? undefined, + }); const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state); return setResponseCookie( HttpServerResponse.redirect(url, { status: 302 }), @@ -211,9 +220,15 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( return HttpServerResponse.text("Failed to create session", { status: 500 }); } + // Resume where the SSR gate interrupted them. The state passed the + // CSRF check above whenever it's present, but it's still a + // round-tripped value — so the returnTo inside it is re-validated + // like any other untrusted path. + const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; + return deleteResponseCookie( setResponseCookie( - HttpServerResponse.redirect("/", { status: 302 }), + HttpServerResponse.redirect(returnTo, { status: 302 }), "wos-session", sealedSession, RESPONSE_COOKIE_OPTIONS, @@ -253,7 +268,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", () => diff --git a/apps/cloud/src/auth/login-state.test.ts b/apps/cloud/src/auth/login-state.test.ts new file mode 100644 index 000000000..07ae6a9c3 --- /dev/null +++ b/apps/cloud/src/auth/login-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { decodeLoginState, encodeLoginState } from "./login-state"; + +// The OAuth state parameter carries { nonce, returnTo } through the WorkOS +// round trip. It crosses a trust boundary twice (authorize URL out, callback +// query in), so decoding must be total — junk reads as null, never a throw. +describe("login state codec", () => { + it("round-trips nonce + returnTo", () => { + const state = { nonce: "a".repeat(64), returnTo: "/integrations/sentry?addAccount=1" }; + expect(decodeLoginState(encodeLoginState(state))).toEqual(state); + }); + + it("round-trips a state without returnTo", () => { + const state = { nonce: "b".repeat(64) }; + expect(decodeLoginState(encodeLoginState(state))).toEqual(state); + }); + + it("is URL-safe verbatim (no characters that need query escaping)", () => { + const encoded = encodeLoginState({ nonce: "c".repeat(64), returnTo: "/tools?q=a b&x=+/" }); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + // The callback can be reached by WorkOS-initiated redirects whose state we + // never minted, and by anyone typing a URL. + const junk = [ + null, + undefined, + "", // absent + "not-base64url!!", // invalid alphabet + "aGVsbG8", // valid base64url, not JSON ("hello") + "eyJmb28iOiJiYXIifQ", // valid JSON, wrong shape ({"foo":"bar"}) + ]; + for (const value of junk) { + it(`reads ${JSON.stringify(value)} as null`, () => { + expect(decodeLoginState(value)).toBeNull(); + }); + } +}); diff --git a/apps/cloud/src/auth/login-state.ts b/apps/cloud/src/auth/login-state.ts new file mode 100644 index 000000000..c016a6ef8 --- /dev/null +++ b/apps/cloud/src/auth/login-state.ts @@ -0,0 +1,36 @@ +// --------------------------------------------------------------------------- +// Login state — the OAuth `state` parameter for the WorkOS login round trip. +// +// `state` exists for CSRF (the callback checks it against the wos-login-state +// cookie), and OAuth round-trips it verbatim — which makes it the natural +// carrier for `returnTo` too: base64url(JSON { nonce, returnTo }). One value, +// one cookie, and the destination is covered by the same timing-safe +// comparison that authenticates the nonce. +// +// Decoding is total: anything that isn't our encoding reads as null, because +// the callback can also be reached by WorkOS-initiated redirects whose state +// (if any) we never minted. +// --------------------------------------------------------------------------- + +import { Encoding, Option, Result, Schema } from "effect"; + +const LoginStateSchema = Schema.Struct({ + nonce: Schema.String, + returnTo: Schema.optional(Schema.String), +}); + +export type LoginState = typeof LoginStateSchema.Type; + +const LoginStateFromJson = Schema.fromJsonString(LoginStateSchema); +const decodeLoginStateJson = Schema.decodeUnknownOption(LoginStateFromJson); +const encodeLoginStateJson = Schema.encodeSync(LoginStateFromJson); + +export const encodeLoginState = (state: LoginState): string => + Encoding.encodeBase64Url(encodeLoginStateJson(state)); + +/** Decode a state value back; null for anything we didn't mint. */ +export const decodeLoginState = (value: string | null | undefined): LoginState | null => { + if (!value) return null; + const json = Result.getOrNull(Encoding.decodeBase64UrlString(value)); + return json === null ? null : Option.getOrNull(decodeLoginStateJson(json)); +}; diff --git a/apps/cloud/src/auth/return-to.test.ts b/apps/cloud/src/auth/return-to.test.ts new file mode 100644 index 000000000..01585c273 --- /dev/null +++ b/apps/cloud/src/auth/return-to.test.ts @@ -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 → +// OAuth state param → 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", + ); + }); +}); diff --git a/apps/cloud/src/auth/return-to.ts b/apps/cloud/src/auth/return-to.ts new file mode 100644 index 000000000..79bf17bd6 --- /dev/null +++ b/apps/cloud/src/auth/return-to.ts @@ -0,0 +1,23 @@ +// --------------------------------------------------------------------------- +// returnTo — the "send me back where I was" path carried through the login +// flow (SSR gate → /login → /api/auth/login → OAuth state param → callback). +// +// The value crosses trust boundaries (query params, the state round-tripped +// through the identity provider), 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)}`; diff --git a/apps/cloud/src/auth/ssr-gate.ts b/apps/cloud/src/auth/ssr-gate.ts new file mode 100644 index 000000000..82f13e523 --- /dev/null +++ b/apps/cloud/src/auth/ssr-gate.ts @@ -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 | 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 => { + 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; + }, +); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 9150541bf..b29bad2bd 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -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"; @@ -442,13 +443,3 @@ export class WorkOSClient extends Context.Service { - 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; -}; diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 8d039c982..9b8b579a3 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -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", @@ -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 diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 3cbc16262..570484984 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SetupMcpRouteImport } from './routes/app/setup-mcp' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as OrgRouteImport } from './routes/app/org' +import { Route as LoginRouteImport } from './routes/app/login' import { Route as CreateOrgRouteImport } from './routes/app/create-org' import { Route as BillingRouteImport } from './routes/app/billing' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' @@ -50,6 +51,11 @@ const OrgRoute = OrgRouteImport.update({ path: '/org', getParentRoute: () => rootRouteImport, } as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const CreateOrgRoute = CreateOrgRouteImport.update({ id: '/create-org', path: '/create-org', @@ -103,6 +109,7 @@ export interface FileRoutesByFullPath { '/api-keys': typeof ApiKeysRoute '/billing': typeof BillingRoute '/create-org': typeof CreateOrgRoute + '/login': typeof LoginRoute '/org': typeof OrgRoute '/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/secrets': typeof SecretsRoute @@ -118,6 +125,7 @@ export interface FileRoutesByTo { '/api-keys': typeof ApiKeysRoute '/billing': typeof BillingRoute '/create-org': typeof CreateOrgRoute + '/login': typeof LoginRoute '/org': typeof OrgRoute '/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/secrets': typeof SecretsRoute @@ -134,6 +142,7 @@ export interface FileRoutesById { '/api-keys': typeof ApiKeysRoute '/billing': typeof BillingRoute '/create-org': typeof CreateOrgRoute + '/login': typeof LoginRoute '/org': typeof OrgRoute '/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/secrets': typeof SecretsRoute @@ -151,6 +160,7 @@ export interface FileRouteTypes { | '/api-keys' | '/billing' | '/create-org' + | '/login' | '/org' | '/policies' | '/secrets' @@ -166,6 +176,7 @@ export interface FileRouteTypes { | '/api-keys' | '/billing' | '/create-org' + | '/login' | '/org' | '/policies' | '/secrets' @@ -181,6 +192,7 @@ export interface FileRouteTypes { | '/api-keys' | '/billing' | '/create-org' + | '/login' | '/org' | '/policies' | '/secrets' @@ -197,6 +209,7 @@ export interface RootRouteChildren { ApiKeysRoute: typeof ApiKeysRoute BillingRoute: typeof BillingRoute CreateOrgRoute: typeof CreateOrgRoute + LoginRoute: typeof LoginRoute OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute @@ -245,6 +258,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OrgRouteImport parentRoute: typeof rootRouteImport } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/create-org': { id: '/create-org' path: '/create-org' @@ -310,6 +330,7 @@ const rootRouteChildren: RootRouteChildren = { ApiKeysRoute: ApiKeysRoute, BillingRoute: BillingRoute, CreateOrgRoute: CreateOrgRoute, + LoginRoute: LoginRoute, OrgRoute: OrgRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index d66288bd8..b1cbfc90e 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -19,12 +19,16 @@ import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { AuthProvider, useAuth } from "../web/auth"; +import { loginPath } from "../auth/return-to"; import { SupportOptions } from "../web/components/support-options"; -import { LoginPage } from "../web/pages/login"; import { Shell } from "../web/shell"; import appCss from "@executor-js/react/globals.css?url"; const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]); +// Routes that render for SIGNED-OUT visitors — the auth gate stays out of +// their way entirely (the SSR gate in auth/ssr-gate.ts bounces signed-in +// visitors off /login before the document is even served). +const PUBLIC_PATHS = new Set(["/login"]); if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) { Sentry.init({ @@ -72,7 +76,28 @@ const captureFrontendError: FrontendErrorReporter = (error, context) => { }); }; +function NotFoundPage() { + return ( +
+
+

404

+

Page not found

+

+ There's nothing at this address. +

+ + Go home + +
+
+ ); +} + export const Route = createRootRoute({ + notFoundComponent: NotFoundPage, head: () => ({ meta: [ { charSet: "utf-8" }, @@ -209,9 +234,13 @@ function AuthGate() { const location = useLocation(); const navigate = useNavigate(); const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname); + const isPublicRoute = PUBLIC_PATHS.has(location.pathname); const needsOrgRedirect = - auth.status === "authenticated" && auth.organization == null && !isOnboardingRoute; + auth.status === "authenticated" && + auth.organization == null && + !isOnboardingRoute && + !isPublicRoute; React.useEffect(() => { if (needsOrgRedirect) { @@ -219,12 +248,34 @@ function AuthGate() { } }, [needsOrgRedirect, navigate]); + // The signed-out safety net behind the SSR gate: if a session dies while + // the SPA is already loaded (logout elsewhere, expiry), go to /login the + // same way a fresh document request would — keeping where they were. + const needsLoginRedirect = auth.status === "unauthenticated" && !isPublicRoute; + React.useEffect(() => { + if (needsLoginRedirect) { + window.location.assign(loginPath(`${location.pathname}${location.searchStr}`)); + } + }, [needsLoginRedirect, location.pathname, location.searchStr]); + + if (isPublicRoute) { + return ; + } + + // Signed-out visitors never reach this point on a fresh document request — + // the SSR gate (auth/ssr-gate.ts) redirected them to /login before the SPA + // was served. So "loading" here means a VERIFIED signed-in user whose + // /account/me is still in flight (and usually not even that: the auth-hint + // cookie resolves them to "authenticated" a frame after mount), making the + // app-shell skeleton the right placeholder again. if (auth.status === "loading") { return ; } + // Mid-session sign-out (logout elsewhere, expiry): blank for the moment the + // redirect effect above needs — never a skeleton for an app they're out of. if (auth.status === "unauthenticated") { - return ; + return
; } if (isOnboardingRoute) { diff --git a/apps/cloud/src/routes/app/login.tsx b/apps/cloud/src/routes/app/login.tsx new file mode 100644 index 000000000..963d75a14 --- /dev/null +++ b/apps/cloud/src/routes/app/login.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { safeReturnTo } from "../../auth/return-to"; +import { LoginPage } from "../../web/pages/login"; + +// The signed-out landing page. The SSR auth gate (auth/ssr-gate.ts) sends +// signed-out document requests here with ?returnTo=, +// and bounces already-signed-in visitors straight back to it. +export const Route = createFileRoute("/login")({ + validateSearch: (search: Record) => ({ + returnTo: + safeReturnTo(typeof search.returnTo === "string" ? search.returnTo : null) ?? undefined, + }), + component: LoginRoute, +}); + +function LoginRoute() { + const { returnTo } = Route.useSearch(); + return ; +} diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index d8d8f15c1..9760f8c28 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -2,6 +2,7 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; import { cloudApiHandler } from "./app"; import { isAppOwnedPath } from "./app-paths"; +import { authGateMiddleware } from "./auth/ssr-gate"; import { prepareMcpOrgScope } from "./mcp/mount"; import { marketingMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware } from "./edge"; @@ -44,12 +45,15 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server( // The edge concerns (marketing proxy, sentry tunnel, posthog proxy) live in // `./edge`; they run before the app's own dispatch. Ordering is load-bearing: // marketing first (production landing/page proxy), then the analytics tunnels, -// then the unified app plane (api + mcp). +// then the unified app plane (api + mcp), and last the SSR auth gate — it only +// sees document requests nothing above claimed, so signed-out visitors are +// redirected to /login before the SPA (and its app-shell skeleton) is served. export const startInstance = createStart(() => ({ requestMiddleware: [ marketingMiddleware, sentryTunnelMiddleware, posthogProxyMiddleware, appRequestMiddleware, + authGateMiddleware, ], })); diff --git a/apps/cloud/src/web/pages/login.tsx b/apps/cloud/src/web/pages/login.tsx index 3da91fe3d..d65b1b1b9 100644 --- a/apps/cloud/src/web/pages/login.tsx +++ b/apps/cloud/src/web/pages/login.tsx @@ -1,7 +1,12 @@ import React from "react"; import { AUTH_PATHS } from "../../auth/api"; +import { safeReturnTo } from "../../auth/return-to"; -export const LoginPage = () => { +export const LoginPage = ({ returnTo }: { returnTo?: string | undefined }) => { + const destination = safeReturnTo(returnTo); + const loginHref = destination + ? `${AUTH_PATHS.login}?returnTo=${encodeURIComponent(destination)}` + : AUTH_PATHS.login; return (
@@ -10,7 +15,7 @@ export const LoginPage = () => {

Sign in to manage your tools and sources

Sign in diff --git a/bun.lock b/bun.lock index d62092e9c..2dffb80ee 100644 --- a/bun.lock +++ b/bun.lock @@ -347,6 +347,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "iron-webcrypto": "^2.0.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -3742,7 +3743,7 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + "iron-webcrypto": ["iron-webcrypto@2.0.0", "", { "dependencies": { "uint8array-extras": "^1.5.0" } }, "sha512-rtffZKDUHciZElM8mjFCufBC7nVhCxHYyWHESqs89OioEDz4parOofd8/uhrejh/INhQFfYQfByS22LlezR9sQ=="], "is-accessor-descriptor": ["is-accessor-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA=="], @@ -5636,6 +5637,8 @@ "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + "h3/iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/e2e/cloud/auth-hint.test.ts b/e2e/cloud/auth-hint.test.ts new file mode 100644 index 000000000..772a422dc --- /dev/null +++ b/e2e/cloud/auth-hint.test.ts @@ -0,0 +1,98 @@ +// Cloud-specific: the auth-hint cookie lifecycle. The sealed session is +// HttpOnly, so on a fresh page load the SPA can't know it's signed in until +// /account/me resolves — the hint (executor-auth-hint, non-HttpOnly, written +// by AuthProvider once /account/me confirms) is what lets the NEXT load paint +// the real app shell immediately instead of a skeleton. These scenarios pin +// the full loop: confirmed identity writes it, the next load seeds from it +// while the probe is still in flight, and logout takes it away. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +const HINT_COOKIE = "executor-auth-hint"; + +scenario( + "Auth hint · a confirmed session writes the hint, and the next load paints the real shell from it", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + const hintCookie = async () => + (await page.context().cookies()).find((cookie) => cookie.name === HINT_COOKIE); + + await step("First signed-in load → /account/me confirms → the hint is written", async () => { + await page.goto("/", { waitUntil: "commit" }); + await page.getByRole("link", { name: "Policies" }).waitFor(); + // The write happens in an effect after /account/me resolves. + await expect.poll(hintCookie, { timeout: 10_000 }).toBeTruthy(); + }); + + const hint = (await hintCookie())!; + expect(hint.httpOnly, "the hint is readable by the SPA — that's its job").toBe(false); + expect( + decodeURIComponent(hint.value), + "it carries the confirmed identity (display data only)", + ).toContain(identity.label); + + // Hold the auth probe open on the SECOND load. Without the hint this + // window is a full-page skeleton; with it, the real shell. + let probeResolved = false; + await page.route("**/api/account/me", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2_000)); + probeResolved = true; + await route.continue(); + }); + + await step("Reload: the real app shell paints while /account/me is in flight", async () => { + await page.goto("/", { waitUntil: "commit" }); + // Real nav text — the loading skeleton has no text at all. + await page.getByRole("link", { name: "Policies" }).waitFor(); + }); + + expect(probeResolved, "the shell did NOT wait for /account/me — the hint seeded it").toBe( + false, + ); + expect( + await page.getByRole("link", { name: "Billing" }).isVisible(), + "it is the full signed-in nav, not a placeholder", + ).toBe(true); + }); + }), +); + +scenario( + "Auth hint · logout clears the hint with the session", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Load the app signed in (the hint gets written)", async () => { + await page.goto("/", { waitUntil: "commit" }); + await page.getByRole("link", { name: "Policies" }).waitFor(); + await expect + .poll(async () => (await page.context().cookies()).some((c) => c.name === HINT_COOKIE), { + timeout: 10_000, + }) + .toBe(true); + }); + + await step("Sign out through the product flow", async () => { + // The shell's sign-out POSTs the logout endpoint from the page, so + // the response's Set-Cookie clears apply to this browser context. + await page.evaluate(() => fetch("/api/auth/logout", { method: "POST" })); + }); + + const names = (await page.context().cookies()).map((cookie) => cookie.name); + expect(names, "the hint never outlives the session").not.toContain(HINT_COOKIE); + expect(names, "the session itself is gone too").not.toContain("wos-session"); + }); + }), +); diff --git a/e2e/cloud/auth-session.test.ts b/e2e/cloud/auth-session.test.ts index b257cedec..7d5e96be9 100644 --- a/e2e/cloud/auth-session.test.ts +++ b/e2e/cloud/auth-session.test.ts @@ -5,7 +5,7 @@ // callback refusing forged/incomplete redirects, the sealed-session cookie // actually authorizing the session API, and logout dropping the cookie. import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Encoding, Result, Schema } from "effect"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; @@ -18,6 +18,14 @@ const setCookieFor = (response: Response, name: string): string => { return ""; }; +// state = base64url(JSON { nonce, returnTo? }) — the app's login-state +// envelope (apps/cloud/src/auth/login-state.ts). +const decodeLoginState = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ nonce: Schema.String, returnTo: Schema.optional(Schema.String) }), + ), +); + scenario( "Auth · login redirects to hosted AuthKit carrying a short-lived CSRF state cookie", {}, @@ -33,7 +41,16 @@ scenario( const authorizeUrl = new URL(response.headers.get("location") ?? ""); const state = authorizeUrl.searchParams.get("state") ?? ""; - expect(state, "the redirect carries an unguessable CSRF state").toMatch(/^[0-9a-f]{64}$/); + // The nonce is the CSRF secret; returnTo (absent here, no query was + // passed) rides beside it. + const decoded = decodeLoginState( + Result.getOrElse(Encoding.decodeBase64UrlString(state), () => ""), + ); + expect(decoded._tag, "the state decodes as our login-state envelope").toBe("Some"); + expect( + decoded._tag === "Some" ? decoded.value.nonce : "", + "the state carries an unguessable CSRF nonce", + ).toMatch(/^[0-9a-f]{64}$/); expect( authorizeUrl.searchParams.get("redirect_uri"), "AuthKit is told to come back to this deployment's callback", diff --git a/e2e/cloud/session-gate.test.ts b/e2e/cloud/session-gate.test.ts new file mode 100644 index 000000000..423ba6651 --- /dev/null +++ b/e2e/cloud/session-gate.test.ts @@ -0,0 +1,147 @@ +// Cloud-specific: the SSR auth gate's session handling BEYOND the basic +// signed-out redirect (unauthenticated-skeleton.test.ts owns that): an +// invalid cookie is actively cleared, /cloud (the marketing CTA path) routes +// into the app, and — the nastiest path — a session whose access token no +// longer verifies is refreshed in-flight, with the rotated sealed session +// reaching the browser. WorkOS refresh tokens are single-use, so losing that +// Set-Cookie silently logs the user out on next expiry; these scenarios pin +// it against the real WorkOS emulator (real rotation, real revocation). +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import * as Iron from "iron-webcrypto"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; +import { E2E_COOKIE_PASSWORD } from "../targets/cloud"; + +/** A signed-out-style document request (what the gate keys on). */ +const documentRequest = (url: URL, cookie?: string) => + Effect.promise(() => + fetch(url, { + redirect: "manual", + headers: { accept: "text/html", ...(cookie ? { cookie } : {}) }, + }), + ); + +const setCookieFor = (response: Response, name: string): string => { + for (const header of response.headers.getSetCookie()) { + if (header.startsWith(`${name}=`)) return header; + } + return ""; +}; + +scenario( + "Session gate · an invalid session cookie is cleared on the way to /login", + {}, + Effect.gen(function* () { + // Gate: the REST API plane is mounted on this target. + yield* Api; + const target = yield* Target; + + // A cookie that was never a sealed session. On executor.sh its mere + // presence routes / past the marketing page, so the gate must not just + // redirect — it must take the cookie (and the auth hint beside it) away. + const response = yield* documentRequest( + new URL("/", target.baseUrl), + "wos-session=not-a-real-session; executor-auth-hint=stale", + ); + expect(response.status, "an unverifiable session is signed out").toBe(302); + expect(response.headers.get("location"), "…to the login page").toBe("/login"); + + const clearedSession = setCookieFor(response, "wos-session"); + expect(clearedSession, "the dead session cookie is dropped").toContain("Max-Age=0"); + const clearedHint = setCookieFor(response, "executor-auth-hint"); + expect(clearedHint, "the auth hint goes with it").toContain("Max-Age=0"); + }), +); + +scenario( + "Session gate · /cloud (the marketing CTA path) routes into the app", + {}, + Effect.gen(function* () { + // Gate: the REST API plane is mounted on this target. + yield* Api; + const target = yield* Target; + + // Marketing CTAs link to /cloud, which is not a route — it means "open + // the app". Signed out, that lands on login (no returnTo: the deep link + // IS the root)… + const signedOut = yield* documentRequest(new URL("/cloud", target.baseUrl)); + expect(signedOut.status, "signed-out /cloud is gated like any page").toBe(302); + expect(signedOut.headers.get("location"), "…straight to login").toBe("/login"); + + // …and signed in, it opens the app at the root instead of 404ing. + const identity = yield* target.newIdentity(); + const signedIn = yield* documentRequest( + new URL("/cloud", target.baseUrl), + identity.headers!.cookie!, + ); + expect(signedIn.status, "signed-in /cloud is a redirect, not a 404").toBe(302); + expect(signedIn.headers.get("location"), "…into the app").toBe("/"); + }), +); + +// The sealed wos-session is iron-sealed JSON { accessToken, refreshToken, … }. +// Corrupting the access token's signature makes the gate's verify fail the +// same way an EXPIRED token does (invalid JWT → refresh path) — without +// waiting out a real expiry. Same sealing library + password map the WorkOS +// SDK uses, so the gate can't tell this seal from one the SDK minted. +const withTamperedAccessToken = async (sessionCookie: string): Promise => { + const sealed = sessionCookie.slice("wos-session=".length).replace(/~\d$/, ""); + const session = (await Iron.unseal(sealed, { "1": E2E_COOKIE_PASSWORD }, Iron.defaults)) as { + accessToken: string; + }; + const [header, payload, signature] = session.accessToken.split("."); + const tampered = { + ...session, + accessToken: `${header}.${payload}.${[...(signature ?? "")].reverse().join("")}`, + }; + const resealed = await Iron.seal( + tampered, + { id: "1", secret: E2E_COOKIE_PASSWORD }, + Iron.defaults, + ); + return `wos-session=${resealed}~2`; +}; + +scenario( + "Session gate · a stale access token is refreshed in-flight and the rotated session reaches the browser", + {}, + Effect.gen(function* () { + // Gate: the REST API plane is mounted on this target. + yield* Api; + const target = yield* Target; + + const identity = yield* target.newIdentity(); + const staleCookie = yield* Effect.promise(() => + withTamperedAccessToken(identity.headers!.cookie!), + ); + + // 1. The page is served (no login detour) — the gate refreshed the + // session against WorkOS mid-request — and the ROTATED sealed session + // is set on the response. Refresh tokens are single-use: dropping + // this Set-Cookie would log the user out at the next expiry. + const refreshed = yield* documentRequest(new URL("/", target.baseUrl), staleCookie); + expect(refreshed.status, "a refreshable session still gets the page").toBe(200); + const rotated = setCookieFor(refreshed, "wos-session"); + expect(rotated, "the rotated sealed session is persisted").toContain("Max-Age="); + expect(rotated, "…as a fresh value, not the stale one").not.toContain( + staleCookie.slice("wos-session=".length, 80), + ); + + // 2. The rotation was real: the OLD session's refresh token is revoked + // server-side, so replaying the stale cookie now signs out. + const replayed = yield* documentRequest(new URL("/", target.baseUrl), staleCookie); + expect(replayed.status, "the spent session is refused").toBe(302); + expect(replayed.headers.get("location"), "…and signed out to login").toBe("/login"); + expect( + setCookieFor(replayed, "wos-session"), + "the spent cookie is cleared, not left to retry forever", + ).toContain("Max-Age=0"); + + // 3. And the rotated session the browser was handed actually works. + const rotatedCookie = rotated.split(";")[0]!; + const next = yield* documentRequest(new URL("/tools", target.baseUrl), rotatedCookie); + expect(next.status, "the rotated session opens the app").toBe(200); + }), +); diff --git a/e2e/cloud/unauthenticated-skeleton.test.ts b/e2e/cloud/unauthenticated-skeleton.test.ts new file mode 100644 index 000000000..80e3145de --- /dev/null +++ b/e2e/cloud/unauthenticated-skeleton.test.ts @@ -0,0 +1,171 @@ +// Cloud-specific: what a SIGNED-OUT visitor gets at the app's pages. +// +// Born as the repro for the "bad skeleton on unauthed state" report: the root +// AuthGate used to SSR the AUTHENTICATED app-shell skeleton (sidebar + card +// grid) for every visitor and only swap to a login page after a client-side +// `/account/me` 401 — signed-out users were shown an app they'd never reach. +// Now the SSR auth gate (apps/cloud/src/auth/ssr-gate.ts) verifies the sealed +// session cookie in the worker and 302s signed-out document requests to +// /login (carrying ?returnTo=), so the app shell never exists for them. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +scenario( + "Unauthenticated · the signed-out cloud root lands on /login with no app-shell flash", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + + // No cookies, no headers → the browser context carries no session. + const anonymous = { label: "anonymous" }; + + yield* browser.session(anonymous, async ({ page, step }) => { + // Hold the auth probe open: the old bug lived exactly in this window + // (skeleton shown until /account/me resolved). The page must now be + // login-shaped even while it's pending. + await page.route("**/api/account/me", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await route.continue(); + }); + + await step("Open the cloud root while signed out → redirected to /login", async () => { + await page.goto("/", { waitUntil: "commit" }); + await page.getByText("Sign in to manage your tools and sources").waitFor(); + }); + + // Snapshot DURING the /account/me window — where the skeleton used to be. + const duringLoading = { + url: new URL(page.url()).pathname, + appShellSkeletons: await page.locator('[data-slot="skeleton"]').count(), + sidebarShown: await page.locator("aside").first().isVisible(), + loginShown: await page.getByText("Sign in to manage your tools and sources").isVisible(), + }; + + expect( + duringLoading, + "A signed-out visitor is served the login page directly — never the " + + "authenticated app-shell skeleton (sidebar nav + content-card grid).", + ).toEqual({ url: "/login", appShellSkeletons: 0, sidebarShown: false, loginShown: true }); + }); + }), +); + +scenario( + "Unauthenticated · a deep link survives login: gate → /login?returnTo → callback lands back on it", + {}, + Effect.gen(function* () { + // Gate: the REST API plane is mounted on this target. + yield* Api; + const target = yield* Target; + + const cookiePair = (response: Response, name: string): string | undefined => { + for (const header of response.headers.getSetCookie()) { + if (header.startsWith(`${name}=`)) return header.split(";")[0]; + } + return undefined; + }; + + // 1. A signed-out DOCUMENT request for a deep page is redirected to + // /login carrying the original path. + const gated = yield* Effect.promise(() => + fetch(new URL("/tools", target.baseUrl), { + redirect: "manual", + headers: { accept: "text/html" }, + }), + ); + expect(gated.status, "the page itself is never served signed-out").toBe(302); + expect(gated.headers.get("location"), "login knows where the visitor was headed").toBe( + "/login?returnTo=%2Ftools", + ); + + // Drive /api/auth/login → AuthKit (the emulator signs in headlessly via + // login_hint) → the app's callback, and report where the callback lands + // the signed-in browser. returnTo rides INSIDE the OAuth state param the + // whole way — the state cookie is the only thing the browser carries. + const completeLogin = (loginQuery: string) => + Effect.promise(async () => { + const login = await fetch(new URL(`/api/auth/login${loginQuery}`, target.baseUrl), { + redirect: "manual", + }); + expect(login.status, "login hands the browser to AuthKit").toBe(302); + const stateCookie = cookiePair(login, "wos-login-state"); + expect(stateCookie, "the CSRF state is pinned in a cookie").toBeTruthy(); + + const authorizeUrl = new URL(login.headers.get("location") ?? ""); + authorizeUrl.searchParams.set("login_hint", `returnto-${Date.now()}@e2e.test`); + const consent = await fetch(authorizeUrl, { redirect: "manual" }); + const callbackUrl = consent.headers.get("location"); + expect(callbackUrl, "AuthKit redirects back to the app's callback").toBeTruthy(); + + const callback = await fetch(callbackUrl!, { + redirect: "manual", + headers: { cookie: stateCookie! }, + }); + expect(callback.status, "the callback completes the login").toBe(302); + expect(cookiePair(callback, "wos-session"), "a real session cookie is minted").toBeTruthy(); + return callback.headers.get("location"); + }); + + // 2. Completing the hosted flow resumes exactly where the gate + // interrupted the visitor — the deep link, not "/". + const landed = yield* completeLogin("?returnTo=%2Ftools"); + expect(landed, "login resumes where the gate interrupted the visitor").toBe("/tools"); + + // 3. The returnTo channel never becomes an open redirect: an off-origin + // destination is dropped at the login door, so the flow lands on "/". + const forgedLanding = yield* completeLogin("?returnTo=https%3A%2F%2Fevil.example"); + expect(forgedLanding, "an off-origin returnTo falls back to the root").toBe("/"); + }), +); + +scenario( + "Unauthenticated · a signed-in session still opens the app shell (the gate lets it through)", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the cloud root signed in", async () => { + await page.goto("/", { waitUntil: "commit" }); + await page.locator("aside").first().waitFor({ state: "visible" }); + }); + expect(new URL(page.url()).pathname, "no login detour for a valid session").toBe("/"); + }); + + // A signed-in visitor landing on /login is bounced back into the app. + const loginWhileSignedIn = yield* Effect.promise(() => + fetch(new URL("/login", target.baseUrl), { + redirect: "manual", + headers: { accept: "text/html", ...identity.headers }, + }), + ); + expect(loginWhileSignedIn.status, "/login is for signed-out visitors").toBe(302); + expect(loginWhileSignedIn.headers.get("location")).toBe("/"); + }), +); + +scenario( + "Unauthenticated · unknown paths are a real 404 page, not a skeleton or a blank app", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open a path that doesn't exist", async () => { + await page.goto("/this-page-does-not-exist", { waitUntil: "commit" }); + await page.getByText("Page not found").waitFor(); + }); + expect( + await page.locator('[data-slot="skeleton"]').count(), + "no app-shell skeleton on the 404 page", + ).toBe(0); + }); + }), +); diff --git a/e2e/package.json b/e2e/package.json index 6cd994567..45a0fa78f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -36,6 +36,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "iron-webcrypto": "^2.0.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/react/src/multiplayer/auth-context.tsx b/packages/react/src/multiplayer/auth-context.tsx index 34a083308..b4170b5ea 100644 --- a/packages/react/src/multiplayer/auth-context.tsx +++ b/packages/react/src/multiplayer/auth-context.tsx @@ -1,8 +1,14 @@ -import React, { createContext, useContext, useEffect } from "react"; +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { meAtom } from "../api/account-atoms"; +import { + clearAuthHintCookie, + readAuthHintCookie, + writeAuthHintCookie, + type AuthHint, +} from "./auth-hint"; // --------------------------------------------------------------------------- // Shared auth seam for the multiplayer apps (cloud + self-host). @@ -12,6 +18,13 @@ import { meAtom } from "../api/account-atoms"; // and self-host (Better Auth) is which server answers `me` and how the session // cookie was minted. Analytics stay OUT of here; a host that wants to identify // the user (cloud → PostHog) passes an `onIdentify` callback. +// +// While `/account/me` is in flight the state is seeded from the auth-hint +// cookie (see ./auth-hint): a signed-in user's first paint is the app shell +// with their identity (no skeleton-gate), and a visitor with no hint renders +// as loading — which the host should never let happen for signed-out users +// (cloud redirects them to /login during SSR). The hint is display-only; +// the resolved `/account/me` answer always wins. // --------------------------------------------------------------------------- export type AuthUser = { @@ -39,6 +52,14 @@ const AuthContext = createContext({ status: "loading" }); export const useAuth = () => useContext(AuthContext); +/** The auth-hint cookie as an optimistic AuthState, or null when absent. */ +const hintState = (hint: AuthHint | null): AuthState | null => + hint && { + status: "authenticated", + user: hint.user, + organization: hint.organization, + }; + const AuthProviderClient = ({ children, onIdentify, @@ -48,39 +69,57 @@ const AuthProviderClient = ({ }) => { const result = useAtomValue(meAtom); - const state: AuthState = AsyncResult.match(result, { - onInitial: () => ({ status: "loading" as const }), - onSuccess: ({ value }) => ({ - status: "authenticated" as const, - user: value.user, - organization: value.organization, - }), - onFailure: () => ({ status: "unauthenticated" as const }), - }); - - // Primitive identity fields so the identify effect fires only on real - // transitions (the `state` object is rebuilt every render). - const status = state.status; - const userId = state.status === "authenticated" ? state.user.id : null; - const email = state.status === "authenticated" ? state.user.email : null; - const name = state.status === "authenticated" ? state.user.name : null; - const avatarUrl = state.status === "authenticated" ? state.user.avatarUrl : null; - const organizationId = state.status === "authenticated" ? (state.organization?.id ?? null) : null; - const organizationName = - state.status === "authenticated" ? (state.organization?.name ?? null) : null; + // The hint applies one frame AFTER mount: SSR always renders the loading + // state and the first client render must match that HTML, so the cookie + // read is deferred to an effect (the documented pattern for client-only + // values). The flip costs a frame, not a network round trip. + const [hint, setHint] = useState(null); + useEffect(() => { + setHint(readAuthHintCookie()); + }, []); + + // What `/account/me` actually said — the authority. The atom value only + // changes identity when the query emits, so memoizing on it gives the + // effects below a dependency that tracks real transitions. + const resolved = useMemo( + () => + AsyncResult.match(result, { + onInitial: () => ({ status: "loading" as const }), + onSuccess: ({ value }) => ({ + status: "authenticated" as const, + user: value.user, + organization: value.organization, + }), + onFailure: () => ({ status: "unauthenticated" as const }), + }), + [result], + ); + // Both effects key off RESOLVED state, never hint-derived optimism: + // identify must not report a stale hint to analytics, and the hint cookie + // must not rewrite itself from its own contents. useEffect(() => { - if (!onIdentify) return; - if (status === "authenticated" && userId && email !== null) { - onIdentify({ - status: "authenticated", - user: { id: userId, email, name, avatarUrl }, - organization: organizationId ? { id: organizationId, name: organizationName ?? "" } : null, - }); - } else if (status === "unauthenticated") { - onIdentify({ status: "unauthenticated" }); + if (resolved.status === "loading") return; + onIdentify?.(resolved); + }, [onIdentify, resolved]); + + // Keep the hint cookie in step with reality so the NEXT page load seeds + // correctly: refresh it on every confirmed identity, drop it the moment the + // server says signed-out. + useEffect(() => { + if (resolved.status === "authenticated") { + writeAuthHintCookie({ v: 1, user: resolved.user, organization: resolved.organization }); + } else if (resolved.status === "unauthenticated") { + clearAuthHintCookie(); } - }, [onIdentify, status, userId, email, name, avatarUrl, organizationId, organizationName]); + }, [resolved]); + + // What consumers see — the hint papers over the in-flight window only. + // Memoized so context consumers don't re-render on unrelated renders. + const state = useMemo( + () => (resolved.status === "loading" ? (hintState(hint) ?? resolved) : resolved), + [resolved, hint], + ); return {children}; }; diff --git a/packages/react/src/multiplayer/auth-hint.tsx b/packages/react/src/multiplayer/auth-hint.tsx new file mode 100644 index 000000000..6a48a6e39 --- /dev/null +++ b/packages/react/src/multiplayer/auth-hint.tsx @@ -0,0 +1,76 @@ +// --------------------------------------------------------------------------- +// Auth-hint cookie — the client-readable "probably signed in" signal. +// +// The session cookie itself (cloud's `wos-session`, self-host's Better Auth +// cookie) is HttpOnly, so on a fresh page load the SPA can't know it's signed +// in until `/account/me` resolves. This cookie is the non-HttpOnly companion: +// a display-only snapshot of the authenticated identity that `AuthProvider` +// writes whenever `/account/me` confirms a session and reads back at the next +// page load to render the app shell immediately, while `/account/me` +// reconciles in the background. +// +// It is a HINT, never an authority: every API call is still authenticated by +// the real session cookie server-side, and a stale or forged hint can only +// change which placeholder the user briefly sees. Keep the payload to +// display data the user already knows about themselves. +// +// The encode/decode half is pure (no DOM) so a server that wants to write or +// clear the hint (cloud's logout does) shares one encoding with the client. +// --------------------------------------------------------------------------- + +import { Option, Schema } from "effect"; + +export const AUTH_HINT_COOKIE = "executor-auth-hint"; + +/** Matches the session cookie lifetime (7 days). */ +export const AUTH_HINT_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + +const AuthHintSchema = Schema.Struct({ + v: Schema.Literal(1), + user: Schema.Struct({ + id: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + }), + organization: Schema.NullOr(Schema.Struct({ id: Schema.String, name: Schema.String })), +}); + +export type AuthHint = typeof AuthHintSchema.Type; + +const AuthHintFromJson = Schema.fromJsonString(AuthHintSchema); +const decodeAuthHintJson = Schema.decodeUnknownOption(AuthHintFromJson); +const encodeAuthHintJson = Schema.encodeSync(AuthHintFromJson); +// The cookie value arrives percent-encoded; a truncated/corrupted one can +// make decodeURIComponent itself throw, which must read as "no hint". +const decodeUriComponentOption = Option.liftThrowable(decodeURIComponent); + +/** Encode a hint as a cookie VALUE (URI-encoded JSON). */ +export const encodeAuthHint = (hint: AuthHint): string => + encodeURIComponent(encodeAuthHintJson(hint)); + +/** Decode a cookie VALUE back into a hint; null for anything malformed. */ +export const decodeAuthHint = (value: string | null | undefined): AuthHint | null => { + if (!value) return null; + return decodeUriComponentOption(value).pipe(Option.flatMap(decodeAuthHintJson), Option.getOrNull); +}; + +// ── Browser-side cookie maintenance (AuthProvider) ─────────────────────────── + +export const writeAuthHintCookie = (hint: AuthHint): void => { + document.cookie = `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; Path=/; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}; SameSite=Lax${ + window.location.protocol === "https:" ? "; Secure" : "" + }`; +}; + +export const clearAuthHintCookie = (): void => { + document.cookie = `${AUTH_HINT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`; +}; + +export const readAuthHintCookie = (): AuthHint | null => { + const match = document.cookie + .split(";") + .map((c) => c.trim()) + .find((c) => c.startsWith(`${AUTH_HINT_COOKIE}=`)); + return decodeAuthHint(match ? match.slice(AUTH_HINT_COOKIE.length + 1) : null); +};