Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add support for user tokens in external stoarge.
120 changes: 115 additions & 5 deletions projects/plugins/wpcomsh/connection/class-atomic-storage-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function is_available() {
*/
public function should_handle( $option_name ) {
// Handle blog connection data by default
return in_array( $option_name, array( 'blog_token', 'id' ), true );
return in_array( $option_name, array( 'blog_token', 'id', 'master_user', 'user_tokens' ), true );
}

/**
Expand All @@ -47,11 +47,18 @@ public function get( $option_name ) {

switch ( $option_name ) {
case 'blog_token':
return $persistent_data->JETPACK_BLOG_TOKEN; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
return empty( $persistent_data->JETPACK_BLOG_TOKEN ) ? false : $persistent_data->JETPACK_BLOG_TOKEN; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

case 'id':
$blog_id = $persistent_data->JETPACK_BLOG_ID; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
return $blog_id ? intval( $blog_id ) : null;
return intval( $persistent_data->JETPACK_BLOG_ID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

case 'master_user':
$token = $persistent_data->JETPACK_CONNECTION_OWNER_TOKEN; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
return $token ? $this->get_master_user_id( $token ) : false;

case 'user_tokens':
$token = $persistent_data->JETPACK_CONNECTION_OWNER_TOKEN; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
return $token ? $this->get_user_tokens( $token ) : false;
}

return null;
Expand All @@ -65,6 +72,109 @@ public function get( $option_name ) {
public function get_environment_id() {
return 'woa';
}
}

/**
* Get the master user id from token.
*
* @param string $email_token The email token JSON string.
* @return int|bool The master user id or false if not found.
*/
public function get_master_user_id( $email_token ) {
// Extract email from token
if ( empty( $email_token ) ) {
return false;
}

$token = json_decode( $email_token );
if ( JSON_ERROR_NONE !== json_last_error() || ! $token || empty( $token->user_email ) ) {
return false;
}

if ( ! is_email( $token->user_email ) ) {
return false;
}

$user = get_user_by( 'email', $token->user_email );
if ( ! $user instanceof \WP_User ) {
return false;
}
return $user->ID;
}

/**
* Validates user tokens and removes conflicting token for the specific user.
*
* @param string $normalized_token The normalized token from external storage (token_key.secret.user_id).
* @param array $existing_tokens The existing tokens from the database.
* @param int $user_id The user ID to validate tokens for.
* @return array The tokens array with conflicting user token removed.
*/
private function validate_user_tokens( $normalized_token, $existing_tokens, $user_id ) {
// Check if there's an existing token for this user
if ( ! isset( $existing_tokens[ $user_id ] ) ) {
return $existing_tokens;
}

$existing_token = $existing_tokens[ $user_id ];

if ( hash_equals( $normalized_token, $existing_token ) ) {
return $existing_tokens;
}

// Token mismatch - remove only this user's conflicting token
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "Removing conflicting token for user {$user_id}" );
unset( $existing_tokens[ $user_id ] );

return $existing_tokens;
}

