diff --git a/src/Client.php b/src/Client.php index 9a8a4752..ff8cb271 100644 --- a/src/Client.php +++ b/src/Client.php @@ -15,6 +15,7 @@ use MeiliSearch\Endpoints\Keys; use MeiliSearch\Endpoints\Stats; use MeiliSearch\Endpoints\Tasks; +use MeiliSearch\Endpoints\TenantToken; use MeiliSearch\Endpoints\Version; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -35,6 +36,7 @@ class Client private Stats $stats; private Tasks $tasks; private Dumps $dumps; + private TenantToken $tenantToken; public function __construct( string $url, @@ -50,5 +52,6 @@ public function __construct( $this->tasks = new Tasks($this->http); $this->keys = new Keys($this->http); $this->dumps = new Dumps($this->http); + $this->tenantToken = new TenantToken($this->http, $apiKey); } } diff --git a/src/Contracts/Endpoint.php b/src/Contracts/Endpoint.php index e3d5d1a2..98b413c0 100644 --- a/src/Contracts/Endpoint.php +++ b/src/Contracts/Endpoint.php @@ -8,10 +8,12 @@ abstract class Endpoint { protected const PATH = ''; protected Http $http; + protected ?string $apiKey; - public function __construct(Http $http) + public function __construct(Http $http, ?string $apiKey = null) { $this->http = $http; + $this->apiKey = $apiKey; } public function show(): ?array diff --git a/src/Delegates/HandlesSystem.php b/src/Delegates/HandlesSystem.php index 56fa8ba6..e9e53175 100644 --- a/src/Delegates/HandlesSystem.php +++ b/src/Delegates/HandlesSystem.php @@ -31,4 +31,9 @@ public function stats(): array { return $this->stats->show(); } + + public function generateTenantToken($searchRules, ?array $options = []): string + { + return $this->tenantToken->generateTenantToken($searchRules, $options); + } } diff --git a/src/Endpoints/TenantToken.php b/src/Endpoints/TenantToken.php new file mode 100644 index 00000000..4126f72a --- /dev/null +++ b/src/Endpoints/TenantToken.php @@ -0,0 +1,87 @@ + $options['expiresAt']) { + throw InvalidArgumentException::dateIsExpired($options['expiresAt']); + } + } + + /** + * Generate a new tenant token. + * + * The $options parameter is an array, and the following keys are accepted: + * - apiKey: The API key parent of the token. If you leave it empty the client API Key will be used. + * - expiresAt: A DateTime when the key will expire. Note that if an expiresAt value is included it should be in UTC time. + */ + public function generateTenantToken($searchRules, ?array $options = []): string + { + if (!\array_key_exists('apiKey', $options) || '' == $options['apiKey']) { + $options['apiKey'] = $this->apiKey; + } + + // Validate every field + $this->validateTenantTokenArguments($searchRules, $options); + + $json = new Json(); + + // Standard JWT header for encryption with SHA256/HS256 algorithm + $header = [ + 'typ' => 'JWT', + 'alg' => 'HS256', + ]; + + // Add the required fields to the payload + $payload = []; + $payload['apiKeyPrefix'] = substr($options['apiKey'], 0, 8); + $payload['searchRules'] = $searchRules; + if (\array_key_exists('expiresAt', $options)) { + $payload['exp'] = $options['expiresAt']->getTimestamp(); + } + + // Serialize the Header + $jsonHeader = $json->serialize($header); + + // Serialize the Payload + $jsonPayload = $json->serialize($payload); + + // Encode Header to Base64Url String + $encodedHeader = $this->base64url_encode($jsonHeader); + + // Encode Payload to Base64Url String + $encodedPayload = $this->base64url_encode($jsonPayload); + + // Create Signature Hash + $signature = hash_hmac('sha256', $encodedHeader.'.'.$encodedPayload, $options['apiKey'], true); + + // Encode Signature to Base64Url String + $encodedSignature = $this->base64url_encode($signature); + + // Create JWT + $jwtToken = $encodedHeader.'.'.$encodedPayload.'.'.$encodedSignature; + + return $jwtToken; + } +} diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php index 5ebd3dad..55b7cc96 100644 --- a/src/Exceptions/InvalidArgumentException.php +++ b/src/Exceptions/InvalidArgumentException.php @@ -4,6 +4,7 @@ namespace MeiliSearch\Exceptions; +use DateTime; use Exception; final class InvalidArgumentException extends Exception @@ -25,4 +26,13 @@ public static function emptyArgument(string $argumentName): self null ); } + + public static function dateIsExpired(DateTime $date): self + { + return new self( + sprintf('DateTime "%s" is expired. The date expiresAt should be in the future.', $date->format('Y-m-d H:i:s')), + 400, + null + ); + } } diff --git a/tests/Endpoints/KeysAndPermissionsTest.php b/tests/Endpoints/KeysAndPermissionsTest.php index 7ba95113..3d084d6b 100644 --- a/tests/Endpoints/KeysAndPermissionsTest.php +++ b/tests/Endpoints/KeysAndPermissionsTest.php @@ -79,6 +79,7 @@ public function testGetKey(): void $response = $this->client->getKey($key->getKey()); $this->assertNotNull($response->getKey()); + $this->assertNull($response->getDescription()); $this->assertIsArray($response->getActions()); $this->assertIsArray($response->getIndexes()); $this->assertNull($response->getExpiresAt()); @@ -99,6 +100,7 @@ public function testCreateKey(): void $key = $this->client->createKey(self::INFO_KEY); $this->assertNotNull($key->getKey()); + $this->assertNull($key->getDescription()); $this->assertIsArray($key->getActions()); $this->assertSame($key->getActions(), self::INFO_KEY['actions']); $this->assertIsArray($key->getIndexes()); diff --git a/tests/Endpoints/TenantTokenTest.php b/tests/Endpoints/TenantTokenTest.php new file mode 100644 index 00000000..99be4ce1 --- /dev/null +++ b/tests/Endpoints/TenantTokenTest.php @@ -0,0 +1,154 @@ +createEmptyIndex('tenantToken'); + + $response = $this->client->getKeys(); + $this->privateKey = array_reduce($response, function ($carry, $item) { + if ($item->getDescription() && str_contains($item->getDescription(), 'Default Admin API')) { + return $item->getKey(); + } + }); + $this->privateClient = new Client($this->host, $this->privateKey); + } + + public function testGenerateTenantTokenWithSearchRulesOnly(): void + { + $promise = $this->client->index('tenantToken')->addDocuments(self::DOCUMENTS); + $this->client->waitForTask($promise['uid']); + + $token = $this->privateClient->generateTenantToken(['*']); + $tokenClient = new Client($this->host, $token); + $response = $tokenClient->index('tenantToken')->search(''); + + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertCount(7, $response->getHits()); + } + + public function testGenerateTenantTokenWithSearchRulesAsObject(): void + { + $promise = $this->client->index('tenantToken')->addDocuments(self::DOCUMENTS); + $this->client->waitForTask($promise['uid']); + + $token = $this->privateClient->generateTenantToken((object) ['*' => (object) []]); + $tokenClient = new Client($this->host, $token); + $response = $tokenClient->index('tenantToken')->search(''); + + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertCount(7, $response->getHits()); + } + + public function testGenerateTenantTokenWithFilter(): void + { + $promise = $this->client->index('tenantToken')->addDocuments(self::DOCUMENTS); + $this->client->waitForTask($promise['uid']); + $promiseFromFilter = $this->client->index('tenantToken')->updateFilterableAttributes([ + 'id', + ]); + $this->client->waitForTask($promiseFromFilter['uid']); + + $token = $this->privateClient->generateTenantToken((object) ['tenantToken' => (object) ['filter' => 'id > 10']]); + $tokenClient = new Client($this->host, $token); + $response = $tokenClient->index('tenantToken')->search(''); + + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertCount(4, $response->getHits()); + } + + public function testGenerateTenantTokenWithSearchRulesOnOneIndex(): void + { + $this->createEmptyIndex('tenantTokenDuplicate'); + + $token = $this->privateClient->generateTenantToken(['tenantToken']); + $tokenClient = new Client($this->host, $token); + $response = $tokenClient->index('tenantToken')->search(''); + + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertArrayHasKey('query', $response->toArray()); + $this->expectException(ApiException::class); + $tokenClient->index('tenantTokenDuplicate')->search(''); + } + + public function testGenerateTenantTokenWithApiKey(): void + { + $options = [ + 'apiKey' => $this->privateKey, + ]; + + $token = $this->client->generateTenantToken(['*'], $options); + $tokenClient = new Client($this->host, $token); + $response = $tokenClient->index('tenantToken')->search(''); + + $this->assertArrayHasKey('hits', $response->toArray()); + } + + public function testGenerateTenantTokenWithExpiresAt(): void + { + /* @phpstan-ignore-next-line */ + $date = new DateTime(); + $tomorrow = $date->modify('+1 day'); + $options = [ + 'expiresAt' => $tomorrow, + ]; + + $token = $this->privateClient->generateTenantToken(['*'], $options); + $tokenClient = new Client($this->host, $token); + $response = $tokenClient->index('tenantToken')->search(''); + + $this->assertArrayHasKey('hits', $response->toArray()); + } + + public function testGenerateTenantTokenWithSearchRulesEmptyArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->privateClient->generateTenantToken([]); + } + + public function testGenerateTenantTokenWithBadExpiresAt(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore-next-line */ + $date = new DateTime(); + $yesterday = $date->modify('-1 day'); + $options = [ + 'expiresAt' => $yesterday, + ]; + + $this->privateClient->generateTenantToken(['*'], $options); + } + + public function testGenerateTenantTokenWithNoApiKey(): void + { + $client = new Client($this->host); + + $this->expectException(InvalidArgumentException::class); + $client->generateTenantToken(['*']); + } + + public function testGenerateTenantTokenWithEmptyApiKey(): void + { + $client = new Client($this->host); + + $this->expectException(InvalidArgumentException::class); + $client->generateTenantToken(['*'], ['apiKey' => '']); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ac9f4503..8b1182db 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,7 +21,6 @@ abstract class TestCase extends BaseTestCase ]; protected const INFO_KEY = [ - 'description' => 'test', 'actions' => ['search'], 'indexes' => ['index'], 'expiresAt' => null,