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
77 changes: 60 additions & 17 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,71 @@
## 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`.

## 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
21 changes: 14 additions & 7 deletions src/Client/Decorator/OAuth2/ClientCredentialsDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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));
}
}
216 changes: 216 additions & 0 deletions tests/Unit/Client/OAuth2/ClientCredentialsDecoratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<?php

/*
* This file is part of the Artemeon Core - Web Application Framework.
*
* (c) Artemeon <www.artemeon.de>
*
* 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\OAuth2;

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;
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<string, array{string}>
*/
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<string, array{string|null}>
*/
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 testClientOptionsAreUsedForTheAccessTokenRequestAsWell(): void
{
$clientOptions = ClientOptions::fromDefaults();
$clientOptions->optSetTimeout(5);

/** @var list<ClientOptions|null> $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(
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,
);
}
}
Loading