Native PHP plugin (no Composer) that syncs: - Product stock and pricing from BC to WooCommerce (scheduled cron) - Orders from WooCommerce to BC (on payment received) - Auto-creates customers in BC from WooCommerce billing data Product matching: WooCommerce SKU → BC Item Number, fallback to GTIN (EAN). OAuth2 client credentials auth with encrypted secret storage. Admin settings page with connection test, manual sync, and log viewer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
339 lines
9.7 KiB
PHP
339 lines
9.7 KiB
PHP
<?php
|
|
/**
|
|
* OAuth2 Authentication for Business Central API
|
|
*
|
|
* @package WooBusinessCentral
|
|
*/
|
|
|
|
// Prevent direct access
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class WBC_OAuth
|
|
*
|
|
* Handles OAuth2 token management for Microsoft Business Central API.
|
|
*/
|
|
class WBC_OAuth {
|
|
|
|
/**
|
|
* Token endpoint URL template
|
|
*/
|
|
const TOKEN_ENDPOINT = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token';
|
|
|
|
/**
|
|
* API scope
|
|
*/
|
|
const API_SCOPE = 'https://api.businesscentral.dynamics.com/.default';
|
|
|
|
/**
|
|
* Token transient name
|
|
*/
|
|
const TOKEN_TRANSIENT = 'wbc_access_token';
|
|
|
|
/**
|
|
* Token expiry buffer (refresh 5 minutes before expiry)
|
|
*/
|
|
const EXPIRY_BUFFER = 300;
|
|
|
|
/**
|
|
* Encryption key option name
|
|
*/
|
|
const ENCRYPTION_KEY_OPTION = 'wbc_encryption_key';
|
|
|
|
/**
|
|
* Get the encryption key
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function get_encryption_key() {
|
|
$key = get_option( self::ENCRYPTION_KEY_OPTION );
|
|
|
|
if ( empty( $key ) ) {
|
|
// Generate a new key if one doesn't exist
|
|
$key = wp_generate_password( 32, true, true );
|
|
update_option( self::ENCRYPTION_KEY_OPTION, $key );
|
|
}
|
|
|
|
return $key;
|
|
}
|
|
|
|
/**
|
|
* Encrypt a value
|
|
*
|
|
* @param string $value The value to encrypt.
|
|
* @return string Encrypted value.
|
|
*/
|
|
public static function encrypt( $value ) {
|
|
if ( empty( $value ) ) {
|
|
return '';
|
|
}
|
|
|
|
$key = self::get_encryption_key();
|
|
|
|
// Use WordPress salt for additional entropy
|
|
$salt = wp_salt( 'auth' );
|
|
$key = hash( 'sha256', $key . $salt, true );
|
|
|
|
// Generate random IV
|
|
$iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
|
|
$iv = openssl_random_pseudo_bytes( $iv_length );
|
|
|
|
// Encrypt (OPENSSL_RAW_DATA ensures raw binary output)
|
|
$encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
|
|
|
|
if ( $encrypted === false ) {
|
|
return '';
|
|
}
|
|
|
|
// Combine IV and encrypted data, then base64 encode
|
|
return base64_encode( $iv . $encrypted );
|
|
}
|
|
|
|
/**
|
|
* Decrypt a value
|
|
*
|
|
* @param string $encrypted The encrypted value.
|
|
* @return string Decrypted value.
|
|
*/
|
|
public static function decrypt( $encrypted ) {
|
|
if ( empty( $encrypted ) ) {
|
|
return '';
|
|
}
|
|
|
|
$key = self::get_encryption_key();
|
|
|
|
// Use WordPress salt for additional entropy
|
|
$salt = wp_salt( 'auth' );
|
|
$key = hash( 'sha256', $key . $salt, true );
|
|
|
|
// Decode
|
|
$data = base64_decode( $encrypted );
|
|
if ( $data === false ) {
|
|
return '';
|
|
}
|
|
|
|
// Extract IV and encrypted data
|
|
$iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
|
|
$iv = substr( $data, 0, $iv_length );
|
|
$encrypted_data = substr( $data, $iv_length );
|
|
|
|
// Decrypt (OPENSSL_RAW_DATA matches the encrypt flag)
|
|
$decrypted = openssl_decrypt( $encrypted_data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
|
|
|
|
return $decrypted !== false ? $decrypted : '';
|
|
}
|
|
|
|
/**
|
|
* Get stored tenant ID
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_tenant_id() {
|
|
return get_option( 'wbc_tenant_id', '' );
|
|
}
|
|
|
|
/**
|
|
* Get stored client ID
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_client_id() {
|
|
return get_option( 'wbc_client_id', '' );
|
|
}
|
|
|
|
/**
|
|
* Get stored client secret (decrypted)
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_client_secret() {
|
|
$encrypted = get_option( 'wbc_client_secret', '' );
|
|
return self::decrypt( $encrypted );
|
|
}
|
|
|
|
/**
|
|
* Save client secret (encrypted)
|
|
*
|
|
* @param string $secret The client secret to save.
|
|
*/
|
|
public static function save_client_secret( $secret ) {
|
|
$encrypted = self::encrypt( $secret );
|
|
update_option( 'wbc_client_secret', $encrypted );
|
|
}
|
|
|
|
/**
|
|
* Get environment (production/sandbox)
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_environment() {
|
|
return get_option( 'wbc_environment', 'production' );
|
|
}
|
|
|
|
/**
|
|
* Get company ID
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_company_id() {
|
|
return get_option( 'wbc_company_id', '' );
|
|
}
|
|
|
|
/**
|
|
* Check if credentials are configured
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function is_configured() {
|
|
return ! empty( self::get_tenant_id() )
|
|
&& ! empty( self::get_client_id() )
|
|
&& ! empty( self::get_client_secret() )
|
|
&& ! empty( self::get_company_id() );
|
|
}
|
|
|
|
/**
|
|
* Get access token
|
|
*
|
|
* @param bool $force_refresh Force refresh even if cached token exists.
|
|
* @return string|WP_Error Access token or error.
|
|
*/
|
|
public static function get_access_token( $force_refresh = false ) {
|
|
// Check for cached token
|
|
if ( ! $force_refresh ) {
|
|
$cached_token = get_transient( self::TOKEN_TRANSIENT );
|
|
if ( $cached_token !== false ) {
|
|
return $cached_token;
|
|
}
|
|
}
|
|
|
|
// Request new token
|
|
return self::request_token();
|
|
}
|
|
|
|
/**
|
|
* Request a new access token from Microsoft
|
|
*
|
|
* @return string|WP_Error Access token or error.
|
|
*/
|
|
private static function request_token() {
|
|
$tenant_id = self::get_tenant_id();
|
|
$client_id = self::get_client_id();
|
|
$client_secret = self::get_client_secret();
|
|
|
|
// Validate credentials
|
|
if ( empty( $tenant_id ) || empty( $client_id ) || empty( $client_secret ) ) {
|
|
$error = new WP_Error( 'wbc_missing_credentials', __( 'Business Central API credentials are not configured.', 'woo-business-central' ) );
|
|
WBC_Logger::error( 'OAuth', 'Missing API credentials' );
|
|
return $error;
|
|
}
|
|
|
|
// Build token endpoint URL
|
|
$token_url = sprintf( self::TOKEN_ENDPOINT, $tenant_id );
|
|
|
|
// Prepare request body
|
|
$body = array(
|
|
'grant_type' => 'client_credentials',
|
|
'client_id' => $client_id,
|
|
'client_secret' => $client_secret,
|
|
'scope' => self::API_SCOPE,
|
|
);
|
|
|
|
WBC_Logger::debug( 'OAuth', 'Requesting new access token', array( 'tenant_id' => $tenant_id ) );
|
|
|
|
// Make the request using cURL
|
|
$ch = curl_init();
|
|
|
|
curl_setopt_array( $ch, array(
|
|
CURLOPT_URL => $token_url,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query( $body ),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_HTTPHEADER => array(
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
),
|
|
) );
|
|
|
|
$response = curl_exec( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
$curl_error = curl_error( $ch );
|
|
|
|
curl_close( $ch );
|
|
|
|
// Check for cURL errors
|
|
if ( $response === false ) {
|
|
$error = new WP_Error( 'wbc_curl_error', sprintf( __( 'cURL error: %s', 'woo-business-central' ), $curl_error ) );
|
|
WBC_Logger::error( 'OAuth', 'cURL error during token request', array( 'error' => $curl_error ) );
|
|
return $error;
|
|
}
|
|
|
|
// Parse response
|
|
$data = json_decode( $response, true );
|
|
|
|
// Check for HTTP errors
|
|
if ( $http_code !== 200 ) {
|
|
$error_message = isset( $data['error_description'] ) ? $data['error_description'] : __( 'Unknown error', 'woo-business-central' );
|
|
$error = new WP_Error( 'wbc_token_error', $error_message, array( 'status' => $http_code ) );
|
|
WBC_Logger::error( 'OAuth', 'Token request failed', array(
|
|
'http_code' => $http_code,
|
|
'error' => $data['error'] ?? 'unknown',
|
|
'message' => $error_message,
|
|
) );
|
|
return $error;
|
|
}
|
|
|
|
// Extract token
|
|
if ( ! isset( $data['access_token'] ) ) {
|
|
$error = new WP_Error( 'wbc_invalid_response', __( 'Invalid token response from Microsoft.', 'woo-business-central' ) );
|
|
WBC_Logger::error( 'OAuth', 'Invalid token response', array( 'response' => $data ) );
|
|
return $error;
|
|
}
|
|
|
|
$access_token = $data['access_token'];
|
|
$expires_in = isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600;
|
|
|
|
// Cache the token (with buffer for expiry)
|
|
$cache_duration = max( 60, $expires_in - self::EXPIRY_BUFFER );
|
|
set_transient( self::TOKEN_TRANSIENT, $access_token, $cache_duration );
|
|
|
|
WBC_Logger::info( 'OAuth', 'Successfully obtained access token', array(
|
|
'expires_in' => $expires_in,
|
|
'cached_for' => $cache_duration,
|
|
) );
|
|
|
|
return $access_token;
|
|
}
|
|
|
|
/**
|
|
* Clear cached token
|
|
*/
|
|
public static function clear_token_cache() {
|
|
delete_transient( self::TOKEN_TRANSIENT );
|
|
WBC_Logger::debug( 'OAuth', 'Token cache cleared' );
|
|
}
|
|
|
|
/**
|
|
* Test the connection to Business Central
|
|
*
|
|
* @return array|WP_Error Array with success status or WP_Error.
|
|
*/
|
|
public static function test_connection() {
|
|
// Clear any cached token
|
|
self::clear_token_cache();
|
|
|
|
// Try to get a new token
|
|
$token = self::get_access_token( true );
|
|
|
|
if ( is_wp_error( $token ) ) {
|
|
return $token;
|
|
}
|
|
|
|
return array(
|
|
'success' => true,
|
|
'message' => __( 'Successfully connected to Business Central.', 'woo-business-central' ),
|
|
);
|
|
}
|
|
}
|