diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f2e98e..f9d5d6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite - run: composer run-script pre-commit + run: composer run-script on-commit - name: Show PHP version run: php -v @@ -63,6 +63,19 @@ jobs: - name: Run Basic conformance tests run: | ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-basic-certification-test-plan[client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-basic-ci.json + - name: Switch RP App to dynamic client registration + working-directory: ./main + run: | + CLIENT_REGISTRATION=dynamic_client docker compose -f docker/docker-compose.yml up -d + sleep 5 + - name: Restart trigger-client daemon + run: | + pkill -f trigger-client.py || true + python3 ./main/conformance-tests/trigger-client.py & + sleep 2 + - name: Run Basic conformance tests with dynamic client registration + run: | + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-basic-certification-test-plan[client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-basic-dynamic-ci.json - name: Stop RP App if: always() working-directory: ./main diff --git a/README.md b/README.md index 90151e0..3bbe877 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ It uses OIDC Authorization Code Flow to perform authentication. It implements JWKS public key usage and automatic key rollover, caching mechanism (file based by default), ID token verification and claims extraction, 'userinfo' user data fetching and has support for automatic client -registration for federated environments. +registration for federated environments, as well as OpenID Connect Dynamic +Client Registration. For information on how to use this client, refer to the [documentation](docs/1-Index.md). diff --git a/composer.json b/composer.json index e519a94..24a3b5c 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,13 @@ "scripts": { "pre-commit": [ "vendor/bin/phpcbf", + "vendor/bin/phpcs -p", + "vendor/bin/phpstan --memory-limit=1024M", + "vendor/bin/phpstan analyze -c phpstan-dev.neon --memory-limit=1024M", + "vendor/bin/rector", + "vendor/bin/phpunit --no-coverage" + ], + "on-commit": [ "vendor/bin/phpcs -p", "vendor/bin/phpstan --memory-limit=1024M", "vendor/bin/phpstan analyze -c phpstan-dev.neon --memory-limit=1024M", diff --git a/conformance-tests/conformance-basic-dynamic-ci.json b/conformance-tests/conformance-basic-dynamic-ci.json new file mode 100644 index 0000000..a00a08d --- /dev/null +++ b/conformance-tests/conformance-basic-dynamic-ci.json @@ -0,0 +1,18 @@ +{ + "alias": "oidc-client-php", + "description": "OIDC RP conformance tests for oidc-client-php (dynamic client registration)", + "browser": [ + { + "match": "https://rp.local.conformance.test*", + "tasks": [ + { + "task": "Trigger RP login and wait for completion", + "match": "https://rp.local.conformance.test/", + "commands": [ + ["wait", "id", "submission_complete", 30] + ] + } + ] + } + ] +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 427f517..6dbf83e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,6 +8,8 @@ services: - "443:443" environment: - OP_DISCOVERY_URL=https://localhost.emobix.co.uk:8443/test/a/oidc-client-php/.well-known/openid-configuration + # static_client uses CLIENT_ID / CLIENT_SECRET below; dynamic_client registers itself on the OP. + - CLIENT_REGISTRATION=${CLIENT_REGISTRATION:-static_client} - CLIENT_ID=oidc-client-php-test - CLIENT_SECRET=oidc-client-php-test-secret - REDIRECT_URI=https://rp.local.conformance.test/callback diff --git a/docker/rp-app/index.php b/docker/rp-app/index.php index 6079871..a42b64b 100644 --- a/docker/rp-app/index.php +++ b/docker/rp-app/index.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/vendor/autoload.php'; +use Cicnavi\Oidc\DynamicallyRegisteredClient; use Cicnavi\Oidc\PreRegisteredClient; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; use GuzzleHttp\Client as GuzzleClient; @@ -15,6 +16,7 @@ // Read configurations from environment variables or use default test values matching the JSON config $opConfigurationUrl = getenv('OP_DISCOVERY_URL') ?: 'https://localhost.emobix.co.uk:8443/test/a/oidc-client-php/.well-known/openid-configuration'; +$clientRegistration = getenv('CLIENT_REGISTRATION') ?: 'static_client'; $clientId = getenv('CLIENT_ID') ?: 'oidc-client-php-test'; $clientSecret = getenv('CLIENT_SECRET') ?: 'oidc-client-php-test-secret'; $redirectUri = getenv('REDIRECT_URI') ?: 'https://rp.local.conformance.test/callback'; @@ -24,28 +26,47 @@ // Disable SSL verification for internal Guzzle client because conformance-suite uses a self-signed cert $httpClient = new GuzzleClient(['verify' => false]); - $client = new PreRegisteredClient( - opConfigurationUrl: $opConfigurationUrl, - clientId: $clientId, - clientSecret: $clientSecret, - redirectUri: $redirectUri, - scope: $scope, - httpClient: $httpClient, - defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query - ); + if ($clientRegistration === 'dynamic_client') { + $client = new DynamicallyRegisteredClient( + opConfigurationUrl: $opConfigurationUrl, + redirectUri: $redirectUri, + scope: $scope, + clientName: 'oidc-client-php', + httpClient: $httpClient, + defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query + ); + } else { + $client = new PreRegisteredClient( + opConfigurationUrl: $opConfigurationUrl, + clientId: $clientId, + clientSecret: $clientSecret, + redirectUri: $redirectUri, + scope: $scope, + httpClient: $httpClient, + defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query + ); + } $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); if ($path === '/callback') { // Exchange authorization code for token and fetch user data $userData = $client->getUserData(); - + // Print success div for automated browser/curl matching echo 'OIDC RP Test Completion'; echo '
OIDC Flow Successful!
'; echo '

User Data

' . htmlspecialchars(json_encode($userData, JSON_PRETTY_PRINT)) . '
'; echo ''; } else { + if ($client instanceof DynamicallyRegisteredClient) { + // Each conformance test module is a fresh OP instance served on the + // same issuer URL, so a registration persisted during a previous + // test module is stale — always register anew when starting a flow. + // The callback request then reuses the persisted registration. + $client->register(forceNewRegistration: true); + } + // Initiate OIDC authorization flow $client->authorize(); } diff --git a/docs/1-Index.md b/docs/1-Index.md index fefc4e4..a69de90 100644 --- a/docs/1-Index.md +++ b/docs/1-Index.md @@ -24,18 +24,23 @@ composer require cicnavi/oidc-client-php ## Client Usage -There are two ways to instantiate an OIDC client: +There are three ways to instantiate an OIDC client: * Pre-registered Client (`Cicnavi\Oidc\PreRegisteredClient`) - can be used if the client is already registered with the OpenID Provider. * Federated Client (`Cicnavi\Oidc\FederatedClient`) - can be used in federated environments (as per OpenID Federation specification). This client type currently supports Automatic Client Registration flow using Request Object passed by value. +* Dynamically Registered Client (`Cicnavi\Oidc\DynamicallyRegisteredClient`) - +can be used if the OpenID Provider supports OpenID Connect Dynamic Client +Registration 1.0. The client registers itself with the OpenID Provider and +uses the issued client credentials. Check the dedicated sections below for more details about each client type: * [Pre-registered Client](2-Pre-Registered-Client.md) * [Federated Client](3-Federated-Client.md) -* [Conformance Testing](4-Conformance-Testing.md) +* [Dynamically Registered Client](4-Dynamically-Registered-Client.md) +* [Conformance Testing](5-Conformance-Testing.md) ## Note on SameSite Cookie Attribute diff --git a/docs/4-Dynamically-Registered-Client.md b/docs/4-Dynamically-Registered-Client.md new file mode 100644 index 0000000..26e3894 --- /dev/null +++ b/docs/4-Dynamically-Registered-Client.md @@ -0,0 +1,184 @@ +# Dynamically Registered Client + +Dynamically Registered Client can be used when the OpenID Provider supports +[OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) +(RFC 7591), meaning the OP advertises a `registration_endpoint` in its +metadata. Instead of providing a pre-issued client ID and client secret, the +client registers itself on the OP and uses the issued client credentials. +After registration, it behaves exactly like a +[Pre-Registered Client](2-Pre-Registered-Client.md), using the +`client_secret_basic` client authentication method. + +To instantiate a client, provide configuration parameters to the +`\Cicnavi\Oidc\DynamicallyRegisteredClient` constructor. Here's a basic +example with required parameters: + +```php +use Cicnavi\Oidc\DynamicallyRegisteredClient; + +$oidcClient = new DynamicallyRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', +); +``` + +Registration is performed lazily on first use (first call to `authorize()`, +`getUserData()`, or any method which needs client credentials), or you can +trigger it explicitly: + +```php +$registrationData = $oidcClient->register(); + +echo $registrationData->getClientId(); +``` + +## Client Registration Persistence + +The client registration (client credentials, Registration Access Token, +client configuration endpoint URI) is persisted using a client registration +store, so registration is performed only once, not on every request. On +subsequent instantiations, the persisted registration is used. + +By default, a simple file-based store +(`Cicnavi\Oidc\Registration\FileClientRegistrationStore`) is used, which +stores registrations as JSON files in a dedicated directory in the system +`tmp` folder. Note that this is a *default for convenience*: the system `tmp` +folder is typically subject to periodic cleanup, and losing a stored +registration means losing the issued client credentials (forcing a new +registration on the OP). For production, provide a durable storage directory +(make sure it exists, is writable by the web server, and is properly +protected, since it contains client credentials): + +```php +use Cicnavi\Oidc\DynamicallyRegisteredClient; +use Cicnavi\Oidc\Registration\FileClientRegistrationStore; + +$registrationStore = new FileClientRegistrationStore(__DIR__ . '/../storage/registrations'); + +$oidcClient = new DynamicallyRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', + registrationStore: $registrationStore, +); +``` + +Alternatively, implement +`Cicnavi\Oidc\Registration\Interfaces\ClientRegistrationStoreInterface` to +store registrations in a database or other durable storage. + +Registrations are keyed per OP configuration URL and redirect URI, so one +store instance can be shared between clients for different OPs. + +If the OP issues a client secret with an expiration time +(`client_secret_expires_at`), the client will automatically perform a new +registration once the persisted client secret expires. + +## Initial Access Token + +The OP can protect its registration endpoint, requiring an Initial Access +Token (an OAuth 2.0 Bearer token) for registration requests. Provide it with +the `initialAccessToken` parameter: + +```php +use Cicnavi\Oidc\DynamicallyRegisteredClient; + +$oidcClient = new DynamicallyRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', + initialAccessToken: 'some-initial-access-token', +); +``` + +## Registered Client Metadata + +During registration, the client sends the following client metadata claims, +prepared from the constructor parameters: + +* `redirect_uris` - containing the provided redirect URI, +* `grant_types` - `["authorization_code"]`, +* `response_types` - `["code"]`, +* `token_endpoint_auth_method` - `client_secret_basic`, +* `scope` - the provided scope, +* `client_name` - if provided using the `clientName` parameter, +* `software_id` - by default (can be disabled using the `includeSoftwareId` +parameter). + +Any additional client metadata claims can be provided using the +`additionalClientMetadata` parameter. Claims provided here override the +prepared claims, so make sure to use the correct format for the particular +claim: + +```php +use Cicnavi\Oidc\DynamicallyRegisteredClient; + +$oidcClient = new DynamicallyRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', + clientName: 'My Application', + additionalClientMetadata: [ + 'contacts' => ['admin@your-example.org'], + 'client_uri' => 'https://your-example.org', + ], +); +``` + +## Client Configuration Management (RFC 7592) + +If the OP returns a Registration Access Token +(`registration_access_token`) and a client configuration endpoint URI +(`registration_client_uri`) in the registration response, the client can +also read, update, and delete its registration on the OP, as per +[RFC 7592](https://www.rfc-editor.org/rfc/rfc7592.html): + +```php +// Read the current client registration from the OP (also persists the +// returned client information locally): +$registrationData = $oidcClient->readRegistration(); + +// Update the client registration on the OP. The whole client metadata set is +// sent (as prepared from constructor parameters), with provided claims taking +// precedence: +$registrationData = $oidcClient->updateRegistration([ + 'client_name' => 'My Renamed Application', +]); + +// Delete (deprovision) the client registration on the OP, and remove it from +// the local client registration store: +$oidcClient->deleteRegistration(); +``` + +## Client Usage + +After registration, client usage is the same as for the +[Pre-Registered Client](2-Pre-Registered-Client.md): use `authorize()` to +initiate the authorization flow, and `getUserData()` on the callback to +exchange the authorization code for tokens and fetch user claims. All +optional parameters known from the Pre-Registered Client (PKCE, state, nonce, +authorization request method, response mode, PAR mode, caching, logging...) +are available and forwarded to the underlying client instance. + +```php +use Cicnavi\Oidc\DynamicallyRegisteredClient; +/** @var DynamicallyRegisteredClient $oidcClient */ + +// File: authorize.php +$oidcClient->authorize(); + +// File: callback.php +$userData = $oidcClient->getUserData(); +``` + +## Requirements on the OpenID Provider + +* OP metadata must advertise a `registration_endpoint`. +* The registration response must contain a `client_secret`, since the client +uses the `client_secret_basic` client authentication method. + +As a reference, the [SimpleSAMLphp OIDC module](https://github.com/simplesamlphp/simplesamlphp-module-oidc) +OP implementation supports Dynamic Client Registration, including client +configuration management (read, update, delete) and optional Initial Access +Token protection. diff --git a/docs/4-Conformance-Testing.md b/docs/5-Conformance-Testing.md similarity index 60% rename from docs/4-Conformance-Testing.md rename to docs/5-Conformance-Testing.md index 9a368fc..44a359d 100644 --- a/docs/4-Conformance-Testing.md +++ b/docs/5-Conformance-Testing.md @@ -4,6 +4,7 @@ The `oidc-client-php` library has been fully tested and verified against the off Specifically, currently we run the following OpenID Conformance Tests: * **Basic RP profile** (`oidcc-client-basic-certification-test-plan` plan using static client registration and plain HTTP request authorization). +* **Basic RP profile with dynamic client registration** (`oidcc-client-basic-certification-test-plan` plan using dynamic client registration and plain HTTP request authorization). --- @@ -52,6 +53,18 @@ Specifically, currently we run the following OpenID Conformance Tests: docker compose -f docker/docker-compose.yml up --build -d ``` +The RP test application uses static client registration (`PreRegisteredClient` with the +`CLIENT_ID` / `CLIENT_SECRET` values from `docker/docker-compose.yml`) by default. To run it +with dynamic client registration instead (`DynamicallyRegisteredClient`, which registers itself +on the OP's registration endpoint), set the `CLIENT_REGISTRATION` environment variable when +starting the container: + ```bash + CLIENT_REGISTRATION=dynamic_client docker compose -f docker/docker-compose.yml up --build -d + ``` +Since every conformance test module is a fresh OP instance served on the same issuer URL, the +RP test application performs a new client registration each time an authorization flow is +started (any previously persisted registration would be stale). + --- ### Step 3: Run the Conformance Tests @@ -68,5 +81,15 @@ Specifically, currently we run the following OpenID Conformance Tests: "oidcc-client-basic-certification-test-plan[client_registration=static_client][request_type=plain_http_request]" \ conformance-tests/conformance-basic-ci.json ``` +3. For the dynamic client registration variant (with the RP test application started using + `CLIENT_REGISTRATION=dynamic_client`, see Step 2), run the plan with the `dynamic_client` + variant and the dynamic configuration file (which contains no static client credentials): + ```bash + python3 /path/to/conformance-suite/scripts/run-test-plan.py \ + --expected-failures-file conformance-tests/basic-warnings.json \ + --expected-skips-file conformance-tests/basic-skips.json \ + "oidcc-client-basic-certification-test-plan[client_registration=dynamic_client][request_type=plain_http_request]" \ + conformance-tests/conformance-basic-dynamic-ci.json + ``` All test modules should complete and pass cleanly. diff --git a/src/Bridges/GuzzleBridge.php b/src/Bridges/GuzzleBridge.php index 0455c51..27c67f7 100644 --- a/src/Bridges/GuzzleBridge.php +++ b/src/Bridges/GuzzleBridge.php @@ -7,6 +7,9 @@ use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\StreamInterface; +/** + * @see \Cicnavi\Tests\Oidc\Bridges\GuzzleBridgeTest + */ class GuzzleBridge { /** diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php index 730bb21..6777994 100644 --- a/src/Cache/FileCache.php +++ b/src/Cache/FileCache.php @@ -7,6 +7,9 @@ use Cicnavi\SimpleFileCache\Exceptions\CacheException; use Cicnavi\SimpleFileCache\SimpleFileCache; +/** + * @see \Cicnavi\Tests\Oidc\Cache\FileCacheTest + */ class FileCache extends SimpleFileCache { /** diff --git a/src/DataStore/DataHandlers/Pkce.php b/src/DataStore/DataHandlers/Pkce.php index 95d4ef3..95f81ce 100644 --- a/src/DataStore/DataHandlers/Pkce.php +++ b/src/DataStore/DataHandlers/Pkce.php @@ -11,6 +11,9 @@ use function preg_match; +/** + * @see \Cicnavi\Tests\Oidc\DataStore\DataHandlers\PkceTest + */ class Pkce extends AbstractDataHandler implements PkceDataHandlerInterface { /** diff --git a/src/DataStore/DataHandlers/StateNonce.php b/src/DataStore/DataHandlers/StateNonce.php index 42f5b85..7030967 100644 --- a/src/DataStore/DataHandlers/StateNonce.php +++ b/src/DataStore/DataHandlers/StateNonce.php @@ -8,6 +8,9 @@ use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\Helpers\StringHelper; +/** + * @see \Cicnavi\Tests\Oidc\DataStore\DataHandlers\StateNonceTest + */ class StateNonce extends AbstractDataHandler implements StateNonceDataHandlerInterface { /** diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php new file mode 100644 index 0000000..1413aeb --- /dev/null +++ b/src/DynamicallyRegisteredClient.php @@ -0,0 +1,973 @@ +cache = $cache ?? new FileCache( + 'odrcpc-' . md5($this->opConfigurationUrl . '|' . $this->redirectUri), + ); + + $this->validateCache(); + + $this->metadata = $metadata ?? new OpMetadata($this->opConfigurationUrl, $this->cache, $this->httpClient); + + $this->registrationStore = $registrationStore ?? new FileClientRegistrationStore(); + + $this->registrationHandler = $registrationHandler ?? new ClientRegistrationHandler( + httpClient: $this->httpClient, + logger: $this->logger, + ); + + $this->validateClientMetadata($this->additionalClientMetadata); + } + + /** + * Ensure that provided client metadata claims which affect protocol + * operations are compatible with what this client actually uses at + * runtime. Registering different values would make the OP enforce + * behavior this client can not deliver, causing hard-to-trace errors + * during authorization flows (e.g., registering + * "token_endpoint_auth_method" other than "client_secret_basic" while + * token requests always authenticate using "client_secret_basic"). + * + * @param mixed[] $clientMetadata + * @throws OidcClientException + */ + protected function validateClientMetadata(array $clientMetadata): void + { + $tokenEndpointAuthMethod = $clientMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] ?? null; + + if ( + $tokenEndpointAuthMethod !== null && + $tokenEndpointAuthMethod !== TokenEndpointAuthMethodsEnum::ClientSecretBasic->value + ) { + throw new OidcClientException( + sprintf( + 'Client metadata claim "%s" can only be "%s", since this client always uses that ' . + 'client authentication method.', + ClaimsEnum::TokenEndpointAuthMethod->value, + TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, + ), + ); + } + + $this->validateClientMetadataContains($clientMetadata, ClaimsEnum::RedirectUris->value, $this->redirectUri); + $this->validateClientMetadataContains( + $clientMetadata, + ClaimsEnum::GrantTypes->value, + GrantTypesEnum::AuthorizationCode->value, + ); + $this->validateClientMetadataContains( + $clientMetadata, + ClaimsEnum::ResponseTypes->value, + ResponseTypesEnum::Code->value, + ); + $this->validateClientMetadataScope($clientMetadata); + } + + /** + * Ensure that a "scope" client metadata claim, when provided, is a + * (space-delimited) scope string containing every scope value this + * client uses at runtime (overrides may register supersets, but not + * exclude scopes in actual use). + * + * @param mixed[] $clientMetadata + * @throws OidcClientException + */ + protected function validateClientMetadataScope(array $clientMetadata): void + { + if (!array_key_exists(ClaimsEnum::Scope->value, $clientMetadata)) { + return; + } + + $scopeOverride = $clientMetadata[ClaimsEnum::Scope->value]; + + if (is_string($scopeOverride)) { + $overrideScopes = preg_split('/\s+/', $scopeOverride, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $runtimeScopes = preg_split('/\s+/', $this->scope, -1, PREG_SPLIT_NO_EMPTY) ?: []; + + if (array_diff($runtimeScopes, $overrideScopes) === []) { + return; + } + } + + throw new OidcClientException( + sprintf( + 'Client metadata claim "%s" must be a scope string containing every scope value this ' . + 'client uses at runtime ("%s").', + ClaimsEnum::Scope->value, + $this->scope, + ), + ); + } + + /** + * Ensure that a client metadata claim, when provided, is an array + * containing the value this client uses at runtime (overrides may + * register supersets, but not exclude the value in actual use). + * + * @param mixed[] $clientMetadata + * @throws OidcClientException + */ + protected function validateClientMetadataContains( + array $clientMetadata, + string $claim, + string $requiredValue, + ): void { + if (!array_key_exists($claim, $clientMetadata)) { + return; + } + + $value = $clientMetadata[$claim]; + + if (is_array($value) && in_array($requiredValue, $value, true)) { + return; + } + + throw new OidcClientException( + sprintf( + 'Client metadata claim "%s" must be an array containing "%s", since this client uses that ' . + 'value at runtime.', + $claim, + $requiredValue, + ), + ); + } + + /** + * Check if the current cache state is considered valid. Cache is valid if + * the cache contains metadata for OIDC Configuration URL, which is + * available in current OIDC settings. If the cache is not valid + * (OIDC Configuration URL was changed), the cache will be reinitialized. + * + * @throws OidcClientException If cache could not be reinitialized. + */ + protected function validateCache(): void + { + try { + if ( + $this->opConfigurationUrl != + $this->cache->get(self::CACHE_KEY_OP_CONFIGURATION_URL) + ) { + $this->cache->clear(); + $this->cache->set(self::CACHE_KEY_OP_CONFIGURATION_URL, $this->opConfigurationUrl); + } + } catch (Throwable $throwable) { + $error = 'Cache validation error. ' . $throwable->getMessage(); + $this->logger?->error($error); + throw new OidcClientException($error, $throwable->getCode(), $throwable); + } + } + + /** + * Key under which the current client registration is persisted in the + * client registration store. Unique per OP configuration URL and redirect + * URI. + */ + public function getRegistrationStoreKey(): string + { + return $this->opConfigurationUrl . '|' . $this->redirectUri; + } + + /** + * Key under which the client registration for a particular client ID is + * persisted in the client registration store. Used to resolve the client + * registration an authorization flow was initiated with, even if the + * current registration is replaced in the meantime (see authorize()). + */ + public function getClientRegistrationStoreKey(string $clientId): string + { + return $this->getRegistrationStoreKey() . '|' . $clientId; + } + + /** + * Register this client on the OP's client registration endpoint, or + * return the existing (persisted) registration when one is available and + * its client secret has not expired. When the persisted client secret has + * expired, a new registration is performed automatically. + * + * When the persisted registration was made with different client metadata + * than this client would currently send (configuration change), the + * client registration is updated on the OP (RFC 7592) when possible, or + * replaced with a new registration. + * + * @param bool $forceNewRegistration Perform a new registration even if a + * persisted registration exists. Note that this abandons the existing + * registration on the OP. Its per-client store entry is kept (so + * in-flight authorization flows initiated with it can still complete), + * but is not reused afterwards and is safe to remove externally. + * @throws OidcClientException + */ + public function register(bool $forceNewRegistration = false): ClientRegistrationData + { + $existingRegistrationData = $this->loadRegistrationData(); + + if (!$forceNewRegistration && $existingRegistrationData instanceof ClientRegistrationData) { + if (!$existingRegistrationData->isClientSecretExpired()) { + if ($this->isRegistrationMetadataInSync($existingRegistrationData)) { + return $existingRegistrationData; + } + + $this->logger?->notice( + 'Client metadata has changed since registration, updating client registration.', + ['clientId' => $existingRegistrationData->getClientId()], + ); + + try { + return $this->updateRegistration(); + } catch (Throwable $throwable) { + $this->logger?->warning( + 'Client registration update failed, performing new client registration. ' . + $throwable->getMessage(), + ['clientId' => $existingRegistrationData->getClientId()], + ); + } + } else { + $this->logger?->notice( + 'Persisted client secret has expired, performing new client registration.', + ['clientId' => $existingRegistrationData->getClientId()], + ); + } + } + + $claims = $this->registrationHandler->register( + $this->resolveRegistrationEndpoint(), + $this->buildClientRegistrationMetadata(), + $this->initialAccessToken, + ); + + $claims[self::CLAIM_REQUESTED_METADATA_FINGERPRINT] = $this->buildClientRegistrationMetadataFingerprint(); + + $registrationData = new ClientRegistrationData($claims); + + $this->registrationStore->set($this->getRegistrationStoreKey(), $claims); + // Also persist per client ID, so authorization flows can resolve the + // registration they were initiated with (see authorize()). + $this->registrationStore->set( + $this->getClientRegistrationStoreKey($registrationData->getClientId()), + $claims, + ); + + $this->clearReplacedClientRegistration($existingRegistrationData, $registrationData); + + $this->logger?->info( + 'Client registered on OpenID Provider.', + ['clientId' => $registrationData->getClientId()], + ); + + // Discard any provided client instance so the new credentials are used. + $this->preRegisteredClient = null; + + return $this->registrationData = $registrationData; + } + + /** + * Get the current client registration data, performing registration if + * needed. + * + * @throws OidcClientException + */ + public function getRegistrationData(): ClientRegistrationData + { + return $this->register(); + } + + /** + * Read the current client registration from the OP's client configuration + * endpoint (RFC 7592), and persist the (possibly updated) client + * information returned by the OP. A registration with a Registration + * Access Token and a client configuration endpoint URI must already + * exist. + * + * @throws OidcClientException + */ + public function readRegistration(): ClientRegistrationData + { + $registrationData = $this->requireExistingRegistrationData(); + + $claims = $this->registrationHandler->read( + $this->requireRegistrationClientUri($registrationData), + $this->requireRegistrationAccessToken($registrationData), + ); + + return $this->persistRegistrationClaims($claims, $registrationData); + } + + /** + * Update the client registration on the OP's client configuration + * endpoint (RFC 7592), and persist the client information returned by the + * OP. A registration with a Registration Access Token and a client + * configuration endpoint URI must already exist. + * + * Per RFC 7592, the update request carries the whole client metadata set, + * not a partial update. The metadata sent is the same set this client + * would send at registration (including additional client metadata + * provided in constructor), with provided client metadata claims taking + * precedence. + * + * Note that the persisted requested-metadata fingerprint (used to detect + * configuration drift) always reflects the constructor-derived client + * metadata, not one-off $clientMetadata overrides provided here. + * + * @param mixed[] $clientMetadata Client metadata claims to override the + * prepared client metadata set with. + * @throws OidcClientException + */ + public function updateRegistration(array $clientMetadata = []): ClientRegistrationData + { + $registrationData = $this->requireExistingRegistrationData(); + + $updateMetadata = array_merge( + [ClaimsEnum::ClientId->value => $registrationData->getClientId()], + $this->buildClientRegistrationMetadata(), + $clientMetadata, + ); + + // Overrides must not register behavior this client can not deliver. + $this->validateClientMetadata($updateMetadata); + + $claims = $this->registrationHandler->update( + $this->requireRegistrationClientUri($registrationData), + $this->requireRegistrationAccessToken($registrationData), + $updateMetadata, + ); + + // The update carried the current client metadata set. + $claims[self::CLAIM_REQUESTED_METADATA_FINGERPRINT] = $this->buildClientRegistrationMetadataFingerprint(); + + return $this->persistRegistrationClaims($claims, $registrationData); + } + + /** + * Delete (deprovision) the client registration on the OP's client + * configuration endpoint (RFC 7592), and remove it from the client + * registration store. A registration with a Registration Access Token and + * a client configuration endpoint URI must already exist. + * + * @throws OidcClientException + */ + public function deleteRegistration(): void + { + $registrationData = $this->requireExistingRegistrationData(); + + $this->registrationHandler->delete( + $this->requireRegistrationClientUri($registrationData), + $this->requireRegistrationAccessToken($registrationData), + ); + + $this->registrationStore->delete($this->getRegistrationStoreKey()); + $this->registrationStore->delete( + $this->getClientRegistrationStoreKey($registrationData->getClientId()), + ); + $this->registrationData = null; + $this->preRegisteredClient = null; + + $this->logger?->info('Client registration deleted on OpenID Provider.'); + } + + /** + * Send an authorization request to the authorization server using + * authorization code grant flow. Client registration is performed first + * if needed. + * + * The client ID used to initiate the flow is bound to the user session, + * so the callback request (handled by getUserData()) can resolve the same + * client registration (persisted per client ID) even if the current + * persisted registration is replaced by another process in the meantime + * (concurrent first-use registration, client secret expiry rollover...). + * Only the client ID - which the user agent sees in the authorization + * request anyway - is bound to the session; client credentials never + * leave the client registration store. + * + * @throws OidcClientException If something goes wrong :) + */ + public function authorize( + ?AuthorizationRequestMethodEnum $authorizationRequestMethod = null, + ?ResponseInterface $response = null, + ?ResponseModesEnum $responseMode = null, + ?ParModeEnum $parMode = null, + ): ?ResponseInterface { + $registrationData = $this->register(); + + $this->sessionStore->put( + self::SESSION_KEY_FLOW_CLIENT_ID, + $registrationData->getClientId(), + ); + + return $this->resolvePreRegisteredClientFor($registrationData)->authorize( + $authorizationRequestMethod, + $response, + $responseMode, + $parMode, + ); + } + + /** + * Get user data by performing an HTTP request to a token endpoint first + * and then to the userinfo endpoint using tokens to get user data. + * + * Uses the client registration bound to the current authorization flow + * (per user session), falling back to the persisted registration. The + * flow registration is removed from the session after successful use, so + * a transiently failed callback can be retried with the same credentials. + * + * @return mixed[] User data. + * @throws OidcClientException + */ + public function getUserData(?ServerRequestInterface $request = null): array + { + try { + $registrationData = $this->loadFlowRegistrationData() ?? $this->register(); + + $userData = $this->resolvePreRegisteredClientFor($registrationData)->getUserData($request); + + $this->clearFlowRegistrationData(); + + return $userData; + } catch (OidcClientException $oidcClientException) { + throw $oidcClientException; + } catch (Throwable $throwable) { + throw new OidcClientException( + 'User data error. ' . $throwable->getMessage(), + $throwable->getCode(), + $throwable, + ); + } + } + + /** + * @return MetadataInterface OIDC Configuration URL content (OIDC metadata). + */ + public function getMetadata(): MetadataInterface + { + return $this->metadata; + } + + public function getParMode(): ParModeEnum + { + return $this->parMode; + } + + /** + * Build the client metadata set to send during client registration. + * + * @return mixed[] + */ + public function buildClientRegistrationMetadata(): array + { + $clientMetadata = [ + ClaimsEnum::RedirectUris->value => [$this->redirectUri], + ClaimsEnum::GrantTypes->value => [GrantTypesEnum::AuthorizationCode->value], + ClaimsEnum::ResponseTypes->value => [ResponseTypesEnum::Code->value], + ClaimsEnum::TokenEndpointAuthMethod->value => TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, + ClaimsEnum::Scope->value => $this->scope, + ]; + + if (is_string($this->clientName)) { + $clientMetadata[ClaimsEnum::ClientName->value] = $this->clientName; + } + + if ($this->includeSoftwareId) { + $clientMetadata[ClaimsEnum::SoftwareId->value] = 'https://github.com/cicnavi/oidc-client-php'; + } + + return array_merge($clientMetadata, $this->additionalClientMetadata); + } + + /** + * Fingerprint of the client metadata set this client would currently send + * at registration. Persisted with the client registration claims, and + * used to detect client metadata changes (configuration drift) on + * subsequent runs. + * + * @throws OidcClientException + */ + public function buildClientRegistrationMetadataFingerprint(): string + { + try { + return hash( + 'sha256', + json_encode($this->buildClientRegistrationMetadata(), JSON_THROW_ON_ERROR), + ); + } catch (Throwable $throwable) { + throw new OidcClientException( + 'Could not build client registration metadata fingerprint. ' . $throwable->getMessage(), + $throwable->getCode(), + $throwable, + ); + } + } + + /** + * Check if the provided (persisted) client registration was requested + * with the same client metadata set this client would currently send. + * + * @throws OidcClientException + */ + protected function isRegistrationMetadataInSync(ClientRegistrationData $registrationData): bool + { + return ($registrationData->getClaims()[self::CLAIM_REQUESTED_METADATA_FINGERPRINT] ?? null) === + $this->buildClientRegistrationMetadataFingerprint(); + } + + /** + * Get the underlying pre-registered client instance built using the + * client credentials issued at registration, performing registration + * first if needed. + * + * @throws OidcClientException + */ + public function resolvePreRegisteredClient(): PreRegisteredClient + { + return $this->resolvePreRegisteredClientFor($this->register()); + } + + /** + * Get the underlying pre-registered client instance built using the + * client credentials from the provided client registration. A new + * instance is built on each call, so the provided (current) registration + * is always honored. A pre-built client instance provided in the + * constructor (intended primarily for testing) takes precedence. + * + * @throws OidcClientException + */ + protected function resolvePreRegisteredClientFor(ClientRegistrationData $registrationData): PreRegisteredClient + { + if ($this->preRegisteredClient instanceof PreRegisteredClient) { + return $this->preRegisteredClient; + } + + if (!is_string($clientSecret = $registrationData->getClientSecret())) { + throw new OidcClientException( + 'Client registration does not contain a client secret, which is required for the ' . + '"client_secret_basic" client authentication method.', + ); + } + + return new PreRegisteredClient( + opConfigurationUrl: $this->opConfigurationUrl, + clientId: $registrationData->getClientId(), + clientSecret: $clientSecret, + redirectUri: $this->redirectUri, + scope: $this->scope, + usePkce: $this->usePkce, + pkceCodeChallengeMethod: $this->pkceCodeChallengeMethod, + timestampValidationLeeway: $this->timestampValidationLeeway, + useState: $this->useState, + useNonce: $this->useNonce, + fetchUserinfoClaims: $this->fetchUserinfoClaims, + supportedAlgorithms: $this->supportedAlgorithms, + supportedSerializers: $this->supportedSerializers, + logger: $this->logger, + cache: $this->cache, + sessionStore: $this->sessionStore, + httpClient: $this->httpClient, + metadata: $this->metadata, + core: $this->core, + jwks: $this->jwks, + maxCacheDuration: $this->maxCacheDuration, + defaultAuthorizationRequestMethod: $this->defaultAuthorizationRequestMethod, + responseMode: $this->responseMode, + requestDataHandler: $this->requestDataHandler, + parMode: $this->parMode, + ); + } + + /** + * Load the client registration bound to the current authorization flow, + * using the client ID from the session store to resolve the registration + * from the client registration store. Returns null when not resolvable + * (so the current persisted registration can be used as fallback); + * errors are logged and the session entry is discarded. + */ + protected function loadFlowRegistrationData(): ?ClientRegistrationData + { + try { + $clientId = $this->sessionStore->get(self::SESSION_KEY_FLOW_CLIENT_ID); + + if (!is_string($clientId) || $clientId === '') { + return null; + } + + $claims = $this->registrationStore->get($this->getClientRegistrationStoreKey($clientId)); + + if (is_array($claims)) { + return new ClientRegistrationData($claims); + } + } catch (Throwable $throwable) { + $this->logger?->error( + 'Error loading client registration bound to the current authorization flow, falling back ' . + 'to the persisted registration. ' . $throwable->getMessage(), + ); + $this->clearFlowRegistrationData(); + } + + return null; + } + + /** + * Remove the client ID bound to the current authorization flow from the + * session store (best-effort). + */ + protected function clearFlowRegistrationData(): void + { + try { + $this->sessionStore->delete(self::SESSION_KEY_FLOW_CLIENT_ID); + } catch (Throwable $throwable) { + $this->logger?->warning( + 'Error removing client ID bound to the current authorization flow from session. ' . + $throwable->getMessage(), + ); + } + } + + /** + * Remove the per-client store entry of a replaced client registration + * (best-effort), so the store does not accumulate superseded + * registrations. Only registrations whose client secret has expired are + * removed - authorization flows initiated with them could not complete + * anyway. Replaced registrations with a still-valid client secret + * (forced replacement) are kept, so in-flight authorization flows + * initiated with them can still complete; such entries are not reused + * afterwards and are safe to remove externally. + */ + protected function clearReplacedClientRegistration( + ?ClientRegistrationData $replacedRegistrationData, + ClientRegistrationData $newRegistrationData, + ): void { + if (!$replacedRegistrationData instanceof ClientRegistrationData) { + return; + } + + if ($replacedRegistrationData->getClientId() === $newRegistrationData->getClientId()) { + return; + } + + if (!$replacedRegistrationData->isClientSecretExpired()) { + return; + } + + try { + $this->registrationStore->delete( + $this->getClientRegistrationStoreKey($replacedRegistrationData->getClientId()), + ); + } catch (Throwable $throwable) { + $this->logger?->warning( + 'Error removing replaced client registration from the client registration store. ' . + $throwable->getMessage(), + ['replacedClientId' => $replacedRegistrationData->getClientId()], + ); + } + } + + /** + * Load registration data from memory or the client registration store. + * Invalid stored entries are discarded (with an error logged), so a new + * registration can be performed. + */ + protected function loadRegistrationData(): ?ClientRegistrationData + { + if ($this->registrationData instanceof ClientRegistrationData) { + return $this->registrationData; + } + + try { + $claims = $this->registrationStore->get($this->getRegistrationStoreKey()); + + if (is_array($claims)) { + return $this->registrationData = new ClientRegistrationData($claims); + } + } catch (Throwable $throwable) { + $this->logger?->error( + 'Error loading persisted client registration, discarding it. ' . $throwable->getMessage(), + ['registrationStoreKey' => $this->getRegistrationStoreKey()], + ); + } + + return null; + } + + /** + * @throws OidcClientException If no registration exists yet. + */ + protected function requireExistingRegistrationData(): ClientRegistrationData + { + $registrationData = $this->loadRegistrationData(); + + if (!$registrationData instanceof ClientRegistrationData) { + throw new OidcClientException( + 'No existing client registration available. Register the client first.', + ); + } + + return $registrationData; + } + + /** + * @return non-empty-string + * @throws OidcClientException + */ + protected function requireRegistrationClientUri(ClientRegistrationData $registrationData): string + { + if (!is_string($registrationClientUri = $registrationData->getRegistrationClientUri())) { + throw new OidcClientException( + 'Client registration does not contain a "registration_client_uri" claim, so client ' . + 'configuration management is not available.', + ); + } + + return $registrationClientUri; + } + + /** + * @return non-empty-string + * @throws OidcClientException + */ + protected function requireRegistrationAccessToken(ClientRegistrationData $registrationData): string + { + if (!is_string($registrationAccessToken = $registrationData->getRegistrationAccessToken())) { + throw new OidcClientException( + 'Client registration does not contain a "registration_access_token" claim, so client ' . + 'configuration management is not available.', + ); + } + + return $registrationAccessToken; + } + + /** + * Persist client information claims returned by the OP. Since the OP is + * not required to repeat the "registration_access_token" and + * "registration_client_uri" claims in read / update responses, previous + * claims are used as the base, with new claims taking precedence. + * + * @param mixed[] $claims + * @throws OidcClientException + */ + protected function persistRegistrationClaims( + array $claims, + ClientRegistrationData $previousRegistrationData, + ): ClientRegistrationData { + $claims = array_merge($previousRegistrationData->getClaims(), $claims); + + $registrationData = new ClientRegistrationData($claims); + + $this->registrationStore->set($this->getRegistrationStoreKey(), $claims); + // Also persist per client ID, so authorization flows can resolve the + // registration they were initiated with (see authorize()). + $this->registrationStore->set( + $this->getClientRegistrationStoreKey($registrationData->getClientId()), + $claims, + ); + + // Discard any provided client instance in case credentials changed. + $this->preRegisteredClient = null; + + return $this->registrationData = $registrationData; + } + + /** + * @return non-empty-string + * @throws OidcClientException If the OP does not advertise a + * "registration_endpoint". + */ + protected function resolveRegistrationEndpoint(): string + { + try { + $registrationEndpoint = $this->metadata->get(ClaimsEnum::RegistrationEndpoint->value); + } catch (OidcClientException) { + $registrationEndpoint = null; + } + + if (!is_string($registrationEndpoint) || $registrationEndpoint === '') { + $error = 'OpenID Provider does not advertise a "registration_endpoint", so dynamic client ' . + 'registration is not available.'; + $this->logger?->error($error); + throw new OidcClientException($error); + } + + return $registrationEndpoint; + } +} diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 9abd3b6..18a9bc1 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -57,6 +57,9 @@ use SimpleSAML\OpenID\SupportedSerializers; use SimpleSAML\OpenID\ValueAbstracts\TrustAnchorConfigBag; +/** + * @see \Cicnavi\Tests\Oidc\FederatedClientTest + */ class FederatedClient { protected readonly CacheInterface $cache; @@ -345,7 +348,7 @@ public function buildEntityStatement(): EntityStatement ->getAll(); // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata $rpMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] = TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value; - https: //openid.net/specs/openid-connect-rp-metadata-choices-1_0-01.html + // https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-01.html $rpMetadata[ClaimsEnum::TokenEndpointAuthMethodsSupported->value] = [ TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value, ]; @@ -713,7 +716,6 @@ public function autoRegisterAndAuthenticate( $signingKeyPair = $this->federation->keyPairResolver()->resolveSignatureKeyPairByAlgorithm( signatureKeyPairBag: $this->connectSignatureKeyPairBag, receiverEntityMetadata: $opResolvedMetadata, - receiverDesignatedSignatureAlgorithmMetadataKey: null, receiverSupportedSignatureAlgorithmsMetadataKey: ClaimsEnum::RequestObjectSigningAlgValuesSupported->value, ); @@ -916,7 +918,6 @@ public function getUserData(?ServerRequestInterface $request = null): array opJwksUri: $opJwksUri, opTokenEndpoint: $opTokenEndpoint, opUserinfoEndpoint: $opUserinfoEndpoint, - clientSecret: null, clientAssertion: $this->buildClientAssertion($resolvedOpMetadata, $opEntityId), usePkce: $this->usePkce, useNonce: $this->useNonce, diff --git a/src/Federation/EntityConfig.php b/src/Federation/EntityConfig.php index 9790b19..16725b3 100644 --- a/src/Federation/EntityConfig.php +++ b/src/Federation/EntityConfig.php @@ -10,6 +10,9 @@ use SimpleSAML\OpenID\ValueAbstracts\UniqueStringBag; use SimpleSAML\OpenID\ValueAbstracts\TrustAnchorConfigBag; +/** + * @see \Cicnavi\Tests\Oidc\Federation\EntityConfigTest + */ class EntityConfig { /** diff --git a/src/Federation/RelyingPartyConfig.php b/src/Federation/RelyingPartyConfig.php index 4cea260..46d49af 100644 --- a/src/Federation/RelyingPartyConfig.php +++ b/src/Federation/RelyingPartyConfig.php @@ -9,6 +9,9 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; use SimpleSAML\OpenID\ValueAbstracts\RedirectUriBag; +/** + * @see \Cicnavi\Tests\Oidc\Federation\RelyingPartyConfigTest + */ class RelyingPartyConfig { /** diff --git a/src/Helpers/HttpHelper.php b/src/Helpers/HttpHelper.php index 0245540..8d0939a 100644 --- a/src/Helpers/HttpHelper.php +++ b/src/Helpers/HttpHelper.php @@ -4,6 +4,9 @@ namespace Cicnavi\Oidc\Helpers; +/** + * @see \Cicnavi\Tests\Oidc\Helpers\HttpHelperTest + */ class HttpHelper { /** diff --git a/src/Helpers/StringHelper.php b/src/Helpers/StringHelper.php index 449dee2..90814f6 100644 --- a/src/Helpers/StringHelper.php +++ b/src/Helpers/StringHelper.php @@ -7,6 +7,9 @@ use Cicnavi\Oidc\Exceptions\OidcClientException; use Throwable; +/** + * @see \Cicnavi\Tests\Oidc\Helpers\StringHelperTest + */ class StringHelper { /** diff --git a/src/Http/RequestFactory.php b/src/Http/RequestFactory.php index abf1c37..4bdb759 100644 --- a/src/Http/RequestFactory.php +++ b/src/Http/RequestFactory.php @@ -9,6 +9,9 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; +/** + * @see \Cicnavi\Tests\Oidc\Http\RequestFactoryTest + */ class RequestFactory implements RequestFactoryInterface { /** diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index d277c18..42e01a2 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -37,6 +37,9 @@ use SimpleSAML\OpenID\SupportedSerializers; use Throwable; +/** + * @see \Cicnavi\Tests\Oidc\PreRegisteredClientTest + */ class PreRegisteredClient { /** @@ -329,7 +332,6 @@ public function getUserData(?ServerRequestInterface $request = null): array opTokenEndpoint: $opTokenEndpoint, opUserinfoEndpoint: $opUserinfoEndpoint, clientSecret: $this->clientSecret, - clientAssertion: null, usePkce: $this->usePkce, useNonce: $this->useNonce, fetchUserinfoClaims: $this->fetchUserinfoClaims, diff --git a/src/Protocol/ClientRegistrationHandler.php b/src/Protocol/ClientRegistrationHandler.php new file mode 100644 index 0000000..39cadc1 --- /dev/null +++ b/src/Protocol/ClientRegistrationHandler.php @@ -0,0 +1,310 @@ +logger?->debug( + 'Sending client registration request.', + ['registrationEndpoint' => $registrationEndpoint, 'clientMetadata' => $clientMetadata], + ); + + $response = $this->sendJsonRequest( + HttpMethodsEnum::POST, + $registrationEndpoint, + $clientMetadata, + $initialAccessToken, + 'Client registration', + ); + + // Per OpenID Connect Dynamic Client Registration 1.0, a successful + // registration response uses HTTP 201 Created. + $this->validateResponseStatusCode($response, [201], 'Client registration'); + + return $this->decodeJsonResponse($response, 'Client registration'); + } + + /** + * Read the current client registration from the OP's client configuration + * endpoint (HTTP GET), as per RFC 7592. + * + * @param string $registrationClientUri The client configuration endpoint + * ("registration_client_uri" from the client information response). + * @param string $registrationAccessToken Registration Access Token issued + * at registration. + * @return mixed[] Client information response claims. + * @throws OidcClientException + */ + public function read( + string $registrationClientUri, + string $registrationAccessToken, + ): array { + $this->logger?->debug( + 'Sending client registration read request.', + ['registrationClientUri' => $registrationClientUri], + ); + + $response = $this->sendJsonRequest( + HttpMethodsEnum::GET, + $registrationClientUri, + null, + $registrationAccessToken, + 'Client registration read', + ); + + $this->validateResponseStatusCode($response, [200], 'Client registration read'); + + return $this->decodeJsonResponse($response, 'Client registration read'); + } + + /** + * Update the client registration on the OP's client configuration + * endpoint (HTTP PUT), as per RFC 7592. + * + * Note that per RFC 7592 the update request must contain the whole client + * metadata set (not a partial update), including the "client_id" claim. + * Claims which must not be sent in update requests + * (registration_access_token, registration_client_uri, client_id_issued_at, + * client_secret_expires_at) are removed automatically. + * + * @param string $registrationClientUri The client configuration endpoint + * ("registration_client_uri" from the client information response). + * @param string $registrationAccessToken Registration Access Token issued + * at registration. + * @param mixed[] $clientMetadata Full client metadata set to update to, + * including the "client_id" claim. + * @return mixed[] Client information response claims. + * @throws OidcClientException + */ + public function update( + string $registrationClientUri, + string $registrationAccessToken, + array $clientMetadata, + ): array { + $clientId = $clientMetadata[ClaimsEnum::ClientId->value] ?? null; + if (!is_string($clientId) || $clientId === '') { + throw new OidcClientException( + 'Client registration update metadata must contain a valid "client_id" claim.', + ); + } + + // Per RFC 7592, these claims must not be included in update requests. + unset( + $clientMetadata[ClaimsEnum::RegistrationAccessToken->value], + $clientMetadata[ClaimsEnum::RegistrationClientUri->value], + $clientMetadata[ClaimsEnum::ClientIdIssuedAt->value], + $clientMetadata[ClaimsEnum::ClientSecretExpiresAt->value], + ); + + $this->logger?->debug( + 'Sending client registration update request.', + ['registrationClientUri' => $registrationClientUri, 'clientMetadata' => $clientMetadata], + ); + + $response = $this->sendJsonRequest( + HttpMethodsEnum::PUT, + $registrationClientUri, + $clientMetadata, + $registrationAccessToken, + 'Client registration update', + ); + + $this->validateResponseStatusCode($response, [200], 'Client registration update'); + + return $this->decodeJsonResponse($response, 'Client registration update'); + } + + /** + * Delete (deprovision) the client registration on the OP's client + * configuration endpoint (HTTP DELETE), as per RFC 7592. + * + * @param string $registrationClientUri The client configuration endpoint + * ("registration_client_uri" from the client information response). + * @param string $registrationAccessToken Registration Access Token issued + * at registration. + * @throws OidcClientException + */ + public function delete( + string $registrationClientUri, + string $registrationAccessToken, + ): void { + $this->logger?->debug( + 'Sending client registration delete request.', + ['registrationClientUri' => $registrationClientUri], + ); + + $response = $this->sendJsonRequest( + HttpMethodsEnum::DELETE, + $registrationClientUri, + null, + $registrationAccessToken, + 'Client registration delete', + ); + + $this->validateResponseStatusCode($response, [204], 'Client registration delete'); + } + + /** + * Send an HTTP request with an optional JSON body and optional Bearer + * token authorization. + * + * @param ?mixed[] $jsonBody Body to send as JSON, or null for no body. + * @throws OidcClientException + */ + protected function sendJsonRequest( + HttpMethodsEnum $httpMethod, + string $uri, + ?array $jsonBody, + ?string $bearerToken, + string $context, + ): ResponseInterface { + try { + $request = $this->httpRequestFactory + ->createRequest($httpMethod->value, $uri) + ->withHeader('Accept', 'application/json'); + + if (is_string($bearerToken)) { + $request = $request->withHeader('Authorization', 'Bearer ' . $bearerToken); + } + + if (is_array($jsonBody)) { + $bodyStream = $this->guzzleBridge->psr7StreamFor( + json_encode($jsonBody, JSON_THROW_ON_ERROR), + ); + $request = $request + ->withHeader('Content-Type', 'application/json') + ->withBody($bodyStream); + } + + return $this->httpClient->sendRequest($request); + } catch (Throwable $throwable) { + $message = sprintf('%s request error. %s', $context, $throwable->getMessage()); + $this->logger?->error($message); + throw new OidcClientException($message, $throwable->getCode(), $throwable); + } + } + + /** + * Ensure that the response has one of the expected HTTP status codes. + * Error responses are returned as JSON objects with 'error' and optional + * 'error_description' members, so surface those when present. + * + * @param int[] $expectedStatusCodes + * @throws OidcClientException If the response does not indicate success. + */ + protected function validateResponseStatusCode( + ResponseInterface $response, + array $expectedStatusCodes, + string $context, + ): void { + $httpStatusCode = $response->getStatusCode(); + if (in_array($httpStatusCode, $expectedStatusCodes, true)) { + return; + } + + $message = sprintf( + '%s was not successful (HTTP %s - %s).', + $context, + $httpStatusCode, + $response->getReasonPhrase(), + ); + + try { + $errorData = $this->decodeJsonOrThrow((string) $response->getBody()); + $error = $errorData[ParamsEnum::Error->value] ?? null; + $errorDescription = $errorData[ParamsEnum::ErrorDescription->value] ?? null; + if (is_string($error)) { + $message = sprintf( + '%s rejected by OpenID Provider - error "%s"%s', + $context, + $error, + is_string($errorDescription) ? ': ' . $errorDescription . '.' : '.', + ); + } + } catch (Throwable) { + // Body was not a JSON error object; keep the generic status message. + } + + $this->logger?->error($message); + throw new OidcClientException($message); + } + + /** + * @return mixed[] Decoded JSON from the response body. + * @throws OidcClientException + */ + protected function decodeJsonResponse(ResponseInterface $response, string $context): array + { + $responseBody = (string) $response->getBody(); + + try { + return $this->decodeJsonOrThrow($responseBody); + } catch (Throwable $throwable) { + $message = sprintf('%s JSON response is not valid.', $context); + $this->logger?->error($message, ['responseBody' => $responseBody]); + throw new OidcClientException($message, $throwable->getCode(), $throwable); + } + } + + /** + * @return mixed[] Decoded JSON from the provided string. + * @throws OidcClientException + */ + protected function decodeJsonOrThrow(string $json): array + { + try { + if (!is_array($decodedJson = json_decode($json, true, 512, JSON_THROW_ON_ERROR))) { + throw new OidcClientException('JSON decode error.'); + } + + return $decodedJson; + } catch (Throwable) { + throw new OidcClientException('JSON decode error.'); + } + } +} diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index e6d2072..683f389 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -32,6 +32,9 @@ use SimpleSAML\OpenID\Jwks; use Throwable; +/** + * @see \Cicnavi\Tests\Oidc\Protocol\RequestDataHandlerTest + */ class RequestDataHandler { public const KEY_OP_METADATA_FOR_STATE = 'op_metadata_for_state_'; @@ -296,7 +299,10 @@ protected function buildClientAuthentication( ); } - $headers['Authorization'] = 'Basic ' . base64_encode($clientId . ':' . $clientSecret); + // Per RFC 6749 section 2.3.1, the client ID and secret must be + // form-urlencoded before being used as Basic auth credentials. + $headers['Authorization'] = 'Basic ' . + base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)); } if ($clientAuthenticationMethod === ClientAuthenticationMethodsEnum::PrivateKeyJwt) { diff --git a/src/Registration/ClientRegistrationData.php b/src/Registration/ClientRegistrationData.php new file mode 100644 index 0000000..eb8839d --- /dev/null +++ b/src/Registration/ClientRegistrationData.php @@ -0,0 +1,153 @@ +validate(); + } + + /** + * @throws OidcClientException + */ + protected function validate(): void + { + $clientId = $this->claims[ClaimsEnum::ClientId->value] ?? null; + if (!is_string($clientId) || $clientId === '') { + throw new OidcClientException( + 'Client registration data does not contain a valid "client_id" claim.', + ); + } + + $clientSecret = $this->claims[ClaimsEnum::ClientSecret->value] ?? null; + if ($clientSecret !== null && (!is_string($clientSecret) || $clientSecret === '')) { + throw new OidcClientException( + 'Client registration data contains an invalid "client_secret" claim.', + ); + } + } + + /** + * @return non-empty-string + */ + public function getClientId(): string + { + /** @var non-empty-string $clientId Validated in constructor. */ + $clientId = $this->claims[ClaimsEnum::ClientId->value]; + + return $clientId; + } + + /** + * @return ?non-empty-string + */ + public function getClientSecret(): ?string + { + /** @var ?non-empty-string $clientSecret Validated in constructor. */ + $clientSecret = $this->claims[ClaimsEnum::ClientSecret->value] ?? null; + + return $clientSecret; + } + + public function getClientIdIssuedAt(): ?int + { + $clientIdIssuedAt = $this->claims[ClaimsEnum::ClientIdIssuedAt->value] ?? null; + + return is_numeric($clientIdIssuedAt) ? (int) $clientIdIssuedAt : null; + } + + /** + * Time at which the client secret expires, as a Unix timestamp. Value 0 + * means the client secret does not expire. + */ + public function getClientSecretExpiresAt(): int + { + $clientSecretExpiresAt = $this->claims[ClaimsEnum::ClientSecretExpiresAt->value] ?? null; + + return is_numeric($clientSecretExpiresAt) ? (int) $clientSecretExpiresAt : 0; + } + + /** + * Check if the client secret has expired. If no client secret was issued, + * or the "client_secret_expires_at" claim is 0 (or not provided), the + * client secret is considered non-expiring. + * + * @param ?int $atTime Unix timestamp to check expiry against. Defaults to + * the current time. + */ + public function isClientSecretExpired(?int $atTime = null): bool + { + if ($this->getClientSecret() === null) { + return false; + } + + $clientSecretExpiresAt = $this->getClientSecretExpiresAt(); + if ($clientSecretExpiresAt === 0) { + return false; + } + + return ($atTime ?? time()) >= $clientSecretExpiresAt; + } + + /** + * @return ?non-empty-string + */ + public function getRegistrationAccessToken(): ?string + { + $registrationAccessToken = $this->claims[ClaimsEnum::RegistrationAccessToken->value] ?? null; + + return (is_string($registrationAccessToken) && $registrationAccessToken !== '') ? + $registrationAccessToken : + null; + } + + /** + * @return ?non-empty-string + */ + public function getRegistrationClientUri(): ?string + { + $registrationClientUri = $this->claims[ClaimsEnum::RegistrationClientUri->value] ?? null; + + return (is_string($registrationClientUri) && $registrationClientUri !== '') ? + $registrationClientUri : + null; + } + + /** + * @return mixed[] All client information response claims. + */ + public function getClaims(): array + { + return $this->claims; + } + + /** + * @return mixed[] + */ + public function jsonSerialize(): array + { + return $this->claims; + } +} diff --git a/src/Registration/FileClientRegistrationStore.php b/src/Registration/FileClientRegistrationStore.php new file mode 100644 index 0000000..6dc8f1d --- /dev/null +++ b/src/Registration/FileClientRegistrationStore.php @@ -0,0 +1,257 @@ +storageDirectory = $storageDirectory ?? $this->buildDefaultStorageDirectory(); + + $this->prepareStorageDirectory(); + } + + public function getStorageDirectory(): string + { + return $this->storageDirectory; + } + + /** + * Default storage directory - dedicated per (effective) user, so + * different local users using the default location get their own + * (ownership-validated) directory instead of clashing on a shared one. + */ + protected function buildDefaultStorageDirectory(): string + { + $userId = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid(); + + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . self::DEFAULT_DIRECTORY_NAME . + (is_int($userId) ? '-' . $userId : ''); + } + + /** + * @throws OidcClientException If the storage directory could not be + * prepared, or can not be considered private to the current user. + */ + protected function prepareStorageDirectory(): void + { + if ( + (!is_dir($this->storageDirectory)) && + (!mkdir($this->storageDirectory, 0o700, true)) && + (!is_dir($this->storageDirectory)) + ) { + throw new OidcClientException( + sprintf('Could not create client registration storage directory (%s).', $this->storageDirectory), + ); + } + + if (!is_writable($this->storageDirectory)) { + throw new OidcClientException( + sprintf('Client registration storage directory is not writable (%s).', $this->storageDirectory), + ); + } + + $this->validateStorageDirectoryPrivacy(); + } + + /** + * Ensure the storage directory is private to the current user: not a + * symbolic link, owned by the current (effective) user, and not + * accessible to group / others (tightened when possible). The directory + * will contain client credentials, and a pre-existing directory in a + * shared location (like the system temp directory) could otherwise be + * controlled by another local user. Not applicable on Windows, where + * POSIX ownership and permission semantics are not available. + * + * @throws OidcClientException If the storage directory can not be + * considered private to the current user. + */ + protected function validateStorageDirectoryPrivacy(): void + { + if (PHP_OS_FAMILY === 'Windows') { + return; + } + + clearstatcache(true, $this->storageDirectory); + + if (is_link($this->storageDirectory)) { + throw new OidcClientException( + sprintf( + 'Client registration storage directory (%s) is a symbolic link, refusing to use it.', + $this->storageDirectory, + ), + ); + } + + $userId = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid(); + $owner = fileowner($this->storageDirectory); + + if (!is_int($owner) || (is_int($userId) && $owner !== $userId)) { + throw new OidcClientException( + sprintf( + 'Client registration storage directory (%s) is not owned by the current user, ' . + 'refusing to use it.', + $this->storageDirectory, + ), + ); + } + + $permissions = fileperms($this->storageDirectory); + + if (!is_int($permissions)) { + throw new OidcClientException( + sprintf( + 'Could not check client registration storage directory permissions (%s).', + $this->storageDirectory, + ), + ); + } + + // No group / other access - the directory will contain client credentials. + if ((($permissions & 0o077) !== 0) && (!chmod($this->storageDirectory, 0o700))) { + throw new OidcClientException( + sprintf( + 'Client registration storage directory (%s) is accessible to other users, and its ' . + 'permissions could not be restricted.', + $this->storageDirectory, + ), + ); + } + } + + /** + * @inheritDoc + */ + public function get(string $key): ?array + { + $filePath = $this->resolveFilePathForKey($key); + + if (!is_file($filePath)) { + return null; + } + + $fileContent = file_get_contents($filePath); + if (!is_string($fileContent)) { + throw new OidcClientException( + sprintf('Could not read client registration file (%s).', $filePath), + ); + } + + try { + $claims = json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable $throwable) { + throw new OidcClientException( + sprintf('Could not decode client registration file (%s). %s', $filePath, $throwable->getMessage()), + $throwable->getCode(), + $throwable, + ); + } + + if (!is_array($claims)) { + throw new OidcClientException( + sprintf('Unexpected client registration file content (%s).', $filePath), + ); + } + + return $claims; + } + + /** + * @inheritDoc + */ + public function set(string $key, array $claims): void + { + $filePath = $this->resolveFilePathForKey($key); + + try { + $fileContent = json_encode($claims, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + } catch (Throwable $throwable) { + throw new OidcClientException( + 'Could not encode client registration claims. ' . $throwable->getMessage(), + $throwable->getCode(), + $throwable, + ); + } + + // Write to a temporary file with an unpredictable name first, then + // atomically move it into place. This prevents partial reads, and + // prevents writing through a symbolic link planted at the + // (predictable) file path - rename() replaces such a link instead of + // following it. + try { + $temporaryFilePath = $filePath . '.' . bin2hex(random_bytes(8)) . '.tmp'; + } catch (Throwable $throwable) { + throw new OidcClientException( + 'Could not generate temporary client registration file name. ' . $throwable->getMessage(), + $throwable->getCode(), + $throwable, + ); + } + + if (file_put_contents($temporaryFilePath, $fileContent) === false) { + throw new OidcClientException( + sprintf('Could not write client registration file (%s).', $temporaryFilePath), + ); + } + + // Contains client credentials, so restrict access to the owner + // before moving into place. + if ((!chmod($temporaryFilePath, 0o600)) || (!rename($temporaryFilePath, $filePath))) { + unlink($temporaryFilePath); + throw new OidcClientException( + sprintf('Could not write client registration file (%s).', $filePath), + ); + } + } + + /** + * @inheritDoc + */ + public function delete(string $key): void + { + $filePath = $this->resolveFilePathForKey($key); + + if (is_file($filePath) && (!unlink($filePath))) { + throw new OidcClientException( + sprintf('Could not delete client registration file (%s).', $filePath), + ); + } + } + + protected function resolveFilePathForKey(string $key): string + { + return $this->storageDirectory . DIRECTORY_SEPARATOR . hash('sha256', $key) . '.json'; + } +} diff --git a/src/Registration/Interfaces/ClientRegistrationStoreInterface.php b/src/Registration/Interfaces/ClientRegistrationStoreInterface.php new file mode 100644 index 0000000..67f5353 --- /dev/null +++ b/src/Registration/Interfaces/ClientRegistrationStoreInterface.php @@ -0,0 +1,46 @@ + 'client-id', + 'client_secret' => 'client-secret', + 'client_secret_expires_at' => 0, + 'registration_access_token' => 'registration-access-token', + 'registration_client_uri' => 'https://op.example.org/register/client-id', + ]; + + protected MockObject $registrationStoreMock; + + protected MockObject $registrationHandlerMock; + + protected MockObject $metadataMock; + + protected MockObject $cacheMock; + + protected MockObject $preRegisteredClientMock; + + protected MockObject $sessionStoreMock; + + protected function setUp(): void + { + $this->registrationStoreMock = $this->createMock(ClientRegistrationStoreInterface::class); + $this->registrationHandlerMock = $this->createMock(ClientRegistrationHandler::class); + $this->metadataMock = $this->createMock(MetadataInterface::class); + $this->cacheMock = $this->createMock(\Psr\SimpleCache\CacheInterface::class); + $this->preRegisteredClientMock = $this->createMock(PreRegisteredClient::class); + $this->sessionStoreMock = $this->createMock(SessionStoreInterface::class); + + // By default, keep cache valid to avoid side-effects in most tests. + $this->cacheMock->method('get')->with('OIDC_OP_CONFIGURATION_URL') + ->willReturn($this->opConfigurationUrl); + } + + protected function sut( + ?string $opConfigurationUrl = null, + ?string $redirectUri = null, + ?string $scope = null, + ?string $clientName = null, + ?string $initialAccessToken = null, + ?ClientRegistrationStoreInterface $registrationStore = null, + array $additionalClientMetadata = [], + bool $includeSoftwareId = true, + ?\Psr\SimpleCache\CacheInterface $cache = null, + ?MetadataInterface $metadata = null, + ?ClientRegistrationHandler $registrationHandler = null, + ?PreRegisteredClient $preRegisteredClient = null, + bool $injectPreRegisteredClient = true, + ): DynamicallyRegisteredClient { + $registrationStore ??= $this->registrationStoreMock; + $cache ??= $this->cacheMock; + $metadata ??= $this->metadataMock; + $registrationHandler ??= $this->registrationHandlerMock; + + if ($injectPreRegisteredClient) { + $preRegisteredClient ??= $this->preRegisteredClientMock; + } + + $this->assertInstanceOf(ClientRegistrationStoreInterface::class, $registrationStore); + $this->assertInstanceOf(ClientRegistrationHandler::class, $registrationHandler); + $this->assertInstanceOf(SessionStoreInterface::class, $this->sessionStoreMock); + $this->assertInstanceOf(Client::class, $this->createStub(\GuzzleHttp\Client::class)); + + return new DynamicallyRegisteredClient( + opConfigurationUrl: $opConfigurationUrl ?? $this->opConfigurationUrl, + redirectUri: $redirectUri ?? $this->redirectUri, + scope: $scope ?? $this->scope, + clientName: $clientName, + initialAccessToken: $initialAccessToken, + registrationStore: $registrationStore, + additionalClientMetadata: $additionalClientMetadata, + includeSoftwareId: $includeSoftwareId, + cache: $cache, + sessionStore: $this->sessionStoreMock, + httpClient: $this->createStub(\GuzzleHttp\Client::class), + metadata: $metadata, + registrationHandler: $registrationHandler, + preRegisteredClient: $preRegisteredClient, + ); + } + + /** + * Client information response carrying the fingerprint of the metadata + * the default sut() would currently send, so register() considers the + * persisted registration in sync (no drift update / re-registration). + * + * @return mixed[] + */ + protected function clientInformationResponseWithCurrentFingerprint(): array + { + return array_merge($this->clientInformationResponse, [ + DynamicallyRegisteredClient::CLAIM_REQUESTED_METADATA_FINGERPRINT => + $this->sut()->buildClientRegistrationMetadataFingerprint(), + ]); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(DynamicallyRegisteredClient::class, $this->sut()); + } + + public function testCanGetMetadata(): void + { + $this->assertSame($this->metadataMock, $this->sut()->getMetadata()); + } + + public function testRegistrationStoreKeyContainsOpConfigurationUrlAndRedirectUri(): void + { + $registrationStoreKey = $this->sut()->getRegistrationStoreKey(); + + $this->assertStringContainsString($this->opConfigurationUrl, $registrationStoreKey); + $this->assertStringContainsString($this->redirectUri, $registrationStoreKey); + } + + public function testCanBuildClientRegistrationMetadata(): void + { + $clientMetadata = $this->sut(clientName: 'Test Client')->buildClientRegistrationMetadata(); + + $this->assertSame([$this->redirectUri], $clientMetadata['redirect_uris']); + $this->assertSame(['authorization_code'], $clientMetadata['grant_types']); + $this->assertSame(['code'], $clientMetadata['response_types']); + $this->assertSame('client_secret_basic', $clientMetadata['token_endpoint_auth_method']); + $this->assertSame($this->scope, $clientMetadata['scope']); + $this->assertSame('Test Client', $clientMetadata['client_name']); + $this->assertArrayHasKey('software_id', $clientMetadata); + } + + public function testCanBuildClientRegistrationMetadataWithoutOptionalClaims(): void + { + $clientMetadata = $this->sut(includeSoftwareId: false)->buildClientRegistrationMetadata(); + + $this->assertArrayNotHasKey('client_name', $clientMetadata); + $this->assertArrayNotHasKey('software_id', $clientMetadata); + } + + public function testAdditionalClientMetadataOverridesPreparedClaims(): void + { + $clientMetadata = $this->sut(additionalClientMetadata: [ + 'scope' => 'openid profile custom-scope', + 'contacts' => ['admin@example.org'], + ])->buildClientRegistrationMetadata(); + + $this->assertSame('openid profile custom-scope', $clientMetadata['scope']); + $this->assertSame(['admin@example.org'], $clientMetadata['contacts']); + } + + public function testAdditionalClientMetadataAllowsRuntimeCompatibleOverrides(): void + { + $clientMetadata = $this->sut(additionalClientMetadata: [ + 'token_endpoint_auth_method' => 'client_secret_basic', + 'redirect_uris' => [$this->redirectUri, 'https://rp.example.org/other-callback'], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + ])->buildClientRegistrationMetadata(); + + $this->assertSame( + [$this->redirectUri, 'https://rp.example.org/other-callback'], + $clientMetadata['redirect_uris'], + ); + $this->assertSame(['authorization_code', 'refresh_token'], $clientMetadata['grant_types']); + } + + public function testThrowsOnUnsupportedTokenEndpointAuthMethodOverride(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('token_endpoint_auth_method'); + + $this->sut(additionalClientMetadata: ['token_endpoint_auth_method' => 'client_secret_post']); + } + + public function testThrowsWhenRedirectUrisOverrideExcludesConfiguredRedirectUri(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('redirect_uris'); + + $this->sut(additionalClientMetadata: ['redirect_uris' => ['https://other.example.org/callback']]); + } + + public function testThrowsWhenGrantTypesOverrideExcludesAuthorizationCode(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('grant_types'); + + $this->sut(additionalClientMetadata: ['grant_types' => ['client_credentials']]); + } + + public function testThrowsWhenResponseTypesOverrideExcludesCode(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('response_types'); + + $this->sut(additionalClientMetadata: ['response_types' => ['token']]); + } + + public function testAllowsScopeSupersetOverride(): void + { + $clientMetadata = $this->sut(additionalClientMetadata: [ + 'scope' => $this->scope . ' offline_access', + ])->buildClientRegistrationMetadata(); + + $this->assertSame($this->scope . ' offline_access', $clientMetadata['scope']); + } + + public function testThrowsWhenScopeOverrideExcludesRuntimeScope(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('scope'); + + // Runtime scope is 'openid profile' - override drops 'profile'. + $this->sut(additionalClientMetadata: ['scope' => 'openid custom-scope']); + } + + public function testUpdateRegistrationThrowsOnRuntimeIncompatibleOverride(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->registrationHandlerMock->expects($this->never())->method('update'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('token_endpoint_auth_method'); + + $this->sut()->updateRegistration(['token_endpoint_auth_method' => 'client_secret_post']); + } + + public function testCanRegisterClient(): void + { + $this->registrationStoreMock->method('get')->willReturn(null); + $this->metadataMock->method('get')->with('registration_endpoint') + ->willReturn($this->registrationEndpoint); + + $this->registrationHandlerMock->expects($this->once()) + ->method('register') + ->with( + $this->registrationEndpoint, + $this->callback( + function (array $clientMetadata): bool { + $this->assertSame([$this->redirectUri], $clientMetadata['redirect_uris']); + return true; + }, + ), + null, + ) + ->willReturn($this->clientInformationResponse); + + // Registration is persisted under the current key and per client ID. + $this->registrationStoreMock->expects($this->exactly(2)) + ->method('set') + ->with( + $this->isString(), + $this->callback( + function (array $claims): bool { + $this->assertSame('client-id', $claims['client_id']); + // Requested metadata fingerprint is persisted for + // configuration drift detection. + $this->assertArrayHasKey( + DynamicallyRegisteredClient::CLAIM_REQUESTED_METADATA_FINGERPRINT, + $claims, + ); + return true; + }, + ), + ); + + $registrationData = $this->sut()->register(); + + $this->assertSame('client-id', $registrationData->getClientId()); + $this->assertSame('client-secret', $registrationData->getClientSecret()); + } + + public function testRegisterUsesInitialAccessToken(): void + { + $this->registrationStoreMock->method('get')->willReturn(null); + $this->metadataMock->method('get')->with('registration_endpoint') + ->willReturn($this->registrationEndpoint); + + $this->registrationHandlerMock->expects($this->once()) + ->method('register') + ->with($this->registrationEndpoint, $this->isArray(), 'initial-access-token') + ->willReturn($this->clientInformationResponse); + + $this->sut(initialAccessToken: 'initial-access-token')->register(); + } + + public function testRegisterReturnsPersistedRegistration(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationHandlerMock->expects($this->never())->method('update'); + + $registrationData = $this->sut()->register(); + + $this->assertSame('client-id', $registrationData->getClientId()); + } + + public function testRegisterPerformsNewRegistrationOnExpiredClientSecret(): void + { + $expiredClientInformation = array_merge($this->clientInformationResponse, [ + 'client_secret_expires_at' => time() - 60, + ]); + + $this->registrationStoreMock->method('get')->willReturn($expiredClientInformation); + $this->metadataMock->method('get')->with('registration_endpoint') + ->willReturn($this->registrationEndpoint); + + $this->registrationHandlerMock->expects($this->once()) + ->method('register') + ->willReturn(array_merge($this->clientInformationResponse, ['client_id' => 'renewed-client-id'])); + + // Replaced (expired) registration's per-client entry is removed. + $this->registrationStoreMock->expects($this->once()) + ->method('delete') + ->with($this->stringEndsWith('|client-id')); + + $registrationData = $this->sut()->register(); + + $this->assertFalse($registrationData->isClientSecretExpired()); + $this->assertSame('renewed-client-id', $registrationData->getClientId()); + } + + public function testCanForceNewRegistration(): void + { + // A valid (non-expired) registration exists, but a new one is forced. + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + $this->metadataMock->method('get')->with('registration_endpoint') + ->willReturn($this->registrationEndpoint); + + $this->registrationHandlerMock->expects($this->once()) + ->method('register') + ->willReturn(array_merge($this->clientInformationResponse, ['client_id' => 'forced-client-id'])); + + // Replaced registration's client secret is still valid, so its + // per-client entry is kept for in-flight authorization flows. + $this->registrationStoreMock->expects($this->never())->method('delete'); + + $registrationData = $this->sut()->register(forceNewRegistration: true); + + $this->assertSame('forced-client-id', $registrationData->getClientId()); + } + + public function testRegisterUpdatesRegistrationOnClientMetadataChange(): void + { + // Persisted registration was requested with different (stale) metadata. + $staleClaims = array_merge($this->clientInformationResponse, [ + DynamicallyRegisteredClient::CLAIM_REQUESTED_METADATA_FINGERPRINT => 'stale-fingerprint', + ]); + $this->registrationStoreMock->method('get')->willReturn($staleClaims); + + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationHandlerMock->expects($this->once()) + ->method('update') + ->with( + $this->clientInformationResponse['registration_client_uri'], + $this->clientInformationResponse['registration_access_token'], + $this->isArray(), + ) + ->willReturn($this->clientInformationResponse); + + // Refreshed fingerprint is persisted with the updated registration. + $this->registrationStoreMock->expects($this->exactly(2)) + ->method('set') + ->with( + $this->isString(), + $this->callback( + function (array $claims): bool { + $this->assertNotSame( + 'stale-fingerprint', + $claims[DynamicallyRegisteredClient::CLAIM_REQUESTED_METADATA_FINGERPRINT], + ); + return true; + }, + ), + ); + + $registrationData = $this->sut()->register(); + + $this->assertSame('client-id', $registrationData->getClientId()); + } + + public function testRegisterPerformsNewRegistrationWhenMetadataChangeUpdateNotPossible(): void + { + // Persisted registration with stale metadata and no management claims + // (no registration_client_uri / registration_access_token). + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'client_secret_expires_at' => 0, + ]); + $this->metadataMock->method('get')->with('registration_endpoint') + ->willReturn($this->registrationEndpoint); + + $this->registrationHandlerMock->expects($this->once()) + ->method('register') + ->willReturn(array_merge($this->clientInformationResponse, ['client_id' => 'new-client-id'])); + + $registrationData = $this->sut()->register(); + + $this->assertSame('new-client-id', $registrationData->getClientId()); + } + + public function testRegisterThrowsWhenRegistrationEndpointNotAvailable(): void + { + $this->registrationStoreMock->method('get')->willReturn(null); + $this->metadataMock->method('get')->with('registration_endpoint') + ->willThrowException(new OidcClientException('OIDC metadata parameter not supported.')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('registration_endpoint'); + + $this->sut()->register(); + } + + public function testAuthorizeDelegatesToPreRegisteredClient(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('authorize') + ->willReturn(null); + + $this->assertNotInstanceOf(\Psr\Http\Message\ResponseInterface::class, $this->sut()->authorize()); + } + + public function testGetUserDataDelegatesToPreRegisteredClient(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('getUserData') + ->willReturn(['sub' => 'user-id']); + + $this->assertSame(['sub' => 'user-id'], $this->sut()->getUserData()); + } + + public function testGetUserDataWrapsErrors(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('getUserData') + ->willThrowException(new \Exception('Some error.')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('User data error'); + + $this->sut()->getUserData(); + } + + public function testAuthorizeBindsFlowClientIdToSession(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + // Only the (public) client ID is bound to the session, never claims + // containing client credentials. + $this->sessionStoreMock->expects($this->once()) + ->method('put') + ->with($this->isString(), 'client-id'); + + $this->preRegisteredClientMock->expects($this->once())->method('authorize'); + + $this->sut()->authorize(); + } + + public function testGetUserDataUsesFlowRegistrationBoundToSession(): void + { + $this->sessionStoreMock->method('get')->willReturn('flow-client-id'); + // Flow client ID binding is removed from session after successful use. + $this->sessionStoreMock->expects($this->once())->method('delete'); + + // Flow registration is resolved per client ID, taking precedence over + // the current persisted registration. + $this->registrationStoreMock->expects($this->once()) + ->method('get') + ->with($this->stringContains('flow-client-id')) + ->willReturn([ + 'client_id' => 'flow-client-id', + 'client_secret' => 'flow-client-secret', + 'client_secret_expires_at' => 0, + ]); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('getUserData') + ->willReturn(['sub' => 'user-id']); + + $this->assertSame(['sub' => 'user-id'], $this->sut()->getUserData()); + } + + public function testGetUserDataKeepsFlowRegistrationOnError(): void + { + $this->sessionStoreMock->method('get')->willReturn('flow-client-id'); + // Flow binding is kept in session, so the callback can be retried. + $this->sessionStoreMock->expects($this->never())->method('delete'); + + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'flow-client-id', + 'client_secret' => 'flow-client-secret', + 'client_secret_expires_at' => 0, + ]); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('getUserData') + ->willThrowException(new \Exception('Token error.')); + + $this->expectException(OidcClientException::class); + + $this->sut()->getUserData(); + } + + public function testGetUserDataFallsBackToPersistedRegistrationOnUnresolvableFlowClientId(): void + { + $this->sessionStoreMock->method('get')->willReturn('flow-client-id'); + + // No per-client registration available for the flow client ID, so the + // current persisted registration is used. + $this->registrationStoreMock->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback( + fn (string $key): ?array => str_contains($key, 'flow-client-id') ? + null : + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('getUserData') + ->willReturn(['sub' => 'user-id']); + + $this->assertSame(['sub' => 'user-id'], $this->sut()->getUserData()); + } + + public function testGetUserDataFallsBackToPersistedRegistrationOnInvalidFlowRegistration(): void + { + $this->sessionStoreMock->method('get')->willReturn('flow-client-id'); + // Invalid flow binding is discarded, and session is cleared after success. + $this->sessionStoreMock->expects($this->exactly(2))->method('delete'); + + // Per-client registration entry is invalid (no valid client_id). + $this->registrationStoreMock->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback( + fn (string $key): array => str_contains($key, 'flow-client-id') ? + ['client_id' => ''] : + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('getUserData') + ->willReturn(['sub' => 'user-id']); + + $this->assertSame(['sub' => 'user-id'], $this->sut()->getUserData()); + } + + public function testResolvePreRegisteredClientBuildsNewInstanceOnEachCall(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $sut = $this->sut(injectPreRegisteredClient: false); + + // No memoization, so the current registration is always honored. + $this->assertNotSame($sut->resolvePreRegisteredClient(), $sut->resolvePreRegisteredClient()); + } + + public function testResolvePreRegisteredClientThrowsWhenNoClientSecretIssued(): void + { + $clientInformationWithoutSecret = [ + 'client_id' => 'client-id', + DynamicallyRegisteredClient::CLAIM_REQUESTED_METADATA_FINGERPRINT => + $this->sut()->buildClientRegistrationMetadataFingerprint(), + ]; + $this->registrationStoreMock->method('get')->willReturn($clientInformationWithoutSecret); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('client secret'); + + $this->sut(injectPreRegisteredClient: false)->resolvePreRegisteredClient(); + } + + public function testCanReadRegistration(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + // Simulate OP not repeating registration management claims in read response. + $readResponse = [ + 'client_id' => 'client-id', + 'client_secret' => 'rotated-client-secret', + 'client_secret_expires_at' => 0, + ]; + + $this->registrationHandlerMock->expects($this->once()) + ->method('read') + ->with( + $this->clientInformationResponse['registration_client_uri'], + $this->clientInformationResponse['registration_access_token'], + ) + ->willReturn($readResponse); + + // Registration is persisted under the current key and per client ID. + $this->registrationStoreMock->expects($this->exactly(2)) + ->method('set') + ->with( + $this->isString(), + $this->callback( + function (array $claims): bool { + $this->assertSame('rotated-client-secret', $claims['client_secret']); + $this->assertSame('registration-access-token', $claims['registration_access_token']); + return true; + }, + ), + ); + + $registrationData = $this->sut()->readRegistration(); + + $this->assertSame('rotated-client-secret', $registrationData->getClientSecret()); + // Previous claims are kept when not repeated in the response. + $this->assertSame('registration-access-token', $registrationData->getRegistrationAccessToken()); + } + + public function testCanUpdateRegistration(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->registrationHandlerMock->expects($this->once()) + ->method('update') + ->with( + $this->clientInformationResponse['registration_client_uri'], + $this->clientInformationResponse['registration_access_token'], + $this->callback( + function (array $clientMetadata): bool { + $this->assertSame('client-id', $clientMetadata['client_id']); + $this->assertSame('Updated Client', $clientMetadata['client_name']); + return true; + }, + ), + ) + ->willReturn(array_merge($this->clientInformationResponse, ['client_name' => 'Updated Client'])); + + $this->registrationStoreMock->expects($this->exactly(2))->method('set'); + + $registrationData = $this->sut()->updateRegistration(['client_name' => 'Updated Client']); + + $this->assertSame('Updated Client', $registrationData->getClaims()['client_name']); + } + + public function testCanDeleteRegistration(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->registrationHandlerMock->expects($this->once()) + ->method('delete') + ->with( + $this->clientInformationResponse['registration_client_uri'], + $this->clientInformationResponse['registration_access_token'], + ); + + // Current and per-client registration entries are deleted. + $this->registrationStoreMock->expects($this->exactly(2)) + ->method('delete') + ->with($this->isString()); + + $this->sut()->deleteRegistration(); + } + + public function testManagementThrowsWhenNoRegistrationExists(): void + { + $this->registrationStoreMock->method('get')->willReturn(null); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('No existing client registration'); + + $this->sut()->readRegistration(); + } + + public function testManagementThrowsWhenRegistrationClientUriNotAvailable(): void + { + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'client-id', + 'registration_access_token' => 'registration-access-token', + ]); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('registration_client_uri'); + + $this->sut()->readRegistration(); + } + + public function testManagementThrowsWhenRegistrationAccessTokenNotAvailable(): void + { + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'client-id', + 'registration_client_uri' => 'https://op.example.org/register/client-id', + ]); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('registration_access_token'); + + $this->sut()->readRegistration(); + } + + public function testCacheIsReinitializedOnOpConfigurationUrlChange(): void + { + $cacheMock = $this->createMock(\Psr\SimpleCache\CacheInterface::class); + $cacheMock->method('get')->with('OIDC_OP_CONFIGURATION_URL')->willReturn('https://other.example.org'); + $cacheMock->expects($this->once())->method('clear'); + $cacheMock->expects($this->once())->method('set') + ->with('OIDC_OP_CONFIGURATION_URL', $this->opConfigurationUrl); + + $this->sut(cache: $cacheMock); + } + + public function testThrowsOnCacheError(): void + { + $cacheMock = $this->createMock(\Psr\SimpleCache\CacheInterface::class); + $cacheMock->method('get')->willThrowException(new \Exception('Cache error.')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Cache validation error'); + + $this->sut(cache: $cacheMock); + } +} diff --git a/tests/Oidc/Protocol/ClientRegistrationHandlerTest.php b/tests/Oidc/Protocol/ClientRegistrationHandlerTest.php new file mode 100644 index 0000000..14948aa --- /dev/null +++ b/tests/Oidc/Protocol/ClientRegistrationHandlerTest.php @@ -0,0 +1,255 @@ + ['https://rp.example.org/callback'], + 'token_endpoint_auth_method' => 'client_secret_basic', + ]; + + /** + * @var mixed[] + */ + protected array $clientInformationResponse = [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'client_secret_expires_at' => 0, + 'registration_access_token' => 'registration-access-token', + 'registration_client_uri' => 'https://op.example.org/register/client-id', + ]; + + protected function setUp(): void + { + $this->httpClientMock = $this->createMock(Client::class); + $this->capturedRequest = null; + } + + protected function sut( + ?Client $httpClient = null, + ): ClientRegistrationHandler { + $httpClient ??= $this->httpClientMock; + $this->assertInstanceOf(Client::class, $httpClient); + + return new ClientRegistrationHandler( + httpClient: $httpClient, + ); + } + + protected function mockHttpResponse(Response $response): void + { + $this->httpClientMock->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($response): Response { + $this->capturedRequest = $request; + return $response; + }); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ClientRegistrationHandler::class, $this->sut()); + } + + public function testCanRegisterClient(): void + { + $this->mockHttpResponse(new Response(201, [], json_encode($this->clientInformationResponse))); + + $claims = $this->sut()->register($this->registrationEndpoint, $this->clientMetadata); + + $this->assertSame($this->clientInformationResponse, $claims); + + $this->assertInstanceOf(RequestInterface::class, $this->capturedRequest); + $this->assertSame('POST', $this->capturedRequest->getMethod()); + $this->assertSame($this->registrationEndpoint, (string) $this->capturedRequest->getUri()); + $this->assertSame('application/json', $this->capturedRequest->getHeaderLine('Content-Type')); + $this->assertSame('application/json', $this->capturedRequest->getHeaderLine('Accept')); + $this->assertFalse($this->capturedRequest->hasHeader('Authorization')); + $this->assertSame( + $this->clientMetadata, + json_decode((string) $this->capturedRequest->getBody(), true), + ); + } + + public function testCanRegisterClientWithInitialAccessToken(): void + { + $this->mockHttpResponse(new Response(201, [], json_encode($this->clientInformationResponse))); + + $this->sut()->register($this->registrationEndpoint, $this->clientMetadata, 'initial-access-token'); + + $this->assertInstanceOf(RequestInterface::class, $this->capturedRequest); + $this->assertSame( + 'Bearer initial-access-token', + $this->capturedRequest->getHeaderLine('Authorization'), + ); + } + + public function testRegisterThrowsOnUnexpectedStatusCode(): void + { + $this->mockHttpResponse(new Response(200, [], json_encode($this->clientInformationResponse))); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('not successful'); + + $this->sut()->register($this->registrationEndpoint, $this->clientMetadata); + } + + public function testRegisterSurfacesErrorResponse(): void + { + $this->mockHttpResponse(new Response(400, [], json_encode([ + 'error' => 'invalid_redirect_uri', + 'error_description' => 'Redirect URI is not valid.', + ]))); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('invalid_redirect_uri'); + + $this->sut()->register($this->registrationEndpoint, $this->clientMetadata); + } + + public function testRegisterThrowsOnInvalidJsonResponse(): void + { + $this->mockHttpResponse(new Response(201, [], 'not-json')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('JSON response is not valid'); + + $this->sut()->register($this->registrationEndpoint, $this->clientMetadata); + } + + public function testRegisterWrapsHttpClientErrors(): void + { + $this->httpClientMock->expects($this->once()) + ->method('sendRequest') + ->willThrowException(new \Exception('Connection error.')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('request error'); + + $this->sut()->register($this->registrationEndpoint, $this->clientMetadata); + } + + public function testCanReadClientRegistration(): void + { + $this->mockHttpResponse(new Response(200, [], json_encode($this->clientInformationResponse))); + + $claims = $this->sut()->read($this->registrationClientUri, $this->registrationAccessToken); + + $this->assertSame($this->clientInformationResponse, $claims); + + $this->assertInstanceOf(RequestInterface::class, $this->capturedRequest); + $this->assertSame('GET', $this->capturedRequest->getMethod()); + $this->assertSame($this->registrationClientUri, (string) $this->capturedRequest->getUri()); + $this->assertSame( + 'Bearer ' . $this->registrationAccessToken, + $this->capturedRequest->getHeaderLine('Authorization'), + ); + } + + public function testCanUpdateClientRegistration(): void + { + $this->mockHttpResponse(new Response(200, [], json_encode($this->clientInformationResponse))); + + $updateMetadata = array_merge( + ['client_id' => 'client-id'], + $this->clientMetadata, + [ + 'registration_access_token' => 'must-be-removed', + 'registration_client_uri' => 'must-be-removed', + 'client_id_issued_at' => 123, + 'client_secret_expires_at' => 456, + ], + ); + + $claims = $this->sut()->update( + $this->registrationClientUri, + $this->registrationAccessToken, + $updateMetadata, + ); + + $this->assertSame($this->clientInformationResponse, $claims); + + $this->assertInstanceOf(RequestInterface::class, $this->capturedRequest); + $this->assertSame('PUT', $this->capturedRequest->getMethod()); + $this->assertSame( + 'Bearer ' . $this->registrationAccessToken, + $this->capturedRequest->getHeaderLine('Authorization'), + ); + + $sentMetadata = json_decode((string) $this->capturedRequest->getBody(), true); + $this->assertIsArray($sentMetadata); + $this->assertSame('client-id', $sentMetadata['client_id']); + // Per RFC 7592, these claims must not be sent in update requests. + $this->assertArrayNotHasKey('registration_access_token', $sentMetadata); + $this->assertArrayNotHasKey('registration_client_uri', $sentMetadata); + $this->assertArrayNotHasKey('client_id_issued_at', $sentMetadata); + $this->assertArrayNotHasKey('client_secret_expires_at', $sentMetadata); + } + + public function testUpdateThrowsOnMissingClientId(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('client_id'); + + $this->sut()->update( + $this->registrationClientUri, + $this->registrationAccessToken, + $this->clientMetadata, + ); + } + + public function testCanDeleteClientRegistration(): void + { + $this->mockHttpResponse(new Response(204)); + + $this->sut()->delete($this->registrationClientUri, $this->registrationAccessToken); + + $this->assertInstanceOf(RequestInterface::class, $this->capturedRequest); + $this->assertSame('DELETE', $this->capturedRequest->getMethod()); + $this->assertSame( + 'Bearer ' . $this->registrationAccessToken, + $this->capturedRequest->getHeaderLine('Authorization'), + ); + } + + public function testDeleteThrowsOnUnexpectedStatusCode(): void + { + $this->mockHttpResponse(new Response(403, [], json_encode(['error' => 'invalid_token']))); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('invalid_token'); + + $this->sut()->delete($this->registrationClientUri, $this->registrationAccessToken); + } +} diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index ab00d83..e07bbcb 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -856,7 +856,9 @@ public function testRequestTokenDataWithClientSecretBasicAddsHeader(): void ->method('withHeader') ->willReturnCallback(function (string $name, $value) use ($request): MockObject { if ($name === 'Authorization') { - $this->assertSame('Basic ' . base64_encode('client-id:client-secret'), $value); + // Per RFC 6749 section 2.3.1, client ID and secret must be + // form-urlencoded before being used as Basic credentials. + $this->assertSame('Basic ' . base64_encode('client-id:secret_w%3Ai%2Bt%25h+spec%2Fals'), $value); } return $request; @@ -876,7 +878,7 @@ public function testRequestTokenDataWithClientSecretBasicAddsHeader(): void 'auth-code', 'client-id', 'https://client.example.com/cb', - 'client-secret' + 'secret_w:i+t%h spec/als' ); } @@ -1280,7 +1282,9 @@ public function testPushAuthorizationRequestWithClientSecretBasicAddsHeader(): v ->method('withHeader') ->willReturnCallback(function (string $name, $value) use ($request): MockObject { if ($name === 'Authorization') { - $this->assertSame('Basic ' . base64_encode('client-id:client-secret'), $value); + // Per RFC 6749 section 2.3.1, client ID and secret must be + // form-urlencoded before being used as Basic credentials. + $this->assertSame('Basic ' . base64_encode('client-id:secret_w%3Ai%2Bt%25h+spec%2Fals'), $value); } return $request; @@ -1299,7 +1303,7 @@ public function testPushAuthorizationRequestWithClientSecretBasicAddsHeader(): v 'https://op.example.com/par', ['response_type' => 'code'], 'client-id', - 'client-secret', + 'secret_w:i+t%h spec/als', ); $this->assertSame(['request_uri' => 'urn:abc', 'expires_in' => 90], $result); diff --git a/tests/Oidc/Registration/ClientRegistrationDataTest.php b/tests/Oidc/Registration/ClientRegistrationDataTest.php new file mode 100644 index 0000000..62c28ae --- /dev/null +++ b/tests/Oidc/Registration/ClientRegistrationDataTest.php @@ -0,0 +1,128 @@ + 'client-id', + 'client_secret' => 'client-secret', + 'client_id_issued_at' => 1700000000, + 'client_secret_expires_at' => 1800000000, + 'registration_access_token' => 'registration-access-token', + 'registration_client_uri' => 'https://op.example.org/register/client-id', + ]; + + /** + * @param ?mixed[] $claims + */ + protected function sut(?array $claims = null): ClientRegistrationData + { + return new ClientRegistrationData($claims ?? $this->claims); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ClientRegistrationData::class, $this->sut()); + } + + public function testCanGetClaimValues(): void + { + $sut = $this->sut(); + + $this->assertSame('client-id', $sut->getClientId()); + $this->assertSame('client-secret', $sut->getClientSecret()); + $this->assertSame(1700000000, $sut->getClientIdIssuedAt()); + $this->assertSame(1800000000, $sut->getClientSecretExpiresAt()); + $this->assertSame('registration-access-token', $sut->getRegistrationAccessToken()); + $this->assertSame('https://op.example.org/register/client-id', $sut->getRegistrationClientUri()); + $this->assertSame($this->claims, $sut->getClaims()); + $this->assertSame($this->claims, $sut->jsonSerialize()); + } + + public function testCanCreateInstanceWithMinimalClaims(): void + { + $sut = $this->sut(['client_id' => 'client-id']); + + $this->assertSame('client-id', $sut->getClientId()); + $this->assertNull($sut->getClientSecret()); + $this->assertNull($sut->getClientIdIssuedAt()); + $this->assertSame(0, $sut->getClientSecretExpiresAt()); + $this->assertNull($sut->getRegistrationAccessToken()); + $this->assertNull($sut->getRegistrationClientUri()); + } + + public function testThrowsOnMissingClientId(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('client_id'); + + $this->sut(['client_secret' => 'client-secret']); + } + + public function testThrowsOnEmptyClientId(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('client_id'); + + $this->sut(['client_id' => '']); + } + + public function testThrowsOnInvalidClientSecret(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('client_secret'); + + $this->sut(['client_id' => 'client-id', 'client_secret' => '']); + } + + public function testClientSecretIsNotExpiredWhenNoSecretIssued(): void + { + $sut = $this->sut(['client_id' => 'client-id', 'client_secret_expires_at' => 1]); + + $this->assertFalse($sut->isClientSecretExpired()); + } + + public function testClientSecretIsNotExpiredWhenExpiryIsZero(): void + { + $sut = $this->sut(['client_id' => 'client-id', 'client_secret' => 'client-secret']); + + $this->assertFalse($sut->isClientSecretExpired()); + } + + public function testClientSecretExpiry(): void + { + $sut = $this->sut([ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'client_secret_expires_at' => 1800000000, + ]); + + $this->assertFalse($sut->isClientSecretExpired(1799999999)); + $this->assertTrue($sut->isClientSecretExpired(1800000000)); + $this->assertTrue($sut->isClientSecretExpired(1800000001)); + } + + public function testCanHandleNumericStringTimestamps(): void + { + $sut = $this->sut([ + 'client_id' => 'client-id', + 'client_id_issued_at' => '1700000000', + 'client_secret_expires_at' => '1800000000', + ]); + + $this->assertSame(1700000000, $sut->getClientIdIssuedAt()); + $this->assertSame(1800000000, $sut->getClientSecretExpiresAt()); + } +} diff --git a/tests/Oidc/Registration/FileClientRegistrationStoreTest.php b/tests/Oidc/Registration/FileClientRegistrationStoreTest.php new file mode 100644 index 0000000..df1cad7 --- /dev/null +++ b/tests/Oidc/Registration/FileClientRegistrationStoreTest.php @@ -0,0 +1,190 @@ +storageDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . + 'oidc-client-php-registration-store-test-' . uniqid(); + } + + protected function tearDown(): void + { + if (is_dir($this->storageDirectory)) { + $files = glob($this->storageDirectory . DIRECTORY_SEPARATOR . '*'); + foreach (is_array($files) ? $files : [] as $file) { + unlink($file); + } + + rmdir($this->storageDirectory); + } + } + + protected function sut(?string $storageDirectory = null): FileClientRegistrationStore + { + return new FileClientRegistrationStore($storageDirectory ?? $this->storageDirectory); + } + + public function testCanCreateInstanceAndStorageDirectory(): void + { + $sut = $this->sut(); + + $this->assertInstanceOf(FileClientRegistrationStore::class, $sut); + $this->assertSame($this->storageDirectory, $sut->getStorageDirectory()); + $this->assertDirectoryExists($this->storageDirectory); + } + + public function testCanSetGetAndDeleteRegistration(): void + { + $sut = $this->sut(); + $claims = ['client_id' => 'client-id', 'client_secret' => 'client-secret']; + + $this->assertNull($sut->get('key')); + + $sut->set('key', $claims); + $this->assertSame($claims, $sut->get('key')); + + $sut->delete('key'); + $this->assertNull($sut->get('key')); + } + + public function testCanOverwriteRegistration(): void + { + $sut = $this->sut(); + + $sut->set('key', ['client_id' => 'client-id']); + $sut->set('key', ['client_id' => 'new-client-id']); + + $this->assertSame(['client_id' => 'new-client-id'], $sut->get('key')); + } + + public function testRegistrationsAreIsolatedPerKey(): void + { + $sut = $this->sut(); + + $sut->set('key', ['client_id' => 'client-id']); + $sut->set('another-key', ['client_id' => 'another-client-id']); + + $this->assertSame(['client_id' => 'client-id'], $sut->get('key')); + $this->assertSame(['client_id' => 'another-client-id'], $sut->get('another-key')); + } + + public function testDeleteOfNonExistentEntryIsNotAnError(): void + { + $this->sut()->delete('non-existent-key'); + + $this->expectNotToPerformAssertions(); + } + + public function testThrowsOnInvalidFileContent(): void + { + $sut = $this->sut(); + $sut->set('key', ['client_id' => 'client-id']); + + $files = glob($this->storageDirectory . DIRECTORY_SEPARATOR . '*.json'); + $this->assertIsArray($files); + $this->assertCount(1, $files); + file_put_contents($files[0], 'not-json'); + + $this->expectException(OidcClientException::class); + + $sut->get('key'); + } + + public function testDefaultStorageDirectoryIsDedicatedPerUser(): void + { + $sut = new FileClientRegistrationStore(); + + $this->assertStringContainsString( + FileClientRegistrationStore::DEFAULT_DIRECTORY_NAME, + $sut->getStorageDirectory(), + ); + + if (function_exists('posix_geteuid')) { + $this->assertStringEndsWith('-' . posix_geteuid(), $sut->getStorageDirectory()); + } + } + + public function testSetWritesAtomicallyAndRestrictsFilePermissions(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('POSIX permissions are not applicable on Windows.'); + } + + $this->sut()->set('key', ['client_id' => 'client-id']); + + // No temporary file leftovers, only the final registration file. + $files = glob($this->storageDirectory . DIRECTORY_SEPARATOR . '*'); + $this->assertIsArray($files); + $this->assertCount(1, $files); + $this->assertStringEndsWith('.json', $files[0]); + + // Contains client credentials, so access is restricted to the owner. + $this->assertSame(0o600, fileperms($files[0]) & 0o777); + } + + public function testTightensPermissiveStorageDirectoryPermissions(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('POSIX permissions are not applicable on Windows.'); + } + + mkdir($this->storageDirectory, 0777, true); + chmod($this->storageDirectory, 0777); + + $this->sut(); + + clearstatcache(true, $this->storageDirectory); + $this->assertSame(0o700, fileperms($this->storageDirectory) & 0o777); + } + + public function testRejectsSymlinkedStorageDirectory(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Symbolic link handling is not applicable on Windows.'); + } + + $realDirectory = $this->storageDirectory . '-real'; + mkdir($realDirectory, 0700, true); + symlink($realDirectory, $this->storageDirectory); + + try { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('symbolic link'); + $this->sut(); + } finally { + unlink($this->storageDirectory); + rmdir($realDirectory); + } + } + + public function testThrowsOnNonWritableStorageDirectory(): void + { + mkdir($this->storageDirectory, 0500, true); + + // Skip if running as root (root can write anywhere). + if (is_writable($this->storageDirectory)) { + chmod($this->storageDirectory, 0700); + $this->markTestSkipped('Storage directory is writable regardless of permissions (running as root?).'); + } + + try { + $this->expectException(OidcClientException::class); + $this->sut(); + } finally { + chmod($this->storageDirectory, 0700); + } + } +}