Migrate authentication to Better Auth

Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-29 19:07:04 +00:00
co-authored by Claude Opus 5
parent 4afa5d6fa0
commit 733d2459df
47 changed files with 2430 additions and 1585 deletions
+104 -142
View File
@@ -1,8 +1,8 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
import { authClient } from '@/lib/auth-client';
import { fetchApi } from '@/lib/api/client';
interface User {
id: string;
@@ -18,18 +18,19 @@ interface User {
interface AuthContextType {
user: User | null;
/** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */
token: string | null;
isLoading: boolean;
isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<User | null>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
updateUser: (user: User) => void;
setAuthData: (data: { user: User; token: string }) => void;
refreshUser: () => Promise<void>;
setAuthData: (data: { user: User; token?: string }) => void;
refreshUser: () => Promise<User | null>;
}
interface RegisterData {
@@ -42,178 +43,139 @@ interface RegisterData {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'spanglish-token';
const USER_KEY = 'spanglish-user';
const AUTH_COOKIE = 'spanglish-auth';
// Legacy storage from the pre-Better-Auth JWT era; cleared once on mount.
const LEGACY_TOKEN_KEY = 'spanglish-token';
const LEGACY_USER_KEY = 'spanglish-user';
const LEGACY_AUTH_COOKIE = 'spanglish-auth';
function setAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
function mapSessionUser(sessionUser: any): User {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
role: sessionUser.role ?? 'user',
phone: sessionUser.phone ?? undefined,
languagePreference: sessionUser.languagePreference ?? undefined,
isClaimed: Boolean(sessionUser.isClaimed ?? true),
rucNumber: sessionUser.rucNumber ?? undefined,
accountStatus: sessionUser.accountStatus ?? 'active',
};
}
function clearAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string {
return error?.message || fallback;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => {
const currentToken = localStorage.getItem(TOKEN_KEY);
if (!currentToken) return;
const refreshUser = useCallback(async (): Promise<User | null> => {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${currentToken}`,
'Content-Type': 'application/json',
},
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
} else if (res.status === 401) {
// Token is invalid, clear auth state
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
const { data } = await authClient.getSession();
if (data?.user) {
const mapped = mapSessionUser(data.user);
setUser(mapped);
return mapped;
}
setUser(null);
return null;
} catch (error) {
// Network error, keep using cached data
// Network error: keep current state
console.error('Failed to refresh user data:', error);
return null;
}
}, []);
useEffect(() => {
// Load auth state from localStorage
const savedToken = localStorage.getItem(TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
let parsedUser: User | null = null;
try {
parsedUser = JSON.parse(savedUser);
} catch {
parsedUser = null;
}
if (parsedUser) {
setToken(savedToken);
setUser(parsedUser);
// Refresh user data from server to get latest role/permissions (source of truth)
refreshUser().finally(() => setIsLoading(false));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setIsLoading(false);
}
} else {
setIsLoading(false);
// One-time cleanup of legacy JWT-era storage
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
} catch {
/* SSR / storage unavailable */
}
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]);
const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token);
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
}, []);
const login = async (email: string, password: string) => {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Login failed');
const { error } = await authClient.signIn.email({ email, password });
if (error) {
throw new Error(messageFrom(error, 'Login failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const loginWithGoogle = async (credential: string) => {
const res = await fetch(`${API_BASE}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
const { error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Google login failed');
if (error) {
throw new Error(messageFrom(error, 'Google login failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const loginWithMagicLink = async (magicToken: string) => {
const res = await fetch(`${API_BASE}/api/auth/magic-link/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: magicToken }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Magic link login failed');
const loginWithMagicLink = async (magicToken: string): Promise<User | null> => {
const { error } = await authClient.magicLink.verify({ query: { token: magicToken } });
if (error) {
throw new Error(messageFrom(error, 'Invalid or expired link'));
}
const data = await res.json();
setAuthData(data);
return refreshUser();
};
const register = async (registerData: RegisterData) => {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerData),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Registration failed');
const { error } = await authClient.signUp.email({
email: registerData.email,
password: registerData.password,
name: registerData.name,
phone: registerData.phone || undefined,
languagePreference: registerData.languagePreference || undefined,
} as any);
if (error) {
// Existing-but-unclaimed accounts (created during guest booking) should
// point the user to the claim flow instead of a bare "already exists".
if (error.code === 'USER_ALREADY_EXISTS') {
try {
const { canClaim } = await fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}`
);
if (canClaim) {
const err = new Error(
'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.'
);
(err as any).canClaim = true;
throw err;
}
} catch (e: any) {
if (e?.canClaim) throw e;
/* eligibility check failed: fall through to generic message */
}
}
throw new Error(messageFrom(error, 'Registration failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const logout = useCallback(() => {
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
const currentToken = localStorage.getItem(TOKEN_KEY);
if (currentToken) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` },
}).catch(() => {
// Ignore network errors; local state is cleared regardless.
});
}
setToken(null);
// Best-effort server-side revocation; local state clears regardless.
authClient.signOut().catch(() => {
/* ignore network errors */
});
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}, []);
const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser);
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
}, []);
// Compat shim for callers that used to push {user, token} after custom auth
// flows; the session cookie is already set by then, so only state updates.
const setAuthData = useCallback((data: { user: User; token?: string }) => {
setUser(data.user);
}, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
@@ -221,16 +183,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isAdmin,
value={{
user,
token: null,
isLoading,
isAdmin,
hasAdminAccess,
login,
login,
loginWithGoogle,
loginWithMagicLink,
register,
register,
logout,
updateUser,
setAuthData,