/**
* Get the user tokens by email.
*
* @param object|string $email_token The email token object or JSON encoded string.
* @return array|false The user tokens array or false if not found/invalid.
*/
public function get_user_tokens( $email_token ) {
// Validate input
if ( empty( $email_token ) ) {
return false;
}

$token = json_decode( $email_token );

if ( JSON_ERROR_NONE !== json_last_error() || ! $token || empty( $token->user_email ) || empty( $token->secret ) ) {
return false;
}

// Get user by email
$user = get_user_by( 'email', $token->user_email );
if ( ! $user || ! $user->ID ) {
return false;
}

$user_id = (int) $user->ID;

// Create normalized token (format: token_key.secret.user_id)
// The secret from external storage should be token_key.secret (2 parts)
// We need to append LOCAL user_id to make it 3 parts for Jetpack validation
$normalized_token = $token->secret . '.' . $user_id;

// Get existing tokens from database (bypass external storage to avoid circular dependency)
$private_options = \Jetpack_Options::get_raw_option( 'jetpack_private_options', array() );
$existing_tokens = isset( $private_options['user_tokens'] ) && is_array( $private_options['user_tokens'] )
? $private_options['user_tokens']
: array();

// Validate tokens and clean up if there's a mismatch
if ( ! empty( $existing_tokens ) ) {
$existing_tokens = $this->validate_user_tokens( $normalized_token, $existing_tokens, $user_id );
}

// Store the token with local user ID as key and local user ID in token
$existing_tokens[ $user_id ] = $normalized_token;

return $existing_tokens;
}
}
}
198 changes: 198 additions & 0 deletions projects/plugins/wpcomsh/tests/AtomicStorageProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php
/**
* Atomic Storage Provider Test file.
*
* @package wpcomsh
*/

require_once __DIR__ . '/../connection/class-atomic-storage-provider.php';

