Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
38 / 40
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
JwksProvider
95.00% covered (success)
95.00%
38 / 40
60.00% covered (warning)
60.00%
3 / 5
18
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 getKeys
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 loadJwks
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 cacheFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeCache
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3/**
4 * JWKS provider for Entra ID (Microsoft Identity Platform).
5 *
6 * Fetches and caches the JSON Web Key Set used to verify id_token signatures.
7 *
8 * This Source Code Form is subject to the terms of the Mozilla Public License,
9 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
10 * obtain one at https://mozilla.org/MPL/2.0/.
11 *
12 * @package   phpMyFAQ
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ\Auth\EntraId;
22
23use Firebase\JWT\JWK;
24use Firebase\JWT\Key;
25use RuntimeException;
26use Symfony\Component\HttpClient\HttpClient;
27use Symfony\Contracts\HttpClient\HttpClientInterface;
28
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}