diff --git a/apps/api/lib/auth.ts b/apps/api/lib/auth.ts index 086059817..82474697b 100644 --- a/apps/api/lib/auth.ts +++ b/apps/api/lib/auth.ts @@ -1,4 +1,5 @@ import { schema as Db } from "@repo/db"; +import { createAuthMiddleware } from "better-auth/api"; import { betterAuth } from "better-auth"; import type { DB } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; @@ -8,6 +9,11 @@ import { emailOTP } from "better-auth/plugins/email-otp"; import { sendOTP, sendPasswordReset, sendVerificationEmail } from "./email"; import type { Env } from "./env"; +// Auth hint cookie for edge routing (see docs/adr/001-auth-hint-cookie.md) +// NOT a security boundary - false positives are acceptable (causes one redirect) +// __Host- prefix requires Secure; use plain name in HTTP dev +const AUTH_HINT_VALUE = "1"; + /** * Environment variables required for authentication configuration. * Extracted from the main Env type for better type safety and documentation. @@ -130,6 +136,47 @@ export function createAuth( generateId: false, }, }, + + // Set/clear auth hint cookie for edge routing + hooks: { + after: createAuthMiddleware(async (ctx) => { + const isSecure = new URL(env.APP_ORIGIN).protocol === "https:"; + // __Host- prefix requires Secure; browsers reject it over HTTP + const cookieName = isSecure ? "__Host-auth" : "auth"; + const cookieOpts = { + path: "/", + secure: isSecure, + httpOnly: true, + sameSite: "lax" as const, + }; + + // Set hint cookie on session creation (sign-in, sign-up, OAuth callback) + if (ctx.context.newSession) { + ctx.setCookie(cookieName, AUTH_HINT_VALUE, cookieOpts); + return; + } + + // Clear hint cookie on sign-out + // ctx.path is normalized (base path stripped) by better-call router + if (ctx.path.startsWith("/sign-out")) { + ctx.setCookie(cookieName, "", { ...cookieOpts, maxAge: 0 }); + return; + } + + // Clear stale hint cookie on session check when session is invalid + // Only run on /get-session where ctx.context.session is reliably populated + // This handles: expired sessions, revoked sessions, deleted users + if (ctx.path === "/get-session" && !ctx.context.session) { + const cookies = ctx.request?.headers.get("cookie") ?? ""; + const hasHintCookie = cookies + .split(";") + .some((c) => c.trim().startsWith(`${cookieName}=`)); + if (hasHintCookie) { + ctx.setCookie(cookieName, "", { ...cookieOpts, maxAge: 0 }); + } + } + }), + }, }); } diff --git a/apps/web/package.json b/apps/web/package.json index 239871de4..1c8d5619a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@astrojs/react": "^4.4.2", "@repo/ui": "workspace:*", "astro": "^5.16.4", + "hono": "^4.10.7", "react": "^19.2.1", "react-dom": "^19.2.1" }, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 405d0014e..3f447ad1b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,7 +12,7 @@ "@repo/ui": ["../../packages/ui"], "@repo/ui/*": ["../../packages/ui/*"] }, - "types": ["astro/client"] + "types": ["astro/client", "@cloudflare/workers-types"] }, "include": ["**/*.ts", "**/*.tsx", "**/*.json", "**/*.astro"], "exclude": ["**/dist/**/*", "**/node_modules/**/*"], diff --git a/apps/web/worker.ts b/apps/web/worker.ts new file mode 100644 index 000000000..96592dab0 --- /dev/null +++ b/apps/web/worker.ts @@ -0,0 +1,58 @@ +/** + * Edge router for the marketing site. + * + * Routes "/" based on auth-hint cookie presence: + * - Cookie present: proxy to app (session validated there) + * - No cookie: serve marketing site + * + * See docs/adr/001-auth-hint-cookie.md + */ + +import { Hono } from "hono"; +import { getCookie } from "hono/cookie"; + +interface Env { + ASSETS: Fetcher; + APP_SERVICE: Fetcher; + API_SERVICE: Fetcher; +} + +const app = new Hono<{ Bindings: Env }>(); + +// API proxy +app.all("/api/*", (c) => c.env.API_SERVICE.fetch(c.req.raw)); + +// App routes +app.all("/_app/*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); +app.all("/login*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); +app.all("/signup*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); +app.all("/settings*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); +app.all("/analytics*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); +app.all("/reports*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); + +// Home page: route based on auth-hint cookie presence +// __Host-auth (HTTPS) or auth (HTTP dev) — see docs/adr/001-auth-hint-cookie.md +app.on(["GET", "HEAD"], "/", async (c) => { + const hasAuthHint = + getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1"; + + const upstream = await (hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS).fetch( + c.req.raw, + ); + + // Prevent caching — response varies by auth state + const headers = new Headers(upstream.headers); + headers.set("Cache-Control", "private, no-store"); + headers.set("Vary", "Cookie"); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +}); + +// Marketing pages +app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw)); + +export default app; diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index b6ead5545..a3ac16afd 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -4,6 +4,7 @@ // [METADATA] // Application identity and Cloudflare runtime compatibility settings. "name": "example-web", + "main": "./worker.ts", "compatibility_date": "2025-08-15", "compatibility_flags": [], "workers_dev": false, @@ -11,9 +12,22 @@ // [ASSETS] // Serves bundled JavaScript, CSS, images, and other static assets. "assets": { - "directory": "./dist" + "directory": "./dist", + "binding": "ASSETS", + // Force worker execution for "/" to enable auth-aware routing + // See docs/adr/001-auth-hint-cookie.md + "run_worker_first": ["/"] }, + // [SERVICE BINDINGS] + // Connect to other workers for dynamic routing. + // NOTE: services is non-inheritable — must be specified per environment. + // Use naming convention: - (e.g., example-app-staging) + "services": [ + { "binding": "APP_SERVICE", "service": "example-app" }, + { "binding": "API_SERVICE", "service": "example-api" } + ], + // [ENVIRONMENTS] // Environment-specific configurations for different deployment stages. // Top-level config = production, nested env objects = other environments. @@ -48,6 +62,10 @@ "routes": [ { "pattern": "staging.example.com/*", "zone_name": "example.com" } ], + "services": [ + { "binding": "APP_SERVICE", "service": "example-app-staging" }, + { "binding": "API_SERVICE", "service": "example-api-staging" } + ], "vars": { "ENVIRONMENT": "staging" } @@ -60,6 +78,10 @@ "routes": [ { "pattern": "preview.example.com/*", "zone_name": "example.com" } ], + "services": [ + { "binding": "APP_SERVICE", "service": "example-app-preview" }, + { "binding": "API_SERVICE", "service": "example-api-preview" } + ], "vars": { "ENVIRONMENT": "preview" } diff --git a/docs/adr/001-auth-hint-cookie.md b/docs/adr/001-auth-hint-cookie.md new file mode 100644 index 000000000..46e4835e8 --- /dev/null +++ b/docs/adr/001-auth-hint-cookie.md @@ -0,0 +1,35 @@ +# ADR-001 Auth Hint Cookie For Edge Routing + +**Status:** Accepted +**Date:** 2025-12-28 +**Tags:** auth, routing, edge + +## Problem + +The web edge needs a fast signal to route `/` without owning auth logic. + +## Decision + +Use a dedicated auth-hint cookie set on login and cleared on logout or invalid session. The web worker checks only cookie presence to route, while the app remains the authority. No API calls or session validation in `web`. + +This cookie is NOT a security boundary. It is a routing hint only. False positives are acceptable and result in one extra redirect to `/login`. + +## Implementation Notes + +- Cookie name: `__Host-auth` in HTTPS; `auth` in HTTP dev (browsers reject `__Host-` without Secure). +- Cookie lifecycle: set on new session; clear on sign-out; clear on session-check failure. +- Web routing: check for either cookie name; never read session cookies. + +## Alternatives Considered + +1. **Validate session in web via API** — Couples edge to auth, adds latency/failure modes. +2. **Read Better Auth session cookie directly** — Brittle to auth library changes and cookie formats. + +## Consequences + +- **Positive:** Faster edge routing, clear separation of concerns, auth-lib agnostic. +- **Negative:** False positives cause one extra redirect; requires maintaining set/clear hooks. + +## Links + +- https://github.com/kriasoft/react-starter-kit/issues/2101