diff --git a/inc/Abilities/AuthAbilities.php b/inc/Abilities/AuthAbilities.php index cc134d933..a43fd7a28 100644 --- a/inc/Abilities/AuthAbilities.php +++ b/inc/Abilities/AuthAbilities.php @@ -179,6 +179,9 @@ private function registerAbilities(): void { $this->registerGetAuthStatus(); $this->registerDisconnectAuth(); $this->registerSaveAuthConfig(); + $this->registerSetAuthToken(); + $this->registerRefreshAuth(); + $this->registerListProviders(); }; if ( doing_action( 'wp_abilities_api_init' ) ) { @@ -293,10 +296,170 @@ private function registerSaveAuthConfig(): void { ); } + private function registerSetAuthToken(): void { + wp_register_ability( + 'datamachine/set-auth-token', + array( + 'label' => __( 'Set Auth Token', 'data-machine' ), + 'description' => __( 'Manually set authentication token and account data for a handler. Used for migration, CI, and headless auth setup.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'handler_slug', 'account_data' ), + 'properties' => array( + 'handler_slug' => array( + 'type' => 'string', + 'description' => __( 'Handler identifier (e.g., twitter, facebook, linkedin)', 'data-machine' ), + ), + 'account_data' => array( + 'type' => 'object', + 'description' => __( 'Account data to store. Must include access_token. Can include any platform-specific fields (user_id, username, token_expires_at, refresh_token, etc.).', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeSetAuthToken' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + private function registerRefreshAuth(): void { + wp_register_ability( + 'datamachine/refresh-auth', + array( + 'label' => __( 'Refresh Auth Token', 'data-machine' ), + 'description' => __( 'Force a token refresh for an OAuth2 handler. Only works for providers that support token refresh.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'handler_slug' ), + 'properties' => array( + 'handler_slug' => array( + 'type' => 'string', + 'description' => __( 'Handler identifier (e.g., twitter, facebook, linkedin)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'expires_at' => array( 'type' => array( 'string', 'null' ) ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeRefreshAuth' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + private function registerListProviders(): void { + wp_register_ability( + 'datamachine/list-auth-providers', + array( + 'label' => __( 'List Auth Providers', 'data-machine' ), + 'description' => __( 'List all registered authentication providers with status, config fields, and account details.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'providers' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( $this, 'executeListProviders' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + public function checkPermission(): bool { return PermissionHelper::can_manage(); } + /** + * List all registered auth providers with status and configuration. + * + * Returns each provider with its type (oauth2, oauth1, simple), + * authentication status, config fields, callback URL, and connected + * account details. + * + * @since 0.47.0 + * @param array $input Ability input (unused). + * @return array Provider list. + */ + public function executeListProviders( array $input ): array { + $input; + $providers = $this->getAllProviders(); + + $data = array(); + + foreach ( $providers as $provider_key => $instance ) { + $auth_type = 'simple'; + if ( $instance instanceof \DataMachine\Core\OAuth\BaseOAuth2Provider ) { + $auth_type = 'oauth2'; + } elseif ( $instance instanceof \DataMachine\Core\OAuth\BaseOAuth1Provider ) { + $auth_type = 'oauth1'; + } + + $is_authenticated = false; + if ( method_exists( $instance, 'is_authenticated' ) ) { + $is_authenticated = $instance->is_authenticated(); + } + + $entry = array( + 'provider_key' => $provider_key, + 'label' => ucfirst( str_replace( '_', ' ', $provider_key ) ), + 'auth_type' => $auth_type, + 'is_configured' => method_exists( $instance, 'is_configured' ) ? $instance->is_configured() : false, + 'is_authenticated' => $is_authenticated, + 'auth_fields' => method_exists( $instance, 'get_config_fields' ) ? $instance->get_config_fields() : array(), + 'callback_url' => null, + 'account_details' => null, + ); + + if ( in_array( $auth_type, array( 'oauth1', 'oauth2' ), true ) && method_exists( $instance, 'get_callback_url' ) ) { + $entry['callback_url'] = $instance->get_callback_url(); + } + + if ( $is_authenticated && method_exists( $instance, 'get_account_details' ) ) { + $entry['account_details'] = $instance->get_account_details(); + } + + $data[] = $entry; + } + + // Sort: authenticated first, then alphabetically by label. + usort( $data, function ( $a, $b ) { + if ( $a['is_authenticated'] !== $b['is_authenticated'] ) { + return $a['is_authenticated'] ? -1 : 1; + } + return strcasecmp( $a['label'], $b['label'] ); + } ); + + return array( + 'success' => true, + 'providers' => $data, + ); + } + public function executeGetAuthStatus( array $input ): array { $handler_slug = sanitize_text_field( $input['handler_slug'] ?? '' ); @@ -523,4 +686,179 @@ public function executeSaveAuthConfig( array $input ): array { 'error' => __( 'Failed to save configuration', 'data-machine' ), ); } + + /** + * Manually set authentication token and account data for a handler. + * + * Bypasses OAuth flow to directly inject credentials. Useful for: + * - Migrating tokens from another plugin + * - CI/headless environments where browser OAuth is impossible + * - Restoring credentials from backup + * + * @since 0.47.0 + * @param array $input Input with handler_slug and account_data. + * @return array Result. + */ + public function executeSetAuthToken( array $input ): array { + $handler_slug = sanitize_text_field( $input['handler_slug'] ?? '' ); + $account_data = $input['account_data'] ?? array(); + + if ( empty( $handler_slug ) ) { + return array( + 'success' => false, + 'error' => __( 'Handler slug is required', 'data-machine' ), + ); + } + + if ( empty( $account_data ) || ! is_array( $account_data ) ) { + return array( + 'success' => false, + 'error' => __( 'Account data is required and must be an object', 'data-machine' ), + ); + } + + if ( empty( $account_data['access_token'] ) ) { + return array( + 'success' => false, + 'error' => __( 'access_token is required in account_data', 'data-machine' ), + ); + } + + $auth_instance = $this->getProviderForHandler( $handler_slug ); + + if ( ! $auth_instance ) { + return array( + 'success' => false, + 'error' => __( 'Authentication provider not found', 'data-machine' ), + ); + } + + if ( ! method_exists( $auth_instance, 'save_account' ) ) { + return array( + 'success' => false, + 'error' => __( 'This handler does not support saving account data', 'data-machine' ), + ); + } + + // Sanitize string values in account data. + $sanitized = array(); + foreach ( $account_data as $key => $value ) { + if ( is_string( $value ) ) { + $sanitized[ $key ] = sanitize_text_field( $value ); + } elseif ( is_int( $value ) || is_float( $value ) || is_bool( $value ) || is_null( $value ) ) { + $sanitized[ $key ] = $value; + } elseif ( is_array( $value ) ) { + $sanitized[ $key ] = $value; + } + } + + $saved = $auth_instance->save_account( $sanitized ); + + if ( $saved ) { + // Schedule proactive refresh if the provider supports it. + if ( method_exists( $auth_instance, 'schedule_proactive_refresh' ) ) { + $auth_instance->schedule_proactive_refresh(); + } + + do_action( + 'datamachine_log', + 'info', + 'Auth: Token set manually via CLI/ability', + array( + 'handler_slug' => $handler_slug, + 'has_expiry' => ! empty( $sanitized['token_expires_at'] ), + ) + ); + + return array( + 'success' => true, + /* translators: %s: Service name (e.g., Twitter, Facebook) */ + 'message' => sprintf( __( '%s authentication token set successfully', 'data-machine' ), ucfirst( $handler_slug ) ), + ); + } + + return array( + 'success' => false, + 'error' => __( 'Failed to save account data', 'data-machine' ), + ); + } + + /** + * Force a token refresh for an OAuth2 handler. + * + * Calls get_valid_access_token() which handles refresh logic automatically. + * Only works for providers extending BaseOAuth2Provider that implement + * do_refresh_token(). + * + * @since 0.47.0 + * @param array $input Input with handler_slug. + * @return array Result with new expiry if available. + */ + public function executeRefreshAuth( array $input ): array { + $handler_slug = sanitize_text_field( $input['handler_slug'] ?? '' ); + + if ( empty( $handler_slug ) ) { + return array( + 'success' => false, + 'error' => __( 'Handler slug is required', 'data-machine' ), + ); + } + + $auth_instance = $this->getProviderForHandler( $handler_slug ); + + if ( ! $auth_instance ) { + return array( + 'success' => false, + 'error' => __( 'Authentication provider not found', 'data-machine' ), + ); + } + + if ( ! method_exists( $auth_instance, 'is_authenticated' ) || ! $auth_instance->is_authenticated() ) { + return array( + 'success' => false, + 'error' => sprintf( + /* translators: %s: Service name (e.g., Twitter, Facebook) */ + __( '%s is not currently authenticated. Connect first before refreshing.', 'data-machine' ), + ucfirst( $handler_slug ) + ), + ); + } + + if ( ! method_exists( $auth_instance, 'get_valid_access_token' ) ) { + return array( + 'success' => false, + 'error' => __( 'This handler does not support token refresh', 'data-machine' ), + ); + } + + // Force refresh by getting a valid token (handles expiry check + refresh). + $new_token = $auth_instance->get_valid_access_token(); + + if ( null === $new_token ) { + return array( + 'success' => false, + 'error' => sprintf( + /* translators: %s: Service name (e.g., Twitter, Facebook) */ + __( 'Token refresh failed for %s. Re-authorization may be required.', 'data-machine' ), + ucfirst( $handler_slug ) + ), + ); + } + + // Get updated account to show new expiry. + $expires_at = null; + if ( method_exists( $auth_instance, 'get_account' ) ) { + $account = $auth_instance->get_account(); + $expires_at = ! empty( $account['token_expires_at'] ) + ? wp_date( 'Y-m-d H:i:s', intval( $account['token_expires_at'] ) ) + : null; + } + + return array( + 'success' => true, + /* translators: %s: Service name (e.g., Twitter, Facebook) */ + 'message' => sprintf( __( '%s token refreshed successfully', 'data-machine' ), ucfirst( $handler_slug ) ), + 'expires_at' => $expires_at, + ); + } } diff --git a/inc/Api/Auth.php b/inc/Api/Auth.php index 7dae73d2e..b07f99b79 100644 --- a/inc/Api/Auth.php +++ b/inc/Api/Auth.php @@ -2,9 +2,9 @@ /** * REST API Authentication Endpoint * - * Provides REST API access to OAuth and authentication operations. - * Delegates to AuthAbilities for core logic. - * Requires WordPress manage_options capability. + * Thin REST transport layer for authentication operations. + * All business logic lives in AuthAbilities — this file only handles + * HTTP concerns (route registration, request parsing, response formatting). * * @package DataMachine\Api */ @@ -41,6 +41,7 @@ public static function register() { * Register /datamachine/v1/auth endpoints */ public static function register_routes() { + // List all providers. register_rest_route( 'datamachine/v1', '/auth/providers', @@ -51,6 +52,7 @@ public static function register_routes() { ) ); + // Disconnect (DELETE) and save config (PUT) for a handler. register_rest_route( 'datamachine/v1', '/auth/(?P[a-zA-Z0-9_\-]+)', @@ -59,31 +61,18 @@ public static function register_routes() { 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( self::class, 'handle_disconnect_account' ), 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'handler_slug' => array( - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => __( 'Handler identifier (e.g., twitter, facebook)', 'data-machine' ), - ), - ), + 'args' => self::handler_slug_args(), ), array( 'methods' => 'PUT', 'callback' => array( self::class, 'handle_save_auth_config' ), 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'handler_slug' => array( - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => __( 'Handler identifier', 'data-machine' ), - ), - ), + 'args' => self::handler_slug_args(), ), ) ); + // Get auth status for a handler. register_rest_route( 'datamachine/v1', '/auth/(?P[a-zA-Z0-9_\-]+)/status', @@ -91,20 +80,37 @@ public static function register_routes() { 'methods' => 'GET', 'callback' => array( self::class, 'handle_check_oauth_status' ), 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'handler_slug' => array( - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => __( 'Handler identifier', 'data-machine' ), - ), - ), + 'args' => self::handler_slug_args(), + ) + ); + + // Set token manually. + register_rest_route( + 'datamachine/v1', + '/auth/(?P[a-zA-Z0-9_\-]+)/token', + array( + 'methods' => 'PUT', + 'callback' => array( self::class, 'handle_set_token' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => self::handler_slug_args(), + ) + ); + + // Force token refresh. + register_rest_route( + 'datamachine/v1', + '/auth/(?P[a-zA-Z0-9_\-]+)/refresh', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_refresh' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => self::handler_slug_args(), ) ); } /** - * Check if user has permission to manage authentication + * Check if user has permission to manage authentication. */ public static function check_permission( $request ) { $request; @@ -120,44 +126,37 @@ public static function check_permission( $request ) { } /** - * Handle account disconnection request + * List all registered auth providers. * - * DELETE /datamachine/v1/auth/{handler_slug} + * GET /datamachine/v1/auth/providers */ - public static function handle_disconnect_account( $request ) { - $handler_slug = sanitize_text_field( $request->get_param( 'handler_slug' ) ); - - $result = self::getAbilities()->executeDisconnectAuth( - array( 'handler_slug' => $handler_slug ) - ); - - if ( ! $result['success'] ) { - $status = 400; - if ( false !== strpos( $result['error'] ?? '', 'not found' ) ) { - $status = 404; - } elseif ( false !== strpos( $result['error'] ?? '', 'Failed to disconnect' ) || - false !== strpos( $result['error'] ?? '', 'does not support' ) ) { - $status = 500; - } - - return new \WP_Error( - 'disconnect_auth_error', - $result['error'], - array( 'status' => $status ) - ); - } + public static function handle_list_providers( $request ) { + $request; + $result = self::getAbilities()->executeListProviders( array() ); return rest_ensure_response( array( 'success' => true, - 'data' => null, - 'message' => $result['message'], + 'data' => $result['providers'] ?? array(), ) ); } /** - * Handle OAuth status check request + * Handle account disconnection request. + * + * DELETE /datamachine/v1/auth/{handler_slug} + */ + public static function handle_disconnect_account( $request ) { + $result = self::getAbilities()->executeDisconnectAuth( + array( 'handler_slug' => sanitize_text_field( $request->get_param( 'handler_slug' ) ) ) + ); + + return self::ability_to_response( $result, 'disconnect_auth_error' ); + } + + /** + * Handle OAuth status check request. * * GET /datamachine/v1/auth/{handler_slug}/status */ @@ -169,42 +168,16 @@ public static function handle_check_oauth_status( $request ) { ); if ( ! $result['success'] ) { - $status = 400; - if ( false !== strpos( $result['error'] ?? '', 'not found' ) ) { - $status = 404; - } elseif ( false !== strpos( $result['error'] ?? '', 'generation' ) ) { - $status = 500; - } - - return new \WP_Error( - 'get_auth_status_error', - $result['error'], - array( 'status' => $status ) - ); + return self::ability_to_response( $result, 'get_auth_status_error' ); } - $data = array( - 'handler_slug' => $result['handler_slug'] ?? $handler_slug, - ); - - if ( isset( $result['authenticated'] ) ) { - $data['authenticated'] = $result['authenticated']; - } + // Pass through relevant fields from the ability result. + $data = array( 'handler_slug' => $result['handler_slug'] ?? $handler_slug ); - if ( isset( $result['requires_auth'] ) ) { - $data['requires_auth'] = $result['requires_auth']; - } - - if ( isset( $result['message'] ) ) { - $data['message'] = $result['message']; - } - - if ( isset( $result['oauth_url'] ) ) { - $data['oauth_url'] = $result['oauth_url']; - } - - if ( isset( $result['instructions'] ) ) { - $data['instructions'] = $result['instructions']; + foreach ( array( 'authenticated', 'requires_auth', 'message', 'oauth_url', 'instructions' ) as $key ) { + if ( isset( $result[ $key ] ) ) { + $data[ $key ] = $result[ $key ]; + } } return rest_ensure_response( @@ -216,14 +189,13 @@ public static function handle_check_oauth_status( $request ) { } /** - * Handle auth configuration save request + * Handle auth configuration save request. * * PUT /datamachine/v1/auth/{handler_slug} */ public static function handle_save_auth_config( $request ) { $handler_slug = sanitize_text_field( $request->get_param( 'handler_slug' ) ); $request_params = $request->get_params(); - unset( $request_params['handler_slug'] ); $result = self::getAbilities()->executeSaveAuthConfig( @@ -233,99 +205,89 @@ public static function handle_save_auth_config( $request ) { ) ); - if ( ! $result['success'] ) { - $status = 400; - if ( false !== strpos( $result['error'] ?? '', 'not found' ) ) { - $status = 404; - } elseif ( false !== strpos( $result['error'] ?? '', 'Failed to save' ) || - false !== strpos( $result['error'] ?? '', 'Could not retrieve' ) ) { - $status = 500; - } + return self::ability_to_response( $result, 'save_auth_config_error' ); + } - return new \WP_Error( - 'save_auth_config_error', - $result['error'], - array( 'status' => $status ) - ); - } + /** + * Handle manual token injection. + * + * PUT /datamachine/v1/auth/{handler_slug}/token + */ + public static function handle_set_token( $request ) { + $handler_slug = sanitize_text_field( $request->get_param( 'handler_slug' ) ); + $body = $request->get_json_params(); - return rest_ensure_response( + $result = self::getAbilities()->executeSetAuthToken( array( - 'success' => true, - 'data' => null, - 'message' => $result['message'], + 'handler_slug' => $handler_slug, + 'account_data' => $body, ) ); + + return self::ability_to_response( $result, 'set_auth_token_error' ); } /** - * List all registered auth providers with status and configuration. + * Handle forced token refresh. * - * GET /datamachine/v1/auth/providers - * - * Returns each provider with its type (oauth2, oauth1, simple), - * authentication status, config fields, callback URL, and connected - * account details — everything the Settings UI needs. - * - * @since 0.44.1 - * @param \WP_REST_Request $request Request object. - * @return \WP_REST_Response Provider list. + * POST /datamachine/v1/auth/{handler_slug}/refresh */ - public static function handle_list_providers( $request ) { - $request; - $abilities = self::getAbilities(); - $providers = $abilities->getAllProviders(); + public static function handle_refresh( $request ) { + $handler_slug = sanitize_text_field( $request->get_param( 'handler_slug' ) ); - $data = array(); + $result = self::getAbilities()->executeRefreshAuth( + array( 'handler_slug' => $handler_slug ) + ); - foreach ( $providers as $provider_key => $instance ) { - $auth_type = 'simple'; - if ( $instance instanceof \DataMachine\Core\OAuth\BaseOAuth2Provider ) { - $auth_type = 'oauth2'; - } elseif ( $instance instanceof \DataMachine\Core\OAuth\BaseOAuth1Provider ) { - $auth_type = 'oauth1'; - } + return self::ability_to_response( $result, 'refresh_auth_error' ); + } - $is_authenticated = false; - if ( method_exists( $instance, 'is_authenticated' ) ) { - $is_authenticated = $instance->is_authenticated(); - } + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- - $entry = array( - 'provider_key' => $provider_key, - 'label' => ucfirst( str_replace( '_', ' ', $provider_key ) ), - 'auth_type' => $auth_type, - 'is_configured' => method_exists( $instance, 'is_configured' ) ? $instance->is_configured() : false, - 'is_authenticated' => $is_authenticated, - 'auth_fields' => method_exists( $instance, 'get_config_fields' ) ? $instance->get_config_fields() : array(), - 'callback_url' => null, - 'account_details' => null, - ); + /** + * Common handler_slug route args definition. + * + * @return array Route args. + */ + private static function handler_slug_args(): array { + return array( + 'handler_slug' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => __( 'Handler identifier (e.g., twitter, facebook, linkedin)', 'data-machine' ), + ), + ); + } - if ( in_array( $auth_type, array( 'oauth1', 'oauth2' ), true ) && method_exists( $instance, 'get_callback_url' ) ) { - $entry['callback_url'] = $instance->get_callback_url(); - } + /** + * Convert an ability result to a REST response. + * + * Success results are returned as 200 with the ability's data. + * Failure results are returned as WP_Error with an inferred HTTP status. + * + * @param array $result Ability result array. + * @param string $error_code WP_Error code for failures. + * @return \WP_REST_Response|\WP_Error + */ + private static function ability_to_response( array $result, string $error_code ) { + if ( ! empty( $result['success'] ) ) { + return rest_ensure_response( $result ); + } - if ( $is_authenticated && method_exists( $instance, 'get_account_details' ) ) { - $entry['account_details'] = $instance->get_account_details(); - } + $error = $result['error'] ?? 'Unknown error'; + $status = 400; - $data[] = $entry; + if ( str_contains( $error, 'not found' ) ) { + $status = 404; + } elseif ( str_contains( $error, 'not authenticated' ) || str_contains( $error, 'not currently authenticated' ) ) { + $status = 409; + } elseif ( str_contains( $error, 'Failed' ) || str_contains( $error, 'Could not' ) ) { + $status = 500; } - // Sort: authenticated first, then alphabetically by label. - usort( $data, function ( $a, $b ) { - if ( $a['is_authenticated'] !== $b['is_authenticated'] ) { - return $a['is_authenticated'] ? -1 : 1; - } - return strcasecmp( $a['label'], $b['label'] ); - } ); - - return rest_ensure_response( - array( - 'success' => true, - 'data' => $data, - ) - ); + return new \WP_Error( $error_code, $error, array( 'status' => $status ) ); } } diff --git a/inc/Cli/Commands/AuthCommand.php b/inc/Cli/Commands/AuthCommand.php index 1065c6183..7b00eb7f5 100644 --- a/inc/Cli/Commands/AuthCommand.php +++ b/inc/Cli/Commands/AuthCommand.php @@ -269,6 +269,232 @@ public function config( array $args, array $assoc_args ): void { $this->showConfig( $handler_slug, $provider, $config_fields, $show_secrets ); } + /** + * Manually set a token and account data for a handler. + * + * Bypasses the OAuth browser flow to directly inject credentials. + * Useful for migrating tokens, CI environments, and headless setups. + * + * The account_data JSON must include at minimum an access_token field. + * Additional fields depend on the platform (e.g., user_id, username, + * token_expires_at, refresh_token, person_id, page_id). + * + * ## OPTIONS + * + * + * : Handler to set token for (e.g., twitter, facebook, linkedin). + * + * --token= + * : The access token to set. + * + * [--refresh-token=] + * : Refresh token (for OAuth2 providers with refresh support). + * + * [--expires=] + * : Token expiry as Unix timestamp (e.g., 1720000000). + * + * [--user-id=] + * : Platform-specific user/person/page ID. + * + * [--username=] + * : Platform-specific username or display name. + * + * [--json=] + * : Full account data as JSON string. Overrides other flags. + * + * ## EXAMPLES + * + * # Set a token with expiry + * wp datamachine auth set-token linkedin --token=AQUvlL... --expires=1720000000 --user-id=abc123 + * + * # Set from full JSON (e.g., migrating from another plugin) + * wp datamachine auth set-token twitter --json='{"access_token":"...", "user_id":"123", "username":"chubes"}' + * + * # Minimal token set + * wp datamachine auth set-token reddit --token=eyJhbGciOi... + * + * @subcommand set-token + */ + public function set_token( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) ) { + WP_CLI::error( 'Handler slug is required.' ); + return; + } + + $handler_slug = sanitize_text_field( $args[0] ); + + if ( ! $this->abilities->providerExists( $handler_slug ) ) { + WP_CLI::error( sprintf( 'Auth provider "%s" not found. Use "wp datamachine auth status" to see available providers.', $handler_slug ) ); + return; + } + + // Build account data from flags or JSON. + if ( ! empty( $assoc_args['json'] ) ) { + $account_data = json_decode( $assoc_args['json'], true ); + if ( ! is_array( $account_data ) ) { + WP_CLI::error( 'Invalid JSON provided via --json flag.' ); + return; + } + } else { + if ( empty( $assoc_args['token'] ) ) { + WP_CLI::error( 'Either --token or --json is required.' ); + return; + } + + $account_data = array( + 'access_token' => $assoc_args['token'], + 'authenticated_at' => time(), + ); + + if ( ! empty( $assoc_args['refresh-token'] ) ) { + $account_data['refresh_token'] = $assoc_args['refresh-token']; + } + + if ( ! empty( $assoc_args['expires'] ) ) { + $account_data['token_expires_at'] = intval( $assoc_args['expires'] ); + } + + if ( ! empty( $assoc_args['user-id'] ) ) { + $account_data['user_id'] = $assoc_args['user-id']; + $account_data['person_id'] = $assoc_args['user-id']; + } + + if ( ! empty( $assoc_args['username'] ) ) { + $account_data['username'] = $assoc_args['username']; + $account_data['name'] = $assoc_args['username']; + } + } + + $result = $this->abilities->executeSetAuthToken( + array( + 'handler_slug' => $handler_slug, + 'account_data' => $account_data, + ) + ); + + if ( ! empty( $result['success'] ) ) { + WP_CLI::success( $result['message'] ?? sprintf( '%s token set.', ucfirst( $handler_slug ) ) ); + + $keys = array_keys( $account_data ); + WP_CLI::log( sprintf( 'Stored fields: %s', implode( ', ', $keys ) ) ); + + if ( ! empty( $account_data['token_expires_at'] ) ) { + $expires = wp_date( 'Y-m-d H:i:s', intval( $account_data['token_expires_at'] ) ); + $days = max( 0, intval( ( intval( $account_data['token_expires_at'] ) - time() ) / DAY_IN_SECONDS ) ); + WP_CLI::log( sprintf( 'Token expires: %s (%d days)', $expires, $days ) ); + } + } else { + WP_CLI::error( $result['error'] ?? 'Failed to set token.' ); + } + } + + /** + * Force a token refresh for an OAuth2 handler. + * + * Triggers the provider's refresh mechanism. Only works for OAuth2 + * providers that support token refresh (e.g., LinkedIn, Facebook, + * Threads, Pinterest, Reddit). + * + * ## OPTIONS + * + * + * : Handler to refresh (e.g., linkedin, facebook, threads). + * + * ## EXAMPLES + * + * wp datamachine auth refresh linkedin + * + * @subcommand refresh + */ + public function refresh( array $args, array $assoc_args ): void { + $assoc_args; + if ( empty( $args[0] ) ) { + WP_CLI::error( 'Handler slug is required. Use "wp datamachine auth status" to see available providers.' ); + return; + } + + $handler_slug = sanitize_text_field( $args[0] ); + + if ( ! $this->abilities->providerExists( $handler_slug ) ) { + WP_CLI::error( sprintf( 'Auth provider "%s" not found.', $handler_slug ) ); + return; + } + + WP_CLI::log( sprintf( 'Refreshing %s token...', ucfirst( $handler_slug ) ) ); + + $result = $this->abilities->executeRefreshAuth( + array( 'handler_slug' => $handler_slug ) + ); + + if ( ! empty( $result['success'] ) ) { + WP_CLI::success( $result['message'] ?? sprintf( '%s token refreshed.', ucfirst( $handler_slug ) ) ); + + if ( ! empty( $result['expires_at'] ) ) { + WP_CLI::log( sprintf( 'New expiry: %s', $result['expires_at'] ) ); + } + } else { + WP_CLI::error( $result['error'] ?? 'Token refresh failed.' ); + } + } + + /** + * Refresh tokens for all authenticated OAuth2 providers. + * + * Iterates over all registered auth providers and attempts to + * refresh tokens for those that are currently authenticated and + * support token refresh. + * + * ## EXAMPLES + * + * wp datamachine auth refresh-all + * + * @subcommand refresh-all + */ + public function refresh_all( array $args, array $assoc_args ): void { + $args; + $assoc_args; + $providers = $this->abilities->getAllProviders(); + + if ( empty( $providers ) ) { + WP_CLI::warning( 'No auth providers registered.' ); + return; + } + + $refreshed = 0; + $skipped = 0; + $failed = 0; + + foreach ( $providers as $key => $provider ) { + $authenticated = method_exists( $provider, 'is_authenticated' ) && $provider->is_authenticated(); + $can_refresh = method_exists( $provider, 'get_valid_access_token' ); + + if ( ! $authenticated || ! $can_refresh ) { + ++$skipped; + continue; + } + + $result = $this->abilities->executeRefreshAuth( array( 'handler_slug' => $key ) ); + + if ( ! empty( $result['success'] ) ) { + $expiry_info = ! empty( $result['expires_at'] ) ? " (expires: {$result['expires_at']})" : ''; + WP_CLI::log( sprintf( ' %s: refreshed%s', $key, $expiry_info ) ); + ++$refreshed; + } else { + WP_CLI::log( sprintf( ' %s: FAILED — %s', $key, $result['error'] ?? 'unknown error' ) ); + ++$failed; + } + } + + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Refreshed: %d | Skipped: %d | Failed: %d', $refreshed, $skipped, $failed ) ); + + if ( $failed > 0 ) { + WP_CLI::warning( sprintf( '%d provider(s) failed to refresh.', $failed ) ); + } else { + WP_CLI::success( 'All eligible tokens refreshed.' ); + } + } + // ------------------------------------------------------------------------- // Private helpers // -------------------------------------------------------------------------