// The rules around the emailed sign-in link. Pure, with no imports, so the // tests in signin-link.test.ts run under plain `node --test`. export const SIGN_IN_LINK_MINUTES = 20; export const SIGN_IN_LINK_MAX_AGE_SECONDS = SIGN_IN_LINK_MINUTES * 60; export const SIGN_IN_RESEND_SECONDS = 60; export const SIGN_IN_CALLBACK_PATH = "/api/auth/callback/email"; export const SIGN_IN_CONFIRM_PATH = "/signin/confirm"; export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } /** * Whether a link went to this address too recently to send another. * `latestExpires` is the newest unexpired token's expiry for the address; a * token expires SIGN_IN_LINK_MAX_AGE_SECONDS after it is created, so one * created in the last SIGN_IN_RESEND_SECONDS expires later than this cutoff. */ export function sentTooRecently( latestExpires: Date | null, now: Date, ): boolean { if (!latestExpires) return false; const cutoff = now.getTime() + (SIGN_IN_LINK_MAX_AGE_SECONDS - SIGN_IN_RESEND_SECONDS) * 1000; return latestExpires.getTime() > cutoff; } /** * The link to put in the email. Auth.js's callback URL signs in on a plain * GET, and mail scanners prefetch links, which would spend the single-use * token before the person clicks. So the email points at a confirm page on * the same origin, and only its button goes on to the callback. */ export function confirmLinkFor( callbackUrl: string, pinnedOrigin: string | null = null, ): string { const callback = new URL(callbackUrl); // Auth.js builds the callback from the request's Host header. In production // the link must not trust that: a forged Host would mail a real token to a // page on someone else's domain. const origin = pinnedOrigin ?? callback.origin; const pinned = new URL( `${callback.pathname}${callback.search}`, origin, ).toString(); return `${origin}${SIGN_IN_CONFIRM_PATH}?${new URLSearchParams({ url: pinned })}`; } export type ConfirmTarget = { email: string; params: [name: string, value: string][]; }; /** * The callback the confirm page may send the visitor to, or null. Only * Auth.js's email callback on `origin` qualifies, so the page can't be used to * send anyone elsewhere. */ export function parseConfirmTarget( raw: string | undefined, origin: string, ): ConfirmTarget | null { if (!raw) return null; let url: URL; try { url = new URL(raw); } catch { return null; } if (url.origin !== origin || url.pathname !== SIGN_IN_CALLBACK_PATH) return null; const email = url.searchParams.get("email"); if (!email || !url.searchParams.get("token")) return null; return { email, params: [...url.searchParams] }; } /** The origin Auth.js builds its URLs from (trustHost: the forwarded headers). */ export function requestOrigin(headers: Pick): string { const host = headers.get("x-forwarded-host") ?? headers.get("host"); const proto = headers.get("x-forwarded-proto") ?? "https"; return `${proto}://${host}`; }