From e313fce19737b71475c18eddffead76094a9ee82 Mon Sep 17 00:00:00 2001 From: Malin Date: Wed, 21 Jan 2026 08:52:57 +0100 Subject: [PATCH] feat: Switch to OAuth 2.0 authentication for Google Merchant API - Replace service account authentication with OAuth 2.0 user flow - Add "Authorize with Google" button in admin settings - Handle OAuth callback and token exchange - Store refresh token for automatic access token renewal - Add revoke authorization functionality - Update admin UI to show authorization status - Update price updater to use new OAuth credentials - Add CSRF protection with state parameter This change supports organizations that have disabled service account key creation via iam.disableServiceAccountKeyCreation policy. Co-Authored-By: Claude Opus 4.5 --- admin/class-informatiq-sp-admin.php | 300 ++++++++++++++++-- assets/js/admin.js | 41 +++ includes/class-informatiq-sp-google-api.php | 227 +++++++------ .../class-informatiq-sp-price-updater.php | 28 +- 4 files changed, 456 insertions(+), 140 deletions(-) diff --git a/admin/class-informatiq-sp-admin.php b/admin/class-informatiq-sp-admin.php index b98253cc..062b74e8 100644 --- a/admin/class-informatiq-sp-admin.php +++ b/admin/class-informatiq-sp-admin.php @@ -44,9 +44,13 @@ class Informatiq_SP_Admin { // Register settings. add_action( 'admin_init', array( $this, 'register_settings' ) ); + // Handle OAuth callback. + add_action( 'admin_init', array( $this, 'handle_oauth_callback' ) ); + // Handle AJAX requests. add_action( 'wp_ajax_informatiq_sp_manual_sync', array( $this, 'handle_manual_sync' ) ); add_action( 'wp_ajax_informatiq_sp_test_connection', array( $this, 'handle_test_connection' ) ); + add_action( 'wp_ajax_informatiq_sp_revoke_auth', array( $this, 'handle_revoke_auth' ) ); // Enqueue admin assets. add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); @@ -72,7 +76,8 @@ class Informatiq_SP_Admin { public function register_settings() { // Register settings. register_setting( 'informatiq_sp_settings', 'informatiq_sp_merchant_id' ); - register_setting( 'informatiq_sp_settings', 'informatiq_sp_service_account' ); + register_setting( 'informatiq_sp_settings', 'informatiq_sp_client_id' ); + register_setting( 'informatiq_sp_settings', 'informatiq_sp_client_secret' ); register_setting( 'informatiq_sp_settings', 'informatiq_sp_minimum_margin' ); register_setting( 'informatiq_sp_settings', 'informatiq_sp_auto_update_enabled' ); register_setting( 'informatiq_sp_settings', 'informatiq_sp_update_frequency' ); @@ -109,9 +114,17 @@ class Informatiq_SP_Admin { ); add_settings_field( - 'informatiq_sp_service_account', - __( 'Service Account JSON', 'informatiq-smart-pricing' ), - array( $this, 'render_service_account_field' ), + 'informatiq_sp_oauth_credentials', + __( 'OAuth 2.0 Credentials', 'informatiq-smart-pricing' ), + array( $this, 'render_oauth_credentials_field' ), + 'informatiq_sp_settings', + 'informatiq_sp_google_settings' + ); + + add_settings_field( + 'informatiq_sp_authorization', + __( 'Google Authorization', 'informatiq-smart-pricing' ), + array( $this, 'render_authorization_field' ), 'informatiq_sp_settings', 'informatiq_sp_google_settings' ); @@ -141,6 +154,108 @@ class Informatiq_SP_Admin { ); } + /** + * Handle OAuth callback from Google. + */ + public function handle_oauth_callback() { + // Check if this is an OAuth callback. + if ( ! isset( $_GET['informatiq_sp_oauth'] ) || $_GET['informatiq_sp_oauth'] !== 'callback' ) { + return; + } + + // Verify state parameter. + $state = isset( $_GET['state'] ) ? sanitize_text_field( $_GET['state'] ) : ''; + $saved_state = get_transient( 'informatiq_sp_oauth_state' ); + + if ( empty( $state ) || $state !== $saved_state ) { + add_settings_error( + 'informatiq_sp_messages', + 'informatiq_sp_oauth_error', + __( 'OAuth state mismatch. Please try again.', 'informatiq-smart-pricing' ), + 'error' + ); + return; + } + + delete_transient( 'informatiq_sp_oauth_state' ); + + // Check for errors. + if ( isset( $_GET['error'] ) ) { + $error = sanitize_text_field( $_GET['error'] ); + $error_desc = isset( $_GET['error_description'] ) ? sanitize_text_field( $_GET['error_description'] ) : $error; + add_settings_error( + 'informatiq_sp_messages', + 'informatiq_sp_oauth_error', + sprintf( __( 'Authorization failed: %s', 'informatiq-smart-pricing' ), $error_desc ), + 'error' + ); + return; + } + + // Get authorization code. + $code = isset( $_GET['code'] ) ? sanitize_text_field( $_GET['code'] ) : ''; + + if ( empty( $code ) ) { + add_settings_error( + 'informatiq_sp_messages', + 'informatiq_sp_oauth_error', + __( 'No authorization code received.', 'informatiq-smart-pricing' ), + 'error' + ); + return; + } + + // Exchange code for tokens. + $client_id = get_option( 'informatiq_sp_client_id' ); + $client_secret = get_option( 'informatiq_sp_client_secret' ); + $redirect_uri = $this->get_oauth_redirect_uri(); + + try { + $tokens = Informatiq_SP_Google_API::exchange_code_for_tokens( + $code, + $client_id, + $client_secret, + $redirect_uri + ); + + // Save refresh token. + update_option( 'informatiq_sp_refresh_token', $tokens['refresh_token'] ); + + $this->logger->info( 'Google OAuth authorization successful' ); + + // Redirect to settings page with success message. + wp_redirect( admin_url( 'admin.php?page=informatiq-smart-pricing&oauth=success' ) ); + exit; + + } catch ( Exception $e ) { + $this->logger->error( 'OAuth token exchange failed: ' . $e->getMessage() ); + add_settings_error( + 'informatiq_sp_messages', + 'informatiq_sp_oauth_error', + sprintf( __( 'Token exchange failed: %s', 'informatiq-smart-pricing' ), $e->getMessage() ), + 'error' + ); + } + } + + /** + * Get OAuth redirect URI. + * + * @return string Redirect URI. + */ + private function get_oauth_redirect_uri() { + return admin_url( 'admin.php?page=informatiq-smart-pricing&informatiq_sp_oauth=callback' ); + } + + /** + * Check if plugin is authorized. + * + * @return bool True if authorized. + */ + private function is_authorized() { + return ! empty( get_option( 'informatiq_sp_refresh_token' ) ); + } + /** * Render admin page. */ @@ -149,6 +264,16 @@ class Informatiq_SP_Admin { return; } + // Check for OAuth success message. + if ( isset( $_GET['oauth'] ) && $_GET['oauth'] === 'success' ) { + add_settings_error( + 'informatiq_sp_messages', + 'informatiq_sp_message', + __( 'Google authorization successful!', 'informatiq-smart-pricing' ), + 'success' + ); + } + // Check if form was submitted. if ( isset( $_GET['settings-updated'] ) ) { // Update cron schedule if frequency changed. @@ -185,14 +310,20 @@ class Informatiq_SP_Admin {

- -

+ is_authorized() ) : ?> +

