API error contract
This commit is contained in:
@@ -10,13 +10,20 @@ import {
|
||||
} from "../../../../../lib/server/hash";
|
||||
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import { logger } from "../../../../../lib/logger";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
} from "../../../../../lib/server/responses";
|
||||
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
||||
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||
const SCOPE = "auth.magicLink.request";
|
||||
|
||||
function normalizeEmail(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
@@ -32,7 +39,7 @@ function readNextPath(body: unknown): string | null {
|
||||
return safeInternalPath(n);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
@@ -41,7 +48,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
return errorJson("invalid_json", "Invalid JSON", 400);
|
||||
}
|
||||
|
||||
const email = normalizeEmail(
|
||||
@@ -50,10 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
: null,
|
||||
);
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Valid email required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return errorJson("validation_error", "Valid email required", 400);
|
||||
}
|
||||
|
||||
const ip =
|
||||
@@ -63,28 +67,19 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||
if (rlEmail.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
return rateLimited(rlEmail.retryAfterMs);
|
||||
}
|
||||
|
||||
const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||
if (rlIp.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
return rateLimited(rlIp.retryAfterMs);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfiguration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const token = newSessionToken();
|
||||
@@ -108,13 +103,10 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await sendMagicLinkEmail(email, verifyUrl);
|
||||
} catch (err) {
|
||||
logger.error("sendMagicLinkEmail failed:", err);
|
||||
logRouteError(SCOPE, requestId, err, { phase: "sendMagicLinkEmail", email });
|
||||
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not send email" },
|
||||
{ status: 502 },
|
||||
);
|
||||
return errorJson("mail_failed", "Could not send email", 502);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,52 +10,83 @@ import {
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
getOrCreateRequestId,
|
||||
logRouteError,
|
||||
} from "../../../../../lib/server/requestId";
|
||||
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
||||
|
||||
const SCOPE = "auth.magicLink.verify";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = getOrCreateRequestId(request);
|
||||
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
const res = dbUnavailable();
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token || token.length < 10) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=invalid_link", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL("/login?error=server", request.url));
|
||||
}
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token || token.length < 10) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=invalid_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" });
|
||||
return redirectWithRequestId(request, "/login?error=server", requestId);
|
||||
}
|
||||
|
||||
const row = await prisma.magicLinkToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
|
||||
if (!row || row.expiresAt < new Date()) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=expired_link", request.url),
|
||||
const row = await prisma.magicLinkToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!row || row.expiresAt < new Date()) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=expired_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.magicLinkToken.delete({ where: { id: row.id } });
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: row.email },
|
||||
create: { email: row.email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||
user.id,
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
|
||||
const dest = safeInternalPath(row.nextPath);
|
||||
return redirectWithRequestId(request, dest, requestId);
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err);
|
||||
return redirectWithRequestId(request, "/login?error=server", requestId);
|
||||
}
|
||||
|
||||
await prisma.magicLinkToken.delete({ where: { id: row.id } });
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: row.email },
|
||||
create: { email: row.email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||
user.id,
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
|
||||
const dest = safeInternalPath(row.nextPath);
|
||||
return NextResponse.redirect(new URL(dest, request.url));
|
||||
}
|
||||
|
||||
function redirectWithRequestId(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
requestId: string,
|
||||
): NextResponse {
|
||||
const res = NextResponse.redirect(new URL(path, request.url));
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user