Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ if ($result instanceof BlockResult) {
// Token is valid — serve content
```

**Bot — obtain a license token:**
**Bot — obtain a license token (client credentials):**

```php
use Supertab\Connect\SupertabConnect;
Expand All @@ -60,6 +60,32 @@ curl_setopt_array($ch, [
$response = curl_exec($ch);
```

**Bot — generate a license token (private key JWT assertion):**

```php
use Supertab\Connect\SupertabConnect;

// 1. Fetch the publisher's license.xml
$licenseXml = file_get_contents('https://example.com/license.xml');

// 2. Generate a token using your private key
$token = SupertabConnect::generateLicenseToken(
clientId: 'urn:stc:customer:system:your-system-id',
kid: 'your-key-id',
privateKeyPem: file_get_contents('/path/to/private-key.pem'),
resourceUrl: 'https://example.com/article/my-slug',
licenseXml: $licenseXml,
);

// 3. Access content with the token
$ch = curl_init('https://example.com/article/my-slug');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: License {$token}"],
]);
$response = curl_exec($ch);
```

## Enforcement Modes

The `EnforcementMode` enum controls how `handleRequest()` responds to detected bots when a token is absent or invalid. Non-bot requests without a token are always allowed regardless of mode. Requests with an invalid token are always blocked (except in DISABLED mode).
Expand Down Expand Up @@ -213,6 +239,31 @@ The SDK handles the full RSL flow automatically:
3. POSTs to the token endpoint using OAuth2 `client_credentials`
4. Caches the token in memory (keyed by `clientId:resourceUrl`, reused until 30s before expiry)

### `SupertabConnect::generateLicenseToken()` (static)

Generates a license token using a private key JWT assertion. The caller provides the license XML (typically fetched from the publisher's `license.xml` endpoint), and the SDK parses it, matches the resource URL, and authenticates using a signed JWT client assertion instead of a shared secret.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `clientId` | `string` | Yes | — | Your system's client ID (`urn:stc:customer:system:...`) |
| `kid` | `string` | Yes | — | Key ID identifying your registered public key |
| `privateKeyPem` | `string` | Yes | — | Private key in PEM format (RSA or P-256 EC) |
| `resourceUrl` | `string` | Yes | — | Full URL of the protected resource |
| `licenseXml` | `string` | Yes | — | The publisher's license XML content |
| `debug` | `bool` | No | `false` | Emit debug logs |
| `httpClient` | `?HttpClientInterface` | No | `null` | Inject a custom HTTP client |

**Returns:** `string` (the access token). Throws `SupertabConnectException` on failure.

The SDK handles the token exchange automatically:

1. Parses the license XML and finds the best matching content block for the resource URL
2. Creates a short-lived JWT assertion (5 min) signed with your private key
3. POSTs to the token endpoint with `grant_type=rsl` and the JWT assertion
4. Caches the token in memory (keyed by `clientId:resourceUrl`, reused until 30s before expiry)

Supports ES256 (P-256 EC) and RS256 (RSA) private keys. The algorithm is detected automatically from the key.

### `SupertabConnect::setBaseUrl()` (static)

Sets the global default base URL for all API requests. Useful for sandbox/testing environments. This affects all subsequent calls (both instance and static methods).
Expand Down
185 changes: 169 additions & 16 deletions src/Customer/LicenseTokenClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Supertab\Connect\Customer;

use Firebase\JWT\JWT;
use Supertab\Connect\Exception\SupertabConnectException;
use Supertab\Connect\Http\HttpClientInterface;

Expand Down Expand Up @@ -46,7 +47,111 @@ public function obtainLicenseToken(
}

// 3. Parse and match
$contentBlocks = LicenseXmlParser::parseContentElements($xml, $this->debug);
$matchedContent = $this->resolveContentMatch($xml, $resourceUrl);

// 4. Request token
$tokenEndpoint = rtrim($matchedContent->server, '/') . '/token';

if ($this->debug) {
error_log("[SupertabConnect] Requesting license token from {$tokenEndpoint}");
}

$token = $this->requestToken(
$tokenEndpoint,
$clientId,
$clientSecret,
$matchedContent->licenseXml,
$matchedContent->urlPattern,
);

// 5. Cache token
$this->cacheToken($cacheKey, $token);

return $token;
}

/**
* Generate a license token using private key JWT assertion.
*
* The caller provides the license XML content (typically fetched from the
* publisher's license.xml endpoint). The SDK parses it, matches the resource
* URL, and requests a token using a signed JWT client assertion.
*
* @throws SupertabConnectException on any failure
*/
public function generateLicenseToken(
string $clientId,
string $kid,
string $privateKeyPem,
string $resourceUrl,
string $licenseXml,
): string {
// 1. Check cache
$cacheKey = "{$clientId}:{$resourceUrl}";
$cached = $this->cache->get($cacheKey, $this->debug);
if ($cached !== null) {
return $cached;
}

// 2. Parse and match
$matchedContent = $this->resolveContentMatch($licenseXml, $resourceUrl);

// 3. Build token endpoint
$tokenEndpoint = rtrim($matchedContent->server, '/') . '/token';

if ($this->debug) {
error_log("[SupertabConnect] Requesting license token from {$tokenEndpoint} using JWT assertion");
}

// 4. Detect key algorithm and create JWT assertion
$alg = self::detectKeyAlgorithm($privateKeyPem);

if ($this->debug) {
error_log("[SupertabConnect] Detected key algorithm: {$alg}");
}

$now = time();
$payload = [
'iss' => $clientId,
'sub' => $clientId,
'aud' => $tokenEndpoint,
'iat' => $now,
'exp' => $now + 300,
];

$clientAssertion = JWT::encode($payload, $privateKeyPem, $alg, $kid);

// 5. POST to token endpoint
$body = http_build_query([
'grant_type' => 'rsl',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $clientAssertion,
'license' => $matchedContent->licenseXml,
'resource' => $matchedContent->urlPattern,
]);

$headers = [
'Content-Type' => 'application/x-www-form-urlencoded',
'Accept' => 'application/json',
];

$response = $this->postToTokenEndpoint($tokenEndpoint, $body, $headers);
$token = $this->parseTokenResponse($response);

// 6. Cache token
$this->cacheToken($cacheKey, $token);

return $token;
}

/**
* Parse license XML and find the best matching content block for a resource URL.
*
* @throws SupertabConnectException
*/
private function resolveContentMatch(string $licenseXml, string $resourceUrl): ContentBlock
{
$contentBlocks = LicenseXmlParser::parseContentElements($licenseXml, $this->debug);

if ($contentBlocks === []) {
if ($this->debug) {
Expand Down Expand Up @@ -76,25 +181,48 @@ public function obtainLicenseToken(
error_log("[SupertabConnect] Using license XML: {$matchedContent->licenseXml}");
}

// 4. Request token
$tokenEndpoint = rtrim($matchedContent->server, '/') . '/token';
return $matchedContent;
}

if ($this->debug) {
error_log("[SupertabConnect] Requesting license token from {$tokenEndpoint}");
/**
* Detect the JWT signing algorithm from a PEM-encoded private key.
*
* @throws SupertabConnectException if the key format is unsupported
*/
private static function detectKeyAlgorithm(string $privateKeyPem): string
{
$key = openssl_pkey_get_private($privateKeyPem);
if ($key === false) {
throw new SupertabConnectException(
'Unsupported private key format. Expected RSA or P-256 EC private key.'
);
}

$token = $this->requestToken(
$tokenEndpoint,
$clientId,
$clientSecret,
$matchedContent->licenseXml,
$matchedContent->urlPattern,
);
$details = openssl_pkey_get_details($key);
if ($details === false) {
throw new SupertabConnectException(
'Unsupported private key format. Expected RSA or P-256 EC private key.'
);
}

// 5. Cache token
$this->cacheToken($cacheKey, $token);
if ($details['type'] === OPENSSL_KEYTYPE_EC) {
$curveName = $details['ec']['curve_name'] ?? '';
if ($curveName !== 'prime256v1') {
throw new SupertabConnectException(
"Unsupported EC curve: {$curveName}. Expected prime256v1 (P-256)."
);
}

return $token;
return 'ES256';
}

if ($details['type'] === OPENSSL_KEYTYPE_RSA) {
return 'RS256';
}

throw new SupertabConnectException(
'Unsupported private key format. Expected RSA or P-256 EC private key.'
);
}

/**
Expand Down Expand Up @@ -167,16 +295,41 @@ private function requestToken(
'Authorization' => 'Basic ' . base64_encode("{$clientId}:{$clientSecret}"),
];

$response = $this->postToTokenEndpoint($tokenEndpoint, $body, $headers);

return $this->parseTokenResponse($response);
}

/**
* POST to a token endpoint with error wrapping.
*
* @param array<string, string> $headers
* @return array{statusCode: int, body: string}
*
* @throws SupertabConnectException
*/
private function postToTokenEndpoint(string $tokenEndpoint, string $body, array $headers): array
{
try {
$response = $this->httpClient->post($tokenEndpoint, $body, $headers);
return $this->httpClient->post($tokenEndpoint, $body, $headers);
} catch (\Throwable $e) {
throw new SupertabConnectException(
'Failed to obtain license token: ' . $e->getMessage(),
0,
$e,
);
}
}

/**
* Parse a token endpoint response and extract the access_token.
*
* @param array{statusCode: int, body: string} $response
*
* @throws SupertabConnectException
*/
private function parseTokenResponse(array $response): string
{
if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) {
$errorBody = $response['body'] !== '' ? " - {$response['body']}" : '';

Expand Down
28 changes: 28 additions & 0 deletions src/SupertabConnect.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,34 @@ public static function obtainLicenseToken(
return $client->obtainLicenseToken($clientId, $clientSecret, $resourceUrl);
}

/**
* Generate a license token using private key JWT assertion.
*
* The caller provides the license XML content (typically fetched from the
* publisher's license.xml endpoint). The SDK parses it, matches the resource
* URL, and requests a token using a signed JWT client assertion.
*
* Does not require a SupertabConnect instance.
*
* @throws SupertabConnectException on any failure
*/
public static function generateLicenseToken(
string $clientId,
string $kid,
string $privateKeyPem,
string $resourceUrl,
string $licenseXml,
bool $debug = false,
?HttpClientInterface $httpClient = null,
): string {
$client = new LicenseTokenClient(
httpClient: $httpClient ?? new HttpClient,
debug: $debug,
);

return $client->generateLicenseToken($clientId, $kid, $privateKeyPem, $resourceUrl, $licenseXml);
}

/**
* Handle an incoming request by extracting the license token, verifying it,
* recording analytics events, and applying enforcement mode with bot detection.
Expand Down
Loading