Skip to content

Commit

Permalink
Merge pull request #297 from meilisearch/generate_token
Browse files Browse the repository at this point in the history
Adding generateTenantToken method to the client
  • Loading branch information
alallema authored Mar 7, 2022
2 parents 9a8d04b + 68cdec3 commit df48591
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 2 deletions.
3 changes: 3 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +36,7 @@ class Client
private Stats $stats;
private Tasks $tasks;
private Dumps $dumps;
private TenantToken $tenantToken;

public function __construct(
string $url,
Expand All @@ -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);
}
}
4 changes: 3 additions & 1 deletion src/Contracts/Endpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/Delegates/HandlesSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
87 changes: 87 additions & 0 deletions src/Endpoints/TenantToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace MeiliSearch\Endpoints;

use DateTime;
use MeiliSearch\Contracts\Endpoint;
use MeiliSearch\Exceptions\InvalidArgumentException;
use MeiliSearch\Http\Serialize\Json;

class TenantToken extends Endpoint
{
private function base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

private function validateTenantTokenArguments($searchRules, ?array $options = []): void
{
if (!\array_key_exists('apiKey', $options) || '' == $options['apiKey'] || \strlen($options['apiKey']) <= 8) {
throw InvalidArgumentException::emptyArgument('api key');
}
if ((!\is_array($searchRules) && !\is_object($searchRules)) || null == $searchRules) {
throw InvalidArgumentException::emptyArgument('search rules');
}
if (\array_key_exists('expiresAt', $options) && new DateTime() > $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;
}
}
10 changes: 10 additions & 0 deletions src/Exceptions/InvalidArgumentException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MeiliSearch\Exceptions;

use DateTime;
use Exception;

final class InvalidArgumentException extends Exception
Expand All @@ -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
);
}
}
2 changes: 2 additions & 0 deletions tests/Endpoints/KeysAndPermissionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand Down
154 changes: 154 additions & 0 deletions tests/Endpoints/TenantTokenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

declare(strict_types=1);

namespace Tests\Endpoints;

/* @phpstan-ignore-next-line */
use Datetime;
use MeiliSearch\Client;
use MeiliSearch\Exceptions\ApiException;
use MeiliSearch\Exceptions\InvalidArgumentException;
use Tests\TestCase;

final class TenantTokenTest extends TestCase
{
private string $privateKey;
private Client $privateClient;

protected function setUp(): void
{
parent::setUp();
$this->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' => '']);
}
}
1 change: 0 additions & 1 deletion tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ abstract class TestCase extends BaseTestCase
];

protected const INFO_KEY = [
'description' => 'test',
'actions' => ['search'],
'indexes' => ['index'],
'expiresAt' => null,
Expand Down

0 comments on commit df48591

Please sign in to comment.