credentials = compact('clientId', 'clientSecret', 'audience'); // Rotation or another audience gets a separate cache; no secrets in filenames. $key = hash('sha256', json_encode($this->credentials, JSON_THROW_ON_ERROR)); $this->cacheFile = $directory . DIRECTORY_SEPARATOR . 'vinquery-' . $key . '.json'; $this->fetch = fn(): array => $this->requestToken(); $this->clock = fn(): float => microtime(true); } public function getToken(): string { return $this->withCache(function (array &$cached): string { $now = ($this->clock)(); if (is_string($cached['token'] ?? null) && trim($cached['token']) !== '' && is_numeric($cached['refreshAt'] ?? null) && $now < $cached['refreshAt']) { return $cached['token']; } // This runs under the lock, after reading the latest saved cache. $result = ($this->fetch)(); $token = $result['jwtToken'] ?? null; $rawExpiry = $result['expiresUtc'] ?? null; try { if (!is_string($rawExpiry) || !preg_match('/T.*(?:Z|[+-]\d{2}:\d{2})$/', $rawExpiry)) { throw new RuntimeException(); } $expires = new DateTimeImmutable($rawExpiry); $parseErrors = DateTimeImmutable::getLastErrors(); if ($parseErrors !== false && ($parseErrors['warning_count'] || $parseErrors['error_count'])) { throw new RuntimeException(); } $expiresAt = (float)$expires->format('U.u'); } catch (Exception $error) { throw new RuntimeException('VINquery returned an invalid expiry.'); } $now = ($this->clock)(); if (!is_string($token) || trim($token) === '' || $expiresAt <= $now) { throw new RuntimeException('VINquery returned an empty token or invalid expiry.'); } $cached = ['token' => $token, 'refreshAt' => $expiresAt - min(60.0, ($expiresAt - $now) * 0.1)]; return $token; }); } public function invalidate(string $rejectedToken): void { $this->withCache(function (array &$cached) use ($rejectedToken): void { if (($cached['token'] ?? null) === $rejectedToken) $cached = []; }); } private function withCache(Closure $action): mixed { $file = fopen($this->cacheFile, 'c+b'); // Do not truncate before locking. if ($file === false) throw new RuntimeException('Cannot open the private token cache.'); try { if (PHP_OS_FAMILY !== 'Windows' && !chmod($this->cacheFile, 0600)) { throw new RuntimeException('Cannot restrict token cache permissions.'); } $deadline = hrtime(true) + 35_000_000_000; while (!flock($file, LOCK_EX | LOCK_NB)) { if (hrtime(true) >= $deadline) throw new RuntimeException('Timed out waiting for token renewal.'); usleep(10_000); } rewind($file); $stored = stream_get_contents($file); if ($stored === false) throw new RuntimeException('Cannot read the token cache.'); $cached = json_decode($stored, true); $cached = is_array($cached) ? $cached : []; $before = $cached; $result = $action($cached); if ($cached !== $before) { $serialized = json_encode($cached, JSON_THROW_ON_ERROR); if (!rewind($file) || !ftruncate($file, 0) || fwrite($file, $serialized) !== strlen($serialized) || !fflush($file)) { throw new RuntimeException('Cannot save the token cache.'); } } return $result; } finally { // Close releases the lock, including after HTTP/parse/write failures. fclose($file); } } private function requestToken(): array { $curl = curl_init('https://identity.vinquery.com/connect/token'); if ($curl === false) throw new RuntimeException('Cannot initialize the token request.'); try { curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode($this->credentials, JSON_THROW_ON_ERROR), CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); if ($body === false || $status < 200 || $status >= 300) { throw new RuntimeException('VINquery token request failed (HTTP ' . $status . ').'); } $result = json_decode($body, true); if (!is_array($result)) throw new RuntimeException('VINquery returned an invalid token response.'); return $result; } finally { unset($curl); // Release the CurlHandle (PHP 8+). } } }