Files
zyt/server/app/common/service/iam/IamJwtVerifier.php
T

136 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\iam;
class IamJwtVerifier
{
private string $issuer;
private string $clientId;
private int $clockSkewSeconds;
public function __construct(
string $issuer,
string $clientId,
int $clockSkewSeconds = 30
) {
$this->issuer = $issuer;
$this->clientId = $clientId;
$this->clockSkewSeconds = $clockSkewSeconds;
}
/** @return array<string,mixed> */
public function verify(string $jwt, string $accessToken, string $nonce, array $jwks): array
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
throw new IamHubException('Invalid ID token');
}
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
$header = $this->decodeJson($encodedHeader);
$claims = $this->decodeJson($encodedPayload);
if (($header['alg'] ?? '') !== 'RS256' || trim((string) ($header['kid'] ?? '')) === '') {
throw new IamHubException('Unsupported ID token algorithm');
}
$key = $this->matchingKey((string) $header['kid'], $jwks);
$verified = openssl_verify(
$encodedHeader . '.' . $encodedPayload,
IamSecurity::base64UrlDecode($encodedSignature),
$this->jwkToPem($key),
OPENSSL_ALGO_SHA256
);
if ($verified !== 1) {
throw new IamHubException('Invalid ID token signature');
}
$now = time();
if (!hash_equals($this->issuer, (string) ($claims['iss'] ?? ''))) {
throw new IamHubException('Invalid ID token issuer');
}
$audience = $claims['aud'] ?? [];
$audiences = is_array($audience) ? $audience : [$audience];
if (!in_array($this->clientId, $audiences, true)) {
throw new IamHubException('Invalid ID token audience');
}
if ((int) ($claims['exp'] ?? 0) < $now - $this->clockSkewSeconds) {
throw new IamHubException('Expired ID token');
}
if ((int) ($claims['iat'] ?? 0) > $now + $this->clockSkewSeconds) {
throw new IamHubException('Invalid ID token issue time');
}
if ($nonce === '' || !hash_equals($nonce, (string) ($claims['nonce'] ?? ''))) {
throw new IamHubException('Invalid ID token nonce');
}
if (trim((string) ($claims['sub'] ?? '')) === '') {
throw new IamHubException('ID token subject is missing');
}
if (isset($claims['at_hash'])) {
$expected = IamSecurity::base64UrlEncode(substr(hash('sha256', $accessToken, true), 0, 16));
if (!hash_equals($expected, (string) $claims['at_hash'])) {
throw new IamHubException('Invalid access token hash');
}
}
return $claims;
}
/** @return array<string,mixed> */
private function decodeJson(string $value): array
{
$decoded = json_decode(IamSecurity::base64UrlDecode($value), true);
if (!is_array($decoded)) {
throw new IamHubException('Invalid ID token JSON');
}
return $decoded;
}
/** @return array<string,mixed> */
private function matchingKey(string $kid, array $jwks): array
{
foreach (($jwks['keys'] ?? []) as $key) {
if (is_array($key) && ($key['kid'] ?? '') === $kid && ($key['kty'] ?? '') === 'RSA') {
return $key;
}
}
throw new IamHubException('ID token signing key was not found');
}
private function jwkToPem(array $key): string
{
$modulus = IamSecurity::base64UrlDecode((string) ($key['n'] ?? ''));
$exponent = IamSecurity::base64UrlDecode((string) ($key['e'] ?? ''));
if ($modulus === '' || $exponent === '') {
throw new IamHubException('Invalid RSA signing key');
}
$rsaKey = $this->asn1Sequence($this->asn1Integer($modulus) . $this->asn1Integer($exponent));
$algorithm = hex2bin('300d06092a864886f70d0101010500');
$publicKey = $this->asn1Sequence($algorithm . "\x03" . $this->asn1Length(strlen($rsaKey) + 1) . "\x00" . $rsaKey);
return "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode($publicKey), 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
private function asn1Integer(string $value): string
{
$value = ltrim($value, "\x00") ?: "\x00";
if ((ord($value[0]) & 0x80) !== 0) {
$value = "\x00" . $value;
}
return "\x02" . $this->asn1Length(strlen($value)) . $value;
}
private function asn1Sequence(string $value): string
{
return "\x30" . $this->asn1Length(strlen($value)) . $value;
}
private function asn1Length(int $length): string
{
if ($length < 128) {
return chr($length);
}
$bytes = ltrim(pack('N', $length), "\x00");
return chr(0x80 | strlen($bytes)) . $bytes;
}
}