Files
Spanglish/backend/src/lib/auth.ts
T
MichilisandClaude Opus 5 733d2459df 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>
2026-07-29 19:07:04 +00:00

128 lines
3.7 KiB
TypeScript

import { Context } from 'hono';
import { and, eq } from 'drizzle-orm';
import { auth } from './betterAuth.js';
import { db, dbGet } from '../db/index.js';
import { authAccounts } from '../db/auth-schema.js';
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
// sessions validated against the auth_sessions table on every request, so
// revocation (ban/suspend/password reset) applies instantly. This module keeps
// the request-side helpers that the route files use.
// Re-exported for routes that hash/validate passwords outside Better Auth
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
export interface AuthUser {
id: string;
email: string;
name: string;
phone: string | null;
role: string;
languagePreference: string | null;
isClaimed: boolean;
rucNumber: string | null;
accountStatus: string;
emailVerified: boolean;
image: string | null;
createdAt: Date | string;
updatedAt: Date | string;
/** ID of the Better Auth session backing this request. */
sessionId: string;
}
/**
* Resolve the authenticated user for a request from its Better Auth session
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
* accounts never get API access even with a live session cookie.
*/
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
try {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return null;
}
const user = session.user as any;
// Suspended (banned) or unclaimed accounts must not retain API access
if (user.banned) {
return null;
}
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone ?? null,
role: user.role ?? 'user',
languagePreference: user.languagePreference ?? null,
isClaimed: Boolean(user.isClaimed),
rucNumber: user.rucNumber ?? null,
accountStatus: user.accountStatus ?? 'active',
emailVerified: Boolean(user.emailVerified),
image: user.image ?? null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
sessionId: session.session.id,
};
} catch {
return null;
}
}
export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
if (roles && !roles.includes(user.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', user);
await next();
};
}
/**
* Fetch only the credential password hash (never exposed via getAuthUser).
* Returns null when the user has no password set (Google-only or unclaimed).
*/
export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>(
(db as any)
.select({ password: (authAccounts as any).password })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
);
const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null;
}
/** Whether the user has a linked Google account. */
export async function hasGoogleAccount(userId: string): Promise<boolean> {
const row = await dbGet<any>(
(db as any)
.select({ id: (authAccounts as any).id })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'google')
)
)
);
return !!row;
}