Files
pn-new-crm/src/app/api/auth/resolve-identifier/route.ts
Matt 0baca41693 audit: Tier 0 quick wins — EMAIL_REDIRECT_TO prod guard + storage routing + metadata masking
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>
2026-05-12 17:02:10 +02:00

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 });
}
}