From 721008bdfc468e53d93c015435ca96e201248ae7 Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Tue, 25 Nov 2025 16:51:45 +0000 Subject: [PATCH] feat: implement Guzzle facade and update JwtMiddleware to use it (#22) Reviewed-on: https://gitea.siteworxpro.com/siteworxpro/Php-Template/pulls/22 Co-authored-by: Ron Rise Co-committed-by: Ron Rise --- bin/xdebug.sh | 10 +- src/Http/Middleware/JwtMiddleware.php | 25 ++-- src/Services/Facades/Guzzle.php | 28 ++++ tests/Facades/GuzzleTest.php | 21 +++ tests/Http/Middleware/JwtMiddlewareTest.php | 140 +++++++++++++++++++- 5 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 src/Services/Facades/Guzzle.php create mode 100644 tests/Facades/GuzzleTest.php diff --git a/bin/xdebug.sh b/bin/xdebug.sh index eb10825..dbeca36 100755 --- a/bin/xdebug.sh +++ b/bin/xdebug.sh @@ -4,9 +4,9 @@ echo "Installing xDebug" apk add make gcc linux-headers autoconf alpine-sdk -curl -sL https://github.com/xdebug/xdebug/archive/3.4.0.tar.gz -o 3.4.0.tar.gz -tar -xvf 3.4.0.tar.gz -cd xdebug-3.4.0 || exit +curl -sL https://github.com/xdebug/xdebug/archive/3.5.0alpha3.tar.gz -o 3.5.0alpha3.tar.gz +tar -xvf 3.5.0alpha3.tar.gz +cd xdebug-3.5.0alpha3 || exit phpize ./configure --enable-xdebug make @@ -20,5 +20,5 @@ xdebug.client_host = host.docker.internal " > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini cd .. -rm -rf xdebug-3.4.0 -rm -rf 3.4.0.tar.gz +rm -rf xdebug-3.5.0alpha3 +rm -rf 3.5.0alpha3.tar.gz diff --git a/src/Http/Middleware/JwtMiddleware.php b/src/Http/Middleware/JwtMiddleware.php index 4df6136..a9f87e1 100644 --- a/src/Http/Middleware/JwtMiddleware.php +++ b/src/Http/Middleware/JwtMiddleware.php @@ -6,7 +6,6 @@ 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; @@ -28,6 +27,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\Guzzle; use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\HttpStatus\CodesEnum; @@ -133,21 +133,21 @@ class JwtMiddleware extends Middleware } return JsonResponseFactory::createJsonResponse([ - 'status_code' => 401, + 'status_code' => CodesEnum::UNAUTHORIZED->value, 'message' => 'Unauthorized: Invalid token', 'errors' => $violations ], CodesEnum::UNAUTHORIZED); } catch (InvalidTokenStructure) { // Token could not be parsed due to malformed structure. return JsonResponseFactory::createJsonResponse([ - 'status_code' => 401, + 'status_code' => CodesEnum::UNAUTHORIZED->value, 'message' => 'Unauthorized: Invalid token', ], CodesEnum::UNAUTHORIZED); - } catch (GuzzleException) { + } catch (GuzzleException | \RuntimeException) { return JsonResponseFactory::createJsonResponse([ - 'status_code' => 501, - 'message' => 'Token validation service unavailable', - ], CodesEnum::UNAUTHORIZED); + 'status_code' => CodesEnum::INTERNAL_SERVER_ERROR->value, + 'message' => 'Token validation service unavailable or unknown error', + ], CodesEnum::INTERNAL_SERVER_ERROR); } // Expose all token claims as request attributes for downstream consumers. @@ -170,7 +170,6 @@ 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(string $token): SignedWith @@ -205,9 +204,6 @@ 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); @@ -215,15 +211,14 @@ class JwtMiddleware extends Middleware return InMemory::plainText($cached); } - $client = new Client(); - $openIdConfig = $client->get($url); + $openIdConfig = Guzzle::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); + $jwksResponse = Guzzle::get($jwksUri); $jwksBody = json_decode( $jwksResponse->getBody()->getContents(), true, @@ -234,7 +229,7 @@ class JwtMiddleware extends Middleware $firstKey = array_filter( $jwksBody['keys'], fn($key) => $key['kid'] === $keyId - )[0] ?? null; + )[0] ?? $jwksBody['keys'][0] ?? null; if (empty($firstKey)) { throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId); diff --git a/src/Services/Facades/Guzzle.php b/src/Services/Facades/Guzzle.php new file mode 100644 index 0000000..480a6ca --- /dev/null +++ b/src/Services/Facades/Guzzle.php @@ -0,0 +1,28 @@ +assertEquals(CodesEnum::OK->value, $response->getStatusCode()); } + /** + * @throws \JsonException + */ + public function testJwtFromJwkEndpoint() + { + Config::set('jwt.audience', 'https://client-app.io'); + Config::set('jwt.issuer', 'https://api.my-awesome-app.io'); + + Redis::partialMock()->shouldReceive('get')->andReturn(null); + Redis::shouldReceive('set')->andReturn('OK'); + Guzzle::partialMock()->shouldReceive('get') + ->with('https://test.com/.well-known/openid-configuration') + ->andReturn(new Response(200, [], json_encode([ + 'jwks_uri' => 'https://test.com/keys' + ], JSON_THROW_ON_ERROR))); + + Guzzle::shouldReceive('get') + ->with('https://test.com/keys') + ->andReturn(new Response(200, [], self::TEST_JWKS_JSON)); + + Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testCatchesInvalidJwksUrl() + { + Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration'); + Redis::partialMock()->shouldReceive('get')->andReturn(null); + Redis::shouldReceive('set')->andReturn('OK'); + Guzzle::partialMock()->shouldReceive('get') + ->with('https://test.com/.well-known/openid-configuration') + ->andReturn(new Response(200, [], json_encode([], JSON_THROW_ON_ERROR))); + + + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::INTERNAL_SERVER_ERROR->value, $response->getStatusCode()); + } + + private function getJwtRsa(): string + { + $key = InMemory::plainText(self::TEST_RSA_PRIVATE_KEY); + $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); + $token = new JwtFacade()->issue( + $signer, + $key, + static fn( + Builder $builder, + DateTimeImmutable $issuedAt + ): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') + ->expiresAt($issuedAt->modify('+10 minutes')) + ); + + return $token->toString(); + } + private function getJwt(): string { $key = InMemory::plainText(self::TEST_SIGNING_KEY); @@ -216,7 +352,7 @@ class JwtMiddlewareTest extends Middleware $token = new JwtFacade()->issue( $signer, $key, - static fn ( + static fn( Builder $builder, DateTimeImmutable $issuedAt ): Builder => $builder