Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions lib/Sdk/Storage/Storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}

Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}
}
59 changes: 53 additions & 6 deletions lib/Sdk/Utils/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,21 @@ 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)
{
$jwks = null;
$jwks_url = $jwksUrl;

try {
$jwks_url = 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 = Storage::getInstance()->getCachedJwks($jwks_url);

if ($jwks === null) {
// Cache miss - fetch from server
Expand All @@ -109,7 +117,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);
}
}

Expand All @@ -122,12 +130,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) {
Expand All @@ -139,6 +147,45 @@ static public function parseJWT(string $token)
}
}

/**
* 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');
}

// 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) {
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) {
throw new InvalidArgumentException('Untrusted JWKS domain');
}

return $jwksUrl;
}

/**
* Checks and validates additional parameters provided as an associative array.
*
Expand Down
50 changes: 50 additions & 0 deletions lib/Webhooks/WebhookDecoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Kinde\KindeSDK\Webhooks;

use Kinde\KindeSDK\Sdk\Storage\Storage;
use Kinde\KindeSDK\Sdk\Utils\Utils;

/**
* Decode and validate webhook JWTs using the SDK's JWKS handling.
*/
final class WebhookDecoder
{
/**
* Decode a webhook JWT and return its payload as an array.
*
* Returns null when the token is missing, domain is missing, signature
* validation fails, or the payload cannot be decoded.
*
* @param string|null $token The webhook JWT.
* @param string|null $domain The Kinde domain (e.g. https://your-subdomain.kinde.com).
*
* @return array|null
*/
public static function decodeWebhook(?string $token, ?string $domain): ?array
{
if (empty($token) || empty($domain)) {
return null;
}

// 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 {
// 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;
} catch (\Throwable $e) {
return null;
}
}

}
51 changes: 51 additions & 0 deletions lib/Webhooks/WebhookEventType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Kinde\KindeSDK\Webhooks;

/**
* Webhook event type constants mirroring webhook-main.
*/
final class WebhookEventType
{
public const ORGANIZATION_CREATED = 'organization.created';
public const ORGANIZATION_UPDATED = 'organization.updated';
public const USER_CREATED = 'user.created';
public const USER_UPDATED = 'user.updated';
public const USER_DELETED = 'user.deleted';
public const USER_AUTHENTICATION_FAILED = 'user.authentication_failed';
public const USER_AUTHENTICATED = 'user.authenticated';
public const ORGANIZATION_DELETED = 'organization.deleted';
public const ROLE_CREATED = 'role.created';
public const ROLE_UPDATED = 'role.updated';
public const ROLE_DELETED = 'role.deleted';
public const PERMISSION_CREATED = 'permission.created';
public const PERMISSION_UPDATED = 'permission.updated';
public const PERMISSION_DELETED = 'permission.deleted';
public const SUBSCRIBER_CREATED = 'subscriber.created';
public const ACCESS_REQUEST_CREATED = 'access_request.created';

/**
* @return string[]
*/
public static function all(): array
{
return [
self::ORGANIZATION_CREATED,
self::ORGANIZATION_UPDATED,
self::USER_CREATED,
self::USER_UPDATED,
self::USER_DELETED,
self::USER_AUTHENTICATION_FAILED,
self::USER_AUTHENTICATED,
self::ORGANIZATION_DELETED,
self::ROLE_CREATED,
self::ROLE_UPDATED,
self::ROLE_DELETED,
self::PERMISSION_CREATED,
self::PERMISSION_UPDATED,
self::PERMISSION_DELETED,
self::SUBSCRIBER_CREATED,
self::ACCESS_REQUEST_CREATED,
];
}
}
25 changes: 25 additions & 0 deletions lib/Webhooks/WebhookSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Kinde\KindeSDK\Webhooks;

/**
* Origin of the webhook event.
*/
final class WebhookSource
{
public const ADMIN = 'admin';
public const API = 'api';
public const USER = 'user';

/**
* @return string[]
*/
public static function all(): array
{
return [
self::ADMIN,
self::API,
self::USER,
];
}
}