Scaffold and Phase 1

This commit is contained in:
2026-05-02 19:46:42 -05:00
parent ab74e7cad4
commit d909cb7c30
92 changed files with 4967 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
/**
* 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<string> {
// 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<string> {
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 });
});