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
+5 -2
View File
@@ -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
+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
@@ -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');
+84 -7
View File
@@ -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 <a> so
// it is a full page load, like every other navigation out of this page.
if (bounced) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold">{t('auth.login.redirectBlocked')}</h1>
{user && <p className="mt-2 text-sm text-gray-600">{user.email}</p>}
<p className="mt-4 text-sm text-gray-600">
<code className="px-1.5 py-0.5 bg-gray-100 rounded">{redirectTo}</code>
</p>
<a href={redirectTo} className="mt-6 block">
<Button className="w-full" size="lg">
{t('auth.login.redirectRetry')}
</Button>
</a>
<Link
href="/"
className="mt-3 inline-block text-sm text-secondary-blue hover:underline"
>
{t('nav.home')}
</Link>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -151,9 +212,25 @@ function LoginContent() {
</Link>
</div>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
<Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
{t('auth.login.submit')}
</Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form>
) : magicLinkSent ? (
<div className="text-center py-8">
+25 -5
View File
@@ -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 })}
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
<Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
{t('auth.register.submit')}
</Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form>
<p className="mt-6 text-center text-sm text-gray-600">
@@ -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;
+16 -2
View File
@@ -7,10 +7,24 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
(
{
className,
variant = 'primary',
size = 'md',
isLoading,
loadingText = 'Loading...',
children,
disabled,
...props
},
ref
) => {
return (
<button
ref={ref}
@@ -62,7 +76,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
{loadingText}
</>
) : (
children
+3
View File
@@ -235,6 +235,9 @@
"email": "Email",
"password": "Password",
"submit": "Sign In",
"redirecting": "Redirecting...",
"redirectBlocked": "You're signed in, but we couldn't open that page.",
"redirectRetry": "Try again",
"noAccount": "Don't have an account?",
"register": "Sign Up"
},
+3
View File
@@ -235,6 +235,9 @@
"email": "Email",
"password": "Contraseña",
"submit": "Iniciar Sesión",
"redirecting": "Redirigiendo...",
"redirectBlocked": "Has iniciado sesión, pero no pudimos abrir esa página.",
"redirectRetry": "Intentar de nuevo",
"noAccount": "¿No tienes cuenta?",
"register": "Registrarse"
},
+56
View File
@@ -0,0 +1,56 @@
/**
* Post-authentication navigation.
*
* Navigation uses a full page load rather than `router.push`. The session is an httpOnly
* cookie, and only a top-level request lets the middleware guard in `middleware.ts` see it
* and re-render the header and the destination together.
*
* A marker in sessionStorage records where we last sent someone, and when. If the
* middleware cannot see the session cookie it redirects straight back to `/login`, and
* without the marker the login page would immediately navigate again — an endless reload
* loop. A React ref cannot do this job: a full page load resets component state.
*/
const ATTEMPT_KEY = 'spanglish.auth-redirect-attempt';
// A real bounce comes back within a few hundred milliseconds (it is a server redirect).
// An older marker means an unrelated later visit to /login, not a failed redirect.
const ATTEMPT_TTL_MS = 30_000;
export function markRedirectAttempt(target: string): void {
try {
sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify({ target, at: Date.now() }));
} catch {
/* storage disabled (private mode): the loop guard degrades, navigation still works */
}
}
export function clearRedirectAttempt(): void {
try {
sessionStorage.removeItem(ATTEMPT_KEY);
} catch {
/* ignore */
}
}
/** True when we just sent the user to `target` and are back on the login page instead. */
export function didRedirectBounce(target: string): boolean {
try {
const raw = sessionStorage.getItem(ATTEMPT_KEY);
if (!raw) return false;
const { target: attempted, at } = JSON.parse(raw) as { target?: string; at?: number };
return (
attempted === target && typeof at === 'number' && Date.now() - at < ATTEMPT_TTL_MS
);
} catch {
return false;
}
}
/**
* Records the attempt, then navigates. Callers should keep their pending/loading UI on
* screen: this never resolves, the page is replaced.
*/
export function redirectAfterAuth(target: string): void {
markRedirectAttempt(target);
window.location.assign(target);
}
+15 -6
View File
@@ -13,16 +13,25 @@ const SESSION_COOKIES = [
'spanglish.session_token', // development
];
// Matched as a fallback so a change to Better Auth's `advanced.cookiePrefix` cannot
// silently lock every user out of these routes.
const SESSION_COOKIE_SUFFIX = '.session_token';
function hasSessionCookie(request: NextRequest): boolean {
if (SESSION_COOKIES.some((name) => !!request.cookies.get(name)?.value)) return true;
return request.cookies
.getAll()
.some((cookie) => cookie.name.endsWith(SESSION_COOKIE_SUFFIX) && !!cookie.value);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const { pathname, search } = request.nextUrl;
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
const hasSessionCookie = SESSION_COOKIES.some(
(name) => !!request.cookies.get(name)?.value
);
if (!hasSessionCookie) {
if (!hasSessionCookie(request)) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
// Keep the query string, so a bounced /admin/photos?page=3 resumes where it was.
loginUrl.searchParams.set('redirect', `${pathname}${search}`);
return NextResponse.redirect(loginUrl);
}
}