/** * POST /api/auth/magic-link – request email magic link * GET /api/auth/verify?token= – verify magic link, issue JWT * POST /api/auth/otp/send – request SMS OTP via Twilio Verify * POST /api/auth/otp/verify – verify SMS OTP, issue JWT * POST /api/auth/logout – clear session (client drops token) */ import { Router } from "express"; import { z } from "zod"; import { randomBytes } from "node:crypto"; import { prisma } from "../lib/prisma.js"; import { signToken } from "../lib/jwt.js"; import { sendMagicLink } from "../services/email.js"; import { sendOtp, verifyOtp } from "../services/twilio.js"; import { requireAuth } from "../middleware/auth.js"; export const authRouter = Router(); // ── Helpers ──────────────────────────────────────────────────────────────────── const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes /** Find or create a Bidder + BidderAuthMethod for the given identifier. */ async function upsertBidder( type: "email_magic_link" | "sms_otp", identifier: string, organizationId: string, ): Promise { // Find existing auth method const existing = await prisma.bidderAuthMethod.findUnique({ where: { type_identifier: { type, identifier } }, include: { bidder: true }, }); if (existing) return existing.bidderId; // Create new bidder + auth method const bidder = await prisma.bidder.create({ data: { organizationId, email: type === "email_magic_link" ? identifier : null, phone: type === "sms_otp" ? identifier : null, firstName: "Guest", lastName: "", authMethods: { create: { type, identifier }, }, }, }); return bidder.id; } /** Resolve the base public URL for building magic-link callbacks. */ function resolveBaseUrl(req: { protocol: string; hostname: string }): string { return ( process.env["PUBLIC_URL"] ?? `${req.protocol}://${req.hostname}` ); } /** Build a signed JWT for a bidder. */ async function issueBidderToken(bidderId: string, deviceId?: string): Promise { const bidder = await prisma.bidder.findUniqueOrThrow({ where: { id: bidderId }, }); return signToken({ sub: bidderId, role: "bidder", organizationId: bidder.organizationId, deviceId, }); } // ── Magic link – request ─────────────────────────────────────────────────────── const MagicLinkRequestSchema = z.object({ email: z.string().email(), deviceId: z.string().optional(), }); authRouter.post("/magic-link", async (req, res) => { const parse = MagicLinkRequestSchema.safeParse(req.body); if (!parse.success) { res.status(400).json({ error: "Invalid email address" }); return; } const { email, deviceId } = parse.data; // Resolve organization (single-org install) const org = await prisma.organization.findFirst(); if (!org) { res.status(500).json({ error: "Organization not configured" }); return; } const bidderId = await upsertBidder("email_magic_link", email, org.id); // Generate a short-lived token stored in Redis (or fall back to a signed value) const rawToken = randomBytes(32).toString("hex"); const expiresAt = Date.now() + MAGIC_LINK_TTL_MS; // Store token in DB on the DeviceSession-like approach: reuse AuditLog payload // Simple approach: store in a dedicated magic_token via AuditLog with entityType='magic_link' await prisma.auditLog.create({ data: { action: "magic_link_issued", entityType: "magic_link", entityId: rawToken, payload: { bidderId, email, expiresAt, deviceId: deviceId ?? null }, }, }); try { await sendMagicLink(email, rawToken, resolveBaseUrl(req)); } catch (err) { console.error("[auth] sendMagicLink failed", err); // Don't leak whether the email exists } // Always respond with success to prevent email enumeration res.json({ ok: true, message: "If that address is registered, a link is on its way." }); }); // ── Magic link – verify ──────────────────────────────────────────────────────── authRouter.get("/verify", async (req, res) => { const token = req.query["token"]; if (typeof token !== "string" || !token) { res.status(400).json({ error: "Missing token" }); return; } const log = await prisma.auditLog.findFirst({ where: { entityType: "magic_link", entityId: token }, }); if (!log || !log.payload) { res.status(401).json({ error: "Invalid or expired link" }); return; } const payload = log.payload as { bidderId: string; expiresAt: number; deviceId: string | null; }; if (Date.now() > payload.expiresAt) { res.status(401).json({ error: "Link has expired" }); return; } // Consume token (delete so it can't be reused) await prisma.auditLog.delete({ where: { id: log.id } }); // Mark auth method verified await prisma.bidderAuthMethod.updateMany({ where: { bidderId: payload.bidderId, type: "email_magic_link" }, data: { verifiedAt: new Date() }, }); const jwt = await issueBidderToken(payload.bidderId, payload.deviceId ?? undefined); res.json({ token: jwt }); }); // ── SMS OTP – send ───────────────────────────────────────────────────────────── const OtpSendSchema = z.object({ phone: z.string().regex(/^\+[1-9]\d{7,14}$/, "Phone must be E.164 format (e.g. +12025551234)"), }); authRouter.post("/otp/send", async (req, res) => { const parse = OtpSendSchema.safeParse(req.body); if (!parse.success) { res.status(400).json({ error: parse.error.issues[0]?.message ?? "Invalid phone" }); return; } const { phone } = parse.data; const org = await prisma.organization.findFirst(); if (!org) { res.status(500).json({ error: "Organization not configured" }); return; } await upsertBidder("sms_otp", phone, org.id); try { await sendOtp(phone); } catch (err) { console.error("[auth] sendOtp failed", err); // Return generic error – don't reveal Twilio config issues to clients res.status(503).json({ error: "Could not send verification code. Please try again." }); return; } res.json({ ok: true }); }); // ── SMS OTP – verify ─────────────────────────────────────────────────────────── const OtpVerifySchema = z.object({ phone: z.string(), code: z.string().min(4).max(10), deviceId: z.string().optional(), }); authRouter.post("/otp/verify", async (req, res) => { const parse = OtpVerifySchema.safeParse(req.body); if (!parse.success) { res.status(400).json({ error: "Invalid request" }); return; } const { phone, code, deviceId } = parse.data; let approved: boolean; try { approved = await verifyOtp(phone, code); } catch (err) { console.error("[auth] verifyOtp failed", err); res.status(503).json({ error: "Verification check failed. Please try again." }); return; } if (!approved) { res.status(401).json({ error: "Incorrect or expired code" }); return; } const authMethod = await prisma.bidderAuthMethod.findUnique({ where: { type_identifier: { type: "sms_otp", identifier: phone } }, }); if (!authMethod) { res.status(401).json({ error: "Phone not registered" }); return; } await prisma.bidderAuthMethod.update({ where: { id: authMethod.id }, data: { verifiedAt: new Date() }, }); const jwt = await issueBidderToken(authMethod.bidderId, deviceId); res.json({ token: jwt }); }); // ── Logout ───────────────────────────────────────────────────────────────────── authRouter.post("/logout", requireAuth, (_req, res) => { // JWT is stateless; the client drops the token. // For harder logout, add a token denylist in Redis here. res.json({ ok: true }); });