diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index 8f0c452..cf3bf34 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -1,5 +1,6 @@ // server/api/auth/login.post.js import jwt from "jsonwebtoken"; +import { randomUUID } from "crypto"; import { Resend } from "resend"; import Member from "../../models/member.js"; import { connectDB } from "../../utils/mongoose.js"; @@ -11,6 +12,14 @@ const resend = new Resend(process.env.RESEND_API_KEY); export default defineEventHandler(async (event) => { await connectDB(); + const baseUrl = process.env.BASE_URL; + if (!baseUrl) { + throw createError({ + statusCode: 500, + statusMessage: "BASE_URL environment variable is not set", + }); + } + const { email } = await validateBody(event, emailSchema); const GENERIC_MESSAGE = "If this email is registered, we've sent a login link."; @@ -25,16 +34,23 @@ export default defineEventHandler(async (event) => { } const config = useRuntimeConfig(event); + const jti = randomUUID(); + const token = jwt.sign( - { memberId: member._id }, + { memberId: member._id, jti }, config.jwtSecret, { expiresIn: "15m" }, ); - const headers = getHeaders(event); - const baseUrl = - process.env.BASE_URL || - `${headers.host?.includes("localhost") ? "http" : "https"}://${headers.host}`; + // Store jti so we can burn it on first use + await Member.findByIdAndUpdate( + member._id, + { $set: { magicLinkJti: jti, magicLinkJtiUsed: false } }, + { runValidators: false } + ); + + // Token goes in the fragment — never sent to server, never logged + const magicLink = `${baseUrl}/verify#${token}`; try { await resend.emails.send({ @@ -44,7 +60,7 @@ export default defineEventHandler(async (event) => { text: `Hi, Sign in to Ghost Guild: -${baseUrl}/api/auth/verify?token=${token} +${magicLink} This link expires in 15 minutes. If you didn't request it, ignore this email.`, });