From fff16c2647388d8c98df3ab9fc4ef278a2d275cb Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Wed, 21 Jan 2026 14:52:47 +1100 Subject: [PATCH 1/6] feat: Add webhook decoder --- lib/Webhooks/WebhookDecoder.php | 43 ++++++++++++++++++++++++++ lib/Webhooks/WebhookEventType.php | 51 +++++++++++++++++++++++++++++++ lib/Webhooks/WebhookSource.php | 25 +++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 lib/Webhooks/WebhookDecoder.php create mode 100644 lib/Webhooks/WebhookEventType.php create mode 100644 lib/Webhooks/WebhookSource.php diff --git a/lib/Webhooks/WebhookDecoder.php b/lib/Webhooks/WebhookDecoder.php new file mode 100644 index 0000000..fbe2a35 --- /dev/null +++ b/lib/Webhooks/WebhookDecoder.php @@ -0,0 +1,43 @@ +setJwksUrl($jwksUrl); + $payload = Utils::parseJWT($token); + + return is_array($payload) ? $payload : null; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/lib/Webhooks/WebhookEventType.php b/lib/Webhooks/WebhookEventType.php new file mode 100644 index 0000000..e3f9714 --- /dev/null +++ b/lib/Webhooks/WebhookEventType.php @@ -0,0 +1,51 @@ + Date: Wed, 21 Jan 2026 15:03:14 +1100 Subject: [PATCH 2/6] chore: fix coderabbit comment --- lib/Webhooks/WebhookDecoder.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Webhooks/WebhookDecoder.php b/lib/Webhooks/WebhookDecoder.php index fbe2a35..fb02825 100644 --- a/lib/Webhooks/WebhookDecoder.php +++ b/lib/Webhooks/WebhookDecoder.php @@ -27,8 +27,13 @@ public static function decodeWebhook(?string $token, ?string $domain): ?array return null; } - // Normalise and set JWKS URL so Utils::parseJWT can verify signature. + // Basic domain validation: require HTTPS scheme and host, strip path/query. $normalizedDomain = rtrim($domain, '/'); + $parts = parse_url($normalizedDomain); + if ($parts === false || empty($parts['scheme']) || empty($parts['host']) || strtolower($parts['scheme']) !== 'https') { + return null; + } + $normalizedDomain = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''); $jwksUrl = $normalizedDomain . '/.well-known/jwks.json'; try { @@ -40,4 +45,5 @@ public static function decodeWebhook(?string $token, ?string $domain): ?array return null; } } + } From a23182dd7990ab1ab0c2c261d55bce029d8ba2df Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Wed, 21 Jan 2026 15:17:24 +1100 Subject: [PATCH 3/6] chore: Namespace JWKS cache per JWKS URL --- lib/Sdk/Storage/Storage.php | 32 +++++++++++++++++++++++++------- lib/Sdk/Utils/Utils.php | 12 ++++++------ lib/Webhooks/WebhookDecoder.php | 3 +-- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/lib/Sdk/Storage/Storage.php b/lib/Sdk/Storage/Storage.php index d1fc9eb..6c2374e 100644 --- a/lib/Sdk/Storage/Storage.php +++ b/lib/Sdk/Storage/Storage.php @@ -171,11 +171,13 @@ static function setJwksUrl($jwksUrl) /** * Gets cached JWKS data if available and not expired * + * @param string|null $jwksUrl Optional JWKS URL to namespace the cache * @return array|null The cached JWKS data or null if not available/expired */ - static function getCachedJwks() + static function getCachedJwks(?string $jwksUrl = null) { - $cachedData = self::getItem(StorageEnums::JWKS_CACHE); + $cacheKey = self::getJwksCacheKey($jwksUrl); + $cachedData = self::getItem($cacheKey); if (empty($cachedData)) { return null; } @@ -187,7 +189,7 @@ static function getCachedJwks() // Check if cache has expired if ($data['expires_at'] < time()) { - self::removeItem(StorageEnums::JWKS_CACHE); + self::removeItem($cacheKey); return null; } @@ -199,26 +201,30 @@ static function getCachedJwks() * * @param array $jwks The JWKS data to cache * @param int $ttlSeconds TTL in seconds (default: 1 hour) + * @param string|null $jwksUrl Optional JWKS URL to namespace the cache * @return void */ - static function setCachedJwks(array $jwks, int $ttlSeconds = 3600) + static function setCachedJwks(array $jwks, int $ttlSeconds = 3600, ?string $jwksUrl = null) { + $cacheKey = self::getJwksCacheKey($jwksUrl); $cacheData = [ 'jwks' => $jwks, 'expires_at' => time() + $ttlSeconds ]; - self::setItem(StorageEnums::JWKS_CACHE, json_encode($cacheData), time() + $ttlSeconds); + self::setItem($cacheKey, json_encode($cacheData), time() + $ttlSeconds); } /** * Clears the cached JWKS data * + * @param string|null $jwksUrl Optional JWKS URL to namespace the cache * @return void */ - static function clearCachedJwks() + static function clearCachedJwks(?string $jwksUrl = null) { - self::removeItem(StorageEnums::JWKS_CACHE); + $cacheKey = self::getJwksCacheKey($jwksUrl); + self::removeItem($cacheKey); } /** @@ -284,4 +290,16 @@ static function setPersistentCookieDuration(int $duration) { self::$persistentCookieDuration = $duration; } + + /** + * Build a cache key for JWKS, optionally namespaced by URL. + */ + private static function getJwksCacheKey(?string $jwksUrl = null): string + { + if (empty($jwksUrl)) { + return StorageEnums::JWKS_CACHE; + } + + return StorageEnums::JWKS_CACHE . '_' . md5($jwksUrl); + } } diff --git a/lib/Sdk/Utils/Utils.php b/lib/Sdk/Utils/Utils.php index 54807c5..326c110 100644 --- a/lib/Sdk/Utils/Utils.php +++ b/lib/Sdk/Utils/Utils.php @@ -94,13 +94,13 @@ static public function validationURL(string $url) * * @return array|null The decoded payload as an associative array, or null if the token is invalid. */ - static public function parseJWT(string $token) + static public function parseJWT(string $token, ?string $jwksUrl = null) { try { - $jwks_url = Storage::getInstance()->getJwksUrl(); + $jwks_url = $jwksUrl ?? Storage::getInstance()->getJwksUrl(); // Try to get cached JWKS first - $jwks = Storage::getInstance()->getCachedJwks(); + $jwks = Storage::getInstance()->getCachedJwks($jwks_url); if ($jwks === null) { // Cache miss - fetch from server @@ -109,7 +109,7 @@ static public function parseJWT(string $token) if ($jwks && isset($jwks['keys'])) { // Cache the JWKS for 1 hour (3600 seconds) - Storage::getInstance()->setCachedJwks($jwks, 3600); + Storage::getInstance()->setCachedJwks($jwks, 3600, $jwks_url); } } @@ -122,12 +122,12 @@ static public function parseJWT(string $token) // If parsing fails with cached JWKS, try to refresh from server if ($jwks !== null) { try { - Storage::getInstance()->clearCachedJwks(); + Storage::getInstance()->clearCachedJwks($jwks_url); $jwks_json = file_get_contents($jwks_url); $jwks = json_decode($jwks_json, true); if ($jwks && isset($jwks['keys'])) { - Storage::getInstance()->setCachedJwks($jwks, 3600); + Storage::getInstance()->setCachedJwks($jwks, 3600, $jwks_url); return json_decode(json_encode(JWT::decode($token, JWK::parseKeySet($jwks))), true); } } catch (Exception $refreshException) { diff --git a/lib/Webhooks/WebhookDecoder.php b/lib/Webhooks/WebhookDecoder.php index fb02825..9e3f0bd 100644 --- a/lib/Webhooks/WebhookDecoder.php +++ b/lib/Webhooks/WebhookDecoder.php @@ -37,8 +37,7 @@ public static function decodeWebhook(?string $token, ?string $domain): ?array $jwksUrl = $normalizedDomain . '/.well-known/jwks.json'; try { - Storage::getInstance()->setJwksUrl($jwksUrl); - $payload = Utils::parseJWT($token); + $payload = Utils::parseJWT($token, $jwksUrl); return is_array($payload) ? $payload : null; } catch (\Throwable $e) { From 3bd281a9b9a7dbeae2c65b1c65d227a7f12d12f0 Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Wed, 21 Jan 2026 15:22:04 +1100 Subject: [PATCH 4/6] chore: delete extra space --- lib/Sdk/Utils/Utils.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Sdk/Utils/Utils.php b/lib/Sdk/Utils/Utils.php index 326c110..69010c9 100644 --- a/lib/Sdk/Utils/Utils.php +++ b/lib/Sdk/Utils/Utils.php @@ -122,12 +122,12 @@ static public function parseJWT(string $token, ?string $jwksUrl = null) // If parsing fails with cached JWKS, try to refresh from server if ($jwks !== null) { try { - Storage::getInstance()->clearCachedJwks($jwks_url); + Storage::getInstance()->clearCachedJwks($jwks_url); $jwks_json = file_get_contents($jwks_url); $jwks = json_decode($jwks_json, true); if ($jwks && isset($jwks['keys'])) { - Storage::getInstance()->setCachedJwks($jwks, 3600, $jwks_url); + Storage::getInstance()->setCachedJwks($jwks, 3600, $jwks_url); return json_decode(json_encode(JWT::decode($token, JWK::parseKeySet($jwks))), true); } } catch (Exception $refreshException) { From e494111a7c366244e0053da2a61342f549084f2f Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Fri, 23 Jan 2026 16:16:16 +1100 Subject: [PATCH 5/6] chore: Guard JWKS fetches against untrusted domains --- lib/Sdk/Utils/Utils.php | 47 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/Sdk/Utils/Utils.php b/lib/Sdk/Utils/Utils.php index 69010c9..cfbcb22 100644 --- a/lib/Sdk/Utils/Utils.php +++ b/lib/Sdk/Utils/Utils.php @@ -96,8 +96,16 @@ static public function validationURL(string $url) */ static public function parseJWT(string $token, ?string $jwksUrl = null) { + $jwks = null; + $jwks_url = $jwksUrl; + try { - $jwks_url = $jwksUrl ?? Storage::getInstance()->getJwksUrl(); + if ($jwks_url === null) { + $jwks_url = Storage::getInstance()->getJwksUrl(); + } + + // Prevent fetching JWKS from an unexpected domain + $jwks_url = self::validateTrustedJwksUrl($jwks_url); // Try to get cached JWKS first $jwks = Storage::getInstance()->getCachedJwks($jwks_url); @@ -139,6 +147,43 @@ static public function parseJWT(string $token, ?string $jwksUrl = null) } } + /** + * Validates that the JWKS URL uses HTTPS and matches the configured domain. + * + * @param string $jwksUrl The JWKS URL to validate. + * + * @return string The validated JWKS URL. + * + * @throws InvalidArgumentException If the URL is invalid, uses a non-HTTPS scheme, + * or does not match the configured JWKS host. + */ + static public function validateTrustedJwksUrl(string $jwksUrl): string + { + $parts = parse_url($jwksUrl); + + if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) { + throw new InvalidArgumentException('Invalid JWKS URL'); + } + + if (strtolower($parts['scheme']) !== 'https') { + throw new InvalidArgumentException('JWKS URL must use https'); + } + + // Only enforce host match when a trusted JWKS URL has been set + try { + $trustedJwksUrl = Storage::getInstance()->getJwksUrl(); + $trustedHost = parse_url($trustedJwksUrl, PHP_URL_HOST); + } catch (\LogicException $e) { + $trustedHost = null; + } + + if (!empty($trustedHost) && strcasecmp($parts['host'], $trustedHost) !== 0) { + throw new InvalidArgumentException('Untrusted JWKS domain'); + } + + return $jwksUrl; + } + /** * Checks and validates additional parameters provided as an associative array. * From dfb5356bfe8e565bae20f76430c2c307abefc993 Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Wed, 4 Feb 2026 15:06:50 +1100 Subject: [PATCH 6/6] chore: fail-close JWKS domain validation and seed webhook JWKS host --- lib/Sdk/Utils/Utils.php | 8 +++++--- lib/Webhooks/WebhookDecoder.php | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/Sdk/Utils/Utils.php b/lib/Sdk/Utils/Utils.php index cfbcb22..625ce24 100644 --- a/lib/Sdk/Utils/Utils.php +++ b/lib/Sdk/Utils/Utils.php @@ -169,15 +169,17 @@ static public function validateTrustedJwksUrl(string $jwksUrl): string throw new InvalidArgumentException('JWKS URL must use https'); } - // Only enforce host match when a trusted JWKS URL has been set + // Enforce host match; fail closed when no trusted domain is configured try { $trustedJwksUrl = Storage::getInstance()->getJwksUrl(); $trustedHost = parse_url($trustedJwksUrl, PHP_URL_HOST); } catch (\LogicException $e) { - $trustedHost = null; + throw new InvalidArgumentException( + 'Cannot validate JWKS URL: no trusted domain configured. Initialize the SDK first or provide a pre-validated URL.' + ); } - if (!empty($trustedHost) && strcasecmp($parts['host'], $trustedHost) !== 0) { + if (empty($trustedHost) || strcasecmp($parts['host'], $trustedHost) !== 0) { throw new InvalidArgumentException('Untrusted JWKS domain'); } diff --git a/lib/Webhooks/WebhookDecoder.php b/lib/Webhooks/WebhookDecoder.php index 9e3f0bd..117c53c 100644 --- a/lib/Webhooks/WebhookDecoder.php +++ b/lib/Webhooks/WebhookDecoder.php @@ -37,6 +37,8 @@ public static function decodeWebhook(?string $token, ?string $domain): ?array $jwksUrl = $normalizedDomain . '/.well-known/jwks.json'; try { + // Seed the trusted JWKS URL so validation can enforce host matching + Storage::getInstance()->setJwksUrl($jwksUrl); $payload = Utils::parseJWT($token, $jwksUrl); return is_array($payload) ? $payload : null;