Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
32 changes: 32 additions & 0 deletions docs/mfa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 <user> [--format=json]
wp workos mfa require <user>
wp workos mfa unrequire <user>
wp workos mfa reset <user> [--yes]
```

`<user>` 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**
Expand Down
50 changes: 50 additions & 0 deletions src/WorkOS/Admin/DiagnosticsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*
Expand Down
60 changes: 60 additions & 0 deletions src/WorkOS/Admin/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1365,6 +1390,33 @@ public function render_select( array $args ): void {
echo '</select>';
}

/**
* 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( '<input type="hidden" name="%s[]" value="" />', esc_attr( $name ) );
echo '<fieldset>';
foreach ( wp_roles()->get_names() as $role => $label ) {
printf(
'<label style="margin-right:1em"><input type="checkbox" name="%s[]" value="%s" %s /> %s</label> ',
esc_attr( $name ),
esc_attr( $role ),
checked( in_array( $role, $selected, true ), true, false ),
esc_html( translate_user_role( $label ) )
);
}
echo '</fieldset>';
echo '<p class="description">' . 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' ) . '</p>';
}

/**
* Render a checkbox.
*
Expand Down Expand Up @@ -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 ) ) {
Expand All @@ -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'] );
Expand Down
1 change: 1 addition & 0 deletions src/WorkOS/CLI/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down
169 changes: 169 additions & 0 deletions src/WorkOS/CLI/MfaCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php
/**
* CLI MFA Command
*
* @package WorkOS\CLI
*/

namespace WorkOS\CLI;

use WorkOS\App;
use WorkOS\Mfa\FactorService;
use WorkOS\Mfa\Policy;
use WP_CLI;
use WP_CLI\Formatter;

/**
* Manage a user's two-step verification (MFA).
*/
class MfaCommand extends \WP_CLI_Command {

/**
* Show a user's MFA status, read from WorkOS.
*
* ## OPTIONS
*
* <user>
* : WP user ID, email or login.
*
* [--format=<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
*
* <user>
* : 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
*
* <user>
* : 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
*
* <user>
* : 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 );
}
}
Loading
Loading