Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Design: Multi-Factor Authentication (Phase 2)

This document describes the design for MFA support in Aero2, covering TOTP, WebAuthn/Passkeys, and SMS OTP.

Overview

MFA is configurable per-application (off | optional | required) via app settings. The implementation starts with TOTP (authenticator apps), then adds WebAuthn/Passkeys for phishing-resistant auth, and SMS OTP as a fallback.

Schema

MFA Credentials

CREATE TABLE user_mfa (
  id TEXT PRIMARY KEY,
  app_id TEXT NOT NULL REFERENCES applications(id),
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK(type IN ('totp', 'webauthn', 'sms')),
  secret TEXT,            -- Encrypted TOTP secret (AES-256-GCM)
  credential_data TEXT,   -- JSON for WebAuthn public key, credential ID, sign count
  verified BOOLEAN NOT NULL DEFAULT 0,
  name TEXT,              -- User-provided label (e.g., "My YubiKey")
  created_at DATETIME DEFAULT (datetime('now')),
  UNIQUE(user_id, type)   -- One TOTP per user; multiple WebAuthn via separate design
);

Recovery Codes

CREATE TABLE mfa_recovery_codes (
  id TEXT PRIMARY KEY,
  app_id TEXT NOT NULL REFERENCES applications(id),
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  code_hash TEXT NOT NULL,  -- Hashed recovery code
  used_at DATETIME,
  created_at DATETIME DEFAULT (datetime('now'))
);

User Table Addition

ALTER TABLE users ADD COLUMN mfa_enabled BOOLEAN NOT NULL DEFAULT 0;

TOTP Implementation

Setup Flow

  1. User calls POST /api/users/me/mfa/totp/setup
  2. Server generates 20-byte random secret (base32 encoded)
  3. Secret stored encrypted in user_mfa with verified = false
  4. Returns { secret, otpauth_uri, qr_code_data_url }

Verification

  1. User enters 6-digit code from authenticator app
  2. Server calls POST /api/users/me/mfa/totp/verify with the code
  3. TOTP verified using RFC 6238 (30-second step, ±1 window for clock skew)
  4. On success: user_mfa.verified = true, users.mfa_enabled = true
  5. Generate 10 recovery codes, hash and store
  6. Return recovery codes (shown once)

TOTP Generation (RFC 6238)

// src/backend/totp.ts
function generateSecret(): string;                    // 20-byte random, base32
function generateTOTP(secret: string, time?: number): string;  // 6-digit code
function verifyTOTP(secret: string, code: string, window?: number): boolean;
function generateOTPAuthURI(secret: string, email: string, issuer: string): string;

Uses Web Crypto API for HMAC-SHA1 computation per the RFC.

Login Flow with MFA

User submits credentials (password or OAuth callback)

├─ MFA not enabled → Create session, return cookie

└─ MFA enabled → Issue short-lived mfa_token (JWT, 5-min expiry)
   │             Return { mfa_required: true, mfa_token, mfa_methods: ["totp"] }

   └─ User submits TOTP code

      ├─ POST /api/auth/mfa/verify { mfa_token, code }
      │  Verify TOTP → Create full session → Return cookie

      └─ POST /api/auth/mfa/recovery { mfa_token, recovery_code }
         Verify recovery code → Mark used → Create session
         Warn about remaining recovery codes

The mfa_token JWT contains:

  • sub — user ID
  • token_use"mfa_challenge"
  • exp — 5 minutes from now
  • app_id — application context

WebAuthn / Passkeys

Registration

  1. POST /api/users/me/mfa/webauthn/register-options — generate PublicKeyCredentialCreationOptions
    • RP ID = app subdomain
    • Supported algorithms: ES256, RS256
    • Challenge stored with 5-min TTL
  2. Browser WebAuthn API creates credential
  3. POST /api/users/me/mfa/webauthn/register — verify attestation, store public key

Login

  1. POST /api/auth/webauthn/login-options — generate PublicKeyCredentialRequestOptions
  2. Browser WebAuthn API asserts credential
  3. POST /api/auth/webauthn/login — verify assertion, create session
    • Passkey login bypasses additional MFA (inherently 2FA)

Multiple Credentials

Allow multiple WebAuthn credentials per user (the UNIQUE(user_id, type) constraint is relaxed for WebAuthn entries).

SMS OTP

Phone Number Management

CREATE TABLE user_phone_numbers (
  id TEXT PRIMARY KEY,
  app_id TEXT NOT NULL REFERENCES applications(id),
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  phone_number TEXT NOT NULL,
  phone_verified BOOLEAN NOT NULL DEFAULT 0,
  is_primary BOOLEAN NOT NULL DEFAULT 0,
  created_at DATETIME DEFAULT (datetime('now'))
);

SMS Provider Abstraction

// src/backend/sms.ts
interface SMSSender {
  sendSMS(to: string, message: string): Promise<void>;
}

Configurable via SMS_PROVIDER env var (twilio | vonage | mock).

SMS MFA Flow

  1. After primary authentication, if SMS is the MFA method
  2. Send 6-digit code to user's verified phone number
  3. Code expires in 5 minutes
  4. Rate limit: 3 SMS per phone per 15 minutes

Per-App MFA Policy

Stored in applications.settings:

{
  "mfa_policy": "optional"  // "off" | "optional" | "required"
}
  • off — MFA not available for this app
  • optional — Users can choose to enable MFA
  • required — Users must set up MFA on next login if not already enabled

When required and mfa_enabled = false, the login flow forces MFA setup before granting a session.

Recovery Codes

  • 10 codes generated on MFA setup
  • Each code: 10-character alphanumeric string
  • Stored as hashed values (HMAC-SHA256)
  • Each code is single-use
  • Regeneration available (replaces all existing codes)
  • Shown once at setup with download/copy option

API Endpoints Summary

MethodEndpointDescription
POST/api/users/me/mfa/totp/setupStart TOTP setup
POST/api/users/me/mfa/totp/verifyConfirm TOTP setup
POST/api/users/me/mfa/totp/disableDisable TOTP
POST/api/users/me/mfa/webauthn/register-optionsWebAuthn registration options
POST/api/users/me/mfa/webauthn/registerComplete WebAuthn registration
DELETE/api/users/me/mfa/webauthn/:credentialIdRemove passkey
POST/api/auth/mfa/verifyVerify MFA during login
POST/api/auth/mfa/recoveryUse recovery code
POST/api/auth/webauthn/login-optionsWebAuthn login options
POST/api/auth/webauthn/loginComplete WebAuthn login

Audit Events

  • mfa_enabled — MFA setup completed
  • mfa_disabled — MFA removed
  • mfa_verified — Successful MFA verification during login
  • mfa_recovery_used — Recovery code used
  • passkey_registered — WebAuthn credential added
  • passkey_removed — WebAuthn credential removed
  • passkey_login — Login via passkey

Dependencies

  • Multi-tenancy (Phase 0) — all tables require app_id
  • Email/password auth (Phase 1 #6) — MFA augments the password login flow
  • Account lockout (#4) — failed MFA attempts should contribute to lockout