Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/Provider/KeycloakClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ public function verifyToken(AccessTokenInterface $token): ?UserRepresentationDTO
'values' => $token->getValues(),
]);

$decoder = TokenDecoderFactory::create($this->encryption_algorithm, ['base_url' => $this->base_url, 'realm' => $this->realm], $this->httpClient);
$decoder = TokenDecoderFactory::create(
$this->encryption_algorithm,
$this->httpClient,
['base_url' => $this->base_url, 'realm' => $this->realm]
);
$tokenDecoded = $decoder->decode($accessToken->getToken(), $this->encryption_key);
$decoder->validateToken($this->realm, $tokenDecoded);
$this->keycloakClientLogger->info('KeycloakClient::verifyToken', [
Expand Down
187 changes: 73 additions & 114 deletions src/Token/JWKSTokenDecoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,32 @@

namespace Mainick\KeycloakClientBundle\Token;

use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Mainick\KeycloakClientBundle\Exception\TokenDecoderException;
use Mainick\KeycloakClientBundle\Interface\TokenDecoderInterface;

final class JWKSTokenDecoder implements TokenDecoderInterface
final readonly class JWKSTokenDecoder implements TokenDecoderInterface
{

public function __construct(
private readonly array $options,
private readonly ?ClientInterface $httpClient = null
private ClientInterface $httpClient,
private array $options
)
{
foreach ($options as $allowOption => $value) {
if (!\in_array($allowOption, ['base_url', 'realm', 'alg', 'http_timeout', 'http_connect_timeout', 'allowed_jwks_domains'], true)) {
throw TokenDecoderException::forInvalidConfiguration(\sprintf(
"Unknown option '%s' for %s",
$allowOption,
self::class
));
}
}

foreach (['base_url', 'realm'] as $requiredOption) {
if (!\array_key_exists($requiredOption, $this->options) || $this->options[$requiredOption] === null || $this->options[$requiredOption] === '') {
throw TokenDecoderException::forInvalidConfiguration(\sprintf(
Expand Down Expand Up @@ -50,30 +61,39 @@ public function __construct(
public function decode(string $token, string $key): array
{
try {
[$headerB64] = explode('.', $token, 2);
$parts = explode('.', $token);
if (\count($parts) !== 3 || $parts[0] === '' || $parts[1] === '' || $parts[2] === '') {
throw TokenDecoderException::forDecodingError(
'Invalid JWT format: token must consist of header.payload.signature',
new \Exception('invalid token format')
);
}

[$headerB64] = $parts;
$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'));
// Enforce a server-side algorithm instead of trusting the token header.
// Default to RS256 (commonly used by Keycloak) if not explicitly configured.
$algorithm = $this->options['alg'] ?? 'RS256';
if (isset($header['alg']) && $algorithm !== (string) $header['alg']) {
throw TokenDecoderException::forDecodingError(
sprintf('Token algorithm "%s" does not match expected algorithm "%s"', $header['alg'], $algorithm),
new \Exception('algorithm mismatch')
);
}

$keyPem = $this->getPemKeyForKid($kid);
$tokenDecoded = JWT::decode($token, new Key($keyPem, $alg));
$keyObject = $this->getKeyForKid($kid, $algorithm);
$tokenDecoded = JWT::decode($token, $keyObject);

$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);
}
Expand All @@ -98,54 +118,64 @@ public function validateToken(string $realm, array $tokenDecoded): void
}
}

private function getPemKeyForKid(string $kid): string
private function getKeyForKid(string $kid, string $algorithm): Key
{
$jwks = $this->fetchJwks();
if (empty($jwks)) {
$jwksData = $this->fetchJwks();
if (empty($jwksData['keys'])) {
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);
}

// Filter to only include signing keys
$signingKeys = array_filter($jwksData['keys'], fn($jwk) => ($jwk['use'] ?? 'sig') === 'sig');
if (empty($signingKeys)) {
throw TokenDecoderException::forJwksError('No signing keys found in JWKS endpoint', new \Exception('No sig keys in JWKS'));
}

throw TokenDecoderException::forJwksError(
sprintf('No matching signing key found for kid: %s', $kid),
new \Exception('Key ID not found in JWKS')
);
try {
$keys = JWK::parseKeySet(['keys' => array_values($signingKeys)], $algorithm);
} catch (\Exception $e) {
throw TokenDecoderException::forJwksError(
sprintf('Failed to parse JWKS: %s', $e->getMessage()),
$e
);
}

if (!isset($keys[$kid])) {
throw TokenDecoderException::forJwksError(
sprintf('No matching signing key found for kid: %s', $kid),
new \Exception('Key ID not found in JWKS')
);
}

return $keys[$kid];
}

private function fetchJwks(): array
{
$timeout = $this->options['http_timeout'] ?? 10;
$connectTimeout = $this->options['http_connect_timeout'] ?? 5;
$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');
}
if ($this->httpClient === null) {
throw TokenDecoderException::forJwksError(
'HTTP client is not configured; unable to fetch JWKS.',
new \RuntimeException('Missing HTTP client for JWKS retrieval')
);
}

$response = $this->httpClient->request('GET', $url, [
'timeout' => $timeout,
'connect_timeout' => $connectTimeout,
]);
$json = $response->getBody()->getContents();

$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

return $data['keys'] ?? [];
return $data;
} catch (GuzzleException $e) {
throw TokenDecoderException::forJwksError(
sprintf('Failed to fetch JWKS from %s: %s', $url, $e->getMessage()),
Expand All @@ -164,53 +194,6 @@ private function fetchJwks(): array
}
}

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);
Expand All @@ -224,30 +207,6 @@ private function base64urlDecode(string $data): string
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.
*
Expand Down Expand Up @@ -319,7 +278,7 @@ private function validateJwksUrl(string $url): void
// Support wildcard subdomains (e.g., *.example.com)
if (str_starts_with($allowedDomain, '*.')) {
$domain = substr($allowedDomain, 2);
if (str_ends_with($host, '.' . $domain) || $host === $domain) {
if ($host === $domain || str_ends_with($host, '.' . $domain)) {
$isAllowed = true;
break;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Token/TokenDecoderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ class TokenDecoderFactory
public const ALGORITHM_HS256 = 'HS256';
public const ALGORITHM_JWKS = 'JWKS';

public static function create(string $algorithm, array $options = [], ?ClientInterface $httpClient = null): TokenDecoderInterface
public static function create(string $algorithm, ClientInterface $httpClient, array $options = []): TokenDecoderInterface
{
return match ($algorithm) {
self::ALGORITHM_RS256 => new RS256TokenDecoder(),
self::ALGORITHM_HS256 => new HS256TokenDecoder(),
self::ALGORITHM_JWKS => new JWKSTokenDecoder($options, $httpClient),
self::ALGORITHM_JWKS => new JWKSTokenDecoder($httpClient, $options),
default => throw new \RuntimeException('Invalid algorithm'),
};
}
Expand Down
Loading