Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding generateTenantToken method to the client #297

Merged
merged 15 commits into from
Mar 7, 2022
Merged
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
alallema marked this conversation as resolved.
Show resolved Hide resolved
{
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about add some validation about this UTC requirement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure we want to force the user to use the UTC? Rather than putting a note of information?

*/
public function generateTenantToken($searchRules, ?array $options = []): string
alallema marked this conversation as resolved.
Show resolved Hide resolved
{
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 */
brunoocasali marked this conversation as resolved.
Show resolved Hide resolved
$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 */
alallema marked this conversation as resolved.
Show resolved Hide resolved
$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);
alallema marked this conversation as resolved.
Show resolved Hide resolved

$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