diff --git a/bootstrap/middlewares.php b/bootstrap/middlewares.php index 9ce7e00..5b7fb89 100644 --- a/bootstrap/middlewares.php +++ b/bootstrap/middlewares.php @@ -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', ]); diff --git a/routes/apiv1.php b/routes/apiv1.php index 7747429..632e2aa 100644 --- a/routes/apiv1.php +++ b/routes/apiv1.php @@ -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'); + }); }); diff --git a/src/Presentation/Api/Exceptions/AuthorizationException.php b/src/Presentation/Api/Exceptions/AuthorizationException.php new file mode 100644 index 0000000..54a8202 --- /dev/null +++ b/src/Presentation/Api/Exceptions/AuthorizationException.php @@ -0,0 +1,11 @@ +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; + } +} diff --git a/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php b/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php index fcb5c2f..7c2fc77 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php +++ b/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php @@ -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 diff --git a/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php b/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php index 82e55e6..42d102b 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php +++ b/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php @@ -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 */ diff --git a/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php b/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php index 8f2063b..c87c231 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php +++ b/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php @@ -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; } } diff --git a/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php b/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php new file mode 100644 index 0000000..0d3208f --- /dev/null +++ b/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php @@ -0,0 +1,315 @@ +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']); + } +} diff --git a/src/Presentation/Api/V1/Security/ApiUserDetailsProvider.php b/src/Presentation/Api/V1/Security/ApiUserDetailsProvider.php new file mode 100644 index 0000000..4c9013a --- /dev/null +++ b/src/Presentation/Api/V1/Security/ApiUserDetailsProvider.php @@ -0,0 +1,24 @@ +currentUser = $user; + } + + #[\Override] + public function currentUser(): ?UserDetails + { + return $this->currentUser; + } +} diff --git a/src/Presentation/Console/Security/ConsoleUserDetails.php b/src/Presentation/Console/Security/ConsoleUserDetails.php new file mode 100644 index 0000000..031e2bd --- /dev/null +++ b/src/Presentation/Console/Security/ConsoleUserDetails.php @@ -0,0 +1,42 @@ +name; + } + + #[\Override] + public function type(): UserType + { + return ClientType::CONSOLE; + } + + #[\Override] + public function identifier(): string + { + return $this->identifier; + } +} diff --git a/src/Presentation/Console/Security/ConsoleUserDetailsProvider.php b/src/Presentation/Console/Security/ConsoleUserDetailsProvider.php new file mode 100644 index 0000000..fa42ecd --- /dev/null +++ b/src/Presentation/Console/Security/ConsoleUserDetailsProvider.php @@ -0,0 +1,22 @@ +user ?? new ConsoleUserDetails(); + } +} diff --git a/src/Presentation/Shared/Models/ValueObjects/ClientType.php b/src/Presentation/Shared/Models/ValueObjects/ClientType.php new file mode 100644 index 0000000..d0b2a04 --- /dev/null +++ b/src/Presentation/Shared/Models/ValueObjects/ClientType.php @@ -0,0 +1,25 @@ +value; + } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof ClientType && $this->value === $other->value; + } +} diff --git a/tests/Feature/Api/V1/VerifyHmacSignatureTest.php b/tests/Feature/Api/V1/VerifyHmacSignatureTest.php new file mode 100644 index 0000000..05977a1 --- /dev/null +++ b/tests/Feature/Api/V1/VerifyHmacSignatureTest.php @@ -0,0 +1,368 @@ +|string|null $body + * @return array + */ +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.', + ]); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 21a2b46..2c4a235 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -14,6 +14,7 @@ BypassFinals::denyPaths([ '*/Dto/*', '*/Models/*', '*/Shared/DDD/*', + '*/ValueObjects/*', ]); pest()