Lines 95.00% 38 / 40
Methods 60.00% 3 / 5
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 83.33% 5 / 6 0.00% 0 / 1 2.02
 getKeys 100.00% 2 / 2 100.00% 1 / 1 1
 loadJwks 100.00% 21 / 21 100.00% 1 / 1 11
 cacheFile 100.00% 1 / 1 100.00% 1 / 1 1
 writeCache 90.00% 9 / 10 0.00% 0 / 1 3.01
29class JwksProvider
30{
31    private const int CACHE_TTL_SECONDS = 86_400;
32
33    private HttpClientInterface $httpClient;
34
35    private string $cacheDir;
36
37    public function __construct(?HttpClientInterface $httpClient = null, ?string $cacheDir = null)
38    {
39        $this->httpClient = $httpClient ?? HttpClient::create();
40        $this->cacheDir =
41            $cacheDir
42            ?? (
43                defined('PMF_ROOT_DIR') ? (string) PMF_ROOT_DIR . '/cache/jwks' : sys_get_temp_dir() . '/phpmyfaq-jwks'
44            );
45    }
46
47    /**
48     * @return array<string, Key>
49     */
50    public function getKeys(string $tenantId): array
51    {
52        $jwks = $this->loadJwks($tenantId);
53
54        return JWK::parseKeySet($jwks);
55    }
56
57    /**
58     * @return array{keys: array<array-key, mixed>}
59     */
60    private function loadJwks(string $tenantId): array
61    {
62        $cacheFile = $this->cacheFile($tenantId);
63
64        if (is_file($cacheFile) && (time() - (int) filemtime($cacheFile)) < self::CACHE_TTL_SECONDS) {
65            $cached = file_get_contents($cacheFile);
66            if ($cached !== false) {
67                $decoded = json_decode($cached, associative: true);
68                if (is_array($decoded) && array_key_exists('keys', $decoded) && is_array($decoded['keys'])) {
69                    return ['keys' => $decoded['keys']];
70                }
71            }
72        }
73
74        $url = 'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/discovery/v2.0/keys';
75        $response = $this->httpClient->request('GET', $url);
76        if ($response->getStatusCode() !== 200) {
77            throw new RuntimeException(sprintf(
78                'Failed to fetch JWKS from %s (HTTP %d)',
79                $url,
80                $response->getStatusCode(),
81            ));
82        }
83
84        $body = $response->getContent();
85        $decoded = json_decode($body, associative: true);
86        if (!is_array($decoded) || !array_key_exists('keys', $decoded) || !is_array($decoded['keys'])) {
87            throw new RuntimeException('Malformed JWKS response from identity provider.');
88        }
89
90        $this->writeCache($cacheFile, $body);
91
92        return ['keys' => $decoded['keys']];
93    }
94
95    private function cacheFile(string $tenantId): string
96    {
97        return rtrim($this->cacheDir, characters: '/') . '/jwks-' . sha1($tenantId) . '.json';
98    }
99
100    private function writeCache(string $file, string $body): void
101    {
102        $dir = dirname($file);
103        if (!is_dir($dir)) {
104            set_error_handler(static fn(): bool => true);
105            try {
106                mkdir(directory: $dir, permissions: 0o775, recursive: true);
107            } finally {
108                restore_error_handler();
109            }
110            if (!is_dir($dir)) {
111                return;
112            }
113        }
114        set_error_handler(static fn(): bool => true);
115        try {
116            file_put_contents(filename: $file, data: $body, flags: LOCK_EX);
117        } finally {
118            restore_error_handler();
119        }
120    }
121}