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:
@@ -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); }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user