Merge pull request #96 from Molecule-AI/feat/canvas-auth-redirect

feat(canvas): AuthGate — redirect anonymous users to cp login
This commit is contained in:
Hongming Wang 2026-04-14 20:42:12 -07:00 committed by GitHub
commit 96d88f42a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 196 additions and 1 deletions

View File

@ -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 (
<html lang="en">
<body className="bg-zinc-950 text-white">{children}</body>
<body className="bg-zinc-950 text-white">
{/* 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. */}
<AuthGate>{children}</AuthGate>
</body>
</html>
);
}

View File

@ -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=<here>.
*
* 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<AuthGateState>({ 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}</>;
}

View File

@ -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");
});
});

51
canvas/src/lib/auth.ts Normal file
View File

@ -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<Session | null> {
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;
}