Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.84% covered (success)
91.84%
45 / 49
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlApiKeyRepository
91.67% covered (success)
91.67%
44 / 48
75.00% covered (warning)
75.00%
3 / 4
18.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findByToken
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 touchLastUsed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 hydrateRow
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
11.44
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\Domain\Model\ApiKey;
12use App\Core\Api\Domain\Repository\ApiKeyRepositoryInterface;
13use DateTimeImmutable;
14use PDO;
15use Throwable;
16
17/**
18 * SQL Implementation of ApiKeyRepositoryInterface.
19 *
20 * Persists and validates API keys with SHA-256 constant-time hash verification.
21 *
22 * @package App\Core\Api\Infrastructure\Repository
23 */
24final readonly class SqlApiKeyRepository implements ApiKeyRepositoryInterface
25{
26    /**
27     * SqlApiKeyRepository constructor.
28     *
29     * @param PDO    $pdo         Database connection instance.
30     * @param string $tablePrefix Table prefix.
31     */
32    public function __construct(
33        private PDO $pdo,
34        private string $tablePrefix = 'a_'
35    ) {
36    }
37
38    /**
39     * Finds active API key matching raw bearer token.
40     *
41     * @param string $rawToken Bearer token from request.
42     * @return ApiKey|null Found valid API key entity or null.
43     */
44    public function findByToken(string $rawToken): ?ApiKey
45    {
46        if (trim($rawToken) === '') {
47            return null;
48        }
49
50        $hash = hash('sha256', $rawToken);
51        $tableName = $this->tablePrefix . 'core_api_key_records';
52
53        try {
54            $stmt = $this->pdo->prepare(
55                "SELECT `id`, `name`, `key_prefix`, `key_hash`, `scopes`, `rate_limit`,
56                        `expires_at`, `last_used_at`, `is_active`
57                 FROM `{$tableName}`
58                 WHERE `is_active` = 1 AND `key_hash` = :hash
59                 LIMIT 1"
60            );
61            $stmt->execute(['hash' => $hash]);
62            /** @var array<string, mixed>|false $row */
63            $row = $stmt->fetch(PDO::FETCH_ASSOC);
64
65            return is_array($row) ? $this->hydrateRow($row) : null;
66        } catch (Throwable) {
67            return null;
68        }
69    }
70
71    /**
72     * Records timestamp of successful API key usage.
73     *
74     * @param int $id API key primary identifier.
75     * @return void
76     */
77    public function touchLastUsed(int $id): void
78    {
79        $tableName = $this->tablePrefix . 'core_api_key_records';
80
81        try {
82            $stmt = $this->pdo->prepare(
83                "UPDATE `{$tableName}`
84                 SET `last_used_at` = CURRENT_TIMESTAMP(6)
85                 WHERE `id` = :id"
86            );
87            $stmt->execute(['id' => $id]);
88        } catch (Throwable) {
89            // Non-blocking update failure
90        }
91    }
92
93    /**
94     * Hydrates associative row into ApiKey entity.
95     *
96     * @param array<string, mixed> $row Database row.
97     * @return ApiKey Domain entity.
98     */
99    private function hydrateRow(array $row): ApiKey
100    {
101        /** @var list<string> $scopes */
102        $scopes = ['*'];
103        if (isset($row['scopes']) && is_string($row['scopes'])) {
104            $decoded = json_decode($row['scopes'], true);
105            if (is_array($decoded)) {
106                /** @var list<string> $scopes */
107                $scopes = array_values(array_filter($decoded, 'is_string'));
108            }
109        }
110
111        $expiresAt = null;
112        if (!empty($row['expires_at']) && is_string($row['expires_at'])) {
113            try {
114                $expiresAt = new DateTimeImmutable($row['expires_at']);
115            } catch (Throwable) {
116                $expiresAt = null;
117            }
118        }
119
120        $lastUsedAt = null;
121        if (!empty($row['last_used_at']) && is_string($row['last_used_at'])) {
122            try {
123                $lastUsedAt = new DateTimeImmutable($row['last_used_at']);
124            } catch (Throwable) {
125                $lastUsedAt = null;
126            }
127        }
128
129        return new ApiKey(
130            id: (int) $row['id'],
131            name: (string) $row['name'],
132            keyPrefix: (string) $row['key_prefix'],
133            keyHash: (string) $row['key_hash'],
134            scopes: $scopes !== [] ? $scopes : ['*'],
135            rateLimit: max(1, (int) ($row['rate_limit'] ?? 120)),
136            expiresAt: $expiresAt,
137            lastUsedAt: $lastUsedAt,
138            isActive: (bool) $row['is_active']
139        );
140    }
141}