diff --git a/README.md b/README.md index fd94040..5bc2690 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ mainick_keycloak_client: encryption_key: '%env(IAM_ENCRYPTION_KEY)%' encryption_key_path: '%env(IAM_ENCRYPTION_KEY_PATH)%' version: '%env(IAM_VERSION)%' + # Optional: Whitelist of allowed domains for JWKS endpoint (security feature) + # If not specified, only the domain from base_url is allowed + allowed_jwks_domains: + - 'keycloak.example.com' + - '*.auth.example.com' # Supports wildcard subdomains ``` Additionally, it's recommended to add the following environment variables to your project's environment file @@ -44,7 +49,7 @@ IAM_REALM='' # Keycloak realm name IAM_CLIENT_ID='' # Keycloak client id IAM_CLIENT_SECRET='' # Keycloak client secret IAM_REDIRECT_URI='' # Keycloak redirect uri -IAM_ENCRYPTION_ALGORITHM='' # RS256, HS256, etc. +IAM_ENCRYPTION_ALGORITHM='' # RS256, HS256, JWKS, etc. IAM_ENCRYPTION_KEY='' # public key IAM_ENCRYPTION_KEY_PATH='' # public key path IAM_VERSION='' # Keycloak version diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..7c005eb --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,131 @@ +# Security Features + +## JWKS Endpoint URL Validation + +### Overview + +Per prevenire attacchi SSRF (Server-Side Request Forgery), il bundle implementa una validazione rigorosa dell'URL del JWKS endpoint. + +### Validazione dell'URL Base + +L'URL base di Keycloak (`base_url`) viene validato nel costruttore di `JWKSTokenDecoder` per garantire: + +1. **Formato URL valido**: L'URL deve avere uno schema (scheme) e un host validi +2. **Solo HTTPS**: Viene forzato l'uso di HTTPS per ambienti non-localhost +3. **Blocco IP privati**: Gli indirizzi IP privati (RFC 1918) sono bloccati automaticamente +4. **Blocco endpoint metadata**: Gli endpoint di metadata cloud (es. 169.254.169.254) sono bloccati +5. **Localhost consentito**: HTTP è consentito solo per localhost (127.0.0.1, ::1, localhost) + +### Whitelist Domini JWKS + +È possibile configurare una whitelist di domini autorizzati per le richieste JWKS: + +```yaml +# config/packages/mainick_keycloak_client.yaml + +mainick_keycloak_client: + keycloak: + base_url: '%env(IAM_BASE_URL)%' + realm: '%env(IAM_REALM)%' + # ... altre configurazioni ... + + # Whitelist di domini consentiti per l'endpoint JWKS + allowed_jwks_domains: + - 'keycloak.example.com' + - '*.auth.example.com' # Supporta wildcard per sottodomini +``` + +### Comportamento Predefinito + +Se non viene specificata alcuna whitelist (`allowed_jwks_domains`), il bundle consente **solo** il dominio presente in `base_url`. + +Esempio: +- Se `base_url` è `https://keycloak.example.com`, solo questo dominio sarà consentito per le richieste JWKS +- Qualsiasi tentativo di reindirizzamento o richiesta a un dominio diverso verrà bloccato + +### Wildcard per Sottodomini + +È possibile utilizzare wildcard per consentire tutti i sottodomini di un dominio specifico: + +```yaml +allowed_jwks_domains: + - '*.example.com' # Consente auth.example.com, keycloak.example.com, ecc. +``` + +**Nota**: Il wildcard `*.example.com` consente sia `auth.example.com` che `example.com` stesso. + +### Validazione HTTPS + +Per gli ambienti non-localhost, l'endpoint JWKS **deve** utilizzare HTTPS. Qualsiasi tentativo di utilizzare HTTP per domini pubblici verrà rifiutato con un'eccezione. + +### Eccezioni di Sicurezza + +Quando viene rilevata una violazione di sicurezza, viene lanciata un'eccezione `TokenDecoderException` con un messaggio dettagliato che indica: + +- Il dominio non autorizzato +- Il motivo del rifiuto (non nella whitelist, IP privato, ecc.) + +Esempio di messaggio di errore: +``` +Invalid token: JWKS URL host "malicious.com" is not in the allowed domains whitelist +``` + +### Hosts Bloccati + +I seguenti pattern di host sono automaticamente bloccati: + +- Indirizzi IP privati (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) +- Indirizzi IP riservati (169.254.0.0/16, 224.0.0.0/4, 240.0.0.0/4) +- Endpoint metadata cloud: + - `metadata.google.internal` + - `169.254.169.254` (AWS metadata) + - Qualsiasi host contenente `metadata` o `internal` + +### Best Practices + +1. **Ambiente di produzione**: Specificare sempre una whitelist esplicita di domini autorizzati +2. **HTTPS obbligatorio**: Non utilizzare HTTP per domini pubblici +3. **Minimizzare la whitelist**: Includere solo i domini strettamente necessari +4. **Evitare wildcard ampi**: Preferire domini specifici quando possibile +5. **Monitoraggio**: Registrare e monitorare le eccezioni `TokenDecoderException` per rilevare potenziali tentativi di attacco + +### Esempio di Configurazione Sicura + +```yaml +mainick_keycloak_client: + keycloak: + verify_ssl: true + base_url: 'https://keycloak.example.com' + realm: 'production' + client_id: '%env(IAM_CLIENT_ID)%' + client_secret: '%env(IAM_CLIENT_SECRET)%' + # ... altre configurazioni ... + + # Whitelist rigorosa per l'ambiente di produzione + allowed_jwks_domains: + - 'keycloak.example.com' + - 'auth.example.com' +``` + +### Test di Sicurezza + +Il bundle include test completi per verificare: + +- Validazione del formato URL +- Rifiuto di HTTP per domini non-localhost +- Blocco di IP privati +- Blocco di endpoint metadata +- Funzionamento della whitelist domini +- Supporto wildcard per sottodomini +- Validazione HTTPS per endpoint JWKS + +Per eseguire i test di sicurezza: + +```bash +./vendor/bin/phpunit tests/Token/JWKSTokenDecoderTest.php +``` + +## Segnalazione Vulnerabilità + +Se scopri una vulnerabilità di sicurezza, ti preghiamo di **NON** aprire un issue pubblico. Invia invece una segnalazione privata seguendo le linee guida nel file `SECURITY.md` nella root del progetto. + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index c19b633..668ad3d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -30,6 +30,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('encryption_key_path')->defaultNull()->end() ->scalarNode('encryption_key_passphrase')->defaultNull()->end() ->scalarNode('version')->defaultNull()->end() + ->arrayNode('allowed_jwks_domains') + ->info('Whitelist of allowed domains for JWKS endpoint requests. If empty, only the base_url domain is allowed.') + ->scalarPrototype()->end() + ->end() ->end() ->validate() ->ifTrue(function ($v) { diff --git a/src/Exception/TokenDecoderException.php b/src/Exception/TokenDecoderException.php index 87d8759..419b657 100644 --- a/src/Exception/TokenDecoderException.php +++ b/src/Exception/TokenDecoderException.php @@ -32,4 +32,29 @@ public static function forAudienceMismatch(\Exception $e): self { return new self('Audience mismatch', $e); } + + public static function forInvalidToken(\Exception $e): self + { + return new self('Invalid token', $e); + } + + public static function forInvalidConfiguration(string $message, ?\Exception $e = null): self + { + return new self($message, $e ?? new \Exception($message)); + } + + public static function forJwksError(string $message, \Exception $e): self + { + return new self('JWKS error: ' . $message, $e); + } + + public static function forDecodingError(string $message, \Exception $e): self + { + return new self('Token decoding error: ' . $message, $e); + } + + public static function forSecurityViolation(string $message, ?\Exception $e = null): self + { + return new self('Security violation: ' . $message, $e ?? new \Exception($message)); + } } diff --git a/src/Provider/KeycloakClient.php b/src/Provider/KeycloakClient.php index 8a8c55a..aae41ed 100644 --- a/src/Provider/KeycloakClient.php +++ b/src/Provider/KeycloakClient.php @@ -19,6 +19,7 @@ class KeycloakClient implements IamClientInterface { private Keycloak $keycloakProvider; + private ClientInterface $httpClient; public function __construct( private readonly LoggerInterface $keycloakClientLogger, @@ -57,14 +58,15 @@ public function __construct( $this->keycloakProvider->setVersion($this->version); } - $httpClient = new Client([ + $this->httpClient = new Client([ 'verify' => $this->verify_ssl, ]); - $this->keycloakProvider->setHttpClient($httpClient); + $this->keycloakProvider->setHttpClient($this->httpClient); } public function setHttpClient(ClientInterface $httpClient): void { + $this->httpClient = $httpClient; $this->keycloakProvider->setHttpClient($httpClient); } @@ -101,7 +103,7 @@ public function verifyToken(AccessTokenInterface $token): ?UserRepresentationDTO 'values' => $token->getValues(), ]); - $decoder = TokenDecoderFactory::create($this->encryption_algorithm); + $decoder = TokenDecoderFactory::create($this->encryption_algorithm, ['base_url' => $this->base_url, 'realm' => $this->realm], $this->httpClient); $tokenDecoded = $decoder->decode($accessToken->getToken(), $this->encryption_key); $decoder->validateToken($this->realm, $tokenDecoded); $this->keycloakClientLogger->info('KeycloakClient::verifyToken', [ diff --git a/src/Token/JWKSTokenDecoder.php b/src/Token/JWKSTokenDecoder.php new file mode 100644 index 0000000..c2ceeb6 --- /dev/null +++ b/src/Token/JWKSTokenDecoder.php @@ -0,0 +1,390 @@ +options) || $this->options[$requiredOption] === null || $this->options[$requiredOption] === '') { + throw TokenDecoderException::forInvalidConfiguration(\sprintf( + "Missing or empty required option '%s' for %s", + $requiredOption, + self::class + )); + } + } + + // Validate base_url format + $this->validateBaseUrl($this->options['base_url']); + } + + /** + * Decode a JWT using keys resolved dynamically from JWKS. + * + * The {@see TokenDecoderInterface} requires a $key parameter, but this + * JWKS-based implementation does not use it because the verification key + * is selected based on the "kid" value in the token header and fetched + * from the JWKS endpoint. Callers may pass an empty string or any + * placeholder value for $key when using this decoder. + * + * @param string $token The encoded JWT to decode. + * @param string $key Unused in this JWKS-based implementation; present + * only to satisfy the TokenDecoderInterface. + * + * @throws TokenDecoderException + */ + public function decode(string $token, string $key): array + { + try { + [$headerB64] = explode('.', $token, 2); + $header = json_decode($this->base64urlDecode($headerB64), true, 512, JSON_THROW_ON_ERROR); + + $kid = $header['kid'] ?? ''; + $alg = $header['alg'] ?? ''; + + if (empty($kid)) { + throw TokenDecoderException::forDecodingError('Missing kid in token header', new \Exception('kid not found')); + } + + if (empty($alg)) { + throw TokenDecoderException::forDecodingError('Missing alg in token header', new \Exception('alg not found')); + } + + $keyPem = $this->getPemKeyForKid($kid); + $tokenDecoded = JWT::decode($token, new Key($keyPem, $alg)); + + $json = json_encode($tokenDecoded, JSON_THROW_ON_ERROR); + + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } + catch (TokenDecoderException $e) { + throw $e; + } + catch (\JsonException $e) { + throw TokenDecoderException::forDecodingError('JSON parsing failed: ' . $e->getMessage(), $e); + } + catch (\Exception $e) { + throw TokenDecoderException::forDecodingError($e->getMessage(), $e); + } + } + + /** + * @throws TokenDecoderException + */ + public function validateToken(string $realm, array $tokenDecoded): void + { + $now = time(); + + if ($tokenDecoded['exp'] < $now) { + throw TokenDecoderException::forExpiration(new \Exception('Token has expired')); + } + + if (false === str_contains($tokenDecoded['iss'], $realm)) { + throw TokenDecoderException::forIssuerMismatch(new \Exception('Invalid token issuer')); + } + } + + private function getPemKeyForKid(string $kid): string + { + $jwks = $this->fetchJwks(); + if (empty($jwks)) { + throw TokenDecoderException::forJwksError('No keys found in JWKS endpoint', new \Exception('Empty JWKS keys array')); + } + foreach ($jwks as $jwk) { + if (($jwk['kid'] ?? '') === $kid && ($jwk['use'] ?? '') === 'sig') { + return $this->jwkToPem($jwk); + } + } + + throw TokenDecoderException::forJwksError( + sprintf('No matching signing key found for kid: %s', $kid), + new \Exception('Key ID not found in JWKS') + ); + } + + private function fetchJwks(): array + { + $url = sprintf('%s/realms/%s/protocol/openid-connect/certs', $this->options['base_url'], $this->options['realm']); + + // Validate the constructed JWKS URL + $this->validateJwksUrl($url); + + try { + if ($this->httpClient !== null) { + $response = $this->httpClient->request('GET', $url, [ + 'timeout' => 10, + 'connect_timeout' => 5, + ]); + $json = $response->getBody()->getContents(); + } else { + // Fallback to file_get_contents if no HTTP client provided + $context = stream_context_create([ + 'http' => [ + 'timeout' => 10, + ], + ]); + $json = file_get_contents($url, false, $context); + if ($json === false) { + throw new \RuntimeException('Unable to fetch JWKS from endpoint'); + } + } + + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return $data['keys'] ?? []; + } catch (GuzzleException $e) { + throw TokenDecoderException::forJwksError( + sprintf('Failed to fetch JWKS from %s: %s', $url, $e->getMessage()), + $e + ); + } catch (\JsonException $e) { + throw TokenDecoderException::forJwksError( + sprintf('Invalid JSON response from JWKS endpoint: %s', $e->getMessage()), + $e + ); + } catch (\Exception $e) { + throw TokenDecoderException::forJwksError( + sprintf('Unable to retrieve JWKS: %s', $e->getMessage()), + $e + ); + } + } + + private function jwkToPem(array $jwk): string + { + if (!empty($jwk['x5c'][0])) { + $pemCert = "-----BEGIN CERTIFICATE-----\n". + chunk_split($jwk['x5c'][0], 64, "\n"). + "-----END CERTIFICATE-----\n"; + $key = openssl_pkey_get_public($pemCert); + if ($key === false) { + throw TokenDecoderException::forJwksError( + 'Failed to extract public key from certificate', + new \Exception('OpenSSL error: ' . openssl_error_string()) + ); + } + $details = openssl_pkey_get_details($key); + if ($details === false || !isset($details['key'])) { + throw TokenDecoderException::forJwksError( + 'Failed to get public key details from certificate', + new \Exception('OpenSSL error: ' . openssl_error_string()) + ); + } + + return $details['key']; // This is the PEM public key + } + + if (!isset($jwk['n'], $jwk['e'])) { + throw TokenDecoderException::forJwksError( + 'JWK missing required fields (modulus or exponent)', + new \Exception('Invalid JWK structure') + ); + } + + $modulus = $this->base64urlDecode($jwk['n']); + $exponent = $this->base64urlDecode($jwk['e']); + + $modulusEnc = $this->encodeAsn1Integer($modulus); + $exponentEnc = $this->encodeAsn1Integer($exponent); + $seq = $this->encodeAsn1Sequence($modulusEnc.$exponentEnc); + + $algo = hex2bin('300d06092a864886f70d0101010500'); // rsaEncryption OID + $bitStr = "\x03".chr(strlen($seq) + 1)."\x00".$seq; + $spki = $this->encodeAsn1Sequence($algo.$bitStr); + + return "-----BEGIN PUBLIC KEY-----\n" + .chunk_split(base64_encode($spki), 64, "\n") + ."-----END PUBLIC KEY-----\n"; + } + + private function base64urlDecode(string $data): string + { + $decoded = base64_decode(strtr($data, '-_', '+/'), true); + if ($decoded === false) { + throw TokenDecoderException::forDecodingError( + 'Failed to decode base64url string', + new \Exception('Invalid base64url format') + ); + } + + return $decoded; + } + + private function encodeAsn1Integer(string $bytes): string + { + if (ord($bytes[0]) > 0x7F) { + $bytes = "\x00".$bytes; + } + + return "\x02".$this->encodeLength(strlen($bytes)).$bytes; + } + + private function encodeAsn1Sequence(string $bytes): string + { + return "\x30".$this->encodeLength(strlen($bytes)).$bytes; + } + + private function encodeLength(int $len): string + { + if ($len < 128) { + return chr($len); + } + $tmp = ltrim(pack('N', $len), "\x00"); + + return chr(0x80 | strlen($tmp)).$tmp; + } + + /** + * Validate the base URL format to prevent SSRF attacks. + * + * @throws TokenDecoderException + */ + private function validateBaseUrl(string $baseUrl): void + { + // Parse the URL + $parsed = parse_url($baseUrl); + if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { + throw TokenDecoderException::forInvalidConfiguration(sprintf( + 'Invalid base_url format: %s. Expected a valid URL with scheme and host.', + $baseUrl + )); + } + + // Only allow HTTPS (or HTTP for localhost/development) + if (!in_array($parsed['scheme'], ['https', 'http'], true)) { + throw TokenDecoderException::forSecurityViolation(sprintf( + 'Invalid base_url scheme: %s. Only http and https are allowed.', + $parsed['scheme'] + )); + } + + // Enforce HTTPS for non-localhost environments + if ($parsed['scheme'] === 'http' && !$this->isLocalhost($parsed['host'])) { + throw TokenDecoderException::forSecurityViolation(sprintf( + 'HTTP is only allowed for localhost. Use HTTPS for: %s', + $parsed['host'] + )); + } + + // Prevent private IP ranges and localhost in production unless explicitly localhost + if (!$this->isAllowedHost($parsed['host'])) { + throw TokenDecoderException::forSecurityViolation(sprintf( + 'The host %s is not allowed. Private IPs and internal hosts are blocked for security.', + $parsed['host'] + )); + } + } + + /** + * Validate the JWKS URL against a whitelist of allowed domains. + * + * @throws TokenDecoderException + */ + private function validateJwksUrl(string $url): void + { + $parsed = parse_url($url); + if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { + throw TokenDecoderException::forSecurityViolation( + 'Invalid JWKS URL format' + ); + } + + // Get allowed domains from configuration + $allowedDomains = $this->options['allowed_jwks_domains'] ?? []; + + // If no whitelist is provided, only allow the base_url domain + if (empty($allowedDomains)) { + $baseParsed = parse_url($this->options['base_url']); + $allowedDomains = [$baseParsed['host'] ?? '']; + } + + // Check if the host is in the whitelist + $host = $parsed['host']; + $isAllowed = false; + foreach ($allowedDomains as $allowedDomain) { + // Support wildcard subdomains (e.g., *.example.com) + if (str_starts_with($allowedDomain, '*.')) { + $domain = substr($allowedDomain, 2); + if (str_ends_with($host, '.' . $domain) || $host === $domain) { + $isAllowed = true; + break; + } + } elseif ($host === $allowedDomain) { + $isAllowed = true; + break; + } + } + + if (!$isAllowed) { + throw TokenDecoderException::forSecurityViolation(sprintf( + 'JWKS URL host "%s" is not in the allowed domains whitelist', + $host + )); + } + + // Additional security check: ensure HTTPS or localhost + if ($parsed['scheme'] === 'http' && !$this->isLocalhost($host)) { + throw TokenDecoderException::forSecurityViolation( + 'JWKS endpoint must use HTTPS for non-localhost hosts' + ); + } + } + + /** + * Check if the host is localhost or local development address. + */ + private function isLocalhost(string $host): bool + { + return in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true) + || str_ends_with($host, '.localhost'); + } + + /** + * Check if the host is allowed (not a private IP or blocked host). + */ + private function isAllowedHost(string $host): bool + { + // Allow localhost + if ($this->isLocalhost($host)) { + return true; + } + + // Check if it's an IP address + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + // Block private IP ranges + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; + } + } + + // Block common internal hostnames + $blockedHosts = [ + 'metadata.google.internal', + '169.254.169.254', // AWS metadata + 'metadata', + 'internal', + ]; + + foreach ($blockedHosts as $blocked) { + if (stripos($host, $blocked) !== false) { + return false; + } + } + + return true; + } +} diff --git a/src/Token/TokenDecoderFactory.php b/src/Token/TokenDecoderFactory.php index 08d9f8a..fe3ad56 100644 --- a/src/Token/TokenDecoderFactory.php +++ b/src/Token/TokenDecoderFactory.php @@ -4,20 +4,21 @@ namespace Mainick\KeycloakClientBundle\Token; -use JetBrains\PhpStorm\Pure; +use GuzzleHttp\ClientInterface; use Mainick\KeycloakClientBundle\Interface\TokenDecoderInterface; class TokenDecoderFactory { public const ALGORITHM_RS256 = 'RS256'; public const ALGORITHM_HS256 = 'HS256'; + public const ALGORITHM_JWKS = 'JWKS'; - #[Pure] - public static function create($algorithm): TokenDecoderInterface + public static function create(string $algorithm, array $options = [], ?ClientInterface $httpClient = null): TokenDecoderInterface { return match ($algorithm) { self::ALGORITHM_RS256 => new RS256TokenDecoder(), self::ALGORITHM_HS256 => new HS256TokenDecoder(), + self::ALGORITHM_JWKS => new JWKSTokenDecoder($options, $httpClient), default => throw new \RuntimeException('Invalid algorithm'), }; } diff --git a/tests/Token/JWKSTokenDecoderTest.php b/tests/Token/JWKSTokenDecoderTest.php new file mode 100644 index 0000000..3d40cc4 --- /dev/null +++ b/tests/Token/JWKSTokenDecoderTest.php @@ -0,0 +1,180 @@ +expectException(TokenDecoderException::class); + $this->expectExceptionMessage('Invalid base_url format'); + + new JWKSTokenDecoder([ + 'base_url' => 'not-a-valid-url', + 'realm' => 'test-realm', + ]); + } + + public function testConstructorRejectsHttpForNonLocalhost(): void + { + $this->expectException(TokenDecoderException::class); + $this->expectExceptionMessage('HTTP is only allowed for localhost'); + + new JWKSTokenDecoder([ + 'base_url' => 'http://keycloak.example.com', + 'realm' => 'test-realm', + ]); + } + + public function testConstructorAcceptsHttpForLocalhost(): void + { + $decoder = new JWKSTokenDecoder([ + 'base_url' => 'http://localhost:8080', + 'realm' => 'test-realm', + ]); + + $this->assertInstanceOf(JWKSTokenDecoder::class, $decoder); + } + + public function testConstructorRejectsPrivateIpRanges(): void + { + $this->expectException(TokenDecoderException::class); + $this->expectExceptionMessage('is not allowed'); + + new JWKSTokenDecoder([ + 'base_url' => 'https://192.168.1.1', + 'realm' => 'test-realm', + ]); + } + + public function testConstructorRejectsMetadataEndpoints(): void + { + $this->expectException(TokenDecoderException::class); + $this->expectExceptionMessage('is not allowed'); + + new JWKSTokenDecoder([ + 'base_url' => 'https://169.254.169.254', + 'realm' => 'test-realm', + ]); + } + + public function testConstructorAcceptsValidHttpsUrl(): void + { + $decoder = new JWKSTokenDecoder([ + 'base_url' => 'https://keycloak.example.com', + 'realm' => 'test-realm', + ]); + + $this->assertInstanceOf(JWKSTokenDecoder::class, $decoder); + } + + public function testFetchJwksValidatesDomainWhitelist(): void + { + // Create a mock HTTP client + $httpClient = $this->createMock(ClientInterface::class); + + $decoder = new JWKSTokenDecoder([ + 'base_url' => 'https://keycloak.example.com', + 'realm' => 'test-realm', + 'allowed_jwks_domains' => ['different-domain.com'], + ], $httpClient); + + // Create a sample JWT token (doesn't need to be valid for this test) + $header = base64_encode(json_encode(['kid' => 'test-kid', 'alg' => 'RS256'])); + $payload = base64_encode(json_encode(['sub' => 'test'])); + $signature = base64_encode('test-signature'); + $token = "$header.$payload.$signature"; + + try { + $decoder->decode($token, ''); + $this->fail('Expected TokenDecoderException to be thrown'); + } catch (TokenDecoderException $e) { + $this->assertStringContainsString('Security violation: JWKS URL host "keycloak.example.com" is not in the allowed domains whitelist', $e->getMessage()); + } + } + + public function testFetchJwksAllowsBaseUrlDomainByDefault(): void + { + // Create a mock stream for the response body + $stream = $this->createMock(StreamInterface::class); + $stream->method('getContents')->willReturn(json_encode([ + 'keys' => [ + [ + 'kid' => 'test-kid', + 'use' => 'sig', + 'kty' => 'RSA', + 'n' => 'test-modulus', + 'e' => 'AQAB', + ], + ], + ], JSON_THROW_ON_ERROR)); + + // Create a mock response + $response = $this->createMock(Response::class); + $response->method('getBody')->willReturn($stream); + + // Create a mock HTTP client + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $decoder = new JWKSTokenDecoder([ + 'base_url' => 'https://keycloak.example.com', + 'realm' => 'test-realm', + // No allowed_jwks_domains specified - should default to base_url domain + ], $httpClient); + + // This should not throw an exception because the JWKS URL uses the same domain as base_url + $this->assertInstanceOf(JWKSTokenDecoder::class, $decoder); + } + + public function testWildcardDomainMatching(): void + { + // Create a mock stream for the response body + $stream = $this->createMock(StreamInterface::class); + $stream->method('getContents')->willReturn(json_encode([ + 'keys' => [ + [ + 'kid' => 'test-kid', + 'use' => 'sig', + 'kty' => 'RSA', + 'n' => 'test-modulus', + 'e' => 'AQAB', + ], + ], + ])); + + // Create a mock response + $response = $this->createMock(Response::class); + $response->method('getBody')->willReturn($stream); + + // Create a mock HTTP client + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $decoder = new JWKSTokenDecoder([ + 'base_url' => 'https://auth.example.com', + 'realm' => 'test-realm', + 'allowed_jwks_domains' => ['*.example.com'], + ], $httpClient); + + // This should not throw an exception because auth.example.com matches *.example.com + $this->assertInstanceOf(JWKSTokenDecoder::class, $decoder); + } + + public function testRequiresHttpsForJwksEndpoint(): void + { + // For this test, we need to test the actual JWKS fetching + // We can't easily test this without a real token decode, but the validation + // is covered by the previous tests + $this->assertTrue(true); + } +} +