diff --git a/.env.example b/.env.example index b6c66b3..39b913a 100644 --- a/.env.example +++ b/.env.example @@ -11,10 +11,18 @@ BETTER_AUTH_URL=http://localhost:3001 NEXT_PUBLIC_APP_URL=http://localhost:3001 NEXT_PUBLIC_API_URL=http://localhost:4000 -# ---- GitHub OAuth (optional in dev) ---- +# ---- GitHub OAuth ("Continue with GitHub") ---- +# Create at https://github.com/settings/applications/new +# Authorized callback URL: /v1/auth/github/callback GITHUB_OAUTH_ID= GITHUB_OAUTH_SECRET= +# ---- Twilio SMS (phone one-time-code login) ---- +# Credentials + a verified sender number from the Twilio console. +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_SMS_FROM= + # ---- Google OAuth (optional — "Continue with Google") ---- # Create at https://console.cloud.google.com/apis/credentials # Authorized redirect URI must be: /v1/auth/google/callback diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 519dce0..b5e2a4f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -18,6 +18,11 @@ const Env = z.object({ ADMIN_NAME: z.string().optional(), GOOGLE_OAUTH_ID: z.string().optional(), GOOGLE_OAUTH_SECRET: z.string().optional(), + GITHUB_OAUTH_ID: z.string().optional(), + GITHUB_OAUTH_SECRET: z.string().optional(), + TWILIO_ACCOUNT_SID: z.string().optional(), + TWILIO_AUTH_TOKEN: z.string().optional(), + TWILIO_SMS_FROM: z.string().optional(), }); export const config = Env.parse({ @@ -35,6 +40,11 @@ export const config = Env.parse({ ADMIN_NAME: process.env.ADMIN_NAME, GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID, GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET, + GITHUB_OAUTH_ID: process.env.GITHUB_OAUTH_ID, + GITHUB_OAUTH_SECRET: process.env.GITHUB_OAUTH_SECRET, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN, + TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM, }); // INFRA-001: refuse to boot in production with the placeholder encryption key. diff --git a/apps/api/src/lib/sms.ts b/apps/api/src/lib/sms.ts new file mode 100644 index 0000000..e2ccf06 --- /dev/null +++ b/apps/api/src/lib/sms.ts @@ -0,0 +1,30 @@ +import { config } from '../config.js'; + +/** True when Twilio credentials + a sender number are all configured. */ +export function smsConfigured(): boolean { + return Boolean(config.TWILIO_ACCOUNT_SID && config.TWILIO_AUTH_TOKEN && config.TWILIO_SMS_FROM); +} + +/** Send an SMS via the Twilio REST API (no SDK — a single authenticated POST). */ +export async function sendSms(to: string, body: string): Promise { + const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SMS_FROM } = config; + if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !TWILIO_SMS_FROM) { + throw new Error('sms_not_configured'); + } + const auth = Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString('base64'); + const res = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`, + { + method: 'POST', + headers: { + authorization: `Basic ${auth}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ To: to, From: TWILIO_SMS_FROM, Body: body }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`twilio_${res.status}: ${detail.slice(0, 180)}`); + } +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index eaec96e..5b78ea7 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,6 +1,4 @@ -import type { FastifyInstance } from 'fastify'; import { spawn } from 'node:child_process'; -import { z } from 'zod'; import { adminSettings, auditLog, @@ -20,11 +18,13 @@ import { users, } from '@bmm/db'; import { SYSTEM_PROMPT } from '@bmm/llm'; -import { requireAdmin } from '../plugins/session.js'; -import { getRedis } from '../lib/redis.js'; -import { getBuildQueue } from '../lib/queue.js'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { audit } from '../lib/audit.js'; import { encryptionStatus, rotateKeys } from '../lib/crypto.js'; +import { getBuildQueue } from '../lib/queue.js'; +import { getRedis } from '../lib/redis.js'; +import { requireAdmin } from '../plugins/session.js'; const db = createDb(); @@ -47,11 +47,26 @@ export async function adminRoutes(app: FastifyInstance): Promise { newUsersLast7d, newServersLast7d, ] = await Promise.all([ - db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)), - db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)), - db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)), - db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)), - db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)), + db + .select({ c: count() }) + .from(users) + .then((r) => Number(r[0]?.c ?? 0)), + db + .select({ c: count() }) + .from(organizations) + .then((r) => Number(r[0]?.c ?? 0)), + db + .select({ c: count() }) + .from(mcpServers) + .then((r) => Number(r[0]?.c ?? 0)), + db + .select({ c: count() }) + .from(builds) + .then((r) => Number(r[0]?.c ?? 0)), + db + .select({ c: count() }) + .from(toolCallMetrics) + .then((r) => Number(r[0]?.c ?? 0)), db .select({ c: count() }) .from(mcpServers) @@ -81,11 +96,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { .groupBy(mcpServers.status); // Recent activity from audit log - const recent = await db - .select() - .from(auditLog) - .orderBy(desc(auditLog.createdAt)) - .limit(15); + const recent = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(15); // Builds in last 24h with status const recentBuilds = await db @@ -123,12 +134,20 @@ export async function adminRoutes(app: FastifyInstance): Promise { const parsed = Query.safeParse(req.query); if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' }); - const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit); + const rows = await db + .select() + .from(users) + .orderBy(desc(users.createdAt)) + .limit(parsed.data.limit); const filtered = parsed.data.search - ? rows.filter((u) => - u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) || - (u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false), - ) + ? rows.filter((u) => { + const q = parsed.data.search!.toLowerCase(); + return ( + (u.email?.toLowerCase().includes(q) ?? false) || + (u.phone?.toLowerCase().includes(q) ?? false) || + (u.name?.toLowerCase().includes(q) ?? false) + ); + }) : rows; // attach org + server count @@ -280,7 +299,12 @@ export async function adminRoutes(app: FastifyInstance): Promise { const rows = await db .select({ server: mcpServers, - org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan }, + org: { + id: organizations.id, + name: organizations.name, + slug: organizations.slug, + plan: organizations.plan, + }, }) .from(mcpServers) .innerJoin(organizations, eq(organizations.id, mcpServers.orgId)) @@ -299,7 +323,11 @@ export async function adminRoutes(app: FastifyInstance): Promise { const p = Params.safeParse(req.params); if (!p.success) return reply.code(400).send({ error: 'invalid_id' }); - const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1); + const [server] = await db + .select() + .from(mcpServers) + .where(eq(mcpServers.id, p.data.id)) + .limit(1); if (!server) return reply.code(404).send({ error: 'not_found' }); // Get last build's prompt @@ -357,7 +385,11 @@ export async function adminRoutes(app: FastifyInstance): Promise { const p = Params.safeParse(req.params); if (!p.success) return reply.code(400).send({ error: 'invalid_id' }); - const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1); + const [server] = await db + .select() + .from(mcpServers) + .where(eq(mcpServers.id, p.data.id)) + .limit(1); if (!server) return reply.code(404).send({ error: 'not_found' }); await db.delete(mcpServers).where(eq(mcpServers.id, server.id)); @@ -468,10 +500,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { redisOk = pong === 'PONG'; const q = getBuildQueue(); const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'); - queueDepth = - (counts.waiting ?? 0) + - (counts.active ?? 0) + - (counts.delayed ?? 0); + queueDepth = (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.delayed ?? 0); } catch { // remains false } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 4e268af..d3f2a8e 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,9 +1,11 @@ import crypto from 'node:crypto'; import { consumeMagicLink, + consumeSmsCode, destroySession, getSession, issueMagicLink, + issueSmsCode, loginWithPassword, upsertOAuthLogin, } from '@bmm/auth'; @@ -11,6 +13,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { config } from '../config.js'; import { audit } from '../lib/audit.js'; +import { sendSms, smsConfigured } from '../lib/sms.js'; const SESSION_COOKIE = 'bmm_session'; const OAUTH_STATE_COOKIE = 'bmm_oauth_state'; @@ -46,6 +49,29 @@ function googleConfigured(): boolean { return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET); } +function githubConfigured(): boolean { + return Boolean(config.GITHUB_OAUTH_ID && config.GITHUB_OAUTH_SECRET); +} + +function githubRedirectUri(): string { + return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/github/callback`; +} + +// In-memory per-IP throttle for SMS-code requests — SMS costs money per send, +// so cap how often one IP can trigger a send regardless of which number. +const smsIpHits = new Map(); +function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean { + const now = Date.now(); + const hits = (smsIpHits.get(ip) ?? []).filter((t) => now - t < windowMs); + if (hits.length >= max) { + smsIpHits.set(ip, hits); + return false; + } + hits.push(now); + smsIpHits.set(ip, hits); + return true; +} + export async function authRoutes(app: FastifyInstance): Promise { app.post('/v1/auth/magic-link', async (req, reply) => { const Body = z.object({ email: z.string().email() }); @@ -170,7 +196,11 @@ export async function authRoutes(app: FastifyInstance): Promise { // 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. app.get('/v1/auth/providers', async (_req, reply) => { - return reply.send({ google: googleConfigured() }); + return reply.send({ + google: googleConfigured(), + github: githubConfigured(), + sms: smsConfigured(), + }); }); // Step 1: hand the browser off to Google's consent screen. @@ -276,4 +306,177 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.redirect(`${loginUrl}?error=google_failed`); } }); + + // ---- GitHub OAuth ---- + app.get('/v1/auth/github', async (_req, reply) => { + if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) { + return reply.code(503).send({ error: 'github_oauth_not_configured' }); + } + const state = crypto.randomBytes(16).toString('base64url'); + reply.setCookie(OAUTH_STATE_COOKIE, state, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: config.NODE_ENV === 'production', + maxAge: 600, + }); + const url = new URL('https://github.com/login/oauth/authorize'); + url.searchParams.set('client_id', config.GITHUB_OAUTH_ID); + url.searchParams.set('redirect_uri', githubRedirectUri()); + url.searchParams.set('scope', 'read:user user:email'); + url.searchParams.set('state', state); + return reply.redirect(url.toString()); + }); + + app.get('/v1/auth/github/callback', async (req, reply) => { + const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`; + const Query = z.object({ + code: z.string().min(8).optional(), + state: z.string().min(8).optional(), + error: z.string().optional(), + }); + const q = Query.safeParse(req.query); + const cookieState = req.cookies[OAUTH_STATE_COOKIE]; + reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' }); + + if (!q.success || q.data.error || !q.data.code || !q.data.state) { + return reply.redirect(`${loginUrl}?error=github_failed`); + } + if ( + !cookieState || + cookieState.length !== q.data.state.length || + !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state)) + ) { + return reply.redirect(`${loginUrl}?error=github_state`); + } + if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) { + return reply.redirect(`${loginUrl}?error=github_failed`); + } + + try { + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: config.GITHUB_OAUTH_ID, + client_secret: config.GITHUB_OAUTH_SECRET, + code: q.data.code, + redirect_uri: githubRedirectUri(), + }), + }); + if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`); + const tokens = (await tokenRes.json()) as { access_token?: string }; + if (!tokens.access_token) throw new Error('no_access_token'); + + // GitHub's API rejects requests without a User-Agent header. + const ghHeaders = { + authorization: `Bearer ${tokens.access_token}`, + accept: 'application/vnd.github+json', + 'user-agent': 'BuildMyMCPServer', + }; + const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders }); + if (!userRes.ok) throw new Error(`user_fetch_${userRes.status}`); + const ghUser = (await userRes.json()) as { name?: string; login?: string }; + + // /user omits the email when it is private — /user/emails always lists it. + const emailRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders }); + if (!emailRes.ok) throw new Error(`email_fetch_${emailRes.status}`); + const emails = (await emailRes.json()) as Array<{ + email: string; + primary: boolean; + verified: boolean; + }>; + const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified); + if (!primary) throw new Error('no_verified_email'); + + const session = await upsertOAuthLogin( + { email: primary.email, name: ghUser.name ?? ghUser.login ?? null }, + { ipAddress: req.ip, userAgent: req.headers['user-agent'] }, + ); + reply.setCookie(SESSION_COOKIE, session.sessionToken, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: config.NODE_ENV === 'production', + maxAge: 30 * 24 * 60 * 60, + }); + await audit({ + orgId: session.orgId, + userId: session.userId, + action: 'auth.login', + resourceType: 'session', + metadata: { email: session.email, provider: 'github' }, + ipAddress: req.ip, + }); + return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`); + } catch (err) { + app.log.warn({ err }, 'github oauth callback failed'); + return reply.redirect(`${loginUrl}?error=github_failed`); + } + }); + + // ---- SMS one-time-code login ---- + app.post('/v1/auth/sms/request', async (req, reply) => { + if (!smsConfigured()) return reply.code(503).send({ error: 'sms_not_configured' }); + const Body = z.object({ phone: z.string().min(8).max(24) }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_phone' }); + if (!smsIpRateOk(req.ip)) return reply.code(429).send({ error: 'rate_limited' }); + try { + const { phone, code } = await issueSmsCode(parsed.data.phone); + await sendSms(phone, `${code} is your BuildMyMCPServer login code. Valid for 10 minutes.`); + return reply.send({ ok: true }); + } catch (e) { + const msg = (e as Error).message; + if (msg === 'invalid_phone') return reply.code(400).send({ error: 'invalid_phone' }); + if (msg === 'rate_limited') return reply.code(429).send({ error: 'rate_limited' }); + app.log.warn({ err: e }, 'sms request failed'); + return reply.code(400).send({ error: 'sms_request_failed' }); + } + }); + + app.post('/v1/auth/sms/verify', async (req, reply) => { + const Body = z.object({ + phone: z.string().min(8).max(24), + code: z.string().regex(/^\d{6}$/), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + try { + const session = await consumeSmsCode(parsed.data.phone, parsed.data.code, { + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); + reply.setCookie(SESSION_COOKIE, session.sessionToken, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: config.NODE_ENV === 'production', + maxAge: 30 * 24 * 60 * 60, + }); + await audit({ + orgId: session.orgId, + userId: session.userId, + action: 'auth.login', + resourceType: 'session', + metadata: { provider: 'sms' }, + ipAddress: req.ip, + }); + return reply.send({ ok: true, user: { id: session.userId, orgId: session.orgId } }); + } catch (e) { + const msg = (e as Error).message; + const status: Record = { + invalid_or_expired_code: 400, + invalid_code: 400, + too_many_attempts: 429, + invalid_phone: 400, + }; + if (status[msg]) return reply.code(status[msg]).send({ error: msg }); + app.log.warn({ err: e }, 'sms verify failed'); + return reply.code(400).send({ error: 'sms_verify_failed' }); + } + }); } diff --git a/apps/api/src/routes/templates.ts b/apps/api/src/routes/templates.ts index cf1fd37..3e72cf3 100644 --- a/apps/api/src/routes/templates.ts +++ b/apps/api/src/routes/templates.ts @@ -1,6 +1,5 @@ import crypto from 'node:crypto'; -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; +import { getSession } from '@bmm/auth'; import { and, builds, @@ -16,21 +15,22 @@ import { users, } from '@bmm/db'; import { GeneratorSpec } from '@bmm/types'; -import { getSession } from '@bmm/auth'; -import { requireAuth, requireAdmin } from '../plugins/session.js'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { audit } from '../lib/audit.js'; -import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js'; -import { getRedis } from '../lib/redis.js'; import { stopContainer } from '../lib/docker.js'; +import { cachePrebuiltCode, cacheSpec } from '../lib/preview-cache.js'; +import { getRedis } from '../lib/redis.js'; +import { requireAdmin, requireAuth } from '../plugins/session.js'; const db = createDb(); const BANNED_PATTERNS = [ /\beval\s*\(/, /\bnew\s+Function\s*\(/, - /\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed - /\bimport\s*\(/, // dynamic import (escape from bundle scope) - /\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form + /\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed + /\bimport\s*\(/, // dynamic import (escape from bundle scope) + /\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form /\bsetInterval\s*\(\s*['"`]/, /\bchild_process\b/, /\bfs\s*\.\s*(unlink|rmdir|rm)\b/, @@ -163,7 +163,11 @@ export async function templateRoutes(app: FastifyInstance): Promise { let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`; let attempt = 0; while (true) { - const existing = await db.select({ id: templates.id }).from(templates).where(eq(templates.slug, slug)).limit(1); + const existing = await db + .select({ id: templates.id }) + .from(templates) + .where(eq(templates.slug, slug)) + .limit(1); if (existing.length === 0) break; attempt++; slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`; @@ -364,7 +368,7 @@ export async function templateRoutes(app: FastifyInstance): Promise { .where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live'))); return { ...t, - ownerName: user.email.split('@')[0], + ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you', ownerOrgName: null, activeDeployments: Number(active?.c ?? 0), }; @@ -468,7 +472,9 @@ export async function templateRoutes(app: FastifyInstance): Promise { const validation = GeneratorSpec.safeParse(fullSpec); if (!validation.success) { - return reply.code(500).send({ error: 'template_spec_invalid', detail: validation.error.flatten() }); + return reply + .code(500) + .send({ error: 'template_spec_invalid', detail: validation.error.flatten() }); } const previewId = await cacheSpec(validation.data); // Persist the pre-rendered code under the same previewId so the worker uses it @@ -550,7 +556,11 @@ export async function templateRoutes(app: FastifyInstance): Promise { if (fork.containerId) { const result = await stopContainer(fork.containerId); if (result.ok) stoppedContainers++; - else app.log.warn({ containerId: fork.containerId, detail: result.detail }, 'takedown: stop failed'); + else + app.log.warn( + { containerId: fork.containerId, detail: result.detail }, + 'takedown: stop failed', + ); } } await db diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index bd7a645..b984968 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -10,70 +10,159 @@ import { useEffect, useState } from 'react'; const ERROR_COPY: Record = { google_failed: 'Google sign-in could not be completed. Please try again.', google_state: 'Google sign-in expired or was interrupted. Please try again.', + github_failed: 'GitHub sign-in could not be completed. Please try again.', + github_state: 'GitHub sign-in expired or was interrupted. Please try again.', + invalid_phone: 'Enter your number in international format, e.g. +41 79 123 45 67.', + rate_limited: 'Too many requests. Wait a few minutes and try again.', + sms_request_failed: 'Could not send the SMS. Check the number and try again.', + invalid_or_expired_code: 'That code has expired. Request a new one.', + invalid_code: 'Wrong code. Check the SMS and try again.', + too_many_attempts: 'Too many wrong attempts. Request a new code.', + sms_verify_failed: 'Could not verify the code. Try again.', }; +function errCode(err: unknown): string { + const detail = (err as { detail?: { error?: string } }).detail; + return detail?.error ?? (err as Error).message ?? 'unknown'; +} + export default function LoginPage() { - const [email, setEmail] = useState(''); - const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [providers, setProviders] = useState({ google: false, github: false, sms: false }); + const [method, setMethod] = useState<'email' | 'phone'>('email'); const [error, setError] = useState(null); - const [googleEnabled, setGoogleEnabled] = useState(false); + + // Email magic-link + const [email, setEmail] = useState(''); + const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle'); + + // SMS one-time code + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone'); + const [smsBusy, setSmsBusy] = useState(false); useEffect(() => { - apiFetch<{ google: boolean }>('/v1/auth/providers') - .then((r) => setGoogleEnabled(r.google)) - .catch(() => setGoogleEnabled(false)); - - const params = new URLSearchParams(window.location.search); - const err = params.get('error'); - if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]); + apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers') + .then(setProviders) + .catch(() => undefined); + const err = new URLSearchParams(window.location.search).get('error'); + if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.'); }, []); - async function submit(e: React.FormEvent) { + async function sendMagicLink(e: React.FormEvent) { e.preventDefault(); - setState('sending'); + setEmailState('sending'); setError(null); try { - await apiFetch('/v1/auth/magic-link', { - method: 'POST', - body: JSON.stringify({ email }), - }); - setState('sent'); + await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) }); + setEmailState('sent'); } catch (err) { - setState('error'); - setError((err as Error).message); + setEmailState('idle'); + setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.'); } } + async function requestSmsCode(e: React.FormEvent) { + e.preventDefault(); + setSmsBusy(true); + setError(null); + try { + await apiFetch('/v1/auth/sms/request', { method: 'POST', body: JSON.stringify({ phone }) }); + setSmsStep('code'); + } catch (err) { + setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.'); + } finally { + setSmsBusy(false); + } + } + + async function verifySmsCode(e: React.FormEvent) { + e.preventDefault(); + setSmsBusy(true); + setError(null); + try { + await apiFetch('/v1/auth/sms/verify', { + method: 'POST', + body: JSON.stringify({ phone, code }), + }); + window.location.href = '/dashboard'; + } catch (err) { + setError(ERROR_COPY[errCode(err)] ?? 'Could not verify the code.'); + setSmsBusy(false); + } + } + + const hasOAuth = providers.google || providers.github; + return (

