Skip to content

Feature: Per-user two-factor authentication (TOTP) via Login Profiles #28

Description

@bordoni

Feature: Per-user two-factor authentication (TOTP) via Login Profiles

Summary

Deliver end-to-end TOTP-based two-factor authentication (2FA) wired into the Login Profile policy system. Users may self-serve enroll (with admin opt-in per profile), and admins may enforce 2FA per user. WorkOS maintains the actual factors; WordPress stores only a boolean state + required flag per user. When a user with active 2FA attempts login through a profile that excludes 2FA, block the login with a clear error.

Background / current state

Existing infrastructure:

  • Profile value object (src/WorkOS/Auth/AuthKit/Profile.php) already has an mfa field with enforce (never|if_required|always) and factors (totp|sms|webauthn) — constants at lines 37–43.
  • Profile methods allows_factor() at line 499 and get_mfa() at line 639 are fully functional.
  • LoginCompleter (src/WorkOS/Auth/AuthKit/LoginCompleter.php) already checks the profile's MFA policy and surfaces pending factors at lines 93–119. The decision to proceed, challenge, or error is partially baked in.
  • API Client (src/WorkOS/Api/Client.php) has all required MFA factor methods:
    • enroll_totp_factor() at line 518 (returns QR code + secret)
    • list_auth_factors() at line 505
    • delete_auth_factor() at line 554
    • challenge_auth_factor() at line 569
    • authenticate_with_totp() at line 273 (grant_type urn:workos:oauth:grant-type:mfa-totp)
  • REST endpoints under src/WorkOS/REST/Auth/Mfa.php are fully implemented:
    • POST /auth/mfa/totp/enroll at line 287 — begins enrollment, returns factor_id + QR + secret
    • GET /auth/mfa/factors at line 251 — lists current user's enrolled factors
    • POST /auth/mfa/challenge at line 132 — starts a challenge on a factor
    • POST /auth/mfa/verify at line 189 — completes login with TOTP code
    • POST /auth/mfa/factor/delete at line 409 — removes a factor (ownership-checked)
  • User model (src/WorkOS/User.php) has meta-key constants for session / org data; profile hash at line 79. No 2FA-specific keys yet.
  • Activity logging (src/WorkOS/ActivityLog/EventLogger.php) is static-method-based; existing event types are 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated' (see AdminPage.php line 94).
  • Admin user-edit screen (src/WorkOS/Admin/UserProfile.php) renders WorkOS metadata but has no Sessions or 2FA panels yet.

What is missing:

  • Per-user state in WordPress usermeta (_workos_2fa_enabled, _workos_2fa_required).
  • Logic to reconcile the enabled flag against WorkOS's list_auth_factors() to detect drift.
  • Admin checkbox + status display on the user-edit screen to set/view the required flag.
  • Self-service React component + UI surfaces (account page panel, shortcode, Gutenberg block).
  • A REST endpoint to "confirm" a TOTP enrollment and flip the _workos_2fa_enabled flag.
  • An endpoint to read the current user's 2FA status (enabled + required flags).
  • Enforcement decision table in LoginCompleter that combines profile policy + user flags + actual enrolled factors.
  • Explicit block + error message when a user with 2FA active tries to log in through a profile that disables 2FA.
  • Admin misconfiguration notice on the Profile editor when a profile excludes 2FA but users with 2FA active would route to it.
  • Activity-log event types for 2FA lifecycle (enroll, remove, require-set, require-cleared, challenge-succeeded, challenge-failed, profile-conflict-blocked).
  • Diagnostics check confirming WorkOS MFA factor API reachability.
  • Tests and docs.

