Tier 0.2: src/lib/env.ts now refuses boot when NODE_ENV=production AND EMAIL_REDIRECT_TO is set. Sendmail logs the rewrite at warn (was debug) so dev/staging windows where someone forgets to unset are immediately visible. Tier 0.6: backup_jobs.storage_path added to TABLES_WITH_STORAGE_KEYS in src/lib/storage/migrate.ts. Flipping the storage backend used to silently orphan every pg_dump artefact — last-resort recovery path is now actually portable. Tier 1.7: createAuditLog now runs metadata through maskSensitiveFields (was only applied to old/new value diffs). Portal-auth, crm-invite, hard-delete and email-accounts services were writing raw emails into this column unbounded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.5 KiB
TypeScript
91 lines
3.5 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
}
|