From 9b11ca57c0c537b5aa66468115086d2d0de67894 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 24 Feb 2026 12:45:25 -0600 Subject: [PATCH 1/5] SSO: Bypass local Two-Factor prompt when WP.com 2FA was completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-Factor plugin 0.15.0+ unconditionally hooks wp_login at PHP_INT_MAX, which destroys the auth session and shows a local 2FA prompt — even for SSO logins that already completed 2FA on WordPress.com. When WP.com confirms two_step_enabled, remove Two-Factor's wp_login hook so SSO can complete without a redundant prompt. When WP.com 2FA is NOT active, the hook stays so Two-Factor can enforce local 2FA normally. See CONNECT-178 --- .../changelog/fix-sso-two-factor-bypass | 4 ++++ .../packages/connection/src/sso/class-sso.php | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 projects/packages/connection/changelog/fix-sso-two-factor-bypass diff --git a/projects/packages/connection/changelog/fix-sso-two-factor-bypass b/projects/packages/connection/changelog/fix-sso-two-factor-bypass new file mode 100644 index 000000000000..8fc84ec80ae6 --- /dev/null +++ b/projects/packages/connection/changelog/fix-sso-two-factor-bypass @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fixed SSO login conflict with Two-Factor plugin 0.15.0+ that caused a redundant local 2FA prompt after completing WordPress.com 2FA. diff --git a/projects/packages/connection/src/sso/class-sso.php b/projects/packages/connection/src/sso/class-sso.php index a3bfc4c286b2..504c626d6258 100644 --- a/projects/packages/connection/src/sso/class-sso.php +++ b/projects/packages/connection/src/sso/class-sso.php @@ -918,6 +918,23 @@ public function handle_login() { // Cache the user's details, so we can present it back to them on their user screen. update_user_meta( $user->ID, 'wpcom_user_data', $user_data ); + /* + * Two-Factor plugin 0.15.0+ unconditionally hooks wp_login at PHP_INT_MAX, + * which destroys the auth session and prompts for local 2FA — even for SSO + * logins that already completed 2FA on WordPress.com. + * + * When WP.com confirms the user has 2FA active, remove Two-Factor's wp_login + * hook so SSO can complete without a redundant local 2FA prompt. + * + * When WP.com 2FA is NOT active, the hook stays and Two-Factor can enforce + * local 2FA as a safety net. + * + * @see https://github.com/WordPress/two-factor/issues/811 + */ + if ( ! empty( $user_data->two_step_enabled ) && class_exists( 'Two_Factor_Core' ) ) { + remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX ); + } + add_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) ); wp_set_auth_cookie( $user->ID, true ); remove_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) ); From 6c76775dc0610dd87f51dba973ddf73b35a416a6 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 6 Mar 2026 01:04:53 +0100 Subject: [PATCH 2/5] SSO: Add filter and two-factor-login session metadata Add jetpack_sso_accept_wpcom_2fa filter so site admins can force the local Two-Factor prompt even when WP.com 2FA was completed. Set two-factor-login session metadata via attach_session_information so is_current_user_session_two_factor() returns true, preventing users from being re-prompted for 2FA when managing their Two-Factor settings. See CONNECT-178 --- .../packages/connection/src/sso/class-sso.php | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/projects/packages/connection/src/sso/class-sso.php b/projects/packages/connection/src/sso/class-sso.php index 504c626d6258..4f440ba5d532 100644 --- a/projects/packages/connection/src/sso/class-sso.php +++ b/projects/packages/connection/src/sso/class-sso.php @@ -931,7 +931,37 @@ public function handle_login() { * * @see https://github.com/WordPress/two-factor/issues/811 */ - if ( ! empty( $user_data->two_step_enabled ) && class_exists( 'Two_Factor_Core' ) ) { + if ( + ! empty( $user_data->two_step_enabled ) + && class_exists( 'Two_Factor_Core' ) + /** + * Filter whether to accept WordPress.com 2FA in place of a local + * Two-Factor prompt during SSO login. + * + * Return false to always require the local Two-Factor prompt, + * even when the user has completed 2FA on WordPress.com. + * + * @since $$next-version$$ + * @module sso + * + * @param bool $accept Whether to accept WP.com 2FA. Default true. + * @param object $user_data WordPress.com user data from SSO validation. + * @param WP_User $user The local WordPress user. + */ + && apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user ) + ) { + add_filter( + 'attach_session_information', + function ( $session, $user_id ) use ( $user ) { + if ( $user->ID === $user_id ) { + $session['two-factor-login'] = time(); + } + return $session; + }, + 10, + 2 + ); + remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX ); } From 465b7475352d529e1cbac618a78cd7f77b2c893d Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 16 Mar 2026 10:40:36 -0500 Subject: [PATCH 3/5] Fix Phan error: move filter docblock out of if-condition The @param annotations for the jetpack_sso_accept_wpcom_2fa filter were being parsed by Phan as belonging to the anonymous closure below, causing PhanCommentParamWithoutRealParam errors. Extract the apply_filters call into a variable so the docblock associates correctly. --- .../packages/connection/src/sso/class-sso.php | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/projects/packages/connection/src/sso/class-sso.php b/projects/packages/connection/src/sso/class-sso.php index 4f440ba5d532..311398ee1cbe 100644 --- a/projects/packages/connection/src/sso/class-sso.php +++ b/projects/packages/connection/src/sso/class-sso.php @@ -931,24 +931,26 @@ public function handle_login() { * * @see https://github.com/WordPress/two-factor/issues/811 */ + /** + * Filter whether to accept WordPress.com 2FA in place of a local + * Two-Factor prompt during SSO login. + * + * Return false to always require the local Two-Factor prompt, + * even when the user has completed 2FA on WordPress.com. + * + * @since $$next-version$$ + * @module sso + * + * @param bool $accept Whether to accept WP.com 2FA. Default true. + * @param object $user_data WordPress.com user data from SSO validation. + * @param WP_User $user The local WordPress user. + */ + $accept_wpcom_2fa = apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user ); + if ( ! empty( $user_data->two_step_enabled ) && class_exists( 'Two_Factor_Core' ) - /** - * Filter whether to accept WordPress.com 2FA in place of a local - * Two-Factor prompt during SSO login. - * - * Return false to always require the local Two-Factor prompt, - * even when the user has completed 2FA on WordPress.com. - * - * @since $$next-version$$ - * @module sso - * - * @param bool $accept Whether to accept WP.com 2FA. Default true. - * @param object $user_data WordPress.com user data from SSO validation. - * @param WP_User $user The local WordPress user. - */ - && apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user ) + && $accept_wpcom_2fa ) { add_filter( 'attach_session_information', From f5445dce72cd7a3dda6da7bd244024a9d7ad881b Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 16 Mar 2026 12:15:56 -0500 Subject: [PATCH 4/5] Replace closure with named method to fix Phan docblock association Phan attaches /** docblocks to the nearest closure regardless of intervening statements, so the filter docblock for jetpack_sso_accept_wpcom_2fa was being associated with the attach_session_information closure, causing PhanCommentParamWithoutRealParam errors. Replace the closure with a static method (add_two_factor_session_meta) and a static property to pass the user context, eliminating the docblock/closure proximity issue. --- .../packages/connection/src/sso/class-sso.php | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/projects/packages/connection/src/sso/class-sso.php b/projects/packages/connection/src/sso/class-sso.php index 311398ee1cbe..3bcb5a8eca62 100644 --- a/projects/packages/connection/src/sso/class-sso.php +++ b/projects/packages/connection/src/sso/class-sso.php @@ -40,6 +40,14 @@ class SSO { */ public static $instance = null; + /** + * Stores the WP_User being authenticated via SSO so the + * attach_session_information callback can tag the session. + * + * @var WP_User|null + */ + private static $sso_user_for_2fa = null; + /** * Automattic\Jetpack\Connection\SSO constructor. */ @@ -952,17 +960,8 @@ public function handle_login() { && class_exists( 'Two_Factor_Core' ) && $accept_wpcom_2fa ) { - add_filter( - 'attach_session_information', - function ( $session, $user_id ) use ( $user ) { - if ( $user->ID === $user_id ) { - $session['two-factor-login'] = time(); - } - return $session; - }, - 10, - 2 - ); + self::$sso_user_for_2fa = $user; + add_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10, 2 ); remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX ); } @@ -1322,4 +1321,19 @@ public function is_user_connected( $user_id ) { public function get_user_data( $user_id ) { return get_user_meta( $user_id, 'wpcom_user_data', true ); } + + /** + * Marks a session as two-factor-authenticated when SSO handled 2FA via WP.com. + * + * @param array $session Session information array. + * @param int $user_id User ID for the session being created. + * @return array Modified session information. + */ + public static function add_two_factor_session_meta( $session, $user_id ) { + if ( self::$sso_user_for_2fa && self::$sso_user_for_2fa->ID === $user_id ) { + $session['two-factor-login'] = time(); + self::$sso_user_for_2fa = null; + } + return $session; + } } From f214023c22815d6af97956911a0edb22890e1b23 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 16 Mar 2026 18:34:13 -0500 Subject: [PATCH 5/5] Add unit tests for SSO two-factor session meta Test that add_two_factor_session_meta correctly tags sessions when the SSO user matches, skips non-matching users, no-ops when no user is stored, and clears the stored user after use. --- .../connection/tests/php/sso/SSO_Test.php | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 projects/packages/connection/tests/php/sso/SSO_Test.php diff --git a/projects/packages/connection/tests/php/sso/SSO_Test.php b/projects/packages/connection/tests/php/sso/SSO_Test.php new file mode 100644 index 000000000000..dc07aaab6fe2 --- /dev/null +++ b/projects/packages/connection/tests/php/sso/SSO_Test.php @@ -0,0 +1,115 @@ +setAccessible( true ); + $ref->setValue( null, null ); + + parent::tear_down(); + } + + /** + * Helper to create a WP user and return the WP_User object. + * + * @return \WP_User + */ + private function create_test_user() { + $user_id = wp_insert_user( + array( + 'user_login' => 'sso_test_' . wp_generate_password( 6, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'sso_test_' . wp_generate_password( 6, false ) . '@example.com', + ) + ); + return get_userdata( $user_id ); + } + + /** + * Helper to set the private static $sso_user_for_2fa property. + * + * @param \WP_User|null $user User to set. + */ + private function set_sso_user_for_2fa( $user ) { + $ref = new \ReflectionProperty( SSO::class, 'sso_user_for_2fa' ); + $ref->setAccessible( true ); + $ref->setValue( null, $user ); + } + + /** + * Helper to get the private static $sso_user_for_2fa property. + * + * @return \WP_User|null + */ + private function get_sso_user_for_2fa() { + $ref = new \ReflectionProperty( SSO::class, 'sso_user_for_2fa' ); + $ref->setAccessible( true ); + return $ref->getValue(); + } + + /** + * Test that session is tagged with two-factor-login when user ID matches. + */ + public function test_add_two_factor_session_meta_tags_session_for_matching_user() { + $user = $this->create_test_user(); + $session = array( 'expiration' => time() + 3600 ); + + $this->set_sso_user_for_2fa( $user ); + $result = SSO::add_two_factor_session_meta( $session, $user->ID ); + + $this->assertArrayHasKey( 'two-factor-login', $result ); + $this->assertIsInt( $result['two-factor-login'] ); + } + + /** + * Test that the stored user is cleared after tagging (one-shot). + */ + public function test_add_two_factor_session_meta_clears_stored_user() { + $user = $this->create_test_user(); + $session = array(); + + $this->set_sso_user_for_2fa( $user ); + SSO::add_two_factor_session_meta( $session, $user->ID ); + + $this->assertNull( $this->get_sso_user_for_2fa() ); + } + + /** + * Test that session is unchanged when user ID does not match. + */ + public function test_add_two_factor_session_meta_skips_non_matching_user() { + $user = $this->create_test_user(); + $session = array( 'expiration' => time() + 3600 ); + + $this->set_sso_user_for_2fa( $user ); + $result = SSO::add_two_factor_session_meta( $session, $user->ID + 999 ); + + $this->assertArrayNotHasKey( 'two-factor-login', $result ); + $this->assertNotNull( $this->get_sso_user_for_2fa() ); + } + + /** + * Test that session is unchanged when no SSO user is stored. + */ + public function test_add_two_factor_session_meta_noop_when_no_user_stored() { + $session = array( 'expiration' => time() + 3600 ); + $result = SSO::add_two_factor_session_meta( $session, 1 ); + + $this->assertArrayNotHasKey( 'two-factor-login', $result ); + $this->assertEquals( $session, $result ); + } +}