You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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").
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).
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.
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.
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).
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:
Create a test user (no 2FA yet).
Sign in as the test user; navigate to account settings / 2FA panel.
Click "Enable two-factor authentication".
Scan QR code with an authenticator app (or use manual secret).
Enter the TOTP code from the app; confirm.
Verify _workos_2fa_enabled is now true in the DB.
Log out.
Log back in with same user; MFA challenge appears during login.
Enter code from authenticator; login completes.
Sign in as admin; go to user-edit for the test user.
Verify "Two-factor authentication" section shows enabled + option to require.
Check "Require two-factor authentication"; save.
Create a new Login Profile with mfa.enforce: never.
Attempt login through that profile; login is blocked with a clear error message.
Admin goes to profile editor; misconfiguration notice appears (1 user affected).
Return to user-edit, click remove TOTP factor; factor is deleted.
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):
At a glance — Table of all surfaces (self-service enroll, self-service remove, admin require, admin status, error cases).
Enroll flow — Step-by-step: user navigates to 2FA panel, clicks enroll, receives QR, scans, confirms code, factor is active.
Login flow with MFA — User authenticates with password/magic/oauth, receives pending token + factor list, user verifies code, login completes.
Profile policy conflicts — What happens when user has 2FA but profile disables it (blocked login, error message, admin notice).
Admin enforcement — How to set "require 2FA" on a user, what that means for next login.
REST API — All endpoints under /auth/2fa and /admin/*/2fa with input/output examples.
Hooks and filters — List all actions/filters this feature adds; show examples of filtering enrollment, custom error messages, programmatic requirement.
Activity logging — Which events are logged; how to query them.
Diagnostics — What the MFA API check does and how to troubleshoot if it fails.
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.
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).
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:
mfafield withenforce(never|if_required|always) andfactors(totp|sms|webauthn) — constants at lines 37–43.allows_factor()at line 499 andget_mfa()at line 639 are fully functional.enroll_totp_factor()at line 518 (returns QR code + secret)list_auth_factors()at line 505delete_auth_factor()at line 554challenge_auth_factor()at line 569authenticate_with_totp()at line 273 (grant_typeurn:workos:oauth:grant-type:mfa-totp)POST /auth/mfa/totp/enrollat line 287 — begins enrollment, returns factor_id + QR + secretGET /auth/mfa/factorsat line 251 — lists current user's enrolled factorsPOST /auth/mfa/challengeat line 132 — starts a challenge on a factorPOST /auth/mfa/verifyat line 189 — completes login with TOTP codePOST /auth/mfa/factor/deleteat line 409 — removes a factor (ownership-checked)What is missing:
_workos_2fa_enabled,_workos_2fa_required)._workos_2fa_enabledflag.Goals & acceptance criteria
_workos_2fa_enabledis set to true in usermeta and reflected immediately in the UI.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— AddMETA_2FA_ENABLEDandMETA_2FA_REQUIREDconstants (lines 60–86). Add accessorsis_2fa_enabled(),is_2fa_required(), updatesnapshot()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 inrun_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.factor_id(from enroll),code(TOTP code from authenticator).{ confirmed: true, enabled: true }or error.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.{ enabled: bool, required: bool, factors: [...] }._workos_2fa_enabledagainstlist_auth_factors()to detect drift.DELETE
/wp-json/workos/v1/auth/2fa/factor— Removes a user's TOTP factor.factor_id.Client::delete_auth_factor(), then sets_workos_2fa_enabled = falseif no factors remain.Admin-only (manage_options):
/wp-json/workos/v1/admin/users/{id}/2fa/require— Sets or clears the required flag.required(bool)._workos_2fa_requireduser meta; logs activity event.Note: The existing
POST /auth/mfa/totp/enroll,POST /auth/mfa/challenge,POST /auth/mfa/verify, andGET /auth/mfa/factorsendpoints in Mfa.php remain unchanged and are reused.Data model (options / user_meta / tables)
User meta keys (add to
User.phpconstants):_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.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.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.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.Client::verify_auth_challenge()at line 587; returns confirmation or error.DELETE /user_management/auth_factors/{factor_id}— Delete a factor.Client::delete_auth_factor()at line 554.POST /user_management/authenticate(grant_type:urn:workos:oauth:grant-type:mfa-totp) — Complete login with TOTP.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
list_auth_factors()to confirm), matching the pattern in Mfa.php:409–458._workos_2fa_enabledagainstlist_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).manage_optionscap.current_user_id() > 0+ pass theworkos_2fa_can_managefilter._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").Testing
WPUnit (/slic)
File: tests/wpunit/TwoFactorEnrollmentTest.php
_workos_2fa_enabled = true.File: tests/wpunit/TwoFactorEnforcementTest.php
File: tests/wpunit/TwoFactorProfileConflictTest.php
File: tests/wpunit/TwoFactorAdminTest.php
Browser smoke
Staging environment:
_workos_2fa_enabledis now true in the DB._workos_2fa_enabledis false in the DB.Diagnostics & activity log
New diagnostic check (add to DiagnosticsPage.php, run_all_checks() at line 161):
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):
Out of scope
Dependencies & sequencing
Within 1.1.0:
This feature is independent and should be sequenced first because:
Cross-feature dependencies:
Version forward compatibility:
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).
session.created/session.revokedeventsMilestone: https://github.com/bordoni/integration-workos/milestone/4