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>
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
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 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: async (token: string) => {
|
|
const { data, error } = await authClient.magicLink.verify({ query: { token } });
|
|
throwIfError(error, 'Invalid or expired token');
|
|
return data;
|
|
},
|
|
|
|
// 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: 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: 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) =>
|
|
authApi.requestMagicLink(email, '/auth/claim-account'),
|
|
|
|
confirmClaimAccount: (password: string) =>
|
|
fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
|
|
claimEligibility: (email: string) =>
|
|
fetchApi<{ canClaim: boolean }>(
|
|
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}`
|
|
),
|
|
|
|
// 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; 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: 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 };
|
|
},
|
|
};
|