diff --git a/src/app/api/auth.$.ts b/src/app/api/auth.$.ts index 5c33898e..e125423e 100644 --- a/src/app/api/auth.$.ts +++ b/src/app/api/auth.$.ts @@ -1,14 +1,60 @@ import { createFileRoute } from "@tanstack/react-router"; import { auth } from "~/server/auth"; +import { isTrustedCorsOrigin } from "~/server/auth/constants"; + +/** + * Returns the request's Origin header if it is a trusted cross-origin caller + * (e.g. the marketing site checking for a session), otherwise null. + */ +function getTrustedCorsOrigin(request: Request): string | null { + const origin = request.headers.get("origin"); + if (!origin) return null; + return isTrustedCorsOrigin(origin) ? origin : null; +} + +function withCorsHeaders(response: Response, request: Request): Response { + const corsOrigin = getTrustedCorsOrigin(request); + if (!corsOrigin) return response; + + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", corsOrigin); + headers.set("Access-Control-Allow-Credentials", "true"); + headers.append("Vary", "Origin"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +async function handleAuthRequest(request: Request) { + const response = await auth.handler(request); + return withCorsHeaders(response, request); +} export const Route = createFileRoute("/api/auth/$")({ server: { handlers: { GET: ({ request }) => { - return auth.handler(request); + return handleAuthRequest(request); }, POST: ({ request }) => { - return auth.handler(request); + return handleAuthRequest(request); + }, + OPTIONS: ({ request }) => { + // CORS preflight for trusted cross-origin callers. + return withCorsHeaders( + new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }, + }), + request, + ); }, }, }, diff --git a/src/server/auth/constants.ts b/src/server/auth/constants.ts index 9e7bb085..53524dd7 100644 --- a/src/server/auth/constants.ts +++ b/src/server/auth/constants.ts @@ -6,6 +6,28 @@ export const TRUSTED_ORIGINS_SET = new Set([ ...env.TRUSTED_ORIGINS.map((o) => new URL(o).origin), ]); +/** + * Whether an origin may make credentialed CORS requests to the auth API. + * Trusts the explicit TRUSTED_ORIGINS list, plus any https origin on the + * shared cookie domain — those hosts already receive the session cookie, + * so allowing them CORS grants nothing new. + */ +export function isTrustedCorsOrigin(origin: string): boolean { + if (TRUSTED_ORIGINS_SET.has(origin)) return true; + if (!env.COOKIE_DOMAIN) return false; + + try { + const { protocol, hostname } = new URL(origin); + if (protocol !== "https:") return false; + const cookieDomain = env.COOKIE_DOMAIN.replace(/^\./, ""); + const isOnCookieDomain = + hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`); + return isOnCookieDomain; + } catch { + return false; + } +} + /** * Validate an incoming origin/referer against the trusted origins list. * Returns the matched origin, or falls back to VITE_PUBLIC_BASE_URL. diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index da5a16f6..1fedeab4 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -46,6 +46,12 @@ import { captureException, logError, logMessage } from "~/server/logger"; import { env } from "~/env"; import { IS_DEMO_INSTANCE } from "~/lib/demo"; +const SIGNED_IN_REDIRECT_AUTH_PATHS = [ + "/auth", + "/auth/sign-in", + "/auth/sign-up", +]; + export const authMiddleware = createMiddleware().server( async ({ pathname, next }) => { const headers = getRequestHeaders() as Headers; @@ -66,6 +72,14 @@ export const authMiddleware = createMiddleware().server( } } + // Signed-in users have no business on the sign-in/sign-up pages — + // send them into the app instead of showing them an auth form. + const isSignedInOnAuthEntryPage = + !!session && SIGNED_IN_REDIRECT_AUTH_PATHS.includes(pathname); + if (isSignedInOnAuthEntryPage) { + throw redirect({ to: "/" }); + } + if (!session) { if (!pathname.startsWith("/auth/") && pathname !== "/auth") { throw redirect({ to: BASE_SIGNED_OUT_URL }); diff --git a/tests/e2e/fixtures/auth.ts b/tests/e2e/fixtures/auth.ts index 6553f8d9..90c08b38 100644 --- a/tests/e2e/fixtures/auth.ts +++ b/tests/e2e/fixtures/auth.ts @@ -142,6 +142,23 @@ export async function signUpAsAdmin({ await signIn({ page, email, password }); } +/** + * Signs out the current user. Better Auth's sign-out endpoint is POST-only, + * so navigating to /api/auth/sign-out with page.goto does NOT clear the + * session — this helper issues a real POST through the page's cookie jar. + */ +export async function signOut(page: Page) { + const result = await page.evaluate(async () => { + const response = await fetch("/api/auth/sign-out", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + return { ok: response.ok, status: response.status }; + }); + expect(result.ok, `sign-out failed with status ${result.status}`).toBe(true); +} + /** * Signs in an existing user, handling the case where the page may land on * sign-up instead of sign-in (e.g. if no users exist yet and the app diff --git a/tests/e2e/self-hosted/invite-flow.spec.ts b/tests/e2e/self-hosted/invite-flow.spec.ts index f42b1a18..26924e7d 100644 --- a/tests/e2e/self-hosted/invite-flow.spec.ts +++ b/tests/e2e/self-hosted/invite-flow.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { signIn, signUpAsAdmin } from "../fixtures/auth"; +import { signIn, signOut, signUpAsAdmin } from "../fixtures/auth"; import { SELF_HOSTED_TURSO_PORT } from "../fixtures/ports"; import { cleanupUser, generateTestEmail } from "../fixtures/seed-db"; import type { Page } from "@playwright/test"; @@ -106,7 +106,7 @@ test.describe("invite flow", () => { await closeButton.first().click(); } - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); // ── 8. First invited user signs up ────────────────────────────── await page.goto(invitePath); @@ -135,7 +135,7 @@ test.describe("invite flow", () => { await expect(page).toHaveURL("/", { timeout: 30000 }); // ── 9. Sign out first invited user, sign in as admin ──────────── - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); await signIn({ page, email: adminEmail, password }); // ── 10. Verify use count updated to 1, still active ──────────── @@ -147,7 +147,7 @@ test.describe("invite flow", () => { await expect(page.getByText("Active").first()).toBeVisible(); // ── 11. Sign out admin ───────────────────────────────────────── - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); // ── 12. Second invited user signs up with the same link ───────── await page.goto(invitePath); @@ -170,7 +170,7 @@ test.describe("invite flow", () => { await expect(page).toHaveURL("/", { timeout: 30000 }); // ── 13. Sign out second user, sign in as admin ────────────────── - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); await signIn({ page, email: adminEmail, password }); // ── 14. Verify use count updated to 2, still active ──────────── @@ -243,7 +243,7 @@ test.describe("invite flow", () => { await expect(page.getByText("Active").first()).toBeVisible(); // ── 7. Sign out the admin ─────────────────────────────────────── - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); // ── 8. First user signs up successfully ───────────────────────── await page.goto(invitePath); @@ -264,7 +264,7 @@ test.describe("invite flow", () => { await expect(page).toHaveURL("/", { timeout: 30000 }); // ── 9. Sign out first user, sign in as admin ──────────────────── - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); await signIn({ page, email: adminEmail, password }); // ── 10. Verify use count is 1/1 and status is Exhausted ───────── @@ -274,7 +274,7 @@ test.describe("invite flow", () => { await expect(page.getByText("Used")).toBeVisible(); // ── 11. Sign out admin ────────────────────────────────────────── - await page.goto("/api/auth/sign-out", { waitUntil: "networkidle" }); + await signOut(page); // ── 12. Second user sees disabled sign-up ─────────────────────── await page.goto(invitePath);