feat(auth): implement HMAC authentication middleware, nonce and credentials value objects

This commit is contained in:
Jérémy Munsch 2026-09-14 19:26:11 +02:00
parent c5f7199b9c
commit a2e810c2eb
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
4 changed files with 230 additions and 0 deletions

View 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(),
);
}
}

View 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
View 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
View 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');
}
}
}