From 5947d7f51844402541e8ce21ac6923a5e6da8920 Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Mon, 17 Nov 2025 18:18:51 -0500 Subject: [PATCH] feat: add JWK support for JWT validation and update dependencies --- composer.json | 3 +- composer.lock | 423 +++++++++++++++++- config.php | 2 +- docker-compose.yml | 4 +- src/Attributes/Guards/Scope.php | 14 +- src/Http/Middleware/JwtMiddleware.php | 156 ++++++- src/Http/Middleware/ScopeMiddleware.php | 7 +- tests/Http/Middleware/ScopeMiddlewareTest.php | 2 +- 8 files changed, 596 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index dd00d9a..622e8b2 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "robinvdvleuten/ulid": "^5.0", "monolog/monolog": "^3.9", "react/promise": "^3", - "react/async": "^4" + "react/async": "^4", + "guzzlehttp/guzzle": "^7.10" }, "require-dev": { "phpunit/phpunit": "^12.4", diff --git a/composer.lock b/composer.lock index 736ee65..2c208b5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "856fdd307835b635e6e912a2d5028515", + "content-hash": "8c2444c3a25a3469cf369de1c085ad01", "packages": [ { "name": "adhocore/cli", @@ -342,6 +342,331 @@ }, "time": "2025-11-12T21:58:05+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, { "name": "illuminate/collections", "version": "v12.38.1", @@ -1476,6 +1801,58 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, { "name": "psr/http-factory", "version": "1.1.0", @@ -1798,6 +2175,50 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "react/async", "version": "v4.3.0", diff --git a/config.php b/config.php index 46129d9..9da772b 100644 --- a/config.php +++ b/config.php @@ -51,7 +51,7 @@ return [ 'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'), 'audience' => Env::get('JWT_AUDIENCE', 'my_audience'), 'issuer' => Env::get('JWT_ISSUER', 'my_issuer'), - 'strict_validation' => Env::get('JWT_STRICT_VALIDATION', true, 'bool'), + 'strict_validation' => Env::get('JWT_STRICT_VALIDATION', false, 'bool'), ], 'queue' => [ diff --git a/docker-compose.yml b/docker-compose.yml index 2a5bbd0..38e0e6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,13 +83,15 @@ services: postgres: condition: service_healthy environment: + JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/ + JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9 + JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration QUEUE_BROKER: redis PHP_IDE_CONFIG: serverName=localhost WORKERS: 1 DEBUG: 1 REDIS_HOST: redis DB_HOST: postgres - JWT_SIGNING_KEY: a-string-secret-at-least-256-bits-long ## Kafka and Zookeeper for local development kafka-ui: diff --git a/src/Attributes/Guards/Scope.php b/src/Attributes/Guards/Scope.php index 9ee76a5..f86ac36 100644 --- a/src/Attributes/Guards/Scope.php +++ b/src/Attributes/Guards/Scope.php @@ -10,7 +10,9 @@ use Attribute; readonly class Scope { public function __construct( - private array $scopes = [] + private array $scopes = [], + private string $claim = 'scope', + private string $separator = ' ', ) { } @@ -18,4 +20,14 @@ readonly class Scope { return $this->scopes; } + + public function getClaim(): string + { + return $this->claim; + } + + public function getSeparator(): string + { + return $this->separator; + } } diff --git a/src/Http/Middleware/JwtMiddleware.php b/src/Http/Middleware/JwtMiddleware.php index f5d8e7f..4df6136 100644 --- a/src/Http/Middleware/JwtMiddleware.php +++ b/src/Http/Middleware/JwtMiddleware.php @@ -6,8 +6,11 @@ namespace Siteworxpro\App\Http\Middleware; use Carbon\Carbon; use Carbon\WrapperClock; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use Lcobucci\JWT\JwtFacade; use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256; +use Lcobucci\JWT\Signer\Key; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token\InvalidTokenStructure; @@ -25,6 +28,7 @@ use Siteworxpro\App\Attributes\Guards\Jwt; use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Services\Facades\Config; +use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\HttpStatus\CodesEnum; /** @@ -114,7 +118,7 @@ class JwtMiddleware extends Middleware // Parse and validate the token with signature, time, issuer and audience constraints. $jwt = new JwtFacade()->parse( $token, - $this->getSignedWith(), + $this->getSignedWith($token), Config::get('jwt.strict_validation') ? new StrictValidAt(new WrapperClock(Carbon::now())) : new LooseValidAt(new WrapperClock(Carbon::now())), @@ -139,6 +143,11 @@ class JwtMiddleware extends Middleware 'status_code' => 401, 'message' => 'Unauthorized: Invalid token', ], CodesEnum::UNAUTHORIZED); + } catch (GuzzleException) { + return JsonResponseFactory::createJsonResponse([ + 'status_code' => 501, + 'message' => 'Token validation service unavailable', + ], CodesEnum::UNAUTHORIZED); } // Expose all token claims as request attributes for downstream consumers. @@ -161,20 +170,31 @@ class JwtMiddleware extends Middleware * @return SignedWith Signature constraint used during JWT parsing. * * @throws \RuntimeException When no signing key is configured. + * @throws GuzzleException On JWKS key retrieval issues. + * @throws \JsonException */ - private function getSignedWith(): SignedWith + private function getSignedWith(string $token): SignedWith { - $key = Config::get('jwt.signing_key'); + $keyConfig = Config::get('jwt.signing_key'); - if ($key === null) { + if ($keyConfig === null) { throw new \RuntimeException('JWT signing key is not configured.'); } - // Load key either from file or raw text. - if (str_starts_with($key, 'file://')) { - $key = InMemory::file(substr($key, 7)); + // file:// path to key + if (str_starts_with($keyConfig, 'file://')) { + $key = InMemory::file(substr($keyConfig, 7)); + // openid jwks url + } elseif (str_contains($keyConfig, '.well-known/')) { + $jwt = explode('.', $token); + if (count($jwt) !== 3) { + throw new \RuntimeException('Invalid JWT structure for JWKS key retrieval.'); + } + $header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR); + $keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present + $key = $this->getJwksKey($keyConfig, $keyId); } else { - $key = InMemory::plainText($key); + $key = InMemory::plainText($keyConfig); } // Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC. @@ -184,4 +204,124 @@ class JwtMiddleware extends Middleware return new SignedWith(new Hmac256(), $key); } + + /** + * @throws GuzzleException + */ + private function getJwksKey(string $url, string $keyId): Key + { + $cached = Redis::get('jwks_key_' . $keyId); + if ($cached !== null) { + return InMemory::plainText($cached); + } + + $client = new Client(); + $openIdConfig = $client->get($url); + $body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR); + $jwksUri = $body['jwks_uri'] ?? ''; + if (empty($jwksUri)) { + throw new \RuntimeException('JWKS URI not found in OpenID configuration.'); + } + + $jwksResponse = $client->get($jwksUri); + $jwksBody = json_decode( + $jwksResponse->getBody()->getContents(), + true, + JSON_THROW_ON_ERROR + ); + + // For simplicity, we take the first key in the JWKS. + $firstKey = array_filter( + $jwksBody['keys'], + fn($key) => $key['kid'] === $keyId + )[0] ?? null; + + if (empty($firstKey)) { + throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId); + } + + $n = $firstKey['n']; + $e = $firstKey['e']; + $publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" . + chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) . + "-----END PUBLIC KEY-----\n"; + + Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600); + + return InMemory::plainText($publicKeyPem); + } + + /** + * Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'. + * Returns raw DER bytes; caller base64-encodes and wraps with PEM headers. + */ + private function convertJwkToPem(string $n, string $e): string + { + $modulus = $this->base64UrlDecode($n); + $exponent = $this->base64UrlDecode($e); + + $derN = $this->derEncodeInteger($modulus); + $derE = $this->derEncodeInteger($exponent); + + // RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER } + $rsaPublicKey = $this->derEncodeSequence($derN . $derE); + + // AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL + $algId = hex2bin('300d06092a864886f70d0101010500'); + + // SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey + $subjectPublicKey = $this->derEncodeBitString($rsaPublicKey); + + // SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } + return $this->derEncodeSequence($algId . $subjectPublicKey); + } + + private function base64UrlDecode(string $data): string + { + $data = strtr($data, '-_', '+/'); + $pad = strlen($data) % 4; + if ($pad) { + $data .= str_repeat('=', 4 - $pad); + } + return base64_decode($data); + } + + private function derEncodeLength(int $len): string + { + if ($len < 0x80) { + return chr($len); + } + $bytes = ''; + while ($len > 0) { + $bytes = chr($len & 0xFF) . $bytes; + $len >>= 8; + } + return chr(0x80 | strlen($bytes)) . $bytes; + } + + private function derEncodeInteger(string $bytes): string + { + // Remove leading zeroes + $bytes = ltrim($bytes, "\x00"); + if ($bytes === '') { + $bytes = "\x00"; + } + // Ensure positive INTEGER (prepend 0x00 if MSB set) + if ((ord($bytes[0]) & 0x80) !== 0) { + $bytes = "\x00" . $bytes; + } + return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes; + } + + private function derEncodeSequence(string $bytes): string + { + return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes; + } + + private function derEncodeBitString(string $bytes): string + { + // 0 unused bits + data + $payload = "\x00" . $bytes; + return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload; + } } diff --git a/src/Http/Middleware/ScopeMiddleware.php b/src/Http/Middleware/ScopeMiddleware.php index fb9d5d0..19a144e 100644 --- a/src/Http/Middleware/ScopeMiddleware.php +++ b/src/Http/Middleware/ScopeMiddleware.php @@ -69,7 +69,12 @@ class ScopeMiddleware extends Middleware $requiredScopes = $scopeInstance->getScopes(); // Retrieve user scopes from the request (defaults to an empty array). - $userScopes = $request->getAttribute('scopes', []); + $userScopes = $request->getAttribute($scopeInstance->getClaim(), []); + + if (!is_array($userScopes)) { + // If user scopes are not an array, treat as no scopes provided. + $userScopes = explode($scopeInstance->getSeparator(), (string) $userScopes); + } // Deny if any required scope is missing from the user's scopes. if ( diff --git a/tests/Http/Middleware/ScopeMiddlewareTest.php b/tests/Http/Middleware/ScopeMiddlewareTest.php index 57de8d9..b8f0642 100644 --- a/tests/Http/Middleware/ScopeMiddlewareTest.php +++ b/tests/Http/Middleware/ScopeMiddlewareTest.php @@ -74,7 +74,7 @@ class ScopeMiddlewareTest extends Middleware ->once() ->andReturn(new Response(200)); - $request = new ServerRequest('GET', '/')->withAttribute('scopes', ['admin', 'user']); + $request = new ServerRequest('GET', '/')->withAttribute('scope', ['admin', 'user']); $middleware = new ScopeMiddleware(); $response = $middleware->process($request, $handler); $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());