ldc-arcadia-api/tests/Feature/Api/V1/VerifyHmacSignatureTest.php

373 lines
12 KiB
PHP

<?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',
'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::query()->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::query()->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::query()->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::query()->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.',
]);
});
});