Lines
95.00%
38 / 40
Methods
60.00%
3 / 5
Classes
0.00%
0 / 1
| 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 | ||
| 29 | class 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 | } |