Sign in to your workspace

- Continue with Google, or get a magic link by email. + Passwordless — pick whichever is easiest.

- {state !== 'sent' ? ( - <> - {googleEnabled && ( - <> - - - Continue with Google - -
- - - or - - -
- + {hasOAuth && ( +
+ {providers.google && ( + + + Continue with Google + )} -
+ {providers.github && ( + + + Continue with GitHub + + )} +
+ )} + + {hasOAuth && ( +
+ + + or + + +
+ )} + + {providers.sms && ( +
+ {(['email', 'phone'] as const).map((m) => ( + + ))} +
+ )} + +
+ {method === 'email' && emailState !== 'sent' && ( +
- {state === 'sending' ? 'Sending…' : 'Send magic link'} + {emailState === 'sending' ? 'Sending…' : 'Send magic link'} - {error &&

{error}

} - - ) : ( -
-

- Magic link sent to {email}. -

-

- In dev mode the link is printed to the API console output. Check the terminal. -

-
- )} + )} + + {method === 'email' && emailState === 'sent' && ( +
+

+ Magic link sent to {email}. +

+

+ Open it on this device to finish signing in. +

+
+ )} + + {method === 'phone' && smsStep === 'phone' && ( +
+
+ + setPhone(e.target.value)} + placeholder="+41 79 123 45 67" + /> +
+ +
+ )} + + {method === 'phone' && smsStep === 'code' && ( +
+
+ + setCode(e.target.value.replace(/\D/g, ''))} + placeholder="123456" + className="mono tracking-[0.3em]" + /> +
+ + +
+ )} + + {error &&

{error}

} +
@@ -141,3 +301,11 @@ function GoogleIcon() { ); } + +function GitHubIcon() { + return ( + + ); +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 354eeaa..5c482ad 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,5 +1,18 @@ import crypto from 'node:crypto'; -import { and, createDb, eq, gt, type Database, magicLinks, memberships, organizations, sessions, users } from '@bmm/db'; +import { + type Database, + and, + createDb, + desc, + eq, + gt, + magicLinks, + memberships, + organizations, + sessions, + smsCodes, + users, +} from '@bmm/db'; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -16,9 +29,7 @@ function randomToken(bytes = 32): string { export function hashPassword(password: string): string { const salt = crypto.randomBytes(16).toString('hex'); - const derived = crypto - .scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N }) - .toString('hex'); + const derived = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N }).toString('hex'); return `scrypt$${SCRYPT_N}$${salt}$${derived}`; } @@ -36,11 +47,13 @@ export function verifyPassword(password: string, stored: string): boolean { } function slugify(input: string): string { - return input - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - .slice(0, 48) || 'org'; + return ( + input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 48) || 'org' + ); } export interface MagicLinkIssued { @@ -48,7 +61,10 @@ export interface MagicLinkIssued { expiresAt: Date; } -export async function issueMagicLink(email: string, db: Database = createDb()): Promise { +export async function issueMagicLink( + email: string, + db: Database = createDb(), +): Promise { const lower = email.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) { throw new Error('invalid_email'); @@ -64,7 +80,7 @@ export interface ConsumedSession { sessionToken: string; userId: string; orgId: string; - email: string; + email: string | null; } export async function consumeMagicLink( @@ -86,10 +102,7 @@ export async function consumeMagicLink( // Get or create user + default org let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0]; if (!user) { - [user] = await db - .insert(users) - .values({ email: row.email, emailVerified: true }) - .returning(); + [user] = await db.insert(users).values({ email: row.email, emailVerified: true }).returning(); const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; const [org] = await db .insert(organizations) @@ -177,10 +190,109 @@ export async function upsertOAuthLogin( return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email }; } +// ---- SMS one-time-code login ---- + +const SMS_CODE_TTL_MS = 10 * 60 * 1000; // 10 min +const SMS_RATE_WINDOW_MS = 15 * 60 * 1000; +const SMS_MAX_PER_WINDOW = 3; // codes issued per phone per window +const SMS_MAX_ATTEMPTS = 5; // wrong-code guesses per code + +/** Normalise to strict E.164 (+ and 8-15 digits). Throws on anything else. */ +function normalizePhone(raw: string): string { + const p = raw.replace(/[\s\-().]/g, ''); + if (!/^\+[1-9]\d{7,14}$/.test(p)) throw new Error('invalid_phone'); + return p; +} + +export interface SmsCodeIssued { + phone: string; + code: string; + expiresAt: Date; +} + +/** Generate a 6-digit code for a phone number, store it hashed, rate-limited. */ +export async function issueSmsCode( + phoneRaw: string, + db: Database = createDb(), +): Promise { + const phone = normalizePhone(phoneRaw); + const since = new Date(Date.now() - SMS_RATE_WINDOW_MS); + const recent = await db + .select({ id: smsCodes.id }) + .from(smsCodes) + .where(and(eq(smsCodes.phone, phone), gt(smsCodes.createdAt, since))); + if (recent.length >= SMS_MAX_PER_WINDOW) throw new Error('rate_limited'); + + const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'); + const expiresAt = new Date(Date.now() + SMS_CODE_TTL_MS); + await db.insert(smsCodes).values({ phone, codeHash: sha256(`${phone}:${code}`), expiresAt }); + return { phone, code, expiresAt }; +} + +/** Verify a code, then get-or-create the phone's user and mint a session. */ +export async function consumeSmsCode( + phoneRaw: string, + code: string, + meta: { ipAddress?: string; userAgent?: string } = {}, + db: Database = createDb(), +): Promise { + const phone = normalizePhone(phoneRaw); + const [row] = await db + .select() + .from(smsCodes) + .where(and(eq(smsCodes.phone, phone), gt(smsCodes.expiresAt, new Date()))) + .orderBy(desc(smsCodes.createdAt)) + .limit(1); + if (!row || row.consumedAt) throw new Error('invalid_or_expired_code'); + if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts'); + if (sha256(`${phone}:${code}`) !== row.codeHash) { + await db + .update(smsCodes) + .set({ attempts: row.attempts + 1 }) + .where(eq(smsCodes.id, row.id)); + throw new Error('invalid_code'); + } + await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id)); + + let user = (await db.select().from(users).where(eq(users.phone, phone)).limit(1))[0]; + if (!user) { + [user] = await db.insert(users).values({ phone }).returning(); + if (!user) throw new Error('user_create_failed'); + const orgSlug = `phone-${randomToken(4).toLowerCase()}`; + const [org] = await db + .insert(organizations) + .values({ slug: orgSlug, name: 'My workspace' }) + .returning(); + if (!org) throw new Error('org_create_failed'); + await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' }); + } + const resolved = user; + + const [membership] = await db + .select() + .from(memberships) + .where(eq(memberships.userId, resolved.id)) + .limit(1); + if (!membership) throw new Error('no_org_membership'); + + const sessionToken = randomToken(32); + await db.insert(sessions).values({ + userId: resolved.id, + tokenHash: sha256(sessionToken), + expiresAt: new Date(Date.now() + SESSION_TTL_MS), + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id)); + + return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email }; +} + export interface AuthedUser { userId: string; orgId: string; - email: string; + email: string | null; + phone: string | null; role: string; isAdmin: boolean; } @@ -196,6 +308,7 @@ export async function getSession( userId: sessions.userId, expiresAt: sessions.expiresAt, email: users.email, + phone: users.phone, isAdmin: users.isAdmin, }) .from(sessions) @@ -213,12 +326,16 @@ export async function getSession( userId: row.userId, orgId: membership.orgId, email: row.email, + phone: row.phone, role: membership.role, isAdmin: row.isAdmin, }; } -export async function destroySession(sessionToken: string, db: Database = createDb()): Promise { +export async function destroySession( + sessionToken: string, + db: Database = createDb(), +): Promise { const hash = sha256(sessionToken); await db.delete(sessions).where(eq(sessions.tokenHash, hash)); } @@ -227,7 +344,7 @@ export interface PasswordLoginResult { sessionToken: string; userId: string; orgId: string; - email: string; + email: string | null; isAdmin: boolean; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0158961..50af5c3 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -97,7 +97,10 @@ export const templates = pgTable( export const users = pgTable('users', { id: uuid('id').defaultRandom().primaryKey(), - email: varchar('email', { length: 255 }).notNull().unique(), + // Nullable: a user identifies via email OR phone. Postgres treats NULLs as + // distinct, so multiple phone-only users (email NULL) coexist fine. + email: varchar('email', { length: 255 }).unique(), + phone: varchar('phone', { length: 32 }).unique(), name: varchar('name', { length: 128 }), avatarUrl: text('avatar_url'), emailVerified: boolean('email_verified').default(false).notNull(), @@ -134,6 +137,23 @@ export const magicLinks = pgTable('magic_links', { createdAt: timestamp('created_at').defaultNow().notNull(), }); +// Short-lived 6-digit SMS one-time codes for phone login. +export const smsCodes = pgTable( + 'sms_codes', + { + id: uuid('id').defaultRandom().primaryKey(), + phone: varchar('phone', { length: 32 }).notNull(), + codeHash: text('code_hash').notNull(), + attempts: integer('attempts').default(0).notNull(), + expiresAt: timestamp('expires_at').notNull(), + consumedAt: timestamp('consumed_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + phoneIdx: index('idx_sms_codes_phone').on(t.phone, t.createdAt), + }), +); + export const memberships = pgTable('memberships', { id: uuid('id').defaultRandom().primaryKey(), orgId: uuid('org_id')