Goals & acceptance criteria

  • Users can self-service enroll in TOTP 2FA (if their current Login Profile allows it) via a "Two-factor authentication" account panel or shortcode.
  • Enrollment flow displays a QR code + manual secret; user scans/enters secret into an authenticator app and confirms with a code.
  • On successful confirmation, _workos_2fa_enabled is set to true in usermeta and reflected immediately in the UI.
  • Users can view their enrolled TOTP factor and remove it (with optional re-authentication or confirmation).
  • Admins can set a per-user "Require two-factor authentication" flag on the user-edit screen.
  • Setting the required flag forces the user to enroll before next login (blocks login with a clear message until enrolled).
  • When a user with 2FA active logs in through a profile whose mfa policy excludes 2FA (enforce: never or totp not in factors list), the login is blocked with a clear, translatable error message.
  • The Profile editor displays an admin notice when a profile's MFA policy conflicts with enrolled users (e.g., "3 users have two-factor enabled but this profile disables it").
  • Activity log records 2FA events: enroll, remove, require-set, require-cleared, challenge-succeeded, challenge-failed, profile-conflict-blocked.
  • Diagnostics page includes a check for WorkOS MFA factor API reachability.
  • WPUnit test suites cover: enrollment, removal, required-flag enforcement, profile-conflict blocking, admin status display.
  • Browser smoke test on Staging confirms end-to-end happy path (enroll, login with challenge, remove).
  • docs/two-factor.md documents the feature and all its surfaces (API, admin, self-service, error cases).

Technical design

Files to add

  • src/WorkOS/TwoFactor/Controller.php — Main feature controller (di52 registration).
  • src/WorkOS/TwoFactor/TwoFactorManager.php — Business logic for enroll/remove/status reconciliation.
  • src/WorkOS/REST/Auth/TwoFactor.php — REST endpoints for self-service (POST enroll-confirm, GET status, DELETE remove).
  • src/WorkOS/Admin/TwoFactor/UserEdit.php — Admin user-edit screen panel (require checkbox + status display).
  • src/WorkOS/Admin/TwoFactor/ProfileEditor.php — Profile editor misconfiguration notice.
  • src/WorkOS/Admin/TwoFactor/DiagnosticsCheck.php — Diagnostics check for MFA API.
  • src/js/two-factor/ — React component (enroll QR, verify code, list/remove factors).
    • TwoFactorComponent.tsx — Main container.
    • EnrollFlow.tsx — QR + secret + confirmation.
    • FactorsList.tsx — View + remove existing factors.
    • api.ts — HTTP client for the REST endpoints.
  • tests/wpunit/TwoFactorEnrollmentTest.php — Enroll / confirm / remove happy paths.
  • tests/wpunit/TwoFactorEnforcementTest.php — Enforcement decision table (all branches).
  • tests/wpunit/TwoFactorProfileConflictTest.php — Block on profile mismatch.
  • tests/wpunit/TwoFactorAdminTest.php — Admin require flag + status display.
  • docs/two-factor.md — Complete feature documentation.

Files to change

  • src/WorkOS/User.php — Add META_2FA_ENABLED and META_2FA_REQUIRED constants (lines 60–86). Add accessors is_2fa_enabled(), is_2fa_required(), update snapshot() to include 2FA state.
  • src/WorkOS/Auth/AuthKit/LoginCompleter.php — Expand the MFA logic (lines 89–132) to implement the full enforcement decision table: check profile policy + user flags + actual enrolled factors, block on conflict, force-enroll on required flag.
  • src/WorkOS/Admin/UserProfile.php — Add a new section after "Recent Events" (after line 106) to render a "Two-factor authentication" panel with require checkbox and status display.
  • src/WorkOS/Admin/LoginProfiles/RestApi.php — In the profile response (e.g., to_editor_array() phase), add a warnings/notices section that flags profiles with MFA policy conflicts.
  • src/WorkOS/ActivityLog/AdminPage.php — Update the event_types filter dropdown (line 94) to include the new 2FA event types.
  • src/WorkOS/Admin/DiagnosticsPage.php — Add a 2FA factor API check in run_all_checks() (after line 175).
  • src/WorkOS/UI/Controller.php — Register the TwoFactor block + shortcode.
  • src/WorkOS/Controller.php — Register TwoFactor\Controller in the DI container (in the existing hardcoded list).

