From df646aa03b94b9ebd00e4809329b2f341b76e21d Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Sun, 18 Oct 2026 17:04:16 +0000 Subject: [PATCH] feat(mfa): admin tools, settings, Diagnostics and WP-CLI - WorkOS -> Settings -> Authentication: MFA mode and required roles (per environment, sanitized against the known modes and roles) - User-edit screen: status, Require checkbox, Lost authenticator reset - PUT /users/{id}/mfa/required and POST /users/{id}/mfa/reset (edit_user, filter workos_mfa_can_manage_user) - wp workos mfa status|require|unrequire|reset - Diagnostics: Two-Step Verification check (factor API reachable, mode) --- CHANGELOG.md | 1 + docs/mfa.md | 32 ++++ src/WorkOS/Admin/DiagnosticsPage.php | 50 ++++++ src/WorkOS/Admin/Settings.php | 60 +++++++ src/WorkOS/CLI/Controller.php | 1 + src/WorkOS/CLI/MfaCommand.php | 169 +++++++++++++++++++ src/WorkOS/Mfa/Admin/UserEditPanel.php | 155 +++++++++++++++++ src/WorkOS/Mfa/Controller.php | 2 + src/WorkOS/Mfa/RestApi.php | 86 +++++++++- tests/wpunit/MfaAdminTest.php | 222 +++++++++++++++++++++++++ 10 files changed, 776 insertions(+), 2 deletions(-) create mode 100644 src/WorkOS/CLI/MfaCommand.php create mode 100644 src/WorkOS/Mfa/Admin/UserEditPanel.php create mode 100644 tests/wpunit/MfaAdminTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df527a..aaa3dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **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 at sign-in, decided by the plugin** — in `plugin` mode every AuthKit sign-in (password, magic code, OAuth, the hosted-AuthKit callback) asks the MFA policy before it sets a session: a user with a factor answers a code, a user who must use MFA and has none enrolls an authenticator app on the spot (QR code and setup key), and a profile that can't satisfy the user's MFA refuses with `403 workos_mfa_blocked`. The WorkOS response waits server-side (`WorkOS\Mfa\PendingLogin`: bound to the profile, single use, 5 wrong codes, 10 minutes) and the browser only holds an `mfa_token`. New `POST /wp-json/workos/v1/auth/mfa/enroll`; `/challenge` and `/verify` accept `mfa_token`. WorkOS's own `mfa_enrollment` now enrolls in the same step instead of being refused. A profile with `enforce: always` asks a user without a factor to enroll instead of refusing them. Hosted-AuthKit sign-ins finish the step on `/workos/login/{profile}/?workos_mfa=…`. - **Two-step verification account panel** — a "Two-step verification" section on the profile screen and a `[workos:mfa]` shortcode let users turn MFA on (QR code and setup key, confirmed with the first code) and remove an authenticator app (with a current code from it). New routes `GET /wp-json/workos/v1/users/{id}/mfa`, `POST …/mfa/enroll`, `POST …/mfa/confirm` and `DELETE …/mfa/factors/{factor_id}`, cookie-authenticated with the `wp_rest` nonce. Filters `workos_mfa_can_manage` and `workos_mfa_can_manage_user`. +- **MFA admin tools** — **WorkOS → Settings → Authentication** sets the mode and the roles that must use MFA, per environment. The user-edit screen shows another user's MFA status with a **Require** checkbox and a **Lost authenticator** reset. New routes `PUT /wp-json/workos/v1/users/{id}/mfa/required` and `POST …/mfa/reset`, WP-CLI `wp workos mfa status|require|unrequire|reset`, and a "Two-Step Verification" check on **WorkOS → Diagnostics**. - **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`, `workos_mfa_totp_issuer`, `workos_mfa_block_message`, `workos_mfa_pending_ttl` and `workos_mfa_allow_password_fallback`; actions `workos_mfa_enrolled`, `workos_mfa_factor_removed`, `workos_mfa_reset`, `workos_mfa_required_changed`, `workos_mfa_challenge_succeeded`, `workos_mfa_challenge_failed` and `workos_mfa_blocked`; Activity Log events `mfa.enrolled`, `mfa.removed`, `mfa.reset`, `mfa.required_set`, `mfa.required_cleared`, `mfa.challenge_succeeded`, `mfa.challenge_failed` and `mfa.blocked`. diff --git a/docs/mfa.md b/docs/mfa.md index fd1f6cd..ff21651 100644 --- a/docs/mfa.md +++ b/docs/mfa.md @@ -19,6 +19,8 @@ WorkOS users. | Sign-in step | `POST /auth/mfa/enroll`, `/auth/mfa/challenge`, `/auth/mfa/verify` and the React shell | Visitors | | Password-only sign-ins | Refused for users with MFA (`workos_mfa_allow_password_fallback`) | Site | | Account panel | Profile screen, `[workos:mfa]` shortcode, `/users/{id}/mfa…` routes | Users | +| Admin tools | **WorkOS → Settings → Authentication**, the user-edit screen, `PUT …/mfa/required`, `POST …/mfa/reset`, `wp workos mfa` | Admins, Support | +| Diagnostics | "Two-Step Verification" check on **WorkOS → Diagnostics** | Admins | | Factors | `WorkOS\Mfa\FactorService`, helpers `workos_mfa_*` | Code | | Activity log | `mfa.*` events | Admin | @@ -246,6 +248,36 @@ const res = await fetch( `/wp-json/workos/v1/users/${ userId }/mfa`, { const { enabled, required } = await res.json(); ``` +## Admin tools + +- **Settings:** **WorkOS → Settings → Authentication** has the mode + (**Two-Step Verification**) and the roles that must use it, per environment. +- **User-edit screen:** for another linked user, shows whether MFA is on, a + **Require** checkbox, and **Lost authenticator** (removes every factor on + save). Shown to anyone with `edit_user` on the target, or whom + `workos_mfa_can_manage_user` allows, e.g. a Support role. +- **Routes** (same permission): + + | Route | Body | Returns | + |---|---|---| + | `PUT /users/{id}/mfa/required` | `required` (bool) | `{ required, enabled }` | + | `POST /users/{id}/mfa/reset` | — | `{ removed }` | + +- **WP-CLI:** + + ``` + wp workos mfa status [--format=json] + wp workos mfa require + wp workos mfa unrequire + wp workos mfa reset [--yes] + ``` + + `` is an ID, email or login. + +- **Diagnostics:** the "Two-Step Verification" check lists factors for a user + that can't exist; a "not found" answer means the factor API is reachable with + these keys. It also says which mode is active. + ## Hooks **Filters** diff --git a/src/WorkOS/Admin/DiagnosticsPage.php b/src/WorkOS/Admin/DiagnosticsPage.php index 3798194..e9b7a41 100644 --- a/src/WorkOS/Admin/DiagnosticsPage.php +++ b/src/WorkOS/Admin/DiagnosticsPage.php @@ -10,6 +10,7 @@ use WorkOS\App; use WorkOS\Config; use WorkOS\Auth\Login; +use WorkOS\Mfa\Settings as MfaSettings; use WorkOS\Options\Global_Options; defined( 'ABSPATH' ) || exit; @@ -167,6 +168,7 @@ private function run_all_checks(): array { // 2. API Connectivity. $checks[] = $this->check_api_connectivity(); + $checks[] = $this->check_mfa(); // 3. Database Tables. $checks[] = $this->check_database_tables(); @@ -256,6 +258,54 @@ private function check_api_connectivity(): array { ]; } + /** + * Check that the WorkOS MFA factor API answers, and show the MFA mode. + * + * Lists factors for a user that can't exist: a "not found" answer + * means the API is reachable with these keys. + * + * @return array + */ + private function check_mfa(): array { + $label = __( 'Two-Step Verification', 'integration-workos' ); + $mode = App::container()->get( MfaSettings::class )->mode(); + $modes = [ + MfaSettings::MODE_PLUGIN => __( 'WordPress decides who is challenged.', 'integration-workos' ), + MfaSettings::MODE_WORKOS => __( 'WorkOS requires MFA for every user in the environment.', 'integration-workos' ), + MfaSettings::MODE_OFF => __( 'WordPress asks nobody for a code.', 'integration-workos' ), + ]; + + if ( ! workos()->is_enabled() ) { + return [ + 'label' => $label, + 'pass' => false, + 'detail' => esc_html__( 'Plugin not configured (missing API key, Client ID, or Environment ID).', 'integration-workos' ), + ]; + } + + $result = workos()->api()->list_auth_factors( 'user_workos_diagnostics_check' ); + $status = is_wp_error( $result ) ? (int) ( $result->get_error_data()['status'] ?? 0 ) : 200; + $pass = ! is_wp_error( $result ) || in_array( $status, [ 400, 404, 422 ], true ); + + return [ + 'label' => $label, + 'pass' => $pass, + 'detail' => esc_html( + $pass + ? sprintf( + /* translators: %s: sentence describing the MFA mode. */ + __( 'The WorkOS factor API is reachable. %s', 'integration-workos' ), + $modes[ $mode ] + ) + : sprintf( + /* translators: %s: error message from WorkOS. */ + __( 'Could not reach the WorkOS factor API: %s', 'integration-workos' ), + $result->get_error_message() + ) + ), + ]; + } + /** * Check if custom database tables exist. * diff --git a/src/WorkOS/Admin/Settings.php b/src/WorkOS/Admin/Settings.php index a4d600f..aa0b362 100644 --- a/src/WorkOS/Admin/Settings.php +++ b/src/WorkOS/Admin/Settings.php @@ -11,6 +11,7 @@ use WorkOS\Auth\AuthKit\Profile; use WorkOS\Auth\AuthKit\ProfileRepository; use WorkOS\Config; +use WorkOS\Mfa\Settings as MfaSettings; use WorkOS\Options\Options; use WorkOS\Options\Production; use WorkOS\Options\Staging; @@ -726,6 +727,30 @@ static function (): void { ] ); + add_settings_field( + 'workos_env_mfa_mode', + __( 'Two-Step Verification', 'integration-workos' ), + [ $this, 'render_select' ], + 'workos', + 'workos_auth', + [ + 'name' => $this->env_option( MfaSettings::OPTION_MODE ), + 'options' => [ + MfaSettings::MODE_PLUGIN => __( 'WordPress decides — per user, role and Login Profile (Recommended)', 'integration-workos' ), + MfaSettings::MODE_WORKOS => __( 'WorkOS decides — MFA is set to Required in the WorkOS dashboard', 'integration-workos' ), + MfaSettings::MODE_OFF => __( 'Off — WordPress asks nobody for a code', 'integration-workos' ), + ], + ] + ); + + add_settings_field( + 'workos_env_mfa_required_roles', + __( 'Require Two-Step Verification', 'integration-workos' ), + [ $this, 'render_mfa_required_roles' ], + 'workos', + 'workos_auth' + ); + // --- Audit Logging section --- add_settings_section( 'workos_audit', @@ -1365,6 +1390,33 @@ public function render_select( array $args ): void { echo ''; } + /** + * Render the roles that must use two-step verification. + * + * A hidden empty value keeps an all-unchecked submit from leaving the + * old list in place. + * + * @return void + */ + public function render_mfa_required_roles(): void { + $name = $this->env_option( MfaSettings::OPTION_REQUIRED_ROLES ); + $selected = (array) $this->get_field_value( $name, [] ); + + printf( '', esc_attr( $name ) ); + echo '
'; + foreach ( wp_roles()->get_names() as $role => $label ) { + printf( + ' ', + esc_attr( $name ), + esc_attr( $role ), + checked( in_array( $role, $selected, true ), true, false ), + esc_html( translate_user_role( $label ) ) + ); + } + echo '
'; + echo '

