int, 'body'=>string]. */ public function __construct(array $config, ?callable $transport = null) { foreach (['issuer', 'client_id', 'client_secret', 'redirect_uri', 'api_url', 'application_id', 'application_token'] as $key) { if (!isset($config[$key]) || !is_string($config[$key]) || trim($config[$key]) === '' || preg_match('/[\r\n]/', $config[$key])) { throw new RuntimeException('IAM configuration is incomplete'); } } $this->config = $config; $this->transport = $transport; $this->httpsUrl($config['issuer']); $this->httpsUrl($config['redirect_uri']); $this->endpoint($config['api_url']); if (parse_url($config['issuer'], PHP_URL_QUERY) !== null || parse_url($config['api_url'], PHP_URL_QUERY) !== null) { throw new RuntimeException('IAM base URL must not contain a query'); } } public function authorizationUrl(string $state, string $nonce, string $verifier): string { $this->verifier($verifier); if (strlen($state) < 32 || strlen($nonce) < 32) { throw new RuntimeException('IAM state and nonce must be random transaction values'); } $metadata = $this->metadata(); return $metadata['authorization_endpoint'] . (str_contains($metadata['authorization_endpoint'], '?') ? '&' : '?') . http_build_query([ 'response_type' => 'code', 'client_id' => $this->config['client_id'], 'redirect_uri' => $this->config['redirect_uri'], 'scope' => 'openid profile', 'state' => $state, 'nonce' => $nonce, 'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='), 'code_challenge_method' => 'S256', ], '', '&', PHP_QUERY_RFC3986); } public function exchange(string $code, string $verifier, string $nonce): array { $this->verifier($verifier); if ($code === '' || $nonce === '') { throw new RuntimeException('IAM authorization response is incomplete'); } $metadata = $this->metadata(); $response = $this->request('POST', $metadata['token_endpoint'], [ 'Content-Type: application/x-www-form-urlencoded', 'Authorization: Basic ' . base64_encode(urlencode($this->config['client_id']) . ':' . urlencode($this->config['client_secret'])), ], http_build_query([ 'grant_type' => 'authorization_code', 'code' => $code, 'code_verifier' => $verifier, 'redirect_uri' => $this->config['redirect_uri'], ], '', '&', PHP_QUERY_RFC3986)); if (!isset($response['id_token']) || !is_string($response['id_token']) || strlen($response['id_token']) > 65536) { throw new RuntimeException('IAM ID token is missing'); } $jwks = $this->request('GET', $metadata['jwks_uri']); $keys = []; $kids = []; foreach ($jwks['keys'] ?? [] as $key) { if (is_array($key) && ($key['kty'] ?? '') === 'RSA' && ($key['alg'] ?? 'RS256') === 'RS256' && ($key['use'] ?? 'sig') === 'sig' && (!isset($key['key_ops']) || in_array('verify', $key['key_ops'], true))) { if (!is_string($key['kid'] ?? null) || $key['kid'] === '' || isset($kids[$key['kid']])) { throw new RuntimeException('IAM signing key identifier is invalid'); } $kids[$key['kid']] = true; $key['alg'] = 'RS256'; $keys[] = $key; } } if ($keys === []) { throw new RuntimeException('IAM signing keys are unavailable'); } try { $claims = (array) JWT::decode($response['id_token'], JWK::parseKeySet(['keys' => $keys])); } catch (\Throwable $error) { throw new RuntimeException('IAM ID token verification failed'); } $aud = $claims['aud'] ?? null; $audiences = is_string($aud) ? [$aud] : (is_array($aud) ? $aud : []); $now = time(); if (($claims['iss'] ?? null) !== $this->config['issuer'] || !in_array($this->config['client_id'], $audiences, true) || (count($audiences) > 1 && !isset($claims['azp'])) || (isset($claims['azp']) && $claims['azp'] !== $this->config['client_id']) || !is_string($claims['sub'] ?? null) || $claims['sub'] === '' || !is_string($claims['nonce'] ?? null) || !hash_equals($nonce, $claims['nonce']) || !is_int($claims['exp'] ?? null) || $claims['exp'] <= $now || !is_int($claims['iat'] ?? null) || $claims['iat'] > $now || (isset($claims['nbf']) && (!is_int($claims['nbf']) || $claims['nbf'] > $now))) { throw new RuntimeException('IAM ID token claims are invalid'); } return $claims; } public function provisioning(string $subject): array { if ($subject === '' || strlen($subject) > 512) { throw new RuntimeException('IAM subject is invalid'); } return $this->request('GET', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/' . rawurlencode($this->config['application_id']) . '/employees/' . rawurlencode($subject) . '/provisioning-context', ['Authorization: Bearer ' . $this->config['application_token']]); } public function bind(string $subject, int $adminId): array { if ($subject === '' || strlen($subject) > 512 || $adminId <= 0) { throw new RuntimeException('IAM binding input is invalid'); } $key = hash('sha256', json_encode([$this->config['issuer'], $this->config['application_id'], $subject, $adminId], JSON_THROW_ON_ERROR)); return $this->request('POST', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/' . rawurlencode($this->config['application_id']) . '/bindings', [ 'Authorization: Bearer ' . $this->config['application_token'], 'Content-Type: application/json', 'X-Request-ID: ' . bin2hex(random_bytes(16)), 'Idempotency-Key: zyt-provision-' . $key, ], json_encode(['employeeSubject' => $subject, 'externalAccountId' => (string) $adminId, 'verificationMethod' => 'oidc_verified_subject_local_creation'], JSON_THROW_ON_ERROR), [201]); } private function metadata(): array { if ($this->discovery !== null) { return $this->discovery; } $metadata = $this->request('GET', rtrim($this->config['issuer'], '/') . '/.well-known/openid-configuration'); if (($metadata['issuer'] ?? null) !== $this->config['issuer']) { throw new RuntimeException('IAM discovery issuer mismatch'); } foreach (['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as $key) { if (!isset($metadata[$key]) || !is_string($metadata[$key])) { throw new RuntimeException('IAM discovery endpoint is missing'); } $this->endpoint($metadata[$key]); } return $this->discovery = $metadata; } private function verifier(string $verifier): void { if (!preg_match('/^[A-Za-z0-9._~-]{43,128}$/D', $verifier)) { throw new RuntimeException('IAM PKCE verifier is invalid'); } } private function httpsUrl(string $url): array { $parts = parse_url($url); if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f\\\\]/', $url)) { throw new RuntimeException('IAM URL must be a fixed HTTPS URL'); } return $parts; } private function endpoint(string $url): void { $urlParts = $this->httpsUrl($url); $issuer = $this->httpsUrl($this->config['issuer']); if (strtolower($urlParts['host']) !== strtolower($issuer['host']) || ($urlParts['port'] ?? 443) !== ($issuer['port'] ?? 443)) { throw new RuntimeException('IAM endpoint origin mismatch'); } } private function request(string $method, string $url, array $headers = [], string $body = '', array $statuses = [200]): array { $this->endpoint($url); $headers[] = 'Accept: application/json'; if ($this->transport !== null) { $result = ($this->transport)($method, $url, $headers, $body); } else { $curl = curl_init($url); $response = ''; curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 15, CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$response): int { if (strlen($response) + strlen($chunk) > 1048576) { return 0; } $response .= $chunk; return strlen($chunk); }, ]); if ($method === 'POST') { curl_setopt($curl, CURLOPT_POSTFIELDS, $body); } $ok = curl_exec($curl); $result = ['status' => curl_getinfo($curl, CURLINFO_RESPONSE_CODE), 'body' => $ok === false ? false : $response]; curl_close($curl); } if (!is_array($result) || !in_array($result['status'] ?? 0, $statuses, true) || !is_string($result['body'] ?? null) || strlen($result['body']) > 1048576) { throw new RuntimeException('IAM request failed'); } try { $data = json_decode($result['body'], true, 64, JSON_THROW_ON_ERROR); } catch (\Throwable $error) { throw new RuntimeException('IAM response is invalid'); } if (!is_array($data) || isset($data['error'])) { throw new RuntimeException('IAM response is invalid'); } return $data; } }