/** * Self-signup for allowed email domains */ import { escapeHtml } from "./invite.js"; import { generateTokenWithHash, hashToken } from "./tokens.js"; import type { AuthAdapter, RoleLevel, EmailMessage, User } 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; /** * 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)); } export interface SignupConfig { baseUrl: string; siteName: string; /** Optional email sender. When omitted, signup verification cannot be sent. */ email?: EmailSendFn; } /** * Check if an email domain is allowed for self-signup */ export async function canSignup( adapter: AuthAdapter, email: string, ): Promise<{ allowed: boolean; role: RoleLevel } | null> { const domain = email.split("@")[1]?.toLowerCase(); if (!domain) return null; const allowedDomain = await adapter.getAllowedDomain(domain); if (!allowedDomain || !allowedDomain.enabled) { return null; } return { allowed: true, role: allowedDomain.defaultRole, }; } /** * Request self-signup (sends verification email). * * Requires `config.email` to be set. Throws if no email sender is configured. */ export async function requestSignup( config: SignupConfig, adapter: AuthAdapter, email: string, ): Promise { if (!config.email) { throw new SignupError("email_not_configured", "Email is not configured"); } // Check if user already exists const existing = await adapter.getUserByEmail(email); if (existing) { // Don't reveal that user exists - add delay to match successful path timing await timingDelay(); return; } // Check if domain is allowed const signup = await canSignup(adapter, email); if (!signup) { // Don't reveal that domain is not allowed - add delay to match successful path timing await timingDelay(); return; } // Generate token const { token, hash } = generateTokenWithHash(); // Store token with role info await adapter.createToken({ hash, email, type: "email_verify", role: signup.role, expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS), }); // Build verification URL const url = new URL("/_emdash/api/auth/signup/verify", config.baseUrl); url.searchParams.set("token", token); // Send email const safeName = escapeHtml(config.siteName); await config.email({ to: email, subject: `Verify your email for ${config.siteName}`, text: `Click this link to verify your email and create your account:\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: `

Verify your email

Click the button below to verify your email and create your ${safeName} account:

Verify Email

This link expires in 15 minutes.

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

`, }); } /** * Validate a signup verification token */ export async function validateSignupToken( adapter: AuthAdapter, token: string, ): Promise<{ email: string; role: RoleLevel }> { const hash = hashToken(token); const authToken = await adapter.getToken(hash, "email_verify"); if (!authToken) { throw new SignupError("invalid_token", "Invalid or expired verification link"); } if (authToken.expiresAt < new Date()) { await adapter.deleteToken(hash); throw new SignupError("token_expired", "This link has expired"); } if (!authToken.email || authToken.role === null) { throw new SignupError("invalid_token", "Invalid token data"); } return { email: authToken.email, role: authToken.role, }; } /** * Complete signup process (after passkey registration) */ export async function completeSignup( adapter: AuthAdapter, token: string, userData: { name?: string; avatarUrl?: string; }, ): Promise { const hash = hashToken(token); // Validate token one more time const authToken = await adapter.getToken(hash, "email_verify"); if (!authToken || authToken.expiresAt < new Date()) { throw new SignupError("invalid_token", "Invalid or expired verification"); } if (!authToken.email || authToken.role === null) { throw new SignupError("invalid_token", "Invalid token data"); } // Check user doesn't already exist const existing = await adapter.getUserByEmail(authToken.email); if (existing) { await adapter.deleteToken(hash); throw new SignupError("user_exists", "An account with this email already exists"); } // Delete token (single-use) await adapter.deleteToken(hash); // Create user const user = await adapter.createUser({ email: authToken.email, name: userData.name, avatarUrl: userData.avatarUrl, role: authToken.role, emailVerified: true, }); return user; } export class SignupError extends Error { constructor( public code: | "invalid_token" | "token_expired" | "user_exists" | "domain_not_allowed" | "email_not_configured", message: string, ) { super(message); this.name = "SignupError"; } }