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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [Unreleased]

### 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.

### Fixed

- **MFA challenges from WorkOS never reached the sign-in form** — WorkOS reports a required second factor as an `mfa_challenge` error, and the sign-in flows only looked for it in a successful response, so users saw WorkOS's error message instead of the code prompt. The challenge is now read from the error, and the profile's factor allowlist applies to it. `mfa_enrollment` returns `403 workos_authkit_mfa_enrollment_required` until the sign-in form can enroll a factor.
- **MFA challenge, verify and factor delete called endpoints WorkOS doesn't have** — they went to `/user_management/auth_factors/…` and `/user_management/auth_challenges/…`. Factors enrolled through User Management are MFA API factors, so the calls now go to `/auth/factors/{id}/challenge`, `/auth/challenges/{id}/verify` and `/auth/factors/{id}`. `POST /auth/mfa/totp/enroll` also read the factor from the wrong level of WorkOS's response, so the QR code and secret came back empty. It now reads `authentication_factor` and also returns the `challenge_id` WorkOS creates with it. And a request with an empty body (a challenge with no SMS template) went out as `[]`, which WorkOS rejects with 422 "Validation failed"; empty bodies now go out as `{}`.
- **`enforce = always` refused every MFA sign-in** — a login profile set to always require MFA rejected the response from a correct code, because that response carries no pending token. `POST /auth/mfa/verify` now marks its result as having passed MFA.

## [1.0.10] - 2026-10-14

