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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Google Merchant API integration using direct REST calls.
|
||||
* Google Merchant API integration using OAuth 2.0 authentication.
|
||||
*
|
||||
* This class implements authentication and API calls to the new Google Merchant API
|
||||
* without requiring Composer or external dependencies.
|
||||
* This class implements authentication and API calls to the Google Merchant API
|
||||
* using OAuth 2.0 user authentication flow.
|
||||
*
|
||||
* @package InformatiqSmartPricing
|
||||
*/
|
||||
@@ -24,6 +24,13 @@ class Informatiq_SP_Google_API {
|
||||
*/
|
||||
const API_BASE_URL = 'https://merchantapi.googleapis.com';
|
||||
|
||||
/**
|
||||
* OAuth authorization endpoint.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const AUTH_URL = 'https://accounts.google.com/o/oauth2/auth';
|
||||
|
||||
/**
|
||||
* OAuth token endpoint.
|
||||
*
|
||||
@@ -45,13 +52,6 @@ class Informatiq_SP_Google_API {
|
||||
*/
|
||||
private $access_token;
|
||||
|
||||
/**
|
||||
* Token expiration time.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $token_expires;
|
||||
|
||||
/**
|
||||
* Merchant ID (account ID).
|
||||
*
|
||||
@@ -60,11 +60,25 @@ class Informatiq_SP_Google_API {
|
||||
private $merchant_id;
|
||||
|
||||
/**
|
||||
* Service account credentials.
|
||||
* OAuth Client ID.
|
||||
*
|
||||
* @var array
|
||||
* @var string
|
||||
*/
|
||||
private $credentials;
|
||||
private $client_id;
|
||||
|
||||
/**
|
||||
* OAuth Client Secret.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $client_secret;
|
||||
|
||||
/**
|
||||
* Refresh token.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $refresh_token;
|
||||
|
||||
/**
|
||||
* Logger instance.
|
||||
@@ -76,27 +90,97 @@ class Informatiq_SP_Google_API {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $merchant_id Google Merchant ID.
|
||||
* @param string $service_account Service account JSON.
|
||||
* @param Informatiq_SP_Logger $logger Logger instance.
|
||||
* @throws Exception If authentication fails.
|
||||
* @param string $merchant_id Google Merchant ID.
|
||||
* @param string $client_id OAuth Client ID.
|
||||
* @param string $client_secret OAuth Client Secret.
|
||||
* @param string $refresh_token OAuth Refresh Token.
|
||||
* @param Informatiq_SP_Logger $logger Logger instance.
|
||||
* @throws Exception If initialization fails.
|
||||
*/
|
||||
public function __construct( $merchant_id, $service_account, $logger ) {
|
||||
$this->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;
|
||||
|
||||
Reference in New Issue
Block a user