Files
wc-licensed-product-client/docs/server-implementation.md
magdev 9f513a819e Update server implementation documentation
- Add complete API endpoints reference with request/response formats
- Add recursive key sorting for nested objects in signatures
- Add comprehensive error codes table with HTTP status codes
- Add rate limiting implementation with configurable limits
- Add complete WordPress plugin example with all handlers
- Add security sections: HTTPS, input sanitization, caching conflicts
- Update PHP version requirement to 8.3 for consistency
- Expand troubleshooting section with more scenarios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:45:59 +01:00

26 KiB

Server-Side Implementation Guide

This document describes how to implement the WooCommerce Licensed Product REST API with response signing. The API allows external applications to validate, check status, and activate software licenses.

Overview

The license server provides:

  1. REST API endpoints for license operations
  2. Response signing using HMAC-SHA256 for tamper protection
  3. Rate limiting to prevent abuse
  4. Comprehensive error responses for programmatic handling

Security Model

The signature system prevents attackers from:

  • Faking valid license responses
  • Replaying old responses (timestamp validation)
  • Tampering with response data in transit

Requirements

  • PHP 8.3 or higher (to match client requirements)
  • WordPress 6.0+ with WooCommerce
  • A server secret stored securely (not in version control)

Server Configuration

1. Store the Server Secret

Add a secret key to your WordPress configuration:

// wp-config.php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');

Generate a secure secret:

# Using OpenSSL
openssl rand -hex 32

# Or using PHP
php -r "echo bin2hex(random_bytes(32));"

IMPORTANT: Never commit this secret to version control!

2. Configure Rate Limiting (Optional)

// wp-config.php
define('WC_LICENSE_RATE_LIMIT', 30);           // Requests per window
define('WC_LICENSE_RATE_WINDOW', 60);          // Window in seconds

API Endpoints

Base URL: {site_url}/wp-json/wc-licensed-product/v1

POST /validate

Validate a license key for a specific domain.

Request:

{
    "license_key": "ABCD-1234-EFGH-5678",
    "domain": "example.com"
}

Success Response (200):

{
    "valid": true,
    "license": {
        "product_id": 123,
        "expires_at": "2027-01-21",
        "version_id": null
    }
}

Error Response (403):

{
    "valid": false,
    "error": "license_expired",
    "message": "This license has expired."
}

POST /status

Get detailed license status information.

Request:

{
    "license_key": "ABCD-1234-EFGH-5678"
}

Success Response (200):

{
    "valid": true,
    "status": "active",
    "domain": "example.com",
    "expires_at": "2027-01-21",
    "activations_count": 1,
    "max_activations": 3
}

POST /activate

Activate a license on a specific domain.

Request:

{
    "license_key": "ABCD-1234-EFGH-5678",
    "domain": "newdomain.com"
}

Success Response (200):

{
    "success": true,
    "message": "License activated successfully."
}

Error Response (403):

{
    "success": false,
    "error": "max_activations_reached",
    "message": "Maximum number of activations reached."
}

Error Codes

The API uses consistent error codes for programmatic handling:

Error Code HTTP Status Description
license_not_found 404 License key doesn't exist
license_expired 403 License past expiration date
license_revoked 403 License manually revoked by admin
license_inactive 403 License not yet activated
license_invalid 403 License invalid for requested operation
domain_mismatch 403 License not authorized for this domain
max_activations_reached 403 Maximum activations limit exceeded
activation_failed 500 Server error during activation
rate_limit_exceeded 429 Too many requests

Rate Limit Response

When rate limit is exceeded, include retry_after:

{
    "success": false,
    "error": "rate_limit_exceeded",
    "message": "Too many requests. Please try again later.",
    "retry_after": 45
}

Also include the Retry-After HTTP header.

Response Signing Implementation

Key Derivation

Each license key gets a unique signing key derived from the server secret:

/**
 * Derive a unique signing key for a license.
 *
 * @param string $licenseKey The license key
 * @param string $serverSecret The server's master secret
 * @return string The derived key (hex encoded)
 */
function derive_signing_key(string $licenseKey, string $serverSecret): string
{
    // HKDF-like key derivation
    $prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);

    return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}

Recursive Key Sorting

IMPORTANT: Response data must have keys sorted recursively for consistent signatures:

/**
 * Recursively sort array keys for consistent JSON output.
 *
 * @param array $data The data to sort
 * @return array Sorted data
 */