' . esc_html__( 'Members of these roles must use an authenticator app. Those without one set it up at their next sign-in. Applies when WordPress decides; you can also require it per user on their profile.', 'integration-workos' ) . '

'; + } + /** * Render a checkbox. * @@ -2328,6 +2380,7 @@ public function sanitize_environment_options( $input ): array { 'login_mode' => [ 'custom', 'redirect', 'headless' ], 'deprovision_action' => [ 'deactivate', 'demote', 'delete' ], 'role_sync_schedule' => array_keys( RoleSyncCheck::SCHEDULES ), + 'mfa_mode' => MfaSettings::modes(), ]; foreach ( $choices as $key => $allowed ) { if ( isset( $input[ $key ] ) && in_array( $input[ $key ], $allowed, true ) ) { @@ -2349,6 +2402,13 @@ public function sanitize_environment_options( $input ): array { $sanitized['identity_gate_profile'] = '' !== $slug ? $slug : Profile::DEFAULT_SLUG; } + // Roles that must use two-step verification. + if ( isset( $input[ MfaSettings::OPTION_REQUIRED_ROLES ] ) ) { + $sanitized[ MfaSettings::OPTION_REQUIRED_ROLES ] = array_values( + array_intersect( (array) $input[ MfaSettings::OPTION_REQUIRED_ROLES ], array_keys( wp_roles()->get_names() ) ) + ); + } + // Integer fields. if ( isset( $input['reassign_user'] ) ) { $sanitized['reassign_user'] = absint( $input['reassign_user'] ); diff --git a/src/WorkOS/CLI/Controller.php b/src/WorkOS/CLI/Controller.php index aa9e5a3..6e36cb4 100644 --- a/src/WorkOS/CLI/Controller.php +++ b/src/WorkOS/CLI/Controller.php @@ -33,6 +33,7 @@ protected function doRegister(): void { \WP_CLI::add_command( 'workos user', UserCommand::class ); \WP_CLI::add_command( 'workos org', OrgCommand::class ); \WP_CLI::add_command( 'workos sync', SyncCommand::class ); + \WP_CLI::add_command( 'workos mfa', MfaCommand::class ); } /** diff --git a/src/WorkOS/CLI/MfaCommand.php b/src/WorkOS/CLI/MfaCommand.php new file mode 100644 index 0000000..c8bf959 --- /dev/null +++ b/src/WorkOS/CLI/MfaCommand.php @@ -0,0 +1,169 @@ + + * : WP user ID, email or login. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * wp workos mfa status 42 + * wp workos mfa status alice@example.com --format=json + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function status( $args, $assoc_args ) { + $user = $this->user( $args[0] ); + $status = $this->factors()->status( $user->ID ); + if ( is_wp_error( $status ) ) { + WP_CLI::error( $status->get_error_message() ); + } + + $row = [ + 'user' => $user->user_email, + 'enabled' => $status['enabled'] ? 'yes' : 'no', + 'required_flag' => $status['required'] ? 'yes' : 'no', + 'must_use_mfa' => App::container()->get( Policy::class )->is_required( $user->ID ) ? 'yes' : 'no', + 'factors' => count( $status['factors'] ), + ]; + + ( new Formatter( $assoc_args, array_keys( $row ) ) )->display_item( $row ); + } + + /** + * Require MFA for a user. Without a factor, they set one up at their next sign-in. + * + * ## OPTIONS + * + * + * : WP user ID, email or login. + * + * ## EXAMPLES + * + * wp workos mfa require 42 + * + * @param array $args Positional arguments. + */ + public function require( $args ) { + $user = $this->user( $args[0] ); + $changed = $this->factors()->set_required( $user->ID, true ); + + WP_CLI::success( $changed ? "MFA is now required for {$user->user_email}." : "MFA was already required for {$user->user_email}." ); + } + + /** + * Clear the require flag on a user. Roles and filters may still require MFA. + * + * ## OPTIONS + * + * + * : WP user ID, email or login. + * + * ## EXAMPLES + * + * wp workos mfa unrequire 42 + * + * @param array $args Positional arguments. + */ + public function unrequire( $args ) { + $user = $this->user( $args[0] ); + $changed = $this->factors()->set_required( $user->ID, false ); + + WP_CLI::success( $changed ? "Cleared the MFA requirement for {$user->user_email}." : "MFA wasn't required for {$user->user_email}." ); + } + + /** + * Remove every MFA factor a user has (lost authenticator). + * + * ## OPTIONS + * + * + * : WP user ID, email or login. + * + * [--yes] + * : Skip the confirmation prompt. + * + * ## EXAMPLES + * + * wp workos mfa reset 42 --yes + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function reset( $args, $assoc_args ) { + $user = $this->user( $args[0] ); + + WP_CLI::confirm( "Remove every authenticator app from {$user->user_email}?", $assoc_args ); + + $removed = $this->factors()->reset( $user->ID ); + if ( is_wp_error( $removed ) ) { + WP_CLI::error( $removed->get_error_message() ); + } + + WP_CLI::success( "Removed {$removed} factor(s) from {$user->user_email}." ); + } + + /** + * Resolve a user by ID, email or login, or stop. + * + * @param string $identifier User ID, email or login. + * + * @return \WP_User + */ + private function user( string $identifier ): \WP_User { + if ( ctype_digit( $identifier ) ) { + $user = get_user_by( 'id', (int) $identifier ); + } elseif ( is_email( $identifier ) ) { + $user = get_user_by( 'email', $identifier ); + } else { + $user = get_user_by( 'login', $identifier ); + } + + if ( ! $user ) { + WP_CLI::error( 'User not found.' ); + } + + return $user; + } + + /** + * Factor service. + * + * @return FactorService + */ + private function factors(): FactorService { + return App::container()->get( FactorService::class ); + } +} diff --git a/src/WorkOS/Mfa/Admin/UserEditPanel.php b/src/WorkOS/Mfa/Admin/UserEditPanel.php new file mode 100644 index 0000000..e9ab7de --- /dev/null +++ b/src/WorkOS/Mfa/Admin/UserEditPanel.php @@ -0,0 +1,155 @@ +factors = $factors; + $this->policy = $policy; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'edit_user_profile', [ $this, 'render' ], 22 ); + add_action( 'edit_user_profile_update', [ $this, 'save' ] ); + } + + /** + * Render the panel. + * + * @param WP_User $user User being edited. + * + * @return void + */ + public function render( WP_User $user ): void { + if ( ! User::is_sso( $user->ID ) || true !== RestApi::can_manage_user( $user->ID ) ) { + return; + } + + $status = $this->factors->status( $user->ID ); + $flag = $this->factors->is_required_flag( $user->ID ); + $required = $this->policy->is_required( $user->ID ); + ?> +

