import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { resolveTransactionalBrandTokens } from "@toolctrl/brand"; import { loadRequiredWebConfig } from "@toolctrl/config"; import { buildBrand, renderTransactionalEmail } from "@toolctrl/mail"; import { db } from "~/utils/db.server"; import { getAbsoluteUrl } from "~/utils/url.server"; import { queueTransactionalEmail } from "~/modules/emails/services/TransactionalEmailQueue.server"; import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server"; import { getMailTranslator, getMailTranslatorFromRequest } from "~/locale/mail-translator.server"; export const MAGIC_LINK_TOKEN_TTL_MINUTES = 15; const MAGIC_LINK_TOKEN_VERSION = "v1"; export type MagicLinkPurpose = "login" | "invitation" | "reset"; type MagicLinkPayload = { exp: string; iat: string; jti: string; sub: string; /** * Discriminator for what the token is allowed to do. Verifiers pass an * expected value to consumeMagicLinkToken so a "login" token can't be * replayed against an "invitation" endpoint. Older tokens that predate * this field (pre-v2 payloads) are treated as "login". */ p?: MagicLinkPurpose; v: typeof MAGIC_LINK_TOKEN_VERSION; }; type MagicLinkConsumeResult = | { ok: true; userId: string; tokenId: string; purpose: MagicLinkPurpose } | { ok: false; reason: "expired" | "invalid" | "not-found" | "replayed" | "purpose-mismatch" }; function getSigningSecret() { const { cryptoSecret, sessionSecret } = loadRequiredWebConfig(); const secret = cryptoSecret || sessionSecret; if (!secret) { throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for magic-link auth."); } return secret; } function base64UrlEncode(value: string) { return Buffer.from(value).toString("base64url"); } function base64UrlDecode(value: string) { return Buffer.from(value, "base64url").toString("utf8"); } function signPayload(encodedPayload: string, secret = getSigningSecret()) { return createHmac("sha256", secret).update(encodedPayload).digest("base64url"); } function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } function signaturesMatch(actual: string, expected: string) { const actualBuffer = Buffer.from(actual); const expectedBuffer = Buffer.from(expected); return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); } async function getMagicLinkMailTranslator(request: Request, userId: string) { const userDelegate = (db as { user?: { findUnique: typeof db.user.findUnique } }).user; if (!userDelegate) { return getMailTranslatorFromRequest(request); } const user = await userDelegate.findUnique({ where: { id: userId }, select: { locale: true }, }); return user?.locale ? getMailTranslator(user.locale) : getMailTranslatorFromRequest(request); } function toBrandConfiguration(appConfiguration: Awaited>) { return { productName: appConfiguration.app?.name, supportEmail: appConfiguration.email?.supportEmail, supportFromName: appConfiguration.email?.fromName, emailFromName: appConfiguration.email?.fromName, logoUrl: appConfiguration.branding?.logo, faviconUrl: appConfiguration.branding?.favicon, }; } export function createSignedMagicLinkToken({ userId, tokenId = randomBytes(16).toString("hex"), now = new Date(), ttlMinutes = MAGIC_LINK_TOKEN_TTL_MINUTES, purpose = "login", }: { userId: string; tokenId?: string; now?: Date; ttlMinutes?: number; purpose?: MagicLinkPurpose; }) { const expiresAt = new Date(now.getTime() + ttlMinutes * 60 * 1000); const payload: MagicLinkPayload = { exp: expiresAt.toISOString(), iat: now.toISOString(), jti: tokenId, sub: userId, p: purpose, v: MAGIC_LINK_TOKEN_VERSION, }; const encodedPayload = base64UrlEncode(JSON.stringify(payload)); const signature = signPayload(encodedPayload); const token = `${encodedPayload}.${signature}`; return { expiresAt, token, tokenHash: hashToken(token), tokenId, }; } export function verifySignedMagicLinkToken(token: string): MagicLinkPayload | null { const [encodedPayload, signature, extra] = token.split("."); if (!encodedPayload || !signature || extra !== undefined) { return null; } const expectedSignature = signPayload(encodedPayload); if (!signaturesMatch(signature, expectedSignature)) { return null; } try { const payload = JSON.parse(base64UrlDecode(encodedPayload)) as Partial; if (payload.v !== MAGIC_LINK_TOKEN_VERSION || !payload.jti || !payload.sub || !payload.exp || !payload.iat) { return null; } return payload as MagicLinkPayload; } catch { return null; } } export async function issueMagicLink({ request, userId, email, name, redirectPath = "/auth/magic-link", now = new Date(), purpose = "login", }: { request: Request; userId: string; email: string; name?: string | null; redirectPath?: string; now?: Date; purpose?: MagicLinkPurpose; }) { const token = createSignedMagicLinkToken({ userId, now, purpose }); await db.magicLinkToken.create({ data: { id: token.tokenId, userId, tokenHash: token.tokenHash, expiresAt: token.expiresAt, }, }); const actionUrl = getAbsoluteUrl(request, `${redirectPath}?token=${encodeURIComponent(token.token)}`); const emailData = { action_url: actionUrl, name, expires_in_minutes: String(MAGIC_LINK_TOKEN_TTL_MINUTES) }; const mailTranslator = await getMagicLinkMailTranslator(request, userId); const appConfiguration = await getAppConfiguration({ request }); const brand = buildBrand(resolveTransactionalBrandTokens(toBrandConfiguration(appConfiguration))); const { html } = renderTransactionalEmail("magic-link", { brand, recipientName: name ?? undefined, actionUrl, expiresInMinutes: MAGIC_LINK_TOKEN_TTL_MINUTES, }); const subject = mailTranslator.t("magicLink.subject", { productName: brand.productName }); await queueTransactionalEmail({ to: email, alias: "magic-link", subject, body: html, userId, data: emailData, priorityOverride: "critical", }); return { expiresAt: token.expiresAt, token: token.token, }; } export async function consumeMagicLinkToken(token: string, options: { now?: Date; expectedPurpose?: MagicLinkPurpose } = {}): Promise { const now = options.now ?? new Date(); const payload = verifySignedMagicLinkToken(token); if (!payload) { return { ok: false, reason: "invalid" }; } if (new Date(payload.exp).getTime() <= now.getTime()) { return { ok: false, reason: "expired" }; } const tokenHash = hashToken(token); const storedToken = await db.magicLinkToken.findUnique({ where: { id: payload.jti } }); if (!storedToken || storedToken.userId !== payload.sub || storedToken.tokenHash !== tokenHash) { return { ok: false, reason: "not-found" }; } if (storedToken.consumedAt) { return { ok: false, reason: "replayed" }; } if (storedToken.expiresAt.getTime() <= now.getTime()) { return { ok: false, reason: "expired" }; } // Pre-v2 tokens lack p; treat them as "login" so legacy outstanding tokens // still work during the rollout. New tokens always have an explicit p. const actualPurpose: MagicLinkPurpose = (payload.p as MagicLinkPurpose | undefined) ?? "login"; if (options.expectedPurpose && options.expectedPurpose !== actualPurpose) { return { ok: false, reason: "purpose-mismatch" }; } await db.magicLinkToken.update({ where: { id: payload.jti }, data: { consumedAt: now }, }); return { ok: true, userId: payload.sub, tokenId: payload.jti, purpose: actualPurpose }; }