getAuthorizationHeader()]; // "Bearer " * $token = $creds->getAccessToken(); // just the token * * Notes: * - Reuse ONE instance for the life of the process (e.g. register it as a * shared service in your container). Tokens are cached in memory and * refreshed automatically after 30 minutes (the server issues 60-minute * tokens). Under PHP-FPM the cache lasts one request; in long-running * workers it lasts the life of the worker. * - A 401 from the API is a real error (bad key, revoked key, missing * permission). Do not retry it by fetching a new token. * - Tokens are never written to disk. */ declare(strict_types=1); /** Thrown when a key file is missing or invalid, or a token request fails. */ final class EnfonicaCredentialsException extends RuntimeException { } /** Service account credentials that produce cached Enfonica access tokens. */ final class EnfonicaCredentials { private const SCOPE = 'https://api.enfonica.com/auth/platform'; private const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; private const ENV_VAR = 'ENFONICA_APPLICATION_CREDENTIALS'; private const JWT_LIFETIME_SECONDS = 3600; private const TOKEN_REFRESH_SECONDS = 30 * 60; private const HTTP_TIMEOUT_SECONDS = 30; private const REQUIRED_FIELDS = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'token_uri']; public readonly string $projectId; public readonly string $clientEmail; /** Always read from the key file, never hardcoded. */ public readonly string $tokenUri; private readonly string $privateKeyId; private readonly OpenSSLAsymmetricKey $privateKey; private ?string $token = null; private float $fetchedAt = 0.0; /** Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. */ public static function fromEnv(): self { $path = getenv(self::ENV_VAR); if ($path === false || $path === '') { throw new EnfonicaCredentialsException(self::ENV_VAR . ' is not set. Set it to the path of a service ' . 'account key file, or use fromServiceAccountFile() / fromServiceAccountInfo().'); } return self::fromServiceAccountFile($path); } /** Load a service account key file from disk. */ public static function fromServiceAccountFile(string $path): self { $json = is_readable($path) ? file_get_contents($path) : false; if ($json === false) { throw new EnfonicaCredentialsException("Could not read key file '{$path}'"); } return self::fromServiceAccountInfo($json); } /** * Load a service account key from its JSON text or an already-decoded array. * * @param string|array $info */ public static function fromServiceAccountInfo(string|array $info): self { if (is_string($info)) { try { $info = json_decode($info, true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new EnfonicaCredentialsException('Invalid key file: not valid JSON: ' . $e->getMessage(), 0, $e); } if (!is_array($info) || array_is_list($info)) { throw new EnfonicaCredentialsException('Invalid key file: expected a JSON object'); } } return new self($info); } /** @param array $info */ private function __construct(array $info) { if (($info['type'] ?? null) !== 'service_account') { throw new EnfonicaCredentialsException("Invalid key file: expected type 'service_account', got '" . (is_string($info['type'] ?? null) ? $info['type'] : '') . "'"); } $missing = array_filter( self::REQUIRED_FIELDS, static fn (string $f): bool => !is_string($info[$f] ?? null) || $info[$f] === '', ); if ($missing !== []) { throw new EnfonicaCredentialsException('Invalid key file: missing field(s): ' . implode(', ', $missing)); } $this->projectId = $info['project_id']; $this->clientEmail = $info['client_email']; $this->tokenUri = $info['token_uri']; $this->privateKeyId = $info['private_key_id']; $this->privateKey = self::loadPrivateKey($info['private_key']); } /** Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. */ public function getAccessToken(): string { $now = hrtime(true) / 1e9; if ($this->token === null || $now - $this->fetchedAt >= self::TOKEN_REFRESH_SECONDS) { $this->token = $this->fetchToken(); $this->fetchedAt = hrtime(true) / 1e9; } return $this->token; } /** Return the value for the HTTP Authorization header: "Bearer ". */ public function getAuthorizationHeader(): string { return 'Bearer ' . $this->getAccessToken(); } private function fetchToken(): string { $curl = curl_init($this->tokenUri); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => self::GRANT_TYPE, 'assertion' => $this->signedAssertion(), ]), CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::HTTP_TIMEOUT_SECONDS, ]); $body = curl_exec($curl); $error = curl_error($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); if ($body === false) { throw new EnfonicaCredentialsException("Token request to {$this->tokenUri} failed: {$error}"); } if ($status < 200 || $status > 299) { throw new EnfonicaCredentialsException("Token request failed: HTTP {$status} from {$this->tokenUri}: {$body}"); } $decoded = json_decode((string) $body, true); $token = is_array($decoded) ? ($decoded['access_token'] ?? null) : null; if (!is_string($token) || $token === '') { throw new EnfonicaCredentialsException("Token response was malformed: {$body}"); } return $token; } /** Build the RS256-signed JWT assertion (RFC 7523 §3). */ private function signedAssertion(): string { $now = time(); $header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => $this->privateKeyId]; $payload = [ 'iss' => $this->clientEmail, 'aud' => $this->tokenUri, 'scope' => self::SCOPE, 'iat' => $now, 'exp' => $now + self::JWT_LIFETIME_SECONDS, ]; $signingInput = self::base64Url(json_encode($header, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)) . '.' . self::base64Url(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); if (!openssl_sign($signingInput, $signature, $this->privateKey, OPENSSL_ALGO_SHA256)) { throw new EnfonicaCredentialsException('Could not sign JWT assertion: ' . openssl_error_string()); } return $signingInput . '.' . self::base64Url($signature); } // Re-wrap the PEM body at 64 characters per line before parsing: OpenSSL // rejects keys wrapped at other widths even though the key material is valid. private static function loadPrivateKey(string $pem): OpenSSLAsymmetricKey { $body = preg_replace('/-----(BEGIN|END) [A-Z ]+-----|\s+/', '', $pem); $normalized = "-----BEGIN PRIVATE KEY-----\n" . chunk_split($body, 64, "\n") . "-----END PRIVATE KEY-----\n"; $key = openssl_pkey_get_private($normalized); if ($key === false) { throw new EnfonicaCredentialsException( 'Invalid key file: private_key is not a valid PKCS#8 RSA key: ' . openssl_error_string()); } return $key; } private static function base64Url(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } }