Scaffold and Phase 1
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user