Fix post-login redirect when the session cookie is off-origin

Signing in showed the "Welcome back!" toast but never left /login. The
session cookie was host-only on the API subdomain, so the Next middleware
guard on the site origin saw no cookie and bounced /dashboard straight
back to /login?redirect=/dashboard.

- Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so
  the cookie also reaches the site origin. Unset in dev, where localhost
  is single-host and must stay host-only.
- Navigate after authentication with a full page load, via a shared
  authRedirect helper: only a top-level request carries the httpOnly
  cookie. Used by the login, register, magic-link and Google flows.
- Show "Redirecting..." on the login and register pages and keep the
  submit button disabled until the browser replaces the page, instead of
  re-enabling it mid-navigation.
- Guard against a redirect loop with a sessionStorage marker. A React ref
  cannot do this: the full page load resets component state. If the
  destination bounces back, explain it rather than navigating again.
- Middleware: accept any *.session_token cookie so a cookiePrefix change
  cannot lock everyone out, and preserve the destination's query string.
- Trust any loopback port in dev, so reaching the dev server through a
  forwarded port does not fail Better Auth's CSRF origin check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-29 20:45:05 +00:00
co-authored by Claude Opus 4.6
parent 733d2459df
commit dafa3711f8
12 changed files with 243 additions and 26 deletions
+8
View File
@@ -59,6 +59,14 @@ BETTER_AUTH_SECRET=
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
BETTER_AUTH_URL=
# Session cookie domain, shared across subdomains. Set this when the API is served
# from a different host than the site (e.g. api.spanglishcommunity.com vs
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
# E.g. .spanglishcommunity.com
AUTH_COOKIE_DOMAIN=
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
# trusted; anything else is treated as a client and rate-limited by its
+21
View File
@@ -19,6 +19,12 @@ import { getLoginLockout } from './stores/loginLockout.js';
const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
// and needs a host-only cookie.
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
const rawSecret = process.env.BETTER_AUTH_SECRET;
@@ -48,6 +54,15 @@ function computeTrustedOrigins(): string[] {
/* keep frontendUrl as-is */
}
if (process.env.API_URL) origins.add(process.env.API_URL);
if (!isProduction) {
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
// port forwarding), so the browser's Origin is http://localhost:<random> and every
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
origins.add('http://localhost:*');
origins.add('http://127.0.0.1:*');
}
return [...origins];
}
@@ -99,6 +114,12 @@ export const auth = betterAuth({
advanced: {
cookiePrefix: 'spanglish',
useSecureCookies: isProduction,
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
// stay valid.
...(cookieDomain
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
: {}),
ipAddress: {
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
// anchors trust in the TCP peer address (only our own proxies may speak