Commit 7ea8ce435e3 for woocommerce

commit 7ea8ce435e38cbf7b89cc35c5cdb0ace2d3f3385
Author: malinajirka <malinajirka@gmail.com>
Date:   Fri Sep 11 10:05:45 2026 +0200

    Update mobile app QR login session handling (#68540)

    * Update mobile app QR login session handling

    * Add tests for mobile app QR login session handling

diff --git a/plugins/woocommerce/changelog/fix-woo6-146-qr-login-session-handling b/plugins/woocommerce/changelog/fix-woo6-146-qr-login-session-handling
new file mode 100644
index 00000000000..accb03ea126
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-woo6-146-qr-login-session-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update mobile app QR login session handling.
diff --git a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
index 3eeee5bbf19..d037e5c6137 100644
--- a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
+++ b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
@@ -1842,6 +1842,11 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
 			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
 		}

+		$canonical_session = isset( $record['challenge']['session_id'] ) ? (string) $record['challenge']['session_id'] : '';
+		if ( '' === $canonical_session || ! hash_equals( $canonical_session, $session_id ) ) {
+			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
+		}
+
 		$state    = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
 		$response = array( 'state' => $state );

diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
index 4a8a0e8febf..c9eec80d728 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
@@ -2392,6 +2392,50 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
 		$this->assertNotEmpty( $post->get_data()['exchange_grant'] );
 	}

+	/**
+	 * @testdox Session-status is scoped to the session recorded on the token.
+	 */
+	public function test_session_status_is_scoped_to_the_recorded_session(): void {
+		$plaintext = $this->generate_token_as_admin();
+
+		wp_set_current_user( 0 );
+		$scan_data        = $this->dispatch_scan( $plaintext )->get_data();
+		$recorded_session = $scan_data['session_id'];
+
+		// A second session id can also resolve to the same token record, while
+		// the record itself still names the scanned session above.
+		$other_session = wp_generate_uuid4();
+		set_transient(
+			MobileAppQRLogin::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $other_session ),
+			$this->token_hash( $plaintext ),
+			MobileAppQRLogin::TOKEN_TTL
+		);
+
+		wp_set_current_user( $this->admin_id );
+		$this->assertSame( 200, $this->dispatch_approve( $plaintext, $scan_data['real_number'] )->get_status() );
+		wp_set_current_user( 0 );
+
+		$other = $this->dispatch_session_status( $other_session, $plaintext );
+		$this->assertSame( 200, $other->get_status() );
+		$this->assertSame(
+			MobileAppQRLogin::STATE_EXPIRED,
+			$other->get_data()['state'],
+			'State is only returned for the session recorded on the token.'
+		);
+		$this->assertArrayNotHasKey(
+			'exchange_grant',
+			$other->get_data(),
+			'The exchange grant is only returned for the session recorded on the token.'
+		);
+
+		$recorded = $this->dispatch_session_status( $recorded_session, $plaintext );
+		$this->assertSame( MobileAppQRLogin::STATE_APPROVED, $recorded->get_data()['state'] );
+		$this->assertNotEmpty(
+			$recorded->get_data()['exchange_grant'],
+			'The recorded session receives its grant.'
+		);
+	}
+
 	// -----------------------------------------------------------------------
 	// Availability endpoint (`/qr-login-availability`).
 	// -----------------------------------------------------------------------