diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5fc88a3..8c1f7f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
## [Unreleased]
+### Added
+
+- **MFA core** — a site-level MFA mode (`mfa_mode` per environment: `plugin`, the default, decides who is challenged per user, role and Login Profile; `workos` defers to WorkOS's environment-wide MFA; `off`), an admin require flag per user, and required roles (`mfa_required_roles`). `WorkOS\Mfa\Policy` turns the profile's `mfa.enforce`, the user's factor and the requirement into `none`, `challenge`, `enroll` or `block`. `WorkOS\Mfa\FactorService` is the one path for listing, enrolling (confirmed with a first code before it counts), removing and resetting factors, and keeps the mirrored `_workos_mfa_enabled` flag in step with WorkOS. See [`docs/mfa.md`](docs/mfa.md).
+- **MFA 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()`.
+- **MFA hooks and events** — 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`.
+
### Security
- **WordPress password could skip WorkOS MFA** — on the headless `wp-login.php` path, a WorkOS `mfa_challenge` or `mfa_enrollment` answer was treated like a wrong password, and WordPress's own password check then signed the user in without a second factor. That path now refuses with `403 workos_mfa_step_unsupported`, past WordPress's password handlers too. The login button's form gives the same message instead of WorkOS's raw error, and the AuthKit password route no longer re-syncs the password to WorkOS when only the second factor is missing.
diff --git a/README.md b/README.md
index e28ba64..acd88f6 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos.
- **Admin-triggered password reset** — send a WorkOS reset email on behalf of any linked user via `POST /wp-json/workos/v1/admin/users/{id}/password-reset` (gated by `edit_user($id)` so the same route covers self-service). Surfaced as a row action under the **WorkOS column** on `wp-admin/users.php`, a button on the user-edit screen, a per-row button on the WorkOS Users admin page, and a `[workos:password-reset]` shortcode. Successful sends are audited as `password_reset.admin_sent` in the activity log; emails point at the in-site React shell (`/workos/login/{slug}?token=…&redirect_to=…`) and the new password is mirrored to the linked WP user so `?fallback=1` / `wp_authenticate` / REST app passwords stay in sync. See [`docs/password-reset.md`](docs/password-reset.md).
- **Change email** — WorkOS-verified, conflict-guarded email-change flow. Self-service via `[workos:change-email]` shortcode confirms a WP-side hashed token sent to the new address (with a cancel-link notice to the old address) before committing; an admin acting on *another* account commits immediately (gated by `edit_users`, no verification round-trip). Admin surfaces are a row action under the **WorkOS column** on `wp-admin/users.php`, a panel on the user-edit screen, and a "Change email" action on the **WorkOS → Users** admin page. A configurable conflict policy (`block`, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Commits to WorkOS first (via `update_user`) then mirrors into WP, with a 60-second in-progress transient that short-circuits the webhook fan-back. See [`docs/change-email.md`](docs/change-email.md).
- **Skeleton placeholders** on every AuthKit surface (wp-login.php takeover, `/workos/login/{profile}`, shortcode) — pre-hydration markup from PHP plus a React `FlowSkeleton` during bootstrap, mirroring the real card heights so swap-in is a flicker not a jump.
-- **MFA** — TOTP, SMS, WebAuthn/passkey with full enrollment + challenge UI; profile-level `mfa.enforce` (`never` / `if_required` / `always`) and factor allowlist
+- **MFA** — TOTP factors held by WorkOS. The plugin decides who must use a second factor (per user, role and Login Profile `mfa.enforce`), or defers to WorkOS's environment-wide MFA. PHP helpers, hooks and activity-log events for every step. See [`docs/mfa.md`](docs/mfa.md)
- **Profile routing rules** — ordered `redirect_to` glob / `referrer_host` / `user_role` matchers pick the right profile per request
- **WorkOS Radar** anti-fraud integration — browser SDK supplies an action token the plugin forwards on every server-side auth call
- **No WorkOS PHP SDK** — every WorkOS call is `wp_remote_request` from PHP, so the API key never leaves the server
@@ -234,16 +234,14 @@ fallback.
### MFA policy
-Profile-level MFA enforcement is applied by `Auth\AuthKit\LoginCompleter`:
+A profile's `mfa.enforce` (`never` / `if_required` / `always`) and
+`mfa.factors` feed the MFA policy, together with the user's own state (has a
+factor, must use MFA) and the site's MFA mode (`plugin`, `workos` or `off`).
+The full decision table, the helpers and the hooks are in
+[`docs/mfa.md`](docs/mfa.md).
-- `enforce: never` — single-step login is always accepted
-- `enforce: if_required` (default) — MFA step surfaces when WorkOS returns
- a pending factor; otherwise single-step is accepted
-- `enforce: always` — single-step success is rejected with
- `workos_authkit_mfa_required`; the user must enroll a factor first
-
-Factor types WorkOS returns in a pending-factor response are checked against
-the profile's `mfa.factors` allowlist; disallowed types are rejected.
+Factor types WorkOS returns with an MFA challenge are checked against the
+profile's `mfa.factors` allowlist; disallowed types are rejected.
### WorkOS Radar
@@ -321,6 +319,10 @@ workos_get_user_id( $user_id = 0 ); // string — WorkOS user id
workos_get_access_token( $user_id = 0 ); // string — current access token
```
+MFA has its own set (`workos_mfa_get_status()`, `workos_mfa_is_enabled()`,
+`workos_mfa_is_required()`, `workos_mfa_set_required()`, `workos_mfa_list_factors()`,
+`workos_mfa_reset()`, `workos_mfa_decision()`); see [`docs/mfa.md`](docs/mfa.md#php-helpers).
+
### Distinguishing "linked" vs "currently signed in"
- **`is_sso()`** remains true after the user logs out. Use it when you
@@ -371,6 +373,8 @@ before authorizing a sensitive action), verify via
## Hooks Reference
+MFA hooks (`workos_mfa_*`) are listed in [`docs/mfa.md`](docs/mfa.md#hooks).
+
### Filters
#### `workos_redirect_urls`
diff --git a/docs/mfa.md b/docs/mfa.md
new file mode 100644
index 0000000..4da1e35
--- /dev/null
+++ b/docs/mfa.md
@@ -0,0 +1,201 @@
+# Multi-factor authentication (MFA)
+
+WorkOS holds every factor and its secret. WordPress mirrors two flags per
+user and decides, per site, who has to use a second factor. Everything the
+plugin does with MFA goes through one PHP service, so REST, the PHP helpers,
+WP-CLI and the admin screens behave the same and fire the same hooks.
+
+Only TOTP (authenticator apps) is supported. WorkOS's built-in MFA is TOTP
+only, and its SMS factors belong to a separate API that isn't tied to
+WorkOS users.
+
+## At a glance
+
+| Surface | Where | Who |
+|---|---|---|
+| Mode | `mfa_mode` option per environment, filter `workos_mfa_mode` | Site |
+| Who must use MFA | Require flag per user, `mfa_required_roles` option, filter `workos_mfa_required_for_user` | Admin, code |
+| What a sign-in needs | `WorkOS\Mfa\Policy::decide()`, filter `workos_mfa_decision`, helper `workos_mfa_decision()` | Code |
+| Factors | `WorkOS\Mfa\FactorService`, helpers `workos_mfa_*` | Code |
+| Activity log | `mfa.*` events | Admin |
+
+## Modes
+
+WorkOS's own MFA is one switch per environment (**Authentication →
+Multi-factor auth → Required**). Once it's on, every non-SSO user must
+enroll at their next sign-in. That suits a staff-only site, not a customer
+portal where only some accounts should use MFA. So the plugin has three
+modes:
+
+| Mode | Who decides | Use it when |
+|---|---|---|
+| `plugin` (default) | The plugin: per user, role and Login Profile, using the table below | WorkOS MFA is off and only some users need MFA |
+| `workos` | WorkOS, for every user in the environment | WorkOS MFA is set to Required |
+| `off` | Nobody in WordPress | You don't want the plugin to challenge anyone |
+
+In every mode, a step WorkOS asks for (`mfa_challenge`, `mfa_enrollment`)
+still runs. WordPress can't skip a factor WorkOS requires.
+
+```php
+// Lock the mode in code, e.g. per environment.
+add_filter( 'workos_mfa_mode', fn() => 'workos' );
+```
+
+## Who must use MFA
+
+`Policy::is_required()` is true when any of these is:
+
+1. The user's require flag (`_workos_mfa_required`), set by an admin or
+ `workos_mfa_set_required()`.
+2. The user has a role listed in the `mfa_required_roles` option.
+3. The `workos_mfa_required_for_user` filter returns true.
+
+```php
+// Everyone who can edit others' posts must use MFA.
+add_filter( 'workos_mfa_required_for_user', function ( bool $required, int $user_id ) {
+ return $required || user_can( $user_id, 'edit_others_posts' );
+}, 10, 2 );
+```
+
+## What a sign-in needs
+
+After the first factor succeeds, `Policy::decide( $user_id, $profile )`
+returns one of four answers. Only `plugin` mode decides anything; the other
+modes always answer `none`.
+
+| Profile `mfa.enforce` | Has a factor | Must use MFA | Decision |
+|---|---|---|---|
+| `never`, or TOTP not in `mfa.factors` | yes | any | `block` |
+| `never`, or TOTP not in `mfa.factors` | no | yes | `block` |
+| `never`, or TOTP not in `mfa.factors` | no | no | `none` |
+| `if_required` | yes | any | `challenge` |
+| `if_required` | no | yes | `enroll` |
+| `if_required` | no | no | `none` |
+| `always` | yes | any | `challenge` |
+| `always` | no | any | `enroll` |
+
+A profile that turns MFA off never lets a user with MFA sign in without it.
+The sign-in is blocked instead, so the conflict is visible.
+
+The `workos_mfa_decision` filter gets the last word:
+
+```php
+// Skip the challenge for requests from the office network.
+add_filter( 'workos_mfa_decision', function ( string $decision, int $user_id, $profile, array $context ) {
+ if ( 'challenge' === $decision && office_ip() ) {
+ return 'none';
+ }
+ return $decision;
+}, 10, 4 );
+```
+
+## Factors
+
+`WorkOS\Mfa\FactorService` is the only code that touches a user's factors.
+None of its methods check capabilities, so callers do.
+
+| Method | Does |
+|---|---|
+| `status( $user_id )` | `{ enabled, required, factors }`, reconciled with WorkOS |
+| `list_factors( $user_id )` | Confirmed factors from WorkOS. Also fixes the mirrored flag when factors were added or removed outside WordPress |
+| `start_enrollment( $user_id )` | Creates a TOTP factor and returns `{ factor_id, challenge_id, qr_code, secret, otpauth_uri }`. The factor stays unconfirmed, and doesn't count, until confirmed |
+| `confirm_enrollment( $user_id, $factor_id, $code, $challenge_id = '' )` | Checks the first code and turns MFA on |
+| `verify_code( $factor_id, $code, $challenge_id = '' )` | Checks a code; no hooks |
+| `delete_factor( $user_id, $factor_id )` | Removes one of the user's factors (404 for anyone else's) |
+| `reset( $user_id )` | Removes every factor, for a lost authenticator |
+| `set_required( $user_id, $required )` | Sets or clears the require flag |
+
+An unconfirmed factor is deleted from WorkOS when the user starts over, or
+after a day.
+
+### PHP helpers
+
+```php
+workos_mfa_get_status( $user_id = 0 ); // array|WP_Error — { enabled, required, factors }
+workos_mfa_is_enabled( $user_id = 0 ); // bool — mirrored flag, no WorkOS call
+workos_mfa_is_required( $user_id = 0 ); // bool — flag, role or filter
+workos_mfa_set_required( $user_id, $required ); // bool|WP_Error — whether it changed
+workos_mfa_list_factors( $user_id = 0 ); // array|WP_Error
+workos_mfa_reset( $user_id ); // int|WP_Error — factors removed
+workos_mfa_decision( $user_id = 0, $profile ); // 'none'|'challenge'|'enroll'|'block'
+```
+
+`$user_id = 0` means the current user. Like the service, the helpers don't
+check capabilities.
+
+### Errors
+
+| Code | Status | Cause |
+|---|---|---|
+| `workos_mfa_user_not_linked` | 409 | The account has no WorkOS user |
+| `workos_mfa_factor_not_found` | 404 | The factor isn't the user's, or isn't the one being enrolled |
+| `workos_mfa_invalid_code` | 400 | Wrong, expired or malformed code |
+| `workos_mfa_invalid_user` | 404 | `workos_mfa_set_required()` got an unknown user |
+| `workos_mfa_invalid_response` | 502 | WorkOS answered an enrollment without a factor |
+
+## Sign-in errors from WorkOS
+
+When WorkOS requires MFA, `authenticate` answers with an error, not a
+success:
+
+- `mfa_challenge`: the sign-in form shows the code step
+ (`{ mfa_required, pending_authentication_token, factors }`). The profile's
+ factor allowlist applies.
+- `mfa_enrollment`: refused with `403 workos_authkit_mfa_enrollment_required`
+ for now.
+
+Sign-in paths that can't collect a code (headless `wp-login.php`, the login
+button's form) refuse with `403 workos_mfa_step_unsupported`. They never fall
+back to the WordPress password, since WorkOS already accepted the password and
+only the second factor is missing.
+
+## Hooks
+
+**Filters**
+
+| Filter | Arguments | Default |
+|---|---|---|
+| `workos_mfa_mode` | `string $mode` | `mfa_mode` option, else `plugin` |
+| `workos_mfa_required_for_user` | `bool $required, int $user_id, ?Profile $profile` | Flag or role |
+| `workos_mfa_decision` | `string $decision, int $user_id, Profile $profile, array $context` | Policy table |
+| `workos_mfa_totp_issuer` | `string $issuer, int $user_id` | Site name |
+
+**Actions**
+
+| Action | Arguments | When |
+|---|---|---|
+| `workos_mfa_enrolled` | `int $user_id, array $factor` | A new factor is confirmed |
+| `workos_mfa_factor_removed` | `int $user_id, string $factor_id` | One factor is removed |
+| `workos_mfa_reset` | `int $user_id, int $removed` | Every factor is removed |
+| `workos_mfa_required_changed` | `int $user_id, bool $required` | The require flag changes |
+
+## Activity log
+
+| Event | Metadata |
+|---|---|
+| `mfa.enrolled` | `factor_id`, `factor_type` |
+| `mfa.removed` | `factor_id` |
+| `mfa.reset` | `removed`, `actor_id` |
+| `mfa.required_set`, `mfa.required_cleared` | `actor_id` |
+
+## Don't do this
+
+- Don't store TOTP secrets or QR codes in WordPress. WorkOS holds them; the
+ plugin returns them once, at enrollment.
+- Don't write `_workos_mfa_enabled` yourself. It mirrors WorkOS, and
+ `list_factors()` overwrites it.
+- Don't let a profile with `enforce: never` stand in for "MFA off for this
+ user". It blocks users who have MFA. Use `workos_mfa_decision` for
+ exceptions.
+
+## Internal references
+
+| Concept | Source |
+|---|---|
+| Modes and options | `src/WorkOS/Mfa/Settings.php` |
+| Decision table | `src/WorkOS/Mfa/Policy.php` |
+| Factors, flags, hooks, events | `src/WorkOS/Mfa/FactorService.php` |
+| Helpers | `src/includes/functions-helpers.php` |
+| WorkOS MFA errors | `src/WorkOS/Auth/AuthKit/MfaStep.php` |
+| Sign-in completion | `src/WorkOS/Auth/AuthKit/LoginCompleter.php` |
+| WorkOS calls | `src/WorkOS/Api/Client.php` (`list_auth_factors`, `enroll_totp_factor`, `challenge_auth_factor`, `verify_auth_challenge`, `delete_auth_factor`) |
diff --git a/src/WorkOS/ActivityLog/AdminPage.php b/src/WorkOS/ActivityLog/AdminPage.php
index 5a812ad..3f9bf33 100644
--- a/src/WorkOS/ActivityLog/AdminPage.php
+++ b/src/WorkOS/ActivityLog/AdminPage.php
@@ -91,7 +91,7 @@ public function render_page(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$cleared = ! empty( SuperGlobals::get_get_var( 'cleared' ) );
- $event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused' ];
+ $event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'mfa.enrolled', 'mfa.removed', 'mfa.reset', 'mfa.required_set', 'mfa.required_cleared', 'mfa.challenge_succeeded', 'mfa.challenge_failed', 'mfa.blocked' ];
?>
diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php
index d46772b..dff0722 100644
--- a/src/WorkOS/Controller.php
+++ b/src/WorkOS/Controller.php
@@ -16,6 +16,7 @@
use WorkOS\Auth\Controller as AuthController;
use WorkOS\Auth\PasswordResetAdmin\Controller as PasswordResetAdminController;
use WorkOS\Auth\ChangeEmail\Controller as ChangeEmailController;
+use WorkOS\Mfa\Controller as MfaController;
use WorkOS\REST\Controller as RESTController;
use WorkOS\Webhook\Controller as WebhookController;
use WorkOS\Sync\Controller as SyncController;
@@ -42,6 +43,7 @@ protected function doRegister(): void {
$this->container->register( HttpController::class );
$this->container->register( EmailController::class );
$this->container->register( RateLimitController::class );
+ $this->container->register( MfaController::class );
$this->container->register( AdminController::class );
$this->container->register( AuthKitController::class );
diff --git a/src/WorkOS/Mfa/Controller.php b/src/WorkOS/Mfa/Controller.php
new file mode 100644
index 0000000..b4ed82f
--- /dev/null
+++ b/src/WorkOS/Mfa/Controller.php
@@ -0,0 +1,38 @@
+container->singleton( Settings::class );
+ $this->container->singleton( Policy::class );
+ $this->container->singleton( FactorService::class );
+ }
+
+ /**
+ * Unregister.
+ *
+ * @return void
+ */
+ protected function doUnregister(): void {
+ }
+}
diff --git a/src/WorkOS/Mfa/FactorService.php b/src/WorkOS/Mfa/FactorService.php
new file mode 100644
index 0000000..b7cc46f
--- /dev/null
+++ b/src/WorkOS/Mfa/FactorService.php
@@ -0,0 +1,517 @@
+list_factors( $user_id );
+ if ( is_wp_error( $factors ) ) {
+ return $factors;
+ }
+
+ return [
+ 'enabled' => $this->is_enabled( $user_id ),
+ 'required' => $this->is_required_flag( $user_id ),
+ 'factors' => $factors,
+ ];
+ }
+
+ /**
+ * Confirmed factors, read from WorkOS.
+ *
+ * Also keeps `_workos_mfa_enabled` in step with WorkOS, since factors
+ * can be added or removed outside WordPress, and drops a stale
+ * unconfirmed factor.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return array|WP_Error List of `{ id, type, created }`.
+ */
+ public function list_factors( int $user_id ) {
+ $workos_id = $this->workos_id( $user_id );
+ if ( is_wp_error( $workos_id ) ) {
+ return $workos_id;
+ }
+
+ $result = workos()->api()->list_auth_factors( $workos_id );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $pending = $this->pending_factor( $user_id );
+ if ( $pending && ( time() - $pending['created'] ) > self::PENDING_FACTOR_TTL ) {
+ workos()->api()->delete_auth_factor( $pending['factor_id'] );
+ delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR );
+ $pending = null;
+ }
+
+ $factors = [];
+ foreach ( (array) ( $result['data'] ?? $result ) as $factor ) {
+ if ( ! is_array( $factor ) || empty( $factor['id'] ) ) {
+ continue;
+ }
+ if ( $pending && $pending['factor_id'] === $factor['id'] ) {
+ continue;
+ }
+ $factors[] = [
+ 'id' => (string) $factor['id'],
+ 'type' => (string) ( $factor['type'] ?? '' ),
+ 'created' => (string) ( $factor['created_at'] ?? '' ),
+ ];
+ }
+
+ $this->sync_enabled( $user_id, ! empty( $factors ) );
+
+ return $factors;
+ }
+
+ /**
+ * Start a TOTP enrollment.
+ *
+ * Creates the factor in WorkOS and remembers it as unconfirmed. Any
+ * earlier unconfirmed factor is deleted first, so restarting doesn't
+ * pile up factors the user never finished.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return array|WP_Error `{ factor_id, challenge_id, qr_code, secret, otpauth_uri }`.
+ */
+ public function start_enrollment( int $user_id ) {
+ $workos_id = $this->workos_id( $user_id );
+ if ( is_wp_error( $workos_id ) ) {
+ return $workos_id;
+ }
+
+ $pending = $this->pending_factor( $user_id );
+ if ( $pending ) {
+ workos()->api()->delete_auth_factor( $pending['factor_id'] );
+ delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR );
+ }
+
+ $user = get_userdata( $user_id );
+
+ /**
+ * Filters the issuer an authenticator app shows for the factor.
+ *
+ * @param string $issuer Issuer. Default: the site name.
+ * @param int $user_id WP user ID.
+ */
+ $issuer = (string) apply_filters( 'workos_mfa_totp_issuer', (string) get_bloginfo( 'name' ), $user_id );
+
+ $result = workos()->api()->enroll_totp_factor( $workos_id, $issuer, $user ? (string) $user->user_email : '' );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $factor = isset( $result['authentication_factor'] ) && is_array( $result['authentication_factor'] ) ? $result['authentication_factor'] : $result;
+ $challenge = isset( $result['authentication_challenge'] ) && is_array( $result['authentication_challenge'] ) ? $result['authentication_challenge'] : [];
+ $totp = isset( $factor['totp'] ) && is_array( $factor['totp'] ) ? $factor['totp'] : [];
+ $factor_id = (string) ( $factor['id'] ?? '' );
+
+ if ( '' === $factor_id ) {
+ return new WP_Error(
+ 'workos_mfa_invalid_response',
+ __( 'Unexpected response from the authentication provider.', 'integration-workos' ),
+ [ 'status' => 502 ]
+ );
+ }
+
+ update_user_meta(
+ $user_id,
+ User::META_MFA_PENDING_FACTOR,
+ [
+ 'factor_id' => $factor_id,
+ 'created' => time(),
+ ]
+ );
+
+ return [
+ 'factor_id' => $factor_id,
+ 'challenge_id' => (string) ( $challenge['id'] ?? '' ),
+ 'qr_code' => (string) ( $totp['qr_code'] ?? '' ),
+ 'secret' => (string) ( $totp['secret'] ?? '' ),
+ 'otpauth_uri' => (string) ( $totp['uri'] ?? '' ),
+ ];
+ }
+
+ /**
+ * Confirm an enrollment with the first code from the authenticator.
+ *
+ * @param int $user_id WP user ID.
+ * @param string $factor_id Factor from start_enrollment().
+ * @param string $code Code from the authenticator app.
+ * @param string $challenge_id Challenge from start_enrollment(); a new one is created when empty.
+ *
+ * @return array|WP_Error Status, as status() returns it.
+ */
+ public function confirm_enrollment( int $user_id, string $factor_id, string $code, string $challenge_id = '' ) {
+ $pending = $this->pending_factor( $user_id );
+ if ( ! $pending || $pending['factor_id'] !== $factor_id ) {
+ return $this->factor_not_found();
+ }
+
+ $valid = $this->verify_code( $factor_id, $code, $challenge_id );
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
+
+ delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR );
+ $this->sync_enabled( $user_id, true );
+
+ EventLogger::log(
+ 'mfa.enrolled',
+ [
+ 'user_id' => $user_id,
+ 'metadata' => [
+ 'factor_id' => $factor_id,
+ 'factor_type' => 'totp',
+ ],
+ ]
+ );
+
+ /**
+ * Fires after a user confirms a new MFA factor.
+ *
+ * @param int $user_id WP user ID.
+ * @param array $factor `{ id, type }`.
+ */
+ do_action(
+ 'workos_mfa_enrolled',
+ $user_id,
+ [
+ 'id' => $factor_id,
+ 'type' => 'totp',
+ ]
+ );
+
+ return $this->status( $user_id );
+ }
+
+ /**
+ * Check a code against one of the user's factors.
+ *
+ * Used to confirm an enrollment and, in plugin mode, to answer the
+ * sign-in challenge. Doesn't fire hooks: callers know which of the two
+ * it was.
+ *
+ * @param string $factor_id Factor ID.
+ * @param string $code Code from the authenticator app.
+ * @param string $challenge_id Existing challenge; a new one is created when empty.
+ *
+ * @return true|WP_Error
+ */
+ public function verify_code( string $factor_id, string $code, string $challenge_id = '' ) {
+ $code = preg_replace( '/\D/', '', $code );
+ if ( 6 !== strlen( (string) $code ) ) {
+ return $this->invalid_code();
+ }
+
+ if ( '' === $challenge_id ) {
+ $challenge = workos()->api()->challenge_auth_factor( $factor_id );
+ if ( is_wp_error( $challenge ) ) {
+ return $challenge;
+ }
+ $challenge_id = (string) ( $challenge['id'] ?? ( $challenge['authentication_challenge']['id'] ?? '' ) );
+ }
+
+ $result = workos()->api()->verify_auth_challenge( $challenge_id, (string) $code );
+ if ( is_wp_error( $result ) ) {
+ // A wrong code may come back as an error rather than `valid: false`.
+ $status = (int) ( $result->get_error_data()['status'] ?? 0 );
+ return ( $status >= 400 && $status < 500 ) ? $this->invalid_code() : $result;
+ }
+
+ return empty( $result['valid'] ) ? $this->invalid_code() : true;
+ }
+
+ /**
+ * Remove one of the user's factors.
+ *
+ * @param int $user_id WP user ID.
+ * @param string $factor_id Factor ID; must belong to the user.
+ *
+ * @return true|WP_Error
+ */
+ public function delete_factor( int $user_id, string $factor_id ) {
+ $factors = $this->list_factors( $user_id );
+ if ( is_wp_error( $factors ) ) {
+ return $factors;
+ }
+
+ // The plugin calls WorkOS with a tenant-wide key, so WorkOS can't
+ // tell whose factor this is. 404, not 403, so factor IDs that
+ // belong to other users don't leak.
+ if ( ! in_array( $factor_id, array_column( $factors, 'id' ), true ) ) {
+ return $this->factor_not_found();
+ }
+
+ $result = workos()->api()->delete_auth_factor( $factor_id );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $this->sync_enabled( $user_id, count( $factors ) > 1 );
+
+ EventLogger::log(
+ 'mfa.removed',
+ [
+ 'user_id' => $user_id,
+ 'metadata' => [ 'factor_id' => $factor_id ],
+ ]
+ );
+
+ /**
+ * Fires after one of a user's MFA factors is removed.
+ *
+ * @param int $user_id WP user ID.
+ * @param string $factor_id Factor ID.
+ */
+ do_action( 'workos_mfa_factor_removed', $user_id, $factor_id );
+
+ return true;
+ }
+
+ /**
+ * Remove every factor the user has, confirmed or not.
+ *
+ * For a user who lost their authenticator. With the require flag set
+ * they enroll again at their next sign-in.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return int|WP_Error Number of factors removed.
+ */
+ public function reset( int $user_id ) {
+ $workos_id = $this->workos_id( $user_id );
+ if ( is_wp_error( $workos_id ) ) {
+ return $workos_id;
+ }
+
+ $result = workos()->api()->list_auth_factors( $workos_id );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $removed = 0;
+ foreach ( (array) ( $result['data'] ?? $result ) as $factor ) {
+ if ( ! is_array( $factor ) || empty( $factor['id'] ) ) {
+ continue;
+ }
+ $deleted = workos()->api()->delete_auth_factor( (string) $factor['id'] );
+ if ( is_wp_error( $deleted ) ) {
+ return $deleted;
+ }
+ ++$removed;
+ }
+
+ delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR );
+ $this->sync_enabled( $user_id, false );
+
+ EventLogger::log(
+ 'mfa.reset',
+ [
+ 'user_id' => $user_id,
+ 'metadata' => [
+ 'removed' => $removed,
+ 'actor_id' => get_current_user_id(),
+ ],
+ ]
+ );
+
+ /**
+ * Fires after every MFA factor of a user is removed.
+ *
+ * @param int $user_id WP user ID.
+ * @param int $removed Number of factors removed.
+ */
+ do_action( 'workos_mfa_reset', $user_id, $removed );
+
+ return $removed;
+ }
+
+ /**
+ * Set or clear the admin require flag.
+ *
+ * @param int $user_id WP user ID.
+ * @param bool $required Whether the user must use MFA.
+ *
+ * @return bool Whether the flag changed.
+ */
+ public function set_required( int $user_id, bool $required ): bool {
+ if ( $this->is_required_flag( $user_id ) === $required ) {
+ return false;
+ }
+
+ if ( $required ) {
+ update_user_meta( $user_id, User::META_MFA_REQUIRED, '1' );
+ } else {
+ delete_user_meta( $user_id, User::META_MFA_REQUIRED );
+ }
+
+ EventLogger::log(
+ $required ? 'mfa.required_set' : 'mfa.required_cleared',
+ [
+ 'user_id' => $user_id,
+ 'metadata' => [ 'actor_id' => get_current_user_id() ],
+ ]
+ );
+
+ /**
+ * Fires after the admin require flag on a user changes.
+ *
+ * @param int $user_id WP user ID.
+ * @param bool $required New value.
+ */
+ do_action( 'workos_mfa_required_changed', $user_id, $required );
+
+ return true;
+ }
+
+ /**
+ * Whether the user has a confirmed factor (mirrored, no API call).
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return bool
+ */
+ public function is_enabled( int $user_id ): bool {
+ return '1' === (string) get_user_meta( $user_id, User::META_MFA_ENABLED, true );
+ }
+
+ /**
+ * Whether an admin set the require flag (roles and filters aside).
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return bool
+ */
+ public function is_required_flag( int $user_id ): bool {
+ return '1' === (string) get_user_meta( $user_id, User::META_MFA_REQUIRED, true );
+ }
+
+ /**
+ * The unconfirmed factor, if any.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return array{factor_id: string, created: int}|null
+ */
+ public function pending_factor( int $user_id ): ?array {
+ $pending = get_user_meta( $user_id, User::META_MFA_PENDING_FACTOR, true );
+ if ( ! is_array( $pending ) || empty( $pending['factor_id'] ) ) {
+ return null;
+ }
+
+ return [
+ 'factor_id' => (string) $pending['factor_id'],
+ 'created' => (int) ( $pending['created'] ?? 0 ),
+ ];
+ }
+
+ /**
+ * Linked WorkOS user ID, or an error.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return string|WP_Error
+ */
+ private function workos_id( int $user_id ) {
+ $workos_id = $user_id > 0 ? User::get_workos_id( $user_id ) : '';
+ if ( '' === $workos_id ) {
+ return new WP_Error(
+ 'workos_mfa_user_not_linked',
+ __( 'This account is not linked to a WorkOS user.', 'integration-workos' ),
+ [ 'status' => 409 ]
+ );
+ }
+
+ return $workos_id;
+ }
+
+ /**
+ * Mirror the enabled state.
+ *
+ * @param int $user_id WP user ID.
+ * @param bool $enabled Whether the user has a confirmed factor.
+ *
+ * @return void
+ */
+ private function sync_enabled( int $user_id, bool $enabled ): void {
+ if ( $enabled ) {
+ update_user_meta( $user_id, User::META_MFA_ENABLED, '1' );
+ } else {
+ delete_user_meta( $user_id, User::META_MFA_ENABLED );
+ }
+ }
+
+ /**
+ * Error for a factor the user doesn't own.
+ *
+ * @return WP_Error
+ */
+ private function factor_not_found(): WP_Error {
+ return new WP_Error(
+ 'workos_mfa_factor_not_found',
+ __( 'Factor not found.', 'integration-workos' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ /**
+ * Error for a wrong or malformed code.
+ *
+ * @return WP_Error
+ */
+ private function invalid_code(): WP_Error {
+ return new WP_Error(
+ 'workos_mfa_invalid_code',
+ __( 'That code didn’t work. Check your authenticator app and try again.', 'integration-workos' ),
+ [ 'status' => 400 ]
+ );
+ }
+}
diff --git a/src/WorkOS/Mfa/Policy.php b/src/WorkOS/Mfa/Policy.php
new file mode 100644
index 0000000..4ce37e8
--- /dev/null
+++ b/src/WorkOS/Mfa/Policy.php
@@ -0,0 +1,189 @@
+settings = $settings;
+ }
+
+ /**
+ * What this sign-in needs.
+ *
+ * @param int $user_id WP user ID.
+ * @param Profile $profile Login Profile the sign-in came through.
+ *
+ * @return string One of the decision constants.
+ */
+ public function decide( int $user_id, Profile $profile ): string {
+ $enrolled = $this->is_enrolled( $user_id );
+ $required = $this->is_required( $user_id, $profile );
+ $decision = $this->table( $profile, $enrolled, $required );
+
+ /**
+ * Filters what a sign-in needs after the first factor succeeded.
+ *
+ * Runs in `plugin` mode only. Return `none`, `challenge`, `enroll`
+ * or `block`; anything else is ignored.
+ *
+ * @param string $decision Decision from the policy table.
+ * @param int $user_id WP user ID.
+ * @param Profile $profile Login Profile the sign-in came through.
+ * @param array $context `{ mode, enrolled, required }`.
+ */
+ $filtered = (string) apply_filters(
+ 'workos_mfa_decision',
+ $decision,
+ $user_id,
+ $profile,
+ [
+ 'mode' => $this->settings->mode(),
+ 'enrolled' => $enrolled,
+ 'required' => $required,
+ ]
+ );
+
+ return in_array( $filtered, [ self::NONE, self::CHALLENGE, self::ENROLL, self::BLOCK ], true ) ? $filtered : $decision;
+ }
+
+ /**
+ * Whether the user must use MFA.
+ *
+ * True when an admin set the flag, the user has a required role, or
+ * the `workos_mfa_required_for_user` filter says so. Independent of
+ * the mode, so admin screens can show the requirement before the site
+ * switches to `plugin` mode.
+ *
+ * @param int $user_id WP user ID.
+ * @param Profile|null $profile Login Profile, when known.
+ *
+ * @return bool
+ */
+ public function is_required( int $user_id, ?Profile $profile = null ): bool {
+ $required = '1' === (string) get_user_meta( $user_id, User::META_MFA_REQUIRED, true );
+
+ if ( ! $required ) {
+ $roles = $this->settings->required_roles();
+ $user = $roles ? get_userdata( $user_id ) : false;
+ if ( $user && array_intersect( $roles, (array) $user->roles ) ) {
+ $required = true;
+ }
+ }
+
+ /**
+ * Filters whether a user must use MFA.
+ *
+ * @param bool $required Whether the user must use MFA.
+ * @param int $user_id WP user ID.
+ * @param Profile|null $profile Login Profile, when known.
+ */
+ return (bool) apply_filters( 'workos_mfa_required_for_user', $required, $user_id, $profile );
+ }
+
+ /**
+ * Whether the user has a confirmed factor (mirrored from WorkOS).
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return bool
+ */
+ public function is_enrolled( int $user_id ): bool {
+ return '1' === (string) get_user_meta( $user_id, User::META_MFA_ENABLED, true );
+ }
+
+ /**
+ * The decision table, before the filter.
+ *
+ * @param Profile $profile Login Profile.
+ * @param bool $enrolled Whether the user has a confirmed factor.
+ * @param bool $required Whether the user must use MFA.
+ *
+ * @return string
+ */
+ private function table( Profile $profile, bool $enrolled, bool $required ): string {
+ if ( Settings::MODE_PLUGIN !== $this->settings->mode() ) {
+ return self::NONE;
+ }
+
+ $enforce = (string) ( $profile->get_mfa()['enforce'] ?? Profile::MFA_ENFORCE_IF_REQUIRED );
+
+ if ( Profile::MFA_ENFORCE_NEVER === $enforce || ! $profile->allows_factor( Profile::FACTOR_TOTP ) ) {
+ return ( $enrolled || $required ) ? self::BLOCK : self::NONE;
+ }
+
+ if ( $enrolled ) {
+ return self::CHALLENGE;
+ }
+
+ if ( Profile::MFA_ENFORCE_ALWAYS === $enforce || $required ) {
+ return self::ENROLL;
+ }
+
+ return self::NONE;
+ }
+}
diff --git a/src/WorkOS/Mfa/Settings.php b/src/WorkOS/Mfa/Settings.php
new file mode 100644
index 0000000..d22342f
--- /dev/null
+++ b/src/WorkOS/Mfa/Settings.php
@@ -0,0 +1,93 @@
+option( self::OPTION_MODE, self::MODE_PLUGIN );
+
+ /**
+ * Filters who decides that a sign-in needs a second factor.
+ *
+ * `plugin` (default): the plugin decides per user, role and Login
+ * Profile. `workos`: the WorkOS environment requires MFA for
+ * everyone and the plugin only runs the steps WorkOS asks for.
+ * `off`: the plugin challenges nobody.
+ *
+ * @param string $mode Active mode.
+ */
+ $mode = (string) apply_filters( 'workos_mfa_mode', $mode );
+
+ return in_array( $mode, self::modes(), true ) ? $mode : self::MODE_PLUGIN;
+ }
+
+ /**
+ * Roles whose members must use MFA (plugin mode).
+ *
+ * @return string[]
+ */
+ public function required_roles(): array {
+ $roles = workos()->option( self::OPTION_REQUIRED_ROLES, [] );
+
+ return is_array( $roles ) ? array_values( array_filter( array_map( 'strval', $roles ) ) ) : [];
+ }
+}
diff --git a/src/WorkOS/User.php b/src/WorkOS/User.php
index fe6ddda..6ce209e 100644
--- a/src/WorkOS/User.php
+++ b/src/WorkOS/User.php
@@ -84,6 +84,15 @@ final class User {
/** User meta key — flag indicating this WP user's first login via WorkOS. */
public const META_FIRST_LOGIN = '_workos_first_login';
+ /** User meta key — '1' once the user has a confirmed MFA factor in WorkOS (mirrored; WorkOS holds the factor). */
+ public const META_MFA_ENABLED = '_workos_mfa_enabled';
+
+ /** User meta key — '1' when an admin requires this user to use MFA. */
+ public const META_MFA_REQUIRED = '_workos_mfa_required';
+
+ /** User meta key — factor enrolled but not yet confirmed with a code: `{ factor_id, created }`. */
+ public const META_MFA_PENDING_FACTOR = '_workos_mfa_pending_factor';
+
/**
* Whether the user is linked to a WorkOS identity.
*
diff --git a/src/includes/functions-helpers.php b/src/includes/functions-helpers.php
index b914a5d..3de7052 100644
--- a/src/includes/functions-helpers.php
+++ b/src/includes/functions-helpers.php
@@ -102,3 +102,157 @@ function workos_get_user_id( int $user_id = 0 ): string {
function workos_get_access_token( int $user_id = 0 ): string {
return User::get_access_token( $user_id );
}
+
+/**
+ * Resolve an MFA helper's user argument: 0 or omitted means the current user.
+ *
+ * @internal Used by the `workos_mfa_*` helpers below.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return int
+ */
+function workos_mfa_resolve_user_id( int $user_id ): int {
+ return $user_id > 0 ? $user_id : get_current_user_id();
+}
+
+/**
+ * A user's MFA status, reconciled with WorkOS.
+ *
+ * Calls WorkOS. Doesn't check capabilities: callers do.
+ *
+ * ```php
+ * $status = workos_mfa_get_status();
+ * if ( ! is_wp_error( $status ) && ! $status['enabled'] ) {
+ * // Nudge the user to turn on MFA.
+ * }
+ * ```
+ *
+ * Errors: `workos_mfa_user_not_linked` (409), `workos_api_error`.
+ *
+ * @param int $user_id WP user ID; 0 or omitted targets the current user.
+ *
+ * @return array|\WP_Error `{ enabled: bool, required: bool, factors: array }`.
+ */
+function workos_mfa_get_status( int $user_id = 0 ) {
+ return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->status( workos_mfa_resolve_user_id( $user_id ) );
+}
+
+/**
+ * Whether a user has a confirmed MFA factor.
+ *
+ * Reads the mirrored flag; no WorkOS call. {@see workos_mfa_get_status()}
+ * reconciles it with WorkOS.
+ *
+ * @param int $user_id WP user ID; 0 or omitted targets the current user.
+ *
+ * @return bool
+ */
+function workos_mfa_is_enabled( int $user_id = 0 ): bool {
+ return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->is_enabled( workos_mfa_resolve_user_id( $user_id ) );
+}
+
+/**
+ * Whether a user must use MFA.
+ *
+ * True when an admin set the require flag, the user has a role listed in
+ * the MFA settings, or the `workos_mfa_required_for_user` filter says so.
+ *
+ * @param int $user_id WP user ID; 0 or omitted targets the current user.
+ *
+ * @return bool
+ */
+function workos_mfa_is_required( int $user_id = 0 ): bool {
+ $user_id = workos_mfa_resolve_user_id( $user_id );
+
+ return $user_id > 0 && \WorkOS\App::container()->get( \WorkOS\Mfa\Policy::class )->is_required( $user_id );
+}
+
+/**
+ * Set or clear the admin flag that requires MFA for a user.
+ *
+ * Logs `mfa.required_set` / `mfa.required_cleared` and fires
+ * `workos_mfa_required_changed` when the value changes. Doesn't check
+ * capabilities: callers do.
+ *
+ * ```php
+ * // Require MFA for everyone who can publish.
+ * add_action( 'set_user_role', function ( $user_id, $role ) {
+ * workos_mfa_set_required( $user_id, in_array( $role, [ 'editor', 'administrator' ], true ) );
+ * }, 10, 2 );
+ * ```
+ *
+ * Errors: `workos_mfa_invalid_user` (404).
+ *
+ * @param int $user_id WP user ID.
+ * @param bool $required Whether the user must use MFA.
+ *
+ * @return bool|\WP_Error Whether the flag changed.
+ */
+function workos_mfa_set_required( int $user_id, bool $required ) {
+ if ( $user_id <= 0 || ! get_userdata( $user_id ) ) {
+ return new \WP_Error(
+ 'workos_mfa_invalid_user',
+ __( 'User not found.', 'integration-workos' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->set_required( $user_id, $required );
+}
+
+/**
+ * A user's confirmed MFA factors, read from WorkOS.
+ *
+ * Errors: `workos_mfa_user_not_linked` (409), `workos_api_error`.
+ *
+ * @param int $user_id WP user ID; 0 or omitted targets the current user.
+ *
+ * @return array|\WP_Error List of `{ id, type, created }`.
+ */
+function workos_mfa_list_factors( int $user_id = 0 ) {
+ return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->list_factors( workos_mfa_resolve_user_id( $user_id ) );
+}
+
+/**
+ * Remove every MFA factor a user has.
+ *
+ * For a user who lost their authenticator. Logs `mfa.reset` and fires
+ * `workos_mfa_reset`. Doesn't check capabilities: callers do.
+ *
+ * Errors: `workos_mfa_user_not_linked` (409), `workos_api_error`.
+ *
+ * @param int $user_id WP user ID.
+ *
+ * @return int|\WP_Error Number of factors removed.
+ */
+function workos_mfa_reset( int $user_id ) {
+ return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->reset( $user_id );
+}
+
+/**
+ * What a sign-in by this user would need after the first factor.
+ *
+ * `none`, `challenge`, `enroll` or `block`. Useful to warn a user before
+ * they sign in through a profile that would block them. Only `plugin`
+ * mode decides anything; other modes always return `none`.
+ *
+ * @param int $user_id WP user ID; 0 or omitted targets the current user.
+ * @param \WorkOS\Auth\AuthKit\Profile|string $profile Login Profile or its slug; default profile when omitted.
+ *
+ * @return string
+ */
+function workos_mfa_decision( int $user_id = 0, $profile = '' ): string {
+ $user_id = workos_mfa_resolve_user_id( $user_id );
+
+ if ( ! $profile instanceof \WorkOS\Auth\AuthKit\Profile ) {
+ $repository = \WorkOS\App::container()->get( \WorkOS\Auth\AuthKit\ProfileRepository::class );
+ $profile = $repository->find_by_slug( '' !== $profile ? (string) $profile : \WorkOS\Auth\AuthKit\Profile::DEFAULT_SLUG );
+ }
+
+ if ( $user_id <= 0 || ! $profile ) {
+ return \WorkOS\Mfa\Policy::NONE;
+ }
+
+ return \WorkOS\App::container()->get( \WorkOS\Mfa\Policy::class )->decide( $user_id, $profile );
+}
diff --git a/tests/wpunit/MfaFactorServiceTest.php b/tests/wpunit/MfaFactorServiceTest.php
new file mode 100644
index 0000000..49b3c5b
--- /dev/null
+++ b/tests/wpunit/MfaFactorServiceTest.php
@@ -0,0 +1,430 @@
+
+ */
+ private array $responses = [];
+
+ /**
+ * Hooks fired during the test.
+ *
+ * @var array
+ */
+ private array $fired = [];
+
+ public function setUp(): void {
+ parent::setUp();
+
+ \WorkOS\Config::set_active_environment( 'production' );
+ update_option(
+ 'workos_production',
+ [
+ 'api_key' => 'sk_test_fake',
+ 'client_id' => 'client_fake',
+ 'environment_id' => 'environment_test',
+ ]
+ );
+ \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
+
+ add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 );
+
+ $this->service = new FactorService();
+ $this->user_id = self::factory()->user->create( [ 'user_email' => 'mfa@example.com' ] );
+ update_user_meta( $this->user_id, User::META_WORKOS_ID, 'user_mfa' );
+
+ // Creating the user pushes it to WorkOS; start the capture clean.
+ $this->captured = [];
+
+ foreach ( [ 'workos_mfa_enrolled', 'workos_mfa_factor_removed', 'workos_mfa_reset', 'workos_mfa_required_changed' ] as $hook ) {
+ add_action(
+ $hook,
+ function ( ...$args ) use ( $hook ) {
+ $this->fired[] = [ $hook, $args ];
+ },
+ 10,
+ 2
+ );
+ }
+ }
+
+ public function tearDown(): void {
+ remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 );
+ foreach ( [ 'workos_mfa_enrolled', 'workos_mfa_factor_removed', 'workos_mfa_reset', 'workos_mfa_required_changed', 'workos_mfa_totp_issuer' ] as $hook ) {
+ remove_all_filters( $hook );
+ }
+ $this->captured = [];
+ $this->responses = [];
+ $this->fired = [];
+
+ wp_set_current_user( 0 );
+ delete_option( 'workos_production' );
+ \WorkOS\Config::set_active_environment( 'staging' );
+ \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
+
+ parent::tearDown();
+ }
+
+ public function intercept_http( $preempt, array $args, string $url ) {
+ if ( ! str_contains( $url, 'api.workos.com' ) ) {
+ return $preempt;
+ }
+
+ $method = $args['method'] ?? 'GET';
+ $this->captured[] = [
+ 'method' => $method,
+ 'url' => $url,
+ 'body' => $args['body'] ?? '',
+ ];
+
+ foreach ( $this->responses as $key => $resp ) {
+ [ $want_method, $needle ] = explode( ' ', $key, 2 );
+ if ( $want_method === $method && str_contains( $url, $needle ) ) {
+ return $resp;
+ }
+ }
+
+ return [
+ 'response' => [ 'code' => 200, 'message' => 'OK' ],
+ 'body' => '{}',
+ ];
+ }
+
+ // ------------------------------- Enrollment -------------------------------
+
+ public function test_start_enrollment_creates_factor_and_remembers_it_unconfirmed(): void {
+ $this->queue( 'POST /user_management/users/user_mfa/auth_factors', $this->enroll_response( 'auth_factor_new' ), 201 );
+
+ $result = $this->service->start_enrollment( $this->user_id );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'auth_factor_new', $result['factor_id'] );
+ $this->assertSame( 'auth_challenge_new', $result['challenge_id'] );
+ $this->assertSame( 'SECRET', $result['secret'] );
+ $this->assertSame( 'auth_factor_new', $this->service->pending_factor( $this->user_id )['factor_id'] );
+ $this->assertFalse( $this->service->is_enabled( $this->user_id ) );
+
+ $body = json_decode( $this->captured[0]['body'], true );
+ $this->assertSame( 'totp', $body['type'] );
+ $this->assertSame( 'mfa@example.com', $body['totp_user'] );
+ }
+
+ public function test_start_enrollment_uses_issuer_filter(): void {
+ add_filter( 'workos_mfa_totp_issuer', static fn() => 'Nexcess Portal' );
+ $this->queue( 'POST /user_management/users/user_mfa/auth_factors', $this->enroll_response( 'auth_factor_new' ), 201 );
+
+ $this->service->start_enrollment( $this->user_id );
+
+ $this->assertSame( 'Nexcess Portal', json_decode( $this->captured[0]['body'], true )['totp_issuer'] );
+ }
+
+ public function test_restarting_enrollment_deletes_the_unfinished_factor(): void {
+ update_user_meta(
+ $this->user_id,
+ User::META_MFA_PENDING_FACTOR,
+ [
+ 'factor_id' => 'auth_factor_old',
+ 'created' => time(),
+ ]
+ );
+ $this->queue( 'POST /user_management/users/user_mfa/auth_factors', $this->enroll_response( 'auth_factor_new' ), 201 );
+
+ $this->service->start_enrollment( $this->user_id );
+
+ $this->assertSame( 'DELETE', $this->captured[0]['method'] );
+ $this->assertStringContainsString( '/auth/factors/auth_factor_old', $this->captured[0]['url'] );
+ $this->assertSame( 'auth_factor_new', $this->service->pending_factor( $this->user_id )['factor_id'] );
+ }
+
+ public function test_unlinked_user_gets_409(): void {
+ $unlinked = self::factory()->user->create();
+ $this->captured = [];
+
+ $result = $this->service->start_enrollment( $unlinked );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'workos_mfa_user_not_linked', $result->get_error_code() );
+ $this->assertSame( 409, $result->get_error_data()['status'] );
+ $this->assertEmpty( $this->captured );
+ }
+
+ public function test_confirm_with_valid_code_enables_mfa_and_fires_hook(): void {
+ $this->set_pending( 'auth_factor_new' );
+ $this->queue( 'POST /auth/challenges/auth_challenge_new/verify', [ 'challenge' => [ 'id' => 'auth_challenge_new' ], 'valid' => true ] );
+ $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_new', 'type' => 'totp' ] ] ] );
+
+ $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '123 456', 'auth_challenge_new' );
+
+ $this->assertIsArray( $result );
+ $this->assertTrue( $result['enabled'] );
+ $this->assertSame( 'auth_factor_new', $result['factors'][0]['id'] );
+ $this->assertNull( $this->service->pending_factor( $this->user_id ) );
+ $this->assertSame( 'workos_mfa_enrolled', $this->fired[0][0] );
+ $this->assertSame( $this->user_id, $this->fired[0][1][0] );
+ $this->assertSame( '123456', json_decode( $this->captured[0]['body'], true )['code'] );
+ }
+
+ public function test_confirm_creates_challenge_when_none_given(): void {
+ $this->set_pending( 'auth_factor_new' );
+ $this->queue( 'POST /auth/factors/auth_factor_new/challenge', [ 'id' => 'auth_challenge_fresh' ] );
+ $this->queue( 'POST /auth/challenges/auth_challenge_fresh/verify', [ 'valid' => true ] );
+ $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_new', 'type' => 'totp' ] ] ] );
+
+ $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '123456' );
+
+ $this->assertStringContainsString( '/auth/factors/auth_factor_new/challenge', $this->captured[0]['url'] );
+ $this->assertStringContainsString( '/auth/challenges/auth_challenge_fresh/verify', $this->captured[1]['url'] );
+ $this->assertTrue( $this->service->is_enabled( $this->user_id ) );
+ }
+
+ /**
+ * @return array
+ */
+ public function wrong_code_responses(): array {
+ return [
+ 'valid false' => [ [ 'valid' => false ], 200 ],
+ '4xx error' => [ [ 'code' => 'authentication_challenge_failed' ], 400 ],
+ ];
+ }
+
+ /**
+ * @dataProvider wrong_code_responses
+ */
+ public function test_confirm_with_wrong_code_keeps_mfa_off( array $body, int $status ): void {
+ $this->set_pending( 'auth_factor_new' );
+ $this->queue( 'POST /auth/challenges/auth_challenge_new/verify', $body, $status );
+
+ $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '000000', 'auth_challenge_new' );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'workos_mfa_invalid_code', $result->get_error_code() );
+ $this->assertFalse( $this->service->is_enabled( $this->user_id ) );
+ $this->assertSame( 'auth_factor_new', $this->service->pending_factor( $this->user_id )['factor_id'] );
+ $this->assertEmpty( $this->fired );
+ }
+
+ public function test_confirm_rejects_malformed_code_without_calling_workos(): void {
+ $this->set_pending( 'auth_factor_new' );
+
+ $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '12ab', 'auth_challenge_new' );
+
+ $this->assertSame( 'workos_mfa_invalid_code', $result->get_error_code() );
+ $this->assertEmpty( $this->captured );
+ }
+
+ public function test_confirm_rejects_factor_that_is_not_pending(): void {
+ $this->set_pending( 'auth_factor_new' );
+
+ $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_someone_else', '123456', 'auth_challenge_new' );
+
+ $this->assertSame( 'workos_mfa_factor_not_found', $result->get_error_code() );
+ $this->assertEmpty( $this->captured );
+ }
+
+ // --------------------------------- Listing ---------------------------------
+
+ public function test_list_hides_pending_factor_and_reconciles_enabled_flag(): void {
+ $this->set_pending( 'auth_factor_pending' );
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ $this->queue(
+ 'GET /user_management/users/user_mfa/auth_factors',
+ [ 'data' => [ [ 'id' => 'auth_factor_pending', 'type' => 'totp' ] ] ]
+ );
+
+ $factors = $this->service->list_factors( $this->user_id );
+
+ $this->assertSame( [], $factors );
+ $this->assertFalse( $this->service->is_enabled( $this->user_id ), 'Only a pending factor in WorkOS: MFA is off.' );
+ }
+
+ public function test_list_turns_flag_on_for_factor_added_outside_wordpress(): void {
+ $this->queue(
+ 'GET /user_management/users/user_mfa/auth_factors',
+ [ 'data' => [ [ 'id' => 'auth_factor_1', 'type' => 'totp', 'created_at' => '2026-10-18T00:00:00Z' ] ] ]
+ );
+
+ $factors = $this->service->list_factors( $this->user_id );
+
+ $this->assertSame( [ [ 'id' => 'auth_factor_1', 'type' => 'totp', 'created' => '2026-10-18T00:00:00Z' ] ], $factors );
+ $this->assertTrue( $this->service->is_enabled( $this->user_id ) );
+ }
+
+ public function test_list_deletes_stale_pending_factor(): void {
+ update_user_meta(
+ $this->user_id,
+ User::META_MFA_PENDING_FACTOR,
+ [
+ 'factor_id' => 'auth_factor_stale',
+ 'created' => time() - FactorService::PENDING_FACTOR_TTL - 60,
+ ]
+ );
+ $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_stale', 'type' => 'totp' ] ] ] );
+
+ $this->service->list_factors( $this->user_id );
+
+ $deletes = array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] );
+ $this->assertCount( 1, $deletes );
+ $this->assertNull( $this->service->pending_factor( $this->user_id ) );
+ }
+
+ // ----------------------------- Delete and reset -----------------------------
+
+ public function test_delete_owned_factor_turns_mfa_off_when_last(): void {
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] ] );
+
+ $result = $this->service->delete_factor( $this->user_id, 'auth_factor_1' );
+
+ $this->assertTrue( $result );
+ $this->assertFalse( $this->service->is_enabled( $this->user_id ) );
+ $this->assertSame( [ 'workos_mfa_factor_removed', [ $this->user_id, 'auth_factor_1' ] ], $this->fired[0] );
+ }
+
+ public function test_delete_other_users_factor_is_404_and_deletes_nothing(): void {
+ $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_mine', 'type' => 'totp' ] ] ] );
+
+ $result = $this->service->delete_factor( $this->user_id, 'auth_factor_theirs' );
+
+ $this->assertSame( 'workos_mfa_factor_not_found', $result->get_error_code() );
+ $this->assertEmpty( array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] ) );
+ }
+
+ public function test_reset_deletes_every_factor(): void {
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ $this->set_pending( 'auth_factor_pending' );
+ $this->queue(
+ 'GET /user_management/users/user_mfa/auth_factors',
+ [ 'data' => [ [ 'id' => 'auth_factor_1' ], [ 'id' => 'auth_factor_pending' ] ] ]
+ );
+
+ $removed = $this->service->reset( $this->user_id );
+
+ $this->assertSame( 2, $removed );
+ $this->assertFalse( $this->service->is_enabled( $this->user_id ) );
+ $this->assertNull( $this->service->pending_factor( $this->user_id ) );
+ $this->assertSame( [ 'workos_mfa_reset', [ $this->user_id, 2 ] ], $this->fired[0] );
+ }
+
+ // ------------------------------ Require flag ------------------------------
+
+ public function test_set_required_fires_only_on_change(): void {
+ $this->assertTrue( $this->service->set_required( $this->user_id, true ) );
+ $this->assertFalse( $this->service->set_required( $this->user_id, true ) );
+ $this->assertTrue( $this->service->is_required_flag( $this->user_id ) );
+ $this->assertTrue( $this->service->set_required( $this->user_id, false ) );
+
+ $this->assertSame(
+ [
+ [ 'workos_mfa_required_changed', [ $this->user_id, true ] ],
+ [ 'workos_mfa_required_changed', [ $this->user_id, false ] ],
+ ],
+ $this->fired
+ );
+ }
+
+ // --------------------------------- Helpers ---------------------------------
+
+ public function test_helpers_default_to_current_user(): void {
+ wp_set_current_user( $this->user_id );
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ update_user_meta( $this->user_id, User::META_MFA_REQUIRED, '1' );
+
+ $this->assertTrue( workos_mfa_is_enabled() );
+ $this->assertTrue( workos_mfa_is_required() );
+ }
+
+ public function test_set_required_helper_rejects_unknown_user(): void {
+ $result = workos_mfa_set_required( 999999, true );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'workos_mfa_invalid_user', $result->get_error_code() );
+ }
+
+ public function test_set_required_helper_sets_flag(): void {
+ $this->assertTrue( workos_mfa_set_required( $this->user_id, true ) );
+ $this->assertTrue( workos_mfa_is_required( $this->user_id ) );
+ }
+
+ public function test_decision_helper_returns_none_for_logged_out_visitor(): void {
+ $this->assertSame( 'none', workos_mfa_decision() );
+ }
+
+ // -------------------------------- Fixtures --------------------------------
+
+ private function set_pending( string $factor_id ): void {
+ update_user_meta(
+ $this->user_id,
+ User::META_MFA_PENDING_FACTOR,
+ [
+ 'factor_id' => $factor_id,
+ 'created' => time(),
+ ]
+ );
+ }
+
+ private function enroll_response( string $factor_id ): array {
+ return [
+ 'authentication_factor' => [
+ 'object' => 'authentication_factor',
+ 'id' => $factor_id,
+ 'type' => 'totp',
+ 'totp' => [
+ 'qr_code' => 'data:image/png;base64,AAA',
+ 'secret' => 'SECRET',
+ 'uri' => 'otpauth://totp/Site:mfa@example.com?secret=SECRET',
+ ],
+ ],
+ 'authentication_challenge' => [
+ 'object' => 'authentication_challenge',
+ 'id' => 'auth_challenge_new',
+ ],
+ ];
+ }
+
+ private function queue( string $key, array $body, int $code = 200 ): void {
+ $this->responses[ $key ] = [
+ 'response' => [ 'code' => $code, 'message' => 'OK' ],
+ 'body' => wp_json_encode( $body ),
+ ];
+ }
+}
diff --git a/tests/wpunit/MfaPolicyTest.php b/tests/wpunit/MfaPolicyTest.php
new file mode 100644
index 0000000..950c08d
--- /dev/null
+++ b/tests/wpunit/MfaPolicyTest.php
@@ -0,0 +1,203 @@
+ Settings::MODE_PLUGIN ] );
+ \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
+
+ $this->policy = new Policy( new Settings() );
+ $this->user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ }
+
+ public function tearDown(): void {
+ remove_all_filters( 'workos_mfa_mode' );
+ remove_all_filters( 'workos_mfa_decision' );
+ remove_all_filters( 'workos_mfa_required_for_user' );
+ delete_option( 'workos_production' );
+ \WorkOS\Config::set_active_environment( 'staging' );
+ \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @return array
+ */
+ public function table_provider(): array {
+ return [
+ 'never, enrolled' => [ Profile::MFA_ENFORCE_NEVER, true, false, Policy::BLOCK ],
+ 'never, required' => [ Profile::MFA_ENFORCE_NEVER, false, true, Policy::BLOCK ],
+ 'never, neither' => [ Profile::MFA_ENFORCE_NEVER, false, false, Policy::NONE ],
+ 'if_required, enrolled' => [ Profile::MFA_ENFORCE_IF_REQUIRED, true, false, Policy::CHALLENGE ],
+ 'if_required, enrolled+required' => [ Profile::MFA_ENFORCE_IF_REQUIRED, true, true, Policy::CHALLENGE ],
+ 'if_required, required' => [ Profile::MFA_ENFORCE_IF_REQUIRED, false, true, Policy::ENROLL ],
+ 'if_required, neither' => [ Profile::MFA_ENFORCE_IF_REQUIRED, false, false, Policy::NONE ],
+ 'always, enrolled' => [ Profile::MFA_ENFORCE_ALWAYS, true, false, Policy::CHALLENGE ],
+ 'always, not enrolled' => [ Profile::MFA_ENFORCE_ALWAYS, false, false, Policy::ENROLL ],
+ 'always, required not enrolled' => [ Profile::MFA_ENFORCE_ALWAYS, false, true, Policy::ENROLL ],
+ ];
+ }
+
+ /**
+ * @dataProvider table_provider
+ */
+ public function test_decision_table( string $enforce, bool $enrolled, bool $required, string $expected ): void {
+ if ( $enrolled ) {
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ }
+ if ( $required ) {
+ update_user_meta( $this->user_id, User::META_MFA_REQUIRED, '1' );
+ }
+
+ $this->assertSame( $expected, $this->policy->decide( $this->user_id, $this->profile( $enforce ) ) );
+ }
+
+ public function test_profile_without_totp_acts_like_never(): void {
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+
+ $profile = $this->profile( Profile::MFA_ENFORCE_ALWAYS, [ Profile::FACTOR_SMS ] );
+
+ $this->assertSame( Policy::BLOCK, $this->policy->decide( $this->user_id, $profile ) );
+ }
+
+ /**
+ * @return array
+ */
+ public function non_plugin_modes(): array {
+ return [
+ 'workos' => [ Settings::MODE_WORKOS ],
+ 'off' => [ Settings::MODE_OFF ],
+ ];
+ }
+
+ /**
+ * @dataProvider non_plugin_modes
+ */
+ public function test_only_plugin_mode_decides( string $mode ): void {
+ update_option( 'workos_production', [ Settings::OPTION_MODE => $mode ] );
+ \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+
+ $this->assertSame( Policy::NONE, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_ALWAYS ) ) );
+ }
+
+ public function test_mode_filter_overrides_option_and_rejects_unknown_values(): void {
+ add_filter( 'workos_mfa_mode', static fn() => Settings::MODE_OFF );
+ $this->assertSame( Settings::MODE_OFF, ( new Settings() )->mode() );
+
+ remove_all_filters( 'workos_mfa_mode' );
+ add_filter( 'workos_mfa_mode', static fn() => 'sometimes' );
+ $this->assertSame( Settings::MODE_PLUGIN, ( new Settings() )->mode() );
+ }
+
+ public function test_required_role_requires_mfa(): void {
+ update_option(
+ 'workos_production',
+ [
+ Settings::OPTION_MODE => Settings::MODE_PLUGIN,
+ Settings::OPTION_REQUIRED_ROLES => [ 'editor' ],
+ ]
+ );
+ \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
+
+ $this->assertFalse( $this->policy->is_required( $this->user_id ) );
+
+ ( new \WP_User( $this->user_id ) )->set_role( 'editor' );
+
+ $this->assertTrue( $this->policy->is_required( $this->user_id ) );
+ $this->assertSame( Policy::ENROLL, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) );
+ }
+
+ public function test_required_filter_requires_mfa(): void {
+ $user_id = $this->user_id;
+ add_filter(
+ 'workos_mfa_required_for_user',
+ static fn( bool $required, int $id ) => $required || $id === $user_id,
+ 10,
+ 2
+ );
+
+ $this->assertSame( Policy::ENROLL, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) );
+ }
+
+ public function test_decision_filter_wins_and_ignores_unknown_values(): void {
+ add_filter( 'workos_mfa_decision', static fn() => Policy::CHALLENGE );
+ $this->assertSame( Policy::CHALLENGE, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) );
+
+ remove_all_filters( 'workos_mfa_decision' );
+ add_filter( 'workos_mfa_decision', static fn() => 'maybe' );
+ $this->assertSame( Policy::NONE, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) );
+ }
+
+ public function test_decision_filter_receives_context(): void {
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ $seen = null;
+ add_filter(
+ 'workos_mfa_decision',
+ static function ( $decision, $user_id, $profile, $context ) use ( &$seen ) {
+ $seen = $context;
+ return $decision;
+ },
+ 10,
+ 4
+ );
+
+ $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) );
+
+ $this->assertSame(
+ [
+ 'mode' => Settings::MODE_PLUGIN,
+ 'enrolled' => true,
+ 'required' => false,
+ ],
+ $seen
+ );
+ }
+
+ private function profile( string $enforce, array $factors = [ Profile::FACTOR_TOTP ] ): Profile {
+ return Profile::from_array(
+ [
+ 'slug' => 'members',
+ 'title' => 'Members',
+ 'mfa' => [
+ 'enforce' => $enforce,
+ 'factors' => $factors,
+ ],
+ ]
+ );
+ }
+}