237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
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<ReturnType<typeof getAppConfiguration>>) {
|
|
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<MagicLinkPayload>;
|
|
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<MagicLinkConsumeResult> {
|
|
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 };
|
|
}
|