|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::factory()->create([ 'id' => Uuid::uuid7()->toString(), 'name' => 'Inactive Client', 'slug' => 'test-' . uniqid(), '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::factory()->create([ 'id' => Uuid::uuid7()->toString(), 'name' => 'Active Client', 'slug' => 'test-' . uniqid(), '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::factory()->create([ 'id' => Uuid::uuid7()->toString(), 'name' => 'Active Client', 'slug' => 'test-' . uniqid(), '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::factory()->create([ 'id' => Uuid::uuid7()->toString(), 'name' => 'Valid API Client', 'slug' => 'test-' . uniqid(), '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::factory()->create([ 'id' => Uuid::uuid7()->toString(), 'name' => 'Valid API Client', 'slug' => 'test-' . uniqid(), '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.', ]); }); it('bypasses HMAC authentication in local/testing when dev bypass is explicitly enabled', function (): void { // GIVEN /** @var \Tests\TestCase $this */ Config::set('auth.hmac.allow_dev_bypass', true); // WHEN $response = $this->getJson('/api/v1/test-hmac-auth'); // THEN $response->assertStatus(200) ->assertJson([ 'message' => 'ok', 'client' => 'Arcadia Dev Test Client', ]); }); it('strictly rejects HMAC bypass in production environment even if allow_dev_bypass is enabled', function (): void { // GIVEN /** @var \Tests\TestCase $this */ Config::set('auth.hmac.allow_dev_bypass', true); app()['env'] = 'production'; // WHEN $response = $this->getJson('/api/v1/test-hmac-auth'); // 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.', ]); }); });