/**
* Class AtomicStorageProviderTest.
*/
class AtomicStorageProviderTest extends WP_UnitTestCase {
use \Automattic\Jetpack\PHPUnit\WP_UnitTestCase_Fix;

/**
* The Atomic Storage Provider instance.
*
* @var Atomic_Storage_Provider
*/
private $provider;

/**
* Set up test environment.
*/
public function set_up() {
parent::set_up();

// Create provider instance
$this->provider = new Atomic_Storage_Provider();

// Clean up any existing options
delete_option( 'jetpack_private_options' );
delete_option( 'master_user' );
}

/**
* Test is_available method.
*/
public function test_is_available() {
$this->assertTrue( $this->provider->is_available() );
}

/**
* Test should_handle method.
*/
public function test_should_handle() {
$this->assertTrue( $this->provider->should_handle( 'blog_token' ) );
$this->assertTrue( $this->provider->should_handle( 'id' ) );
$this->assertTrue( $this->provider->should_handle( 'master_user' ) );
$this->assertTrue( $this->provider->should_handle( 'user_tokens' ) );
$this->assertFalse( $this->provider->should_handle( 'other_option' ) );
}

/**
* Test get_master_user_id with valid token.
*/
public function test_get_master_user_id_valid() {
$user_id = static::factory()->user->create( array( 'user_email' => '[email protected]' ) );

$token_data = wp_json_encode(
array(
'user_email' => '[email protected]',
'secret' => 'token.secret',
)
);

$result = $this->provider->get_master_user_id( $token_data );
$this->assertSame( $user_id, $result );
}

/**
* Test get_master_user_id with invalid user email in token.
*/
public function test_get_master_user_id_invalid() {
$token_data = wp_json_encode(
array(
'user_email' => '[email protected]',
'secret' => 'token.secret',
)
);

$result = $this->provider->get_master_user_id( $token_data );
$this->assertFalse( $result );
}

/**
* Test get_master_user_id with empty token.
*/
public function test_get_master_user_id_empty() {
$result = $this->provider->get_master_user_id( '' );
$this->assertFalse( $result );
}

/**
* Test get_master_user_id with invalid token format.
*/
public function test_get_master_user_id_invalid_format() {
$result = $this->provider->get_master_user_id( 'not-valid-json' );
$this->assertFalse( $result );
}

/**
* Test get_master_user_id with invalid email format in token.
*/
public function test_get_master_user_id_invalid_email_format() {
$token_data = wp_json_encode(
array(
'user_email' => 'not-an-email',
'secret' => 'token.secret',
)
);

$result = $this->provider->get_master_user_id( $token_data );
$this->assertFalse( $result );
}

/**
* Test get_user_tokens with invalid input.
*/
public function test_get_user_tokens_invalid_input() {
// Empty input
$this->assertFalse( $this->provider->get_user_tokens( '' ) );

// Invalid JSON
$this->assertFalse( $this->provider->get_user_tokens( 'invalid-json' ) );

// Missing properties
$this->assertFalse( $this->provider->get_user_tokens( '{"user_email":"[email protected]"}' ) );
$this->assertFalse( $this->provider->get_user_tokens( '{"secret":"token.secret"}' ) );
}

/**
* Test get_user_tokens with non-existent user.
*/
public function test_get_user_tokens_nonexistent_user() {
$token_data = wp_json_encode(
array(
'user_email' => '[email protected]',
'secret' => 'token.secret',
)
);

$this->assertFalse( $this->provider->get_user_tokens( $token_data ) );
}

/**
* Test get_user_tokens with no existing tokens (Condition 2).
*/
public function test_get_user_tokens_no_existing_tokens() {
$user_id = static::factory()->user->create( array( 'user_email' => '[email protected]' ) );

$token_data = wp_json_encode(
array(
'user_email' => '[email protected]',
'secret' => 'token.secret',
)
);

// Call get_user_tokens directly
$result = $this->provider->get_user_tokens( $token_data );

$expected = array( $user_id => 'token.secret.' . $user_id );
$this->assertSame( $expected, $result );
}

/**
* Test get_user_tokens with existing matching token (Condition 3).
*/
public function test_get_user_tokens_existing_matching() {
$user_id = static::factory()->user->create( array( 'user_email' => '[email protected]' ) );
$other_user_id = static::factory()->user->create( array( 'user_email' => '[email protected]' ) );

// Set existing tokens with other users
$existing_tokens = array(
$user_id => 'token.secret.' . $user_id,
$other_user_id => 'other.token.' . $other_user_id,
);
update_option( 'jetpack_private_options', array( 'user_tokens' => $existing_tokens ) );

$token_data = wp_json_encode(
array(
'user_email' => '[email protected]',
'secret' => 'token.secret',
)
);

// Call get_user_tokens directly
$result = $this->provider->get_user_tokens( $token_data );

// Should return merged array with both tokens
$expected = array(
$user_id => 'token.secret.' . $user_id,
$other_user_id => 'other.token.' . $other_user_id,
);
$this->assertSame( $expected, $result );
}
}
35 changes: 35 additions & 0 deletions projects/plugins/wpcomsh/tests/lib/mocks/class-jetpack-options.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ class Jetpack_Options {
* @return mixed Option value.
*/
public static function get_option( $option_name, $default = false ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound
// Handle grouped options based on their actual storage location
if ( in_array( $option_name, array( 'master_user', 'id' ), true ) ) {
// These are in the 'compact' group -> jetpack_options
$compact_options = get_option( 'jetpack_options', array() );
return isset( $compact_options[ $option_name ] ) ? $compact_options[ $option_name ] : $default;
}

if ( in_array( $option_name, array( 'user_tokens', 'blog_token' ), true ) ) {
// These are in the 'private' group -> jetpack_private_options
$private_options = get_option( 'jetpack_private_options', array() );
return isset( $private_options[ $option_name ] ) ? $private_options[ $option_name ] : $default;
}

return apply_filters( 'jetpack_options', get_option( $option_name, $default ), $option_name );
}

Expand Down Expand Up @@ -58,5 +71,27 @@ public static function update_option( $option_name, $value ) {
public static function delete_option( $option_name ) {
return delete_option( $option_name );
}

/**
* Get raw option (bypass external storage).
*
* @param string $option_name Option name.
* @param mixed $default Default value.
* @return mixed Option value.
*/
public static function get_raw_option( $option_name, $default = false ) {
return get_option( $option_name, $default );
}

/**
* Update raw option (bypass external storage).
*
* @param string $option_name Option name.
* @param mixed $value Option value.
* @return bool True if the option was updated, false otherwise.
*/
public static function update_raw_option( $option_name, $value ) {
return update_option( $option_name, $value );
}
}
}
Loading