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
+79 -45
View File
@@ -1,63 +1,97 @@
import { authClient } from '../auth-client';
import { fetchApi } from './client';
import type { User } from './types';
// Thin wrappers over the Better Auth client, preserving the legacy authApi
// call surface used by the auth pages.
type ClientError = { message?: string; code?: string; status: number } | null;
function throwIfError(error: ClientError, fallback: string): void {
if (error) {
const err = new Error(error.message || fallback);
(err as any).code = error.code;
(err as any).status = error.status;
throw err;
}
}
export const authApi = {
// Magic link
requestMagicLink: (email: string) =>
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
// Magic link login. Enumeration-safe UX parity: unknown emails resolve with
// the same generic message (magic links never create accounts server-side);
// only rate limiting surfaces as an error.
requestMagicLink: async (email: string, callbackURL: string = '/dashboard') => {
const { error } = await authClient.signIn.magicLink({ email, callbackURL });
if (error && error.status === 429) {
throwIfError(error, 'Too many requests. Please try again later.');
}
return { message: 'If an account exists with this email, a login link has been sent.' };
},
verifyMagicLink: (token: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
method: 'POST',
body: JSON.stringify({ token }),
}),
verifyMagicLink: async (token: string) => {
const { data, error } = await authClient.magicLink.verify({ query: { token } });
throwIfError(error, 'Invalid or expired token');
return data;
},
// Password reset
requestPasswordReset: (email: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
// Password reset (Better Auth is enumeration-safe here by default)
requestPasswordReset: async (email: string) => {
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: '/auth/reset-password',
});
throwIfError(error, 'Failed to request password reset');
return { message: 'If an account exists with this email, a password reset link has been sent.' };
},
confirmPasswordReset: (token: string, password: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
}),
confirmPasswordReset: async (token: string, password: string) => {
const { error } = await authClient.resetPassword({ newPassword: password, token });
throwIfError(error, 'Invalid or expired token');
return { message: 'Password reset successfully. Please log in with your new password.' };
},
// Account claiming
// Account claiming: a magic link that lands on the claim page, where the
// session-holding user sets a password via /api/auth-ext/claim-account.
requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
authApi.requestMagicLink(email, '/auth/claim-account'),
confirmClaimAccount: (password: string) =>
fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', {
method: 'POST',
body: JSON.stringify({ email }),
body: JSON.stringify({ password }),
}),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
'/api/auth/claim-account/confirm',
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
claimEligibility: (email: string) =>
fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}`
),
// Google OAuth
googleAuth: (credential: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
// Google Identity Services credential (ID token) sign-in
googleAuth: async (credential: string) => {
const { data, error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
throwIfError(error, 'Google login failed');
return data;
},
// Change password
changePassword: (currentPassword: string, newPassword: string) =>
fetchApi<{ message: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
}),
// Change password; other sessions are revoked so a stolen session can't
// outlive the change (this device stays signed in).
changePassword: async (currentPassword: string, newPassword: string) => {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
});
throwIfError(error, 'Failed to change password');
return { message: 'Password changed successfully' };
},
// Get current user
me: () => fetchApi<{ user: User }>('/api/auth/me'),
me: async (): Promise<{ user: User | null }> => {
const { data, error } = await authClient.getSession();
throwIfError(error, 'Failed to load session');
return { user: (data?.user as unknown as User) ?? null };
},
};