From b248adf5c04730ab62497b523bb58e6b9a4c8a15 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Mon, 25 May 2026 18:51:57 +0200 Subject: [PATCH] feat(auth): email login soft-disabled until SMTP/Resend is wired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/config.ts | 8 ++++++++ apps/api/src/routes/auth.ts | 20 ++++++++++++++++++-- apps/web/app/login/page.tsx | 33 +++++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index d79c844..acfca89 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index b88e418..9e81e92 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -76,6 +76,16 @@ function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean { export async function authRoutes(app: FastifyInstance): Promise { 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 { }); 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 { 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, }); }); diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index ee6a13f..f3ae6db 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -102,8 +102,15 @@ function errCode(err: unknown): string { } export default function LoginPage() { - const [providers, setProviders] = useState({ google: false, github: false, sms: false }); - const [method, setMethod] = useState<'email' | 'phone'>('email'); + const [providers, setProviders] = useState({ + google: false, + github: false, + sms: false, + email: false, + }); + // Default to SMS — email is off by default until an SMTP/Resend provider + // is wired. The effect below flips to 'email' if the backend says it's on. + const [method, setMethod] = useState<'email' | 'phone'>('phone'); const [error, setError] = useState(null); // Email magic-link @@ -119,8 +126,15 @@ export default function LoginPage() { const [smsBusy, setSmsBusy] = useState(false); useEffect(() => { - apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers') - .then(setProviders) + apiFetch<{ google: boolean; github: boolean; sms: boolean; email: boolean }>( + '/v1/auth/providers', + ) + .then((p) => { + setProviders(p); + // Pick the most-likely method up-front: email if enabled, else SMS. + if (p.email) setMethod('email'); + else if (p.sms) setMethod('phone'); + }) .catch(() => undefined); const err = new URLSearchParams(window.location.search).get('error'); if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.'); @@ -218,7 +232,10 @@ export default function LoginPage() { )} - {providers.sms && ( + {/* Tab toggle only shown when BOTH email and SMS are enabled — if just + one is configured, that method's form renders directly without a + useless one-tab toggle. */} + {providers.sms && providers.email && (
@@ -242,8 +259,8 @@ export default function LoginPage() {
)} -
- {method === 'email' && emailState !== 'sent' && ( +
+ {method === 'email' && providers.email && emailState !== 'sent' && (
@@ -269,7 +286,7 @@ export default function LoginPage() { )} - {method === 'email' && emailState === 'sent' && ( + {method === 'email' && providers.email && emailState === 'sent' && (

Magic link sent to {email}.