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

@@ -1,5 +1,18 @@
import crypto from 'node:crypto';
import { and, createDb, eq, gt, type Database, magicLinks, memberships, organizations, sessions, users } from '@bmm/db';
import {
type Database,
and,
createDb,
desc,
eq,
gt,
magicLinks,
memberships,
organizations,
sessions,
smsCodes,
users,
} from '@bmm/db';
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -16,9 +29,7 @@ function randomToken(bytes = 32): string {
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex');
const derived = crypto
.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N })
.toString('hex');
const derived = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N }).toString('hex');
return `scrypt$${SCRYPT_N}$${salt}$${derived}`;
}
@@ -36,11 +47,13 @@ export function verifyPassword(password: string, stored: string): boolean {
}
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 48) || 'org';
return (
input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 48) || 'org'
);
}
export interface MagicLinkIssued {
@@ -48,7 +61,10 @@ export interface MagicLinkIssued {
expiresAt: Date;
}
export async function issueMagicLink(email: string, db: Database = createDb()): Promise<MagicLinkIssued> {
export async function issueMagicLink(
email: string,
db: Database = createDb(),
): Promise<MagicLinkIssued> {
const lower = email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) {
throw new Error('invalid_email');
@@ -64,7 +80,7 @@ export interface ConsumedSession {
sessionToken: string;
userId: string;
orgId: string;
email: string;
email: string | null;
}
export async function consumeMagicLink(
@@ -86,10 +102,7 @@ export async function consumeMagicLink(
// Get or create user + default org
let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0];
if (!user) {
[user] = await db
.insert(users)
.values({ email: row.email, emailVerified: true })
.returning();
[user] = await db.insert(users).values({ email: row.email, emailVerified: true }).returning();
const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
const [org] = await db
.insert(organizations)
@@ -177,10 +190,109 @@ export async function upsertOAuthLogin(
return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
}
// ---- SMS one-time-code login ----
const SMS_CODE_TTL_MS = 10 * 60 * 1000; // 10 min
const SMS_RATE_WINDOW_MS = 15 * 60 * 1000;
const SMS_MAX_PER_WINDOW = 3; // codes issued per phone per window
const SMS_MAX_ATTEMPTS = 5; // wrong-code guesses per code
/** Normalise to strict E.164 (+ and 8-15 digits). Throws on anything else. */
function normalizePhone(raw: string): string {
const p = raw.replace(/[\s\-().]/g, '');
if (!/^\+[1-9]\d{7,14}$/.test(p)) throw new Error('invalid_phone');
return p;
}
export interface SmsCodeIssued {
phone: string;
code: string;
expiresAt: Date;
}
/** Generate a 6-digit code for a phone number, store it hashed, rate-limited. */
export async function issueSmsCode(
phoneRaw: string,
db: Database = createDb(),
): Promise<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 {
userId: string;
orgId: string;
email: string;
email: string | null;
phone: string | null;
role: string;
isAdmin: boolean;
}
@@ -196,6 +308,7 @@ export async function getSession(
userId: sessions.userId,
expiresAt: sessions.expiresAt,
email: users.email,
phone: users.phone,
isAdmin: users.isAdmin,
})
.from(sessions)
@@ -213,12 +326,16 @@ export async function getSession(
userId: row.userId,
orgId: membership.orgId,
email: row.email,
phone: row.phone,
role: membership.role,
isAdmin: row.isAdmin,
};
}
export async function destroySession(sessionToken: string, db: Database = createDb()): Promise<void> {
export async function destroySession(
sessionToken: string,
db: Database = createDb(),
): Promise<void> {
const hash = sha256(sessionToken);
await db.delete(sessions).where(eq(sessions.tokenHash, hash));
}
@@ -227,7 +344,7 @@ export interface PasswordLoginResult {
sessionToken: string;
userId: string;
orgId: string;
email: string;
email: string | null;
isAdmin: boolean;
}