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:
parent
aa79a71357
commit
b248adf5c0
@ -24,6 +24,13 @@ const Env = z.object({
|
|||||||
TWILIO_ACCOUNT_SID: z.string().optional(),
|
TWILIO_ACCOUNT_SID: z.string().optional(),
|
||||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||||
TWILIO_SMS_FROM: 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_SECRET_KEY: z.string().optional(),
|
||||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||||
STRIPE_WEBHOOK_SECRET: 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_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
|
||||||
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
|
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
|
||||||
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM,
|
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_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||||
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||||
|
|||||||
@ -76,6 +76,16 @@ function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean {
|
|||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post('/v1/auth/magic-link', async (req, reply) => {
|
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 Body = z.object({ email: z.string().email() });
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });
|
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) => {
|
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 Body = z.object({ token: z.string().min(10) });
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
|
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 });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Which third-party login providers are configured. Lets the UI hide the
|
// Which login providers are configured. Lets the UI hide buttons + forms
|
||||||
// Google button when no credentials are set, instead of showing a dead button.
|
// 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) => {
|
app.get('/v1/auth/providers', async (_req, reply) => {
|
||||||
return reply.send({
|
return reply.send({
|
||||||
google: googleConfigured(),
|
google: googleConfigured(),
|
||||||
github: githubConfigured(),
|
github: githubConfigured(),
|
||||||
sms: smsConfigured(),
|
sms: smsConfigured(),
|
||||||
|
email: config.EMAIL_AUTH_ENABLED,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -102,8 +102,15 @@ function errCode(err: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [providers, setProviders] = useState({ google: false, github: false, sms: false });
|
const [providers, setProviders] = useState({
|
||||||
const [method, setMethod] = useState<'email' | 'phone'>('email');
|
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<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Email magic-link
|
// Email magic-link
|
||||||
@ -119,8 +126,15 @@ export default function LoginPage() {
|
|||||||
const [smsBusy, setSmsBusy] = useState(false);
|
const [smsBusy, setSmsBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers')
|
apiFetch<{ google: boolean; github: boolean; sms: boolean; email: boolean }>(
|
||||||
.then(setProviders)
|
'/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);
|
.catch(() => undefined);
|
||||||
const err = new URLSearchParams(window.location.search).get('error');
|
const err = new URLSearchParams(window.location.search).get('error');
|
||||||
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
||||||
@ -218,7 +232,10 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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 && (
|
||||||
<div
|
<div
|
||||||
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
||||||
>
|
>
|
||||||
@ -242,8 +259,8 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={providers.sms ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
||||||
{method === 'email' && emailState !== 'sent' && (
|
{method === 'email' && providers.email && emailState !== 'sent' && (
|
||||||
<form onSubmit={sendMagicLink} className="space-y-3">
|
<form onSubmit={sendMagicLink} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
@ -269,7 +286,7 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method === 'email' && emailState === 'sent' && (
|
{method === 'email' && providers.email && emailState === 'sent' && (
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<p className="text-[13px]">
|
<p className="text-[13px]">
|
||||||
Magic link sent to <span className="mono">{email}</span>.
|
Magic link sent to <span className="mono">{email}</span>.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user