Skip to content

Commit baab46a

Browse files
bindlegirlCopilot
andauthored
wpcomsh: use APD for user token (#45303)
Co-authored-by: Copilot <[email protected]>
1 parent 516e58f commit baab46a

File tree

4 files changed

+468
-5
lines changed

4 files changed

+468
-5
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Add support for user tokens in external storage.

projects/plugins/wpcomsh/connection/class-atomic-storage-provider.php

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
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

Comments
 (0)