feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s

GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.

SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
  rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
  user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
  per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
  (number -> 6-digit code with one-time-code autofill).

SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-21 22:59:58 +02:00
parent f5107922a0
commit cc3c5ad444
9 changed files with 710 additions and 115 deletions

View File

@@ -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.

30
apps/api/src/lib/sms.ts Normal file
View File

@@ -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<void> {
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)}`);
}
}

View File

@@ -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<void> {
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<void> {
.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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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
}

View File

@@ -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<string, number[]>();
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<void> {
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<void> {
// 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<void> {
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<string, number> = {
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' });
}
});
}

View File

@@ -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<void> {
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<void> {
.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<void> {
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<void> {
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