/** * Server-side sign-in endpoint that accepts an email-or-username * `identifier`. The username → email resolution happens entirely server- * side, so the canonical email is never disclosed to the browser. This * closes the username-enumeration vector that the old * `/api/auth/resolve-identifier` endpoint left open (it echoed the real * email on a hit; a synthetic `@auth.invalid` email on a miss was * trivially distinguishable from a real one by domain). * * The endpoint POSTs to better-auth's `/api/auth/sign-in/email` * downstream so the response shape (cookies + JSON body) matches what * the existing client expects. */ import { NextResponse, type NextRequest } from 'next/server'; import { sql, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { user, userProfiles } from '@/lib/db/schema/users'; import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit'; function clientIp(req: NextRequest): string { return ( req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown' ); } async function resolveToEmail(identifier: string): Promise { const raw = identifier.trim(); if (!raw) return null; if (raw.includes('@')) return raw; const normalized = raw.toLowerCase(); const rows = await db .select({ email: user.email }) .from(userProfiles) .innerJoin(user, eq(userProfiles.userId, user.id)) .where(sql`LOWER(${userProfiles.username}) = ${normalized}`) .limit(1); return rows[0]?.email ?? null; } export async function POST(req: NextRequest) { // Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses. const ip = clientIp(req); const rl = await checkRateLimit(ip, rateLimiters.auth); if (!rl.allowed) { return NextResponse.json( { error: { message: 'Too many attempts. Try again later.' } }, { status: 429, headers: rateLimitHeaders(rl) }, ); } const body = (await req.json().catch(() => ({}))) as { identifier?: string; password?: string; rememberMe?: boolean; callbackURL?: string; }; const identifier = (body.identifier ?? '').trim(); const password = body.password ?? ''; if (!identifier || !password) { // Match better-auth's invalid-credentials shape so the client can // surface a uniform error without distinguishing the failure mode. return NextResponse.json( { error: { message: 'Invalid credentials', code: 'INVALID_EMAIL_OR_PASSWORD' } }, { status: 401 }, ); } const email = await resolveToEmail(identifier); // On a username miss we still call better-auth with a guaranteed-fail // email so the timing and response shape match the hit-with-wrong- // password path. The `.invalid` TLD is reserved by RFC 2606 so no real // user could ever match it. const effectiveEmail = email ?? `${identifier.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'}@auth.invalid`; // Forward to better-auth's existing sign-in endpoint. We construct a // fresh Request because Next.js's NextRequest is read-only. const url = new URL('/api/auth/sign-in/email', req.url); const forwardBody = JSON.stringify({ email: effectiveEmail, password, rememberMe: body.rememberMe, callbackURL: body.callbackURL, }); const forwardReq = new Request(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', // Preserve client metadata for audit / rate limiting downstream. 'x-forwarded-for': req.headers.get('x-forwarded-for') ?? ip, 'user-agent': req.headers.get('user-agent') ?? '', cookie: req.headers.get('cookie') ?? '', }, body: forwardBody, }); const { POST: signInHandler } = await import('@/app/api/auth/[...all]/route'); return signInHandler(forwardReq as NextRequest); }