+ +

+ + @@ -217,7 +348,7 @@ class Informatiq_SP_Admin { * Render Google settings section description. */ public function render_google_settings_section() { - echo '

' . esc_html__( 'Configure your Google Merchant Center API credentials.', 'informatiq-smart-pricing' ) . '

'; + echo '

' . esc_html__( 'Configure your Google Merchant Center API credentials using OAuth 2.0.', 'informatiq-smart-pricing' ) . '

'; } /** @@ -243,25 +374,100 @@ class Informatiq_SP_Admin {

- +

- -

- -

+
+

+
+ +

+

+
+ +

+

+ ' . esc_html( $this->get_oauth_redirect_uri() ) . '' + ); + ?> +

+
is_authorized(); + $has_credentials = ! empty( $client_id ) && ! empty( $client_secret ); + + if ( $is_authorized ) { + ?> +
+ + +

+ +

+

+ +

+
+ get_oauth_redirect_uri(), + $state + ); + ?> +
+ + +

+ +

+

+ + + +

+
+ +
+ + +
+
-

+

    -
  1. -
  2. -
  3. -
  4. +
  5. +
  6. +
  7. +
  8. +
@@ -461,6 +668,10 @@ class Informatiq_SP_Admin { wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'informatiq-smart-pricing' ) ) ); } + if ( ! $this->is_authorized() ) { + wp_send_json_error( array( 'message' => __( 'Please authorize with Google first.', 'informatiq-smart-pricing' ) ) ); + } + try { $scheduler = new Informatiq_SP_Scheduler( $this->price_updater ); $results = $scheduler->trigger_manual_update(); @@ -491,16 +702,28 @@ class Informatiq_SP_Admin { wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'informatiq-smart-pricing' ) ) ); } - $merchant_id = get_option( 'informatiq_sp_merchant_id' ); - $service_account = get_option( 'informatiq_sp_service_account' ); + $merchant_id = get_option( 'informatiq_sp_merchant_id' ); + $client_id = get_option( 'informatiq_sp_client_id' ); + $client_secret = get_option( 'informatiq_sp_client_secret' ); + $refresh_token = get_option( 'informatiq_sp_refresh_token' ); - if ( empty( $merchant_id ) || empty( $service_account ) ) { - wp_send_json_error( array( 'message' => __( 'Please configure Google Merchant settings first.', 'informatiq-smart-pricing' ) ) ); + if ( empty( $merchant_id ) ) { + wp_send_json_error( array( 'message' => __( 'Please enter your Merchant ID.', 'informatiq-smart-pricing' ) ) ); + } + + if ( empty( $refresh_token ) ) { + wp_send_json_error( array( 'message' => __( 'Please authorize with Google first.', 'informatiq-smart-pricing' ) ) ); } try { - $google_api = new Informatiq_SP_Google_API( $merchant_id, $service_account, $this->logger ); - $success = $google_api->test_connection(); + $google_api = new Informatiq_SP_Google_API( + $merchant_id, + $client_id, + $client_secret, + $refresh_token, + $this->logger + ); + $success = $google_api->test_connection(); if ( $success ) { wp_send_json_success( array( 'message' => __( 'Connection successful!', 'informatiq-smart-pricing' ) ) ); @@ -512,6 +735,23 @@ class Informatiq_SP_Admin { } } + /** + * Handle revoke authorization AJAX request. + */ + public function handle_revoke_auth() { + check_ajax_referer( 'informatiq_sp_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'informatiq-smart-pricing' ) ) ); + } + + delete_option( 'informatiq_sp_refresh_token' ); + + $this->logger->info( 'Google OAuth authorization revoked' ); + + wp_send_json_success( array( 'message' => __( 'Authorization revoked.', 'informatiq-smart-pricing' ) ) ); + } + /** * Enqueue admin assets. * @@ -544,8 +784,10 @@ class Informatiq_SP_Admin { 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'informatiq_sp_admin' ), 'strings' => array( - 'syncInProgress' => __( 'Sync in progress...', 'informatiq-smart-pricing' ), - 'testInProgress' => __( 'Testing connection...', 'informatiq-smart-pricing' ), + 'syncInProgress' => __( 'Sync in progress...', 'informatiq-smart-pricing' ), + 'testInProgress' => __( 'Testing connection...', 'informatiq-smart-pricing' ), + 'revokeConfirm' => __( 'Are you sure you want to revoke Google authorization?', 'informatiq-smart-pricing' ), + 'revokeInProgress' => __( 'Revoking...', 'informatiq-smart-pricing' ), ), ) ); diff --git a/assets/js/admin.js b/assets/js/admin.js index e13ab619..26e4580d 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -19,6 +19,7 @@ bindEvents: function() { $('#informatiq-sp-manual-sync').on('click', this.handleManualSync); $('#informatiq-sp-test-connection').on('click', this.handleTestConnection); + $('#informatiq-sp-revoke-auth').on('click', this.handleRevokeAuth); }, /** @@ -138,6 +139,46 @@ }, 5000); } }); + }, + + /** + * Handle revoke authorization button click + */ + handleRevokeAuth: function(e) { + e.preventDefault(); + + var $button = $(this); + + // Confirm action + if (!confirm(informatiqSP.strings.revokeConfirm || 'Are you sure you want to revoke Google authorization?')) { + return; + } + + // Disable button and show loading state + $button.prop('disabled', true).text(informatiqSP.strings.revokeInProgress || 'Revoking...'); + + // Make AJAX request + $.ajax({ + url: informatiqSP.ajaxUrl, + type: 'POST', + data: { + action: 'informatiq_sp_revoke_auth', + nonce: informatiqSP.nonce + }, + success: function(response) { + if (response.success) { + // Reload page to show updated status + location.reload(); + } else { + alert('Error: ' + (response.data.message || 'Unknown error')); + $button.prop('disabled', false).text('Revoke Authorization'); + } + }, + error: function(jqXHR, textStatus, errorThrown) { + alert('Error: ' + errorThrown); + $button.prop('disabled', false).text('Revoke Authorization'); + } + }); } }; diff --git a/includes/class-informatiq-sp-google-api.php b/includes/class-informatiq-sp-google-api.php index 137acd65..05f7c236 100644 --- a/includes/class-informatiq-sp-google-api.php +++ b/includes/class-informatiq-sp-google-api.php @@ -1,9 +1,9 @@ merchant_id = $merchant_id; - $this->logger = $logger; + public function __construct( $merchant_id, $client_id, $client_secret, $refresh_token, $logger ) { + $this->merchant_id = $merchant_id; + $this->client_id = $client_id; + $this->client_secret = $client_secret; + $this->refresh_token = $refresh_token; + $this->logger = $logger; - // Parse service account JSON. - $this->credentials = json_decode( $service_account, true ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - throw new Exception( 'Invalid service account JSON: ' . json_last_error_msg() ); + if ( empty( $client_id ) || empty( $client_secret ) ) { + throw new Exception( 'OAuth Client ID and Client Secret are required' ); } - if ( empty( $this->credentials['client_email'] ) || empty( $this->credentials['private_key'] ) ) { - throw new Exception( 'Service account JSON must contain client_email and private_key' ); + if ( empty( $refresh_token ) ) { + throw new Exception( 'Not authorized. Please authorize with Google first.' ); } } + /** + * Get the authorization URL for OAuth flow. + * + * @param string $client_id OAuth Client ID. + * @param string $redirect_uri Redirect URI after authorization. + * @param string $state State parameter for CSRF protection. + * @return string Authorization URL. + */ + public static function get_authorization_url( $client_id, $redirect_uri, $state ) { + $params = array( + 'client_id' => $client_id, + 'redirect_uri' => $redirect_uri, + 'response_type' => 'code', + 'scope' => self::API_SCOPE, + 'access_type' => 'offline', + 'prompt' => 'consent', + 'state' => $state, + ); + + return self::AUTH_URL . '?' . http_build_query( $params ); + } + + /** + * Exchange authorization code for tokens. + * + * @param string $code Authorization code from Google. + * @param string $client_id OAuth Client ID. + * @param string $client_secret OAuth Client Secret. + * @param string $redirect_uri Redirect URI used in authorization. + * @return array Tokens array with access_token and refresh_token. + * @throws Exception If token exchange fails. + */ + public static function exchange_code_for_tokens( $code, $client_id, $client_secret, $redirect_uri ) { + $response = wp_remote_post( + self::TOKEN_URL, + array( + 'body' => array( + 'code' => $code, + 'client_id' => $client_id, + 'client_secret' => $client_secret, + 'redirect_uri' => $redirect_uri, + 'grant_type' => 'authorization_code', + ), + 'timeout' => 30, + ) + ); + + if ( is_wp_error( $response ) ) { + throw new Exception( 'Token exchange failed: ' . $response->get_error_message() ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error'] ) ) { + throw new Exception( 'Token error: ' . ( $body['error_description'] ?? $body['error'] ) ); + } + + if ( empty( $body['access_token'] ) || empty( $body['refresh_token'] ) ) { + throw new Exception( 'Invalid token response from Google' ); + } + + return array( + 'access_token' => $body['access_token'], + 'refresh_token' => $body['refresh_token'], + 'expires_in' => $body['expires_in'] ?? 3600, + ); + } + /** * Get access token, refreshing if necessary. * @@ -104,33 +188,38 @@ class Informatiq_SP_Google_API { * @throws Exception If token retrieval fails. */ private function get_access_token() { - // Return cached token if still valid (with 60 second buffer). - if ( $this->access_token && $this->token_expires > ( time() + 60 ) ) { + // Return cached token if available. + if ( $this->access_token ) { return $this->access_token; } - // Create JWT for token exchange. - $jwt = $this->create_jwt(); - - // Exchange JWT for access token. + // Refresh the access token. $response = wp_remote_post( self::TOKEN_URL, array( 'body' => array( - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion' => $jwt, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'refresh_token' => $this->refresh_token, + 'grant_type' => 'refresh_token', ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { - throw new Exception( 'Token request failed: ' . $response->get_error_message() ); + throw new Exception( 'Token refresh failed: ' . $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['error'] ) ) { + // If refresh token is invalid, user needs to re-authorize. + if ( $body['error'] === 'invalid_grant' ) { + // Clear the stored refresh token. + delete_option( 'informatiq_sp_refresh_token' ); + throw new Exception( 'Authorization expired. Please re-authorize with Google.' ); + } throw new Exception( 'Token error: ' . ( $body['error_description'] ?? $body['error'] ) ); } @@ -138,71 +227,11 @@ class Informatiq_SP_Google_API { throw new Exception( 'No access token in response' ); } - $this->access_token = $body['access_token']; - $this->token_expires = time() + ( $body['expires_in'] ?? 3600 ); + $this->access_token = $body['access_token']; return $this->access_token; } - /** - * Create a signed JWT for authentication. - * - * @return string Signed JWT. - * @throws Exception If JWT creation fails. - */ - private function create_jwt() { - $now = time(); - - // JWT Header. - $header = array( - 'alg' => 'RS256', - 'typ' => 'JWT', - ); - - // JWT Claims. - $claims = array( - 'iss' => $this->credentials['client_email'], - 'scope' => self::API_SCOPE, - 'aud' => self::TOKEN_URL, - 'iat' => $now, - 'exp' => $now + 3600, - ); - - // Encode header and claims. - $header_encoded = $this->base64url_encode( wp_json_encode( $header ) ); - $claims_encoded = $this->base64url_encode( wp_json_encode( $claims ) ); - - // Create signature input. - $signature_input = $header_encoded . '.' . $claims_encoded; - - // Sign with private key. - $private_key = openssl_pkey_get_private( $this->credentials['private_key'] ); - - if ( ! $private_key ) { - throw new Exception( 'Invalid private key in service account' ); - } - - $signature = ''; - $success = openssl_sign( $signature_input, $signature, $private_key, OPENSSL_ALGO_SHA256 ); - - if ( ! $success ) { - throw new Exception( 'Failed to sign JWT' ); - } - - // Return complete JWT. - return $signature_input . '.' . $this->base64url_encode( $signature ); - } - - /** - * Base64url encode (URL-safe base64). - * - * @param string $data Data to encode. - * @return string Encoded data. - */ - private function base64url_encode( $data ) { - return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); - } - /** * Make an API request to the Merchant API. * @@ -411,8 +440,8 @@ class Informatiq_SP_Google_API { $endpoint .= '&pageToken=' . urlencode( $page_token ); } - $response = $this->api_request( 'GET', $endpoint ); - $products = $response['products'] ?? array(); + $response = $this->api_request( 'GET', $endpoint ); + $products = $response['products'] ?? array(); $all_products = array_merge( $all_products, $products ); $page_token = $response['nextPageToken'] ?? null; diff --git a/includes/class-informatiq-sp-price-updater.php b/includes/class-informatiq-sp-price-updater.php index eecf6822..7d221d50 100644 --- a/includes/class-informatiq-sp-price-updater.php +++ b/includes/class-informatiq-sp-price-updater.php @@ -58,19 +58,21 @@ class Informatiq_SP_Price_Updater { ); // Get plugin settings. - $merchant_id = get_option( 'informatiq_sp_merchant_id' ); - $service_account = get_option( 'informatiq_sp_service_account' ); - $minimum_margin = (float) get_option( 'informatiq_sp_minimum_margin', 10 ); + $merchant_id = get_option( 'informatiq_sp_merchant_id' ); + $client_id = get_option( 'informatiq_sp_client_id' ); + $client_secret = get_option( 'informatiq_sp_client_secret' ); + $refresh_token = get_option( 'informatiq_sp_refresh_token' ); + $minimum_margin = (float) get_option( 'informatiq_sp_minimum_margin', 10 ); // Validate settings. - if ( empty( $merchant_id ) || empty( $service_account ) ) { - $this->logger->error( 'Google Merchant settings not configured. Please configure settings first.' ); + if ( empty( $merchant_id ) || empty( $refresh_token ) ) { + $this->logger->error( 'Google Merchant settings not configured. Please configure and authorize first.' ); return $results; } // Initialize Google API. try { - $google_api = new Informatiq_SP_Google_API( $merchant_id, $service_account, $this->logger ); + $google_api = new Informatiq_SP_Google_API( $merchant_id, $client_id, $client_secret, $refresh_token, $this->logger ); } catch ( Exception $e ) { $this->logger->error( 'Failed to initialize Google API: ' . $e->getMessage() ); return $results; @@ -362,19 +364,21 @@ class Informatiq_SP_Price_Updater { } // Get settings. - $merchant_id = get_option( 'informatiq_sp_merchant_id' ); - $service_account = get_option( 'informatiq_sp_service_account' ); - $minimum_margin = (float) get_option( 'informatiq_sp_minimum_margin', 10 ); + $merchant_id = get_option( 'informatiq_sp_merchant_id' ); + $client_id = get_option( 'informatiq_sp_client_id' ); + $client_secret = get_option( 'informatiq_sp_client_secret' ); + $refresh_token = get_option( 'informatiq_sp_refresh_token' ); + $minimum_margin = (float) get_option( 'informatiq_sp_minimum_margin', 10 ); - if ( empty( $merchant_id ) || empty( $service_account ) ) { + if ( empty( $merchant_id ) || empty( $refresh_token ) ) { return array( 'success' => false, - 'message' => 'Google Merchant settings not configured', + 'message' => 'Google Merchant settings not configured or not authorized', ); } try { - $google_api = new Informatiq_SP_Google_API( $merchant_id, $service_account, $this->logger ); + $google_api = new Informatiq_SP_Google_API( $merchant_id, $client_id, $client_secret, $refresh_token, $this->logger ); $updated = $this->process_single_product( $product, $google_api, $minimum_margin ); return array(