feat(presentation): implement HMAC authentication middleware, security providers and feature tests

This commit is contained in:
Jérémy Munsch 2026-08-18 12:03:32 +02:00
parent e7904682b6
commit 726375c00d
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
14 changed files with 888 additions and 12 deletions

View file

@ -6,8 +6,13 @@ use Illuminate\Foundation\Configuration\Middleware;
use Presentation\Api\V1\Http\Middlewares\EnsureApiV1AcceptsJson;
use Presentation\Api\V1\Http\Middlewares\SetApiV1Etag;
use Presentation\Api\V1\Http\Middlewares\TransformApiV1Response;
use Presentation\Api\V1\Http\Middlewares\VerifyHmacSignature;
return function (Middleware $middleware): void {
$middleware->alias([
'auth.hmac' => VerifyHmacSignature::class,
]);
$middleware->web(append: [
'throttle:web',
]);

View file

@ -35,16 +35,18 @@ Route::get('/openapi', function () {
);
})->name('openapi');
// * EMPLOYEES
Route::prefix('employees')->group(function (): void {
Route::get('/', EmployeesIndexController::class)->name('employees.index');
Route::get('/{employeeId}', EmployeeShowController::class)->name('employees.show');
});
Route::middleware('auth.hmac')->group(function (): void {
// * EMPLOYEES
Route::prefix('employees')->group(function (): void {
Route::get('/', EmployeesIndexController::class)->name('employees.index');
Route::get('/{employeeId}', EmployeeShowController::class)->name('employees.show');
});
// * INCO SHEETS
Route::prefix('inco-sheets')->group(function (): void {
Route::get('/', IncoSheetsIndexController::class)->name('inco-sheets.index');
Route::get('/search', IncoSheetsSearchController::class)->name('inco-sheets.search');
Route::get('/{incoId}', IncoSheetsShowController::class)->name('inco-sheets.show');
Route::get('/{incoId}/composition', IncoCompositionShowController::class)->name('inco-sheets.composition');
// * INCO SHEETS
Route::prefix('inco-sheets')->group(function (): void {
Route::get('/', IncoSheetsIndexController::class)->name('inco-sheets.index');
Route::get('/search', IncoSheetsSearchController::class)->name('inco-sheets.search');
Route::get('/{incoId}', IncoSheetsShowController::class)->name('inco-sheets.show');
Route::get('/{incoId}/composition', IncoCompositionShowController::class)->name('inco-sheets.composition');
});
});

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\Exceptions;
use Shared\DDD\Exception\SecurityException;
final class AuthorizationException extends SecurityException
{
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Dto;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Presentation\Shared\Models\ValueObjects\ClientType;
use Shared\DDD\Security\UserDetails;
use Shared\DDD\Security\UserType;
final readonly class ApiClientUserDetails implements UserDetails
{
public function __construct(
private ClientId $clientId,
private ClientIdentifier $clientIdentifier,
private ClientName $clientName,
private ClientSecret $clientSecret,
) {
}
#[\Override]
public function id(): string
{
return $this->clientId->value;
}
#[\Override]
public function name(): string
{
return $this->clientName->value;
}
#[\Override]
public function type(): UserType
{
return ClientType::API;
}
#[\Override]
public function identifier(): string
{
return $this->clientIdentifier->value;
}
public function secret(): ClientSecret
{
return $this->clientSecret;
}
}

View file

@ -10,6 +10,9 @@ use Symfony\Component\HttpFoundation\Response;
final class EnsureApiV1AcceptsJson
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
// * Bypass for OpenAPI YAML spec route

View file

@ -11,6 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
final class SetApiV1Etag
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */

View file

@ -11,6 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
final class TransformApiV1Response
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
@ -19,7 +22,6 @@ final class TransformApiV1Response
return $response->present();
}
/** @var Response $response */
return $response;
}
}

View file