### Security
Expand Down
33 changes: 27 additions & 6 deletions src/WorkOS/Api/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ public function list_auth_factors( string $user_id ) {
* @param string $totp_issuer Human-readable issuer displayed by the authenticator (e.g. site name).
* @param string $totp_user Human-readable account label (usually the user's email).
*
* @return array|\WP_Error Factor data including `totp.qr_code` and `totp.secret`.
* @return array|\WP_Error `authentication_factor` (with `totp.qr_code`, `totp.secret`, `totp.uri`) and `authentication_challenge`.
*/
public function enroll_totp_factor( string $user_id, string $totp_issuer, string $totp_user ) {
return $this->post(
Expand Down Expand Up @@ -574,12 +574,15 @@ public function enroll_sms_factor( string $user_id, string $phone_number ) {
/**
* Delete (un-enroll) an authentication factor.
*
* Factors enrolled through User Management are MFA API factors, so
* delete, challenge and verify go to `/auth/...`, not `/user_management/...`.
*
* @param string $factor_id Factor ID.
*
* @return array|\WP_Error
*/
public function delete_auth_factor( string $factor_id ) {
return $this->delete( "/user_management/auth_factors/{$factor_id}" );
return $this->delete( "/auth/factors/{$factor_id}" );
}

/**
Expand All @@ -599,12 +602,15 @@ public function challenge_auth_factor( string $factor_id, ?string $sms_template
$body['sms_template'] = $sms_template;
}

return $this->post( "/user_management/auth_factors/{$factor_id}/challenge", $body );
return $this->post( "/auth/factors/{$factor_id}/challenge", $body );
}

/**
* Verify an authentication challenge with a user-provided code.
*
* Answers `{ challenge, valid }`: check `valid`, since a wrong code
* is not guaranteed to come back as an error.
*
* @param string $challenge_id Challenge ID.
* @param string $code Code entered by the user.
* @param string|null $radar_action_token Optional Radar action token.
Expand All @@ -617,7 +623,7 @@ public function verify_auth_challenge(
?string $radar_action_token = null
) {
return $this->post(
"/user_management/auth_challenges/{$challenge_id}/verify",
"/auth/challenges/{$challenge_id}/verify",
[ 'code' => $code ],
$this->radar_headers( $radar_action_token )
);
Expand Down Expand Up @@ -1034,6 +1040,21 @@ private function get( string $path, array $params = [], array $extra_headers = [
return $this->parse_response( $response );
}

/**
* Encode a request body as a JSON object.
*
* `wp_json_encode( [] )` is `[]`, a JSON array, and WorkOS answers 422
* "Validation failed" to it (e.g. a challenge with no SMS template).
* An empty body goes out as `{}`.
*
* @param array $data Request body.
*
* @return string
*/
private static function json_body( array $data ): string {
return (string) wp_json_encode( empty( $data ) ? new \stdClass() : $data );
}

/**
* Send a POST request.
*
Expand All @@ -1048,7 +1069,7 @@ private function post( string $path, array $data = [], array $extra_headers = []
self::BASE_URL . $path,
[
'headers' => $this->headers( true, $extra_headers ),
'body' => wp_json_encode( $data ),
'body' => self::json_body( $data ),
'timeout' => 15,
]
);
Expand All @@ -1071,7 +1092,7 @@ private function put( string $path, array $data = [], array $extra_headers = []
[
'method' => 'PUT',
'headers' => $this->headers( true, $extra_headers ),
'body' => wp_json_encode( $data ),
'body' => self::json_body( $data ),
'timeout' => 15,
]
);
Expand Down
58 changes: 55 additions & 3 deletions src/WorkOS/Auth/AuthKit/LoginCompleter.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public function __construct() {
* @param Profile $profile Active Login Profile.
* @param string $redirect_to Client-requested redirect (validated).
* @param bool $honor_profile_redirect Whether the profile's `post_login_redirect` should override `$redirect_to`. AuthKit-REST callers leave this `true`; the legacy hosted-AuthKit callback passes `false` so the state-supplied URL still wins for that flow.
* @param bool $mfa_satisfied Whether this response came from a completed second factor. `POST /auth/mfa/verify` passes `true` so an `enforce = always` profile accepts the login it just challenged.
*
* @return array|WP_Error
* On success: {
Expand All @@ -77,13 +78,23 @@ public function __construct() {
* 'email' => string,
* }
*/
public function complete( $workos_response, Profile $profile, string $redirect_to = '', bool $honor_profile_redirect = true ) {
public function complete( $workos_response, Profile $profile, string $redirect_to = '', bool $honor_profile_redirect = true, bool $mfa_satisfied = false ) {
// WorkOS returns `organization_selection_required` when the
// authenticate call resolves to a user that belongs to multiple
// orgs without an org context. If this profile (or the global
// setting) has an org pinned, complete transparently via the
// organization-selection grant. With no pinned org we bail with a
// clear error — there is no in-shell org picker by design.
if ( is_wp_error( $workos_response ) ) {
$mfa = $this->mfa_step( $workos_response );
if ( is_wp_error( $mfa ) ) {
return $mfa;
}
if ( null !== $mfa ) {
$workos_response = $mfa;
}
}

if ( is_wp_error( $workos_response ) ) {
$verification = $this->email_verification_step( $workos_response );
if ( null !== $verification ) {
Expand Down Expand Up @@ -133,8 +144,10 @@ public function complete( $workos_response, Profile $profile, string $redirect_t
// not return a pending factor (i.e., the user has nothing enrolled
// or WorkOS is not enforcing org-level MFA), we must refuse to
// complete the login. Silently letting the user through would
// quietly break the admin's "MFA required" guarantee.
if ( Profile::MFA_ENFORCE_ALWAYS === ( $profile->get_mfa()['enforce'] ?? '' ) ) {
// quietly break the admin's "MFA required" guarantee. A response
// from a verified second factor has no pending token by design,
// so it is the one single-step success that may pass.
if ( ! $mfa_satisfied && Profile::MFA_ENFORCE_ALWAYS === ( $profile->get_mfa()['enforce'] ?? '' ) ) {
return new WP_Error(
'workos_authkit_mfa_required',
__( 'This login requires multi-factor authentication, but no factor is enrolled on your account. Please enroll a factor and try again.', 'integration-workos' ),
Expand Down Expand Up @@ -242,6 +255,45 @@ private function extract_factors( array $workos_response ): array {
return [];
}

/**
* The MFA step, when WorkOS asks for one.
*
* `mfa_challenge` becomes the same pending-token shape a challenge has
* always used here, so the factor allowlist below applies to it.
* `mfa_enrollment` is refused for now: the shell has no enrollment
* step yet, and handing back a token it can't use would strand the
* user on a challenge with no factor.
*
* @param WP_Error $error Error from the WorkOS authenticate call.
*
* @return array|WP_Error|null Pending-token response, a refusal, or null for any other error.
*/
private function mfa_step( WP_Error $error ) {
$code = MfaStep::code( $error );
if ( '' === $code ) {
return null;
}

$body = MfaStep::body( $error );
$pending_token = (string) ( $body['pending_authentication_token'] ?? '' );
if ( '' === $pending_token ) {
return null;
}

if ( MfaStep::ENROLLMENT === $code ) {
return new WP_Error(
'workos_authkit_mfa_enrollment_required',
__( 'This account must set up multi-factor authentication before it can sign in. Contact the site administrator for help.', 'integration-workos' ),
[ 'status' => 403 ]
);
}

return [
'pending_authentication_token' => $pending_token,
'authentication_factors' => isset( $body['authentication_factors'] ) && is_array( $body['authentication_factors'] ) ? $body['authentication_factors'] : [],
];
}

/**
* The email-verification step, when WorkOS asks for one.
*
Expand Down
101 changes: 101 additions & 0 deletions src/WorkOS/Auth/AuthKit/MfaStep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php
/**
* Reads the MFA step out of a WorkOS authenticate error.
*
* @package WorkOS\Auth\AuthKit
*/

namespace WorkOS\Auth\AuthKit;

use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
* WorkOS reports a required second factor as an error, not a success.
*
* When the environment requires MFA, `authenticate` answers with a 4xx
* whose body carries `code`:
*
* - `mfa_challenge` — the user has a factor; the body lists it under
* `authentication_factors` next to a `pending_authentication_token`.
* - `mfa_enrollment` — the user has no factor yet and must enroll one
* before the pending token can be completed.
*
* `Api\Client::parse_response()` wraps every 4xx as `workos_api_error`
* with the decoded body under `data.body`, so callers use this class
* instead of digging through the error data themselves.
*/
class MfaStep {

/**
* WorkOS error code: the user must answer a challenge on an enrolled factor.
*/
public const CHALLENGE = 'mfa_challenge';

/**
* WorkOS error code: the user must enroll a factor first.
*/
public const ENROLLMENT = 'mfa_enrollment';

/**
* The WorkOS MFA code carried by a response, if any.
*
* @param mixed $response WorkOS authenticate response or WP_Error.
*
* @return string `mfa_challenge`, `mfa_enrollment`, or '' for anything else.
*/
public static function code( $response ): string {
if ( ! $response instanceof WP_Error ) {
return '';
}

$code = (string) ( self::body( $response )['code'] ?? '' );

return in_array( $code, [ self::CHALLENGE, self::ENROLLMENT ], true ) ? $code : '';
}

/**
* Whether a response is a WorkOS MFA step.
*
* Paths that would otherwise retry with the WordPress password must
* check this first: the WorkOS password was correct, and only the
* second factor is missing.
*
* @param mixed $response WorkOS authenticate response or WP_Error.
*
* @return bool
*/
public static function is_mfa_error( $response ): bool {
return '' !== self::code( $response );
}

/**
* Decoded WorkOS error body.
*
* @param WP_Error $error Error from the API client.
*
* @return array
*/
public static function body( WP_Error $error ): array {
$data = $error->get_error_data();

return is_array( $data ) && isset( $data['body'] ) && is_array( $data['body'] ) ? $data['body'] : [];
}

/**
* Error for sign-in paths that have no way to collect a second factor.
*
* Used by the wp-login.php headless filter and the login-button AJAX
* handler, which take an email and password only.
*
* @return WP_Error
*/
public static function unsupported_error(): WP_Error {
return new WP_Error(
'workos_mfa_step_unsupported',
__( 'This account uses multi-factor authentication, which this sign-in form can’t complete. Contact the site administrator for help signing in.', 'integration-workos' ),
[ 'status' => 403 ]
);
}
}
46 changes: 46 additions & 0 deletions src/WorkOS/Auth/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
namespace WorkOS\Auth;

use WorkOS\Auth\AuthKit\LoginCompleter;
use WorkOS\Auth\AuthKit\MfaStep;
use WorkOS\Auth\AuthKit\Profile;
use WorkOS\Auth\AuthKit\ProfileRepository;
use WorkOS\Vendor\StellarWP\SuperGlobals\SuperGlobals;
use WP_Error;

defined( 'ABSPATH' ) || exit;

Expand All @@ -19,6 +21,13 @@
*/
class Login {

/**
* Refusal the headless filter raised for the current `authenticate` run.
*
* @var WP_Error|null
*/
private ?WP_Error $refusal = null;

/**
* Constructor — registers hooks.
*/
Expand All @@ -29,6 +38,9 @@ public function __construct() {
// Headless mode: authenticate filter.
add_filter( 'authenticate', [ $this, 'authenticate_headless' ], 10, 3 );

// Re-assert a headless refusal after WP's own password handlers.
add_filter( 'authenticate', [ $this, 'enforce_refusal' ], 99 );

// Register callback rewrite.
add_action( 'init', [ self::class, 'register_rewrite' ] );
add_action( 'template_redirect', [ $this, 'handle_callback' ] );
Expand Down Expand Up @@ -145,6 +157,15 @@ public function authenticate_headless( $user, $username, $password ) {

$result = workos()->api()->authenticate_with_password( $email, $password );

// WorkOS accepted the password but wants a second factor this form
// can't collect. Refuse, and keep refusing past WP's own password
// check at priority 20: it ignores an earlier WP_Error, so letting
// it run would sign the user in without MFA.
if ( MfaStep::is_mfa_error( $result ) ) {
$this->refusal = MfaStep::unsupported_error();
return $this->refusal;
}

if ( is_wp_error( $result ) ) {
// Don't block fallback — just return $user to let WP try next.
return $user;
Expand Down Expand Up @@ -174,6 +195,31 @@ public function authenticate_headless( $user, $username, $password ) {
return $wp_user;
}

/**
* Re-apply a refusal raised by `authenticate_headless()`.
*
* Core's `wp_authenticate_username_password()` and
* `wp_authenticate_email_password()` only short-circuit on a WP_User,
* so a WP_Error returned at priority 10 doesn't stop them from
* checking the WordPress password. Running last puts the refusal back.
* The refusal is cleared once used, so it never leaks into a later
* `authenticate` run in the same request.
*
* @param \WP_User|\WP_Error|null $user Result so far.
*
* @return \WP_User|\WP_Error|null
*/
public function enforce_refusal( $user ) {
if ( null === $this->refusal ) {
return $user;
}

$refusal = $this->refusal;
$this->refusal = null;

return $refusal;
}

/**
* Register the /workos/callback rewrite rule.
*/
Expand Down
Loading
Loading