From dafa3711f81d01bbb9116c8dd644b565c7ebe443 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 29 Jul 2026 20:45:05 +0000 Subject: [PATCH] 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 --- README.md | 7 +- backend/.env.example | 8 ++ backend/src/lib/betterAuth.ts | 21 +++++ .../src/app/(public)/auth/magic-link/page.tsx | 3 +- frontend/src/app/(public)/login/page.tsx | 91 +++++++++++++++++-- frontend/src/app/(public)/register/page.tsx | 30 +++++- .../src/components/GoogleSignInButton.tsx | 8 +- frontend/src/components/ui/Button.tsx | 18 +++- frontend/src/i18n/locales/en.json | 3 + frontend/src/i18n/locales/es.json | 3 + frontend/src/lib/authRedirect.ts | 56 ++++++++++++ frontend/src/middleware.ts | 21 +++-- 12 files changed, 243 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/authRedirect.ts diff --git a/README.md b/README.md index 48b1349..4f48ba7 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Key settings (see `backend/.env.example` for the full list): - **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL) - **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` +- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development. - **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL` - **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials - **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template. @@ -124,8 +125,10 @@ Key settings (see `frontend/.env.example`): - **Server port**: `PORT=3002` - **API base URL**: `NEXT_PUBLIC_API_URL` (optional) - - Leave empty to use same-origin `/api` (recommended when running behind nginx) - - In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend + - Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port. + - Inlined at build time: changing it requires a rebuild, not just a restart. + - Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well. +- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`). - **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc. ## Database diff --git a/backend/.env.example b/backend/.env.example index 5b57d4c..d9c1c90 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/lib/betterAuth.ts b/backend/src/lib/betterAuth.ts index 7042d92..df5eacf 100644 --- a/backend/src/lib/betterAuth.ts +++ b/backend/src/lib/betterAuth.ts @@ -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: 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 diff --git a/frontend/src/app/(public)/auth/magic-link/page.tsx b/frontend/src/app/(public)/auth/magic-link/page.tsx index 28c4508..a643550 100644 --- a/frontend/src/app/(public)/auth/magic-link/page.tsx +++ b/frontend/src/app/(public)/auth/magic-link/page.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { safeInternalPath } from '@/lib/safeRedirect'; +import { redirectAfterAuth } from '@/lib/authRedirect'; import toast from 'react-hot-toast'; function MagicLinkContent() { @@ -46,7 +47,7 @@ function MagicLinkContent() { ? '/auth/claim-account' : callbackURL; setTimeout(() => { - router.push(destination); + redirectAfterAuth(destination); }, 1500); } catch (err: any) { setStatus('error'); diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx index 809c09d..cecd3f0 100644 --- a/frontend/src/app/(public)/login/page.tsx +++ b/frontend/src/app/(public)/login/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; @@ -11,14 +11,20 @@ import Input from '@/components/ui/Input'; import GoogleSignInButton from '@/components/GoogleSignInButton'; import { authApi } from '@/lib/api'; import { safeInternalPath } from '@/lib/safeRedirect'; +import { + clearRedirectAttempt, + didRedirectBounce, + redirectAfterAuth, +} from '@/lib/authRedirect'; import toast from 'react-hot-toast'; function LoginContent() { - const router = useRouter(); const searchParams = useSearchParams(); const { t, locale: language } = useLanguage(); - const { login } = useAuth(); + const { login, user, isLoading: authLoading } = useAuth(); const [loading, setLoading] = useState(false); + const [redirecting, setRedirecting] = useState(false); + const [bounced, setBounced] = useState(false); const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password'); const [magicLinkSent, setMagicLinkSent] = useState(false); const [formData, setFormData] = useState({ @@ -29,6 +35,27 @@ function LoginContent() { // Check for redirect after login (only same-origin relative paths are honoured) const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard'); + // Send an already-signed-in visitor on to their destination — and detect the case + // where that destination bounced them back here, which otherwise looks like the + // login page silently ignoring a successful sign-in. + useEffect(() => { + if (authLoading || redirecting) return; + + if (!user) { + // Signed out on the login page is a clean slate. + clearRedirectAttempt(); + return; + } + + if (didRedirectBounce(redirectTo)) { + setBounced(true); + return; + } + + setRedirecting(true); + redirectAfterAuth(redirectTo); + }, [authLoading, redirecting, user, redirectTo]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -36,10 +63,12 @@ function LoginContent() { try { await login(formData.email, formData.password); toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!'); - router.push(redirectTo); + // Deliberately leaves `loading` set: the button must stay disabled until the + // browser replaces this page. + setRedirecting(true); + redirectAfterAuth(redirectTo); } catch (error: any) { toast.error(error.message || t('auth.errors.invalidCredentials')); - } finally { setLoading(false); } }; @@ -67,6 +96,38 @@ function LoginContent() { } }; + // The destination sent us back here even though the session is valid. Say so, rather + // than re-showing a form that appears to do nothing. The retry link is a plain so + // it is a full page load, like every other navigation out of this page. + if (bounced) { + return ( +
+
+ +
+
+ ); + } + return (
@@ -151,9 +212,25 @@ function LoginContent() {
- + + {redirecting && ( +

+ {t('auth.login.redirecting')} +

+ )} ) : magicLinkSent ? (
diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 0c5dfc2..c88b836 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; @@ -9,13 +8,16 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import GoogleSignInButton from '@/components/GoogleSignInButton'; +import { redirectAfterAuth } from '@/lib/authRedirect'; import toast from 'react-hot-toast'; +const REDIRECT_TO = '/dashboard'; + export default function RegisterPage() { - const router = useRouter(); const { t, locale: language } = useLanguage(); const { register } = useAuth(); const [loading, setLoading] = useState(false); + const [redirecting, setRedirecting] = useState(false); const [formData, setFormData] = useState({ name: '', email: '', @@ -30,10 +32,12 @@ export default function RegisterPage() { try { await register(formData); toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!'); - router.push('/dashboard'); + // Deliberately leaves `loading` set: the button must stay disabled until the + // browser replaces this page. + setRedirecting(true); + redirectAfterAuth(REDIRECT_TO); } catch (error: any) { toast.error(error.message || t('auth.errors.emailExists')); - } finally { setLoading(false); } }; @@ -104,9 +108,25 @@ export default function RegisterPage() { onChange={(e) => setFormData({ ...formData, phone: e.target.value })} /> - + + {redirecting && ( +

+ {t('auth.login.redirecting')} +

+ )}

diff --git a/frontend/src/components/GoogleSignInButton.tsx b/frontend/src/components/GoogleSignInButton.tsx index a2f0383..9aeabc3 100644 --- a/frontend/src/components/GoogleSignInButton.tsx +++ b/frontend/src/components/GoogleSignInButton.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useAuth } from '@/context/AuthContext'; import { useLanguage } from '@/context/LanguageContext'; import { safeInternalPath } from '@/lib/safeRedirect'; +import { redirectAfterAuth } from '@/lib/authRedirect'; import toast from 'react-hot-toast'; declare global { @@ -83,9 +84,10 @@ export default function GoogleSignInButton({ toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!'); onSuccess?.(); - // Use window.location for navigation to ensure clean state. - // Constrain to a same-origin path to avoid open redirects. - window.location.href = safeInternalPath(redirectTo, '/dashboard'); + // Full page load for a clean state, recording the attempt so the login page can + // tell a bounced redirect from a fresh visit. Constrained to a same-origin path + // to avoid open redirects. + redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard')); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Google login failed'; const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index ba06226..2e21bdf 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -7,10 +7,24 @@ interface ButtonProps extends ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'; size?: 'sm' | 'md' | 'lg'; isLoading?: boolean; + /** Label shown in place of the children while loading. */ + loadingText?: string; } const Button = forwardRef( - ({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => { + ( + { + className, + variant = 'primary', + size = 'md', + isLoading, + loadingText = 'Loading...', + children, + disabled, + ...props + }, + ref + ) => { return (