feat(auth): email login soft-disabled until SMTP/Resend is wired
All checks were successful
Deploy to Production / deploy (push) Successful in 54s

Closes the dependency on an unbuilt email sender. New EMAIL_AUTH_ENABLED
env flag (default false). When off:

- POST /v1/auth/magic-link  → 503 email_auth_disabled
- POST /v1/auth/verify       → 503 email_auth_disabled
- GET  /v1/auth/providers    → { email: false, sms, google, github }
- Login page: hides the email/phone tab toggle (only one method),
  hides the email form entirely, defaults to SMS/phone tab

Flipping EMAIL_AUTH_ENABLED=true re-enables the magic-link routes and
re-shows the email form section. Schema (magic_links table) unchanged
so this is a 1-env-flip re-enable, not a re-implementation.

SECURITY: closes audit finding Za-001 (account-takeover via
cross-provider email lookup). Without a magic-link flow, an attacker
who controls a target's inbox can no longer claim an existing
OAuth-created account. The remaining provider-mixing surface (Google
↔ GitHub at same email) requires controlling the OAuth provider
account itself, which is each provider's own security boundary.

Active login methods now: Google OAuth · GitHub OAuth · SMS code
(Twilio) · admin password (seeded, single user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-25 18:51:57 +02:00
parent aa79a71357
commit b248adf5c0
3 changed files with 51 additions and 10 deletions

View File

@@ -24,6 +24,13 @@ const Env = z.object({
TWILIO_ACCOUNT_SID: z.string().optional(),
TWILIO_AUTH_TOKEN: z.string().optional(),
TWILIO_SMS_FROM: z.string().optional(),
// Email magic-link login is OFF by default — no SMTP/Resend wired yet.
// Set EMAIL_AUTH_ENABLED=true once an email sender is configured; the
// magic-link routes + login-page form section will switch back on.
EMAIL_AUTH_ENABLED: z
.union([z.literal('true'), z.literal('false'), z.literal('1'), z.literal('0')])
.transform((v) => v === 'true' || v === '1')
.default('false'),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
@@ -54,6 +61,7 @@ export const config = Env.parse({
TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM,
EMAIL_AUTH_ENABLED: process.env.EMAIL_AUTH_ENABLED,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,

View File

@@ -76,6 +76,16 @@ function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean {
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/magic-link', async (req, reply) => {
// Email auth is off by default — no SMTP wired yet. Closes the
// account-takeover-via-magic-link path (Za-001) until an email sender
// is configured AND a primaryProvider column lets us bind users to a
// single login method.
if (!config.EMAIL_AUTH_ENABLED) {
return reply.code(503).send({
error: 'email_auth_disabled',
detail: 'Email login is currently unavailable. Use Google, GitHub, or SMS.',
});
}
const Body = z.object({ email: z.string().email() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });
@@ -124,6 +134,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});
app.post('/v1/auth/verify', async (req, reply) => {
if (!config.EMAIL_AUTH_ENABLED) {
return reply.code(503).send({ error: 'email_auth_disabled' });
}
const Body = z.object({ token: z.string().min(10) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
@@ -229,13 +242,16 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ ok: true });
});
// Which third-party login providers are configured. Lets the UI hide the
// Google button when no credentials are set, instead of showing a dead button.
// Which login providers are configured. Lets the UI hide buttons + forms
// when their backing infra isn't wired. `email` defaults to false because
// we haven't bought an SMTP provider yet — flipping EMAIL_AUTH_ENABLED to
// true re-enables the magic-link form section.
app.get('/v1/auth/providers', async (_req, reply) => {
return reply.send({
google: googleConfigured(),
github: githubConfigured(),
sms: smsConfigured(),
email: config.EMAIL_AUTH_ENABLED,
});
});