function sort_keys_recursive(array $data): array
{
    ksort($data);

    foreach ($data as $key => $value) {
        if (is_array($value)) {
            $data[$key] = sort_keys_recursive($value);
        }
    }

    return $data;
}

Response Signing

Sign every API response before sending:

/**
 * Sign an API response.
 *
 * @param array $responseData The response body (before JSON encoding)
 * @param string $licenseKey The license key from the request
 * @param string $serverSecret The server's master secret
 * @return array Headers to add to the response
 */
function sign_response(array $responseData, string $licenseKey, string $serverSecret): array
{
    $timestamp = time();
    $signingKey = derive_signing_key($licenseKey, $serverSecret);

    // Sort keys recursively for consistent ordering
    $sortedData = sort_keys_recursive($responseData);

    // Build signature payload
    $jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    $payload = $timestamp . ':' . $jsonBody;

    // Generate HMAC signature
    $signature = hash_hmac('sha256', $payload, $signingKey);

    return [
        'X-License-Signature' => $signature,
        'X-License-Timestamp' => (string) $timestamp,
    ];
}

Signature Algorithm

signature = HMAC-SHA256(
    key = derive_signing_key(license_key, server_secret),
    message = timestamp + ":" + canonical_json(response_body)
)

Where:

  • derive_signing_key uses HKDF-like derivation
  • canonical_json sorts keys recursively, uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
  • Result is hex-encoded (64 characters)

Response Headers

Every signed response includes:

Header Description Example
X-License-Signature HMAC-SHA256 signature (hex) a1b2c3d4... (64 chars)
X-License-Timestamp Unix timestamp when signed 1706000000

Complete WordPress Plugin Implementation

<?php
/**
 * Plugin Name: WC Licensed Product API
 * Description: License validation API with response signing
 * Version: 1.0.0
 * Requires PHP: 8.3
 */

declare(strict_types=1);

namespace WcLicensedProduct;

/**
 * License API with response signing and rate limiting.
 */
final class LicenseApi
{
    private const API_NAMESPACE = 'wc-licensed-product/v1';

    private string $serverSecret;
    private int $rateLimit;
    private int $rateWindow;

    public function __construct()
    {
        $this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
            ? WC_LICENSE_SERVER_SECRET
            : '';

        $this->rateLimit = defined('WC_LICENSE_RATE_LIMIT')
            ? (int) WC_LICENSE_RATE_LIMIT
            : 30;

        $this->rateWindow = defined('WC_LICENSE_RATE_WINDOW')
            ? (int) WC_LICENSE_RATE_WINDOW
            : 60;
    }

    public function register(): void
    {
        add_action('rest_api_init', [$this, 'registerRoutes']);
        add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
    }

    public function registerRoutes(): void
    {
        register_rest_route(self::API_NAMESPACE, '/validate', [
            'methods' => 'POST',
            'callback' => [$this, 'handleValidate'],
            'permission_callback' => [$this, 'checkRateLimit'],
            'args' => $this->getValidateArgs(),
        ]);

        register_rest_route(self::API_NAMESPACE, '/status', [
            'methods' => 'POST',
            'callback' => [$this, 'handleStatus'],
            'permission_callback' => [$this, 'checkRateLimit'],
            'args' => $this->getStatusArgs(),
        ]);

        register_rest_route(self::API_NAMESPACE, '/activate', [
            'methods' => 'POST',
            'callback' => [$this, 'handleActivate'],
            'permission_callback' => [$this, 'checkRateLimit'],
            'args' => $this->getActivateArgs(),
        ]);
    }

    // =========================================================================
    // Request Validation
    // =========================================================================

    private function getValidateArgs(): array
    {
        return [
            'license_key' => [
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field',
                'validate_callback' => [$this, 'validateLicenseKeyFormat'],
            ],
            'domain' => [
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field',
                'validate_callback' => [$this, 'validateDomainFormat'],
            ],
        ];
    }

    private function getStatusArgs(): array
    {
        return [
            'license_key' => [
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field',
                'validate_callback' => [$this, 'validateLicenseKeyFormat'],
            ],
        ];
    }

    private function getActivateArgs(): array
    {
        return $this->getValidateArgs(); // Same as validate
    }

    public function validateLicenseKeyFormat($value): bool
    {
        return is_string($value) && strlen($value) <= 64 && strlen($value) >= 8;
    }

