136 lines
4.9 KiB
PHP
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;
|
|
}
|
|
}
|