1010
1111 /**
1212 * Atomic Persistent Data storage provider for Jetpack Connection data.
13- * Stage 1: Read-only support for blog_token and id (blog_id).
13+ *
14+ * Provides connection credentials from Atomic Persistent Data (APD) for WordPress.com Atomic sites.
15+ * Supports blog_token, blog_id, master_user, and user_tokens from external storage.
1416 *
1517 * @since 8.0.0
1618 */
@@ -33,7 +35,7 @@ public function is_available() {
3335 */
3436 public function should_handle ( $ option_name ) {
3537 // Handle blog connection data by default
36- return in_array ( $ option_name , array ( 'blog_token ' , 'id ' ), true );
38+ return in_array ( $ option_name , array ( 'blog_token ' , 'id ' , ' master_user ' , ' user_tokens ' ), true );
3739 }
3840
3941 /**
@@ -47,11 +49,25 @@ public function get( $option_name ) {
4749
4850 switch ( $ option_name ) {
4951 case 'blog_token ' :
50- return $ persistent_data ->JETPACK_BLOG_TOKEN ; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
52+ return empty ( $ persistent_data -> JETPACK_BLOG_TOKEN ) ? null : $ persistent_data ->JETPACK_BLOG_TOKEN ; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
5153
5254 case 'id ' :
5355 $ blog_id = $ persistent_data ->JETPACK_BLOG_ID ; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
54- return $ blog_id ? intval ( $ blog_id ) : null ;
56+ return empty ( $ blog_id ) ? null : intval ( $ blog_id );
57+
58+ case 'master_user ' :
59+ $ email = $ persistent_data ->JETPACK_CONNECTION_OWNER_EMAIL ; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
60+ $ id = $ this ->get_master_user_id ( $ email ? $ email : '' );
61+ return $ id ? $ id : null ;
62+
63+ case 'user_tokens ' :
64+ $ email = $ persistent_data ->JETPACK_CONNECTION_OWNER_EMAIL ; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
65+ $ secret = $ persistent_data ->JETPACK_CONNECTION_OWNER_TOKEN_SECRET ; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
66+ if ( empty ( $ email ) || empty ( $ secret ) ) {
67+ return null ;
68+ }
69+ $ tokens = $ this ->get_user_tokens ( $ email , $ secret );
70+ return ( is_array ( $ tokens ) && ! empty ( $ tokens ) ) ? $ tokens : null ;
5571 }
5672
5773 return null ;
@@ -65,6 +81,181 @@ public function get( $option_name ) {
6581 public function get_environment_id () {
6682 return 'woa ' ;
6783 }
68- }
6984
85+ /**
86+ * Get the master user id from email.
87+ *
88+ * @since $$next-version$$
89+ *
90+ * @param string $email The user email.
91+ * @return int|bool The master user id or false if not found.
92+ */
93+ public function get_master_user_id ( $ email ) {
94+ if ( empty ( $ email ) ) {
95+ return false ;
96+ }
97+
98+ if ( ! is_email ( $ email ) ) {
99+ return false ;
100+ }
101+
102+ $ user = get_user_by ( 'email ' , $ email );
103+ if ( ! $ user instanceof \WP_User ) {
104+ return false ;
105+ }
106+ return $ user ->ID ;
107+ }
108+
109+ /**
110+ * Remove conflicting tokens for a given normalized token and user.
111+ *
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
115+ *
116+ * @since $$next-version$$
117+ *
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 }
123+ */
124+ private function remove_conflicting_tokens ( $ tokens , $ normalized_token , $ user_id ) {
125+ $ had_conflicts = false ;
126+ $ last_dot_pos = strrpos ( $ normalized_token , '. ' );
127+
128+ // Validate token format - must contain a dot to separate secret from user_id.
129+ if ( false === $ last_dot_pos ) {
130+ return array (
131+ 'tokens ' => $ tokens ,
132+ 'had_conflicts ' => false ,
133+ );
134+ }
135+
136+ $ secret_prefix = substr ( $ normalized_token , 0 , $ last_dot_pos );
137+
138+ // Remove mismatched token for the current user.
139+ if ( isset ( $ tokens [ $ user_id ] )
140+ && is_string ( $ tokens [ $ user_id ] )
141+ && ! hash_equals ( $ normalized_token , $ tokens [ $ user_id ] ) ) {
142+ unset( $ tokens [ $ user_id ] );
143+ $ had_conflicts = true ;
144+ }
145+
146+ // Remove orphaned tokens (same secret, different user).
147+ foreach ( $ tokens as $ token_user_id => $ token ) {
148+ if ( is_string ( $ token ) && (int ) $ token_user_id !== $ user_id && strpos ( $ token , $ secret_prefix . '. ' ) === 0 ) {
149+ unset( $ tokens [ $ token_user_id ] );
150+ $ had_conflicts = true ;
151+ }
152+ }
153+
154+ return array (
155+ 'tokens ' => $ tokens ,
156+ 'had_conflicts ' => $ had_conflicts ,
157+ );
158+ }
159+
160+ /**
161+ * Validates user tokens and removes conflicting tokens.
162+ *
163+ * Removes any tokens that:
164+ * 1. Belong to the current user but don't match the external storage token
165+ * 2. Have the same secret as external storage but belong to a different user (orphaned tokens)
166+ *
167+ * Re-reads the latest state before persisting to minimize race condition window.
168+ *
169+ * @since $$next-version$$
170+ *
171+ * @param string $normalized_token The normalized token from external storage (token_key.secret.user_id).
172+ * @param array $existing_tokens The existing tokens from the database.
173+ * @param int $user_id The user ID to validate tokens for.
174+ * @return array The tokens array with conflicting tokens removed.
175+ */
176+ private function validate_user_tokens ( $ normalized_token , $ existing_tokens , $ user_id ) {
177+ $ result = $ this ->remove_conflicting_tokens ( $ existing_tokens , $ normalized_token , $ user_id );
178+ $ has_conflicts = $ result ['had_conflicts ' ];
179+
180+ // Only persist changes if conflicts were found
181+ if ( $ has_conflicts ) {
182+ // Re-read latest state right before writing to minimize race window
183+ $ latest_options = \Jetpack_Options::get_raw_option ( 'jetpack_private_options ' , array () );
184+ $ latest_tokens = isset ( $ latest_options ['user_tokens ' ] ) && is_array ( $ latest_options ['user_tokens ' ] )
185+ ? $ latest_options ['user_tokens ' ]
186+ : array ();
187+
188+ // Re-apply cleanup to latest tokens (might find no conflicts now if state changed)
189+ $ latest_result = $ this ->remove_conflicting_tokens ( $ latest_tokens , $ normalized_token , $ user_id );
190+
191+ // Write the cleaned latest state
192+ $ latest_options ['user_tokens ' ] = $ latest_result ['tokens ' ];
193+ \Jetpack_Options::update_raw_option ( 'jetpack_private_options ' , $ latest_options , false );
194+
195+ // Also clear master_user from database since connection owner data has changed
196+ // External storage will provide the correct value on next read
197+ \Jetpack_Options::delete_option ( 'master_user ' );
198+
199+ // Clear object cache to ensure cached values are invalidated
200+ wp_cache_delete ( 'alloptions ' , 'options ' );
201+ wp_cache_delete ( 'jetpack_options ' , 'options ' );
202+ wp_cache_delete ( 'jetpack_private_options ' , 'options ' );
203+
204+ // Return what we actually wrote to the database
205+ return $ latest_result ['tokens ' ];
206+ }
207+
208+ // No conflicts, return cleaned tokens
209+ return $ result ['tokens ' ];
210+ }
211+
212+ /**
213+ * Get the user tokens by email and secret.
214+ *
215+ * @since $$next-version$$
216+ *
217+ * @param string $email The user email.
218+ * @param string $secret The token secret (format: token_key.secret).
219+ * @return array|false The user tokens array or false if not found/invalid.
220+ */
221+ public function get_user_tokens ( $ email , $ secret ) {
222+ // Validate input
223+ if ( empty ( $ email ) || empty ( $ secret ) ) {
224+ return false ;
225+ }
226+
227+ if ( ! is_email ( $ email ) ) {
228+ return false ;
229+ }
230+
231+ // Get user by email
232+ $ user = get_user_by ( 'email ' , $ email );
233+ if ( ! $ user instanceof \WP_User ) {
234+ return false ;
235+ }
236+
237+ $ user_id = (int ) $ user ->ID ;
238+
239+ // Create normalized token (format: token_key.secret.user_id)
240+ // The secret from external storage should be token_key.secret (2 parts)
241+ // We need to append LOCAL user_id to make it 3 parts for Jetpack validation
242+ $ normalized_token = $ secret . '. ' . $ user_id ;
243+
244+ // Get existing tokens from database (bypass external storage to avoid circular dependency)
245+ $ private_options = \Jetpack_Options::get_raw_option ( 'jetpack_private_options ' , array () );
246+ $ existing_tokens = isset ( $ private_options ['user_tokens ' ] ) && is_array ( $ private_options ['user_tokens ' ] )
247+ ? $ private_options ['user_tokens ' ]
248+ : array ();
249+
250+ // Validate tokens and clean up if there's a mismatch
251+ if ( ! empty ( $ existing_tokens ) ) {
252+ $ existing_tokens = $ this ->validate_user_tokens ( $ normalized_token , $ existing_tokens , $ user_id );
253+ }
254+
255+ // Store the token with local user ID as key and local user ID in token
256+ $ existing_tokens [ $ user_id ] = $ normalized_token ;
257+
258+ return $ existing_tokens ;
259+ }
260+ }
70261}
0 commit comments