Files
storybid/packages/server/src/routes/auth.ts
T
2026-05-02 19:46:42 -05:00

259 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });
});