@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Models\ValueObjects\Request\HttpBodyHash;
use Application\Security\Models\ValueObjects\Request\HttpMethod;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Application\Security\Models\ValueObjects\Request\Request as SecurityRequestVo;
use Application\Security\Models\ValueObjects\Request\RequestSignature;
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
use Application\Security\Ports\Services\RequestValidator;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\ApiClientUserDetails;
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Response;
use Uri\Rfc3986\Uri;
final readonly class VerifyHmacSignature
{
private const string CLIENT_IDENTIFIER_HEADER = 'X-Client-Identifier';
private const string SIGNATURE_HEADER = 'X-Signature';
private const string TIMESTAMP_HEADER = 'X-Timestamp';
private const string NONCE_HEADER = 'X-Nonce';
public function __construct(
private LoggerInterface $logger,
private RequestValidator $requestValidator,
private ApiUserDetailsProvider $userDetailsProvider,
) {
}
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
if ($this->shouldBypassHmac()) {
return $this->handleBypassedRequest($request, $next);
}
$headersOrError = $this->extractAndValidateHeaders($request);
if ($headersOrError instanceof Response) {
return $headersOrError;
}
$securityRequest = $this->buildSecurityRequest($request, $headersOrError);
$validationResult = $this->requestValidator->validate($securityRequest);
if (! $validationResult->isValid()) {
$this->logger->warning('HMAC authentication failed', [
'reason' => $validationResult->status->name,
'uri' => $request->getUri(),
'method' => $request->getMethod(),
]);
return $this->generateUnauthorizedResponse($request, 'Authentication failed.');
}
$client = $validationResult->client;
if ($client === null) {
throw new \LogicException('Client cannot be null for a valid HMAC validation result.');
}
$userDetails = new ApiClientUserDetails(
clientId: $client->id,
clientIdentifier: $client->identifier,
clientName: $client->name,
clientSecret: $client->secret,
);
$this->userDetailsProvider->setCurrentUser($userDetails);
$request->attributes->set('user_details', $userDetails);
$response = $next($request);
if ($response instanceof ApiResponse) {
return $response->present();
}
return $response;
}
private function shouldBypassHmac(): bool
{
if (! App::environment('local', 'testing')) {
return false;
}
return Config::boolean('auth.hmac.allow_dev_bypass', false);
}
/**
* @param Closure(Request): Response $next
*/
private function handleBypassedRequest(Request $request, Closure $next): Response
{
$this->logger->debug('HMAC verification bypassed in local/testing environment.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
]);
/** @var array{id: string, name: string, identifier: string, secret: string} $devClientConfig */
$devClientConfig = Config::array('auth.hmac.dev_client');
$devUserDetails = new ApiClientUserDetails(
clientId: new ClientId(Uuid::fromString($devClientConfig['id'])),
clientIdentifier: new ClientIdentifier($devClientConfig['identifier']),
clientName: new ClientName($devClientConfig['name']),
clientSecret: new ClientSecret($devClientConfig['secret']),
);
$this->userDetailsProvider->setCurrentUser($devUserDetails);
$request->attributes->set('user_details', $devUserDetails);
$response = $next($request);
if ($response instanceof ApiResponse) {
return $response->present();
}
return $response;
}
/**
* @return array{identifier: ClientIdentifier, timestamp: RequestTimestamp, signature: RequestSignature, nonce: Nonce}|Response
*/
private function extractAndValidateHeaders(Request $request): array | Response
{
$clientIdentifier = $this->getClientIdentifierFromHeader($request);
if ($clientIdentifier === null) {
return $this->generateUnauthorizedResponse(
$request,
'Missing or invalid client identifier in the request headers.'
);
}
$timestamp = $this->getTimestampFromHeader($request);
if ($timestamp === null) {
return $this->generateUnauthorizedResponse(
$request,
'Missing or invalid timestamp in the request headers.'
);
}
$signature = $this->getSignatureFromHeader($request);
if ($signature === null) {
return $this->generateUnauthorizedResponse(
$request,
'Missing or invalid HMAC signature in the request headers.'
);
}
$nonce = $this->getNonceFromHeader($request);
if ($nonce === null) {
return $this->generateUnauthorizedResponse(
$request,
'Missing or invalid nonce in the request headers.'
);
}
return [
'identifier' => $clientIdentifier,
'timestamp' => $timestamp,
'signature' => $signature,
'nonce' => $nonce,
];
}
/**
* @param array{identifier: ClientIdentifier, timestamp: RequestTimestamp, signature: RequestSignature, nonce: Nonce} $headers
*/
private function buildSecurityRequest(Request $request, array $headers): SecurityRequestVo
{
$uri = new Uri($request->getUri());
$method = HttpMethod::from($request->getMethod());
$bodyHash = $request->getContent() !== '' ? hash('sha256', $request->getContent()) : null;
return new SecurityRequestVo(
uri: $uri,
method: $method,
bodyHash: $bodyHash !== null ? new HttpBodyHash($bodyHash) : null,
signature: $headers['signature'],
nonce: $headers['nonce'],
identifier: $headers['identifier'],
timestamp: $headers['timestamp']
);
}
private function getClientIdentifierFromHeader(Request $request): ?ClientIdentifier
{
$clientIdentifierHeader = $request->header(self::CLIENT_IDENTIFIER_HEADER);
if ($clientIdentifierHeader === null || trim($clientIdentifierHeader) === '') {
return null;
}
try {
return new ClientIdentifier(trim($clientIdentifierHeader));
} catch (\InvalidArgumentException $e) {
$this->logger->debug('Invalid client identifier in the request headers.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
'exception' => $e->getMessage(),
]);
return null;
}
}
private function getTimestampFromHeader(Request $request): ?RequestTimestamp
{
$timestampHeader = $request->header(self::TIMESTAMP_HEADER);
if ($timestampHeader === null || trim($timestampHeader) === '') {
$this->logger->debug('Missing timestamp in the request headers.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
]);
return null;
}
try {
$timestampHeader = trim($timestampHeader);
$timestamp = new \DateTimeImmutable($timestampHeader);
return new RequestTimestamp($timestamp);
} catch (\Exception $e) {
$this->logger->debug('Invalid timestamp in the request headers.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
'exception' => $e->getMessage(),
]);
return null;
}
}
private function getSignatureFromHeader(Request $request): ?RequestSignature
{
$signatureHeader = $request->header(self::SIGNATURE_HEADER);
if ($signatureHeader === null || trim($signatureHeader) === '') {
$this->logger->debug('Missing HMAC signature in the request headers.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
]);
return null;
}
try {
return new RequestSignature(trim($signatureHeader));
} catch (\InvalidArgumentException $e) {
$this->logger->debug('Invalid HMAC signature in the request headers.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
'exception' => $e->getMessage(),
]);
return null;
}
}
private function getNonceFromHeader(Request $request): ?Nonce
{
$nonceHeader = $request->header(self::NONCE_HEADER);
if ($nonceHeader === null || trim($nonceHeader) === '') {
return null;
}
try {
$nonceHeader = trim($nonceHeader);
$validatedNonce = Uuid::fromString($nonceHeader);
return new Nonce($validatedNonce);
} catch (\InvalidArgumentException $e) {
$this->logger->debug('Invalid nonce in the request headers.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
'exception' => $e->getMessage(),
]);
return null;
}
}
private function generateUnauthorizedResponse(Request $request, string $detail): Response
{
return response()->json([
'type' => $request->schemeAndHttpHost() . '/errors/401',
'title' => 'Unauthorized',
'status' => 401,
'detail' => $detail,
'instance' => $request->getPathInfo(),
], 401, ['Content-Type' => 'application/problem+json']);
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Security;
use Shared\DDD\Security\UserDetails;
use Shared\DDD\Security\UserDetailsProvider;
final class ApiUserDetailsProvider implements UserDetailsProvider
{
private ?UserDetails $currentUser = null;
public function setCurrentUser(?UserDetails $user): void
{
$this->currentUser = $user;
}
#[\Override]
public function currentUser(): ?UserDetails
{
return $this->currentUser;
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Security;
use Presentation\Shared\Models\ValueObjects\ClientType;
use Shared\DDD\Security\UserDetails;
use Shared\DDD\Security\UserType;
final readonly class ConsoleUserDetails implements UserDetails
{
public function __construct(
private string $identifier = 'console',
private string $name = 'CLI Console User',
) {
}
#[\Override]
public function id(): string
{
return '00000000-0000-0000-0000-000000000000';
}
#[\Override]
public function name(): string
{
return $this->name;
}
#[\Override]
public function type(): UserType
{
return ClientType::CONSOLE;
}
#[\Override]
public function identifier(): string
{
return $this->identifier;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Security;
use Shared\DDD\Security\UserDetails;
use Shared\DDD\Security\UserDetailsProvider;
final readonly class ConsoleUserDetailsProvider implements UserDetailsProvider
{
public function __construct(
private ?UserDetails $user = null,
) {
}
#[\Override]
public function currentUser(): UserDetails
{
return $this->user ?? new ConsoleUserDetails();
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Presentation\Shared\Models\ValueObjects;
use Shared\DDD\Security\UserType;
enum ClientType: string implements UserType
{
case API = 'api';
case CONSOLE = 'console';
#[\Override]
public function type(): string
{
return $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof ClientType && $this->value === $other->value;
}
}

View file

@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use DateTimeImmutable;
use DateTimeInterface;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route;
use Infra\Database\Models\ApiClient;
use Presentation\Api\V1\Dto\ApiClientUserDetails;
use Presentation\Api\V1\Http\Middlewares\VerifyHmacSignature;
use Ramsey\Uuid\Uuid;
/**
* @param array<string, mixed>|string|null $body
* @return array<string, string>
*/
function generateHmacHeaders(
string $method,
string $pathAndQuery,
string $clientIdentifier,
string $clientSecret,
array | string | null $body = null,
?DateTimeImmutable $timestamp = null,
?string $nonce = null,
): array {
$timestamp ??= new DateTimeImmutable('now');
$nonce ??= Uuid::uuid7()->toString();
$timestampFormatted = $timestamp->format(DateTimeInterface::ATOM);
$bodyString = match (true) {
is_array($body) => json_encode($body) ?: '[]',
is_string($body) => $body,
default => '',
};
$bodyHash = $bodyString !== ''
? hash('sha256', $bodyString)
: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
// Parse and sort query parameters
$parsed = parse_url($pathAndQuery);
$path = $parsed['path'] ?? '/';
$query = $parsed['query'] ?? null;
if ($query !== null && $query !== '') {
parse_str($query, $queryParams);
ksort($queryParams);
$sortedQuery = http_build_query($queryParams);
$path = $path . '?' . $sortedQuery;
}
$payload = implode('|', [
strtoupper($method),
$path,
$timestampFormatted,
$nonce,
$bodyHash,
]);
$signature = hash_hmac('sha256', $payload, $clientSecret);
return [
'X-Client-Identifier' => $clientIdentifier,
'X-Timestamp' => $timestampFormatted,
'X-Nonce' => $nonce,
'X-Signature' => $signature,
];
}
beforeEach(function (): void {
Config::set('auth.hmac.allow_dev_bypass', false);
Route::middleware(VerifyHmacSignature::class)->get('/api/v1/test-hmac-auth', function () {
/** @var ApiClientUserDetails|null $user */
$user = request()->attributes->get('user_details');
return response()->json([
'message' => 'ok',
'client' => $user?->name(),
]);
});
});
describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
it('rejects request with missing client identifier', function (): void {
// GIVEN / WHEN
/** @var \Tests\TestCase $this */
$response = $this->getJson('/api/v1/test-hmac-auth', [
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
'X-Nonce' => Uuid::uuid7()->toString(),
'X-Signature' => str_repeat('a', 64),
]);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Missing or invalid client identifier in the request headers.',
]);
});
it('rejects request with missing timestamp', function (): void {
// GIVEN / WHEN
/** @var \Tests\TestCase $this */
$response = $this->getJson('/api/v1/test-hmac-auth', [
'X-Client-Identifier' => str_repeat('c', 43),
'X-Nonce' => Uuid::uuid7()->toString(),
'X-Signature' => str_repeat('a', 64),
]);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Missing or invalid timestamp in the request headers.',
]);
});
it('rejects request with missing signature', function (): void {
// GIVEN / WHEN
/** @var \Tests\TestCase $this */
$response = $this->getJson('/api/v1/test-hmac-auth', [
'X-Client-Identifier' => str_repeat('c', 43),
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
'X-Nonce' => Uuid::uuid7()->toString(),
]);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Missing or invalid HMAC signature in the request headers.',
]);
});
it('rejects request with missing nonce', function (): void {
// GIVEN / WHEN
/** @var \Tests\TestCase $this */
$response = $this->getJson('/api/v1/test-hmac-auth', [
'X-Client-Identifier' => str_repeat('c', 43),
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
'X-Signature' => str_repeat('a', 64),
]);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Missing or invalid nonce in the request headers.',
]);
});
it('rejects request when client does not exist in database', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$headers = generateHmacHeaders(
method: 'GET',
pathAndQuery: '/api/v1/test-hmac-auth',
clientIdentifier: str_repeat('c', 43),
clientSecret: str_repeat('s', 98),
body: []
);
// WHEN
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Authentication failed.',
]);
});
it('rejects request when client is inactive or suspended', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Inactive Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => false,
]);
$headers = generateHmacHeaders(
method: 'GET',
pathAndQuery: '/api/v1/test-hmac-auth',
clientIdentifier: $identifier,
clientSecret: $secret,
body: []
);
// WHEN
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Authentication failed.',
]);
});
it('rejects request when timestamp is outside skew window', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Active Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$oldTimestamp = new DateTimeImmutable('-10 minutes');
$headers = generateHmacHeaders(
method: 'GET',
pathAndQuery: '/api/v1/test-hmac-auth',
clientIdentifier: $identifier,
clientSecret: $secret,
body: [],
timestamp: $oldTimestamp
);
// WHEN
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Authentication failed.',
]);
});
it('rejects request when HMAC signature does not match', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Active Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$headers = generateHmacHeaders(
method: 'GET',
pathAndQuery: '/api/v1/test-hmac-auth',
clientIdentifier: $identifier,
clientSecret: $secret,
body: []
);
$headers['X-Signature'] = hash('sha256', 'wrong_signature_payload');
// WHEN
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
// THEN
$response->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Authentication failed.',
]);
});
it('accepts request and authenticates client with valid HMAC credentials', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Valid API Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$headers = generateHmacHeaders(
method: 'GET',
pathAndQuery: '/api/v1/test-hmac-auth',
clientIdentifier: $identifier,
clientSecret: $secret,
body: []
);
// WHEN
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
// THEN
$response->assertStatus(200)
->assertJson([
'message' => 'ok',
'client' => 'Valid API Client',
]);
});
it('rejects replayed request with already used nonce', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Valid API Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$headers = generateHmacHeaders(
method: 'GET',
pathAndQuery: '/api/v1/test-hmac-auth',
clientIdentifier: $identifier,
clientSecret: $secret,
body: []
);
// First request succeeds
$firstResponse = $this->getJson('/api/v1/test-hmac-auth', $headers);
$firstResponse->assertStatus(200);
// WHEN (Second identical request with same nonce)
$secondResponse = $this->getJson('/api/v1/test-hmac-auth', $headers);
// THEN
$secondResponse->assertStatus(401)
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'status' => 401,
'title' => 'Unauthorized',
'detail' => 'Authentication failed.',
]);
});
});

View file

@ -14,6 +14,7 @@ BypassFinals::denyPaths([
'*/Dto/*',
'*/Models/*',
'*/Shared/DDD/*',
'*/ValueObjects/*',
]);
pest()