From 780b82a51092e70c6bc1eabfcc4a722db587f682 Mon Sep 17 00:00:00 2001 From: Lennart Van Vaerenbergh Date: Thu, 1 Oct 2026 15:07:58 +0200 Subject: [PATCH] Add API key and legacy authentication alongside OIDC --- CHANGELOG.md | 16 ++ README.md | 4 + UPGRADE.md | 122 ++++++++++++ src/Client/AbstractClient.php | 53 +++-- .../Configuration/ApiKeyConfiguration.php | 60 ++++++ .../ApiKeyConfigurationInterface.php | 25 +++ .../ClientConfigurationInterface.php | 32 +++ .../Configuration/ConfigurationInterface.php | 2 +- .../Configuration/LegacyConfiguration.php | 68 +++++++ tests/Client/AuthenticationTest.php | 183 ++++++++++++++++++ .../AuthenticationConfigurationTest.php | 62 ++++++ 11 files changed, 615 insertions(+), 12 deletions(-) create mode 100644 UPGRADE.md create mode 100644 src/Client/Configuration/ApiKeyConfiguration.php create mode 100644 src/Client/Configuration/ApiKeyConfigurationInterface.php create mode 100644 src/Client/Configuration/ClientConfigurationInterface.php create mode 100644 src/Client/Configuration/LegacyConfiguration.php create mode 100644 tests/Client/AuthenticationTest.php create mode 100644 tests/Client/Configuration/AuthenticationConfigurationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1054ee0..ee2c241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All Notable changes to `digipolisgent/api-client` package. +## [4.3.0] + +### Added + +* Added API key authentication using the `apiKey` and `applicationId` headers. +* Added `ApiKeyConfiguration` and `LegacyConfiguration`, with a shared + `ClientConfigurationInterface` for endpoint, version, and timeout settings. +* Added a v3 to v4.3 upgrade guide and offline authentication compatibility tests. + +### Changed + +* Made the client token cache optional for API key and legacy configurations; + OIDC still requires it. Existing OIDC configuration signatures, interface + methods, protected property types, and token-provider behavior are preserved. + ## [4.2.0] ### Changed @@ -117,6 +132,7 @@ This includes: * Interfaces to create services in client packages. [Unreleased]: https://github.com/digipolisgent/php_package_dg-api-client/compare/master...develop +[4.3.0]: https://github.com/district09/php_package_dg-api-client/compare/4.2.0...4.3.0 [4.2.0]: https://github.com/digipolisgent/php_package_dg-api-client/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/digipolisgent/php_package_dg-api-client/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/digipolisgent/php_package_dg-api-client/compare/3.0.1...4.0.0 diff --git a/README.md b/README.md index 0bb77e6..285b418 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ See the examples of service packages how to use this package: * [gent/services/openinghours](https://github.com/StadGent/php_package_services-opening-hours) : Service to access the Opening Hours API and wrap the responses in value objects. +See [Upgrading from v3 to v4.3](UPGRADE.md) for configuration examples +and migration steps. Existing v4 OIDC consumers +can upgrade to v4.3 without configuration changes. + ## Change log Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..292e506 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,122 @@ +# Upgrading from v3 to v4.3 + +Version 4.3 adds API key headers and a legacy configuration path alongside +OIDC. Existing v4 OIDC consumers need no code or configuration changes. + +## Composer requirements + +Update the API client requirement in your library or application to: + +```json +"digipolisgent/api-client": "^4.3" +``` + +Do not use `^4.0`: the API key and legacy configuration types require 4.3. +Ensure dependent libraries also accept `^4.3`; a remaining `^3.0` requirement +will prevent Composer from resolving the upgrade. + +## What v3 actually did + +In v3.0.1, `Configuration` accepted an endpoint URI and options. The base client +added Content-Length but did not authenticate requests. Consumers supplied +API key headers, Guzzle authentication, or their own Authorization headers. +Version 4.0 replaced this configuration with OIDC and required a token cache. +Version 4.3 provides explicit configuration types for all three paths. + +## API key headers + +Use the new configuration when the service authenticates with the `apiKey` +and `applicationId` headers: + +```php +use DigipolisGent\API\Client\Configuration\ApiKeyConfiguration; + +$configuration = new ApiKeyConfiguration( + 'https://api.example', + 'your-api-key', + 'your-application-id', + ['version' => 3, 'timeout' => 10] +); + +// Your concrete client extends AbstractClient. No token cache is needed. +$client = new YourClient($guzzle, $configuration); +``` + +The base client injects these two headers for each request. Remove consumer +code that injects the same headers. Header names are fixed; this is not a +configuration for arbitrary authentication header names. + +## Retaining authentication managed by the consumer + +Use `LegacyConfiguration` to retain v3 behavior when the consumer handles its +own authentication. It adds no authentication headers and preserves headers +already present on the request. + +Before, in v3: + +```php +use DigipolisGent\API\Client\Configuration\Configuration as BaseConfiguration; +use DigipolisGent\API\Client\Configuration\ConfigurationInterface as BaseConfigurationInterface; + +interface ServiceConfigurationInterface extends BaseConfigurationInterface +{ + public function username(): string; +} + +class ServiceConfiguration extends BaseConfiguration implements ServiceConfigurationInterface +{ + // Existing constructor calls parent::__construct($endpointUri, $options). + // Existing username() implementation is retained. +} +``` + +After, in v4.3, change only the base imports for this legacy path: + +```php +use DigipolisGent\API\Client\Configuration\LegacyConfiguration as BaseConfiguration; +use DigipolisGent\API\Client\Configuration\ClientConfigurationInterface as BaseConfigurationInterface; +``` + +Retain the consumer's constructor, authentication manager, and header logic. +`LegacyConfiguration($endpointUri, $options)` keeps the v3 defaults: version +`1`, timeout `20`, and ignored unknown options. The base client accepts this +configuration without a token cache. + +Update any additional type references that previously accepted the old base +`ConfigurationInterface` to the shared `ClientConfigurationInterface`, or to +your own service configuration interface when service-specific getters are +required. The old name now continues to represent OIDC configuration. + +For a service adopting API key headers instead, extend `ApiKeyConfiguration` +and `ApiKeyConfigurationInterface`. Pass endpoint, API key, application ID, +and options to the parent constructor. Retain old getter names as wrappers if +callers depend on them. + +## OIDC: unchanged for existing v4 consumers + +Keep the existing configuration and cache argument: + +```php +use DigipolisGent\API\Client\Configuration\Configuration; + +$configuration = new Configuration( + $endpointUri, + $authEndpointUri, + $clientId, + $clientSecret, + $scope, + $options +); +$client = new YourClient($guzzle, $configuration, $tokenCache); +``` + +OIDC still injects Bearer authentication using the current token provider and +cache behavior. Custom implementations of `ConfigurationInterface` remain +supported, with no new required methods. Omitting the cache for OIDC throws +an `InvalidArgumentException`; there is no automatic authentication fallback. + +For existing OIDC subclasses, the protected `$configuration` and +`$tokenProvider` properties retain their original types and initialization. +New API key and legacy subclasses should access the shared protected +`$clientConfiguration` property instead. `$configuration` and `$tokenProvider` +are initialized only for OIDC configurations. diff --git a/src/Client/AbstractClient.php b/src/Client/AbstractClient.php index c542563..39adc64 100644 --- a/src/Client/AbstractClient.php +++ b/src/Client/AbstractClient.php @@ -4,6 +4,8 @@ namespace DigipolisGent\API\Client; +use DigipolisGent\API\Client\Configuration\ApiKeyConfigurationInterface; +use DigipolisGent\API\Client\Configuration\ClientConfigurationInterface; use DigipolisGent\API\Client\Configuration\ConfigurationInterface; use DigipolisGent\API\Client\Exception\HandlerNotFound; use DigipolisGent\API\Client\Handler\HandlerInterface; @@ -17,6 +19,7 @@ use GuzzleHttp\Exception\ClientException; use Psr\Http\Message\RequestInterface; use Psr\SimpleCache\CacheInterface; +use InvalidArgumentException; /** * Abstract implementation of the service client. @@ -48,6 +51,13 @@ abstract class AbstractClient implements ClientInterface, LoggableInterface */ protected ConfigurationInterface $configuration; + /** + * Shared configuration for all authentication methods. + * + * @var \DigipolisGent\API\Client\Configuration\ClientConfigurationInterface + */ + protected ClientConfigurationInterface $clientConfiguration; + /** * The OIDC token provider. * @@ -60,17 +70,28 @@ abstract class AbstractClient implements ClientInterface, LoggableInterface * * @param \GuzzleHttp\ClientInterface $guzzle * The Guzzle HTTP client. - * @param \DigipolisGent\API\Client\Configuration\ConfigurationInterface $configuration + * @param \DigipolisGent\API\Client\Configuration\ClientConfigurationInterface $configuration * The client configuration object. - * @param \Psr\SimpleCache\CacheInterface $cache - * Cache used for auth Bearer tokens. Not that this is not for API responses. + * @param \Psr\SimpleCache\CacheInterface|null $cache + * Cache required for OIDC Bearer tokens, not API responses. */ public function __construct( GuzzleClientInterface $guzzle, - ConfigurationInterface $configuration, - CacheInterface $cache, + ClientConfigurationInterface $configuration, + ?CacheInterface $cache = null, ) { $this->guzzle = $guzzle; + $this->clientConfiguration = $configuration; + + if (!$configuration instanceof ConfigurationInterface) { + return; + } + + if ($cache === null) { + throw new InvalidArgumentException('A token cache is required for OIDC authentication.'); + } + + // Keep the protected OIDC properties compatible with existing subclasses. $this->configuration = $configuration; $this->tokenProvider = new OidcTokenProvider( $configuration->getAuthUri(), @@ -116,15 +137,25 @@ public function send(RequestInterface $request): ResponseInterface */ protected function injectHeaders(RequestInterface $request): RequestInterface { - return $request - ->withHeader( - 'Content-Length', - (string) strlen((string) $request->getBody()) - ) - ->withHeader( + $request = $request->withHeader( + 'Content-Length', + (string) strlen((string) $request->getBody()) + ); + + if ($this->clientConfiguration instanceof ConfigurationInterface) { + return $request->withHeader( 'Authorization', 'Bearer ' . $this->tokenProvider->getAccessToken() ); + } + + if ($this->clientConfiguration instanceof ApiKeyConfigurationInterface) { + return $request + ->withHeader('apiKey', $this->clientConfiguration->getApiKey()) + ->withHeader('applicationId', $this->clientConfiguration->getApplicationId()); + } + + return $request; } /** diff --git a/src/Client/Configuration/ApiKeyConfiguration.php b/src/Client/Configuration/ApiKeyConfiguration.php new file mode 100644 index 0000000..16cf945 --- /dev/null +++ b/src/Client/Configuration/ApiKeyConfiguration.php @@ -0,0 +1,60 @@ +apiKey = $apiKey; + $this->applicationId = $applicationId; + } + + /** + * @inheritDoc + */ + public function getApiKey(): string + { + return $this->apiKey; + } + + /** + * @inheritDoc + */ + public function getApplicationId(): string + { + return $this->applicationId; + } +} diff --git a/src/Client/Configuration/ApiKeyConfigurationInterface.php b/src/Client/Configuration/ApiKeyConfigurationInterface.php new file mode 100644 index 0000000..10b39c1 --- /dev/null +++ b/src/Client/Configuration/ApiKeyConfigurationInterface.php @@ -0,0 +1,25 @@ + 1, + 'timeout' => 20, + ]; + + /** + * Create new configuration. + */ + public function __construct(string $endpointUri, array $options = []) + { + $this->endpointUri = $endpointUri; + + foreach ($options as $key => $value) { + if (!array_key_exists($key, $this->options)) { + continue; + } + + $this->options[$key] = $value; + } + } + + /** + * @inheritDoc + */ + public function getUri(): string + { + return $this->endpointUri; + } + + /** + * @inheritDoc + */ + public function getVersion(): string + { + return (string) $this->options['version']; + } + + /** + * @inheritDoc + */ + public function getTimeout(): int + { + return $this->options['timeout']; + } +} diff --git a/tests/Client/AuthenticationTest.php b/tests/Client/AuthenticationTest.php new file mode 100644 index 0000000..c4f9396 --- /dev/null +++ b/tests/Client/AuthenticationTest.php @@ -0,0 +1,183 @@ +assertOidcRequest($configuration); + } + + #[Test] + public function customOidcConfigurationsRemainSupported(): void + { + $configuration = $this->createMock(ConfigurationInterface::class); + $configuration->method('getAuthUri')->willReturn('https://auth.example/token'); + $configuration->method('getClientId')->willReturn('id'); + $configuration->method('getClientSecret')->willReturn('secret'); + $configuration->method('getScope')->willReturn('scope'); + $this->assertOidcRequest($configuration); + } + + #[Test] + public function oidcRequiresACache(): void + { + $configuration = new Configuration( + 'https://api.example', + 'https://auth.example/token', + 'id', + 'secret', + 'scope' + ); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A token cache is required for OIDC authentication.'); + $this->createClient(new Client(), $configuration); + } + + #[Test] + public function apiKeyHeadersAreInjectedWithoutACache(): void + { + $this->assertOutgoingHeaders( + new ApiKeyConfiguration('https://api.example', 'key', 'application'), + ['apiKey' => 'key', 'applicationId' => 'application'], + new Request('POST', 'https://api.example', [], 'abc') + ); + } + + #[Test] + public function legacyAuthenticationHeadersArePreservedWithoutACache(): void + { + $this->assertOutgoingHeaders( + new LegacyConfiguration('https://api.example'), + ['Authorization' => 'Basic dXNlcjpwYXNz', 'apiKey' => 'legacy-key'], + new Request('POST', 'https://api.example', [ + 'Authorization' => 'Basic dXNlcjpwYXNz', + 'apiKey' => 'legacy-key', + ], 'abc') + ); + } + + #[Test] + public function oidcSubclassesCanKeepTheirProtectedPropertiesAndReplaceTheProvider(): void + { + $configuration = new Configuration( + 'https://api.example', + 'https://auth.example/token', + 'id', + 'secret', + 'scope' + ); + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::never())->method('has'); + $provider = $this->createMock(TokenProviderInterface::class); + $provider->expects(self::once())->method('getAccessToken')->willReturn('replacement'); + $client = new class (new Client(), $configuration, $cache) extends AbstractClient { + protected ConfigurationInterface $configuration; + protected TokenProviderInterface $tokenProvider; + + public function configuration(): ConfigurationInterface + { + return $this->configuration; + } + + public function replaceProvider(TokenProviderInterface $provider): void + { + $this->tokenProvider = $provider; + } + + public function prepare(Request $request): \Psr\Http\Message\RequestInterface + { + return parent::injectHeaders($request); + } + }; + self::assertSame($configuration, $client->configuration()); + $client->replaceProvider($provider); + $request = $client->prepare(new Request('GET', 'https://api.example')); + self::assertSame('Bearer replacement', $request->getHeaderLine('Authorization')); + } + + private function assertOidcRequest(ConfigurationInterface $configuration): void + { + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once())->method('has')->with('oidc_access_token')->willReturn(true); + $cache->expects(self::once())->method('get')->with('oidc_access_token')->willReturn('cached-token'); + $cache->expects(self::never())->method('set'); + $this->assertOutgoingHeaders( + $configuration, + ['Authorization' => 'Bearer cached-token'], + new Request('POST', 'https://api.example', ['Authorization' => 'old'], 'abc'), + $cache + ); + } + + private function assertOutgoingHeaders( + ClientConfigurationInterface $configuration, + array $headers, + Request $request, + ?CacheInterface $cache = null + ): void { + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200)])); + $stack->push(Middleware::history($history)); + $client = $this->createClient(new Client(['handler' => $stack]), $configuration, $cache); + $response = $this->createMock(ResponseInterface::class); + $handler = $this->createMock(HandlerInterface::class); + $handler->method('handles')->willReturn([Request::class]); + $handler->expects(self::once())->method('toResponse')->willReturn($response); + $client->addHandler($handler); + self::assertSame($response, $client->send($request)); + self::assertCount(1, $history); + $sent = $history[0]['request']; + foreach ($headers as $name => $value) { + self::assertSame($value, $sent->getHeaderLine($name)); + } + self::assertSame('3', $sent->getHeaderLine('Content-Length')); + self::assertSame('abc', (string) $sent->getBody()); + if ($configuration instanceof ApiKeyConfiguration) { + self::assertFalse($sent->hasHeader('Authorization')); + } + self::assertFalse($request->hasHeader('Content-Length')); + } + + private function createClient( + Client $guzzle, + ClientConfigurationInterface $configuration, + ?CacheInterface $cache = null + ): AbstractClient { + return new class ($guzzle, $configuration, $cache) extends AbstractClient { + }; + } +} diff --git a/tests/Client/Configuration/AuthenticationConfigurationTest.php b/tests/Client/Configuration/AuthenticationConfigurationTest.php new file mode 100644 index 0000000..199c250 --- /dev/null +++ b/tests/Client/Configuration/AuthenticationConfigurationTest.php @@ -0,0 +1,62 @@ +getUri()); + self::assertSame('1', $configuration->getVersion()); + self::assertSame(20, $configuration->getTimeout()); + } + + #[Test] + public function apiKeyConfigurationExposesCredentialsAndDefaults(): void + { + $configuration = new ApiKeyConfiguration('https://api.example', 'key', 'application'); + self::assertInstanceOf(ApiKeyConfigurationInterface::class, $configuration); + self::assertSame('https://api.example', $configuration->getUri()); + self::assertSame('key', $configuration->getApiKey()); + self::assertSame('application', $configuration->getApplicationId()); + self::assertSame('1', $configuration->getVersion()); + self::assertSame(20, $configuration->getTimeout()); + } + + #[Test] + public function bothConfigurationsSupportOptionsAndIgnoreUnknownKeys(): void + { + $options = ['version' => 2, 'timeout' => 10, 'unknown' => 'ignored']; + $configurations = [ + new LegacyConfiguration('https://api.example', $options), + new ApiKeyConfiguration('https://api.example', 'key', 'application', $options), + ]; + foreach ($configurations as $configuration) { + self::assertSame('2', $configuration->getVersion()); + self::assertSame(10, $configuration->getTimeout()); + } + $configurations = [ + new LegacyConfiguration('https://api.example', ['unknown' => 'ignored']), + new ApiKeyConfiguration('https://api.example', 'key', 'application', ['unknown' => 'ignored']), + ]; + foreach ($configurations as $configuration) { + self::assertSame('1', $configuration->getVersion()); + self::assertSame(20, $configuration->getTimeout()); + } + } +}