@@ -67,7 +67,7 @@ public function get( $option_name ) {
6767 return null ;
6868 }
6969 $ tokens = $ this ->get_user_tokens ( $ email , $ secret );
70- return $ tokens ? $ tokens : null ;
70+ return ( is_array ( $ tokens ) && ! empty ( $ tokens ) ) ? $ tokens : null ;
7171 }
7272
7373 return null ;
@@ -107,65 +107,107 @@ public function get_master_user_id( $email ) {
107107 }
108108
109109 /**
110- * Validates user tokens and removes conflicting tokens .
110+ * Remove conflicting tokens for a given normalized token and user .
111111 *
112- * Removes any tokens that :
113- * 1. Belong to the current user but don't match the external storage token
114- * 2. Have the same secret as external storage but belong to a different user (orphaned tokens)
112+ * Conflicts are :
113+ * - Current user has a different token string than normalized token
114+ * - Any other user has a token sharing the same secret prefix
115115 *
116116 * @since $$next-version$$
117117 *
118- * @param string $normalized_token The normalized token from external storage (token_key.secret.user_id).
119- * @param array $existing_tokens The existing tokens from the database.
120- * @param int $user_id The user ID to validate tokens for.
121- * @return array The tokens array with conflicting tokens removed.
118+ * @param array $tokens Tokens array keyed by user ID.
119+ * @param string $normalized_token Normalized token (token_key.secret.user_id).
120+ * @param int $user_id Local user ID for whom the token applies.
121+ * @return array { Updated tokens and whether any conflicts were removed }
122+ * @phpstan-return array{ tokens: array, had_conflicts: bool }
122123 */
123- private function validate_user_tokens ( $ normalized_token , $ existing_tokens , $ user_id ) {
124- $ has_conflicts = false ;
124+ private function remove_conflicting_tokens ( $ tokens , $ normalized_token , $ user_id ) {
125+ $ had_conflicts = false ;
125126 $ last_dot_pos = strrpos ( $ normalized_token , '. ' );
126127
127- // Validate token format - it must contain a dot to separate secret from user_id
128+ // Validate token format - must contain a dot to separate secret from user_id.
128129 if ( false === $ last_dot_pos ) {
129130 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
130- error_log ( "Invalid token format in validate_user_tokens: ' {$ normalized_token }' " );
131- return $ existing_tokens ;
131+ error_log ( "Invalid token format in remove_conflicting_tokens: ' {$ normalized_token }' " );
132+ return array (
133+ 'tokens ' => $ tokens ,
134+ 'had_conflicts ' => false ,
135+ );
132136 }
133137
134138 $ secret_prefix = substr ( $ normalized_token , 0 , $ last_dot_pos );
135139
136- // Check if current user has a mismatched token
137- if ( isset ( $ existing_tokens [ $ user_id ] )
138- && is_string ( $ existing_tokens [ $ user_id ] )
139- && ! hash_equals ( $ normalized_token , $ existing_tokens [ $ user_id ] ) ) {
140+ // Remove mismatched token for the current user.
141+ if ( isset ( $ tokens [ $ user_id ] )
142+ && is_string ( $ tokens [ $ user_id ] )
143+ && ! hash_equals ( $ normalized_token , $ tokens [ $ user_id ] ) ) {
140144 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
141145 error_log ( "Removing conflicting token for user {$ user_id }" );
142- unset( $ existing_tokens [ $ user_id ] );
143- $ has_conflicts = true ;
146+ unset( $ tokens [ $ user_id ] );
147+ $ had_conflicts = true ;
144148 }
145149
146- // Check if any other user has a token with the same secret (orphaned token from previous owner)
147- foreach ( $ existing_tokens as $ token_user_id => $ token ) {
148- if ( $ token_user_id !== $ user_id && strpos ( $ token , $ secret_prefix . '. ' ) === 0 ) {
150+ // Remove orphaned tokens ( same secret, different user).
151+ foreach ( $ tokens as $ token_user_id => $ token ) {
152+ if ( is_string ( $ token ) && ( int ) $ token_user_id !== $ user_id && strpos ( $ token , $ secret_prefix . '. ' ) === 0 ) {
149153 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
150154 error_log ( "Removing orphaned token with same secret for user {$ token_user_id }" );
151- unset( $ existing_tokens [ $ token_user_id ] );
152- $ has_conflicts = true ;
155+ unset( $ tokens [ $ token_user_id ] );
156+ $ had_conflicts = true ;
153157 }
154158 }
155159
160+ return array (
161+ 'tokens ' => $ tokens ,
162+ 'had_conflicts ' => $ had_conflicts ,
163+ );
164+ }
165+
166+ /**
167+ * Validates user tokens and removes conflicting tokens.
168+ *
169+ * Removes any tokens that:
170+ * 1. Belong to the current user but don't match the external storage token
171+ * 2. Have the same secret as external storage but belong to a different user (orphaned tokens)
172+ *
173+ * Re-reads the latest state before persisting to minimize race condition window.
174+ *
175+ * @since $$next-version$$
176+ *
177+ * @param string $normalized_token The normalized token from external storage (token_key.secret.user_id).
178+ * @param array $existing_tokens The existing tokens from the database.
179+ * @param int $user_id The user ID to validate tokens for.
180+ * @return array The tokens array with conflicting tokens removed.
181+ */
182+ private function validate_user_tokens ( $ normalized_token , $ existing_tokens , $ user_id ) {
183+ $ result = $ this ->remove_conflicting_tokens ( $ existing_tokens , $ normalized_token , $ user_id );
184+ $ has_conflicts = $ result ['had_conflicts ' ];
185+
156186 // Only persist changes if conflicts were found
157187 if ( $ has_conflicts ) {
158- // Persist the change to the database to prevent repeated error logging
159- $ private_options = \Jetpack_Options::get_raw_option ( 'jetpack_private_options ' , array () );
160- $ private_options ['user_tokens ' ] = $ existing_tokens ;
161- update_option ( 'jetpack_private_options ' , $ private_options );
188+ // Re-read latest state right before writing to minimize race window
189+ $ latest_options = \Jetpack_Options::get_raw_option ( 'jetpack_private_options ' , array () );
190+ $ latest_tokens = isset ( $ latest_options ['user_tokens ' ] ) && is_array ( $ latest_options ['user_tokens ' ] )
191+ ? $ latest_options ['user_tokens ' ]
192+ : array ();
193+
194+ // Re-apply cleanup to latest tokens (might find no conflicts now if state changed)
195+ $ latest_result = $ this ->remove_conflicting_tokens ( $ latest_tokens , $ normalized_token , $ user_id );
196+
197+ // Write the cleaned latest state
198+ $ latest_options ['user_tokens ' ] = $ latest_result ['tokens ' ];
199+ \Jetpack_Options::update_raw_option ( 'jetpack_private_options ' , $ latest_options , false );
162200
163201 // Also clear master_user from database since connection owner data has changed
164202 // External storage will provide the correct value on next read
165203 \Jetpack_Options::delete_option ( 'master_user ' );
204+
205+ // Return what we actually wrote to the database
206+ return $ latest_result ['tokens ' ];
166207 }
167208
168- return $ existing_tokens ;
209+ // No conflicts, return cleaned tokens
210+ return $ result ['tokens ' ];
169211 }
170212
171213 /**
0 commit comments