Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/app/api/auth.$.ts
Original file line number Diff line number Diff line change
@@ -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,
);
},
},
},
Expand Down
22 changes: 22 additions & 0 deletions src/server/auth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/server/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions tests/e2e/self-hosted/invite-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ────────────
Expand All @@ -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);
Expand All @@ -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 ────────────
Expand Down Expand Up @@ -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);
Expand All @@ -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 ─────────
Expand All @@ -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);
Expand Down
Loading