103 lines
3.8 KiB
TypeScript
103 lines
3.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string | null> {
|
||
|
|
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);
|
||
|
|
}
|