import crypto from 'node:crypto'; import { consumeMagicLink, consumeSmsCode, destroySession, getSession, issueMagicLink, issueSmsCode, loginWithPassword, upsertOAuthLogin, } from '@bmm/auth'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { config } from '../config.js'; import { audit } from '../lib/audit.js'; import { getOrgPlan } from '../lib/plan.js'; import { checkDailyLimit } from '../lib/rate-limit.js'; import { sendSms, smsConfigured } from '../lib/sms.js'; const SESSION_COOKIE = 'bmm_session'; const OAUTH_STATE_COOKIE = 'bmm_oauth_state'; const GoogleClaims = z.object({ iss: z.string(), aud: z.string(), exp: z.number(), email: z.string().email(), email_verified: z.union([z.boolean(), z.string()]).optional(), name: z.string().optional(), }); /** * Decode (NOT signature-verify) a Google ID token payload. Signature verification * is unnecessary here because the token is fetched directly from Google's token * endpoint over TLS, authenticated with our client secret — an intermediary-free * channel, per Google's own guidance. We still validate iss / aud / exp / email * below as defense-in-depth. */ function decodeGoogleIdToken(idToken: string): z.infer { const parts = idToken.split('.'); if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token'); const json = Buffer.from(parts[1], 'base64url').toString('utf8'); return GoogleClaims.parse(JSON.parse(json)); } function googleRedirectUri(): string { return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`; } 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() }); const parsed = Body.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' }); // Two-axis rate-limit: per-IP (prevents IP-flooding the endpoint) and // per-email (prevents inbox-flooding a specific target). Both required // because the IP cap protects us, the email cap protects the recipient. const ipOk = await checkDailyLimit('magic_ip', req.ip, 10); if (!ipOk.ok) { return reply.code(429).send({ error: 'rate_limited', detail: 'Too many magic-link requests from this IP. Try again tomorrow.', }); } const emailOk = await checkDailyLimit('magic_email', parsed.data.email.toLowerCase(), 5); if (!emailOk.ok) { return reply.code(429).send({ error: 'rate_limited', detail: 'Too many magic-link requests for this email. Try again tomorrow.', }); } try { const { token, expiresAt } = await issueMagicLink(parsed.data.email); const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`; // In dev we print the link to stdout so the developer can click it. // In production we must NEVER log the full token — anyone with // `docker logs` access would silently impersonate any user. if (config.NODE_ENV !== 'production') { app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`); console.log(`\n[magic-link] ${parsed.data.email} ->\n ${callbackUrl}\n`); } else { app.log.info( { to: parsed.data.email, expiresAt }, '[magic-link] issued (URL withheld from logs)', ); // TODO(launch): hook up Resend / SES here. Until then, production // magic-link is effectively dead — fail loud rather than silent. app.log.error('magic-link email sender not configured — link cannot reach user'); } return reply.send({ ok: true }); } catch (e) { app.log.error(e); return reply.code(400).send({ error: 'magic_link_failed' }); } }); app.post('/v1/auth/verify', async (req, reply) => { 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' }); try { const session = await consumeMagicLink(parsed.data.token, { 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 }, ipAddress: req.ip, }); return reply.send({ ok: true, user: { id: session.userId, email: session.email, orgId: session.orgId }, }); } catch (e) { app.log.warn({ err: e }, 'magic link verify failed'); return reply.code(400).send({ error: 'invalid_or_expired_token' }); } }); app.get('/v1/auth/me', async (req, reply) => { const token = req.cookies[SESSION_COOKIE]; const session = await getSession(token); if (!session) return reply.code(401).send({ error: 'unauthorized' }); // Plan is on the org, not the session — look it up fresh so a Stripe // upgrade is reflected without forcing a re-login. const plan = await getOrgPlan(session.orgId); return reply.send({ user: { ...session, plan } }); }); app.post('/v1/auth/admin/login', async (req, reply) => { const Body = z.object({ email: z.string().email(), password: z.string().min(8), }); const parsed = Body.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ error: 'invalid_input' }); } try { const session = await loginWithPassword(parsed.data.email, parsed.data.password, { ipAddress: req.ip, userAgent: req.headers['user-agent'], }); if (!session.isAdmin) { await destroySession(session.sessionToken); return reply.code(403).send({ error: 'not_admin' }); } 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: 'admin.login', resourceType: 'session', metadata: { email: session.email }, ipAddress: req.ip, }); return reply.send({ ok: true, user: { id: session.userId, email: session.email, orgId: session.orgId, isAdmin: true }, }); } catch (err) { app.log.warn({ err }, 'admin login failed'); // Constant-time-ish to discourage username enumeration await new Promise((r) => setTimeout(r, 300)); return reply.code(401).send({ error: 'invalid_credentials' }); } }); app.post('/v1/auth/logout', async (req, reply) => { const token = req.cookies[SESSION_COOKIE]; const session = token ? await getSession(token) : null; if (token) await destroySession(token); reply.clearCookie(SESSION_COOKIE, { path: '/' }); if (session) { await audit({ orgId: session.orgId, userId: session.userId, action: 'auth.logout', resourceType: 'session', ipAddress: req.ip, }); } 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. app.get('/v1/auth/providers', async (_req, reply) => { return reply.send({ google: googleConfigured(), github: githubConfigured(), sms: smsConfigured(), }); }); // Step 1: hand the browser off to Google's consent screen. app.get('/v1/auth/google', async (_req, reply) => { if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) { return reply.code(503).send({ error: 'google_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://accounts.google.com/o/oauth2/v2/auth'); url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID); url.searchParams.set('redirect_uri', googleRedirectUri()); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'openid email profile'); url.searchParams.set('state', state); url.searchParams.set('access_type', 'online'); url.searchParams.set('prompt', 'select_account'); return reply.redirect(url.toString()); }); // Step 2: Google redirects back here with an auth code. Exchange it, verify // the ID token, mint a session, drop the user on the dashboard. app.get('/v1/auth/google/callback', async (req, reply) => { const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`; const Query = z.object({ code: z.string().min(10).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=google_failed`); } // CSRF: the state echoed back by Google must match the one we set. // Length-check first — timingSafeEqual throws on a length mismatch. if ( !cookieState || cookieState.length !== q.data.state.length || !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state)) ) { return reply.redirect(`${loginUrl}?error=google_state`); } if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) { return reply.redirect(`${loginUrl}?error=google_failed`); } try { const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code: q.data.code, client_id: config.GOOGLE_OAUTH_ID, client_secret: config.GOOGLE_OAUTH_SECRET, redirect_uri: googleRedirectUri(), grant_type: 'authorization_code', }), }); if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`); const tokens = (await tokenRes.json()) as { id_token?: string }; if (!tokens.id_token) throw new Error('no_id_token'); const claims = decodeGoogleIdToken(tokens.id_token); if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') { throw new Error('bad_iss'); } if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud'); if (claims.exp * 1000 < Date.now()) throw new Error('token_expired'); const verified = claims.email_verified === true || claims.email_verified === 'true'; if (!verified) throw new Error('email_unverified'); const session = await upsertOAuthLogin( { email: claims.email, name: claims.name ?? 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: 'google' }, ipAddress: req.ip, }); return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`); } catch (err) { app.log.warn({ err }, 'google oauth callback failed'); 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' }); } }); }