221 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|