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:
co-authored by
Claude Opus 5
parent
4afa5d6fa0
commit
733d2459df
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user