+ + + + + + + + + + + + + + + + + factors->set_required( $user_id, '1' === $required ); + + if ( ! empty( $_POST['workos_mfa_reset'] ) ) { + $this->factors->reset( $user_id ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } +} diff --git a/src/WorkOS/Mfa/Controller.php b/src/WorkOS/Mfa/Controller.php index c21e56c..4a8b7c5 100644 --- a/src/WorkOS/Mfa/Controller.php +++ b/src/WorkOS/Mfa/Controller.php @@ -33,12 +33,14 @@ protected function doRegister(): void { $this->container->singleton( Assets::class ); $this->container->singleton( Shortcode::class ); $this->container->singleton( UserProfilePanel::class ); + $this->container->singleton( Admin\UserEditPanel::class ); $this->container->get( PasswordGuard::class )->register(); $this->container->get( RestApi::class )->register(); $this->container->get( Assets::class )->register(); $this->container->get( Shortcode::class )->register(); $this->container->get( UserProfilePanel::class )->register(); + $this->container->get( Admin\UserEditPanel::class )->register(); } /** diff --git a/src/WorkOS/Mfa/RestApi.php b/src/WorkOS/Mfa/RestApi.php index 9796a5e..183b3ab 100644 --- a/src/WorkOS/Mfa/RestApi.php +++ b/src/WorkOS/Mfa/RestApi.php @@ -25,9 +25,12 @@ * - POST /users/{id}/mfa/enroll — create a TOTP factor (QR + secret). * - POST /users/{id}/mfa/confirm — confirm it with the first code. * - DELETE /users/{id}/mfa/factors/{factor_id} — remove a factor, with a code from it. + * - PUT /users/{id}/mfa/required — set or clear the require flag (admin). + * - POST /users/{id}/mfa/reset — remove every factor (admin). * - * Enrolling and removing are the account owner's alone. Admins can read - * the status; their tools (reset, require) are in the admin routes. + * Enrolling and removing are the account owner's alone. Admins (`edit_user`, + * filter `workos_mfa_can_manage_user`) read the status, require MFA and + * reset a lost authenticator. */ class RestApi { @@ -127,6 +130,85 @@ public function register_routes(): void { ], ] ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/mfa/required', + [ + 'methods' => 'PUT', + 'callback' => [ $this, 'set_required' ], + 'permission_callback' => [ $this, 'can_administer' ], + 'args' => $id + [ + 'required' => [ + 'required' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/mfa/reset', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'reset' ], + 'permission_callback' => [ $this, 'can_administer' ], + 'args' => $id, + ] + ); + } + + /** + * Permission callback for the admin routes. + * + * @param WP_REST_Request $request REST request. + * + * @return true|WP_Error + */ + public function can_administer( WP_REST_Request $request ) { + return self::can_manage_user( (int) $request->get_param( 'id' ) ); + } + + /** + * PUT /users/{id}/mfa/required + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function set_required( WP_REST_Request $request ) { + $user_id = (int) $request->get_param( 'id' ); + if ( ! get_userdata( $user_id ) ) { + return $this->error( 'workos_mfa_invalid_user', __( 'User not found.', 'integration-workos' ), 404 ); + } + + $required = (bool) $request->get_param( 'required' ); + $this->factors->set_required( $user_id, $required ); + + return new WP_REST_Response( + [ + 'required' => $required, + 'enabled' => $this->factors->is_enabled( $user_id ), + ], + 200 + ); + } + + /** + * POST /users/{id}/mfa/reset + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function reset( WP_REST_Request $request ) { + $removed = $this->factors->reset( (int) $request->get_param( 'id' ) ); + if ( is_wp_error( $removed ) ) { + return $removed; + } + + return new WP_REST_Response( [ 'removed' => $removed ], 200 ); } /** diff --git a/tests/wpunit/MfaAdminTest.php b/tests/wpunit/MfaAdminTest.php new file mode 100644 index 0000000..917f50f --- /dev/null +++ b/tests/wpunit/MfaAdminTest.php @@ -0,0 +1,222 @@ + 'sk_test_fake', + 'client_id' => 'client_fake', + 'environment_id' => 'environment_test', + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $api = new RestApi( new FactorService(), new TieredRateLimiter( new DatabaseRateLimiter() ) ); + add_action( 'rest_api_init', [ $api, 'register_routes' ] ); + do_action( 'rest_api_init', rest_get_server() ); + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + + $this->user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + $this->admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + update_user_meta( $this->user_id, User::META_WORKOS_ID, 'user_member' ); + + $this->captured = []; + wp_set_current_user( $this->admin_id ); + } + + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + remove_all_filters( 'workos_mfa_can_manage_user' ); + remove_all_filters( 'sanitize_option_workos_production' ); + unset( $_POST['workos_mfa_required'], $_POST['workos_mfa_reset'] ); + $this->captured = []; + + 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 = 'GET' === $method && str_contains( $url, '/auth_factors' ) + ? [ 'data' => [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] ] + : []; + + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( $body ), + ]; + } + + // --------------------------------- Routes --------------------------------- + + public function test_admin_requires_and_clears_mfa(): void { + $on = $this->request( 'PUT', '/required', [ 'required' => true ] ); + $this->assertSame( 200, $on->get_status() ); + $this->assertTrue( $on->get_data()['required'] ); + $this->assertSame( '1', get_user_meta( $this->user_id, User::META_MFA_REQUIRED, true ) ); + + $off = $this->request( 'PUT', '/required', [ 'required' => false ] ); + $this->assertFalse( $off->get_data()['required'] ); + $this->assertSame( '', (string) get_user_meta( $this->user_id, User::META_MFA_REQUIRED, true ) ); + } + + public function test_admin_resets_factors(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + + $response = $this->request( 'POST', '/reset' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 1, $response->get_data()['removed'] ); + $this->assertSame( '', (string) get_user_meta( $this->user_id, User::META_MFA_ENABLED, true ) ); + $this->assertNotEmpty( array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] ) ); + } + + public function test_non_admin_cannot_require_or_reset(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + + $this->assertContains( $this->request( 'PUT', '/required', [ 'required' => true ] )->get_status(), [ 401, 403 ] ); + $this->assertContains( $this->request( 'POST', '/reset' )->get_status(), [ 401, 403 ] ); + $this->assertSame( '', (string) get_user_meta( $this->user_id, User::META_MFA_REQUIRED, true ) ); + } + + public function test_can_manage_user_filter_grants_support_role(): void { + $support = self::factory()->user->create( [ 'role' => 'editor' ] ); + wp_set_current_user( $support ); + add_filter( 'workos_mfa_can_manage_user', static fn( $can ) => $can || get_current_user_id() === $support ); + + $this->assertSame( 200, $this->request( 'POST', '/reset' )->get_status() ); + } + + // ------------------------------ User-edit panel ------------------------------ + + public function test_panel_save_sets_flag_and_resets(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $_POST['workos_mfa_required'] = '1'; + $_POST['workos_mfa_reset'] = '1'; + + ( new UserEditPanel( new FactorService(), new Policy( new Settings() ) ) )->save( $this->user_id ); + + $this->assertSame( '1', get_user_meta( $this->user_id, User::META_MFA_REQUIRED, true ) ); + $this->assertSame( '', (string) get_user_meta( $this->user_id, User::META_MFA_ENABLED, true ) ); + } + + public function test_panel_save_ignores_forms_without_the_panel(): void { + update_user_meta( $this->user_id, User::META_MFA_REQUIRED, '1' ); + + ( new UserEditPanel( new FactorService(), new Policy( new Settings() ) ) )->save( $this->user_id ); + + $this->assertSame( '1', get_user_meta( $this->user_id, User::META_MFA_REQUIRED, true ) ); + } + + public function test_panel_renders_status_for_admin_only(): void { + $panel = new UserEditPanel( new FactorService(), new Policy( new Settings() ) ); + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + + ob_start(); + $panel->render( get_userdata( $this->user_id ) ); + $html = (string) ob_get_clean(); + $this->assertStringContainsString( 'name="workos_mfa_required"', $html ); + $this->assertStringContainsString( 'name="workos_mfa_reset"', $html ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + ob_start(); + $panel->render( get_userdata( $this->user_id ) ); + $this->assertSame( '', (string) ob_get_clean() ); + } + + // -------------------------------- Settings -------------------------------- + + public function test_settings_sanitize_mode_and_roles(): void { + add_filter( 'sanitize_option_workos_production', [ new AdminSettings(), 'sanitize_environment_options' ] ); + + $saved = sanitize_option( + 'workos_production', + [ + Settings::OPTION_MODE => Settings::MODE_WORKOS, + Settings::OPTION_REQUIRED_ROLES => [ '', 'editor', 'not_a_role' ], + ] + ); + $this->assertSame( Settings::MODE_WORKOS, $saved[ Settings::OPTION_MODE ] ); + $this->assertSame( [ 'editor' ], $saved[ Settings::OPTION_REQUIRED_ROLES ] ); + + $bad = sanitize_option( 'workos_production', [ Settings::OPTION_MODE => 'sometimes' ] ); + $this->assertArrayNotHasKey( Settings::OPTION_MODE, $bad ); + + $cleared = sanitize_option( 'workos_production', [ Settings::OPTION_REQUIRED_ROLES => [ '' ] ] ); + $this->assertSame( [], $cleared[ Settings::OPTION_REQUIRED_ROLES ] ); + } + + // -------------------------------- Fixtures -------------------------------- + + private function request( string $method, string $path, array $body = [] ) { + $request = new WP_REST_Request( $method, '/workos/v1/users/' . $this->user_id . '/mfa' . $path ); + $request->set_header( 'Content-Type', 'application/json' ); + if ( $body ) { + $request->set_body( wp_json_encode( $body ) ); + } + + return rest_get_server()->dispatch( $request ); + } +}