# Client-Side Implementation Guide This document provides a comprehensive guide for integrating the WooCommerce Licensed Product Client into existing PHP projects. It covers architecture, classes, configuration, and best practices. ## Overview The `magdev/wc-licensed-product-client` library provides a complete PHP client for interacting with the WooCommerce Licensed Product REST API. It supports: - License validation, status checking, and activation - Response signature verification (HMAC-SHA256) - PSR-3 logging integration - PSR-6 caching integration - Comprehensive exception handling - Optional code integrity verification ## Requirements - **PHP:** 8.3 or higher - **Dependencies:** - `symfony/http-client` ^7.0 - `psr/log` ^3.0 - `psr/cache` ^3.0 - `psr/http-client` ^1.0 ## Installation ```bash composer require magdev/wc-licensed-product-client ``` ## Architecture ### Class Diagram ```text ┌─────────────────────────────┐ │ LicenseClientInterface │ │ (Public API Contract) │ ├─────────────────────────────┤ │ + validate() │ │ + status() │ │ + activate() │ └─────────────┬───────────────┘ │ implements ├───────────────────────┐ ▼ ▼ ┌─────────────────────┐ ┌──────────────────────┐ │ LicenseClient │ │ SecureLicenseClient │ │ (Basic Client) │ │ (With Signatures) │ └─────────────────────┘ └──────────────────────┘ │ │ └───────────┬───────────┘ ▼ ┌─────────────────────┐ │ DTOs │ ├─────────────────────┤ │ LicenseInfo │ │ LicenseStatus │ │ ActivationResult │ │ LicenseState (enum) │ └─────────────────────┘ ``` ### Directory Structure ```text src/ ├── LicenseClientInterface.php # Public API contract ├── LicenseClient.php # Basic implementation ├── SecureLicenseClient.php # Secure implementation with signatures ├── Dto/ │ ├── LicenseInfo.php # Validation response DTO │ ├── LicenseStatus.php # Status response DTO + LicenseState enum │ └── ActivationResult.php # Activation response DTO ├── Exception/ │ ├── LicenseException.php # Base exception │ ├── LicenseNotFoundException.php │ ├── LicenseExpiredException.php │ ├── LicenseRevokedException.php │ ├── LicenseInactiveException.php │ ├── LicenseInvalidException.php │ ├── DomainMismatchException.php │ ├── MaxActivationsReachedException.php │ ├── ActivationFailedException.php │ └── RateLimitExceededException.php └── Security/ ├── ResponseSignature.php # HMAC signature verification ├── SignatureException.php # Signature verification error ├── StringEncoder.php # XOR-based string obfuscation ├── IntegrityChecker.php # Source file hash verification └── IntegrityException.php # Integrity check failure ``` ## Client Classes ### LicenseClientInterface The public API contract that both client implementations follow: ```php interface LicenseClientInterface { /** * Validate a license key for a specific domain. * * @throws LicenseException When validation fails */ public function validate(string $licenseKey, string $domain): LicenseInfo; /** * Get detailed status information for a license key. * * @throws LicenseException When status check fails */ public function status(string $licenseKey): LicenseStatus; /** * Activate a license on a specific domain. * * @throws LicenseException When activation fails */ public function activate(string $licenseKey, string $domain): ActivationResult; } ``` ### LicenseClient (Basic) The basic client without signature verification. Use this when: - The server does not support response signing - You're in a development/testing environment - Security is handled at another layer **Constructor Parameters:** | Parameter | Type | Required | Default | Description | | --------- | ---- | -------- | ------- | ----------- | | `httpClient` | `HttpClientInterface` | Yes | - | Symfony HTTP client instance | | `baseUrl` | `string` | Yes | - | WordPress site URL (e.g., `https://example.com`) | | `logger` | `LoggerInterface\|null` | No | `NullLogger` | PSR-3 logger for debugging | | `cache` | `CacheItemPoolInterface\|null` | No | `null` | PSR-6 cache for responses | | `cacheTtl` | `int` | No | `300` | Cache TTL in seconds (5 minutes) | **Example:** ```php use Magdev\WcLicensedProductClient\LicenseClient; use Symfony\Component\HttpClient\HttpClient; $client = new LicenseClient( httpClient: HttpClient::create(), baseUrl: 'https://your-wordpress-site.com', ); ``` ### SecureLicenseClient (Recommended) The secure client with response signature verification. Use this for production environments. **Constructor Parameters:** | Parameter | Type | Required | Default | Description | | --------- | ---- | -------- | ------- | ----------- | | `httpClient` | `HttpClientInterface` | Yes | - | Symfony HTTP client instance | | `baseUrl` | `string` | Yes | - | WordPress site URL | | `serverSecret` | `string` | Yes | - | Shared secret (same as server) | | `logger` | `LoggerInterface\|null` | No | `NullLogger` | PSR-3 logger | | `cache` | `CacheItemPoolInterface\|null` | No | `null` | PSR-6 cache | | `cacheTtl` | `int` | No | `300` | Cache TTL in seconds | | `verifyIntegrity` | `bool` | No | `false` | Enable source file integrity checks | | `encoder` | `StringEncoder\|null` | No | Default instance | Custom string encoder | **Example:** ```php use Magdev\WcLicensedProductClient\SecureLicenseClient; use Symfony\Component\HttpClient\HttpClient; $client = new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: 'https://your-wordpress-site.com', serverSecret: 'your-shared-server-secret-min-32-chars', ); ``` **Security Features:** 1. **Response Signature Verification**: Every response is verified using HMAC-SHA256 2. **Timestamp Validation**: Prevents replay attacks (5-minute tolerance) 3. **Per-License Key Derivation**: Each license gets a unique signing key 4. **Obfuscated API Paths**: API endpoint paths are XOR-encoded in source code 5. **Optional Integrity Checking**: Verify critical source files haven't been modified ## Data Transfer Objects (DTOs) ### LicenseInfo Returned by `validate()`. Contains license details for a valid license. ```php final readonly class LicenseInfo { public function __construct( public int $productId, // WooCommerce product ID public ?\DateTimeImmutable $expiresAt, // null = lifetime license public ?int $versionId, // Product version (if applicable) ) {} public static function fromArray(array $data): self; public function isLifetime(): bool; // Returns true if no expiration } ``` **Usage:** ```php $info = $client->validate('ABCD-1234-EFGH-5678', 'example.com'); echo "Product ID: " . $info->productId; echo "Lifetime: " . ($info->isLifetime() ? 'Yes' : 'No'); if (!$info->isLifetime()) { echo "Expires: " . $info->expiresAt->format('Y-m-d'); } ``` ### LicenseStatus Returned by `status()`. Contains comprehensive license information. ```php final readonly class LicenseStatus { public function __construct( public bool $valid, // Is license currently valid public LicenseState $status, // Status enum value public string $domain, // Bound domain public ?\DateTimeImmutable $expiresAt, // Expiration date public int $activationsCount, // Current activations public int $maxActivations, // Maximum allowed ) {} public static function fromArray(array $data): self; public function isLifetime(): bool; public function hasAvailableActivations(): bool; } ``` **Usage:** ```php $status = $client->status('ABCD-1234-EFGH-5678'); echo "Valid: " . ($status->valid ? 'Yes' : 'No'); echo "Status: " . $status->status->value; // active, inactive, expired, revoked echo "Domain: " . $status->domain; echo "Activations: " . $status->activationsCount . "/" . $status->maxActivations; if ($status->hasAvailableActivations()) { echo "More activations available!"; } ``` ### LicenseState (Enum) Represents possible license states: ```php enum LicenseState: string { case Active = 'active'; // License is active and valid case Inactive = 'inactive'; // License exists but not activated case Expired = 'expired'; // License has passed expiration date case Revoked = 'revoked'; // License was manually revoked } ``` ### ActivationResult Returned by `activate()`. Contains activation outcome. ```php final readonly class ActivationResult { public function __construct( public bool $success, // Whether activation succeeded public string $message, // Human-readable message ) {} public static function fromArray(array $data): self; } ``` **Usage:** ```php $result = $client->activate('ABCD-1234-EFGH-5678', 'newdomain.com'); if ($result->success) { echo "Activated: " . $result->message; } else { echo "Failed: " . $result->message; } ``` ## Exception Hierarchy All exceptions extend `LicenseException` and include an `errorCode` property for programmatic handling. ```text LicenseException (base) ├── LicenseNotFoundException // error: license_not_found ├── LicenseExpiredException // error: license_expired ├── LicenseRevokedException // error: license_revoked ├── LicenseInactiveException // error: license_inactive ├── LicenseInvalidException // error: license_invalid ├── DomainMismatchException // error: domain_mismatch ├── MaxActivationsReachedException // error: max_activations_reached ├── ActivationFailedException // error: activation_failed ├── RateLimitExceededException // error: rate_limit_exceeded (has retryAfter) └── Security\SignatureException // error: signature_invalid └── Security\IntegrityException // error: integrity_check_failed ``` ### Exception Properties **LicenseException (Base):** ```php class LicenseException extends \RuntimeException { public readonly ?string $errorCode; // API error code for programmatic handling } ``` **RateLimitExceededException:** ```php final class RateLimitExceededException extends LicenseException { public readonly ?int $retryAfter; // Seconds until rate limit resets } ``` **IntegrityException:** ```php final class IntegrityException extends LicenseException { public readonly array $failures; // List of failed integrity checks } ``` ### Catching Exceptions ```php use Magdev\WcLicensedProductClient\Exception\{ LicenseException, LicenseNotFoundException, LicenseExpiredException, DomainMismatchException, RateLimitExceededException, }; use Magdev\WcLicensedProductClient\Security\SignatureException; try { $info = $client->validate($licenseKey, $domain); } catch (SignatureException $e) { // Response was tampered with or server secret mismatch error_log("SECURITY: Response signature invalid"); die("License verification failed. Please contact support."); } catch (LicenseNotFoundException $e) { echo "License key not found."; } catch (LicenseExpiredException $e) { echo "Your license has expired. Please renew."; } catch (DomainMismatchException $e) { echo "This license is not valid for this domain."; } catch (RateLimitExceededException $e) { echo "Too many requests. Try again in {$e->retryAfter} seconds."; } catch (LicenseException $e) { // Catch-all for any license error echo "License error: " . $e->getMessage(); error_log("License error [{$e->errorCode}]: " . $e->getMessage()); } ``` ## Security Classes ### ResponseSignature Verifies HMAC-SHA256 signatures on API responses to prevent tampering. **Key Derivation:** Each license key derives a unique signing key from the server secret: ```php // HKDF-like key derivation (internal implementation) $prk = hash_hmac('sha256', $licenseKey, $serverSecret, true); $derivedKey = hash_hmac('sha256', $prk . "\x01", $serverSecret); ``` **Signature Format:** ```text signature = HMAC-SHA256( key = derived_key, message = timestamp + ":" + canonical_json(response_body) ) ``` Where: - `canonical_json` sorts keys alphabetically - JSON uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` flags - Result is hex-encoded (64 characters) **Response Headers:** | Header | Description | Example | | ------ | ----------- | ------- | | `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3...` (64 chars) | | `X-License-Timestamp` | Unix timestamp when signed | `1706000000` | **Custom Timestamp Tolerance:** ```php use Magdev\WcLicensedProductClient\Security\ResponseSignature; // Default is 300 seconds (5 minutes) $signature = new ResponseSignature($secretKey, timestampTolerance: 600); // 10 minutes ``` ### StringEncoder Simple XOR-based string obfuscation for hiding API paths in source code. **WARNING:** This is NOT encryption. It only prevents casual inspection. ```php use Magdev\WcLicensedProductClient\Security\StringEncoder; $encoder = new StringEncoder(); // Encode strings for embedding in source code $encoded = $encoder->encode('/wp-json/wc-licensed-product/v1'); // Result: "MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE=" // Decode at runtime $decoded = $encoder->decode('MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE='); // Result: "/wp-json/wc-licensed-product/v1" // Generate multiple encoded constants $constants = $encoder->generateEncodedConstants([ 'API_PATH' => '/wp-json/wc-licensed-product/v1', 'VALIDATE' => 'validate', 'STATUS' => 'status', ]); ``` **Custom Encoder Key:** ```php $encoder = new StringEncoder('my_custom_encoder_key'); ``` ### IntegrityChecker Verifies that critical source files haven't been modified by comparing SHA256 hashes. ```php use Magdev\WcLicensedProductClient\Security\IntegrityChecker; // Define expected hashes (generated during build) $expectedHashes = [ 'src/SecureLicenseClient.php' => 'abc123...', 'src/Security/ResponseSignature.php' => 'def456...', ]; $checker = new IntegrityChecker($expectedHashes, '/path/to/project'); // Verify all files try { $checker->verify(); echo "All files intact"; } catch (IntegrityException $e) { echo "Tampered files: " . implode(', ', $e->failures); } // Or check silently if (!$checker->isValid()) { die("Critical files have been modified!"); } ``` **Generating Hashes During Build:** ```php $checker = new IntegrityChecker([], '/path/to/project'); $hashes = $checker->generateHashes([ 'src/SecureLicenseClient.php', 'src/Security/ResponseSignature.php', 'src/Security/SignatureException.php', ]); // Output as PHP code for embedding echo $checker->generateHashesAsPhpCode([ 'src/SecureLicenseClient.php', 'src/Security/ResponseSignature.php', ]); ``` ## Caching Both clients support PSR-6 caching to reduce API calls. Cache keys use SHA256 hashes of license keys to avoid exposing sensitive data. **Cache Key Format:** ```text wc_license_{operation}_{sha256(license_key)}[_{sha256(domain)}] ``` **Example with Symfony Cache:** ```php use Symfony\Component\Cache\Adapter\FilesystemAdapter; $cache = new FilesystemAdapter( namespace: 'license_client', defaultLifetime: 300, directory: '/tmp/license-cache' ); $client = new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: 'https://example.com', serverSecret: 'your-secret', cache: $cache, cacheTtl: 600, // 10 minutes ); ``` **Cache Behavior:** - `validate()` and `status()` responses are cached - `activate()` responses are NOT cached - After `activate()`, related cache entries are automatically invalidated ## Logging Both clients support PSR-3 logging for debugging and monitoring. **Log Levels Used:** | Level | When Used | | ----- | --------- | | `debug` | Cache hits, signature verification success | | `info` | API calls, successful operations | | `warning` | API error responses, missing signatures | | `error` | Request failures, network errors | | `critical` | Integrity check failures | **Example with Monolog:** ```php use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('license'); $logger->pushHandler(new StreamHandler('/var/log/license.log', Logger::DEBUG)); $client = new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: 'https://example.com', serverSecret: 'your-secret', logger: $logger, ); ``` ## Integration Guide ### Basic Integration The simplest integration for existing PHP projects: ```php validate($licenseKey, $currentDomain); // Define constant for use throughout application define('MY_PRODUCT_LICENSED', true); define('MY_PRODUCT_ID', $info->productId); } catch (LicenseException $e) { define('MY_PRODUCT_LICENSED', false); define('MY_PRODUCT_ID', null); // Log the error but don't expose details to users error_log("License validation failed: " . $e->getMessage()); } ``` ### WordPress Plugin Integration ```php client = new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: self::SERVER_URL, serverSecret: self::SERVER_SECRET, ); } public function isLicenseValid(): bool { $licenseKey = get_option(self::LICENSE_OPTION); if (empty($licenseKey)) { return false; } // Use transient for caching $cacheKey = 'my_plugin_license_valid_' . md5($licenseKey); $cached = get_transient($cacheKey); if ($cached !== false) { return $cached === 'valid'; } try { $domain = wp_parse_url(home_url(), PHP_URL_HOST); $this->client->validate($licenseKey, $domain); set_transient($cacheKey, 'valid', HOUR_IN_SECONDS); return true; } catch (LicenseException $e) { set_transient($cacheKey, 'invalid', 5 * MINUTE_IN_SECONDS); return false; } } public function activateLicense(string $licenseKey): array { try { $domain = wp_parse_url(home_url(), PHP_URL_HOST); $result = $this->client->activate($licenseKey, $domain); if ($result->success) { update_option(self::LICENSE_OPTION, $licenseKey); delete_transient('my_plugin_license_valid_' . md5($licenseKey)); } return [ 'success' => $result->success, 'message' => $result->message, ]; } catch (LicenseException $e) { return [ 'success' => false, 'message' => $e->getMessage(), ]; } } } ``` ### Laravel Integration **Service Provider:** ```php app->singleton(LicenseClientInterface::class, function ($app) { return new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: config('services.license.url'), serverSecret: config('services.license.secret'), logger: $app->make(Logger::class), cache: $app->make(CacheManager::class)->store('file')->getStore(), ); }); } } ``` **Configuration (config/services.php):** ```php return [ // ... 'license' => [ 'url' => env('LICENSE_SERVER_URL'), 'secret' => env('LICENSE_SERVER_SECRET'), ], ]; ``` **Usage in Controller:** ```php licenseClient->validate( $request->input('license_key'), $request->getHost(), ); return response()->json([ 'valid' => true, 'product_id' => $info->productId, 'expires_at' => $info->expiresAt?->format('Y-m-d'), ]); } catch (LicenseException $e) { return response()->json([ 'valid' => false, 'error' => $e->errorCode, 'message' => $e->getMessage(), ], 403); } } } ``` ### Symfony Integration **Service Configuration (config/services.yaml):** ```yaml services: Magdev\WcLicensedProductClient\LicenseClientInterface: class: Magdev\WcLicensedProductClient\SecureLicenseClient arguments: $httpClient: '@Symfony\Contracts\HttpClient\HttpClientInterface' $baseUrl: '%env(LICENSE_SERVER_URL)%' $serverSecret: '%env(LICENSE_SERVER_SECRET)%' $logger: '@logger' $cache: '@cache.app' ``` ## Best Practices ### 1. Use the Secure Client in Production Always use `SecureLicenseClient` in production to verify response signatures: ```php // Development $client = new LicenseClient(...); // Production $client = new SecureLicenseClient(..., serverSecret: $secret); ``` ### 2. Handle All Exception Types Don't catch just the base exception. Handle specific cases: ```php try { $client->validate($key, $domain); } catch (SignatureException $e) { // Critical: possible tampering notifyAdmin("Signature verification failed!"); die("Security error"); } catch (RateLimitExceededException $e) { // Retry later sleep($e->retryAfter ?? 60); retry(); } catch (LicenseExpiredException $e) { // Prompt renewal showRenewalForm(); } catch (LicenseException $e) { // Generic handling showError($e->getMessage()); } ``` ### 3. Cache Validation Results Use caching to minimize API calls: ```php $cache = new FilesystemAdapter('licenses', 300); $client = new SecureLicenseClient(..., cache: $cache, cacheTtl: 300); ``` ### 4. Store Secrets Securely Never hardcode secrets in source code: ```php // Bad $client = new SecureLicenseClient(..., serverSecret: 'abc123'); // Good $client = new SecureLicenseClient(..., serverSecret: getenv('LICENSE_SECRET')); ``` ### 5. Validate on Application Startup Check license validity early, not on every request: ```php // bootstrap.php $isLicensed = checkLicense(); define('APP_LICENSED', $isLicensed); // Later in code if (APP_LICENSED) { showPremiumFeatures(); } ``` ### 6. Log License Events Enable logging for debugging and audit trails: ```php $client = new SecureLicenseClient( ..., logger: new Logger('license'), ); ``` ### 7. Implement Graceful Degradation Don't crash the entire application on license failure: ```php try { $info = $client->validate($key, $domain); enablePremiumFeatures($info); } catch (LicenseException $e) { // Fall back to free tier enableFreeFeatures(); logLicenseIssue($e); } ``` ## API Reference ### API Endpoints The client communicates with these REST API endpoints: | Endpoint | Method | Description | | -------- | ------ | ----------- | | `/wp-json/wc-licensed-product/v1/validate` | POST | Validate license for domain | | `/wp-json/wc-licensed-product/v1/status` | POST | Get license status details | | `/wp-json/wc-licensed-product/v1/activate` | POST | Activate license on domain | ### Request Format All requests use JSON bodies: ```json { "license_key": "ABCD-1234-EFGH-5678", "domain": "example.com" } ``` ### Response Format **Successful Validation:** ```json { "valid": true, "license": { "product_id": 123, "expires_at": "2027-01-21", "version_id": null } } ``` **Error Response:** ```json { "valid": false, "error": "license_expired", "message": "This license has expired." } ``` ### Error Codes | Error Code | Exception Class | Description | | ---------- | --------------- | ----------- | | `license_not_found` | `LicenseNotFoundException` | License key doesn't exist | | `license_expired` | `LicenseExpiredException` | License past expiration date | | `license_revoked` | `LicenseRevokedException` | License manually revoked | | `license_inactive` | `LicenseInactiveException` | License not activated | | `license_invalid` | `LicenseInvalidException` | License invalid for operation | | `domain_mismatch` | `DomainMismatchException` | Domain not authorized | | `max_activations_reached` | `MaxActivationsReachedException` | Too many activations | | `activation_failed` | `ActivationFailedException` | Server error during activation | | `rate_limit_exceeded` | `RateLimitExceededException` | Too many requests | | `signature_invalid` | `SignatureException` | Response signature invalid | | `integrity_check_failed` | `IntegrityException` | Source files modified | ## Troubleshooting ### "Response is not signed by the server" - Server not configured with `WC_LICENSE_SERVER_SECRET` - Using `SecureLicenseClient` with non-signing server - **Solution:** Configure server secret or use `LicenseClient` ### "Response signature verification failed" - Different secrets on server/client - Clock skew > 5 minutes between server and client - Response modified by proxy/cache - **Solution:** Verify secrets match, sync clocks, disable response caching ### Connection Timeouts - Network issues between client and license server - **Solution:** Configure HTTP client timeout ```php $httpClient = HttpClient::create([ 'timeout' => 30, 'max_duration' => 60, ]); ``` ### Rate Limiting - Too many requests from same IP - **Solution:** Implement caching, handle `RateLimitExceededException` ```php catch (RateLimitExceededException $e) { $waitSeconds = $e->retryAfter ?? 60; // Implement exponential backoff } ```