Files
zyt/server/app/adminapi/service/iam/IamOidcClient.php
T

221 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use RuntimeException;
/** Fixed-origin OIDC client. Transaction storage and account binding belong to the caller. */
final class IamOidcClient
{
private array $config;
private $transport;
private ?array $discovery = null;
/** Transport signature: (method, url, headers, body): ['status'=>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;
}
}