From 0385a58bfae8b56e90a8681329021a2d80fca9fa Mon Sep 17 00:00:00 2001 From: dsimons Date: Mon, 19 Oct 2026 15:50:02 +0200 Subject: [PATCH 1/3] fix: accept a content type with parameters like charset for the access token response --- changelog.md | 76 +++++-- .../OAuth2/ClientCredentialsDecorator.php | 19 +- .../Client/ClientCredentialsDecoratorTest.php | 197 ++++++++++++++++++ 3 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 tests/Unit/Client/ClientCredentialsDecoratorTest.php diff --git a/changelog.md b/changelog.md index 96bd3cd..dd5ab8f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,28 +1,70 @@ +## 1.1.3 - 2026-10-19 +- Fixed: The `ClientCredentialsDecorator` accepts an access token response whose content type carries parameters, e.g. `application/json;charset=UTF-8` (previously rejected with "Content type should be: application/json"). +- Fixed: The exception for an invalid status code of the access token request now contains the status code. + +## 1.1.2 - 2026-02-18 +- Widen the Guzzle dependency range from `~7.9.2` to `^7.9.2`. + +## 1.1.1 - 2025-07-03 +- Added the option `strictRfc` to `ClientCredentialsDecorator::fromClientCredentials()`: it sends the client id and secret only in the authorization header and removes them from the request body. + +## 1.1.0 - 2025-05-30 +- Add support for `psr/log` 2 and 3. + +## 1.0.1 - 2025-02-04 +- Remove the version from `composer.json`. + ## 1.0.0 - 2025-01-20 - Drop support for PHP 8.1, PHP 8.2 and PHP 8.3. - Add Support for PHP 8.4. -## 0.1.0 - 2020-02-12 -* Initial release +## 0.2.7 - 2025-06-04 +- Added the option `strictRfc` to `ClientCredentialsDecorator::fromClientCredentials()`: it sends the client id and secret only in the authorization header and removes them from the request body. -## 0.1.1 - 2020-07-23 -* Added client option 'sink' (GuzzleRequestOptions::SINK) +## 0.2.6 - 2025-02-04 +- Add support for `psr/http-message` 2. -## 0.1.2 - 2021-07-13 -* Fixed null response in exception handling -* Added ClientOptionsModifier classes -* Added type hints for properties +## 0.2.5 - 2025-02-02 +- Require `psr/http-message` 1 because of incompatibilities with version 2. -## 0.2.0 - 2022-08-02 -* Update to php 8.1 -* Update to guzzle 7.4.5 -* Fixed incorrect test namespaces -* Added psalm support +## 0.2.4 - 2024-10-24 +- Replace the deprecated `utf8_encode()` in `ClientCredentials`. +- Fixed `ResponseException::fromResponse()` and `TransferException::fromRequest()` to create the exception class they are called on. +- Added PHPStan. -## 0.2.1 - 2022-10-12 -* Added optional parameter to set custom mime type for json encoder -* Added media type constant for application/vnd.api+json +## 0.2.3 - 2023-07-28 +- Added a readme and updated the license. +- Update `guzzlehttp/psr7` to 2.5.0. ## 0.2.2 - 2023-01-25 -* Fixed null response in exception handling +- Fixed null response in exception handling + +## 0.2.1 - 2022-10-12 +- Added optional parameter to set custom mime type for json encoder +- Added media type constant for application/vnd.api+json + +## 0.2.0 - 2022-08-02 +- Update to php 8.1 +- Update to guzzle 7.4.5 +- Fixed incorrect test namespaces +- Added psalm support + +## 0.1.5 - 2023-02-07 +- Fixed null response in `ResponseException`. + +## 0.1.4 - 2022-10-13 +- Removed `ext-http` from `composer.json`. + +## 0.1.3 - 2022-10-13 +- Backport of 0.2.1: optional parameter to set a custom mime type for the json encoder and the media type constant for application/vnd.api+json. +## 0.1.2 - 2021-07-13 +- Fixed null response in exception handling +- Added ClientOptionsModifier classes +- Added type hints for properties + +## 0.1.1 - 2020-07-23 +- Added client option 'sink' (GuzzleRequestOptions::SINK) + +## 0.1.0 - 2020-02-12 +- Initial release diff --git a/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php b/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php index 3ce4700..739a011 100644 --- a/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php +++ b/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php @@ -140,19 +140,26 @@ private function assertIsValidJsonResponse(Response $response): void if ($response->getStatusCode() !== 200) { throw new RuntimeException( sprintf( - 'Invalid status code: s% for access token request, Body: %s', + 'Invalid status code: %s for access token request, Body: %s', $response->getStatusCode(), $response->getBody()->__toString(), ), ); } - // According RFC header fields are case incentive, normalize to lower for comparison - $contentType = $response->getHeader(HeaderField::CONTENT_TYPE); - $contentType = array_map('strtolower', $contentType); - - if (!in_array(MediaType::JSON, $contentType)) { + if ($this->extractMediaType($response->getHeaderLine(HeaderField::CONTENT_TYPE)) !== MediaType::JSON) { throw new RuntimeException('Content type should be: ' . MediaType::JSON); } } + + /** + * Returns the media type of a content type header value like "application/json; charset=UTF-8". Parameters like + * "charset" do not change the media type, and the media type is case-insensitive. + */ + private function extractMediaType(string $contentType): string + { + [$mediaType] = explode(';', $contentType, 2); + + return strtolower(trim($mediaType)); + } } diff --git a/tests/Unit/Client/ClientCredentialsDecoratorTest.php b/tests/Unit/Client/ClientCredentialsDecoratorTest.php new file mode 100644 index 0000000..a6f79ff --- /dev/null +++ b/tests/Unit/Client/ClientCredentialsDecoratorTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\HttpClient\Tests\Unit\Client; + +use Artemeon\HttpClient\Client\Decorator\OAuth2\ClientCredentials; +use Artemeon\HttpClient\Client\Decorator\OAuth2\ClientCredentialsDecorator; +use Artemeon\HttpClient\Client\HttpClient; +use Artemeon\HttpClient\Exception\Request\Http\ServerResponseException; +use Artemeon\HttpClient\Exception\RuntimeException; +use Artemeon\HttpClient\Http\Header\Header; +use Artemeon\HttpClient\Http\Header\Headers; +use Artemeon\HttpClient\Http\Request; +use Artemeon\HttpClient\Http\Response; +use Artemeon\HttpClient\Http\Uri; +use Artemeon\HttpClient\Stream\Stream; +use Mockery; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use Mockery\MockInterface; +use Override; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +class ClientCredentialsDecoratorTest extends TestCase +{ + use MockeryPHPUnitIntegration; + + private const string TOKEN_URL = 'https://auth.example.com/oauth2/token'; + + private HttpClient & MockInterface $httpClient; + + #[Override] + protected function setUp(): void + { + $this->httpClient = Mockery::mock(HttpClient::class); + } + + #[DataProvider('provideJsonContentTypes')] + public function testAccessTokenIsAcceptedForJsonContentType(string $contentType): void + { + /** @var Request|null $request */ + $request = null; + $this->httpClient->expects('send')->twice()->andReturnUsing(static function (Request $sent) use (&$request, $contentType): Response { + if ((string) $sent->getUri() === self::TOKEN_URL) { + return self::tokenResponse($contentType); + } + + $request = $sent; + + return new Response(200, '1.1'); + }); + + $this->createDecorator()->send(Request::forGet(Uri::fromString('https://api.example.com/resource'))); + + self::assertInstanceOf(Request::class, $request); + self::assertSame('Bearer token-4711', $request->getHeaderLine('Authorization')); + } + + /** + * @return array + */ + public static function provideJsonContentTypes(): array + { + return [ + 'plain' => ['application/json'], + 'charset without space' => ['application/json;charset=UTF-8'], + 'charset with space' => ['application/json; charset=utf-8'], + 'upper case' => ['Application/JSON'], + ]; + } + + #[DataProvider('provideOtherContentTypes')] + public function testAccessTokenIsRejectedForOtherContentType(?string $contentType): void + { + $this->httpClient->expects('send')->once()->andReturn(self::tokenResponse($contentType)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Content type should be: application/json'); + + $this->createDecorator()->send(Request::forGet(Uri::fromString('https://api.example.com/resource'))); + } + + /** + * @return array + */ + public static function provideOtherContentTypes(): array + { + return [ + 'html' => ['text/html'], + 'json as prefix of another type' => ['application/jsonp'], + 'missing header' => [null], + ]; + } + + public function testInvalidStatusCodeIsReportedWithTheStatusCode(): void + { + $this->httpClient->expects('send')->once()->andReturn(new Response(401, '1.1', Stream::fromString('invalid_client'))); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid status code: 401 for access token request, Body: invalid_client'); + + $this->createDecorator()->send(Request::forGet(Uri::fromString('https://api.example.com/resource'))); + } + + public function testClientCredentialsAreSentInTheBodyByDefault(): void + { + $tokenRequest = $this->sendAndCaptureTokenRequest(strictRfc: false); + + self::assertSame('grant_type=client_credentials&client_id=client&client_secret=secret', $tokenRequest->getBody()->__toString()); + self::assertFalse($tokenRequest->hasHeader('Authorization')); + } + + public function testStrictRfcSendsTheClientCredentialsOnlyInTheAuthorizationHeader(): void + { + $tokenRequest = $this->sendAndCaptureTokenRequest(strictRfc: true); + + self::assertSame('grant_type=client_credentials', $tokenRequest->getBody()->__toString()); + self::assertSame('Basic ' . base64_encode('client:secret'), $tokenRequest->getHeaderLine('Authorization')); + } + + public function testFailedAccessTokenRequestIsReportedWithTheCause(): void + { + $cause = ServerResponseException::fromResponse( + new Response(500, '1.1'), + Request::forGet(Uri::fromString(self::TOKEN_URL)), + 'Internal Server Error', + ); + $this->httpClient->expects('send')->once()->andThrow($cause); + + try { + $this->createDecorator()->send(Request::forGet(Uri::fromString('https://api.example.com/resource'))); + self::fail('The failed access token request was not reported.'); + } catch (RuntimeException $exception) { + self::assertSame('Cant request access token', $exception->getMessage()); + self::assertSame($cause, $exception->getPrevious()); + } + } + + private function sendAndCaptureTokenRequest(bool $strictRfc): Request + { + /** @var Request|null $tokenRequest */ + $tokenRequest = null; + $this->httpClient->expects('send')->twice()->andReturnUsing(static function (Request $sent) use (&$tokenRequest): Response { + if ((string) $sent->getUri() !== self::TOKEN_URL) { + return new Response(200, '1.1'); + } + + $tokenRequest = $sent; + + return self::tokenResponse('application/json'); + }); + + $this->createDecorator($strictRfc)->send(Request::forGet(Uri::fromString('https://api.example.com/resource'))); + + self::assertInstanceOf(Request::class, $tokenRequest); + + return $tokenRequest; + } + + private function createDecorator(bool $strictRfc = false): ClientCredentialsDecorator + { + return ClientCredentialsDecorator::fromClientCredentials( + ClientCredentials::forHeaderAuthorization('client', 'secret'), + Uri::fromString(self::TOKEN_URL), + $this->httpClient, + strictRfc: $strictRfc, + ); + } + + private static function tokenResponse(?string $contentType): Response + { + $headers = Headers::create(); + if ($contentType !== null) { + $headers->add(Header::fromString('Content-Type', $contentType)); + } + + return new Response( + 200, + '1.1', + Stream::fromString('{"access_token": "token-4711", "expires_in": 3600, "token_type": "Bearer"}'), + $headers, + ); + } +} From be7228c70d9b1caf41622d1f7630d10f5e0ce10d Mon Sep 17 00:00:00 2001 From: dsimons Date: Mon, 19 Oct 2026 16:06:41 +0200 Subject: [PATCH 2/3] test: move the client credentials decorator test next to the other OAuth2 tests --- .../Unit/Client/{ => OAuth2}/ClientCredentialsDecoratorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/Unit/Client/{ => OAuth2}/ClientCredentialsDecoratorTest.php (99%) diff --git a/tests/Unit/Client/ClientCredentialsDecoratorTest.php b/tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php similarity index 99% rename from tests/Unit/Client/ClientCredentialsDecoratorTest.php rename to tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php index a6f79ff..ca0bdd3 100644 --- a/tests/Unit/Client/ClientCredentialsDecoratorTest.php +++ b/tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace Artemeon\HttpClient\Tests\Unit\Client; +namespace Artemeon\HttpClient\Tests\Unit\Client\OAuth2; use Artemeon\HttpClient\Client\Decorator\OAuth2\ClientCredentials; use Artemeon\HttpClient\Client\Decorator\OAuth2\ClientCredentialsDecorator; From 27d2489c1743d95574cb884f09b3fc2148aa6bde Mon Sep 17 00:00:00 2001 From: dsimons Date: Mon, 19 Oct 2026 16:08:50 +0200 Subject: [PATCH 3/3] fix: pass the client options to the access token request as well --- changelog.md | 1 + .../OAuth2/ClientCredentialsDecorator.php | 2 +- .../OAuth2/ClientCredentialsDecoratorTest.php | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index dd5ab8f..85178c0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ ## 1.1.3 - 2026-10-19 - Fixed: The `ClientCredentialsDecorator` accepts an access token response whose content type carries parameters, e.g. `application/json;charset=UTF-8` (previously rejected with "Content type should be: application/json"). - Fixed: The exception for an invalid status code of the access token request now contains the status code. +- Fixed: The `ClientCredentialsDecorator` passes the client options of a request (e.g. timeout, proxy, TLS) to the access token request as well. ## 1.1.2 - 2026-02-18 - Widen the Guzzle dependency range from `~7.9.2` to `^7.9.2`. diff --git a/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php b/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php index 739a011..9cb5f3c 100644 --- a/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php +++ b/src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php @@ -102,7 +102,7 @@ public static function fromClientCredentials( public function send(Request $request, ?ClientOptions $clientOptions = null): Response { if ($this->accessTokenCache->isExpired()) { - $this->accessTokenCache->add($this->requestAccessToken()); + $this->accessTokenCache->add($this->requestAccessToken($clientOptions)); } $accessToken = $this->accessTokenCache->get(); diff --git a/tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php b/tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php index ca0bdd3..3631dab 100644 --- a/tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php +++ b/tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php @@ -16,6 +16,7 @@ use Artemeon\HttpClient\Client\Decorator\OAuth2\ClientCredentials; use Artemeon\HttpClient\Client\Decorator\OAuth2\ClientCredentialsDecorator; use Artemeon\HttpClient\Client\HttpClient; +use Artemeon\HttpClient\Client\Options\ClientOptions; use Artemeon\HttpClient\Exception\Request\Http\ServerResponseException; use Artemeon\HttpClient\Exception\RuntimeException; use Artemeon\HttpClient\Http\Header\Header; @@ -131,6 +132,24 @@ public function testStrictRfcSendsTheClientCredentialsOnlyInTheAuthorizationHead self::assertSame('Basic ' . base64_encode('client:secret'), $tokenRequest->getHeaderLine('Authorization')); } + public function testClientOptionsAreUsedForTheAccessTokenRequestAsWell(): void + { + $clientOptions = ClientOptions::fromDefaults(); + $clientOptions->optSetTimeout(5); + + /** @var list $usedOptions */ + $usedOptions = []; + $this->httpClient->expects('send')->twice()->andReturnUsing(static function (Request $sent, ?ClientOptions $options) use (&$usedOptions): Response { + $usedOptions[] = $options; + + return (string) $sent->getUri() === self::TOKEN_URL ? self::tokenResponse('application/json') : new Response(200, '1.1'); + }); + + $this->createDecorator()->send(Request::forGet(Uri::fromString('https://api.example.com/resource')), $clientOptions); + + self::assertSame([$clientOptions, $clientOptions], $usedOptions); + } + public function testFailedAccessTokenRequestIsReportedWithTheCause(): void { $cause = ServerResponseException::fromResponse(