From 989a43149daa88e32d3eddad0b39587856dddd30 Mon Sep 17 00:00:00 2001 From: jsnyder10 Date: Sun, 13 Sep 2026 18:02:58 -0500 Subject: [PATCH] feat(auth): Oakbox email sign-in, factor-class gate at every mint site - emailLogin.request / verifyLink / verifyCode / peekLink behind features.emailLogin: one attempt carries a hashed link token and an HMAC'd 6-digit code, consumed once atomically; uniform request response; per email+IP, IP and email rate limits; account created on first verify with a VERIFIED email and a retried generated username. - requiresDeviceStep / runDeviceStep: one 2FA gate for password, email login, magic link and OAuth, with second-step guess limits. - beforeSessionMint hook at every mint site; DEACTIVATED/BANNED refused everywhere, including magic link and passkey. - verifyMagicLink is single-use atomically. - Password reset accepts an app key. - Wildcard-safe case-insensitive lookups (same fix as the 0.20.6 patch). --- .../email-login-and-factor-class-gate.md | 66 ++ packages/auth/prisma/schema.device.prisma | 24 + packages/auth/prisma/schema.standard.prisma | 24 + packages/auth/src/adapters/database.ts | 49 + packages/auth/src/adapters/email.ts | 53 +- packages/auth/src/adapters/prismaAdapter.ts | 124 +- packages/auth/src/index.ts | 25 +- packages/auth/src/procedures/base.ts | 106 +- packages/auth/src/procedures/emailLogin.ts | 583 ++++++++++ packages/auth/src/procedures/magicLink.ts | 111 +- packages/auth/src/procedures/oauth.ts | 123 +- packages/auth/src/procedures/passkey.ts | 6 + .../auth/src/procedures/twoFa/deviceStep.ts | 154 +++ packages/auth/src/router.ts | 10 + packages/auth/src/stack-plugin.ts | 10 + packages/auth/src/types/config.ts | 51 + packages/auth/src/types/hooks.ts | 63 + packages/auth/src/utilities/accountStatus.ts | 34 + packages/auth/src/utilities/config.ts | 106 +- packages/auth/src/utilities/createUser.ts | 91 ++ packages/auth/src/utilities/emailMatch.ts | 5 + packages/auth/src/validators.ts | 4 + packages/auth/tests/emailLogin.test.ts | 1027 +++++++++++++++++ packages/auth/tests/prismaInsensitive.test.ts | 103 ++ 24 files changed, 2858 insertions(+), 94 deletions(-) create mode 100644 .changeset/email-login-and-factor-class-gate.md create mode 100644 packages/auth/src/procedures/emailLogin.ts create mode 100644 packages/auth/src/procedures/twoFa/deviceStep.ts create mode 100644 packages/auth/src/utilities/accountStatus.ts create mode 100644 packages/auth/src/utilities/createUser.ts create mode 100644 packages/auth/tests/emailLogin.test.ts create mode 100644 packages/auth/tests/prismaInsensitive.test.ts diff --git a/.changeset/email-login-and-factor-class-gate.md b/.changeset/email-login-and-factor-class-gate.md new file mode 100644 index 0000000..526977f --- /dev/null +++ b/.changeset/email-login-and-factor-class-gate.md @@ -0,0 +1,66 @@ +--- +'@factiii/auth': minor +--- + +Email sign-in with a link and a code, and one 2FA gate for every sign-in path + +`auth.emailLogin.request`, `verifyLink` and `verifyCode`, behind +`features.emailLogin`. One email carries a sign-in link and a 6-digit code for the +same attempt. `request` returns `{ sent: true }` with the same timing whether or +not an account exists, so it cannot be used to find out who has one; the email is +sent without being awaited, so a slow or failing mail provider changes neither the +timing nor the response. An attempt lives 15 minutes, is consumed once and +atomically by whichever of the link or the code arrives first, allows five wrong +codes, and is replaced by a newer request. Requests are limited per address and IP +pair, per IP, and per address, with the per-address cap higher than the pair limit +so a stranger cannot use up the owner's requests from one IP. Only a hash of the +token and an HMAC of the code are stored. Verifying an address with no account +creates one with the email already VERIFIED; an existing account whose email was +never proven is refused, by the same rule as the OAuth attach fix. `app` is a key +into a server-side allowlist (`emailLogin.apps`, own keys only), which picks the +link host and brand, so no URL ever comes from the client. Needs the new +`EmailLoginAttempt` model, `emailLogin.pepper`, `emailLogin.rateLimit`, and +`emailService.sendLoginEmail`; `createAuthConfig` refuses to start without them. + +The 2FA check now runs at every place a session is minted, not only password +login. A magic link or an OAuth sign-in into an account with 2FA on used to go +straight through; both now return `pendingLogin` or `requires2FA` exactly as +password login does, and accept `twoFaCode` for the second step. Push approval +for these paths goes through the new `hooks.onDeviceStepRequired`. A +user-verified passkey still signs in alone. The second step is bounded: through +`emailLogin.rateLimit`, an account accepts ten second-step codes per 15 minutes +across every IP, five wrong codes spend the email attempt or magic link they came +with, and two requests racing on one link or attempt push a device once. + +New `hooks.beforeSessionMint(userId, { firstFactor, ip })` runs at every mint site +— password, email sign-in, magic link, OAuth (before a provider is linked to an +existing account) and passkey — before the second step and any side effect. +Throw to refuse. Account-status rules kept in `beforeLogin` only ever covered +password login; move them here (for example, refusing a DELETED account past its +grace window). The package itself now refuses DEACTIVATED and BANNED accounts on +every path, including magic link and passkey, which did not check. + +Case-insensitive user lookups are exact. The Prisma adapter's `mode: +'insensitive'` `equals` is an ILIKE on Postgres, where `_` and `%` are wildcards, +so a lookup for `j_hn@outlook.com` could return `john@outlook.com` — and OAuth +attach-by-email, email sign-in and password reset act on that result. Lookups now +escape the wildcards and keep only a row with the same identifier, and those +paths re-check the address before acting on it. + +`verifyMagicLink` is single-use atomically: two requests racing on one link can +no longer both sign in. Adapters gain `magicLink.consume`; one written before it +keeps the old read-then-mark behaviour. + +`sendPasswordResetEmail` takes an optional `app` key and passes the app's reset +URL to the email service, so each product's reset link opens on its own site. + +`auth.emailLogin.peekLink({ token })` returns `{ valid: true, maskedEmail }` for an +open link (`a•••@example.com`, from the exported `maskEmail`), or `{ valid: false }`, +so a confirm page can name the address before anyone signs in. It is a mutation, +so a link pre-fetch never calls it; it spends nothing and is limited per IP. + +Accounts created by email sign-in or OAuth try a fresh generated username when the +one they drew is taken, up to five times, instead of failing. Only an email +violation still counts as a lost race for the same inbox. The default +`generateUsername` now adds a random suffix, so two accounts made in the same +millisecond do not draw the same name. diff --git a/packages/auth/prisma/schema.device.prisma b/packages/auth/prisma/schema.device.prisma index 7bdd6e6..0a1401a 100644 --- a/packages/auth/prisma/schema.device.prisma +++ b/packages/auth/prisma/schema.device.prisma @@ -76,6 +76,8 @@ model User { devices Device[] @relation("devices") admin Admin? magicLinks MagicLink[] + // Optional — email sign-in (see EmailLoginAttempt below). + emailLoginAttempts EmailLoginAttempt[] // Optional — passkey + multi-provider features (see models at end of file). passkeys Passkey[] oAuthAccounts OAuthAccount[] @@ -169,6 +171,28 @@ model MagicLink { @@index([userId]) } +// ============================================================================== +// EmailLoginAttempt Model (optional — enable with features.emailLogin) +// ============================================================================== +// One email sign-in attempt. The link and the 6-digit code in that email share +// this row, so whichever is used first spends both. Only hashes are stored. + +model EmailLoginAttempt { + id String @id @default(uuid()) + email String // normalized: trimmed, lowercase + userId Int? // null → the account is created on verify + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app String // key into emailLogin.apps + tokenHash String @unique // sha256 of the link token + codeHash String // HMAC-SHA256(pepper, ":") + attempts Int @default(0) + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([email, createdAt]) +} + // ============================================================================== // Passkey Model (optional — enable with features.passkey) // ============================================================================== diff --git a/packages/auth/prisma/schema.standard.prisma b/packages/auth/prisma/schema.standard.prisma index f70cca9..e6cc477 100644 --- a/packages/auth/prisma/schema.standard.prisma +++ b/packages/auth/prisma/schema.standard.prisma @@ -77,6 +77,8 @@ model User { otps OTP[] admin Admin? magicLinks MagicLink[] + // Optional — email sign-in (see EmailLoginAttempt below). + emailLoginAttempts EmailLoginAttempt[] // Optional — passkey + multi-provider features (see models at end of file). passkeys Passkey[] oAuthAccounts OAuthAccount[] @@ -153,6 +155,28 @@ model MagicLink { @@index([userId]) } +// ============================================================================== +// EmailLoginAttempt Model (optional — enable with features.emailLogin) +// ============================================================================== +// One email sign-in attempt. The link and the 6-digit code in that email share +// this row, so whichever is used first spends both. Only hashes are stored. + +model EmailLoginAttempt { + id String @id @default(uuid()) + email String // normalized: trimmed, lowercase + userId Int? // null → the account is created on verify + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app String // key into emailLogin.apps + tokenHash String @unique // sha256 of the link token + codeHash String // HMAC-SHA256(pepper, ":") + attempts Int @default(0) + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([email, createdAt]) +} + // ============================================================================== // Passkey Model (optional — enable with features.passkey) // ============================================================================== diff --git a/packages/auth/src/adapters/database.ts b/packages/auth/src/adapters/database.ts index 552a794..e7ac997 100644 --- a/packages/auth/src/adapters/database.ts +++ b/packages/auth/src/adapters/database.ts @@ -65,6 +65,34 @@ export interface AuthMagicLink { userId: number; } +/** + * One email sign-in attempt. The link and the code in that email share this + * row, so whichever is used first spends both. Only hashes are stored. + */ +export interface AuthEmailLoginAttempt { + id: string; + /** Normalized: trimmed, lowercase. */ + email: string; + /** The account the address belonged to when the email was sent, or null. */ + userId: number | null; + /** Key into `emailLogin.apps`. */ + app: string; + /** sha256 of the link token. */ + tokenHash: string; + /** HMAC-SHA256 of `${id}:${code}` under `emailLogin.pepper`. */ + codeHash: string; + /** Codes tried so far. */ + attempts: number; + expiresAt: Date; + consumedAt: Date | null; + createdAt: Date; +} + +export type CreateEmailLoginAttemptData = Pick< + AuthEmailLoginAttempt, + 'id' | 'email' | 'userId' | 'app' | 'tokenHash' | 'codeHash' | 'expiresAt' +>; + // ── Input types ────────────────────────────────────────────────────────────── export interface CreateUserData { @@ -164,5 +192,26 @@ export interface DatabaseAdapter { findById(id: string): Promise; create(data: { userId: number; expiresAt: Date }): Promise; markUsed(id: string): Promise; + /** + * Mark the link used only if it is still unused and unexpired, in one + * conditional write. True for exactly one caller. Optional so an adapter + * written before it still compiles; without it `verifyMagicLink` falls back to + * the non-atomic read-then-`markUsed`. + */ + consume?(id: string): Promise; + }; + + /** Optional — required only when features.emailLogin is enabled. */ + emailLoginAttempt?: { + create(data: CreateEmailLoginAttemptData): Promise; + findByTokenHash(tokenHash: string): Promise; + /** The newest attempt for this email that is neither consumed nor expired. */ + findLatestOpenByEmail(email: string): Promise; + /** Consume only if still open, in one conditional write. True for exactly one caller. */ + consume(id: string): Promise; + /** Count one code try, atomically. Resolves to the new count. */ + incrementAttempts(id: string): Promise; + /** Consume every open attempt for this email — a newer request replaces them. */ + consumeOpenByEmail(email: string): Promise; }; } diff --git a/packages/auth/src/adapters/email.ts b/packages/auth/src/adapters/email.ts index 1dcbb16..6e559bd 100644 --- a/packages/auth/src/adapters/email.ts +++ b/packages/auth/src/adapters/email.ts @@ -1,4 +1,30 @@ /* eslint-disable no-console */ + +/** Extra context for a password reset email. */ +export interface PasswordResetEmailOptions { + /** The `emailLogin.apps` key the reset was requested from. */ + app?: string; + /** The full reset URL on that app's site, token included. */ + resetUrl?: string; +} + +/** + * One email sign-in attempt. The link and the code are the same attempt: send + * both, and whichever the person uses first spends the other. + */ +export interface LoginEmailParams { + to: string; + /** The `emailLogin.apps` key that asked. */ + app: string; + /** That app's `brand`, for choosing the template. */ + brand: string; + /** Opens the app's confirm page; it signs nobody in until they tap Continue. */ + link: string; + /** Six digits, for typing into the app. */ + code: string; + expiresAt: Date; +} + /** * Email service adapter interface * Implement this interface to integrate your email service @@ -10,15 +36,25 @@ export interface EmailAdapter { sendVerificationEmail(email: string, code: string): Promise; /** - * Send password reset email with token/link + * Send password reset email with token/link. `options` is present when the + * request named an app, so the link can open on that app's site. */ - sendPasswordResetEmail(email: string, token: string): Promise; + sendPasswordResetEmail( + email: string, + token: string, + options?: PasswordResetEmailOptions + ): Promise; /** * Send OTP for passwordless login or 2FA reset */ sendOTPEmail(email: string, otp: number): Promise; + /** + * Send an email sign-in link and code. Required when `features.emailLogin` is on. + */ + sendLoginEmail?(params: LoginEmailParams): Promise; + /** * Send login notification to existing devices */ @@ -43,6 +79,9 @@ export function createNoopEmailAdapter(): EmailAdapter { async sendOTPEmail(email: string, otp: number) { console.debug(`[NoopEmailAdapter] Would send OTP email to ${email} with code ${otp}`); }, + async sendLoginEmail(params: LoginEmailParams) { + console.debug(`[NoopEmailAdapter] Would send a ${params.brand} sign-in email to ${params.to}`); + }, async sendLoginNotification(email: string, browserName: string, ip?: string) { console.debug( `[NoopEmailAdapter] Would send login notification to ${email} from ${browserName} (${ip})` @@ -62,10 +101,11 @@ export function createConsoleEmailAdapter(): EmailAdapter { console.log(`Code: ${code}`); console.log('===========================\n'); }, - async sendPasswordResetEmail(email: string, token: string) { + async sendPasswordResetEmail(email: string, token: string, options?: PasswordResetEmailOptions) { console.log('\n=== EMAIL: Password Reset ==='); console.log(`To: ${email}`); console.log(`Token: ${token}`); + if (options?.resetUrl) console.log(`Link: ${options.resetUrl}`); console.log('=============================\n'); }, async sendOTPEmail(email: string, otp: number) { @@ -74,6 +114,13 @@ export function createConsoleEmailAdapter(): EmailAdapter { console.log(`OTP: ${otp}`); console.log('========================\n'); }, + async sendLoginEmail(params: LoginEmailParams) { + console.log('\n=== EMAIL: Sign-in ==='); + console.log(`To: ${params.to} (${params.brand})`); + console.log(`Code: ${params.code}`); + console.log(`Link: ${params.link}`); + console.log('======================\n'); + }, async sendLoginNotification(email: string, browserName: string, ip?: string) { console.log('\n=== EMAIL: Login Notification ==='); console.log(`To: ${email}`); diff --git a/packages/auth/src/adapters/prismaAdapter.ts b/packages/auth/src/adapters/prismaAdapter.ts index 87efb35..b33ac33 100644 --- a/packages/auth/src/adapters/prismaAdapter.ts +++ b/packages/auth/src/adapters/prismaAdapter.ts @@ -1,16 +1,18 @@ import type { + AuthEmailLoginAttempt, AuthMagicLink, AuthOTP, AuthPasswordReset, AuthSession, AuthUser, + CreateEmailLoginAttemptData, CreateSessionData, CreateUserData, DatabaseAdapter, SessionWithUser, } from './database'; import type { DeviceAuthAdapter, SessionWithDevice } from './deviceAuth'; -import { escapeLikePattern, sameIdentifier } from '../utilities/emailMatch'; +import { escapeLikePattern, hasLikeWildcard, sameIdentifier } from '../utilities/emailMatch'; /** Internal accessor for Prisma model delegates (avoids repeating casts). */ type PrismaDelegate = Record Promise>; @@ -22,9 +24,41 @@ interface PrismaModelAccess { device: PrismaDelegate; admin: PrismaDelegate; magicLink?: PrismaDelegate; + emailLoginAttempt?: PrismaDelegate; $transaction?: (fn: (tx: unknown) => Promise) => Promise; } +/** + * Case-insensitive EXACT lookup on one or more user columns. + * + * Prisma's `mode: 'insensitive'` `equals` is ILIKE on Postgres, where `_` and `%` + * are wildcards: unescaped, `j_hn@outlook.com` finds `john@outlook.com`. So the + * value is escaped first, which is exact under ILIKE. An engine that compiles the + * same filter to `LOWER(col) = LOWER($1)` would never match the escaped form of a + * value that really contains `_` or `%`, so only then is the raw value tried once. + * Every row is compared in code before it is returned, so neither query can hand + * back a different account: under ILIKE the escaped query already found any exact + * row, and under LOWER the raw query is the exact one. + */ +async function findUserInsensitive( + db: PrismaModelAccess, + fields: ReadonlyArray<'email' | 'username'>, + value: string +): Promise { + const query = (equals: string) => { + const clauses = fields.map((field) => ({ [field]: { equals, mode: 'insensitive' } })); + return db.user.findFirst({ + where: clauses.length === 1 ? clauses[0] : { OR: clauses }, + }) as Promise; + }; + const exact = (row: AuthUser | null) => + row && fields.some((field) => sameIdentifier(row[field], value)) ? row : null; + + const escaped = exact(await query(escapeLikePattern(value))); + if (escaped || !hasLikeWildcard(value)) return escaped; + return exact(await query(value)); +} + /** * Creates a core DatabaseAdapter backed by Prisma. * @@ -45,33 +79,17 @@ export function createPrismaAdapter(prisma: unknown): DatabaseAdapter { // `mode: 'insensitive'` equals is ILIKE on Postgres, so the value is escaped // (utilities/emailMatch.ts) and the row is re-checked before it is returned. async findByEmailInsensitive(email: string): Promise { - const user = (await db.user.findFirst({ - where: { email: { equals: escapeLikePattern(email), mode: 'insensitive' } }, - })) as AuthUser | null; - return user && sameIdentifier(user.email, email) ? user : null; + return findUserInsensitive(db, ['email'], email); }, async findByUsernameInsensitive(username: string): Promise { - const user = (await db.user.findFirst({ - where: { username: { equals: escapeLikePattern(username), mode: 'insensitive' } }, - })) as AuthUser | null; - return user && sameIdentifier(user.username, username) ? user : null; + return findUserInsensitive(db, ['username'], username); }, + // One OR over both columns, escaped and re-checked like the single-column + // lookups, so a row matches only when its email or its username is exact. async findByEmailOrUsernameInsensitive(identifier: string): Promise { - const pattern = escapeLikePattern(identifier); - const user = (await db.user.findFirst({ - where: { - OR: [ - { email: { equals: pattern, mode: 'insensitive' } }, - { username: { equals: pattern, mode: 'insensitive' } }, - ], - }, - })) as AuthUser | null; - return user && - (sameIdentifier(user.email, identifier) || sameIdentifier(user.username, identifier)) - ? user - : null; + return findUserInsensitive(db, ['email', 'username'], identifier); }, async findById(id: number): Promise { @@ -337,6 +355,68 @@ export function createPrismaAdapter(prisma: unknown): DatabaseAdapter { data: { usedAt: new Date() }, }) as Promise; }, + + async consume(id: string): Promise { + const now = new Date(); + // Conditional on still being unused, so of two racers only one + // update touches a row. + const { count } = (await db.magicLink!.updateMany({ + where: { id, usedAt: null, expiresAt: { gt: now } }, + data: { usedAt: now }, + })) as { count: number }; + return count === 1; + }, + }, + } + : {}), + + // Only populated when the consumer's Prisma schema includes EmailLoginAttempt + ...(db.emailLoginAttempt + ? { + emailLoginAttempt: { + async create(data: CreateEmailLoginAttemptData): Promise { + return db.emailLoginAttempt!.create({ data }) as Promise; + }, + + async findByTokenHash(tokenHash: string): Promise { + return db.emailLoginAttempt!.findUnique({ + where: { tokenHash }, + }) as Promise; + }, + + async findLatestOpenByEmail(email: string): Promise { + return db.emailLoginAttempt!.findFirst({ + where: { email, consumedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + }) as Promise; + }, + + async consume(id: string): Promise { + const now = new Date(); + // Conditional on still being open, so of two racers only one update + // touches a row. + const { count } = (await db.emailLoginAttempt!.updateMany({ + where: { id, consumedAt: null, expiresAt: { gt: now } }, + data: { consumedAt: now }, + })) as { count: number }; + return count === 1; + }, + + async incrementAttempts(id: string): Promise { + const row = (await db.emailLoginAttempt!.update({ + where: { id }, + data: { attempts: { increment: 1 } }, + select: { attempts: true }, + })) as { attempts: number }; + return row.attempts; + }, + + async consumeOpenByEmail(email: string): Promise { + await db.emailLoginAttempt!.updateMany({ + where: { email, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + }, }, } : {}), diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 980e65b..8247b01 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -4,12 +4,14 @@ export type { ClientCookiePayload, CookieSettings } from './types'; export type { AuthConfig, AuthFeatures, + EmailLoginAppConfig, + EmailLoginConfig, SchemaExtensions, TokenSettings, TwoFaMode, } from './types/config'; -export type { ResolvedAuthConfig } from './utilities/config'; -export type { AuthHooks, PasskeyRegisterInput } from './types/hooks'; +export type { ResolvedAuthConfig, ResolvedEmailLoginConfig } from './utilities/config'; +export type { AuthHooks, LoginPlatform, PasskeyRegisterInput } from './types/hooks'; export type { AuthenticationResponseJSON, PasskeyChallengeType, @@ -35,15 +37,21 @@ export { createOAuthVerifier, OAuthVerificationError } from './utilities/oauth'; export { createAuthGuard } from './middleware/authGuard'; -export type { EmailAdapter } from './adapters/email'; +export type { + EmailAdapter, + LoginEmailParams, + PasswordResetEmailOptions, +} from './adapters/email'; export { createConsoleEmailAdapter, createNoopEmailAdapter } from './adapters/email'; export type { + AuthEmailLoginAttempt, AuthMagicLink, AuthOTP, AuthPasswordReset, AuthSession, AuthUser, + CreateEmailLoginAttemptData, CreateSessionData, CreateUserData, DatabaseAdapter, @@ -62,6 +70,16 @@ export { export { detectBrowser, isMobileDevice, isNativeApp } from './utilities/browser'; export type { CreateMagicLinkParams, CreateMagicLinkResult } from './utilities/magicLink'; export { createMagicLink } from './utilities/magicLink'; +export { + emailLoginPeekLinkSchema, + emailLoginRequestSchema, + emailLoginVerifyCodeSchema, + emailLoginVerifyLinkSchema, + maskEmail, + normalizeLoginEmail, +} from './procedures/emailLogin'; +export type { DeviceStepOutcome, FirstFactor } from './procedures/twoFa/deviceStep'; +export { requiresDeviceStep } from './procedures/twoFa/deviceStep'; export { clearAuthCookie, clearAuthCookies, @@ -130,6 +148,7 @@ export { export { AUTH_REQUIRED_ENV_VARS, AUTH_OAUTH_ENV_VARS, + AUTH_EMAIL_LOGIN_ENV_VARS, AUTH_ALL_SECRET_NAMES, AUTH_DEFAULT_FEATURES, AUTH_CONFIG_SCHEMA, diff --git a/packages/auth/src/procedures/base.ts b/packages/auth/src/procedures/base.ts index d598df7..7b4ff9d 100644 --- a/packages/auth/src/procedures/base.ts +++ b/packages/auth/src/procedures/base.ts @@ -3,9 +3,10 @@ import { z } from 'zod'; import { type ClientCookiePayload } from '../types'; import { type AuthProcedure, type BaseProcedure } from '../types/trpc'; +import { assertCanMintSession } from '../utilities/accountStatus'; import { detectBrowser } from '../utilities/browser'; import { sameIdentifier } from '../utilities/emailMatch'; -import { isTwoFaEnabled, verifyTwoFaChallenge } from './twoFa/verifyChallenge'; +import { runDeviceStep } from './twoFa/deviceStep'; import type { ResolvedAuthConfig } from '../utilities/config'; import { clearAuthCookies, setAuthCookies } from '../utilities/cookies'; import { @@ -107,9 +108,11 @@ export class BaseProcedureFactory< } } - const emailCheck = await this.config.database.user.findByEmailInsensitive(email); + const emailMatch = await this.config.database.user.findByEmailInsensitive(email); + // Exact, so a look-alike address (`j_hn@…` for `john@…`) is not taken for this one. + const emailCheck = emailMatch && sameIdentifier(emailMatch.email, email) ? emailMatch : null; - if (emailCheck && sameIdentifier(emailCheck.email, email)) { + if (emailCheck) { throw new TRPCError({ code: 'CONFLICT', message: 'An account already exists with that email.', @@ -236,42 +239,45 @@ export class BaseProcedureFactory< }); } - if (isTwoFaEnabled(this.config, user) && this.config.features?.twoFa) { - if (!code) { - // Push another device to approve instead of asking for a code. Falls - // back to the typed-code flow when the hook is absent. - if (this.config.hooks?.onLoginApprovalRequired) { - const pending = await this.config.hooks.onLoginApprovalRequired(user.id, { - ip: ctx.ip, - browserName: detectBrowser(userAgent), - input: typedInput, - }); - if (pending) { - return { - success: false, - pendingLogin: true, - pendingLoginId: pending.pendingLoginId, - // So a client holding this user's vault locally can answer the - // challenge itself instead of waiting on another device. - userId: user.id, - requires2FA: true, - }; - } - } - return { - success: false, - requires2FA: true, - userId: user.id, - }; - } - - const valid = await verifyTwoFaChallenge(this.config, user, code); - if (!valid) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Invalid 2FA code.', - }); - } + // Status rules beyond the two above — e.g. a consumer's DELETED grace window — + // after the password, so they tell nothing to someone without it. + await assertCanMintSession(this.config, user, { firstFactor: 'PASSWORD', ip: ctx.ip }); + + // A password is an INBOX factor (email can reset it), so an account with 2FA + // on owes a DEVICE step. The same gate runs at every mint site + // (twoFa/deviceStep.ts); `code` answers it, and a wrong one throws there. + const step = await runDeviceStep(this.config, { + user, + firstFactor: 'PASSWORD', + code, + // Push another device to approve instead of asking for a code. Falls + // back to the typed-code flow when the hook is absent. + askApproval: async () => + this.config.hooks?.onLoginApprovalRequired + ? this.config.hooks.onLoginApprovalRequired(user.id, { + ip: ctx.ip, + browserName: detectBrowser(userAgent), + input: typedInput, + }) + : null, + }); + if (step?.kind === 'pending') { + return { + success: false, + pendingLogin: true, + pendingLoginId: step.pendingLoginId, + // So a client holding this user's vault locally can answer the + // challenge itself instead of waiting on another device. + userId: user.id, + requires2FA: true, + }; + } + if (step?.kind === 'code') { + return { + success: false, + requires2FA: true, + userId: user.id, + }; } // Credentials and 2FA have both passed by here, so a session this device @@ -563,7 +569,17 @@ export class BaseProcedureFactory< private sendPasswordResetEmail() { return this.procedure.input(requestPasswordResetSchema).mutation(async ({ input }) => { - const { email } = input; + const { email, app } = input; + + // Checked before the account lookup, so an unknown key answers the same + // whether or not the address has an account. + // Own keys only, so `constructor` or `__proto__` is not an app. + const apps = this.config.emailLogin?.apps; + const appSettings = + app && apps && Object.prototype.hasOwnProperty.call(apps, app) ? apps[app] : undefined; + if (app && !appSettings) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown app.' }); + } const found = await this.config.database.user.findByEmailInsensitive(email); // A reset link for a look-alike address must never go to another account. @@ -594,7 +610,15 @@ export class BaseProcedureFactory< const passwordReset = await this.config.database.passwordReset.create(user.id); if (this.config.emailService) { - await this.config.emailService.sendPasswordResetEmail(user.email, String(passwordReset.id)); + const token = String(passwordReset.id); + if (app && appSettings) { + await this.config.emailService.sendPasswordResetEmail(user.email, token, { + app, + resetUrl: `${appSettings.siteUrl}${appSettings.resetPath}/${encodeURIComponent(token)}`, + }); + } else { + await this.config.emailService.sendPasswordResetEmail(user.email, token); + } } return { message: 'Password reset email sent.' }; diff --git a/packages/auth/src/procedures/emailLogin.ts b/packages/auth/src/procedures/emailLogin.ts new file mode 100644 index 0000000..744ab29 --- /dev/null +++ b/packages/auth/src/procedures/emailLogin.ts @@ -0,0 +1,583 @@ +/** + * Email sign-in: one email carries a sign-in link and a 6-digit code for the same + * attempt. + * + * - `request` never reveals whether an account exists: the same body, padded to + * the same timing, and a rate-limited request answers the same way. + * - A link and its code are one attempt, consumed once and atomically, by + * whichever arrives first. + * - An email signs into an existing account only if that account proved the + * address. An unproven one may be an account someone else registered in the + * victim's name (the same rule as the OAuth attach fix). + * - Every mint runs the account-status rule (`utilities/accountStatus.ts`) and + * the factor-class gate (`twoFa/deviceStep.ts`). + * - No GET mints a session: `verifyLink` is a mutation the consumer's confirm + * page calls after showing the address, because mail scanners pre-fetch links. + */ +import { createHash, createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'crypto'; + +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import type { AuthEmailLoginAttempt, AuthUser, DatabaseAdapter } from '../adapters/database'; +import { type BaseProcedure, type TrpcContext } from '../types/trpc'; +import { assertCanMintSession } from '../utilities/accountStatus'; +import { detectBrowser } from '../utilities/browser'; +import type { ResolvedAuthConfig, ResolvedEmailLoginConfig } from '../utilities/config'; +import { createUserWithFreshUsername, uniqueViolationField } from '../utilities/createUser'; +import { sameIdentifier } from '../utilities/emailMatch'; +import { + carryDeviceTwoFaSecret, + issueAuthCookies, + revokeDeviceSessionsForUser, +} from '../utilities/issueCookies'; +import { requiresDeviceStep, runDeviceStep } from './twoFa/deviceStep'; + +/** Wrong codes an attempt survives; the fifth one spends it. */ +const MAX_CODE_TRIES = 5; +/** Every limit counts over the same window an attempt lives. */ +const LIMIT_WINDOW_SEC = 15 * 60; +/** Requests one address may make from one IP. */ +const REQUESTS_PER_EMAIL_AND_IP = 3; +/** + * Requests one address may receive from every IP together. Higher than the + * per-IP pair limit, so a stranger's requests from one IP cannot use up the + * owner's; it caps how much mail anyone can aim at one inbox. + */ +const REQUESTS_PER_EMAIL = 10; +const REQUESTS_PER_IP = 10; +const VERIFIES_PER_IP = 20; +const TOKEN_BYTES = 32; +const CODE_SPACE = 1_000_000; +const CODE_DIGITS = 6; + +const CODE_DID_NOT_WORK = 'That code did not work. Check it and try again.'; +const LINK_EXPIRED = 'That link has expired. Ask for a new one.'; +const EMAIL_NOT_CONFIRMED = 'Sign in another way, then confirm this email in your account.'; +const TOO_MANY_TRIES = 'Too many tries. Wait a few minutes and try again.'; + +type AttemptStore = NonNullable; + +/** Trim and lowercase, so one inbox is one rate-limit key and one attempt chain. */ +export function normalizeLoginEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** sha256 of the link token — the only form of it that is stored. */ +export function hashLoginToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * HMAC of the code, bound to its attempt. A plain hash of a 6-digit code falls + * to a million guesses offline; the pepper is what a database leak does not have. + */ +export function hashLoginCode(pepper: string, attemptId: string, code: string): string { + return createHmac('sha256', pepper).update(`${attemptId}:${code}`).digest('hex'); +} + +function sameHash(a: string, b: string): boolean { + const left = Buffer.from(a, 'hex'); + const right = Buffer.from(b, 'hex'); + return left.length === right.length && timingSafeEqual(left, right); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const platformSchema = z.enum(['ios', 'android', 'web']); + +/** What a client sends to answer, or be offered, the second step. */ +const deviceStepFields = { + /** TOTP or backup code, for an account with 2FA on. */ + twoFaCode: z.string().max(64).optional(), + /** Binds a push approval to this client; handed to `onDeviceStepRequired`. */ + approvalNonce: z.string().max(256).optional(), + /** Lets the consumer leave this phone out of its own approval targets. */ + devicePushToken: z.string().max(512).optional(), + platform: platformSchema.optional(), +}; + +export const emailLoginRequestSchema = z.object({ + email: z.string().trim().email().max(254), + app: z.string().min(1).max(64), + platform: platformSchema.optional(), +}); + +export const emailLoginVerifyLinkSchema = z.object({ + token: z.string().min(1).max(256), + ...deviceStepFields, +}); + +export const emailLoginVerifyCodeSchema = z.object({ + email: z.string().trim().email().max(254), + code: z.string().regex(/^\d{6}$/), + ...deviceStepFields, +}); + +export const emailLoginPeekLinkSchema = z.object({ + token: z.string().min(1).max(256), +}); + +/** + * `ada@example.com` → `a•••@example.com`: enough for the owner to recognise the + * address on a confirm screen, and it hides a `+tag`. Only ever shown to whoever + * holds the link, who already received the email. + */ +export function maskEmail(email: string): string { + const at = email.lastIndexOf('@'); + if (at <= 0) return '•••'; + return `${email[0]}•••${email.slice(at)}`; +} + +type DeviceStepInput = { + twoFaCode?: string; + approvalNonce?: string; + devicePushToken?: string; + platform?: z.infer; +}; + +/** Confirm-screen lookups one IP may make; past it every link reads as not valid. */ +const PEEKS_PER_IP = 30; + +type PeekLinkResult = { valid: true; maskedEmail: string } | { valid: false }; + +/** Factory for `auth.emailLogin.*`. */ +export class EmailLoginProcedureFactory { + constructor( + private config: ResolvedAuthConfig, + private procedure: BaseProcedure + ) {} + + createEmailLoginProcedures() { + return { + request: this.request(), + peekLink: this.peekLink(), + verifyLink: this.verifyLink(), + verifyCode: this.verifyCode(), + }; + } + + /** + * The masked address of an open link, for the confirm screen that names it + * before anyone signs in. A mutation, so a mail scanner's pre-fetch never calls + * it. It spends nothing and says nothing about accounts: the address is the + * attempt's own, which whoever holds the token already received. + */ + private peekLink() { + return this.procedure + .input(emailLoginPeekLinkSchema) + .mutation(async ({ ctx, input }): Promise => { + const { emailLogin, attempts } = this.settings(); + const allowed = await emailLogin.rateLimit( + `emailLogin:peek:ip:${ctx.ip ?? 'unknown'}`, + PEEKS_PER_IP, + LIMIT_WINDOW_SEC + ); + if (!allowed) return { valid: false }; + + const attempt = await attempts.findByTokenHash(hashLoginToken(input.token)); + if (!attempt || attempt.consumedAt || attempt.expiresAt <= new Date()) { + return { valid: false }; + } + return { valid: true, maskedEmail: maskEmail(attempt.email) }; + }); + } + + /** The resolved settings and attempt store, or NOT_FOUND while the feature is off. */ + private settings(): { emailLogin: ResolvedEmailLoginConfig; attempts: AttemptStore } { + if (!this.config.features.emailLogin) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + const { emailLogin } = this.config; + const attempts = this.config.database.emailLoginAttempt; + // createAuthConfig refuses to start without either; this is the net for a + // hand-built ResolvedAuthConfig. + if (!emailLogin || !attempts) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Email login is not configured', + }); + } + return { emailLogin, attempts }; + } + + /** The account a verified address belongs to — exactly that address, never a look-alike. */ + private async accountFor(email: string): Promise { + const found = await this.config.database.user.findByEmailInsensitive(email); + // A lookup is only as exact as its adapter. The address the inbox proved is + // the one that has to be on the account. + return found && sameIdentifier(found.email, email) ? found : null; + } + + private request() { + return this.procedure.input(emailLoginRequestSchema).mutation(async ({ ctx, input }) => { + const { emailLogin, attempts } = this.settings(); + + // A config error, not an oracle: it answers the same for every address. + // Own keys only, so `constructor` or `__proto__` is not an app. + const app = Object.prototype.hasOwnProperty.call(emailLogin.apps, input.app) + ? emailLogin.apps[input.app] + : undefined; + if (!app) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown app.' }); + } + + const startedAt = Date.now(); + try { + const email = normalizeLoginEmail(input.email); + const ip = ctx.ip ?? 'unknown'; + // Each limit is asked only when the one before it passed, so a refused + // request does not also spend the wider budgets. + const allowed = + (await emailLogin.rateLimit( + `emailLogin:request:emailip:${email}:${ip}`, + REQUESTS_PER_EMAIL_AND_IP, + LIMIT_WINDOW_SEC + )) && + (await emailLogin.rateLimit( + `emailLogin:request:ip:${ip}`, + REQUESTS_PER_IP, + LIMIT_WINDOW_SEC + )) && + (await emailLogin.rateLimit( + `emailLogin:request:email:${email}`, + REQUESTS_PER_EMAIL, + LIMIT_WINDOW_SEC + )); + + // Rate-limited: send nothing, and say nothing different about it. + if (allowed) { + const user = await this.accountFor(email); + + // A newer request replaces every open one, so an email the user did + // not act on stops working the moment they ask again. + await attempts.consumeOpenByEmail(email); + + const id = randomUUID(); + const token = randomBytes(TOKEN_BYTES).toString('base64url'); + const code = String(randomInt(0, CODE_SPACE)).padStart(CODE_DIGITS, '0'); + const expiresAt = new Date(Date.now() + emailLogin.ttlMs); + + await attempts.create({ + id, + email, + userId: user?.id ?? null, + app: input.app, + tokenHash: hashLoginToken(token), + codeHash: hashLoginCode(emailLogin.pepper, id, code), + expiresAt, + }); + + // Not awaited. The mail provider's latency would make an allowed request + // slower than a rate-limited one, and its errors differ by address (a + // suppressed or bouncing inbox fails where a good one does not) — neither + // may reach the response. + void emailLogin + .sendLoginEmail({ + to: email, + app: input.app, + brand: app.brand, + link: `${app.siteUrl}${app.verifyPath}?token=${encodeURIComponent(token)}`, + code, + expiresAt, + }) + .catch((err: unknown) => this.logSendFailure(err, ctx)); + } + } finally { + // Pad every outcome to one floor, so an address with an account does not + // answer faster or slower than one without. + const elapsed = Date.now() - startedAt; + if (elapsed < emailLogin.responseFloorMs) { + await sleep(emailLogin.responseFloorMs - elapsed); + } + } + + return { sent: true as const }; + }); + } + + private logSendFailure(err: unknown, ctx: TrpcContext) { + const error = err instanceof Error ? err : new Error(String(err)); + if (this.config.hooks?.logError) { + void this.config.hooks + .logError({ + type: 'OTHER', + description: `emailLogin: the sign-in email could not be sent: ${error.message}`, + stack: error.stack ?? '', + ip: ctx.ip, + }) + .catch(() => undefined); + return; + } + console.error('@factiii/auth: the sign-in email could not be sent', error); + } + + private verifyLink() { + return this.procedure.input(emailLoginVerifyLinkSchema).mutation(async ({ ctx, input }) => { + const { emailLogin, attempts } = this.settings(); + await this.limitVerify(emailLogin, ctx); + + const attempt = await attempts.findByTokenHash(hashLoginToken(input.token)); + if (!attempt || attempt.consumedAt || attempt.expiresAt <= new Date()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: LINK_EXPIRED }); + } + + return this.complete(ctx, attempt, input, LINK_EXPIRED); + }); + } + + private verifyCode() { + return this.procedure.input(emailLoginVerifyCodeSchema).mutation(async ({ ctx, input }) => { + const { emailLogin, attempts } = this.settings(); + await this.limitVerify(emailLogin, ctx); + + // Only the newest open attempt can match: a newer request spent the rest. + const attempt = await attempts.findLatestOpenByEmail(normalizeLoginEmail(input.email)); + if (!attempt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: CODE_DID_NOT_WORK }); + } + + // Counted before the comparison, so parallel guesses cannot all slip in + // under the limit while each one reads the old count. + const tries = await attempts.incrementAttempts(attempt.id); + if (tries > MAX_CODE_TRIES) { + await attempts.consume(attempt.id); + throw new TRPCError({ code: 'BAD_REQUEST', message: CODE_DID_NOT_WORK }); + } + + const expected = hashLoginCode(emailLogin.pepper, attempt.id, input.code); + if (!sameHash(expected, attempt.codeHash)) { + if (tries >= MAX_CODE_TRIES) { + await attempts.consume(attempt.id); + } + throw new TRPCError({ code: 'BAD_REQUEST', message: CODE_DID_NOT_WORK }); + } + + return this.complete(ctx, attempt, input, CODE_DID_NOT_WORK); + }); + } + + private async limitVerify(emailLogin: ResolvedEmailLoginConfig, ctx: TrpcContext) { + const allowed = await emailLogin.rateLimit( + `emailLogin:verify:ip:${ctx.ip ?? 'unknown'}`, + VERIFIES_PER_IP, + LIMIT_WINDOW_SEC + ); + if (!allowed) { + throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: TOO_MANY_TRIES }); + } + } + + /** + * Resolve the account, run the status rule and the gate, spend the attempt, and + * mint. `spentMessage` is what a caller sees when another request spent the + * attempt first — the same words the link or the code already uses for "no + * longer valid". + */ + private async complete( + ctx: TrpcContext, + attempt: AuthEmailLoginAttempt, + input: DeviceStepInput, + spentMessage: string + ) { + const { attempts } = this.settings(); + const browserName = detectBrowser(ctx.headers['user-agent'] ?? ''); + const existing = await this.accountFor(attempt.email); + + if (existing) { + if (existing.emailVerificationStatus !== 'VERIFIED') { + await attempts.consume(attempt.id); + throw new TRPCError({ code: 'BAD_REQUEST', message: EMAIL_NOT_CONFIRMED }); + } + + // Before the second step and before any push: a refused account is told + // so without ringing a device, and the attempt goes with it. + try { + await assertCanMintSession(this.config, existing, { + firstFactor: 'EMAIL_LOGIN', + ip: ctx.ip, + }); + } catch (err) { + await attempts.consume(attempt.id); + throw err; + } + + const account = existing; + const step = await runDeviceStep(this.config, { + user: account, + firstFactor: 'EMAIL_LOGIN', + code: input.twoFaCode, + askApproval: async () => + this.config.hooks?.onDeviceStepRequired + ? this.config.hooks.onDeviceStepRequired(account.id, { + ip: ctx.ip, + browserName, + firstFactor: 'EMAIL_LOGIN', + input: { + app: attempt.app, + platform: input.platform, + approvalNonce: input.approvalNonce, + devicePushToken: input.devicePushToken, + }, + }) + : null, + // Wrong second-step codes spend the attempt, and two requests racing on + // it push the device once. + guard: { + credentialKey: `emailLogin:${attempt.id}`, + spend: () => attempts.consume(attempt.id), + lockApproval: true, + }, + }); + + if (step?.kind === 'pending') { + // Approval finishes in the consumer's pending-login flow and never comes + // back here, so the attempt is spent now. + if (!(await attempts.consume(attempt.id))) { + throw new TRPCError({ code: 'BAD_REQUEST', message: spentMessage }); + } + return { + success: false, + pendingLogin: true, + pendingLoginId: step.pendingLoginId, + userId: account.id, + requires2FA: true, + }; + } + if (step?.kind === 'code') { + // Left unspent, so the same link or code can come back with `twoFaCode`. + return { + success: false, + requires2FA: true, + userId: account.id, + }; + } + } + + // The single point where the attempt is spent for a sign-in. Of two requests + // racing on one attempt, only one gets past here. + if (!(await attempts.consume(attempt.id))) { + throw new TRPCError({ code: 'BAD_REQUEST', message: spentMessage }); + } + + const { user, created } = existing + ? { user: existing, created: false } + : await this.createAccount(ctx, attempt, input, spentMessage); + + // Every account reaching here proved this address just now. The branches + // above already require VERIFIED; kept explicit so a future branch cannot + // sign into an unproven address without also proving it. + if (user.emailVerificationStatus !== 'VERIFIED') { + await this.config.database.user.update(user.id, { emailVerificationStatus: 'VERIFIED' }); + } + + // The inbox has been proven, so a session this device already holds for the + // same account is stale, not a reason to refuse. + const replacedSessionIds = await revokeDeviceSessionsForUser( + this.config, + ctx.headers.cookie, + user.id + ); + + const extraSessionData = this.config.hooks?.getEmailLoginSessionData + ? await this.config.hooks.getEmailLoginSessionData(user.id, { + app: attempt.app, + platform: input.platform, + }) + : {}; + + const session = await this.config.database.session.create({ + userId: user.id, + browserName, + socketId: null, + ...extraSessionData, + }); + + // Same rule as the other sign-in paths: the device keeps the second factor it + // already had. + await carryDeviceTwoFaSecret(this.config, { + userId: user.id, + revokedSessionIds: replacedSessionIds, + newSessionId: session.id, + }); + + if (this.config.hooks?.onUserLogin) { + await this.config.hooks.onUserLogin(user.id, session.id); + } + + await issueAuthCookies(this.config, { + ctx, + session, + updatedAt: user.updatedAt, + verifiedHumanAt: user.verifiedHumanAt ?? null, + }); + + return { + success: true, + created, + user: { id: user.id, email: user.email, username: user.username }, + }; + } + + /** First verify for an address with no account: create it, already proven. */ + private async createAccount( + ctx: TrpcContext, + attempt: AuthEmailLoginAttempt, + input: DeviceStepInput, + spentMessage: string + ): Promise<{ user: AuthUser; created: boolean }> { + let user: AuthUser; + try { + // A taken generated username is retried inside; what comes out of here is + // an email violation, another failure, or no free username at all. + user = await createUserWithFreshUsername( + this.config, + { + email: attempt.email, + password: null, + status: 'ACTIVE', + tag: this.config.features.biometric ? 'BOT' : 'HUMAN', + emailVerificationStatus: 'VERIFIED', + verifiedHumanAt: null, + }, + { ip: ctx.ip } + ); + } catch (err) { + // Only a lost race is recovered here; any other failure is a real one. An + // unnamed violation only comes out of the helper when an account already + // holds this address, so it is the email index too. + const field = uniqueViolationField(err); + if (field !== 'email' && field !== 'unknown') throw err; + // Two attempts for one inbox can finish together, and the unique email + // index lets only one insert land. Both proved the address, so the loser + // signs into the account the winner made — but only a proven account that + // owes no second step, because this attempt is already spent and cannot + // pause for one. + const winner = await this.accountFor(attempt.email); + if ( + winner && + winner.emailVerificationStatus === 'VERIFIED' && + !requiresDeviceStep(this.config, winner, 'EMAIL_LOGIN') + ) { + await assertCanMintSession(this.config, winner, { firstFactor: 'EMAIL_LOGIN', ip: ctx.ip }); + return { user: winner, created: false }; + } + if (winner) { + throw new TRPCError({ code: 'BAD_REQUEST', message: spentMessage }); + } + throw err; + } + + // Outside the recovery above: a provisioning failure is not a lost race, and + // an account nobody provisioned must not get a session. + if (this.config.hooks?.onEmailLoginUserCreated) { + await this.config.hooks.onEmailLoginUserCreated(user.id, { + email: attempt.email, + app: attempt.app, + platform: input.platform, + }); + } + await assertCanMintSession(this.config, user, { firstFactor: 'EMAIL_LOGIN', ip: ctx.ip }); + return { user, created: true }; + } +} diff --git a/packages/auth/src/procedures/magicLink.ts b/packages/auth/src/procedures/magicLink.ts index 94e3dee..b74d9b1 100644 --- a/packages/auth/src/procedures/magicLink.ts +++ b/packages/auth/src/procedures/magicLink.ts @@ -2,9 +2,14 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { type BaseProcedure } from '../types/trpc'; +import { assertCanMintSession } from '../utilities/accountStatus'; +import { detectBrowser } from '../utilities/browser'; import type { ResolvedAuthConfig } from '../utilities/config'; import { carryDeviceTwoFaSecret, revokeDeviceSessionsForUser } from '../utilities/issueCookies'; import { createSessionWithTokenAndCookie } from '../utilities/session'; +import { runDeviceStep } from './twoFa/deviceStep'; + +const INVALID_LINK = 'This link has expired or is invalid'; /** Factory for magic link authentication procedures. */ export class MagicLinkProcedureFactory { @@ -31,9 +36,31 @@ export class MagicLinkProcedureFactory { } } + /** + * Spend the link. `consume` is one conditional write, so it is true for exactly + * one caller. An adapter written before it existed falls back to + * read-then-`markUsed`, which is not atomic — the `usedAt` check in + * `verifyMagicLink` is then the only guard, as it always was. + */ + private async consume(id: string): Promise { + const db = this.config.database.magicLink!; + if (db.consume) return db.consume(id); + await db.markUsed(id); + return true; + } + private verifyMagicLink() { return this.procedure - .input(z.object({ token: z.string() })) + .input( + z.object({ + token: z.string(), + // Second step for an account with 2FA on: a TOTP or backup code. + twoFaCode: z.string().max(64).optional(), + approvalNonce: z.string().max(256).optional(), + devicePushToken: z.string().max(512).optional(), + platform: z.enum(['ios', 'android', 'web']).optional(), + }) + ) .mutation(async ({ ctx, input }) => { this.checkConfig(); const db = this.config.database.magicLink!; @@ -43,17 +70,90 @@ export class MagicLinkProcedureFactory { if (!magicLink || magicLink.usedAt) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'This link has expired or is invalid', + message: INVALID_LINK, }); } if (magicLink.expiresAt < new Date()) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'This link has expired or is invalid', + message: INVALID_LINK, + }); + } + + const user = await this.config.database.user.findById(magicLink.userId); + if (!user) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: INVALID_LINK, }); } + // A link names an account by id, so nothing on the way here looked at its + // status: a deactivated or banned account, or one the consumer refuses + // (`beforeSessionMint`), stops before the second step and any push. + await assertCanMintSession(this.config, user, { firstFactor: 'MAGIC_LINK', ip: ctx.ip }); + + const userAgent = (ctx.headers as Record)?.['user-agent']; + + // A magic link proves the inbox, an INBOX factor. An account with 2FA on + // owes a DEVICE step before it gets a session — this path used to skip it. + const step = await runDeviceStep(this.config, { + user, + firstFactor: 'MAGIC_LINK', + code: input.twoFaCode, + askApproval: async () => + this.config.hooks?.onDeviceStepRequired + ? this.config.hooks.onDeviceStepRequired(user.id, { + ip: ctx.ip, + browserName: detectBrowser(userAgent ?? ''), + firstFactor: 'MAGIC_LINK', + input: { + platform: input.platform, + approvalNonce: input.approvalNonce, + devicePushToken: input.devicePushToken, + }, + }) + : null, + // A link can live for days, so wrong second-step codes spend it, and two + // requests racing on it push the device once. + guard: { + credentialKey: `magicLink:${magicLink.id}`, + spend: () => this.consume(magicLink.id), + lockApproval: true, + }, + }); + + if (step?.kind === 'pending') { + // Approval finishes in the consumer's pending-login flow and never comes + // back here, so the link is spent now. + if (!(await this.consume(magicLink.id))) { + throw new TRPCError({ code: 'BAD_REQUEST', message: INVALID_LINK }); + } + return { + success: false, + pendingLogin: true, + pendingLoginId: step.pendingLoginId, + userId: user.id, + requires2FA: true, + }; + } + if (step?.kind === 'code') { + // Left unspent, so the same link can come back with `twoFaCode`. + return { + success: false, + requires2FA: true, + userId: user.id, + }; + } + + // Mark as used (single-use). Atomic, and before anything else changes: + // of two requests racing on one link, the loser stops here without + // retiring this device's sessions. + if (!(await this.consume(magicLink.id))) { + throw new TRPCError({ code: 'BAD_REQUEST', message: INVALID_LINK }); + } + // The link proves control of the address, so a session this device // already holds for the same account is stale, not a reason to refuse. const replacedSessionIds = await revokeDeviceSessionsForUser( @@ -62,10 +162,7 @@ export class MagicLinkProcedureFactory { magicLink.userId ); - // Mark as used (single-use) - await db.markUsed(magicLink.id); - - const browserName = (ctx.headers as Record)?.['user-agent'] ?? 'Unknown'; + const browserName = userAgent ?? 'Unknown'; // Let the host app inject extra session data (e.g., instanceId) const extraSessionData = this.config.hooks?.onBeforeMagicLinkSession diff --git a/packages/auth/src/procedures/oauth.ts b/packages/auth/src/procedures/oauth.ts index 48e9a14..02e3948 100644 --- a/packages/auth/src/procedures/oauth.ts +++ b/packages/auth/src/procedures/oauth.ts @@ -1,20 +1,24 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import type { AuthUser } from '../adapters/database'; import type { UsernameMode } from '../types/config'; import type { SchemaExtensions } from '../types/hooks'; import { type AuthProcedure, type BaseProcedure } from '../types/trpc'; import { detectBrowser } from '../utilities'; +import { assertCanMintSession } from '../utilities/accountStatus'; import type { ResolvedAuthConfig } from '../utilities/config'; +import { createUserWithFreshUsername } from '../utilities/createUser'; +import { sameIdentifier } from '../utilities/emailMatch'; import { carryDeviceTwoFaSecret, issueAuthCookies, revokeDeviceSessionsForUser, } from '../utilities/issueCookies'; -import { sameIdentifier } from '../utilities/emailMatch'; import { assertKeepsLoginMethod } from '../utilities/loginMethods'; import { createOAuthVerifier, type OAuthProvider, type OAuthResult } from '../utilities/oauth'; import { type CreatedSchemas, type OAuthSchemaInput } from '../validators'; +import { runDeviceStep } from './twoFa/deviceStep'; const providerEnum = z.enum(['GOOGLE', 'APPLE']); @@ -61,6 +65,38 @@ export class OAuthLoginProcedureFactory< return this.verifyOAuthToken; } + /** The factor-class gate for an OAuth sign-in into `user`. */ + private deviceStep( + ip: string | undefined, + user: AuthUser, + input: OAuthSchemaInput, + userAgent: string, + oauthId: string + ) { + return runDeviceStep(this.config, { + user, + firstFactor: 'OAUTH', + code: input.twoFaCode, + askApproval: async () => { + if (!this.config.hooks?.onDeviceStepRequired) return null; + // Everything the client sent except the provider token, which the hook + // has no use for and should never be handed. + const approvalInput: Record = { ...input }; + delete approvalInput.idToken; + return this.config.hooks.onDeviceStepRequired(user.id, { + ip, + browserName: detectBrowser(userAgent), + firstFactor: 'OAUTH', + input: approvalInput, + }); + }, + // A provider token cannot be revoked from here, so nothing is spent; the + // account-wide cap on second-step codes is what bounds guessing. No push + // lock: each OAuth sign-in is its own ceremony with a fresh token. + guard: { credentialKey: `oauth:${input.provider}:${oauthId}` }, + }); + } + private oAuthLogin(schema: CreatedSchemas['oauth']) { return this.procedure.input(schema).mutation(async ({ ctx, input }) => { this.checkConfig(); @@ -91,6 +127,12 @@ export class OAuthLoginProcedureFactory< throw new TRPCError({ code: 'FORBIDDEN', message: 'This account is not available.' }); } + // Set once the factor-class gate has run for this sign-in, so the attach + // branch and the final check below never ask twice. + let deviceStepDone = false; + // Set once the account-status rule has run, for the same reason. + let statusChecked = false; + // 2. New identity: attach it to an existing passwordless account with the // same email, else create one — then record the link. if (!user) { @@ -127,18 +169,48 @@ export class OAuthLoginProcedureFactory< let created = false; if (existing) { + // Status before anything is attached: a refused account must not gain a + // provider link on the way to being refused. + await assertCanMintSession(this.config, existing, { firstFactor: 'OAUTH', ip: ctx.ip }); + statusChecked = true; + + // An account with 2FA on owes its DEVICE step before a new provider is + // attached to it. Otherwise whoever holds the Google or Apple account + // gains a standing way in that never passed the second factor. + const step = await this.deviceStep(ctx.ip, existing, typedInput, userAgent, oauthId); + if (step?.kind === 'pending') { + return { + success: false, + pendingLogin: true, + pendingLoginId: step.pendingLoginId, + userId: existing.id, + requires2FA: true, + }; + } + if (step?.kind === 'code') { + return { + success: false, + requires2FA: true, + userId: existing.id, + }; + } + deviceStepDone = true; user = existing; } else { - const generateUsername = this.config.generateUsername ?? (() => `user_${Date.now()}`); - user = await this.config.database.user.create({ - username: generateUsername(), - email, - password: null, - emailVerificationStatus: 'VERIFIED', - status: 'ACTIVE', - tag: this.config.features.biometric ? 'BOT' : 'HUMAN', - verifiedHumanAt: null, - }); + // A taken generated username gets a fresh one; any other unique + // violation is not recoverable here and propagates. + user = await createUserWithFreshUsername( + this.config, + { + email, + password: null, + emailVerificationStatus: 'VERIFIED', + status: 'ACTIVE', + tag: this.config.features.biometric ? 'BOT' : 'HUMAN', + verifiedHumanAt: null, + }, + { ip: ctx.ip } + ); created = true; } @@ -156,12 +228,33 @@ export class OAuthLoginProcedureFactory< } } - if (user.status === 'DEACTIVATED') { - throw new TRPCError({ code: 'FORBIDDEN', message: 'Your account has been deactivated.' }); + // A linked identity, or a brand-new account, reaches here unchecked: + // deactivated, banned, and the consumer's own rules (`beforeSessionMint`). + if (!statusChecked) { + await assertCanMintSession(this.config, user, { firstFactor: 'OAUTH', ip: ctx.ip }); } - if (user.status === 'BANNED') { - throw new TRPCError({ code: 'FORBIDDEN', message: 'Your account has been banned.' }); + // A linked identity reaches here without having been asked. Google or Apple + // is an INBOX or FEDERATED factor, never DEVICE, so an account with 2FA on + // still owes the step. A brand-new account has no 2FA, so this is a no-op. + if (!deviceStepDone) { + const step = await this.deviceStep(ctx.ip, user, typedInput, userAgent, oauthId); + if (step?.kind === 'pending') { + return { + success: false, + pendingLogin: true, + pendingLoginId: step.pendingLoginId, + userId: user.id, + requires2FA: true, + }; + } + if (step?.kind === 'code') { + return { + success: false, + requires2FA: true, + userId: user.id, + }; + } } // The provider has vouched for this identity, so a session this device diff --git a/packages/auth/src/procedures/passkey.ts b/packages/auth/src/procedures/passkey.ts index 693074d..a379984 100644 --- a/packages/auth/src/procedures/passkey.ts +++ b/packages/auth/src/procedures/passkey.ts @@ -15,6 +15,7 @@ import type { SchemaExtensions } from '../types/hooks'; import type { AnyZodObject } from '../types/zod'; import { type AuthProcedure, type BaseProcedure } from '../types/trpc'; import { detectBrowser } from '../utilities'; +import { assertCanMintSession } from '../utilities/accountStatus'; import type { ResolvedAuthConfig } from '../utilities/config'; import { issueAuthCookies, isUserInBundle } from '../utilities/issueCookies'; import { assertKeepsLoginMethod } from '../utilities/loginMethods'; @@ -409,6 +410,11 @@ export class PasskeyProcedureFactory< }); } + // A passkey needs no second step, but it is still a sign-in: a deactivated or + // banned account, or one the consumer refuses (`beforeSessionMint`), gets no + // session. This path used to check no status at all. + await assertCanMintSession(this.config, user, { firstFactor: 'PASSKEY', ip: ctx.ip }); + if (await isUserInBundle(this.config, ctx.headers.cookie, user.id)) { throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/packages/auth/src/procedures/twoFa/deviceStep.ts b/packages/auth/src/procedures/twoFa/deviceStep.ts new file mode 100644 index 0000000..0b9869c --- /dev/null +++ b/packages/auth/src/procedures/twoFa/deviceStep.ts @@ -0,0 +1,154 @@ +/** + * The factor-class gate every sign-in path runs before it mints a session. + * + * 2FA means two DIFFERENT factor classes, not two items. INBOX is a verified + * email, an email link or code, a password (email can reset it), and Google or + * Apple with the same email. DEVICE is a user-verified passkey, TOTP, or push + * approval from a signed-in device. FEDERATED is Google or Apple with a + * different email. An account with 2FA on must present a DEVICE factor, so + * every first factor that is not already one owes the second step. + * + * Before this existed only password login asked for it: a magic link or an + * OAuth sign-in into a 2FA account went straight through. One function, called + * from every mint site, is how that stays closed. + */ +import { TRPCError } from '@trpc/server'; + +import type { AuthUser } from '../../adapters/database'; +import type { ResolvedAuthConfig } from '../../utilities/config'; +import { isTwoFaEnabled, verifyTwoFaChallenge } from './verifyChallenge'; + +/** How a sign-in proved itself before any second step. */ +export type FirstFactor = 'PASSWORD' | 'EMAIL_LOGIN' | 'MAGIC_LINK' | 'OAUTH' | 'PASSKEY'; + +/** + * First factors that are already in the DEVICE class. A passkey qualifies only + * because the passkey procedures require user verification at registration and + * at authentication — possession plus inherence. + */ +const DEVICE_CLASS: ReadonlySet = new Set(['PASSKEY']); + +/** Wrong second-step codes one first-factor credential absorbs; the last one spends it. */ +export const MAX_SECOND_STEP_FAILURES = 5; +/** Second-step codes one account may be sent per window, across every IP and credential. */ +export const SECOND_STEP_CODES_PER_USER = 10; +const SECOND_STEP_WINDOW_SEC = 15 * 60; + +const TOO_MANY_CODES = 'Too many tries. Wait a few minutes and try again.'; + +/** True when this account must still present a DEVICE factor after `firstFactor`. */ +export function requiresDeviceStep( + config: ResolvedAuthConfig, + user: AuthUser, + firstFactor: FirstFactor +): boolean { + if (DEVICE_CLASS.has(firstFactor)) return false; + return Boolean(config.features.twoFa) && isTwoFaEnabled(config, user); +} + +/** + * What the caller returns instead of a session. Call sites build their own + * response literal from it, so each procedure's inferred output keeps exactly the + * shape password login has always returned. + */ +export type DeviceStepOutcome = { kind: 'pending'; pendingLoginId: string } | { kind: 'code' }; + +/** + * Bounds on guessing the second step. A wrong code leaves the first factor usable + * on purpose, so without these an attacker who holds the first factor — the inbox, + * a magic link, a provider token — could rotate IPs and walk TOTP space against + * one credential. + * + * Counts go through `emailLogin.rateLimit`, the consumer's limiter. A path with no + * limiter configured keeps its old unbounded behaviour, except email sign-in, + * which requires one. + */ +export interface DeviceStepGuard { + /** Names the first-factor credential, e.g. `emailLogin:`. */ + credentialKey: string; + /** Retire the credential once it has absorbed MAX_SECOND_STEP_FAILURES wrong codes. */ + spend?: () => Promise; + /** + * Ask for push approval at most once per credential, so two requests racing on + * one link or attempt cannot each push a device. The later one gets the typed- + * code step instead. + */ + lockApproval?: boolean; +} + +/** + * Run the gate. `null` means the sign-in may mint now: either no second step is + * owed, or `code` answered it. A wrong `code` throws. With no code, the account + * is offered push approval through `askApproval` and falls back to the + * typed-code step when that returns nothing. + */ +export async function runDeviceStep( + config: ResolvedAuthConfig, + params: { + user: AuthUser; + firstFactor: FirstFactor; + code: string | undefined; + askApproval: () => Promise<{ pendingLoginId: string } | null>; + guard?: DeviceStepGuard; + } +): Promise { + const { user, firstFactor, code, askApproval, guard } = params; + if (!requiresDeviceStep(config, user, firstFactor)) return null; + + const limit = guard ? config.emailLogin?.rateLimit : undefined; + if (guard && !limit && firstFactor === 'EMAIL_LOGIN') { + // createAuthConfig requires the limiter for email sign-in; this is the net for + // a hand-built config, and an unbounded second step is not a degraded mode. + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Email login is not configured', + }); + } + + if (code) { + if ( + limit && + !(await limit( + `twoFa:code:user:${user.id}`, + SECOND_STEP_CODES_PER_USER, + SECOND_STEP_WINDOW_SEC + )) + ) { + throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: TOO_MANY_CODES }); + } + + const valid = await verifyTwoFaChallenge(config, user, code); + if (!valid) { + // The limiter allows `max` calls, so a max one short of the failure budget + // refuses exactly on the last wrong code — which is when the credential goes. + if ( + limit && + guard && + !(await limit( + `twoFa:fail:${guard.credentialKey}`, + MAX_SECOND_STEP_FAILURES - 1, + SECOND_STEP_WINDOW_SEC + )) + ) { + await guard.spend?.(); + } + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Invalid 2FA code.', + }); + } + return null; + } + + if ( + limit && + guard?.lockApproval && + !(await limit(`twoFa:approval:${guard.credentialKey}`, 1, SECOND_STEP_WINDOW_SEC)) + ) { + return { kind: 'code' }; + } + + const pending = await askApproval(); + if (pending) return { kind: 'pending', pendingLoginId: pending.pendingLoginId }; + return { kind: 'code' }; +} diff --git a/packages/auth/src/router.ts b/packages/auth/src/router.ts index cce6c67..623749c 100644 --- a/packages/auth/src/router.ts +++ b/packages/auth/src/router.ts @@ -4,6 +4,7 @@ import type { DeviceAuthAdapter } from './adapters/deviceAuth'; import { createAuthGuard } from './middleware/authGuard'; import { BaseProcedureFactory } from './procedures/base'; import { BiometricProcedureFactory } from './procedures/biometric'; +import { EmailLoginProcedureFactory } from './procedures/emailLogin'; import { EmailVerificationProcedureFactory } from './procedures/emailVerification'; import { MagicLinkProcedureFactory } from './procedures/magicLink'; import { MultiAccountProcedureFactory } from './procedures/multiAccount'; @@ -77,6 +78,10 @@ class AuthScaffold< this.config, this.procedure ).createMagicLinkProcedures(); + const emailLoginRoutes = new EmailLoginProcedureFactory( + this.config, + this.procedure + ).createEmailLoginProcedures(); const multiAccountRoutes = new MultiAccountProcedureFactory( this.config, this.authProcedure @@ -93,6 +98,9 @@ class AuthScaffold< biometric: biometricRoutes.createBiometricProcedures(), emailVerification: emailVerificationRoutes.createEmailVerificationProcedures(), magicLink: magicLinkRoutes, + // Nested sub-router → client.auth.emailLogin.*. Gated at runtime by + // features.emailLogin, so the shape is always present. + emailLogin: this.t.router(emailLoginRoutes), multiAccount: multiAccountRoutes, // Nested sub-router → client.auth.passkey.*. Gated at runtime by // features.passkey (like oauth), so the shape is always present. @@ -151,6 +159,7 @@ function buildStandardAuthRouter< ...shared.magicLink, ...shared.multiAccount, passkey: shared.passkey, + emailLogin: shared.emailLogin, }); const router = scaffold.t.router({ auth: authRouter }); @@ -191,6 +200,7 @@ function buildDeviceAuthRouter< ...shared.magicLink, ...shared.multiAccount, passkey: shared.passkey, + emailLogin: shared.emailLogin, }); const router = scaffold.t.router({ auth: authRouter }); diff --git a/packages/auth/src/stack-plugin.ts b/packages/auth/src/stack-plugin.ts index 972bc6f..f536cd2 100644 --- a/packages/auth/src/stack-plugin.ts +++ b/packages/auth/src/stack-plugin.ts @@ -26,6 +26,13 @@ export const AUTH_OAUTH_ENV_VARS = { apple: ['APPLE_CLIENT_ID'] as const, } as const; +/** + * Email sign-in environment variables. The pepper keys the HMAC over sign-in + * codes; `createAuthConfig` refuses to start without one when + * `features.emailLogin` is on. + */ +export const AUTH_EMAIL_LOGIN_ENV_VARS = ['EMAIL_LOGIN_PEPPER'] as const; + /** * All possible auth-related secret names (for vault management). */ @@ -33,6 +40,7 @@ export const AUTH_ALL_SECRET_NAMES = [ ...AUTH_REQUIRED_ENV_VARS, ...AUTH_OAUTH_ENV_VARS.google, ...AUTH_OAUTH_ENV_VARS.apple, + ...AUTH_EMAIL_LOGIN_ENV_VARS, ] as const; /** @@ -63,6 +71,7 @@ export const AUTH_CONFIG_SCHEMA = { passwordReset: (defaultFeatures as AuthFeatures).passwordReset ?? false, otpLogin: (defaultFeatures as AuthFeatures).otpLogin ?? false, magicLink: (defaultFeatures as AuthFeatures).magicLink ?? false, + emailLogin: (defaultFeatures as AuthFeatures).emailLogin ?? false, }, oauth_provider: 'EXAMPLE_google', }, @@ -95,6 +104,7 @@ export const AUTH_PRISMA_MODELS = AUTH_PRISMA_MODELS_STANDARD; export const stackPlugin = { requiredEnvVars: AUTH_REQUIRED_ENV_VARS, oauthEnvVars: AUTH_OAUTH_ENV_VARS, + emailLoginEnvVars: AUTH_EMAIL_LOGIN_ENV_VARS, allSecretNames: AUTH_ALL_SECRET_NAMES, configSchema: AUTH_CONFIG_SCHEMA, prismaModels: AUTH_PRISMA_MODELS_STANDARD, diff --git a/packages/auth/src/types/config.ts b/packages/auth/src/types/config.ts index fd348c5..3f27db8 100644 --- a/packages/auth/src/types/config.ts +++ b/packages/auth/src/types/config.ts @@ -67,6 +67,13 @@ export interface AuthFeatures { otpLogin?: boolean; /** Enable magic link authentication */ magicLink?: boolean; + /** + * Enable email sign-in: `auth.emailLogin.*`, one email with a link and a + * 6-digit code. Requires `emailLogin` config, `emailService.sendLoginEmail`, + * and the `EmailLoginAttempt` model; `createAuthConfig` refuses to start + * without them. + */ + emailLogin?: boolean; /** Enable WebAuthn passkey registration + authentication */ passkey?: boolean; /** @@ -214,6 +221,11 @@ export interface AuthConfig { defaultExpiryMs?: number; }; + /** + * Email sign-in configuration (required when features.emailLogin is enabled). + */ + emailLogin?: EmailLoginConfig; + /** Max sessions per device. Default 1 (single-account). >1 enables multi-account. */ maxAccounts?: number; @@ -222,3 +234,42 @@ export interface AuthConfig { */ webauthn?: WebAuthnConfig; } + +/** + * One product that may send email sign-in links, keyed in `emailLogin.apps`. The + * client names the key; everything a URL is built from comes from here, never + * from the request. + */ +export interface EmailLoginAppConfig { + /** Origin the link and the reset page open on, e.g. "https://oakbox.me". No trailing slash. */ + siteUrl: string; + /** Page that shows "Continue as …" and calls `emailLogin.verifyLink`, e.g. "/auth/email". */ + verifyPath: string; + /** Password reset page; the token is appended as a path segment, e.g. "/reset-password". */ + resetPath: string; + /** Handed to `emailService.sendLoginEmail` so it can pick the template. */ + brand: string; +} + +export interface EmailLoginConfig { + /** The allowlist. A request naming any other key is refused. */ + apps: Record; + /** + * HMAC key for sign-in codes, at least 32 characters. Pass + * `process.env.EMAIL_LOGIN_PEPPER`; startup fails when it is missing. + */ + pepper: string | undefined; + /** + * Count one hit against `key`; resolve true while at most `max` hits fell in the + * last `windowSec` seconds. The package calls it for requests per email, requests + * per IP, and verifies per IP. + */ + rateLimit: (key: string, max: number, windowSec: number) => Promise; + /** How long an attempt lives. Default 15 minutes. */ + ttlMs?: number; + /** + * Every `request` answer is padded to at least this long, so an address with an + * account cannot be told from one without by timing. Default 400 ms. + */ + responseFloorMs?: number; +} diff --git a/packages/auth/src/types/hooks.ts b/packages/auth/src/types/hooks.ts index 270659d..5f3fe12 100644 --- a/packages/auth/src/types/hooks.ts +++ b/packages/auth/src/types/hooks.ts @@ -4,6 +4,9 @@ import { type loginSchema, type oAuthLoginSchema, type signupSchema } from '../v import type { PasskeyCredential } from './passkey'; import type { AnyZodObject } from './zod'; +/** The client platform an email sign-in reports, for naming its session. */ +export type LoginPlatform = 'ios' | 'android' | 'web'; + /** * Schema extensions for adding custom fields to auth inputs */ @@ -93,6 +96,25 @@ export interface AuthHooks { | ExtendedPasskeyRegisterInput ) => Promise; + /** + * Runs at EVERY place a session is minted — password login, email sign-in, + * magic link, OAuth (before a provider is linked to an existing account), and + * passkey — after the account is identified and before the 2FA step or any + * side effect. Throw to refuse the sign-in. + * + * The package itself already refuses DEACTIVATED and BANNED accounts. Put every + * other account-status rule here, e.g. refusing a DELETED account once its grace + * window has passed: `beforeLogin` runs only for password login, so a rule kept + * there leaves the other sign-in paths open. + */ + beforeSessionMint?: ( + userId: number, + context: { + firstFactor: 'PASSWORD' | 'EMAIL_LOGIN' | 'MAGIC_LINK' | 'OAUTH' | 'PASSKEY'; + ip?: string; + } + ) => Promise; + /** * Called after successful login * Use this to update activity status, send notifications, etc. @@ -169,6 +191,47 @@ export interface AuthHooks { userId: number ) => Record | Promise>; + /** + * An account with 2FA on signed in with a first factor that is not a DEVICE + * factor — an email link or code, a magic link, or OAuth — and sent no + * `twoFaCode`. Return a `pendingLoginId` to push another device for approval, + * or null to fall back to the typed-code step. Password login keeps + * `onLoginApprovalRequired`, whose input is the login form. + * + * `input` carries what the client sent for the second step (`approvalNonce`, + * `devicePushToken`, `platform`; for email sign-in also the `app` key, and for + * OAuth every field of the OAuth input except the provider token). Treat it as + * client-supplied. + */ + onDeviceStepRequired?: ( + userId: number, + context: { + ip?: string; + browserName: string; + firstFactor: 'EMAIL_LOGIN' | 'MAGIC_LINK' | 'OAUTH'; + input: Record; + } + ) => Promise<{ pendingLoginId: string } | null>; + + /** + * Extra fields for the Session row an email sign-in creates — e.g. `instanceId`, + * or a `browserName` naming the app, which a native client's user agent cannot. + */ + getEmailLoginSessionData?: ( + userId: number, + context: { app: string; platform?: LoginPlatform } + ) => Record | Promise>; + + /** + * Called after an email sign-in creates an account. Provision it here: it is + * the email path's `onUserCreated`, which it does not call because there is no + * signup form input to hand it. + */ + onEmailLoginUserCreated?: ( + userId: number, + context: { email: string; app: string; platform?: LoginPlatform } + ) => Promise; + /** * Custom validation for biometric verification * Return timeout in ms, or null to skip timeout enforcement diff --git a/packages/auth/src/utilities/accountStatus.ts b/packages/auth/src/utilities/accountStatus.ts new file mode 100644 index 0000000..340daf1 --- /dev/null +++ b/packages/auth/src/utilities/accountStatus.ts @@ -0,0 +1,34 @@ +/** + * The account-status rule every mint site applies before its 2FA gate and before + * any side effect (a push, a provider link, a session). + * + * Password login always refused a deactivated or banned account, but a magic link, + * a passkey, and parts of OAuth never checked — and a consumer's own status rules + * lived in `beforeLogin`, which only password login calls. One function here, and + * one hook, close every path the same way. + * + * DELETED is deliberately left to `hooks.beforeSessionMint`: a consumer may let a + * deleted account back in during a grace window so it can cancel the deletion, and + * only the consumer knows that window. + */ +import { TRPCError } from '@trpc/server'; + +import type { AuthUser } from '../adapters/database'; +import type { FirstFactor } from '../procedures/twoFa/deviceStep'; +import type { ResolvedAuthConfig } from './config'; + +export async function assertCanMintSession( + config: ResolvedAuthConfig, + user: AuthUser, + context: { firstFactor: FirstFactor; ip?: string } +): Promise { + if (user.status === 'DEACTIVATED') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Your account has been deactivated.' }); + } + if (user.status === 'BANNED') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Your account has been banned.' }); + } + if (config.hooks?.beforeSessionMint) { + await config.hooks.beforeSessionMint(user.id, context); + } +} diff --git a/packages/auth/src/utilities/config.ts b/packages/auth/src/utilities/config.ts index 868eb8a..696e216 100644 --- a/packages/auth/src/utilities/config.ts +++ b/packages/auth/src/utilities/config.ts @@ -1,13 +1,26 @@ import { createNoopEmailAdapter } from '../adapters'; import type { DatabaseAdapter } from '../adapters/database'; import type { DeviceAuthAdapter } from '../adapters/deviceAuth'; +import type { LoginEmailParams } from '../adapters/email'; import type { OAuthAccountAdapter } from '../adapters/oauthAccount'; import type { PasskeyAdapter } from '../adapters/passkey'; import { createPrismaAdapter } from '../adapters/prismaAdapter'; import type { CookieSettings } from '../types'; -import type { AuthConfig, AuthFeatures, TokenSettings } from '../types/config'; +import type { + AuthConfig, + AuthFeatures, + EmailLoginAppConfig, + TokenSettings, +} from '../types/config'; -export type { AuthConfig, AuthFeatures, TokenSettings, TwoFaMode } from '../types/config'; +export type { + AuthConfig, + AuthFeatures, + EmailLoginAppConfig, + EmailLoginConfig, + TokenSettings, + TwoFaMode, +} from '../types/config'; export type { OAuthKeys } from './oauth'; /** @@ -54,6 +67,7 @@ export const defaultFeatures: AuthFeatures = { passwordReset: true, otpLogin: true, magicLink: false, + emailLogin: false, }; /** Resolved magic link config with defaults applied. */ @@ -63,6 +77,21 @@ export interface ResolvedMagicLinkConfig { defaultExpiryMs: number; } +/** Resolved email login config: defaults applied, every requirement checked. */ +export interface ResolvedEmailLoginConfig { + apps: Record; + pepper: string; + rateLimit: (key: string, max: number, windowSec: number) => Promise; + sendLoginEmail: (params: LoginEmailParams) => Promise; + ttlMs: number; + responseFloorMs: number; +} + +/** An HMAC key shorter than this is within reach of an offline guess. */ +const MIN_EMAIL_LOGIN_PEPPER_LENGTH = 32; +const DEFAULT_EMAIL_LOGIN_TTL_MS = 15 * 60 * 1000; // 15 minutes +const DEFAULT_EMAIL_LOGIN_RESPONSE_FLOOR_MS = 400; + /** Resolved config type with database adapter guaranteed. */ export type ResolvedAuthConfig = Required< Omit< @@ -73,6 +102,7 @@ export type ResolvedAuthConfig = Required< | 'prisma' | 'getClientCookiePayload' | 'magicLink' + | 'emailLogin' | 'deviceAuth' | 'passkey' | 'oauthAccounts' @@ -86,9 +116,69 @@ export type ResolvedAuthConfig = Required< passkey?: PasskeyAdapter; oauthAccounts?: OAuthAccountAdapter; magicLink?: ResolvedMagicLinkConfig; + emailLogin?: ResolvedEmailLoginConfig; maxAccounts: number; }; +/** + * Check every piece email sign-in needs before the server takes a request. A + * missing pepper or rate limiter is not a degraded mode — it is an open door — + * so the answer to each one is to refuse to start. + */ +function resolveEmailLogin( + config: AuthConfig, + database: DatabaseAdapter +): ResolvedEmailLoginConfig { + const refuse = (what: string) => + new Error(`@factiii/auth: features.emailLogin is on, but ${what}.`); + + const settings = config.emailLogin; + if (!settings) { + throw refuse('no `emailLogin` config was provided'); + } + + const apps = Object.entries(settings.apps ?? {}); + if (apps.length === 0) { + throw refuse('`emailLogin.apps` lists no app'); + } + for (const [key, app] of apps) { + if (!URL.canParse(app.siteUrl)) { + throw refuse(`\`emailLogin.apps.${key}.siteUrl\` is not a URL`); + } + } + + if (!settings.pepper || settings.pepper.length < MIN_EMAIL_LOGIN_PEPPER_LENGTH) { + throw refuse( + `\`emailLogin.pepper\` is missing or shorter than ${MIN_EMAIL_LOGIN_PEPPER_LENGTH} characters` + ); + } + + if (typeof settings.rateLimit !== 'function') { + throw refuse('no `emailLogin.rateLimit` was provided'); + } + + const emailService = config.emailService; + const sendLoginEmail = emailService?.sendLoginEmail; + if (!emailService || !sendLoginEmail) { + throw refuse('`emailService.sendLoginEmail` is not implemented'); + } + + if (!database.emailLoginAttempt) { + throw refuse( + 'the database adapter has no `emailLoginAttempt` store — add the EmailLoginAttempt model' + ); + } + + return { + apps: settings.apps, + pepper: settings.pepper, + rateLimit: settings.rateLimit, + sendLoginEmail: sendLoginEmail.bind(emailService), + ttlMs: settings.ttlMs ?? DEFAULT_EMAIL_LOGIN_TTL_MS, + responseFloorMs: settings.responseFloorMs ?? DEFAULT_EMAIL_LOGIN_RESPONSE_FLOOR_MS, + }; +} + /** * Create a fully resolved auth config with defaults applied. * Accepts either `database` (adapter) or `prisma` (auto-wrapped). @@ -115,6 +205,10 @@ export function createAuthConfig(config: AuthConfig): ResolvedAuthConfig { ); } + // Fail fast too: resolved against the consumer's own emailService, not the + // no-op default, so a missing sender cannot quietly swallow sign-in emails. + const emailLogin = features.emailLogin ? resolveEmailLogin(config, database) : undefined; + return { ...config, database, @@ -125,7 +219,12 @@ export function createAuthConfig(config: AuthConfig): ResolvedAuthConfig { tokenSettings: { ...defaultTokenSettings, ...config.tokenSettings }, cookieSettings: { ...defaultCookieSettings, ...config.cookieSettings }, storageKeys: { ...defaultStorageKeys, ...config.storageKeys }, - generateUsername: config.generateUsername ?? (() => `user_${Date.now()}`), + // The random tail keeps two accounts made in one millisecond, or a retry after + // a taken name, from drawing the same username. + generateUsername: + config.generateUsername ?? + (() => + `user_${Date.now()}${String(Math.floor(Math.random() * 10_000)).padStart(4, '0')}`), emailService, magicLink: config.magicLink ? { @@ -134,6 +233,7 @@ export function createAuthConfig(config: AuthConfig): ResolvedAuthConfig { defaultExpiryMs: config.magicLink.defaultExpiryMs ?? 7 * 24 * 60 * 60 * 1000, } : undefined, + emailLogin, maxAccounts: config.maxAccounts ?? 1, }; } diff --git a/packages/auth/src/utilities/createUser.ts b/packages/auth/src/utilities/createUser.ts new file mode 100644 index 0000000..79342fa --- /dev/null +++ b/packages/auth/src/utilities/createUser.ts @@ -0,0 +1,91 @@ +import { TRPCError } from '@trpc/server'; + +import type { AuthUser, CreateUserData } from '../adapters/database'; +import type { ResolvedAuthConfig } from './config'; +import { sameIdentifier } from './emailMatch'; + +/** Fresh usernames one account creation tries before it gives up. */ +export const MAX_USERNAME_TRIES = 5; + +/** + * Which unique index a failed insert hit, as Prisma (P2002) or node-postgres + * (23505) reports it: `null` when the error is not a unique violation at all, and + * `'unknown'` when it is one but names no field this code recognises. + */ +export type UniqueViolationField = 'email' | 'username' | 'other' | 'unknown'; + +export function uniqueViolationField(err: unknown): UniqueViolationField | null { + const e = err as { + code?: unknown; + message?: unknown; + meta?: unknown; + constraint?: unknown; + detail?: unknown; + } | null; + if (e?.code !== 'P2002' && e?.code !== '23505') return null; + + // Prisma puts the fields in `meta` (`target`, or a driver adapter's nested + // cause) and in the message; node-postgres names the constraint and the key. + let meta = ''; + try { + meta = e.meta === undefined || e.meta === null ? '' : JSON.stringify(e.meta); + } catch { + meta = ''; + } + const named = [meta, e.constraint, e.detail] + .filter((part): part is string => typeof part === 'string') + .join(' ') + .toLowerCase(); + const text = `${named} ${typeof e.message === 'string' ? e.message.toLowerCase() : ''}`; + + // `username` first: `email` never appears inside it. + if (text.includes('username')) return 'username'; + if (text.includes('email')) return 'email'; + // A named index that is neither is some other constraint. A bare message + // ("duplicate key value violates unique constraint") names nothing. + return named.trim() ? 'other' : 'unknown'; +} + +/** + * Create an account under a generated username, trying a fresh one when the + * username is taken. An email violation, or a violation of any other index, is + * rethrown for the caller: it is not a username problem, and only the caller + * knows whether a lost email race can be recovered. + */ +export async function createUserWithFreshUsername( + config: ResolvedAuthConfig, + data: Omit, + context: { ip?: string } = {} +): Promise { + let lastError: unknown; + for (let tries = 1; tries <= MAX_USERNAME_TRIES; tries += 1) { + try { + return await config.database.user.create({ ...data, username: config.generateUsername() }); + } catch (err) { + const field = uniqueViolationField(err); + if (field === null || field === 'email' || field === 'other') throw err; + if (field === 'unknown') { + // The error named no index. An account already holding this exact address + // means it was the email index; otherwise the username was taken. + const holder = await config.database.user.findByEmailInsensitive(data.email); + if (holder && sameIdentifier(holder.email, data.email)) throw err; + } + lastError = err; + } + } + + const error = lastError instanceof Error ? lastError : new Error(String(lastError)); + const description = `createUser: no free username after ${MAX_USERNAME_TRIES} tries: ${error.message}`; + if (config.hooks?.logError) { + await config.hooks + .logError({ type: 'OTHER', description, stack: error.stack ?? '', ip: context.ip }) + .catch(() => undefined); + } else { + console.error(`@factiii/auth: ${description}`, error); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Could not create the account. Try again.', + cause: error, + }); +} diff --git a/packages/auth/src/utilities/emailMatch.ts b/packages/auth/src/utilities/emailMatch.ts index ca4cd63..34e9073 100644 --- a/packages/auth/src/utilities/emailMatch.ts +++ b/packages/auth/src/utilities/emailMatch.ts @@ -11,6 +11,11 @@ /** The characters LIKE and ILIKE treat specially under the default escape. */ const LIKE_SPECIAL = /[\\%_]/g; +/** True when `value` holds a character a LIKE pattern would not take literally. */ +export function hasLikeWildcard(value: string): boolean { + return /[\\%_]/.test(value); +} + /** Escape `\`, `%` and `_` with a backslash, Postgres's default LIKE escape. */ export function escapeLikePattern(value: string): string { return value.replace(LIKE_SPECIAL, (char) => `\\${char}`); diff --git a/packages/auth/src/validators.ts b/packages/auth/src/validators.ts index e676f89..85eede8 100644 --- a/packages/auth/src/validators.ts +++ b/packages/auth/src/validators.ts @@ -72,6 +72,8 @@ export const oAuthLoginSchema = z.object({ }) .optional(), provider: z.enum(['GOOGLE', 'APPLE']), + // Second step for an account with 2FA on: a TOTP or backup code. + twoFaCode: z.string().max(64).optional(), }); /** @@ -79,6 +81,8 @@ export const oAuthLoginSchema = z.object({ */ export const requestPasswordResetSchema = z.object({ email: z.string().email({ message: 'Invalid email address' }), + // An `emailLogin.apps` key: the reset link then opens on that app's site. + app: z.string().min(1).max(64).optional(), }); /** diff --git a/packages/auth/tests/emailLogin.test.ts b/packages/auth/tests/emailLogin.test.ts new file mode 100644 index 0000000..52bde8a --- /dev/null +++ b/packages/auth/tests/emailLogin.test.ts @@ -0,0 +1,1027 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { initTRPC } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + AuthEmailLoginAttempt, + AuthMagicLink, + AuthUser, + CreateEmailLoginAttemptData, + CreateUserData, +} from '../src/adapters/database'; +import { BaseProcedureFactory } from '../src/procedures/base'; +import { EmailLoginProcedureFactory } from '../src/procedures/emailLogin'; +import { MagicLinkProcedureFactory } from '../src/procedures/magicLink'; +import { OAuthLoginProcedureFactory } from '../src/procedures/oauth'; +import { requiresDeviceStep } from '../src/procedures/twoFa/deviceStep'; +import type { AuthProcedure, BaseProcedure, TrpcContext } from '../src/types/trpc'; +import { createAuthConfig } from '../src/utilities/config'; +import { hashPassword } from '../src/utilities/password'; +import { createSchemas } from '../src/validators'; + +/** + * Email sign-in, and the factor-class gate every sign-in path now runs. + * + * The procedures run for real against in-memory adapters that return full rows. + * The attempt and magic-link stores make their check-and-write without an `await` + * in between, so under one event loop they are as atomic as the conditional + * UPDATE the Prisma adapter uses — which is what lets the race tests mean + * something: two verifies really do interleave at every other `await`. + */ + +const { googleVerify } = vi.hoisted(() => ({ googleVerify: vi.fn() })); + +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + verifyIdToken = googleVerify; + }, +})); + +// Imported by the verifier; never exercised here. +vi.mock('apple-signin-auth', () => ({ default: { verifyIdToken: vi.fn() } })); + +const TWO_FA_SECRET = 'JBSWY3DPEHPK3PXP'; +const BACKUP_CODE = 'a1b2c3d4e5'; +const OAKBOX = { + siteUrl: 'https://oakbox.me', + verifyPath: '/auth/email', + resetPath: '/reset-password', + brand: 'oakbox', +}; + +const CODE_DID_NOT_WORK = 'That code did not work. Check it and try again.'; +const LINK_EXPIRED = 'That link has expired. Ask for a new one.'; + +type UserRow = AuthUser & { twoFaBackupCodes: string[] }; + +const account = (overrides: Partial = {}): UserRow => ({ + id: 7, + status: 'ACTIVE', + email: 'ada@example.com', + username: 'ada', + password: null, + twoFaSecret: null, + twoFaBackupCodes: [], + tag: 'HUMAN', + verifiedHumanAt: null, + emailVerificationStatus: 'VERIFIED', + otpForEmailVerification: null, + isActive: true, + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +const withTwoFa = (overrides: Partial = {}) => + account({ twoFaSecret: TWO_FA_SECRET, twoFaBackupCodes: [BACKUP_CODE], ...overrides }); + +interface HarnessOptions { + users?: UserRow[]; + allow?: (key: string) => boolean; + hooks?: Record; + linkedUserId?: number; + /** Runs inside user.create before its uniqueness check — lets a test play the other racer. */ + beforeCreate?: (data: CreateUserData) => void; +} + +function harness(opts: HarnessOptions = {}) { + const users: UserRow[] = [...(opts.users ?? [])]; + const attempts: AuthEmailLoginAttempt[] = []; + const magicLinks: AuthMagicLink[] = []; + const sent: Array<{ to: string; app: string; brand: string; link: string; code: string }> = []; + + const byEmail = (email: string) => + users.find((u) => u.email?.toLowerCase() === email.toLowerCase()) ?? null; + const isOpen = (a: AuthEmailLoginAttempt) => a.consumedAt === null && a.expiresAt > new Date(); + + const database = { + user: { + findByEmailInsensitive: vi.fn(async (email: string) => byEmail(email)), + findByEmailOrUsernameInsensitive: vi.fn( + async (identifier: string) => + byEmail(identifier) ?? + users.find((u) => u.username?.toLowerCase() === identifier.toLowerCase()) ?? + null + ), + findById: vi.fn(async (id: number) => users.find((u) => u.id === id) ?? null), + findActiveById: vi.fn( + async (id: number) => users.find((u) => u.id === id && u.status === 'ACTIVE') ?? null + ), + create: vi.fn(async (data: CreateUserData) => { + opts.beforeCreate?.(data); + if (byEmail(data.email)) { + throw Object.assign(new Error('Unique constraint failed on the fields: (`email`)'), { + code: 'P2002', + }); + } + const row = account({ ...data, id: 100 + users.length }); + users.push(row); + return row; + }), + update: vi.fn(async (id: number, data: Partial) => { + const row = users.find((u) => u.id === id); + if (!row) throw new Error('no user'); + Object.assign(row, data); + return row; + }), + consumeBackupCode: vi.fn(async (id: number, code: string) => { + const row = users.find((u) => u.id === id); + const index = row ? row.twoFaBackupCodes.indexOf(code) : -1; + if (!row || index === -1) return false; + row.twoFaBackupCodes.splice(index, 1); + return true; + }), + }, + session: { + create: vi.fn(async (data: { userId: number }) => ({ id: 500, userId: data.userId })), + findManyByIds: vi.fn(async () => []), + revoke: vi.fn(async () => {}), + }, + passwordReset: { + deleteAllByUserId: vi.fn(async () => {}), + create: vi.fn(async (userId: number) => ({ id: 'reset-1', createdAt: new Date(), userId })), + }, + emailLoginAttempt: { + create: vi.fn(async (data: CreateEmailLoginAttemptData) => { + const row: AuthEmailLoginAttempt = { + ...data, + attempts: 0, + consumedAt: null, + createdAt: new Date(), + }; + attempts.push(row); + return row; + }), + findByTokenHash: vi.fn(async (hash: string) => attempts.find((a) => a.tokenHash === hash) ?? null), + findLatestOpenByEmail: vi.fn( + async (email: string) => [...attempts].reverse().find((a) => a.email === email && isOpen(a)) ?? null + ), + consume: vi.fn(async (id: string) => { + const row = attempts.find((a) => a.id === id); + if (!row || !isOpen(row)) return false; + row.consumedAt = new Date(); + return true; + }), + incrementAttempts: vi.fn(async (id: string) => { + const row = attempts.find((a) => a.id === id); + if (!row) throw new Error('no attempt'); + row.attempts += 1; + return row.attempts; + }), + consumeOpenByEmail: vi.fn(async (email: string) => { + for (const row of attempts) { + if (row.email === email && row.consumedAt === null) row.consumedAt = new Date(); + } + }), + }, + magicLink: { + findById: vi.fn(async (id: string) => magicLinks.find((l) => l.id === id) ?? null), + create: vi.fn(), + markUsed: vi.fn(), + consume: vi.fn(async (id: string) => { + const row = magicLinks.find((l) => l.id === id); + if (!row || row.usedAt || row.expiresAt <= new Date()) return false; + row.usedAt = new Date(); + return true; + }), + }, + }; + + const oauthAccounts = { + resolve: vi.fn(async () => (opts.linkedUserId ? { userId: opts.linkedUserId } : null)), + link: vi.fn(async () => {}), + list: vi.fn(async () => []), + unlink: vi.fn(async () => {}), + }; + + const emailService = { + sendVerificationEmail: vi.fn(async () => {}), + sendPasswordResetEmail: vi.fn(async () => {}), + sendOTPEmail: vi.fn(async () => {}), + sendLoginEmail: vi.fn(async (params: (typeof sent)[number]) => { + sent.push(params); + }), + }; + + // Counts the way a consumer's limiter does: allowed while the calls for a key + // stay within `max`. `allow` can still refuse a key outright. + const counts = new Map(); + const rateLimit = vi.fn(async (key: string, max: number) => { + if (opts.allow && !opts.allow(key)) return false; + const next = (counts.get(key) ?? 0) + 1; + counts.set(key, next); + return next <= max; + }); + + const config = createAuthConfig({ + database, + secrets: { jwt: 'test-secret-key' }, + features: { twoFa: true, emailLogin: true, magicLink: true, oauth: { google: true } }, + emailService, + emailLogin: { apps: { oakbox: OAKBOX }, pepper: 'p'.repeat(32), rateLimit, responseFloorMs: 0 }, + magicLink: { siteUrl: 'https://example.com' }, + oauthKeys: { google: { clientId: 'google-client' } }, + oauthAccounts, + hooks: opts.hooks, + } as unknown as Parameters[0]); + + const t = initTRPC.context().create(); + const procedure = t.procedure as unknown as BaseProcedure; + const authProcedure = t.procedure as unknown as AuthProcedure; + const router = t.router({ + emailLogin: t.router(new EmailLoginProcedureFactory(config, procedure).createEmailLoginProcedures()), + ...new MagicLinkProcedureFactory(config, procedure).createMagicLinkProcedures(), + ...new OAuthLoginProcedureFactory(config, procedure, authProcedure).createOAuthLoginProcedures( + createSchemas() + ), + ...new BaseProcedureFactory(config, procedure, authProcedure).createBaseProcedures(createSchemas()), + }); + + const ctx = { + headers: { 'user-agent': 'Mozilla/5.0' }, + res: { setHeader: vi.fn() }, + userId: null, + sessionId: null, + socketId: null, + ip: '203.0.113.9', + } as unknown as TrpcContext; + + const lastEmail = () => { + const email = sent[sent.length - 1]; + if (!email) throw new Error('no email was sent'); + return { ...email, token: new URL(email.link).searchParams.get('token') ?? '' }; + }; + + return { + caller: t.createCallerFactory(router)(ctx), + /** The same router, called from another IP. */ + callerAt: (ip: string) => t.createCallerFactory(router)({ ...ctx, ip } as TrpcContext), + config, + database, + users, + attempts, + magicLinks, + sent, + lastEmail, + emailService, + oauthAccounts, + }; +} + +beforeEach(() => { + googleVerify.mockReset(); +}); + +describe('emailLogin.request', () => { + it('answers the same for an address with an account and one without', async () => { + const h = harness({ users: [account()] }); + + const known = await h.caller.emailLogin.request({ email: ' Ada@Example.com ', app: 'oakbox' }); + const unknown = await h.caller.emailLogin.request({ email: 'nobody@example.com', app: 'oakbox' }); + + expect(known).toEqual({ sent: true }); + expect(unknown).toEqual(known); + expect(h.sent.map((e) => e.to)).toEqual(['ada@example.com', 'nobody@example.com']); + const email = h.lastEmail(); + expect(email.link.startsWith('https://oakbox.me/auth/email?token=')).toBe(true); + expect(email.code).toMatch(/^\d{6}$/); + expect(email.brand).toBe('oakbox'); + }); + + it('stores neither the token nor the code', async () => { + const h = harness(); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + + const { token, code } = h.lastEmail(); + const stored = JSON.stringify(h.attempts); + expect(stored).not.toContain(token); + expect(stored).not.toContain(`"${code}"`); + }); + + it('sends nothing when rate-limited, and still answers { sent: true }', async () => { + const h = harness({ allow: (key) => !key.startsWith('emailLogin:request:email:') }); + + await expect( + h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }) + ).resolves.toEqual({ sent: true }); + + expect(h.sent).toHaveLength(0); + expect(h.attempts).toHaveLength(0); + }); + + it('refuses an app key the server does not list', async () => { + const h = harness(); + + await expect( + h.caller.emailLogin.request({ email: 'ada@example.com', app: 'somewhere-else' }) + ).rejects.toThrow('Unknown app.'); + expect(h.sent).toHaveLength(0); + }); + + it('a newer request spends the older attempt', async () => { + const h = harness({ users: [account()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const first = h.lastEmail(); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const second = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token: first.token })).rejects.toThrow(LINK_EXPIRED); + await expect(h.caller.emailLogin.verifyLink({ token: second.token })).resolves.toMatchObject({ + success: true, + }); + }); +}); + +const uniqueError = (fields: Record) => + Object.assign(new Error('Unique constraint failed'), fields); + +describe('account creation with a taken username', () => { + it('tries a fresh username when the generated one is taken', async () => { + let calls = 0; + const h = harness({ + beforeCreate: () => { + calls += 1; + if (calls === 1) throw uniqueError({ code: 'P2002', meta: { target: ['username'] } }); + }, + }); + await h.caller.emailLogin.request({ email: 'fresh@example.com', app: 'oakbox' }); + const { code } = h.lastEmail(); + + const result = await h.caller.emailLogin.verifyCode({ email: 'fresh@example.com', code }); + + expect(result).toMatchObject({ success: true, created: true }); + expect(h.database.user.create).toHaveBeenCalledTimes(2); + expect(h.users).toHaveLength(1); + }); + + it('an unnamed unique violation with no account for the address counts as a taken username', async () => { + let calls = 0; + const h = harness({ + beforeCreate: () => { + calls += 1; + if (calls === 1) throw uniqueError({ code: '23505' }); + }, + }); + await h.caller.emailLogin.request({ email: 'fresh@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token })).resolves.toMatchObject({ + success: true, + created: true, + }); + expect(h.database.user.create).toHaveBeenCalledTimes(2); + }); + + it('gives up after five taken usernames, logs it, and signs nobody in', async () => { + const logError = vi.fn(async () => {}); + const h = harness({ + hooks: { logError }, + beforeCreate: () => { + throw uniqueError({ code: 'P2002', meta: { target: ['username'] } }); + }, + }); + await h.caller.emailLogin.request({ email: 'fresh@example.com', app: 'oakbox' }); + const { code } = h.lastEmail(); + + await expect( + h.caller.emailLogin.verifyCode({ email: 'fresh@example.com', code }) + ).rejects.toThrow('Could not create the account. Try again.'); + expect(h.database.user.create).toHaveBeenCalledTimes(5); + expect(logError).toHaveBeenCalledTimes(1); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('an email violation named by node-postgres still recovers as a lost race', async () => { + let raced = false; + const h = harness({ + beforeCreate: (data) => { + if (raced) return; + raced = true; + h.users.push(account({ id: 55, email: data.email, username: 'winner' })); + throw uniqueError({ code: '23505', constraint: 'User_email_key' }); + }, + }); + await h.caller.emailLogin.request({ email: 'twice@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token })).resolves.toMatchObject({ + success: true, + created: false, + user: { id: 55 }, + }); + expect(h.database.user.create).toHaveBeenCalledTimes(1); + }); +}); + +describe('emailLogin.peekLink', () => { + it('names the address of an open link without spending it', async () => { + const h = harness({ users: [account()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.peekLink({ token })).resolves.toEqual({ + valid: true, + maskedEmail: 'a•••@example.com', + }); + expect(h.attempts[0]?.consumedAt).toBeNull(); + await expect(h.caller.emailLogin.verifyLink({ token })).resolves.toMatchObject({ success: true }); + }); + + it('says nothing for a spent, an expired or an unknown link', async () => { + const h = harness(); + await h.caller.emailLogin.request({ email: 'new@example.com', app: 'oakbox' }); + const spent = h.lastEmail().token; + await h.caller.emailLogin.verifyLink({ token: spent }); + await expect(h.caller.emailLogin.peekLink({ token: spent })).resolves.toEqual({ valid: false }); + + await h.caller.emailLogin.request({ email: 'late@example.com', app: 'oakbox' }); + const expired = h.lastEmail().token; + h.attempts[h.attempts.length - 1]!.expiresAt = new Date(Date.now() - 1000); + await expect(h.caller.emailLogin.peekLink({ token: expired })).resolves.toEqual({ valid: false }); + + await expect(h.caller.emailLogin.peekLink({ token: 'not-a-token' })).resolves.toEqual({ + valid: false, + }); + }); + + it('answers { valid: false } past the per-IP limit', async () => { + const h = harness({ + users: [account()], + allow: (key) => !key.startsWith('emailLogin:peek:'), + }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.peekLink({ token })).resolves.toEqual({ valid: false }); + }); +}); + +describe('maskEmail', () => { + it('keeps the first character and the domain, and hides a +tag', async () => { + const { maskEmail } = await import('../src/procedures/emailLogin'); + expect(maskEmail('ada@example.com')).toBe('a•••@example.com'); + expect(maskEmail('a@x.io')).toBe('a•••@x.io'); + expect(maskEmail('ada+box@example.com')).toBe('a•••@example.com'); + expect(maskEmail('not-an-email')).toBe('•••'); + }); +}); + +describe('emailLogin.verifyLink / verifyCode', () => { + it('the link and the code are one attempt: whichever arrives first spends both', async () => { + const h = harness({ users: [account()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token, code } = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token })).resolves.toMatchObject({ + success: true, + created: false, + user: { id: 7 }, + }); + await expect( + h.caller.emailLogin.verifyCode({ email: 'ada@example.com', code }) + ).rejects.toThrow(CODE_DID_NOT_WORK); + }); + + it('two requests racing on one link sign in once', async () => { + const h = harness({ users: [account()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + const results = await Promise.allSettled([ + h.caller.emailLogin.verifyLink({ token }), + h.caller.emailLogin.verifyLink({ token }), + ]); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(h.database.session.create).toHaveBeenCalledTimes(1); + }); + + it('refuses the sixth code even when it is the right one', async () => { + const h = harness({ users: [account()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { code } = h.lastEmail(); + const wrong = code === '000000' ? '111111' : '000000'; + + for (let i = 0; i < 5; i += 1) { + await expect( + h.caller.emailLogin.verifyCode({ email: 'ada@example.com', code: wrong }) + ).rejects.toThrow(CODE_DID_NOT_WORK); + } + await expect( + h.caller.emailLogin.verifyCode({ email: 'ada@example.com', code }) + ).rejects.toThrow(CODE_DID_NOT_WORK); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('refuses an expired attempt by link and by code', async () => { + const h = harness({ users: [account()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token, code } = h.lastEmail(); + h.attempts[0]!.expiresAt = new Date(Date.now() - 1); + + await expect(h.caller.emailLogin.verifyLink({ token })).rejects.toThrow(LINK_EXPIRED); + await expect( + h.caller.emailLogin.verifyCode({ email: 'ada@example.com', code }) + ).rejects.toThrow(CODE_DID_NOT_WORK); + }); + + it('creates the account on first verify, with the email already proven', async () => { + const onEmailLoginUserCreated = vi.fn(async () => {}); + const h = harness({ hooks: { onEmailLoginUserCreated } }); + await h.caller.emailLogin.request({ email: 'New@Example.com', app: 'oakbox' }); + const { code } = h.lastEmail(); + + const result = await h.caller.emailLogin.verifyCode({ + email: 'new@example.com', + code, + platform: 'ios', + }); + + expect(result).toMatchObject({ success: true, created: true }); + expect(h.users).toHaveLength(1); + expect(h.users[0]).toMatchObject({ + email: 'new@example.com', + emailVerificationStatus: 'VERIFIED', + password: null, + }); + expect(onEmailLoginUserCreated).toHaveBeenCalledWith(h.users[0]!.id, { + email: 'new@example.com', + app: 'oakbox', + platform: 'ios', + }); + }); + + it('refuses an existing account whose email was never proven, and creates nothing', async () => { + // The pre-hijack case: someone registered the victim's address unverified and + // waits for the victim's first email sign-in. + const h = harness({ users: [account({ emailVerificationStatus: 'UNVERIFIED' })] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token })).rejects.toThrow( + 'Sign in another way, then confirm this email in your account.' + ); + expect(h.database.session.create).not.toHaveBeenCalled(); + expect(h.database.user.create).not.toHaveBeenCalled(); + }); + + it('two verifies creating one account leave one account', async () => { + let raced = false; + const h = harness({ + beforeCreate: (data) => { + // The other racer's insert lands between our lookup and our insert. + if (raced) return; + raced = true; + h.users.push(account({ id: 55, email: data.email, username: 'winner' })); + }, + }); + await h.caller.emailLogin.request({ email: 'twice@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + const result = await h.caller.emailLogin.verifyLink({ token }); + + expect(result).toMatchObject({ success: true, created: false, user: { id: 55 } }); + expect(h.users.filter((u) => u.email === 'twice@example.com')).toHaveLength(1); + }); +}); + +describe('factor-class gate', () => { + it('email sign-in into a 2FA account returns pendingLogin and spends the attempt', async () => { + const onDeviceStepRequired = vi.fn(async () => ({ pendingLoginId: 'pending-email' })); + const h = harness({ users: [withTwoFa()], hooks: { onDeviceStepRequired } }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + const result = await h.caller.emailLogin.verifyLink({ token, approvalNonce: 'nonce-1' }); + + expect(result).toEqual({ + success: false, + pendingLogin: true, + pendingLoginId: 'pending-email', + userId: 7, + requires2FA: true, + }); + expect(onDeviceStepRequired).toHaveBeenCalledWith( + 7, + expect.objectContaining({ + firstFactor: 'EMAIL_LOGIN', + input: expect.objectContaining({ app: 'oakbox', approvalNonce: 'nonce-1' }), + }) + ); + expect(h.database.session.create).not.toHaveBeenCalled(); + await expect(h.caller.emailLogin.verifyLink({ token })).rejects.toThrow(LINK_EXPIRED); + }); + + it('without push approval it asks for a code, keeps the link, and signs in with a valid one', async () => { + const h = harness({ users: [withTwoFa()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token })).resolves.toEqual({ + success: false, + requires2FA: true, + userId: 7, + }); + await expect( + h.caller.emailLogin.verifyLink({ token, twoFaCode: 'not-a-code' }) + ).rejects.toThrow('Invalid 2FA code.'); + expect(h.database.session.create).not.toHaveBeenCalled(); + + await expect( + h.caller.emailLogin.verifyLink({ token, twoFaCode: BACKUP_CODE }) + ).resolves.toMatchObject({ success: true, user: { id: 7 } }); + }); + + it('a magic link into a 2FA account no longer goes straight through', async () => { + const onDeviceStepRequired = vi.fn(async () => ({ pendingLoginId: 'pending-magic' })); + const h = harness({ users: [withTwoFa()], hooks: { onDeviceStepRequired } }); + h.magicLinks.push({ id: 'ml-1', userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + + const result = await h.caller.verifyMagicLink({ token: 'ml-1' }); + + expect(result).toEqual({ + success: false, + pendingLogin: true, + pendingLoginId: 'pending-magic', + userId: 7, + requires2FA: true, + }); + expect(onDeviceStepRequired).toHaveBeenCalledWith( + 7, + expect.objectContaining({ firstFactor: 'MAGIC_LINK' }) + ); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('a magic link cannot be used twice, even by two requests racing', async () => { + const h = harness({ users: [account()] }); + h.magicLinks.push({ id: 'ml-2', userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + + const results = await Promise.allSettled([ + h.caller.verifyMagicLink({ token: 'ml-2' }), + h.caller.verifyMagicLink({ token: 'ml-2' }), + ]); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(h.database.session.create).toHaveBeenCalledTimes(1); + await expect(h.caller.verifyMagicLink({ token: 'ml-2' })).rejects.toThrow( + 'This link has expired or is invalid' + ); + }); + + it('OAuth into a 2FA account by a linked identity returns pendingLogin, without the token', async () => { + googleVerify.mockResolvedValue({ + getPayload: () => ({ sub: 'google-sub', email: 'ada@example.com', email_verified: true }), + }); + const onDeviceStepRequired = vi.fn(async () => ({ pendingLoginId: 'pending-oauth' })); + const h = harness({ users: [withTwoFa()], linkedUserId: 7, hooks: { onDeviceStepRequired } }); + + const result = await h.caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' }); + + expect(result).toEqual({ + success: false, + pendingLogin: true, + pendingLoginId: 'pending-oauth', + userId: 7, + requires2FA: true, + }); + const context = onDeviceStepRequired.mock.calls[0]?.[1] as { input: Record }; + expect(context.input).not.toHaveProperty('idToken'); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('OAuth does not attach a new identity to a 2FA account before the second step', async () => { + googleVerify.mockResolvedValue({ + getPayload: () => ({ sub: 'new-google-sub', email: 'ada@example.com', email_verified: true }), + }); + const h = harness({ users: [withTwoFa()] }); + + await expect( + h.caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' }) + ).resolves.toEqual({ success: false, requires2FA: true, userId: 7 }); + expect(h.oauthAccounts.link).not.toHaveBeenCalled(); + + await expect( + h.caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token', twoFaCode: BACKUP_CODE }) + ).resolves.toMatchObject({ success: true, user: { id: 7 } }); + expect(h.oauthAccounts.link).toHaveBeenCalledTimes(1); + }); + + it('password login keeps returning pendingLogin exactly as before', async () => { + const onLoginApprovalRequired = vi.fn(async () => ({ pendingLoginId: 'pending-password' })); + const h = harness({ + users: [withTwoFa({ password: await hashPassword('correct horse battery') })], + hooks: { onLoginApprovalRequired }, + }); + + const result = await h.caller.login({ username: 'ada', password: 'correct horse battery' }); + + expect(result).toEqual({ + success: false, + pendingLogin: true, + pendingLoginId: 'pending-password', + userId: 7, + requires2FA: true, + }); + expect(onLoginApprovalRequired).toHaveBeenCalledWith( + 7, + expect.objectContaining({ input: expect.objectContaining({ username: 'ada' }) }) + ); + }); + + it('a user-verified passkey stays a DEVICE factor, and the passkey ceremony requires UV', () => { + const h = harness(); + const user = withTwoFa(); + expect(requiresDeviceStep(h.config, user, 'PASSKEY')).toBe(false); + for (const factor of ['PASSWORD', 'EMAIL_LOGIN', 'MAGIC_LINK', 'OAUTH'] as const) { + expect(requiresDeviceStep(h.config, user, factor)).toBe(true); + } + + // The exemption above is only sound while the ceremony demands user + // verification at both registration and authentication. + const source = readFileSync( + fileURLToPath(new URL('../src/procedures/passkey.ts', import.meta.url)), + 'utf8' + ); + expect(source.match(/userVerification: 'required'/g)?.length ?? 0).toBeGreaterThanOrEqual(2); + expect(source.match(/requireUserVerification: true/g)?.length ?? 0).toBeGreaterThanOrEqual(2); + }); +}); + +describe('password reset per app', () => { + it('opens the reset link on the site of the app that asked', async () => { + const h = harness({ users: [account({ password: 'hashed' })] }); + + await h.caller.sendPasswordResetEmail({ email: 'ada@example.com', app: 'oakbox' }); + expect(h.emailService.sendPasswordResetEmail).toHaveBeenLastCalledWith( + 'ada@example.com', + 'reset-1', + { app: 'oakbox', resetUrl: 'https://oakbox.me/reset-password/reset-1' } + ); + + await h.caller.sendPasswordResetEmail({ email: 'ada@example.com' }); + expect(h.emailService.sendPasswordResetEmail.mock.calls[1]).toEqual(['ada@example.com', 'reset-1']); + }); + + it('refuses an app key the server does not list, before looking up the account', async () => { + const h = harness({ users: [account({ password: 'hashed' })] }); + + await expect( + h.caller.sendPasswordResetEmail({ email: 'ada@example.com', app: 'nope' }) + ).rejects.toThrow('Unknown app.'); + expect(h.database.user.findByEmailInsensitive).not.toHaveBeenCalled(); + }); +}); + +describe('createAuthConfig with features.emailLogin', () => { + const valid = () => ({ + database: { emailLoginAttempt: {} }, + secrets: { jwt: 'x' }, + features: { emailLogin: true }, + emailService: { sendLoginEmail: vi.fn() }, + emailLogin: { apps: { oakbox: OAKBOX }, pepper: 'p'.repeat(32), rateLimit: vi.fn() }, + }); + const build = (config: unknown) => + createAuthConfig(config as Parameters[0]); + + it('starts when every piece is present', () => { + expect(() => build(valid())).not.toThrow(); + }); + + it('refuses to start without a pepper, a long enough pepper, a rate limiter, a sender, or the attempt store', () => { + const base = valid(); + expect(() => build({ ...base, emailLogin: { ...base.emailLogin, pepper: undefined } })).toThrow( + /pepper/ + ); + expect(() => build({ ...base, emailLogin: { ...base.emailLogin, pepper: 'short' } })).toThrow( + /pepper/ + ); + expect(() => build({ ...base, emailLogin: { ...base.emailLogin, rateLimit: undefined } })).toThrow( + /rateLimit/ + ); + expect(() => build({ ...base, emailService: undefined })).toThrow(/sendLoginEmail/); + expect(() => build({ ...base, database: {} })).toThrow(/emailLoginAttempt/); + expect(() => build({ ...base, emailLogin: { ...base.emailLogin, apps: {} } })).toThrow(/apps/); + }); + + it('asks for none of it while the feature is off', () => { + expect(() => + build({ database: {}, secrets: { jwt: 'x' }, features: { emailLogin: false } }) + ).not.toThrow(); + }); +}); + +describe('security review fixes', () => { + /** A lookup that behaves like an unescaped ILIKE: `_` is any one character. */ + const ilikeLookup = (h: ReturnType) => + h.database.user.findByEmailInsensitive.mockImplementation(async (pattern: string) => { + const source = pattern + .split('') + .map((char) => (char === '_' ? '.' : char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + .join(''); + const re = new RegExp(`^${source}$`, 'i'); + return h.users.find((u) => u.email !== null && re.test(u.email)) ?? null; + }); + const john = () => account({ id: 9, email: 'john@outlook.com', username: 'john' }); + + it('a look-alike address never signs into the account a wildcard lookup matched', async () => { + const h = harness({ users: [john()] }); + ilikeLookup(h); + + await h.caller.emailLogin.request({ email: 'j_hn@outlook.com', app: 'oakbox' }); + const { code } = h.lastEmail(); + const result = await h.caller.emailLogin.verifyCode({ email: 'j_hn@outlook.com', code }); + + expect(result).toMatchObject({ success: true, created: true }); + const lookAlike = h.users.find((u) => u.email === 'j_hn@outlook.com'); + expect(lookAlike).toBeDefined(); + expect(h.database.session.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: lookAlike!.id }) + ); + expect(h.database.session.create).not.toHaveBeenCalledWith(expect.objectContaining({ userId: 9 })); + }); + + it('OAuth does not attach a look-alike address to the account a wildcard lookup matched', async () => { + googleVerify.mockResolvedValue({ + getPayload: () => ({ sub: 'attacker-sub', email: 'j_hn@outlook.com', email_verified: true }), + }); + const h = harness({ users: [john()] }); + ilikeLookup(h); + + await h.caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'attacker-token' }); + + expect(h.oauthAccounts.link).not.toHaveBeenCalledWith(9, expect.anything()); + }); + + it('a password reset for a look-alike address goes nowhere', async () => { + const h = harness({ users: [account({ id: 9, email: 'john@outlook.com', password: 'hashed' })] }); + ilikeLookup(h); + + await h.caller.sendPasswordResetEmail({ email: 'j_hn@outlook.com' }); + + expect(h.emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it('five wrong second-step codes spend the email attempt', async () => { + const h = harness({ users: [withTwoFa()] }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await expect(h.caller.emailLogin.verifyLink({ token })).resolves.toMatchObject({ + requires2FA: true, + }); + for (let i = 0; i < 5; i += 1) { + await expect( + h.caller.emailLogin.verifyLink({ token, twoFaCode: 'wrong-code' }) + ).rejects.toThrow('Invalid 2FA code.'); + } + await expect( + h.caller.emailLogin.verifyLink({ token, twoFaCode: BACKUP_CODE }) + ).rejects.toThrow(LINK_EXPIRED); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('five wrong second-step codes spend a magic link', async () => { + const h = harness({ users: [withTwoFa()] }); + h.magicLinks.push({ id: 'ml-guess', userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + + for (let i = 0; i < 5; i += 1) { + await expect( + h.caller.verifyMagicLink({ token: 'ml-guess', twoFaCode: 'wrong-code' }) + ).rejects.toThrow('Invalid 2FA code.'); + } + await expect( + h.caller.verifyMagicLink({ token: 'ml-guess', twoFaCode: BACKUP_CODE }) + ).rejects.toThrow('This link has expired or is invalid'); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('caps second-step codes for one account across credentials and IPs', async () => { + const h = harness({ users: [withTwoFa()] }); + for (let i = 0; i < 10; i += 1) { + h.magicLinks.push({ id: `ml-cap-${i}`, userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + await expect( + h.callerAt(`198.51.100.${i}`).verifyMagicLink({ token: `ml-cap-${i}`, twoFaCode: 'wrong-code' }) + ).rejects.toThrow('Invalid 2FA code.'); + } + h.magicLinks.push({ id: 'ml-cap-last', userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + await expect( + h.callerAt('198.51.100.99').verifyMagicLink({ token: 'ml-cap-last', twoFaCode: BACKUP_CODE }) + ).rejects.toThrow('Too many tries.'); + }); + + it('two requests racing on one link push the device once', async () => { + const onDeviceStepRequired = vi.fn(async () => ({ pendingLoginId: 'pending-once' })); + const h = harness({ users: [withTwoFa()], hooks: { onDeviceStepRequired } }); + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + const { token } = h.lastEmail(); + + await Promise.allSettled([ + h.caller.emailLogin.verifyLink({ token }), + h.caller.emailLogin.verifyLink({ token }), + ]); + + expect(onDeviceStepRequired).toHaveBeenCalledTimes(1); + }); + + it('every mint site runs beforeSessionMint, and a refusal stops before any push or session', async () => { + const beforeSessionMint = vi.fn(async () => { + throw new Error('Your account has been deleted.'); + }); + const onDeviceStepRequired = vi.fn(async () => ({ pendingLoginId: 'never' })); + const h = harness({ + users: [withTwoFa({ password: await hashPassword('correct horse battery') })], + hooks: { beforeSessionMint, onDeviceStepRequired }, + linkedUserId: 7, + }); + googleVerify.mockResolvedValue({ + getPayload: () => ({ sub: 'google-sub', email: 'ada@example.com', email_verified: true }), + }); + h.magicLinks.push({ id: 'ml-deleted', userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + await expect(h.caller.emailLogin.verifyLink({ token: h.lastEmail().token })).rejects.toThrow( + 'deleted' + ); + await expect(h.caller.verifyMagicLink({ token: 'ml-deleted' })).rejects.toThrow('deleted'); + await expect(h.caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' })).rejects.toThrow( + 'deleted' + ); + await expect( + h.caller.login({ username: 'ada', password: 'correct horse battery' }) + ).rejects.toThrow('deleted'); + + expect(beforeSessionMint.mock.calls.map((call) => (call as unknown[])[1])).toEqual([ + expect.objectContaining({ firstFactor: 'EMAIL_LOGIN' }), + expect.objectContaining({ firstFactor: 'MAGIC_LINK' }), + expect.objectContaining({ firstFactor: 'OAUTH' }), + expect.objectContaining({ firstFactor: 'PASSWORD' }), + ]); + expect(onDeviceStepRequired).not.toHaveBeenCalled(); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('a magic link refuses a banned account', async () => { + const h = harness({ users: [account({ status: 'BANNED' })] }); + h.magicLinks.push({ id: 'ml-banned', userId: 7, usedAt: null, expiresAt: new Date(Date.now() + 60_000) }); + + await expect(h.caller.verifyMagicLink({ token: 'ml-banned' })).rejects.toThrow('banned'); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('a provisioning failure is not hidden behind the create-race recovery', async () => { + const onEmailLoginUserCreated = vi.fn(async () => { + throw new Error('provisioning failed'); + }); + const h = harness({ hooks: { onEmailLoginUserCreated } }); + await h.caller.emailLogin.request({ email: 'new@example.com', app: 'oakbox' }); + const { code } = h.lastEmail(); + + await expect( + h.caller.emailLogin.verifyCode({ email: 'new@example.com', code }) + ).rejects.toThrow('provisioning failed'); + expect(h.database.session.create).not.toHaveBeenCalled(); + }); + + it('a failed send still answers { sent: true }', async () => { + const quiet = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = harness(); + h.emailService.sendLoginEmail.mockRejectedValueOnce( + new Error('MessageRejected: the address is on the suppression list') + ); + + await expect( + h.caller.emailLogin.request({ email: 'bounces@example.com', app: 'oakbox' }) + ).resolves.toEqual({ sent: true }); + } finally { + quiet.mockRestore(); + } + }); + + it('a key inherited from Object is not an app', async () => { + const h = harness({ users: [account({ password: 'hashed' })] }); + + await expect( + h.caller.emailLogin.request({ email: 'ada@example.com', app: 'constructor' }) + ).rejects.toThrow('Unknown app.'); + await expect( + h.caller.sendPasswordResetEmail({ email: 'ada@example.com', app: 'constructor' }) + ).rejects.toThrow('Unknown app.'); + expect(h.sent).toHaveLength(0); + }); + + it("a stranger's requests from one IP cannot use up the owner's", async () => { + const h = harness({ users: [account()] }); + const stranger = h.callerAt('198.51.100.1'); + + for (let i = 0; i < 4; i += 1) { + await stranger.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + } + expect(h.sent).toHaveLength(3); + + await h.caller.emailLogin.request({ email: 'ada@example.com', app: 'oakbox' }); + expect(h.sent).toHaveLength(4); + }); +}); diff --git a/packages/auth/tests/prismaInsensitive.test.ts b/packages/auth/tests/prismaInsensitive.test.ts new file mode 100644 index 0000000..252b8bc --- /dev/null +++ b/packages/auth/tests/prismaInsensitive.test.ts @@ -0,0 +1,103 @@ +/** + * The Prisma adapter's case-insensitive lookups must be EXACT. + * + * Postgres turns Prisma's `mode: 'insensitive'` `equals` into ILIKE, where `_` and + * `%` are wildcards. A lookup for `j_hn@outlook.com` that returns + * `john@outlook.com` hands John's account to whoever owns the look-alike address. + * + * No database here: a fake client runs each filter the way an engine would + * compile it — once as ILIKE (backslash escapes, `_` and `%` wildcards) and once + * as `LOWER(col) = LOWER($1)` — so the adapter is proven exact under both. + */ +import { describe, expect, it } from 'vitest'; + +import { createPrismaAdapter } from '../src/adapters/prismaAdapter'; + +type Row = { id: number; email: string | null; username: string | null }; +type Condition = { equals: string; mode?: string }; +type Where = Partial> & { OR?: Where[] }; + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** ILIKE semantics: `\` escapes the next character, `_` is one character, `%` is any run. */ +function ilikeToRegExp(pattern: string): RegExp { + let source = ''; + for (let i = 0; i < pattern.length; i += 1) { + const char = pattern[i]!; + if (char === '\\' && i + 1 < pattern.length) { + i += 1; + source += escapeRegExp(pattern[i]!); + } else if (char === '_') { + source += '.'; + } else if (char === '%') { + source += '.*'; + } else { + source += escapeRegExp(char); + } + } + return new RegExp(`^${source}$`, 'is'); +} + +function fakePrisma(rows: Row[], engine: 'ilike' | 'lower') { + const matches = (value: string | null, condition: Condition) => { + if (value === null) return false; + return engine === 'ilike' + ? ilikeToRegExp(condition.equals).test(value) + : value.toLowerCase() === condition.equals.toLowerCase(); + }; + const test = (where: Where) => (row: Row): boolean => { + if (where.OR) return where.OR.some((clause) => test(clause)(row)); + return (['email', 'username'] as const).every((field) => { + const condition = where[field]; + return condition ? matches(row[field], condition) : true; + }); + }; + return { + user: { + findFirst: async ({ where }: { where: Where }) => rows.find(test(where)) ?? null, + findMany: async ({ where, take }: { where: Where; take?: number }) => + rows.filter(test(where)).slice(0, take ?? rows.length), + }, + }; +} + +const john: Row = { id: 1, email: 'John@Outlook.com', username: 'john' }; +const underscored: Row = { id: 2, email: 'j_hn@outlook.com', username: 'j_hn' }; + +describe.each(['ilike', 'lower'] as const)('Prisma lookups under %s semantics', (engine) => { + const adapter = (rows: Row[]) => createPrismaAdapter(fakePrisma(rows, engine)).user; + + it('a look-alike address with `_` finds nobody', async () => { + await expect(adapter([john]).findByEmailInsensitive('j_hn@outlook.com')).resolves.toBeNull(); + }); + + it('a `%` in the value finds nobody', async () => { + await expect(adapter([john]).findByEmailInsensitive('%@outlook.com')).resolves.toBeNull(); + }); + + it('still finds the same address in any case', async () => { + await expect(adapter([john]).findByEmailInsensitive('JOHN@outlook.COM')).resolves.toMatchObject({ + id: 1, + }); + }); + + it('finds an address that really contains `_`, and not its look-alike', async () => { + await expect( + adapter([john, underscored]).findByEmailInsensitive('J_HN@outlook.com') + ).resolves.toMatchObject({ id: 2 }); + await expect(adapter([underscored]).findByEmailInsensitive('j_hn@outlook.com')).resolves.toMatchObject({ + id: 2, + }); + }); + + it('usernames and the email-or-username lookup are exact too', async () => { + const user = adapter([john]); + await expect(user.findByUsernameInsensitive('j_hn')).resolves.toBeNull(); + await expect(user.findByEmailOrUsernameInsensitive('j_hn')).resolves.toBeNull(); + await expect(user.findByEmailOrUsernameInsensitive('j_hn@outlook.com')).resolves.toBeNull(); + await expect(user.findByEmailOrUsernameInsensitive('JOHN')).resolves.toMatchObject({ id: 1 }); + await expect( + adapter([john, underscored]).findByEmailOrUsernameInsensitive('j_hn') + ).resolves.toMatchObject({ id: 2 }); + }); +});