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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
201 changes: 201 additions & 0 deletions docs/mfa.md
Original file line number Diff line number Diff line change
@@ -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`) |
2 changes: 1 addition & 1 deletion src/WorkOS/ActivityLog/AdminPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ];

?>
<div class="wrap">
Expand Down
2 changes: 2 additions & 0 deletions src/WorkOS/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 );
Expand Down
38 changes: 38 additions & 0 deletions src/WorkOS/Mfa/Controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
/**
* Controller for multi-factor authentication.
*
* @package WorkOS\Mfa
*/

namespace WorkOS\Mfa;

use WorkOS\Contracts\Controller as BaseController;

/**
* Binds the MFA settings, policy and factor service.
*
* Sign-in enforcement lives in `Auth\AuthKit\LoginCompleter`, and the
* routes in `REST\Auth\Mfa`; both resolve these from the container.
*/
class Controller extends BaseController {

/**
* Register feature components.
*
* @return void
*/
protected function doRegister(): void {
$this->container->singleton( Settings::class );
$this->container->singleton( Policy::class );
$this->container->singleton( FactorService::class );
}

/**
* Unregister.
*
* @return void
*/
protected function doUnregister(): void {
}
}
Loading
Loading