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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@

- **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 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`.

### Changed

- **Profile-scoped MFA settings routes use the factor service** — `POST /auth/mfa/totp/enroll` leaves the factor unconfirmed until `POST /users/{id}/mfa/confirm`, `POST /auth/mfa/factor/delete` now needs a current `code` from the factor, and an unlinked account gets `409 workos_mfa_user_not_linked` instead of `400 workos_authkit_no_workos_user`. Nothing in the plugin called these routes, and against WorkOS they couldn't have worked before (see Fixed).

### Security

- **Password-only sign-ins can't skip MFA** — in `plugin` mode, `wp-login.php` with `?fallback=1`, `wp_signon()`, headless mode and the login button refuse (`403 workos_mfa_step_unsupported`) a user who has a factor or must use MFA, since they can't ask for a code. Application passwords are exempt. Filter `workos_mfa_allow_password_fallback`.
Expand Down
47 changes: 47 additions & 0 deletions docs/mfa.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ WorkOS users.
| What a sign-in needs | `WorkOS\Mfa\Policy::decide()`, filter `workos_mfa_decision`, helper `workos_mfa_decision()` | Code |
| 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 |
| Factors | `WorkOS\Mfa\FactorService`, helpers `workos_mfa_*` | Code |
| Activity log | `mfa.*` events | Admin |

Expand Down Expand Up @@ -203,6 +204,48 @@ add_filter( 'workos_mfa_allow_password_fallback', fn( $allow, $user_id ) => user
In `workos` mode a WordPress password never reaches WorkOS, so turn the
password fallback off.

## Account panel

Users turn two-step verification on and off themselves:

- **Profile screen:** a "Two-step verification" section on `profile.php` for
linked users.
- **`[workos:mfa]`:** the same panel on any page, e.g. a front-end account
screen. It renders nothing for visitors, unlinked accounts, or when
`workos_mfa_can_manage` says no.

Turning it on shows a QR code and setup key, and the factor only counts once
the first code checks out. Removing a factor needs a current code from it, so
a session someone else got hold of can't quietly turn MFA off.

### Routes

Cookie-authenticated with the core `wp_rest` nonce (`X-WP-Nonce`), so any UI
can use them.

| Route | Who | Body | Returns |
|---|---|---|---|
| `GET /users/{id}/mfa` | The owner, or `edit_user` (`workos_mfa_can_manage_user`) | — | `{ enabled, required, factors }` |
| `POST /users/{id}/mfa/enroll` | The owner (`workos_mfa_can_manage`) | — | `201 { factor_id, challenge_id, qr_code, secret, otpauth_uri }` |
| `POST /users/{id}/mfa/confirm` | The owner | `factor_id`, `code`, `challenge_id` | Status |
| `DELETE /users/{id}/mfa/factors/{factor_id}` | The owner | `code` | Status |

Rate limits per user: `mfa_enroll_user`, `mfa_confirm_user`, `mfa_delete_user`.
Errors add `workos_mfa_not_logged_in` (401) and `workos_mfa_forbidden` (403).

The older profile-scoped routes (`GET /auth/mfa/factors`,
`POST /auth/mfa/totp/enroll`, `POST /auth/mfa/factor/delete`) go through the
same service: enrollment needs the confirm step, and delete needs `code`.

```js
// Third-party UI: is MFA on for the current user?
const res = await fetch( `/wp-json/workos/v1/users/${ userId }/mfa`, {
headers: { 'X-WP-Nonce': wpApiSettings.nonce },
credentials: 'same-origin',
} );
const { enabled, required } = await res.json();
```

## Hooks

**Filters**
Expand All @@ -216,6 +259,8 @@ password fallback off.
| `workos_mfa_block_message` | `string $message, int $user_id, Profile $profile` | Generic message |
| `workos_mfa_pending_ttl` | `int $ttl` | 600 seconds |
| `workos_mfa_allow_password_fallback` | `bool $allow, int $user_id` | `false` |
| `workos_mfa_can_manage` | `bool $can, int $user_id` | `true` for the account owner |
| `workos_mfa_can_manage_user` | `bool $can, int $user_id` | `edit_user` on the target |

**Actions**

Expand Down Expand Up @@ -264,4 +309,6 @@ password fallback off.
| Sign-in routes | `src/WorkOS/REST/Auth/Mfa.php` |
| Password-only guard | `src/WorkOS/Mfa/PasswordGuard.php` |
| Enroll and challenge steps | `src/js/authkit/flows.tsx` (`MfaEnroll`, `MfaChallenge`) |
| Account routes | `src/WorkOS/Mfa/RestApi.php` |
| Account panel | `src/js/mfa-settings/index.tsx`, `src/WorkOS/Mfa/{Assets,Shortcode,UserProfilePanel}.php` |
| WorkOS calls | `src/WorkOS/Api/Client.php` (`list_auth_factors`, `enroll_totp_factor`, `challenge_auth_factor`, `verify_auth_challenge`, `delete_auth_factor`) |
96 changes: 96 additions & 0 deletions src/WorkOS/Mfa/Assets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/**
* MFA account panel assets.
*
* @package WorkOS\Mfa
*/

namespace WorkOS\Mfa;

use WorkOS\User;

defined( 'ABSPATH' ) || exit;

/**
* Registers the account panel script and enqueues it on the user's own
* profile screen. The `[workos:mfa]` shortcode enqueues it where it renders.
*/
class Assets {

/**
* Script and style handle.
*/
public const HANDLE = 'workos-mfa-settings';

/**
* Register hooks.
*
* @return void
*/
public function register(): void {
add_action( 'init', [ $this, 'register_assets' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_admin' ] );
}

/**
* Register the script and style.
*
* @return void
*/
public function register_assets(): void {
$asset_file = workos()->getDir() . 'build/mfa-settings.asset.php';
$asset = file_exists( $asset_file )
? require $asset_file
: [
'dependencies' => [ 'wp-element', 'wp-i18n' ],
'version' => workos()->getVersion(),
];
$version = (string) ( $asset['version'] ?? workos()->getVersion() );

wp_register_script(
self::HANDLE,
workos()->getUrl() . 'build/mfa-settings.js',
(array) ( $asset['dependencies'] ?? [] ),
$version,
true
);

wp_set_script_translations( self::HANDLE, 'integration-workos' );

wp_localize_script(
self::HANDLE,
'workosMfa',
[
'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/users/' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
]
);

wp_register_style( self::HANDLE, workos()->getUrl() . 'build/mfa-settings.css', [], $version );
}

/**
* Enqueue on the signed-in user's own profile screen.
*
* @param string $hook_suffix Admin page hook.
*
* @return void
*/
public function maybe_enqueue_admin( string $hook_suffix ): void {
if ( 'profile.php' !== $hook_suffix || ! User::is_sso() ) {
return;
}

self::enqueue();
}

/**
* Enqueue the panel script and style.
*
* @return void
*/
public static function enqueue(): void {
wp_enqueue_script( self::HANDLE );
wp_enqueue_style( self::HANDLE );
}
}
13 changes: 11 additions & 2 deletions src/WorkOS/Mfa/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
use WorkOS\Contracts\Controller as BaseController;

/**
* Binds the MFA settings, policy and factor service, and the guard that
* keeps password-only sign-ins from skipping MFA.
* Binds the MFA settings, policy and factor service, the guard that keeps
* password-only sign-ins from skipping MFA, and the account panel (REST,
* assets, `[workos:mfa]`, profile screen).
*
* Sign-in enforcement lives in `Auth\AuthKit\LoginCompleter`, and the
* routes in `REST\Auth\Mfa`; both resolve these from the container.
Expand All @@ -28,8 +29,16 @@ protected function doRegister(): void {
$this->container->singleton( Policy::class );
$this->container->singleton( FactorService::class );
$this->container->singleton( PasswordGuard::class );
$this->container->singleton( RestApi::class );
$this->container->singleton( Assets::class );
$this->container->singleton( Shortcode::class );
$this->container->singleton( UserProfilePanel::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();
}

/**
Expand Down
Loading
Loading