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
@@ -25,7 +25,7 @@ interface AccountTabProps {
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser, logout } = useAuth();
const { user, updateUser } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
@@ -187,15 +187,17 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all other sessions? This session stays signed in.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
toast.success(
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
@@ -477,7 +479,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
@@ -494,7 +496,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index === 0 ? (
{session.current ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
+5 -3
View File
@@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { locale } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth();
const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
@@ -36,11 +36,13 @@ export default function DashboardPage() {
router.push('/login');
return;
}
if (user && token) {
// Auth rides on the httpOnly session cookie; once the user has resolved
// the API calls are authenticated automatically.
if (user) {
loadDashboardData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading, token]);
}, [user, authLoading]);
const loadDashboardData = async () => {
setLoading(true);