feat(auth): implement HMAC authentication middleware, nonce and credentials value objects
This commit is contained in:
parent
c5f7199b9c
commit
a2e810c2eb
4 changed files with 230 additions and 0 deletions
177
src/Auth/ApiMandatoryHeadersMiddleware.php
Normal file
177
src/Auth/ApiMandatoryHeadersMiddleware.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Uwil\ArcadiaApiPhpClient\Auth;
|
||||
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\UriInterface as Uri;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Uwil\ArcadiaApiPhpClient\Lib\LibMiddleware;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ApiMandatoryHeadersMiddleware implements LibMiddleware
|
||||
{
|
||||
public const string HANDLER_NAME = 'Arcadia API request Signature';
|
||||
|
||||
private const string SHA256_EMPTY_STRING_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
public const string HEADER_CLIENT_IDENTIFIER = 'X-Client-Identifier';
|
||||
|
||||
public const string HEADER_BODY_HASH = 'X-Body-Hash';
|
||||
|
||||
public const string HEADER_TIMESTAMP = 'X-Timestamp';
|
||||
|
||||
public const string HEADER_SIGNATURE = 'X-Signature';
|
||||
|
||||
public const string HEADER_NONCE = 'X-Nonce';
|
||||
|
||||
/**
|
||||
* This middleware signs the request using AWS-style signatures.
|
||||
*/
|
||||
public static function signRequest(
|
||||
ClientIdentifier $apiKey,
|
||||
ClientSecret $withSecret,
|
||||
): callable {
|
||||
return static function (callable $handler) use ($apiKey, $withSecret): self {
|
||||
return new self($handler, $apiKey, $withSecret);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(RequestInterface, array<string, mixed>): PromiseInterface $nextHandler Next handler to invoke.
|
||||
*/
|
||||
public function __construct(
|
||||
private $nextHandler,
|
||||
private ClientIdentifier $apiKey,
|
||||
private ClientSecret $withSecret
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $options */
|
||||
public function __invoke(RequestInterface $request, array $options): PromiseInterface
|
||||
{
|
||||
$fn = $this->nextHandler;
|
||||
|
||||
$signedRequest = $this->sign($request);
|
||||
|
||||
return $fn($signedRequest, $options);
|
||||
}
|
||||
|
||||
private function sign(RequestInterface $request): RequestInterface
|
||||
{
|
||||
/** @var array<string,string> $modifyMap */
|
||||
$modifyMap = [];
|
||||
|
||||
$this->addClientIdHeader($request, $modifyMap);
|
||||
$this->addTimestampHeader($request, $modifyMap);
|
||||
$this->addNonceHeader($request, $modifyMap);
|
||||
$this->addBodyHashHeader($request, $modifyMap);
|
||||
$this->addSignatureHeader($request, $modifyMap, $this->withSecret);
|
||||
|
||||
return Utils::modifyRequest($request, [
|
||||
'set_headers' => $modifyMap,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $modifyMap */
|
||||
private function addClientIdHeader(RequestInterface $request, array &$modifyMap): void
|
||||
{
|
||||
if (! $request->hasHeader(self::HEADER_CLIENT_IDENTIFIER)) {
|
||||
$modifyMap[self::HEADER_CLIENT_IDENTIFIER] = $this->apiKey->value;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,string> $modifyMap */
|
||||
private function addTimestampHeader(RequestInterface $request, array &$modifyMap): void
|
||||
{
|
||||
if (! $request->hasHeader(self::HEADER_TIMESTAMP)) {
|
||||
$modifyMap[self::HEADER_TIMESTAMP] = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTimeInterface::ATOM);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,string> $modifyMap */
|
||||
private function addNonceHeader(RequestInterface $request, array &$modifyMap): void
|
||||
{
|
||||
if (! $request->hasHeader(self::HEADER_NONCE)) {
|
||||
$modifyMap[self::HEADER_NONCE] = $this->generateNonce()->value->toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,string> $modifyMap */
|
||||
private function addBodyHashHeader(RequestInterface $request, array &$modifyMap): void
|
||||
{
|
||||
if (! $request->hasHeader(self::HEADER_BODY_HASH)) {
|
||||
$body = $request->getBody();
|
||||
$body->rewind();
|
||||
$hash = hash('sha256', $body->getContents());
|
||||
$modifyMap[self::HEADER_BODY_HASH] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,string> $modifyMap */
|
||||
private function addSignatureHeader(
|
||||
RequestInterface $request,
|
||||
array &$modifyMap,
|
||||
ClientSecret $withSecret
|
||||
): void {
|
||||
$signaturePayload = $this->compileDataToSign($request, $modifyMap);
|
||||
$signature = $this->calculateSignature($signaturePayload, $withSecret);
|
||||
$modifyMap[self::HEADER_SIGNATURE] = $signature;
|
||||
}
|
||||
|
||||
/** @param list<string> $signaturePayload */
|
||||
private function calculateSignature(array $signaturePayload, ClientSecret $withSecret): string
|
||||
{
|
||||
$payloadString = implode('|', $signaturePayload);
|
||||
|
||||
return hash_hmac('sha256', $payloadString, $withSecret->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $modifyMap
|
||||
* @return list<string>
|
||||
*/
|
||||
private function compileDataToSign(RequestInterface $request, array $modifyMap): array
|
||||
{
|
||||
$timestamp = $modifyMap[self::HEADER_TIMESTAMP] ?? $request->getHeaderLine(self::HEADER_TIMESTAMP);
|
||||
$nonce = $modifyMap[self::HEADER_NONCE] ?? $request->getHeaderLine(self::HEADER_NONCE);
|
||||
$bodyHash = $modifyMap[self::HEADER_BODY_HASH] ?? ($request->getHeaderLine(self::HEADER_BODY_HASH) ?: self::SHA256_EMPTY_STRING_HASH);
|
||||
|
||||
return [
|
||||
strtoupper($request->getMethod()),
|
||||
$this->getOrdonatedPathAndQuery($request->getUri()),
|
||||
$timestamp,
|
||||
$nonce,
|
||||
$bodyHash,
|
||||
];
|
||||
}
|
||||
|
||||
private function getOrdonatedPathAndQuery(Uri $fromUri): string
|
||||
{
|
||||
$path = $fromUri->getPath();
|
||||
$query = $fromUri->getQuery();
|
||||
|
||||
if ($query === '') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
// NOTE: Sort query parameters to ensure consistent signature generation
|
||||
parse_str($query, $queryParams);
|
||||
ksort($queryParams);
|
||||
$sortedQuery = http_build_query($queryParams);
|
||||
|
||||
return "{$path}?{$sortedQuery}";
|
||||
}
|
||||
|
||||
private function generateNonce(): Nonce
|
||||
{
|
||||
return new Nonce(
|
||||
value: Uuid::uuid7(),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/Auth/ClientIdentifier.php
Normal file
17
src/Auth/ClientIdentifier.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Uwil\ArcadiaApiPhpClient\Auth;
|
||||
|
||||
final readonly class ClientIdentifier
|
||||
{
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
public string $value,
|
||||
) {
|
||||
if (empty(mb_trim($value))) {
|
||||
throw new \InvalidArgumentException('ClientIdentifier cannot be empty.');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Auth/ClientSecret.php
Normal file
17
src/Auth/ClientSecret.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Uwil\ArcadiaApiPhpClient\Auth;
|
||||
|
||||
final readonly class ClientSecret
|
||||
{
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
public string $value,
|
||||
) {
|
||||
if (empty(mb_trim($value))) {
|
||||
throw new \InvalidArgumentException('ClientSecret cannot be empty.');
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Auth/Nonce.php
Normal file
19
src/Auth/Nonce.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Uwil\ArcadiaApiPhpClient\Auth;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class Nonce
|
||||
{
|
||||
public function __construct(
|
||||
public readonly UuidInterface $value,
|
||||
) {
|
||||
if (! Uuid::isValid($value->toString())) {
|
||||
throw new \InvalidArgumentException('Nonce value is not a valid UUID');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue