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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- **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**.
- **Passkey and SSO sign-ins count as MFA** — a passkey sign-in (WorkOS requires user verification) skips the plugin's code step, as WorkOS's own MFA does, and so does an SSO sign-in unless **WorkOS → Settings → Authentication → SSO and Two-Step Verification** is turned off (option `mfa_trust_sso`, on by default). Filters `workos_mfa_trust_sso` and `workos_mfa_satisfied_by_sign_in`, action `workos_mfa_step_skipped`, Activity Log event `mfa.step_skipped`.
- **Passkey sign-in button** — a Login Profile with the Passkey method now shows **Sign in with a passkey**, which opens WorkOS's hosted sign-in page (WorkOS runs passkeys only there). The option did nothing before. The unsupported "WebAuthn / passkey" MFA factor is gone from the profile editor.
- **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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Each profile stores:
| `invite_flow` | Allow invitation acceptance |
| `password_reset_flow` | Allow in-app password reset |
| `auto_login_after_reset` | When `true` (default), `reset_confirm` re-authenticates the user with the new password (still running MFA / org-selection / entitlement gates via `LoginCompleter`), sets the WP auth cookie, and lands them on the validated `redirect_url`. When `false`, the user lands on the "Password reset — Continue to sign in" card and signs in manually. |
| `mfa` | `{enforce: never|if_required|always, factors: [totp,sms,webauthn]}` |
| `mfa` | `{enforce: never|if_required|always, factors: [totp,sms]}` — passkey and SSO sign-ins count as MFA; see [`docs/mfa.md`](docs/mfa.md#passkey-and-sso-sign-ins) |
| `branding` | `{logo_mode, logo_attachment_id, primary_color, heading, subheading}` — `logo_mode` is `default` / `custom` / `none` (see "Logo modes" in [`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md)) |
| `post_login_redirect` | URL the React shell navigates to on success (beats `redirect_to`)|
| `forward_query_args` | When `true`, appends safe inbound query args (`utm_*`, `ref`, custom params — never `redirect_to`, `_wpnonce`, `loggedout`, `wp_lang`, `workos_*`, etc.) to the post-login destination |
Expand Down
59 changes: 59 additions & 0 deletions docs/mfa.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ WorkOS users.
| 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 |
| Passkey and SSO sign-ins | Count as MFA (`mfa_trust_sso` option for SSO, filter `workos_mfa_satisfied_by_sign_in`) | Site, 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 |
Expand Down Expand Up @@ -156,6 +157,57 @@ check capabilities.
| `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 |

## Passkey and SSO sign-ins

Some sign-ins already include a second factor, so the plugin's code step is
skipped for them. WorkOS's authenticate response says how the user signed in
(`authentication_method`), and the server reads it from WorkOS itself.

| Method | Skips the code step | Why |
|---|---|---|
| `Passkey` | Always | WorkOS only accepts a passkey with user verification (fingerprint, face or PIN), so it is both factors. WorkOS's own MFA counts it the same way |
| `SSO` | When **SSO and Two-Step Verification** is on (default, option `mfa_trust_sso`, filter `workos_mfa_trust_sso`) | The company's identity provider runs its own MFA. WorkOS's own MFA never applies to SSO users either |
| Anything else (`Password`, `MagicAuth`, `GoogleOAuth`, …) | No | |

It applies in every mode, so an `enforce: always` profile accepts a passkey
sign-in too. A user who must use MFA and has no authenticator isn't asked to
enroll after a passkey sign-in. When the policy would otherwise have asked for
a code or an enrollment, the plugin logs `mfa.step_skipped` and fires
`workos_mfa_step_skipped`.

```php
// Ask SSO users from one organization for a code anyway.
add_filter( 'workos_mfa_satisfied_by_sign_in', function ( bool $satisfied, string $method, array $response ) {
if ( 'SSO' === $method && 'org_untrusted' === ( $response['organization_id'] ?? '' ) ) {
return false;
}
return $satisfied;
}, 10, 3 );
```

### Offering passkeys

WorkOS runs passkey sign-ins on its hosted page only; there is no API for the
plugin's own form. A Login Profile with the **Passkey** method shows a
**Sign in with a passkey** button that sends the visitor to the hosted page
(`GET /auth/oauth/authorize-url?provider=passkey` returns a
`provider=authkit` URL), and the callback finishes the sign-in like any
other. To set it up:

1. Turn on Passkeys in the WorkOS dashboard (**Authentication**). In
production, set up an AuthKit custom domain first: a passkey is bound to
the domain it was created on.
2. Turn on WorkOS's progressive enrollment (**Authentication → Passkeys**). It
offers password users a passkey at their next hosted sign-in, and it's how
existing users get one: on staging, the hosted sign-up page didn't offer a
passkey without it.
3. Tick **Passkey** on the Login Profile.

The hosted page offers every sign-in method turned on in WorkOS, not only
passkeys. A password sign-in there still gets the plugin's code step.
Users manage their passkeys in WorkOS; an admin can delete one from the user's
page in the WorkOS dashboard.

## Sign-in

Every AuthKit sign-in (password, magic code, OAuth, the hosted-AuthKit
Expand Down Expand Up @@ -333,6 +385,8 @@ const { enabled, required } = await res.json();
| Filter | Arguments | Default |
|---|---|---|
| `workos_mfa_mode` | `string $mode` | `mfa_mode` option, else `plugin` |
| `workos_mfa_trust_sso` | `bool $trust` | `mfa_trust_sso` option, else `true` |
| `workos_mfa_satisfied_by_sign_in` | `bool $satisfied, string $method, array $response, Profile $profile` | `true` for `Passkey`, and for `SSO` when trusted |
| `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 |
Expand All @@ -358,6 +412,7 @@ const { enabled, required } = await res.json();
| `workos_mfa_challenge_succeeded` | `int $user_id` | The plugin's sign-in challenge is answered |
| `workos_mfa_challenge_failed` | `int $user_id, string $error_code` | A wrong code at the plugin's sign-in challenge |
| `workos_mfa_blocked` | `int $user_id, Profile $profile` | A sign-in is refused by MFA policy |
| `workos_mfa_step_skipped` | `int $user_id, string $method, Profile $profile` | A passkey or trusted SSO sign-in skips a code or enrollment the policy would have asked for |

## Activity log

Expand All @@ -370,6 +425,7 @@ const { enabled, required } = await res.json();
| `mfa.required_set`, `mfa.required_cleared` | `actor_id` |
| `mfa.challenge_succeeded`, `mfa.challenge_failed` | `factor_type` |
| `mfa.blocked` | `profile` |
| `mfa.step_skipped` | `method`, `profile` |

## Security model

Expand Down Expand Up @@ -414,6 +470,9 @@ Known limits:
per-account daily cap is what keeps guessing out of reach.
- In `workos` mode WorkOS enforces MFA, which doesn't cover SSO users, and
Bearer tokens aren't gated.
- SSO sign-ins trust the identity provider's MFA by default. Turn
**SSO and Two-Step Verification** off if an identity provider doesn't
enforce it.

## Don't do this

Expand Down
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', 'mfa.enrolled', 'mfa.removed', 'mfa.reset', 'mfa.removed_in_workos', 'mfa.required_set', 'mfa.required_cleared', 'mfa.challenge_succeeded', 'mfa.challenge_failed', 'mfa.blocked' ];
$event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'mfa.enrolled', 'mfa.removed', 'mfa.reset', 'mfa.removed_in_workos', 'mfa.required_set', 'mfa.required_cleared', 'mfa.challenge_succeeded', 'mfa.challenge_failed', 'mfa.blocked', 'mfa.step_skipped' ];

?>
<div class="wrap">
Expand Down
15 changes: 14 additions & 1 deletion src/WorkOS/Admin/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,19 @@ static function (): void {
'workos_auth'
);

add_settings_field(
'workos_env_mfa_trust_sso',
__( 'SSO and Two-Step Verification', 'integration-workos' ),
[ $this, 'render_checkbox' ],
'workos',
'workos_auth',
[
'name' => $this->env_option( MfaSettings::OPTION_TRUST_SSO ),
'label' => __( 'Don’t ask for a code after an SSO sign-in: the company’s identity provider runs its own MFA. Passkey sign-ins never ask, since a passkey counts as both steps.', 'integration-workos' ),
'default' => true,
]
);

// --- Audit Logging section ---
add_settings_section(
'workos_audit',
Expand Down Expand Up @@ -2389,7 +2402,7 @@ public function sanitize_environment_options( $input ): array {
}

// Boolean fields.
$bool_keys = [ 'allow_password_fallback', 'audit_logging_enabled', 'wp_password_fallback_email_confirmation', 'allow_magic_code_registration', 'allow_legacy_magic_code_registration', 'relink_protection', 'require_linked_identity' ];
$bool_keys = [ 'allow_password_fallback', 'audit_logging_enabled', 'wp_password_fallback_email_confirmation', 'allow_magic_code_registration', 'allow_legacy_magic_code_registration', 'relink_protection', 'require_linked_identity', MfaSettings::OPTION_TRUST_SSO ];
foreach ( $bool_keys as $key ) {
if ( isset( $input[ $key ] ) ) {
$sanitized[ $key ] = rest_sanitize_boolean( $input[ $key ] );
Expand Down
51 changes: 51 additions & 0 deletions src/WorkOS/Auth/AuthKit/LoginCompleter.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ public function complete( $workos_response, Profile $profile, string $redirect_t
);
}

// A passkey, and by default an SSO sign-in, already counts as MFA.
$satisfied_by = '';
if ( ! $mfa_satisfied && is_array( $workos_response ) ) {
$satisfied_by = App::container()->get( Policy::class )->satisfied_by_sign_in( $workos_response, $profile );
$mfa_satisfied = '' !== $satisfied_by;
}

// When the profile insists MFA is always required and WorkOS did
// not return a pending factor (i.e., the user has nothing enrolled
// or WorkOS is not enforcing org-level MFA), we must refuse to
Expand Down Expand Up @@ -172,6 +179,10 @@ public function complete( $workos_response, Profile $profile, string $redirect_t
return $entitlement;
}

if ( '' !== $satisfied_by && $this->plugin_decides() ) {
$this->note_step_skipped( $wp_user, $satisfied_by, $profile );
}

if ( ! $mfa_satisfied && $this->plugin_decides() ) {
$step = $this->plugin_mfa_step( $wp_user, $workos_response, $profile, $redirect_to, $honor_profile_redirect );
if ( null !== $step ) {
Expand Down Expand Up @@ -396,6 +407,46 @@ private function plugin_mfa_step( WP_User $wp_user, array $workos_response, Prof
];
}

/**
* Record a sign-in whose method stood in for the plugin's code step.
*
* Only when the policy would otherwise have asked for a code or an
* enrollment, so ordinary SSO sign-ins don't fill the log.
*
* @param WP_User $wp_user Matching WP user.
* @param string $method WorkOS `authentication_method`.
* @param Profile $profile Active Login Profile.
*
* @return void
*/
private function note_step_skipped( WP_User $wp_user, string $method, Profile $profile ): void {
$decision = App::container()->get( Policy::class )->decide( $wp_user->ID, $profile );
if ( Policy::CHALLENGE !== $decision && Policy::ENROLL !== $decision ) {
return;
}

EventLogger::log(
'mfa.step_skipped',
[
'user_id' => $wp_user->ID,
'metadata' => [
'method' => $method,
'profile' => $profile->get_slug(),
],
]
);

/**
* Fires when a sign-in skips the plugin's code step because its
* method already counts as MFA (a passkey, or a trusted SSO).
*
* @param int $user_id WP user ID.
* @param string $method WorkOS `authentication_method`.
* @param Profile $profile Login Profile.
*/
do_action( 'workos_mfa_step_skipped', $wp_user->ID, $method, $profile );
}

/**
* Factor fields the browser needs.
*
Expand Down
45 changes: 45 additions & 0 deletions src/WorkOS/Mfa/Policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ class Policy {
*/
public const BLOCK = 'block';

/**
* WorkOS `authentication_method` for a passkey sign-in.
*/
public const SIGN_IN_PASSKEY = 'Passkey';

/**
* WorkOS `authentication_method` for an SSO sign-in.
*/
public const SIGN_IN_SSO = 'SSO';

/**
* MFA settings.
*
Expand Down Expand Up @@ -156,6 +166,41 @@ public function is_enrolled( int $user_id ): bool {
return '1' === (string) get_user_meta( $user_id, User::META_MFA_ENABLED, true );
}

/**
* The sign-in method that already counts as MFA, if this one does.
*
* - `Passkey`: WorkOS only accepts a passkey with user verification
* (fingerprint, face or PIN), so it is both factors at once. WorkOS's
* own MFA counts it the same way.
* - `SSO`: the identity provider runs its own MFA. Trusted unless the
* site turned that off (`Settings::trust_sso()`).
*
* The method comes from WorkOS's authenticate response, which the
* server fetched itself; nothing the browser sends can set it.
*
* @param array $workos_response WorkOS authenticate response.
* @param Profile $profile Login Profile.
*
* @return string The WorkOS method, or '' when the code step still applies.
*/
public function satisfied_by_sign_in( array $workos_response, Profile $profile ): string {
$method = (string) ( $workos_response['authentication_method'] ?? '' );
$satisfied = self::SIGN_IN_PASSKEY === $method
|| ( self::SIGN_IN_SSO === $method && $this->settings->trust_sso() );

/**
* Filters whether a sign-in method already counts as MFA.
*
* @param bool $satisfied Whether the plugin's code step is skipped.
* @param string $method WorkOS `authentication_method` (`Password`, `Passkey`, `SSO`, `MagicAuth`, `GoogleOAuth`, …).
* @param array $workos_response WorkOS authenticate response.
* @param Profile $profile Login Profile.
*/
$satisfied = (bool) apply_filters( 'workos_mfa_satisfied_by_sign_in', $satisfied, $method, $workos_response, $profile );

return $satisfied && '' !== $method ? $method : '';
}

/**
* The decision table, before the filter.
*
Expand Down
24 changes: 24 additions & 0 deletions src/WorkOS/Mfa/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class Settings {
*/
public const OPTION_REQUIRED_ROLES = 'mfa_required_roles';

/**
* Option key for trusting the identity provider's MFA on SSO sign-ins.
*/
public const OPTION_TRUST_SSO = 'mfa_trust_sso';

/**
* Every mode.
*
Expand Down Expand Up @@ -90,4 +95,23 @@ public function required_roles(): array {

return is_array( $roles ) ? array_values( array_filter( array_map( 'strval', $roles ) ) ) : [];
}

/**
* Whether an SSO sign-in skips the plugin's code step.
*
* On by default, like WorkOS's own MFA, which never applies to SSO
* users: their identity provider runs its own MFA.
*
* @return bool
*/
public function trust_sso(): bool {
$trust = (bool) workos()->option( self::OPTION_TRUST_SSO, true );

/**
* Filters whether an SSO sign-in skips the plugin's code step.
*
* @param bool $trust Whether to trust the identity provider's MFA.
*/
return (bool) apply_filters( 'workos_mfa_trust_sso', $trust );
}
}
2 changes: 2 additions & 0 deletions src/WorkOS/Options/Production.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ protected function defaults(): array {
'relink_protection' => false,
'require_linked_identity' => false,
'identity_gate_profile' => 'default',
// Two-step verification (see src/WorkOS/Mfa).
'mfa_trust_sso' => true,
];
}
}
2 changes: 2 additions & 0 deletions src/WorkOS/Options/Staging.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ protected function defaults(): array {
'relink_protection' => false,
'require_linked_identity' => false,
'identity_gate_profile' => 'default',
// Two-step verification (see src/WorkOS/Mfa).
'mfa_trust_sso' => true,
];
}
}
Loading
Loading