Skip to content

feat(mfa): MFA mode, policy, factor service, helpers and hooks - #86

Open
bordoni wants to merge 1 commit into
fix/mfa-login-errorsfrom
feat/mfa-core
Open

bordoni wants to merge 1 commit into
fix/mfa-login-errorsfrom
feat/mfa-core

Conversation

@bordoni

@bordoni bordoni commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

The MFA building blocks for #28: who decides that a sign-in needs a second factor, and one service for a user's factors. Stacked on #85. Sign-in enforcement, self-service and the admin screens come in the next PRs of this stack.

Why a mode setting

WorkOS's own MFA is one switch per environment: once it's set to Required, every non-SSO user must enroll at their next sign-in. Sites that want MFA for some users only (a role, a flagged account, one login page) need WordPress to decide. So mfa_mode (per environment, filter workos_mfa_mode) is:

  • plugin (default): the plugin decides per user, role and Login Profile.
  • workos: WorkOS enforces MFA for everyone, and the plugin runs the steps WorkOS asks for.
  • off: the plugin challenges nobody.

Changes

  • WorkOS\Mfa\Policy::decide( $user_id, $profile ) returns none, challenge, enroll or block from the profile's mfa.enforce and factor list, whether the user has a factor, and whether they must use MFA (admin flag, mfa_required_roles, filter workos_mfa_required_for_user). Filter workos_mfa_decision has the last word. A profile that turns MFA off blocks a user who has it, instead of quietly downgrading them.
  • WorkOS\Mfa\FactorService is the one path for status, listing, enrolling (the factor stays unconfirmed until the first code checks out, and an unfinished one is deleted on restart or after a day), deleting (ownership-checked, 404 otherwise), resetting and the require flag. It keeps the mirrored _workos_mfa_enabled flag in step with WorkOS, since factors can change outside WordPress.
  • Helpers: workos_mfa_get_status(), workos_mfa_is_enabled(), workos_mfa_is_required(), workos_mfa_set_required(), workos_mfa_list_factors(), workos_mfa_reset() and workos_mfa_decision(). Like the service, they don't check capabilities.
  • Hooks: filters workos_mfa_mode, workos_mfa_required_for_user, workos_mfa_decision and workos_mfa_totp_issuer; actions workos_mfa_enrolled, workos_mfa_factor_removed, workos_mfa_reset and workos_mfa_required_changed. Activity Log events mfa.enrolled, mfa.removed, mfa.reset, mfa.required_set and mfa.required_cleared.
  • New docs/mfa.md (modes, decision table, service, helpers, errors, hooks, events). The README's MFA line no longer claims SMS and WebAuthn.

Verified

  • New MfaPolicyTest (18 tests): every row of the decision table, a profile without TOTP, the workos and off modes, the mode filter, required roles and both filters.
  • New MfaFactorServiceTest (21 tests) against WorkOS-shaped responses: enroll, restart, unlinked users, confirm (valid code, valid: false, 4xx, malformed, wrong factor), the pending factor hidden from lists, drift in both directions, a stale pending factor, delete (own and someone else's), reset, the require flag and the helpers.
  • Local WPUnit: 829 tests, with the same 11 environment-only failures as clean main on the local runner. PHPCS passes, and PHPStan reports nothing in the changed files.

Part of #28

@bordoni
bordoni added this pull request to stack #87 October 18, 2026 16:10
@bordoni bordoni added this to the 1.1.0 milestone Oct 18, 2026
- WorkOS\Mfa\Settings: per-environment mfa_mode (plugin | workos | off)
  and mfa_required_roles; filter workos_mfa_mode
- WorkOS\Mfa\Policy: profile enforce + enrolled + required -> none,
  challenge, enroll or block; filters workos_mfa_required_for_user and
  workos_mfa_decision
- WorkOS\Mfa\FactorService: the one path for status, list (reconciles the
  mirrored flag with WorkOS), enroll + confirm with a first code, delete
  (ownership-checked), reset and the require flag; actions and mfa.*
  activity-log events
- workos_mfa_* PHP helpers, docs/mfa.md, README pointers
@bordoni
bordoni marked this pull request as ready for review October 19, 2026 05:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant