# Foundation Delta: Two-Factor Authentication (Phone OTP via Firebase)

**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`  
**Referenced by:** All auth flows, `settings-module`, `admin-dashboard`

---

## Overview

Extends the foundation auth system with phone number OTP as a second factor, delivered via Firebase Authentication SMS. Two-factor authentication is optional per user but can be enforced tenant-wide by OWNER/ADMIN. Firebase is used exclusively as an SMS delivery and OTP verification transport — Zync's own JWT system remains the authoritative identity layer.

System admins at `admin.zync.is` already require TOTP (see `foundation-auth-rbac`). This spec covers tenant user 2FA only.

---

## Firebase Configuration

Firebase is used via its client SDK (loaded on auth pages only) and verified server-side via the Firebase Auth REST API (no Node.js SDK — Cloudflare Workers incompatible).

### DEV environment

```
apiKey:             AIzaSyBJ1aZPj2Tf7kbh3ePCrk17BDAl9XiBCGE
authDomain:         zync-dev-dd678.firebaseapp.com
projectId:          zync-dev-dd678
storageBucket:      zync-dev-dd678.firebasestorage.app
messagingSenderId:  452080181712
appId:              1:452080181712:web:12e2f93c5974c2209199d5
```

### Test numbers (DEV only)

| Phone | OTP |
|-------|-----|
| +972 444444444 | 444444 |
| +972 666666666 | 666666 |
| +972 111111111 | 111111 |
| +972 222222222 | 222222 |
| +972 333333333 | 333333 |
| +972 555555555 | 555555 |
| +972 777777777 | 777777 |
| +972 888888888 | 888888 |

Pattern: repeating digit × 6 is the OTP for the corresponding test number. Test numbers never send real SMS.

---

## Data Model

```sql
-- Delta on existing users table
ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN two_factor_phone TEXT; -- SHA-256 hashed; used for display masking only

-- Delta on existing tenants table
ALTER TABLE tenants ADD COLUMN enforce_2fa BOOLEAN NOT NULL DEFAULT false;

