/** * Magic link authentication */ import { escapeHtml } from "../invite.js"; import { generateTokenWithHash, hashToken } from "../tokens.js"; import type { AuthAdapter, User, EmailMessage } from "../types.js"; const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes /** Function that sends an email (matches the EmailPipeline.send signature) */ export type EmailSendFn = (message: EmailMessage) => Promise; export interface MagicLinkConfig { baseUrl: string; siteName: string; /** Optional email sender. When omitted, magic links cannot be sent. */ email?: EmailSendFn; } /** * Add artificial delay with jitter to prevent timing attacks. * Range approximates the time for token creation + email send. */ async function timingDelay(): Promise { const delay = 100 + Math.random() * 150; // 100-250ms await new Promise((resolve) => setTimeout(resolve, delay)); } /** * Send a magic link to a user's email. * * Requires `config.email` to be set. Throws if no email sender is configured. */ export async function sendMagicLink( config: MagicLinkConfig, adapter: AuthAdapter, email: string, type: "magic_link" | "recovery" = "magic_link", ): Promise { if (!config.email) { throw new MagicLinkError("email_not_configured", "Email is not configured"); } // Find user const user = await adapter.getUserByEmail(email); if (!user) { // Don't reveal whether user exists - add delay to match successful path timing await timingDelay(); return; } // Generate token const { token, hash } = generateTokenWithHash(); // Store token hash await adapter.createToken({ hash, userId: user.id, email: user.email, type, expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS), }); // Build magic link URL const url = new URL("/_emdash/api/auth/magic-link/verify", config.baseUrl); url.searchParams.set("token", token); // Send email const safeName = escapeHtml(config.siteName); await config.email({ to: user.email, subject: `Sign in to ${config.siteName}`, text: `Click this link to sign in to ${config.siteName}:\n\n${url.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`, html: `

Sign in to ${safeName}

Click the button below to sign in:

Sign in

This link expires in 15 minutes.

If you didn't request this, you can safely ignore this email.

`, }); } /** * Verify a magic link token and return the user */ export async function verifyMagicLink(adapter: AuthAdapter, token: string): Promise { const hash = hashToken(token); // Find and validate token const authToken = await adapter.getToken(hash, "magic_link"); if (!authToken) { // Also check for recovery tokens const recoveryToken = await adapter.getToken(hash, "recovery"); if (!recoveryToken) { throw new MagicLinkError("invalid_token", "Invalid or expired link"); } return verifyTokenAndGetUser(adapter, recoveryToken, hash); } return verifyTokenAndGetUser(adapter, authToken, hash); } async function verifyTokenAndGetUser( adapter: AuthAdapter, authToken: { userId: string | null; expiresAt: Date }, hash: string, ): Promise { // Check expiry if (authToken.expiresAt < new Date()) { await adapter.deleteToken(hash); throw new MagicLinkError("token_expired", "This link has expired"); } // Delete token (single-use) await adapter.deleteToken(hash); // Get user if (!authToken.userId) { throw new MagicLinkError("invalid_token", "Invalid token"); } const user = await adapter.getUserById(authToken.userId); if (!user) { throw new MagicLinkError("user_not_found", "User not found"); } return user; } export class MagicLinkError extends Error { constructor( public code: "invalid_token" | "token_expired" | "user_not_found" | "email_not_configured", message: string, ) { super(message); this.name = "MagicLinkError"; } }