feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
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:
parent
f5107922a0
commit
cc3c5ad444
10
.env.example
10
.env.example
@ -11,10 +11,18 @@ BETTER_AUTH_URL=http://localhost:3001
|
|||||||
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
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: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/github/callback
|
||||||
GITHUB_OAUTH_ID=
|
GITHUB_OAUTH_ID=
|
||||||
GITHUB_OAUTH_SECRET=
|
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") ----
|
# ---- Google OAuth (optional — "Continue with Google") ----
|
||||||
# Create at https://console.cloud.google.com/apis/credentials
|
# Create at https://console.cloud.google.com/apis/credentials
|
||||||
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback
|
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback
|
||||||
|
|||||||
@ -18,6 +18,11 @@ const Env = z.object({
|
|||||||
ADMIN_NAME: z.string().optional(),
|
ADMIN_NAME: z.string().optional(),
|
||||||
GOOGLE_OAUTH_ID: z.string().optional(),
|
GOOGLE_OAUTH_ID: z.string().optional(),
|
||||||
GOOGLE_OAUTH_SECRET: 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({
|
export const config = Env.parse({
|
||||||
@ -35,6 +40,11 @@ export const config = Env.parse({
|
|||||||
ADMIN_NAME: process.env.ADMIN_NAME,
|
ADMIN_NAME: process.env.ADMIN_NAME,
|
||||||
GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID,
|
GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID,
|
||||||
GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET,
|
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.
|
// INFRA-001: refuse to boot in production with the placeholder encryption key.
|
||||||
|
|||||||
30
apps/api/src/lib/sms.ts
Normal file
30
apps/api/src/lib/sms.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,4 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
import {
|
||||||
adminSettings,
|
adminSettings,
|
||||||
auditLog,
|
auditLog,
|
||||||
@ -20,11 +18,13 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from '@bmm/db';
|
} from '@bmm/db';
|
||||||
import { SYSTEM_PROMPT } from '@bmm/llm';
|
import { SYSTEM_PROMPT } from '@bmm/llm';
|
||||||
import { requireAdmin } from '../plugins/session.js';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { getRedis } from '../lib/redis.js';
|
import { z } from 'zod';
|
||||||
import { getBuildQueue } from '../lib/queue.js';
|
|
||||||
import { audit } from '../lib/audit.js';
|
import { audit } from '../lib/audit.js';
|
||||||
import { encryptionStatus, rotateKeys } from '../lib/crypto.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();
|
const db = createDb();
|
||||||
|
|
||||||
@ -47,11 +47,26 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
newUsersLast7d,
|
newUsersLast7d,
|
||||||
newServersLast7d,
|
newServersLast7d,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
|
db
|
||||||
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
|
.select({ c: count() })
|
||||||
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
|
.from(users)
|
||||||
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
|
.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(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
|
db
|
||||||
.select({ c: count() })
|
.select({ c: count() })
|
||||||
.from(mcpServers)
|
.from(mcpServers)
|
||||||
@ -81,11 +96,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.groupBy(mcpServers.status);
|
.groupBy(mcpServers.status);
|
||||||
|
|
||||||
// Recent activity from audit log
|
// Recent activity from audit log
|
||||||
const recent = await db
|
const recent = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(15);
|
||||||
.select()
|
|
||||||
.from(auditLog)
|
|
||||||
.orderBy(desc(auditLog.createdAt))
|
|
||||||
.limit(15);
|
|
||||||
|
|
||||||
// Builds in last 24h with status
|
// Builds in last 24h with status
|
||||||
const recentBuilds = await db
|
const recentBuilds = await db
|
||||||
@ -123,12 +134,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const parsed = Query.safeParse(req.query);
|
const parsed = Query.safeParse(req.query);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_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
|
const filtered = parsed.data.search
|
||||||
? rows.filter((u) =>
|
? rows.filter((u) => {
|
||||||
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
|
const q = parsed.data.search!.toLowerCase();
|
||||||
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
|
return (
|
||||||
)
|
(u.email?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(u.phone?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(u.name?.toLowerCase().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
})
|
||||||
: rows;
|
: rows;
|
||||||
|
|
||||||
// attach org + server count
|
// attach org + server count
|
||||||
@ -280,7 +299,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
server: mcpServers,
|
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)
|
.from(mcpServers)
|
||||||
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
.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);
|
const p = Params.safeParse(req.params);
|
||||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
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' });
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
// Get last build's prompt
|
// Get last build's prompt
|
||||||
@ -357,7 +385,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const p = Params.safeParse(req.params);
|
const p = Params.safeParse(req.params);
|
||||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
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' });
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
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';
|
redisOk = pong === 'PONG';
|
||||||
const q = getBuildQueue();
|
const q = getBuildQueue();
|
||||||
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
||||||
queueDepth =
|
queueDepth = (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.delayed ?? 0);
|
||||||
(counts.waiting ?? 0) +
|
|
||||||
(counts.active ?? 0) +
|
|
||||||
(counts.delayed ?? 0);
|
|
||||||
} catch {
|
} catch {
|
||||||
// remains false
|
// remains false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
consumeMagicLink,
|
consumeMagicLink,
|
||||||
|
consumeSmsCode,
|
||||||
destroySession,
|
destroySession,
|
||||||
getSession,
|
getSession,
|
||||||
issueMagicLink,
|
issueMagicLink,
|
||||||
|
issueSmsCode,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
upsertOAuthLogin,
|
upsertOAuthLogin,
|
||||||
} from '@bmm/auth';
|
} from '@bmm/auth';
|
||||||
@ -11,6 +13,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { audit } from '../lib/audit.js';
|
import { audit } from '../lib/audit.js';
|
||||||
|
import { sendSms, smsConfigured } from '../lib/sms.js';
|
||||||
|
|
||||||
const SESSION_COOKIE = 'bmm_session';
|
const SESSION_COOKIE = 'bmm_session';
|
||||||
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
|
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
|
||||||
@ -46,6 +49,29 @@ function googleConfigured(): boolean {
|
|||||||
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
|
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> {
|
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) => {
|
||||||
const Body = z.object({ email: z.string().email() });
|
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
|
// 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.
|
// Google button when no credentials are set, instead of showing a dead button.
|
||||||
app.get('/v1/auth/providers', async (_req, reply) => {
|
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.
|
// 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`);
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import { getSession } from '@bmm/auth';
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
builds,
|
builds,
|
||||||
@ -16,12 +15,13 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from '@bmm/db';
|
} from '@bmm/db';
|
||||||
import { GeneratorSpec } from '@bmm/types';
|
import { GeneratorSpec } from '@bmm/types';
|
||||||
import { getSession } from '@bmm/auth';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { requireAuth, requireAdmin } from '../plugins/session.js';
|
import { z } from 'zod';
|
||||||
import { audit } from '../lib/audit.js';
|
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 { 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 db = createDb();
|
||||||
|
|
||||||
@ -163,7 +163,11 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
|
let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
while (true) {
|
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;
|
if (existing.length === 0) break;
|
||||||
attempt++;
|
attempt++;
|
||||||
slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
|
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')));
|
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
ownerName: user.email.split('@')[0],
|
ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
|
||||||
ownerOrgName: null,
|
ownerOrgName: null,
|
||||||
activeDeployments: Number(active?.c ?? 0),
|
activeDeployments: Number(active?.c ?? 0),
|
||||||
};
|
};
|
||||||
@ -468,7 +472,9 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const validation = GeneratorSpec.safeParse(fullSpec);
|
const validation = GeneratorSpec.safeParse(fullSpec);
|
||||||
if (!validation.success) {
|
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);
|
const previewId = await cacheSpec(validation.data);
|
||||||
// Persist the pre-rendered code under the same previewId so the worker uses it
|
// 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) {
|
if (fork.containerId) {
|
||||||
const result = await stopContainer(fork.containerId);
|
const result = await stopContainer(fork.containerId);
|
||||||
if (result.ok) stoppedContainers++;
|
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
|
await db
|
||||||
|
|||||||
@ -10,60 +10,123 @@ import { useEffect, useState } from 'react';
|
|||||||
const ERROR_COPY: Record<string, string> = {
|
const ERROR_COPY: Record<string, string> = {
|
||||||
google_failed: 'Google sign-in could not be completed. Please try again.',
|
google_failed: 'Google sign-in could not be completed. Please try again.',
|
||||||
google_state: 'Google sign-in expired or was interrupted. 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() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [providers, setProviders] = useState({ google: false, github: false, sms: false });
|
||||||
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
const [method, setMethod] = useState<'email' | 'phone'>('email');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ google: boolean }>('/v1/auth/providers')
|
apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers')
|
||||||
.then((r) => setGoogleEnabled(r.google))
|
.then(setProviders)
|
||||||
.catch(() => setGoogleEnabled(false));
|
.catch(() => undefined);
|
||||||
|
const err = new URLSearchParams(window.location.search).get('error');
|
||||||
const params = new URLSearchParams(window.location.search);
|
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
||||||
const err = params.get('error');
|
|
||||||
if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function sendMagicLink(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setState('sending');
|
setEmailState('sending');
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch('/v1/auth/magic-link', {
|
await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
|
||||||
method: 'POST',
|
setEmailState('sent');
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
});
|
|
||||||
setState('sent');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState('error');
|
setEmailState('idle');
|
||||||
setError((err as Error).message);
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-6">
|
<div className="flex min-h-screen items-center justify-center px-6">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<Logo className="mb-10" />
|
<Logo className="mb-10" />
|
||||||
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
||||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||||
Continue with Google, or get a magic link by email.
|
Passwordless — pick whichever is easiest.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{state !== 'sent' ? (
|
{hasOAuth && (
|
||||||
<>
|
<div className="mt-7 space-y-2">
|
||||||
{googleEnabled && (
|
{providers.google && (
|
||||||
<>
|
|
||||||
<a
|
<a
|
||||||
href={apiUrl('/v1/auth/google')}
|
href={apiUrl('/v1/auth/google')}
|
||||||
className="mt-7 flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||||
>
|
>
|
||||||
<GoogleIcon />
|
<GoogleIcon />
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
{providers.github && (
|
||||||
|
<a
|
||||||
|
href={apiUrl('/v1/auth/github')}
|
||||||
|
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||||
|
>
|
||||||
|
<GitHubIcon />
|
||||||
|
Continue with GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOAuth && (
|
||||||
<div className="my-5 flex items-center gap-3">
|
<div className="my-5 flex items-center gap-3">
|
||||||
<span className="h-px flex-1 bg-[--color-border]" />
|
<span className="h-px flex-1 bg-[--color-border]" />
|
||||||
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||||
@ -71,9 +134,35 @@ export default function LoginPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="h-px flex-1 bg-[--color-border]" />
|
<span className="h-px flex-1 bg-[--color-border]" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<form onSubmit={submit} className={googleEnabled ? 'space-y-3' : 'mt-7 space-y-3'}>
|
|
||||||
|
{providers.sms && (
|
||||||
|
<div
|
||||||
|
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
||||||
|
>
|
||||||
|
{(['email', 'phone'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMethod(m);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
|
||||||
|
method === m
|
||||||
|
? 'bg-[--color-bg-subtle] text-[--color-fg]'
|
||||||
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'email' ? 'Email' : 'Phone'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={providers.sms ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
||||||
|
{method === 'email' && emailState !== 'sent' && (
|
||||||
|
<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>
|
||||||
<Input
|
<Input
|
||||||
@ -91,24 +180,95 @@ export default function LoginPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={state === 'sending'}
|
disabled={emailState === 'sending'}
|
||||||
>
|
>
|
||||||
{state === 'sending' ? 'Sending…' : 'Send magic link'}
|
{emailState === 'sending' ? 'Sending…' : 'Send magic link'}
|
||||||
</Button>
|
</Button>
|
||||||
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
|
|
||||||
</form>
|
</form>
|
||||||
</>
|
)}
|
||||||
) : (
|
|
||||||
<div className="panel mt-7 p-4">
|
{method === 'email' && emailState === 'sent' && (
|
||||||
|
<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>.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
|
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
|
||||||
In dev mode the link is printed to the API console output. Check the terminal.
|
Open it on this device to finish signing in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{method === 'phone' && smsStep === 'phone' && (
|
||||||
|
<form onSubmit={requestSmsCode} className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="phone">Phone number</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
required
|
||||||
|
autoComplete="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+41 79 123 45 67"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={smsBusy}
|
||||||
|
>
|
||||||
|
{smsBusy ? 'Sending…' : 'Send code'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{method === 'phone' && smsStep === 'code' && (
|
||||||
|
<form onSubmit={verifySmsCode} className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="code" hint={`sent to ${phone}`}>
|
||||||
|
6-digit code
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
placeholder="123456"
|
||||||
|
className="mono tracking-[0.3em]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={smsBusy || code.length !== 6}
|
||||||
|
>
|
||||||
|
{smsBusy ? 'Verifying…' : 'Verify & sign in'}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSmsStep('phone');
|
||||||
|
setCode('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||||
|
>
|
||||||
|
← Use a different number
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
||||||
<Link href="/" className="transition-colors hover:text-[--color-fg]">
|
<Link href="/" className="transition-colors hover:text-[--color-fg]">
|
||||||
← Back to home
|
← Back to home
|
||||||
@ -141,3 +301,11 @@ function GoogleIcon() {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GitHubIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
import crypto from 'node:crypto';
|
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 MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min
|
||||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
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 {
|
export function hashPassword(password: string): string {
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
const derived = crypto
|
const derived = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N }).toString('hex');
|
||||||
.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N })
|
|
||||||
.toString('hex');
|
|
||||||
return `scrypt$${SCRYPT_N}$${salt}$${derived}`;
|
return `scrypt$${SCRYPT_N}$${salt}$${derived}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,11 +47,13 @@ export function verifyPassword(password: string, stored: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function slugify(input: string): string {
|
function slugify(input: string): string {
|
||||||
return input
|
return (
|
||||||
|
input
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/(^-|-$)/g, '')
|
.replace(/(^-|-$)/g, '')
|
||||||
.slice(0, 48) || 'org';
|
.slice(0, 48) || 'org'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MagicLinkIssued {
|
export interface MagicLinkIssued {
|
||||||
@ -48,7 +61,10 @@ export interface MagicLinkIssued {
|
|||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function issueMagicLink(email: string, db: Database = createDb()): Promise<MagicLinkIssued> {
|
export async function issueMagicLink(
|
||||||
|
email: string,
|
||||||
|
db: Database = createDb(),
|
||||||
|
): Promise<MagicLinkIssued> {
|
||||||
const lower = email.trim().toLowerCase();
|
const lower = email.trim().toLowerCase();
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) {
|
||||||
throw new Error('invalid_email');
|
throw new Error('invalid_email');
|
||||||
@ -64,7 +80,7 @@ export interface ConsumedSession {
|
|||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumeMagicLink(
|
export async function consumeMagicLink(
|
||||||
@ -86,10 +102,7 @@ export async function consumeMagicLink(
|
|||||||
// Get or create user + default org
|
// Get or create user + default org
|
||||||
let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0];
|
let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0];
|
||||||
if (!user) {
|
if (!user) {
|
||||||
[user] = await db
|
[user] = await db.insert(users).values({ email: row.email, emailVerified: true }).returning();
|
||||||
.insert(users)
|
|
||||||
.values({ email: row.email, emailVerified: true })
|
|
||||||
.returning();
|
|
||||||
const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
|
const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
|
||||||
const [org] = await db
|
const [org] = await db
|
||||||
.insert(organizations)
|
.insert(organizations)
|
||||||
@ -177,10 +190,109 @@ export async function upsertOAuthLogin(
|
|||||||
return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
|
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<SmsCodeIssued> {
|
||||||
|
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<ConsumedSession> {
|
||||||
|
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 {
|
export interface AuthedUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
@ -196,6 +308,7 @@ export async function getSession(
|
|||||||
userId: sessions.userId,
|
userId: sessions.userId,
|
||||||
expiresAt: sessions.expiresAt,
|
expiresAt: sessions.expiresAt,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
phone: users.phone,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
})
|
})
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
@ -213,12 +326,16 @@ export async function getSession(
|
|||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
orgId: membership.orgId,
|
orgId: membership.orgId,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
phone: row.phone,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
isAdmin: row.isAdmin,
|
isAdmin: row.isAdmin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroySession(sessionToken: string, db: Database = createDb()): Promise<void> {
|
export async function destroySession(
|
||||||
|
sessionToken: string,
|
||||||
|
db: Database = createDb(),
|
||||||
|
): Promise<void> {
|
||||||
const hash = sha256(sessionToken);
|
const hash = sha256(sessionToken);
|
||||||
await db.delete(sessions).where(eq(sessions.tokenHash, hash));
|
await db.delete(sessions).where(eq(sessions.tokenHash, hash));
|
||||||
}
|
}
|
||||||
@ -227,7 +344,7 @@ export interface PasswordLoginResult {
|
|||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -97,7 +97,10 @@ export const templates = pgTable(
|
|||||||
|
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
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 }),
|
name: varchar('name', { length: 128 }),
|
||||||
avatarUrl: text('avatar_url'),
|
avatarUrl: text('avatar_url'),
|
||||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||||
@ -134,6 +137,23 @@ export const magicLinks = pgTable('magic_links', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
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', {
|
export const memberships = pgTable('memberships', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
orgId: uuid('org_id')
|
orgId: uuid('org_id')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user