    public function validateDomainFormat($value): bool
    {
        return is_string($value) && strlen($value) <= 255 && strlen($value) >= 1;
    }

    // =========================================================================
    // Rate Limiting
    // =========================================================================

    public function checkRateLimit(\WP_REST_Request $request): bool|\WP_Error
    {
        $ip = $this->getClientIp();
        $key = 'license_rate_' . md5($ip);

        $data = get_transient($key);

        if ($data === false) {
            // First request in window
            set_transient($key, ['count' => 1, 'start' => time()], $this->rateWindow);
            return true;
        }

        if ($data['count'] >= $this->rateLimit) {
            $retryAfter = $this->rateWindow - (time() - $data['start']);

            return new \WP_Error(
                'rate_limit_exceeded',
                'Too many requests. Please try again later.',
                [
                    'status' => 429,
                    'retry_after' => max(1, $retryAfter),
                ]
            );
        }

        // Increment counter
        $data['count']++;
        set_transient($key, $data, $this->rateWindow - (time() - $data['start']));

        return true;
    }

    private function getClientIp(): string
    {
        $headers = [
            'HTTP_CF_CONNECTING_IP',     // Cloudflare
            'HTTP_X_FORWARDED_FOR',      // Proxy
            'HTTP_X_REAL_IP',            // Nginx
            'REMOTE_ADDR',               // Direct
        ];

        foreach ($headers as $header) {
            if (!empty($_SERVER[$header])) {
                $ip = $_SERVER[$header];
                // Handle comma-separated list (X-Forwarded-For)
                if (str_contains($ip, ',')) {
                    $ip = trim(explode(',', $ip)[0]);
                }
                if (filter_var($ip, FILTER_VALIDATE_IP)) {
                    return $ip;
                }
            }
        }

        return '0.0.0.0';
    }

    // =========================================================================
    // API Handlers
    // =========================================================================

    public function handleValidate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
    {
        $licenseKey = $request->get_param('license_key');
        $domain = $request->get_param('domain');

        // TODO: Replace with your license lookup logic
        $license = $this->findLicense($licenseKey);

        if ($license === null) {
            return $this->errorResponse('license_not_found', 'License key not found.', 404);
        }

        if ($license['status'] === 'revoked') {
            return $this->errorResponse('license_revoked', 'This license has been revoked.', 403);
        }

        if ($license['status'] === 'expired' || $this->isExpired($license)) {
            return $this->errorResponse('license_expired', 'This license has expired.', 403);
        }

        if ($license['status'] === 'inactive') {
            return $this->errorResponse('license_inactive', 'This license is inactive.', 403);
        }

        if (!empty($license['domain']) && $license['domain'] !== $domain) {
            return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403);
        }

