From aedd3db697b4f1ab4d704174ead08cb91588a8d5 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 14 Apr 2026 20:37:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20AuthGate=20=E2=80=94=20redirect?= =?UTF-8?q?=20anonymous=20users=20to=20cp=20login=20(Phase=20F=20close)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the canvas root so every tenant-subdomain request checks for a valid session and bounces to app.moleculesai.app/cp/auth/login with a return_to pointing back at the current URL. Local dev + vercel preview URLs + apex pass through unchanged. Files: - canvas/src/lib/auth.ts: fetchSession() probes /cp/auth/me (credentials:include for cross-origin cookie); returns Session on 200, null on 401 (anonymous, no throw), throws on 5xx so transient outages don't leak the UI. - canvas/src/lib/auth.ts: redirectToLogin() builds the cp login URL with window.location.href as return_to; CP's isSafeReturnTo check rejects cross-domain bounces. - canvas/src/components/AuthGate.tsx: client component wrapping children. State machine: loading → authenticated | anonymous. In non-SaaS mode (no tenant slug) skips the gate entirely. - canvas/src/app/layout.tsx: wraps the root body in . Tests: +6 auth.ts (200 / 401 null / 5xx throw / credentials:include / redirectToLogin href + signup variant). Full suite 453 green (was 447). Pairs with molecule-controlplane PR #16 (return_to cookie handshake on the cp side). Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/app/layout.tsx | 9 +++- canvas/src/components/AuthGate.tsx | 68 ++++++++++++++++++++++++++ canvas/src/lib/__tests__/auth.test.ts | 69 +++++++++++++++++++++++++++ canvas/src/lib/auth.ts | 51 ++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 canvas/src/components/AuthGate.tsx create mode 100644 canvas/src/lib/__tests__/auth.test.ts create mode 100644 canvas/src/lib/auth.ts diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx index e0e98f1d..15cd5646 100644 --- a/canvas/src/app/layout.tsx +++ b/canvas/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { AuthGate } from "@/components/AuthGate"; export const metadata: Metadata = { title: "Molecule AI", @@ -13,7 +14,13 @@ export default function RootLayout({ }) { return ( - {children} + + {/* AuthGate is a client component; it checks the session on mount + and bounces anonymous users to the control plane's login page + when running on a tenant subdomain. Non-SaaS hosts (localhost, + vercel preview URL, apex) pass through unchanged. */} + {children} + ); } diff --git a/canvas/src/components/AuthGate.tsx b/canvas/src/components/AuthGate.tsx new file mode 100644 index 00000000..b65eeeb6 --- /dev/null +++ b/canvas/src/components/AuthGate.tsx @@ -0,0 +1,68 @@ +"use client"; + +/** + * AuthGate wraps the canvas root so every page is gated on a valid session. + * Anonymous users get bounced to app.moleculesai.app/cp/auth/login?return_to=. + * + * In non-SaaS mode (no tenant slug — local dev, apex, vercel preview URL), + * the gate is a pass-through: canvas works without auth for local dev. + * This mirrors the control plane's "disabled provider" fallback. + */ +import { useEffect, useState, type ReactNode } from "react"; +import { fetchSession, redirectToLogin, type Session } from "@/lib/auth"; +import { getTenantSlug } from "@/lib/tenant"; + +export type AuthGateState = + | { kind: "loading" } + | { kind: "anonymous"; skipRedirect: boolean } + | { kind: "authenticated"; session: Session }; + +export function AuthGate({ children }: { children: ReactNode }) { + const [state, setState] = useState({ kind: "loading" }); + + useEffect(() => { + // In non-SaaS mode (no tenant slug) we skip the gate entirely — + // local dev, vercel preview URLs, and the app.moleculesai.app apex + // should not force login for API-only interactions. + const slug = getTenantSlug(); + if (!slug) { + setState({ kind: "anonymous", skipRedirect: true }); + return; + } + let cancelled = false; + fetchSession() + .then((s) => { + if (cancelled) return; + if (s) { + setState({ kind: "authenticated", session: s }); + } else { + setState({ kind: "anonymous", skipRedirect: false }); + } + }) + .catch(() => { + // Network error — fail closed (show signin) so a transient + // outage doesn't leak the canvas UI to an unauth'd user. + if (!cancelled) setState({ kind: "anonymous", skipRedirect: false }); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (state.kind === "anonymous" && !state.skipRedirect) { + redirectToLogin("sign-in"); + } + }, [state]); + + if (state.kind === "loading") { + // Minimal placeholder; canvas has its own loading UI downstream. + return null; + } + if (state.kind === "anonymous" && !state.skipRedirect) { + // Redirect already firing from the effect above; render nothing in + // the interim to avoid a flash of unauthenticated content. + return null; + } + return <>{children}; +} diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts new file mode 100644 index 00000000..f1cd3b52 --- /dev/null +++ b/canvas/src/lib/__tests__/auth.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { fetchSession, redirectToLogin } from "../auth"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("fetchSession", () => { + it("returns session on 200", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ user_id: "u1", org_id: "o1", email: "a@x.com" }), + })); + const s = await fetchSession(); + expect(s).toEqual({ user_id: "u1", org_id: "o1", email: "a@x.com" }); + }); + + it("returns null on 401 without throwing", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 })); + const s = await fetchSession(); + expect(s).toBeNull(); + }); + + it("throws on 500 so transient outages aren't treated as 'anonymous'", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "oops" })); + await expect(fetchSession()).rejects.toThrow("500"); + }); + + it("sends credentials:include for cross-origin cookies", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 }); + vi.stubGlobal("fetch", fetchMock); + await fetchSession(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/cp/auth/me"), + expect.objectContaining({ credentials: "include" }), + ); + }); +}); + +describe("redirectToLogin", () => { + it("sets window.location to cp login URL with return_to", () => { + const href = "https://acme.moleculesai.app/dashboard"; + Object.defineProperty(window, "location", { + writable: true, + value: { href }, + }); + redirectToLogin("sign-in"); + // href now holds the redirect target. encodeURIComponent(href) must + // appear in the query. + expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/login"); + expect((window.location as unknown as { href: string }).href).toContain( + encodeURIComponent(href), + ); + }); + + it("uses signup path for sign-up screenHint", () => { + Object.defineProperty(window, "location", { + writable: true, + value: { href: "https://acme.moleculesai.app/" }, + }); + redirectToLogin("sign-up"); + expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/signup"); + }); +}); diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts new file mode 100644 index 00000000..d16006ac --- /dev/null +++ b/canvas/src/lib/auth.ts @@ -0,0 +1,51 @@ +/** + * Canvas-side session detection. Calls /cp/auth/me on the control plane + * (via same-origin → PLATFORM_URL) and returns the session or null. + * + * 401 is the "anonymous" signal and does NOT throw — the caller decides + * whether to redirect. Network errors do throw so React error boundaries + * can surface them. + */ +import { PLATFORM_URL } from "./api"; + +export interface Session { + user_id: string; + org_id: string; + email: string; +} + +// Base path prefix for auth endpoints on the control plane. +const AUTH_BASE = "/cp/auth"; + +/** + * fetchSession probes /cp/auth/me with the session cookie (credentials: + * include mandatory cross-origin). Returns the Session on 200, null on + * 401 (anonymous), throws on anything else so callers don't silently + * treat a 5xx as "not logged in". + */ +export async function fetchSession(): Promise { + const res = await fetch(`${PLATFORM_URL}${AUTH_BASE}/me`, { + credentials: "include", + }); + if (res.status === 401) return null; + if (!res.ok) { + throw new Error(`/cp/auth/me: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +/** + * redirectToLogin bounces the browser to the control plane's login page + * with a `return_to` param so the user lands back on the current URL + * after signup/login completes. Same-origin safety is enforced on the + * CP side (isSafeReturnTo rejects cross-domain / http / protocol- + * relative URLs). Uses window.location.href so the full URL including + * query + hash survives the round trip. + */ +export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): void { + if (typeof window === "undefined") return; + const returnTo = window.location.href; + const path = screenHint === "sign-up" ? "signup" : "login"; + const dest = `${PLATFORM_URL}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; + window.location.href = dest; +}