From 78c8ec5c811e349753fa465533bc36a508bf8902 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Wed, 1 Apr 2026 12:07:22 +0200 Subject: [PATCH 1/3] feat: generateLicenseToken using private key assertion --- src/Customer/LicenseTokenClient.php | 185 ++++++++++- src/SupertabConnect.php | 28 ++ tests/Customer/LicenseTokenClientTest.php | 355 ++++++++++++++++++++++ tests/SupertabConnectTest.php | 45 +++ 4 files changed, 597 insertions(+), 16 deletions(-) diff --git a/src/Customer/LicenseTokenClient.php b/src/Customer/LicenseTokenClient.php index f330d8d..d79e987 100644 --- a/src/Customer/LicenseTokenClient.php +++ b/src/Customer/LicenseTokenClient.php @@ -4,6 +4,7 @@ namespace Supertab\Connect\Customer; +use Firebase\JWT\JWT; use Supertab\Connect\Exception\SupertabConnectException; use Supertab\Connect\Http\HttpClientInterface; @@ -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' => $resourceUrl, + ]); + + $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) { @@ -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.' + ); } /** @@ -167,8 +295,23 @@ 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 $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(), @@ -176,7 +319,17 @@ private function requestToken( $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']}" : ''; diff --git a/src/SupertabConnect.php b/src/SupertabConnect.php index 386151e..100d34d 100644 --- a/src/SupertabConnect.php +++ b/src/SupertabConnect.php @@ -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. diff --git a/tests/Customer/LicenseTokenClientTest.php b/tests/Customer/LicenseTokenClientTest.php index 1fc04f1..02a79a3 100644 --- a/tests/Customer/LicenseTokenClientTest.php +++ b/tests/Customer/LicenseTokenClientTest.php @@ -279,6 +279,329 @@ public function test_uses_path_only_url_pattern_as_resource_param(): void $this->assertSame($fakeToken, $token); } + // --- generateLicenseToken tests --- + + public function test_generate_license_token_with_ec_key(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $ecKey = $this->generateEcKey(); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->never())->method('get'); + $httpClient->expects($this->once()) + ->method('post') + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $token = $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + + $this->assertSame($fakeToken, $token); + } + + public function test_generate_license_token_with_rsa_key(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $rsaKey = $this->generateRsaKey(); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $token = $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $rsaKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + + $this->assertSame($fakeToken, $token); + } + + public function test_generate_license_token_sends_correct_form_body(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $ecKey = $this->generateEcKey(); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->with( + $this->equalTo('http://127.0.0.1:8787/token'), + $this->callback(function (string $body) { + parse_str($body, $params); + + return ($params['grant_type'] ?? null) === 'rsl' + && ($params['client_assertion_type'] ?? null) === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + && isset($params['client_assertion']) + && isset($params['license']) + && ($params['resource'] ?? null) === self::RESOURCE_URL; + }), + $this->anything(), + ) + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + } + + public function test_generate_license_token_sends_no_authorization_header(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $ecKey = $this->generateEcKey(); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->with( + $this->anything(), + $this->anything(), + $this->callback(function (array $headers) { + return ! isset($headers['Authorization']); + }), + ) + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + } + + public function test_generate_license_token_jwt_claims_with_ec_key(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $ecKey = $this->generateEcKey(); + $kid = 'my-key-id'; + + $capturedBody = null; + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->with( + $this->anything(), + $this->callback(function (string $body) use (&$capturedBody) { + $capturedBody = $body; + + return true; + }), + $this->anything(), + ) + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $client->generateLicenseToken( + self::CLIENT_ID, + $kid, + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + + // Decode the client_assertion JWT + parse_str($capturedBody, $params); + $assertion = $params['client_assertion']; + $segments = explode('.', $assertion); + $this->assertCount(3, $segments); + + $header = json_decode($this->base64UrlDecode($segments[0]), true); + $payload = json_decode($this->base64UrlDecode($segments[1]), true); + + // Verify header + $this->assertSame('ES256', $header['alg']); + $this->assertSame($kid, $header['kid']); + + // Verify payload claims + $this->assertSame(self::CLIENT_ID, $payload['iss']); + $this->assertSame(self::CLIENT_ID, $payload['sub']); + $this->assertSame('http://127.0.0.1:8787/token', $payload['aud']); + $this->assertIsInt($payload['iat']); + $this->assertEqualsWithDelta(time(), $payload['iat'], 5); + $this->assertSame($payload['iat'] + 300, $payload['exp']); + } + + public function test_generate_license_token_jwt_claims_with_rsa_key(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $rsaKey = $this->generateRsaKey(); + + $capturedBody = null; + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->with( + $this->anything(), + $this->callback(function (string $body) use (&$capturedBody) { + $capturedBody = $body; + + return true; + }), + $this->anything(), + ) + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $client->generateLicenseToken( + self::CLIENT_ID, + 'rsa-key-1', + $rsaKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + + parse_str($capturedBody, $params); + $segments = explode('.', $params['client_assertion']); + $header = json_decode($this->base64UrlDecode($segments[0]), true); + + $this->assertSame('RS256', $header['alg']); + $this->assertSame('rsa-key-1', $header['kid']); + } + + public function test_generate_license_token_parses_license_xml_and_matches(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $ecKey = $this->generateEcKey(); + + // Use path-only pattern — should still match + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->with( + $this->equalTo('http://127.0.0.1:8787/token'), + $this->anything(), + $this->anything(), + ) + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + $token = $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML_PATH_ONLY, + ); + + $this->assertSame($fakeToken, $token); + } + + public function test_generate_license_token_caches_result(): void + { + $fakeToken = $this->createFakeJwt(['exp' => time() + 3600]); + $ecKey = $this->generateEcKey(); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $client = new LicenseTokenClient($httpClient); + + $token1 = $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + $token2 = $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + + $this->assertSame($fakeToken, $token1); + $this->assertSame($fakeToken, $token2); + } + + public function test_generate_license_token_throws_on_invalid_key(): void + { + $httpClient = $this->createMock(HttpClientInterface::class); + $client = new LicenseTokenClient($httpClient); + + $this->expectException(SupertabConnectException::class); + $this->expectExceptionMessage('Unsupported private key format'); + + $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + 'not-a-valid-pem-key', + self::RESOURCE_URL, + self::LICENSE_XML, + ); + } + + public function test_generate_license_token_throws_on_no_matching_content(): void + { + $ecKey = $this->generateEcKey(); + $xml = <<<'XML' + + + + + + +XML; + + $httpClient = $this->createMock(HttpClientInterface::class); + $client = new LicenseTokenClient($httpClient); + + $this->expectException(SupertabConnectException::class); + $this->expectExceptionMessage('No element in license.xml matches resource URL'); + + $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + $xml, + ); + } + + public function test_generate_license_token_throws_on_endpoint_failure(): void + { + $ecKey = $this->generateEcKey(); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('post') + ->willReturn(['statusCode' => 500, 'body' => 'Internal Server Error']); + + $client = new LicenseTokenClient($httpClient); + + $this->expectException(SupertabConnectException::class); + $this->expectExceptionMessage('Failed to obtain license token: 500'); + + $client->generateLicenseToken( + self::CLIENT_ID, + 'key-1', + $ecKey, + self::RESOURCE_URL, + self::LICENSE_XML, + ); + } + + // --- Helper methods --- + /** * Create a fake JWT with the given payload for testing. * @@ -297,4 +620,36 @@ private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } + + private function base64UrlDecode(string $data): string + { + $remainder = strlen($data) % 4; + if ($remainder !== 0) { + $data .= str_repeat('=', 4 - $remainder); + } + + return base64_decode(strtr($data, '-_', '+/'), true); + } + + private function generateEcKey(): string + { + $key = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + openssl_pkey_export($key, $pem); + + return $pem; + } + + private function generateRsaKey(): string + { + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($key, $pem); + + return $pem; + } } diff --git a/tests/SupertabConnectTest.php b/tests/SupertabConnectTest.php index a9796d0..30837e3 100644 --- a/tests/SupertabConnectTest.php +++ b/tests/SupertabConnectTest.php @@ -8,6 +8,7 @@ use Supertab\Connect\Bot\BotDetectorInterface; use Supertab\Connect\Enum\EnforcementMode; use Supertab\Connect\Enum\HandlerAction; +use Supertab\Connect\Http\HttpClientInterface; use Supertab\Connect\Http\RequestContext; use Supertab\Connect\Result\AllowResult; use Supertab\Connect\Result\BlockResult; @@ -240,6 +241,50 @@ public function test_request_context_constructor(): void $this->assertSame('TestBot/1.0', $ctx->userAgent); } + // --- generateLicenseToken --- + + public function test_generate_license_token_static_method(): void + { + $licenseXml = <<<'XML' + + + + + + + + +XML; + + $ecKey = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + openssl_pkey_export($ecKey, $ecKeyPem); + + // Create a fake JWT that the mock will return + $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'ES256', 'typ' => 'JWT'])), '+/', '-_'), '='); + $body = rtrim(strtr(base64_encode(json_encode(['exp' => time() + 3600])), '+/', '-_'), '='); + $sig = rtrim(strtr(base64_encode('fake'), '+/', '-_'), '='); + $fakeToken = "{$header}.{$body}.{$sig}"; + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('post') + ->willReturn(['statusCode' => 200, 'body' => json_encode(['access_token' => $fakeToken])]); + + $token = SupertabConnect::generateLicenseToken( + clientId: 'test-client', + kid: 'key-1', + privateKeyPem: $ecKeyPem, + resourceUrl: 'http://127.0.0.1:7676/article/my-article', + licenseXml: $licenseXml, + httpClient: $httpClient, + ); + + $this->assertSame($fakeToken, $token); + } + public function test_request_context_constructor_with_new_headers(): void { $ctx = new RequestContext( From 0f31b369b26b8157e4ebd8cff2c6e7fef6835181 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Wed, 1 Apr 2026 12:30:21 +0200 Subject: [PATCH 2/3] update readme --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b732a7..c3c2548 100644 --- a/README.md +++ b/README.md @@ -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; @@ -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). @@ -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). From 0a4fc9dd22f4d85e2125529a450e19f3e3723f32 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Wed, 1 Apr 2026 12:42:20 +0200 Subject: [PATCH 3/3] pass urlPattern instead of full resource --- src/Customer/LicenseTokenClient.php | 2 +- tests/Customer/LicenseTokenClientTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Customer/LicenseTokenClient.php b/src/Customer/LicenseTokenClient.php index d79e987..95d2956 100644 --- a/src/Customer/LicenseTokenClient.php +++ b/src/Customer/LicenseTokenClient.php @@ -127,7 +127,7 @@ public function generateLicenseToken( 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 'client_assertion' => $clientAssertion, 'license' => $matchedContent->licenseXml, - 'resource' => $resourceUrl, + 'resource' => $matchedContent->urlPattern, ]); $headers = [ diff --git a/tests/Customer/LicenseTokenClientTest.php b/tests/Customer/LicenseTokenClientTest.php index 02a79a3..766a020 100644 --- a/tests/Customer/LicenseTokenClientTest.php +++ b/tests/Customer/LicenseTokenClientTest.php @@ -343,7 +343,7 @@ public function test_generate_license_token_sends_correct_form_body(): void && ($params['client_assertion_type'] ?? null) === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' && isset($params['client_assertion']) && isset($params['license']) - && ($params['resource'] ?? null) === self::RESOURCE_URL; + && ($params['resource'] ?? null) === 'http://127.0.0.1:7676/*'; }), $this->anything(), )