REST endpoints

Self-service (authenticated, profile-scoped nonce):

  • POST /wp-json/workos/v1/auth/2fa/enroll/confirm — Confirms TOTP enrollment.

    • Input: factor_id (from enroll), code (TOTP code from authenticator).
    • Output: { confirmed: true, enabled: true } or error.
    • Action: Calls Client::verify_auth_challenge() to confirm the factor, then sets _workos_2fa_enabled = true.
  • GET /wp-json/workos/v1/auth/2fa/status — Gets current user's 2FA state.

    • Output: { enabled: bool, required: bool, factors: [...] }.
    • Reconciles _workos_2fa_enabled against list_auth_factors() to detect drift.
  • DELETE /wp-json/workos/v1/auth/2fa/factor — Removes a user's TOTP factor.

    • Input: factor_id.
    • Action: Calls Client::delete_auth_factor(), then sets _workos_2fa_enabled = false if no factors remain.

Admin-only (manage_options):

  • PUT /wp-json/workos/v1/admin/users/{id}/2fa/require — Sets or clears the required flag.
    • Input: required (bool).
    • Action: Updates _workos_2fa_required user meta; logs activity event.

Note: The existing POST /auth/mfa/totp/enroll, POST /auth/mfa/challenge, POST /auth/mfa/verify, and GET /auth/mfa/factors endpoints in Mfa.php remain unchanged and are reused.

Data model (options / user_meta / tables)

User meta keys (add to User.php constants):

  • _workos_2fa_enabled (bool, stored as '0'|'1') — Mirrored from WorkOS; true once user confirms a TOTP factor.
  • _workos_2fa_required (bool) — Admin-set; when true, user must enroll before login completion.

Activity log event types (add to admin event_types dropdown, EventLogger usage):

  • 2fa.enrolled — User confirmed TOTP enrollment.
  • 2fa.removed — User deleted a TOTP factor.
  • 2fa.required_set — Admin set require flag on a user.
  • 2fa.required_cleared — Admin cleared require flag on a user.
  • 2fa.challenge_succeeded — User completed MFA challenge during login.
  • 2fa.challenge_failed — User failed MFA challenge (incorrect code).
  • 2fa.profile_conflict_blocked — Login blocked due to profile MFA policy mismatch.

Hooks & filters (new actions/filters this feature adds, named)

Filters:

  • workos_2fa_enabled (bool, default true) — Gate the entire 2FA feature. Return false to disable all 2FA UI and enforcement.
  • workos_2fa_can_manage (bool, $user_id) — Capability check for self-service 2FA management. Default: user is logged in.
  • workos_2fa_required_for_user (bool, $user_id) — Programmatic override to enforce 2FA for a user (returns true if user should be required to enroll).
  • workos_2fa_profile_conflict_message (string, $user_id, $profile) — Customize the error message when login is blocked due to profile mismatch.

Actions:

  • workos_2fa_enrolled (int $user_id, array $factor_data) — After a user successfully confirms enrollment.
  • workos_2fa_removed (int $user_id, string $factor_id) — After a user deletes a factor.
  • workos_2fa_required_changed (int $user_id, bool $required) — After admin changes the required flag.
  • workos_2fa_challenge_succeeded (int $user_id) — User successfully completed MFA challenge.
  • workos_2fa_challenge_failed (int $user_id, string $error_code) — User failed MFA challenge.
  • workos_2fa_profile_conflict_blocked (int $user_id, string $profile_slug) — Login blocked due to profile mismatch (no login occurs).

WorkOS API

