Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .changeset/email-login-and-factor-class-gate.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/auth/prisma/schema.device.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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, "<id>:<code>")
attempts Int @default(0)
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())

@@index([email, createdAt])
}

// ==============================================================================
// Passkey Model (optional — enable with features.passkey)
// ==============================================================================
Expand Down
24 changes: 24 additions & 0 deletions packages/auth/prisma/schema.standard.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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, "<id>:<code>")
attempts Int @default(0)
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())

@@index([email, createdAt])
}

// ==============================================================================
// Passkey Model (optional — enable with features.passkey)
// ==============================================================================
Expand Down
49 changes: 49 additions & 0 deletions packages/auth/src/adapters/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -164,5 +192,26 @@ export interface DatabaseAdapter {
findById(id: string): Promise<AuthMagicLink | null>;
create(data: { userId: number; expiresAt: Date }): Promise<AuthMagicLink>;
markUsed(id: string): Promise<AuthMagicLink>;
/**
* 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<boolean>;
};

/** Optional — required only when features.emailLogin is enabled. */
emailLoginAttempt?: {
create(data: CreateEmailLoginAttemptData): Promise<AuthEmailLoginAttempt>;
findByTokenHash(tokenHash: string): Promise<AuthEmailLoginAttempt | null>;
/** The newest attempt for this email that is neither consumed nor expired. */
findLatestOpenByEmail(email: string): Promise<AuthEmailLoginAttempt | null>;
/** Consume only if still open, in one conditional write. True for exactly one caller. */
consume(id: string): Promise<boolean>;
/** Count one code try, atomically. Resolves to the new count. */
incrementAttempts(id: string): Promise<number>;
/** Consume every open attempt for this email — a newer request replaces them. */
consumeOpenByEmail(email: string): Promise<void>;
};
}
53 changes: 50 additions & 3 deletions packages/auth/src/adapters/email.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,15 +36,25 @@ export interface EmailAdapter {
sendVerificationEmail(email: string, code: string): Promise<void>;

/**
* 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<void>;
sendPasswordResetEmail(
email: string,
token: string,
options?: PasswordResetEmailOptions
): Promise<void>;

/**
* Send OTP for passwordless login or 2FA reset
*/
sendOTPEmail(email: string, otp: number): Promise<void>;

/**
* Send an email sign-in link and code. Required when `features.emailLogin` is on.
*/
sendLoginEmail?(params: LoginEmailParams): Promise<void>;

/**
* Send login notification to existing devices
*/
Expand All @@ -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})`
Expand All @@ -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) {
Expand All @@ -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}`);
Expand Down
Loading
Loading