feat(auth): email login soft-disabled until SMTP/Resend is wired
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
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:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user