Endpoints used (wrapping existing Client methods):

  • POST /user_management/users/{user_id}/auth_factors (type: totp) — Enroll a TOTP factor.

    • Wrapped by Client::enroll_totp_factor() at line 518; returns factor_id, QR code, secret, URI.
  • GET /user_management/users/{user_id}/auth_factors — List enrolled factors.

    • Wrapped by Client::list_auth_factors() at line 505; returns array of factors with type, created_at, id.
  • POST /user_management/auth_factors/{factor_id}/challenge — Create a challenge for a TOTP factor.

    • Wrapped by Client::challenge_auth_factor() at line 569; returns challenge_id, expires_at.
  • POST /user_management/auth_challenges/{challenge_id}/verify — Verify a TOTP code against a challenge.

    • Wrapped by Client::verify_auth_challenge() at line 587; returns confirmation or error.
  • DELETE /user_management/auth_factors/{factor_id} — Delete a factor.

    • Wrapped by Client::delete_auth_factor() at line 554.
  • POST /user_management/authenticate (grant_type: urn:workos:oauth:grant-type:mfa-totp) — Complete login with TOTP.

    • Wrapped by Client::authenticate_with_totp() at line 273; used when user provides code to verify a pending_authentication_token.

Note: The plugin does NOT store TOTP secrets, QR codes, or factor metadata in WordPress. All that lives in WorkOS. WordPress mirrors only the boolean state (_workos_2fa_enabled) and the admin requirement flag (_workos_2fa_required).

Edge cases & security

  • Rate limiting: POST /auth/mfa/totp/enroll and DELETE endpoints should rate-limit per authenticated user (e.g., 3 per minute) to prevent brute-force factor enrollment/removal. Reuse the existing RateLimiter pattern from Mfa.php.
  • Ownership check: DELETE /auth/2fa/factor MUST verify the current user owns the factor being deleted (call list_auth_factors() to confirm), matching the pattern in Mfa.php:409–458.
  • Drift detection: When reading 2FA status, reconcile _workos_2fa_enabled against list_auth_factors(). If the user has no factors in WorkOS but WP says enabled, clear the flag. If the user has factors but WP says disabled, set enabled = true (and log a reconciliation event).
  • Profile conflict blocking: When a user has enrolled in 2FA but the current login profile excludes it, return a 403 with a translatable, user-friendly message (not a cryptic error code). Do NOT silently bypass or force-enforce — block and explain.
  • Required flag with no factors: If admin sets required flag but user has no enrolled factors, LoginCompleter must refuse completion and surface a "you must enroll first" error.
  • Nonce protection: All admin endpoints (PUT /admin/users/{id}/2fa/require) require WP REST nonce + manage_options cap.
  • Capability check: Self-service endpoints require current_user_id() > 0 + pass the workos_2fa_can_manage filter.
  • Admin misconfiguration: When computing the profile-conflict notice, query for all SSO users with _workos_2fa_enabled = 1, then for each, resolve their matching profile. Count how many would hit the conflict. Display count in the notice (e.g., "3 users with 2FA enabled would be blocked by this profile").
  • Enumeration safety: 2FA status endpoints should return empty/false rather than exposing user existence. GET /auth/2fa/status on an unauthenticated or non-WorkOS-linked user returns 401.
  • XSS in QR display: QR code is returned as a data URL; sanitize when rendering (React should handle automatically, but verify in tests).
  • SMS/WebAuthn out of scope: The profile already supports sms/webauthn in the factors list, but this issue delivers TOTP only. Code should be architected so future factors can be added without refactoring LoginCompleter.

Testing

WPUnit (/slic)

File: tests/wpunit/TwoFactorEnrollmentTest.php

  • Test enrollment flow: POST /auth/mfa/totp/enroll returns factor_id + QR + secret.
  • Test confirmation: POST /auth/2fa/enroll/confirm with valid code sets _workos_2fa_enabled = true.
  • Test confirmation failure: Invalid code returns 400, flag remains false.
  • Test removal: DELETE /auth/2fa/factor removes factor, sets enabled = false if no factors remain.
  • Test removal ownership: Attempting to delete another user's factor returns 404.

