Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/magic-link.server.ts
T

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 };
}