/** * Resolves an email-or-username sign-in identifier to a canonical email * that Better Auth's email/password flow accepts. * * Public endpoint by design — the login form calls it BEFORE the user is * authenticated, so it can't sit behind `withAuth`. * * **Anti-enumeration:** the response shape is identical for hit and * miss. On a miss we return a synthetic `@auth.invalid` email derived * from the input so Better Auth's `signIn.email` call fails uniformly * with "invalid credentials" — an attacker can't tell whether the * username exists from this endpoint's response. (Previously a miss * returned the bare input string, which lacked an `@` and was visibly * different from a hit's real email.) * * **Rate limiting:** shares the `auth` bucket (5/15min/ip), so an * attacker can't iterate a wordlist faster than they could brute-force * passwords directly. */ import { NextResponse, type NextRequest } from 'next/server'; import { sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { user, userProfiles } from '@/lib/db/schema/users'; import { eq } from 'drizzle-orm'; import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit'; const EMAIL_HINT = /@/; /** Synthetic, definitively-invalid email used for the miss path. The * `.invalid` TLD is reserved by RFC 2606 — no real domain can use it, * so a downstream signIn call always fails as "invalid credentials" * without ever leaking the lookup outcome. */ function syntheticEmail(raw: string): string { const slug = raw.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'; return `${slug}@auth.invalid`; } function clientIp(req: NextRequest): string { return ( req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown' ); } export async function POST(req: NextRequest) { try { // Rate-limit on IP — same 5/15min bucket the actual sign-in uses. // Without this an attacker can wordlist usernames at full HTTP // bandwidth and only funnel the validated emails into the slower // signIn flow. const ip = clientIp(req); const rl = await checkRateLimit(ip, rateLimiters.auth); if (!rl.allowed) { return NextResponse.json({ email: '' }, { status: 429, headers: rateLimitHeaders(rl) }); } const body = (await req.json().catch(() => ({}))) as { identifier?: string }; const raw = (body.identifier ?? '').trim(); if (!raw) return NextResponse.json({ email: syntheticEmail('empty') }); // Looks like an email → already canonical. Hand it straight back. if (EMAIL_HINT.test(raw)) { return NextResponse.json({ email: raw }); } // Otherwise treat the input as a username and look up the linked // Better Auth email. Case-insensitive match against the // `LOWER(username)` unique index. 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); if (rows.length === 0) { // Synthetic `.invalid` email — indistinguishable from a hit in // shape (has `@`, has a tld), guaranteed to fail downstream auth. return NextResponse.json({ email: syntheticEmail(normalized) }); } return NextResponse.json({ email: rows[0]!.email }); } catch { // Defensive — never expose internals from a public endpoint. return NextResponse.json({ email: syntheticEmail('error') }, { status: 200 }); } }