fix(auth): don't 500 / orphan accounts when verification email fails

Registration now rolls back the just-created user (token cascades) and returns a
clear 502 EMAIL_SEND_FAILED if the verification email can't be sent, instead of a
500 leaving an unverifiable orphan account. resend-verification and
forgot-password swallow mail failures (log + still return generic 200) so a broken
mailer can't break the flow or leak account existence. Adds regression tests.
This commit is contained in:
2026-05-21 10:52:28 +02:00
parent 5e7b60dfce
commit eaed138e38
2 changed files with 65 additions and 3 deletions

View File

@@ -32,6 +32,23 @@ function toPublicUser(r: typeof users.$inferSelect): PublicUser {
return { id: r.id, email: r.email, displayName: r.displayName, role: r.role }; return { id: r.id, email: r.email, displayName: r.displayName, role: r.role };
} }
// Send an email but never let a delivery failure break the request flow.
// Used by resend-verification and forgot-password, where the response must stay
// generic regardless of whether the mail actually went out.
async function trySendEmail(
to: string,
msg: { subject: string; html: string; text: string },
context: string,
): Promise<boolean> {
try {
await getMailer().send(to, msg);
return true;
} catch (err) {
console.error(`[mail] failed to send ${context} to ${to}:`, err);
return false;
}
}
export function authRouter(db: Db): Router { export function authRouter(db: Db): Router {
const r = Router(); const r = Router();
@@ -61,7 +78,16 @@ export function authRouter(db: Db): Router {
const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL); const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL);
const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName); const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName);
await getMailer().send(user!.email, tpl); try {
await getMailer().send(user!.email, tpl);
} catch (mailErr) {
// Roll back the half-created account (token cascades) so the user can
// retry cleanly once the mailer is fixed, instead of being left with an
// unverifiable orphan account and a 500.
db.delete(users).where(eq(users.id, user!.id)).run();
console.error(`[mail] verification email failed during register; rolled back user ${user!.id}:`, mailErr);
throw new ApiError(502, 'EMAIL_SEND_FAILED', 'Account could not be created because the verification email failed to send. Please try again later.');
}
res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role }); res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role });
} catch (e) { next(e); } } catch (e) { next(e); }
@@ -103,7 +129,7 @@ export function authRouter(db: Db): Router {
await invalidateTokensForUser(db, u.id, 'verify_email'); await invalidateTokensForUser(db, u.id, 'verify_email');
const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL); const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL);
const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName); const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName);
await getMailer().send(u.email, tpl); await trySendEmail(u.email, tpl, 'verify_email (resend)');
} }
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
} catch (e) { next(e); } } catch (e) { next(e); }
@@ -147,7 +173,7 @@ export function authRouter(db: Db): Router {
await invalidateTokensForUser(db, u.id, 'password_reset'); await invalidateTokensForUser(db, u.id, 'password_reset');
const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL); const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL);
const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName); const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName);
await getMailer().send(u.email, tpl); await trySendEmail(u.email, tpl, 'password_reset');
} }
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
} catch (e) { next(e); } } catch (e) { next(e); }

View File

@@ -11,6 +11,12 @@ class CaptureMailer implements Mailer {
} }
} }
class ThrowingMailer implements Mailer {
async send(): Promise<void> {
throw new Error('SES rejected: sender not verified');
}
}
let env: ReturnType<typeof makeTestDb>; let env: ReturnType<typeof makeTestDb>;
let mailer: CaptureMailer; let mailer: CaptureMailer;
let app: ReturnType<typeof createApp>; let app: ReturnType<typeof createApp>;
@@ -92,4 +98,34 @@ describe('auth integration', () => {
expect(dup.status).toBe(409); expect(dup.status).toBe(409);
expect(dup.body.error.code).toBe('EMAIL_TAKEN'); expect(dup.body.error.code).toBe('EMAIL_TAKEN');
}); });
it('rolls back registration when the verification email fails to send', async () => {
setMailerForTests(new ThrowingMailer());
const failed = await request(app).post('/api/auth/register').send({
email: 'bob@example.com', displayName: 'Bob', password: 'secretpass',
});
expect(failed.status).toBe(502);
expect(failed.body.error.code).toBe('EMAIL_SEND_FAILED');
// The account must NOT persist — registering again with a working mailer succeeds.
setMailerForTests(mailer);
const retry = await request(app).post('/api/auth/register').send({
email: 'bob@example.com', displayName: 'Bob', password: 'secretpass',
});
expect(retry.status).toBe(201);
expect(mailer.sent).toHaveLength(1);
});
it('forgot-password still returns 200 when the mailer fails (no enumeration leak)', async () => {
// Create a verified user first.
await request(app).post('/api/auth/register').send({
email: 'carol@example.com', displayName: 'Carol', password: 'secretpass',
});
const token = tokenFromMail(mailer.sent[0]!.text);
await request(app).post('/api/auth/verify-email').send({ token });
setMailerForTests(new ThrowingMailer());
const r = await request(app).post('/api/auth/forgot-password').send({ email: 'carol@example.com' });
expect(r.status).toBe(200);
});
}); });