-- Backup codes (generated on 2FA enrollment)
CREATE TABLE user_2fa_backup_codes (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  code_hash   TEXT NOT NULL,                 -- SHA-256 of plaintext backup code
  used_at     TIMESTAMPTZ,                   -- NULL = unused
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_2fa_backup_codes_user ON user_2fa_backup_codes(user_id)
  WHERE used_at IS NULL;

-- Temporary session tokens for mid-login 2FA challenge
-- Reuses magic_link_tokens with purpose discriminator
-- purpose = 'pending_2fa'
-- After first-factor success: issue temp token; after second-factor success: issue full JWT
```

### Phone hashing note

`user.two_factor_phone` stores `SHA-256(phone_e164)` in hex. The raw phone number is never stored in Zync's DB. Firebase holds the verified phone number. The hash allows Zync to display a masked version (e.g. `+972 •••• ••44`) by checking only that a phone is enrolled, not what it is. Masking is done by the client after enrollment stores only the last two digits alongside the hash: `user.two_factor_phone_suffix TEXT(2)`.

```sql
ALTER TABLE users ADD COLUMN two_factor_phone_suffix TEXT; -- last 2 digits of phone, for display masking
```

---

## Enrollment Flow

Entry point: `/profile` → **Security** tab → "Enable Two-Factor Authentication"

### Steps

1. User clicks **Enable 2FA** → modal opens
2. User enters phone number (IL format `+972...`; client validates with `libphonenumber-js`)
3. Client initializes `firebase.auth.RecaptchaVerifier` (invisible reCAPTCHA, `size: 'invisible'`)
4. Client calls `firebase.auth.PhoneAuthProvider.verifyPhoneNumber(phoneNumber, recaptchaVerifier)` → Firebase sends SMS OTP
5. User enters 6-digit OTP in modal
6. Client calls `firebase.auth.PhoneAuthCredential.verify(verificationId, code)` → Firebase returns `idToken`
7. Client POSTs `idToken` to `POST /api/auth/2fa/enroll/verify`
8. Worker verifies Firebase ID token via Firebase Auth REST API (see Architecture Decisions)
9. On success: Worker sets `users.two_factor_enabled = true`, `two_factor_phone = SHA256(phone)`, `two_factor_phone_suffix = phone[-2:]`
10. Worker generates 8 backup codes (crypto.randomBytes(6) → base32 encoded, `XXXX-XXXX` format)
11. Worker stores each code as `SHA256(code)` in `user_2fa_backup_codes`
12. Response returns plaintext backup codes (one-time display); client shows them in modal
13. User downloads/copies codes; modal closes

### Backup code format

```
KXMQ-7RPN
JVTB-2WCF
...
```

8 codes, each 8 chars (4+4, uppercase alphanumeric, no ambiguous chars: 0/O, 1/I/L excluded). Generated server-side with `crypto.getRandomValues`.

---

## Login Flow with 2FA

Extends the existing POST /api/auth/login → JWT flow:

### Step 1: First factor (email + password)

```
POST /api/auth/login
{ email, password }

→ 200 { requires_2fa: true, session_token: "temp_<uuid>" }   -- if 2FA enabled
→ 200 { access_token, ... }                                    -- if 2FA not enabled (existing flow)
```

When `requires_2fa: true`:
- Worker creates a `magic_link_tokens` record with `purpose = 'pending_2fa'`, TTL 10 minutes
- `session_token` is the plaintext token (hash stored in DB)
- Client stores `session_token` in memory (not localStorage)

### Step 2: Client initiates OTP

Client shows the 2FA challenge screen. User can choose:
- **SMS OTP**: Firebase `RecaptchaVerifier` + `PhoneAuthProvider.verifyPhoneNumber()` → enter 6-digit code → Firebase `verifyCode()` → Firebase `idToken`
- **Backup code**: user enters one of their 8 backup codes

### Step 3a: SMS OTP verification

```
POST /api/auth/2fa/verify
{ session_token: "temp_<uuid>", firebase_id_token: "eyJ..." }

→ 200 { ... }  -- sets zync_session + zync_refresh cookies; full JWT issued
→ 401 { error: "invalid_token" }
→ 410 { error: "session_expired" }
```

Worker:
1. Validates `session_token` against `magic_link_tokens` (purpose = 'pending_2fa', not expired, not used)
2. Calls Firebase REST: `GET https://identitytoolkit.googleapis.com/v1/accounts:lookup?key={FIREBASE_API_KEY}` with `idToken`
3. Verifies `phone_number` in Firebase response matches `SHA256(phone)` stored in `users.two_factor_phone`
4. Marks temp token as used
5. Issues full Zync JWT + refresh token (existing flow)

### Step 3b: Backup code

```
POST /api/auth/2fa/backup-code
{ session_token: "temp_<uuid>", backup_code: "KXMQ-7RPN" }

→ 200 { ... }  -- sets cookies; full JWT issued
→ 401 { error: "invalid_code" }
→ 410 { error: "session_expired" }
```

Worker:
1. Validates `session_token`
2. Looks up `user_2fa_backup_codes` by `SHA256(backup_code)` where `used_at IS NULL`
3. Sets `used_at = now()` on the matching code
4. Issues full JWT + refresh token

Rate limiting: 5 backup code attempts per `session_token` (tracked in KV, TTL = session TTL). Exceeded → 429 + session invalidated.

---

## Tenant-wide 2FA Enforcement

Entry point: `/settings/security` (OWNER/ADMIN only) → "Require Two-Factor Authentication for all members"

- Toggle sets `tenants.enforce_2fa = true`
- On login: if `tenant.enforce_2fa = true` and `user.two_factor_enabled = false`:
  - First-factor auth succeeds but response is `{ requires_2fa_setup: true, session_token: "temp_..." }`
  - Client redirects to forced-enrollment flow (`/auth/2fa/setup-required`)
  - User cannot access the app until 2FA is enrolled
  - Session token grants access only to `POST /api/auth/2fa/enroll/*` endpoints

### 2FA Enforcement Route Guard Middleware

```ts
// packages/auth/src/middleware.ts
export function require2FAIfEnforced() {
  return async (c: Context, next: Next) => {
    const session = c.get('session')
    if (!session) return c.json({ error: 'Unauthorized' }, 401)

    // Only applies to tenant sessions (not admin sessions)
    if (session.type !== 'user' || !session.tid) return next()

    // Check tenant enforce_2fa flag (cached in JWT at issue time)
    // JWT payload carries `enforce_2fa: boolean` populated at token issuance
    if (session.enforce_2fa && !session.two_factor_verified) {
      return c.json({
        error: '2fa_required',
        message: 'This workspace requires two-factor authentication. Please complete 2FA setup.',
        setup_url: '/auth/2fa/setup-required'
      }, 403)
    }

    return next()
  }
}
```

**JWT payload extension:**

```ts
// JWT payload additions for 2FA enforcement
interface SessionPayload {
  // ... existing fields ...
  enforce_2fa: boolean          // from tenants.enforce_2fa at token issue time
  two_factor_verified: boolean  // true if 2FA was completed in this session
}
```

**Middleware chain order:**

```ts
// Applied after requirePermission, before route handler:
// authMiddleware → require2FAIfEnforced() → requirePermission() → handler

// Exception: 2FA enrollment endpoints bypass this guard (allow unenrolled users to complete setup)
const BYPASS_2FA_GUARD = new Set([
  'POST /api/auth/2fa/enroll/start',
  'POST /api/auth/2fa/enroll/verify',
  'GET /api/auth/me',
  'POST /api/auth/logout',
])
```

**When `enforce_2fa` changes:** existing sessions are not immediately invalidated. The `user_version` KV mechanism (spec 5) is reused: incrementing the tenant version forces all active JWT holders to re-authenticate, at which point the new `enforce_2fa` value is written into the refreshed token. Propagation latency: ≤60s (same as freeze propagation).

---

## Remember Device

Users can opt to skip the 2FA challenge on trusted devices for up to 30 days.

### Enrollment opt-in

On the 2FA challenge screen, a checkbox appears after successful OTP entry:

```
☐ Trust this device for 30 days
   (Skip 2FA on this browser for the next 30 days)
```

If checked, the server issues a **device trust token** (separate from the access/refresh token pair) stored as a `SameSite=Strict; HttpOnly` cookie: `zync_device_trust`.

### Device trust token storage

```sql
CREATE TABLE user_trusted_devices (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  token_hash  TEXT NOT NULL UNIQUE,   -- SHA-256 of the device trust token
  user_agent  TEXT,                   -- browser/OS for display in security settings
  ip_address  INET,                   -- IP at trust time (informational)
  expires_at  TIMESTAMPTZ NOT NULL,   -- now() + 30 days
  revoked_at  TIMESTAMPTZ,
  created_at  TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_trusted_devices_user ON user_trusted_devices(user_id, tenant_id);
```

### Login bypass flow

Modified `POST /api/auth/login` flow when `zync_device_trust` cookie is present:

1. Validate device trust token: `token_hash = SHA256(cookie_value)`; check `user_trusted_devices` (not expired, not revoked, `tenant_id` matches)
2. If valid: skip `requires_2fa: true` response → issue full JWT directly (device is trusted)
3. If invalid/expired: proceed with normal 2FA challenge flow; silently clear the stale cookie

**Device trust tokens are not rotated on use** (unlike refresh tokens). They have a fixed 30-day TTL. Users can revoke individual trusted devices from security settings.

### Security settings: trusted devices list

On `/profile` → Security tab → "Trusted devices" section:

```
Trusted devices (2)
──────────────────────────────────────────────────────────
Chrome on macOS           Added 2026-05-20   Expires 2026-06-19   [Revoke]
Safari on iPhone 15       Added 2026-05-28   Expires 2026-06-27   [Revoke]

[Revoke all trusted devices]
```

```
GET  /api/auth/trusted-devices
     → list trusted devices (not expired, not revoked) for current user + tenant

DELETE /api/auth/trusted-devices/:id
       → revoke one device; sets revoked_at = NOW()

DELETE /api/auth/trusted-devices
       → revoke all trusted devices for current user + tenant
```

### Tenant policy: disable remember-device

If `tenants.enforce_2fa = true`, OWNER/ADMIN can additionally disable the "remember device" option:

```sql
ALTER TABLE tenants ADD COLUMN disable_2fa_remember_device BOOLEAN NOT NULL DEFAULT false;
```

When `disable_2fa_remember_device = true`: the "Trust this device" checkbox is hidden from the 2FA challenge screen, and any existing device trust tokens for that tenant are immediately invalidated.

---

## Disabling 2FA

```
DELETE /api/auth/2fa
{ current_otp: "123456" }   -- must provide current valid OTP or backup code
```

Worker verifies OTP via Firebase before disabling. Disabling deletes all unused backup codes.

---

## API Endpoints

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/auth/2fa/enroll/start` | Session (no 2FA required) | Returns nothing; Firebase SMS sent client-side; placeholder for rate limiting |
| POST | `/api/auth/2fa/enroll/verify` | Session (no 2FA required) | Verify Firebase idToken, complete enrollment, return backup codes |
| POST | `/api/auth/2fa/verify` | Temp session token | Verify SMS OTP during login |
| POST | `/api/auth/2fa/backup-code` | Temp session token | Use backup code during login |
| POST | `/api/auth/2fa/backup-codes/regenerate` | Session (2FA verified) | Invalidate all unused codes, issue 8 new ones |
| DELETE | `/api/auth/2fa` | Session + current OTP | Disable 2FA |

### Rate limits (CF native RateLimiter binding: `RATE_LIMITER_AUTH` — reuses existing)

| Endpoint | Limit |
|----------|-------|
| POST `/api/auth/2fa/verify` | 5/10min per IP |
| POST `/api/auth/2fa/backup-code` | 5/10min per IP |
| POST `/api/auth/2fa/enroll/verify` | 3/10min per user |
| POST `/api/auth/2fa/backup-codes/regenerate` | 3/hour per user |

---

## Screens

### `/profile` → Security tab

- Current 2FA status (enabled/disabled)
- Masked phone number if enrolled: `+972 ••••••44`
- **Enable 2FA** button (if disabled) → enrollment modal
- **Disable 2FA** button (if enabled) → requires OTP confirmation
- **Backup codes** section (if enabled): count of unused codes, **Regenerate** button → requires OTP confirmation

### Enrollment modal (multi-step)

1. Phone entry + country selector (default: 🇮🇱 +972)
2. OTP entry (6-digit, auto-submit on 6th digit)
3. Backup codes display: grid of 8 codes, **Download as .txt** button, **Copy all** button, checkbox "I've saved my backup codes" (gates the Close button)

### Login 2FA challenge screen

- Shows masked phone: "Enter the code sent to +972 ••••••44"
- 6-digit OTP input (auto-submit)
- "Use a backup code instead" link
- Resend OTP link (rate-limited: once per 60s)

### Backup code input screen (alternate flow)

- Single text input: `____-____`
- "Use SMS instead" link

### Forced setup screen (`/auth/2fa/setup-required`)

- Banner: "Your workspace requires two-factor authentication"
- Inline enrollment flow (same steps as modal but full-page)

### `/settings/security` (OWNER/ADMIN)

- "Require 2FA for all workspace members" toggle
- Info text: "Members without 2FA will be prompted to enroll on next login"
- Shows count: X of Y members have 2FA enabled

---

## Permissions

| Action | Required permission | Notes |
|--------|--------------------|-|
| Enroll self in 2FA | Authenticated session | Any active user |
| Disable own 2FA | Authenticated session + current OTP | Any active user |
| Regenerate own backup codes | Authenticated session + current OTP | Any active user |
| Toggle tenant enforce_2fa | `settings:write` | OWNER/ADMIN only |
| View member 2FA status | `users:read` | In `/settings/security` member list |

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| SMS delivery | Firebase Auth | Proven SMS gateway with Israeli carrier support; handles carrier fallback; recaptcha included |
| Server-side Firebase verification | Firebase Auth REST API (`identitytoolkit.googleapis.com`) | Firebase Admin SDK requires Node.js; CF Workers use REST directly |
| Phone storage | SHA-256 hash only + 2-digit suffix | Phone numbers are PII; Firebase holds the source of truth; hash allows de-duplication check |
| Backup codes | 8 single-use base32 codes | Industry standard; covers Firebase/SMS outage scenarios |
| Temp session token | Reuses `magic_link_tokens` with `purpose = 'pending_2fa'` | No new table needed; existing TTL + invalidation logic applies |
| Firebase client SDK loading | Code-split, auth pages only | SDK is ~100KB; not needed on app pages |
| TOTP vs Phone OTP | Phone OTP | Simpler UX for IL market; no authenticator app required; Firebase handles delivery |
| 2FA for system admins | Separate TOTP flow (existing spec) | Admin plane security requires hardware-bound second factor; phone OTP acceptable for tenant users |

### Firebase ID token verification (Worker pseudocode)

```ts
// packages/auth/src/firebase-verify.ts
async function verifyFirebaseIdToken(idToken: string, env: Env): Promise<FirebaseUser> {
  // 1. Fetch Firebase public keys (cached in cache.default, TTL from Cache-Control header)
  const keysRes = await cache.default.match('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com')
    ?? await fetch('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com')
  const publicKeys = await keysRes.json()

  // 2. Decode JWT header → find matching key ID
  // 3. Verify RS256 signature with crypto.subtle.verify
  // 4. Verify claims: iss = "https://securetoken.google.com/{projectId}", aud = projectId, exp > now
  // 5. Return { uid, phone_number }
}
```

No third-party JWT library needed — Workers' `crypto.subtle` supports RS256 natively.

---

## Foundation Delta Additions

### Bindings

No new CF bindings required. Reuses `RATE_LIMITER_AUTH`.

### Secrets

| Secret | Purpose |
|--------|---------|
| `FIREBASE_API_KEY` | Firebase Web API key (used in client SDK config + server REST calls) |
| `FIREBASE_PROJECT_ID` | Firebase project ID for token verification audience check |

> Both secrets are environment-specific (`_DEV` / `_PROD` suffixed in wrangler.toml). The DEV values are listed above and are safe to commit (Firebase project is dev-only).

### Schema deltas on `users`

(See Data Model section above — `two_factor_enabled`, `two_factor_phone`, `two_factor_phone_suffix`. Not repeated here to avoid a duplicate `ADD COLUMN` migration.)

### Schema delta on `tenants`

(See Data Model section above — `enforce_2fa`.)

### New table: `user_2fa_backup_codes`

(See Data Model section above.)

### Schema delta on `magic_link_tokens`

`purpose` discriminator column already added by spec 13 (`purpose = 'timer'`, `purpose = 'portal'`). This spec adds:
- `purpose = 'pending_2fa'` — mid-login challenge session
- `purpose = 'pending_2fa_setup'` — forced enrollment session (enforce_2fa flow)
