import { NextResponse } from "next/server"; import { cookies, headers } from "next/headers"; import { createRateLimiter, getClientIp } from "@/lib/rateLimit"; import { checkCsrf } from "@/lib/apiUtils"; const MEDUSA_URL = process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "http://localhost:9000"; const API_KEY = process.env.MEDUSA_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? ""; // 3 registration attempts per IP per 15 minutes const limiter = createRateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 3 }); export async function POST(request: Request) { // CSRF check const csrfError = await checkCsrf(); if (csrfError) return csrfError; // Rate limit by IP const hdrs = await headers(); const ip = getClientIp(hdrs); const limit = limiter.check(`register:${ip}`); if (!limit.allowed) { return NextResponse.json( { error: "Too many registration attempts. Please try again later." }, { status: 429, headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) } }, ); } let body: Record; try { body = await request.json(); } catch { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } const { email, password, first_name, last_name } = body; // Server-side validation if ( typeof email !== "string" || typeof password !== "string" || typeof first_name !== "string" || typeof last_name !== "string" ) { return NextResponse.json( { error: "All fields are required" }, { status: 400 }, ); } const trimmedEmail = email.trim().toLowerCase(); const trimmedFirst = first_name.trim(); const trimmedLast = last_name.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) { return NextResponse.json({ error: "Invalid email address" }, { status: 400 }); } if (password.length < 8) { return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 }); } if (password.length > 128) { return NextResponse.json({ error: "Password too long" }, { status: 400 }); } if (!/[A-Z]/.test(password)) { return NextResponse.json({ error: "Password must contain at least one uppercase letter" }, { status: 400 }); } if (!/[a-z]/.test(password)) { return NextResponse.json({ error: "Password must contain at least one lowercase letter" }, { status: 400 }); } if (!/\d/.test(password)) { return NextResponse.json({ error: "Password must contain at least one number" }, { status: 400 }); } if (!/[^A-Za-z0-9]/.test(password)) { return NextResponse.json({ error: "Password must contain at least one special character" }, { status: 400 }); } if (!trimmedFirst || !trimmedLast) { return NextResponse.json({ error: "First and last name are required" }, { status: 400 }); } // Step 1: Register auth identity with Medusa const authRes = await fetch(`${MEDUSA_URL}/auth/customer/emailpass/register`, { method: "POST", headers: { "Content-Type": "application/json", "x-publishable-api-key": API_KEY, }, body: JSON.stringify({ email: trimmedEmail, password }), }); if (!authRes.ok) { // Use a generic message to prevent email enumeration return NextResponse.json( { error: "Registration failed. Please try a different email or sign in." }, { status: authRes.status === 409 ? 409 : authRes.status }, ); } const { token } = await authRes.json(); // Set httpOnly cookie const cookieStore = await cookies(); cookieStore.set("medusa_auth_token", token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 604800, }); // Step 2: Create the customer profile const customerRes = await fetch(`${MEDUSA_URL}/store/customers`, { method: "POST", headers: { "Content-Type": "application/json", "x-publishable-api-key": API_KEY, Authorization: `Bearer ${token}`, }, body: JSON.stringify({ email: trimmedEmail, first_name: trimmedFirst, last_name: trimmedLast, }), }); if (!customerRes.ok) { return NextResponse.json( { error: "Failed to create customer profile" }, { status: 500 }, ); } // Step 3: Log in to get a fully-scoped token. // The registration token was issued before the customer entity existed, // so it may lack the customer association needed for endpoints like // /store/customers/me/addresses. A fresh login token resolves this. const loginRes = await fetch(`${MEDUSA_URL}/auth/customer/emailpass`, { method: "POST", headers: { "Content-Type": "application/json", "x-publishable-api-key": API_KEY, }, body: JSON.stringify({ email: trimmedEmail, password }), }); if (loginRes.ok) { const { token: loginToken } = await loginRes.json(); cookieStore.set("medusa_auth_token", loginToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 604800, }); } // Fetch the customer profile with the best available token const finalToken = loginRes.ok ? (await cookieStore.get("medusa_auth_token"))?.value ?? token : token; const meRes = await fetch(`${MEDUSA_URL}/store/customers/me`, { headers: { "x-publishable-api-key": API_KEY, Authorization: `Bearer ${finalToken}`, }, }); if (!meRes.ok) { return NextResponse.json( { error: "Failed to fetch customer profile" }, { status: 500 }, ); } const { customer } = await meRes.json(); return NextResponse.json({ customer }); }