        return new \WP_REST_Response([
            'valid' => true,
            'license' => [
                'product_id' => $license['product_id'],
                'expires_at' => $license['expires_at'],
                'version_id' => $license['version_id'] ?? null,
            ],
        ], 200);
    }

    public function handleStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
    {
        $licenseKey = $request->get_param('license_key');

        $license = $this->findLicense($licenseKey);

        if ($license === null) {
            return $this->errorResponse('license_not_found', 'License key not found.', 404);
        }

        $status = $license['status'];
        if ($status === 'active' && $this->isExpired($license)) {
            $status = 'expired';
        }

        return new \WP_REST_Response([
            'valid' => $status === 'active',
            'status' => $status,
            'domain' => $license['domain'] ?? '',
            'expires_at' => $license['expires_at'],
            'activations_count' => $license['activations_count'] ?? 0,
            'max_activations' => $license['max_activations'] ?? 1,
        ], 200);
    }

    public function handleActivate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
    {
        $licenseKey = $request->get_param('license_key');
        $domain = $request->get_param('domain');

        $license = $this->findLicense($licenseKey);

        if ($license === null) {
            return $this->errorResponse('license_not_found', 'License key not found.', 404);
        }

        if ($license['status'] === 'revoked') {
            return $this->errorResponse('license_invalid', 'This license is not valid.', 403);
        }

        if ($this->isExpired($license)) {
            return $this->errorResponse('license_invalid', 'This license has expired.', 403);
        }

        // Check if already activated on this domain
        if ($license['domain'] === $domain) {
            return new \WP_REST_Response([
                'success' => true,
                'message' => 'License is already activated for this domain.',
            ], 200);
        }

        // Check activation limit
        $activations = $license['activations_count'] ?? 0;
        $maxActivations = $license['max_activations'] ?? 1;

        if ($activations >= $maxActivations && $license['domain'] !== $domain) {
            return $this->errorResponse(
                'max_activations_reached',
                'Maximum number of activations reached.',
                403
            );
        }

        // TODO: Replace with your activation logic
        $activated = $this->activateLicense($licenseKey, $domain);

        if (!$activated) {
            return $this->errorResponse('activation_failed', 'Failed to activate license.', 500);
        }

        return new \WP_REST_Response([
            'success' => true,
            'message' => 'License activated successfully.',
        ], 200);
    }

    private function errorResponse(string $code, string $message, int $status): \WP_REST_Response
    {
        $data = [
            'valid' => false,
            'success' => false,
            'error' => $code,
            'message' => $message,
        ];

        if ($code === 'rate_limit_exceeded') {
            $data['retry_after'] = $this->rateWindow;
        }

        return new \WP_REST_Response($data, $status);
    }

    // =========================================================================
    // Response Signing
    // =========================================================================

    public function signResponse(
        \WP_REST_Response $response,
        \WP_REST_Server $server,
        \WP_REST_Request $request
    ): \WP_REST_Response {
        if (!$this->shouldSign($request)) {
            return $response;
        }

        $data = $response->get_data();
        $licenseKey = $request->get_param('license_key');

        if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
            return $response;
        }

        $headers = $this->createSignatureHeaders($data, $licenseKey);

        foreach ($headers as $name => $value) {
            $response->header($name, $value);
        }

        return $response;
    }

    private function shouldSign(\WP_REST_Request $request): bool
    {
        $route = $request->get_route();

        return str_starts_with($route, '/' . self::API_NAMESPACE . '/');
    }

    private function createSignatureHeaders(array $data, string $licenseKey): array
    {
        $timestamp = time();
        $signingKey = $this->deriveKey($licenseKey);

        // Sort keys recursively for consistent ordering
        $sortedData = $this->sortKeysRecursive($data);

        $payload = $timestamp . ':' . json_encode(
            $sortedData,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );

        return [
            'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
            'X-License-Timestamp' => (string) $timestamp,
        ];
    }

    private function sortKeysRecursive(array $data): array
    {
        ksort($data);

        foreach ($data as $key => $value) {
            if (is_array($value)) {
                $data[$key] = $this->sortKeysRecursive($value);
            }
        }

        return $data;
    }

    private function deriveKey(string $licenseKey): string
    {
        $prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);

        return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
    }

    // =========================================================================
    // License Storage (Replace with your implementation)
    // =========================================================================

    /**
     * Find a license by key.
     *
     * TODO: Replace with your database/WooCommerce lookup
     *
     * @return array|null License data or null if not found
     */
    private function findLicense(string $licenseKey): ?array
    {
        // Example structure - replace with actual database lookup
        // return [
        //     'license_key' => 'ABCD-1234-EFGH-5678',
        //     'product_id' => 123,
        //     'status' => 'active',  // active, inactive, expired, revoked
        //     'domain' => 'example.com',
        //     'expires_at' => '2027-01-21',  // or null for lifetime
        //     'version_id' => null,
        //     'activations_count' => 1,
        //     'max_activations' => 3,
        // ];

        return null; // Replace with actual implementation
    }

    /**
     * Activate a license on a domain.
     *
     * TODO: Replace with your database update logic
     */
    private function activateLicense(string $licenseKey, string $domain): bool
    {
        // Update license record with new domain
        // Increment activations_count
        // Set status to 'active'

        return false; // Replace with actual implementation
    }

    private function isExpired(?array $license): bool
    {
        if ($license === null || empty($license['expires_at'])) {
            return false; // Lifetime license
        }

        return strtotime($license['expires_at']) < time();
    }
}

// Initialize plugin
add_action('plugins_loaded', function () {
    (new LicenseApi())->register();
});

Testing

Verify Signing Works

// Test script
$serverSecret = 'test-secret-key-for-development-only';
$licenseKey = 'ABCD-1234-EFGH-5678';
$responseData = [
    'valid' => true,
    'license' => [
        'product_id' => 123,
        'expires_at' => '2027-01-21',
        'version_id' => null,
    ],
];

$headers = sign_response($responseData, $licenseKey, $serverSecret);