File: tests/wpunit/TwoFactorEnforcementTest.php

  • Test profile allow-list enforcement: User with TOTP can login through a profile that allows totp.
  • Test profile block on disallow: User with TOTP is blocked with a 403 when logging in through a profile with enforce: never.
  • Test profile block on missing factor: User with required flag but no enrolled factors is blocked with a clear error.
  • Test required flag overrides user opt-out: Required flag forces enrollment regardless of user preference.
  • Test profile allow overrides requirement: If profile says "if_required" and user is not required, login proceeds without MFA.
  • Test full decision table: All combinations of (enforce: never/if_required/always) × (user.required: true/false) × (has_enrolled: true/false) → expected outcome (proceed/challenge/block with specific message).

File: tests/wpunit/TwoFactorProfileConflictTest.php

  • Test profile editor notice: When a profile disables 2FA but N users have it enrolled, notice says "N users have two-factor enabled…"
  • Test conflict error message is translatable and user-friendly.
  • Test notice only appears when there is actual conflict (no false positives).

File: tests/wpunit/TwoFactorAdminTest.php

  • Test admin can set require flag on user-edit screen.
  • Test setting require flag logs activity event '2fa.required_set'.
  • Test clearing require flag logs '2fa.required_cleared'.
  • Test status display on user-edit shows enabled/required/pending-enrollment state.
  • Test WP users.php column displays 2FA indicator (icon or badge).
  • Test cap check: non-admin cannot PUT /admin/users/{id}/2fa/require.

Browser smoke

Staging environment:

  1. Create a test user (no 2FA yet).
  2. Sign in as the test user; navigate to account settings / 2FA panel.
  3. Click "Enable two-factor authentication".
  4. Scan QR code with an authenticator app (or use manual secret).
  5. Enter the TOTP code from the app; confirm.
  6. Verify _workos_2fa_enabled is now true in the DB.
  7. Log out.
  8. Log back in with same user; MFA challenge appears during login.
  9. Enter code from authenticator; login completes.
  10. Sign in as admin; go to user-edit for the test user.
  11. Verify "Two-factor authentication" section shows enabled + option to require.
  12. Check "Require two-factor authentication"; save.
  13. Create a new Login Profile with mfa.enforce: never.
  14. Attempt login through that profile; login is blocked with a clear error message.
  15. Admin goes to profile editor; misconfiguration notice appears (1 user affected).
  16. Return to user-edit, click remove TOTP factor; factor is deleted.
  17. Verify _workos_2fa_enabled is false in the DB.

Diagnostics & activity log

New diagnostic check (add to DiagnosticsPage.php, run_all_checks() at line 161):

  • Check: WorkOS MFA Factor API — Call Client::list_auth_factors() with a dummy (non-existent) WorkOS user ID (e.g., 'user_test_nonexistent'). If the API responds (even with "not found"), the endpoint is reachable. If timeout or invalid credentials, fail the check. Label: "WorkOS MFA API Connectivity". Details: "✓ MFA factor API is reachable" or "✗ Could not reach MFA factor API (verify API key and network)".

Activity log event types (add to EventLogger usage and AdminPage dropdown):

  • 2fa.enrolled — Logged when user successfully confirms TOTP enrollment. Metadata: { factor_id: string }.
  • 2fa.removed — Logged when user deletes a TOTP factor. Metadata: { factor_id: string }.
  • 2fa.required_set — Logged when admin sets the required flag. Metadata: { admin_user_id: int }.
  • 2fa.required_cleared — Logged when admin clears the required flag. Metadata: { admin_user_id: int }.
  • 2fa.challenge_succeeded — Logged when user successfully completes MFA during login. Metadata: { factor_type: 'totp' }.
  • 2fa.challenge_failed — Logged when user fails MFA challenge (wrong code). Metadata: { factor_type: 'totp', error: string }.
  • 2fa.profile_conflict_blocked — Logged when login is blocked due to profile mismatch. Metadata: { profile_slug: string, factor_type: 'totp' }.

