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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,10 @@ export const templates = pgTable(
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
// Nullable: a user identifies via email OR phone. Postgres treats NULLs as
|
||||
// distinct, so multiple phone-only users (email NULL) coexist fine.
|
||||
email: varchar('email', { length: 255 }).unique(),
|
||||
phone: varchar('phone', { length: 32 }).unique(),
|
||||
name: varchar('name', { length: 128 }),
|
||||
avatarUrl: text('avatar_url'),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
@@ -134,6 +137,23 @@ export const magicLinks = pgTable('magic_links', {
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Short-lived 6-digit SMS one-time codes for phone login.
|
||||
export const smsCodes = pgTable(
|
||||
'sms_codes',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
phone: varchar('phone', { length: 32 }).notNull(),
|
||||
codeHash: text('code_hash').notNull(),
|
||||
attempts: integer('attempts').default(0).notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
consumedAt: timestamp('consumed_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
phoneIdx: index('idx_sms_codes_phone').on(t.phone, t.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memberships = pgTable('memberships', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
orgId: uuid('org_id')
|
||||
|
||||
Reference in New Issue
Block a user