echo "X-License-Signature: " . $headers['X-License-Signature'] . "\n";
echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n";

Test with Client

use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Magdev\WcLicensedProductClient\Security\SignatureException;
use Symfony\Component\HttpClient\HttpClient;

$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://your-site.com',
    serverSecret: 'same-secret-as-server',
);

try {
    $info = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
    echo "License valid! Product ID: " . $info->productId;
} catch (SignatureException $e) {
    echo "Signature verification failed - possible tampering!";
}

Verify Signature Manually

# Using curl to test the API
curl -X POST https://your-site.com/wp-json/wc-licensed-product/v1/validate \
  -H "Content-Type: application/json" \
  -d '{"license_key":"ABCD-1234-EFGH-5678","domain":"example.com"}' \
  -i

# Check for X-License-Signature and X-License-Timestamp headers

Security Considerations

Timestamp Tolerance

The client allows a 5-minute window for timestamp verification. This:

  • Prevents replay attacks (old responses rejected)
  • Allows for reasonable clock skew between server and client

Adjust if needed:

// Client-side: custom tolerance
use Magdev\WcLicensedProductClient\Security\ResponseSignature;

$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes

Secret Key Rotation

To rotate the server secret:

  1. Deploy new secret to server
  2. Update client configurations
  3. Old signatures become invalid immediately

For zero-downtime rotation, implement versioned secrets:

// Server supports both old and new secrets during transition
$secrets = [
    'v2' => 'new-secret',
    'v1' => 'old-secret',
];

// Add version to signature header
$response->header('X-License-Signature-Version', 'v2');

Sign Error Responses

Sign error responses too! Otherwise attackers could craft fake error messages:

// Both success and error responses are signed by the filter
// No additional code needed - the signResponse filter handles all responses

HTTPS Required

Always serve the API over HTTPS:

// In your plugin, enforce HTTPS
add_action('rest_api_init', function() {
    if (!is_ssl() && !defined('WP_DEBUG') || !WP_DEBUG) {
        wp_die('License API requires HTTPS');
    }
});

Input Sanitization

Always sanitize and validate input:

// The WordPress REST API args handle this automatically
// But add additional validation as needed
$licenseKey = sanitize_text_field($request->get_param('license_key'));
$domain = sanitize_text_field($request->get_param('domain'));

// Validate format
if (!preg_match('/^[A-Z0-9\-]{8,64}$/i', $licenseKey)) {
    return new WP_Error('invalid_license_key', 'Invalid license key format');
}

Troubleshooting

"Response is not signed by the server"

  • Server not configured with WC_LICENSE_SERVER_SECRET
  • Filter not registered (check plugin activation)
  • Route mismatch (check shouldSign() paths)

"Response signature verification failed"

  • Different secrets on server/client
  • Clock skew > 5 minutes
  • Response body modified after signing (e.g., by caching plugin)
  • JSON encoding differences (check recursive ksort and flags)
  • Nested objects not sorted consistently

Debugging

Enable detailed logging:

// Server-side debugging
add_filter('rest_post_dispatch', function($response, $server, $request) {
    if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) {
        return $response;
    }

    $data = $response->get_data();
    $sortedData = sort_keys_recursive($data);
    $json = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

    error_log('License API Debug:');
    error_log('Route: ' . $request->get_route());
    error_log('License Key: ' . $request->get_param('license_key'));
    error_log('Sorted JSON: ' . $json);
    error_log('Timestamp: ' . time());

    return $response;
}, 5, 3);
// Client-side: use a PSR-3 logger
$client = new SecureLicenseClient(
    // ...
    logger: new YourDebugLogger(),
);

Rate Limit Issues

If legitimate users hit rate limits:

// Increase limits in wp-config.php
define('WC_LICENSE_RATE_LIMIT', 60);  // 60 requests
define('WC_LICENSE_RATE_WINDOW', 60); // per minute

// Or implement per-license key rate limiting instead of per-IP
$key = 'license_rate_' . md5($licenseKey);

Caching Conflicts

If a caching plugin modifies responses:

// Exclude license API from caching
add_action('rest_api_init', function() {
    if (str_contains($_SERVER['REQUEST_URI'], '/wc-licensed-product/')) {
        // Disable page caching
        define('DONOTCACHEPAGE', true);

        // Set no-cache headers
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Pragma: no-cache');
    }
});