Documentation

File: docs/two-factor.md

Document the following (matching the style of docs/password-reset.md):

  1. At a glance — Table of all surfaces (self-service enroll, self-service remove, admin require, admin status, error cases).
  2. Enroll flow — Step-by-step: user navigates to 2FA panel, clicks enroll, receives QR, scans, confirms code, factor is active.
  3. Login flow with MFA — User authenticates with password/magic/oauth, receives pending token + factor list, user verifies code, login completes.
  4. Profile policy conflicts — What happens when user has 2FA but profile disables it (blocked login, error message, admin notice).
  5. Admin enforcement — How to set "require 2FA" on a user, what that means for next login.
  6. REST API — All endpoints under /auth/2fa and /admin/*/2fa with input/output examples.
  7. Hooks and filters — List all actions/filters this feature adds; show examples of filtering enrollment, custom error messages, programmatic requirement.
  8. Activity logging — Which events are logged; how to query them.
  9. Diagnostics — What the MFA API check does and how to troubleshoot if it fails.
  10. Don't do this — Anti-patterns (e.g., silently disabling 2FA on conflict, storing secrets in WP, mixing admin-required with profile-always, etc.).

Out of scope

  • SMS and WebAuthn factors — Profile.php already has constants and allowlist entries for sms and webauthn, but this issue delivers only TOTP end-to-end. SMS and WebAuthn (passkeys) are deferred to 1.1.1 or later.
  • Session management UI — Although related (2FA + sessions), the Sessions panel is a separate feature in the 1.1.0 roadmap.
  • Passwordless 2FA — i.e., passkey enrollment without a password. Deferred with WebAuthn.
  • Account recovery codes — Deferred; adds complexity to recovery, testing, and admin UX.
  • Backup authentication methods — e.g., fallback email if TOTP unavailable. Deferred.
  • 2FA at organization level (org-enforce) — This issue enforces per-user + per-profile only. Org-level policy is future work.
  • White-label / custom UI for 2FA screens — Self-service uses the React component + hooks; admin screens are standard WP styling.

Dependencies & sequencing

Within 1.1.0:
This feature is independent and should be sequenced first because:

  • It introduces no breaking changes; all additions are additive.
  • No other 1.1.0 features are blocked by 2FA (though Sessions UI and Admin Portal embed may want to display 2FA status, they can defer that integration to their own issues).
  • Early delivery of 2FA allows dependent features to be tested against it in later sprints.

Cross-feature dependencies:

  • Webhook event coverage (separate issue) — No dependency; 2FA uses existing WorkOS REST endpoints and does not require webhook support (challenge/factor lifecycle is stateless from WorkOS perspective).
  • Sessions UI (separate issue) — Independent; may optionally display 2FA status in a future update, but Sessions UI works without 2FA knowledge.
  • Admin Portal embed (separate issue) — Independent; may optionally link to 2FA settings, but embed works without it.
  • Per-user SCIM sync (if in 1.1.0) — Independent; SCIM sync and 2FA are orthogonal user management concerns.

Version forward compatibility:

  • All code and API surfaces are architected for future SMS/WebAuthn additions (factors are already polymorphic in the Profile and Client).
  • The decision table in LoginCompleter is generic and does not hard-code TOTP logic.
  • Admin notice system is reusable for other profile conflicts (e.g., method conflicts in the future).

1.1.0 milestone navigation

Recommended landing order — #31#30#29; #28 is independent and may land any time. Each lands as soon as it is green (no monolithic 1.1.0 PR).

Milestone: https://github.com/bordoni/integration-workos/milestone/4

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: authAuthentication / AuthKit / Login ProfilesenhancementNew feature or requestsecuritySecurity-relevant change

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions