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
47 changes: 47 additions & 0 deletions apps/api/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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 });
}
}
}),
},
});
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"],
Expand Down
58 changes: 58 additions & 0 deletions apps/web/worker.ts
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 23 additions & 1 deletion apps/web/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,30 @@
// [METADATA]
// Application identity and Cloudflare runtime compatibility settings.
"name": "example-web",
"main": "./worker.ts",
"compatibility_date": "2025-08-15",
"compatibility_flags": [],
"workers_dev": false,

// [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: <worker>-<env> (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.
Expand Down Expand Up @@ -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"
}
Expand All @@ -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"
}
Expand Down
35 changes: 35 additions & 0 deletions docs/adr/001-auth-hint-cookie.md
Original file line number Diff line number Diff line change
@@ -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