From 5c0ae24516ad9448b440b6f4a1d7c3bcf5307361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 25 Jun 2026 17:39:19 +0200 Subject: [PATCH 1/6] WIP --- composer.json | 2 +- docs/3-oidc-configuration.md | 49 +++ routing/routes/routes.php | 7 + routing/services/services.yml | 3 + src/Codebooks/DcrRegistrationAuthEnum.php | 24 ++ src/Codebooks/RegistrationTypeEnum.php | 2 + src/Codebooks/RoutesEnum.php | 2 + src/Controllers/RegistrationController.php | 279 ++++++++++++++++++ src/Entities/ClientEntity.php | 23 ++ .../Interfaces/ClientEntityInterface.php | 2 + .../Entities/ClientEntityFactory.php | 36 +++ src/ModuleConfig.php | 70 +++++ src/Repositories/ClientRepository.php | 9 +- src/Server/Exceptions/OidcServerException.php | 29 ++ .../Registration/ClientMetadataValidator.php | 225 ++++++++++++++ src/Services/DatabaseMigration.php | 19 ++ src/Services/OpMetadataService.php | 4 + .../RegistrationControllerTest.php | 232 +++++++++++++++ tests/unit/src/Entities/ClientEntityTest.php | 1 + .../Entities/ClientEntityFactoryTest.php | 38 +++ .../ClientMetadataValidatorTest.php | 143 +++++++++ .../src/Services/OpMetadataServiceTest.php | 23 ++ 22 files changed, 1218 insertions(+), 4 deletions(-) create mode 100644 src/Codebooks/DcrRegistrationAuthEnum.php create mode 100644 src/Controllers/RegistrationController.php create mode 100644 src/Server/Registration/ClientMetadataValidator.php create mode 100644 tests/unit/src/Controllers/RegistrationControllerTest.php create mode 100644 tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php diff --git a/composer.json b/composer.json index c4e8ed7c..90336144 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/container": "^2.0", "psr/log": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~v0.3.5", + "simplesamlphp/openid": "~0.3.7", "spomky-labs/base64url": "^2.0", "symfony/expression-language": "^7.4", "symfony/psr-http-message-bridge": "^7.4", diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index df0364d9..3dd318e9 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -355,6 +355,55 @@ Users can visit the following link for administration: - [https://example.com/simplesaml/module.php/oidc/clients/](https://example.com/simplesaml/module.php/oidc/clients/) +## OpenID Connect Dynamic Client Registration + +The module can let Relying Parties register themselves dynamically, as described +by [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) +(which is also compatible with RFC 7591). It exposes: + +- a **Client Registration Endpoint** (`POST .../oidc/register`) that creates a + client and returns its `client_id`, `client_secret` (for confidential + clients), a `registration_access_token` and a `registration_client_uri`; and +- a **Client Configuration Endpoint** (`GET .../oidc/register?client_id=...`) + that returns the current registration when called with the + `registration_access_token` as a bearer token. + +When enabled, the registration endpoint is advertised as `registration_endpoint` +in the OP discovery metadata. + +The feature is **disabled by default**. Configure it in `module_oidc.php`: + +```php + true, + + // Access-control mode for the registration endpoint: + // - 'open' (default): anyone may register (rely on deployment rate limiting); + // - 'initial_access_token': callers must present a configured bearer token. + \SimpleSAML\Module\oidc\ModuleConfig::OPTION_OIDC_DCR_REGISTRATION_AUTH => + \SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum::Open->value, + + // Allow-list of Initial Access Tokens, consulted only in 'initial_access_token' mode. + \SimpleSAML\Module\oidc\ModuleConfig::OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS => [ + // 'a-long-random-secret-token', + ], + + // Impersonation protection (spec Section 9.1). When on (default), the host of + // logo_uri / policy_uri / tos_uri must match the host of one of the registered + // redirect_uris, otherwise registration is rejected with invalid_client_metadata. + // Turn off if your clients legitimately host these on a different (e.g. CDN) domain. + \SimpleSAML\Module\oidc\ModuleConfig::OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED => true, +]; +``` + +Note that dynamically registered clients are stored like any other client and +are visible in the admin UI. Open registration lets anyone create a client, so +protect the endpoint with rate limiting at the web-server level, or require an +Initial Access Token. + ## Running multiple OPs on one server A single module instance is designed to serve exactly one OpenID Provider diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 0be7317f..97f76b6a 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -21,6 +21,7 @@ use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController; use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController; +use SimpleSAML\Module\oidc\Controllers\RegistrationController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -105,6 +106,12 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); + // OpenID Connect Dynamic Client Registration. POST registers a new client (create); GET reads an existing + // registration (Client Configuration Endpoint), authenticated with the Registration Access Token. + $routes->add(RoutesEnum::Registration->name, RoutesEnum::Registration->value) + ->controller([RegistrationController::class, 'registration']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + /***************************************************************************************************************** * OAuth 2.0 Authorization Server ****************************************************************************************************************/ diff --git a/routing/services/services.yml b/routing/services/services.yml index 16e0d221..31fdbdcb 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -51,6 +51,9 @@ services: SimpleSAML\Module\oidc\Server\TokenIssuers\: resource: '../../src/Server/TokenIssuers/*' + SimpleSAML\Module\oidc\Server\Registration\: + resource: '../../src/Server/Registration/*' + SimpleSAML\Module\oidc\ModuleConfig: ~ SimpleSAML\Module\oidc\Helpers: ~ SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection: ~ diff --git a/src/Codebooks/DcrRegistrationAuthEnum.php b/src/Codebooks/DcrRegistrationAuthEnum.php new file mode 100644 index 00000000..9c72ed15 --- /dev/null +++ b/src/Codebooks/DcrRegistrationAuthEnum.php @@ -0,0 +1,24 @@ + Translate::noop('Manual'), self::FederatedAutomatic => Translate::noop('Federated Automatic'), + self::Dynamic => Translate::noop('Dynamic'), }; } } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 9f86c543..fa685d76 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -42,6 +42,8 @@ enum RoutesEnum: string case UserInfo = 'userinfo'; case Jwks = 'jwks'; case EndSession = 'end-session'; + // OpenID Connect Dynamic Client Registration endpoint (create + read). + case Registration = 'register'; /***************************************************************************************************************** * OAuth 2.0 Authorization Server diff --git a/src/Controllers/RegistrationController.php b/src/Controllers/RegistrationController.php new file mode 100644 index 00000000..35004fd3 --- /dev/null +++ b/src/Controllers/RegistrationController.php @@ -0,0 +1,279 @@ +psrHttpBridge->getPsrHttpFactory()->createRequest($request); + $psrResponse = $this->handle($psrRequest); + + return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse); + } catch (OAuthServerException $exception) { + return $this->errorResponder->forExceptionJson($exception); + } catch (\Throwable $exception) { + $this->logger->error( + 'RegistrationController: error processing registration request: ' . $exception->getMessage(), + ); + + return $this->errorResponder->forExceptionJson( + OidcServerException::serverError('Unable to process the registration request.'), + ); + } + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + protected function handle(ServerRequestInterface $request): ResponseInterface + { + if (!$this->moduleConfig->getOidcDcrEnabled()) { + return $this->psrHttpBridge->getResponseFactory()->createResponse()->withStatus(404); + } + + return match (strtoupper($request->getMethod())) { + HttpMethodsEnum::POST->value => $this->register($request), + HttpMethodsEnum::GET->value => $this->read($request), + default => $this->psrHttpBridge->getResponseFactory()->createResponse() + ->withStatus(405) + ->withHeader('Allow', HttpMethodsEnum::GET->value . ', ' . HttpMethodsEnum::POST->value), + }; + } + + /** + * Handle a Client Registration Request (Section 3.1). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + protected function register(ServerRequestInterface $request): ResponseInterface + { + $this->guardAccess($request); + + $metadata = $this->parseMetadata($request); + $metadata = $this->clientMetadataValidator->validate($metadata); + + $client = $this->clientEntityFactory->fromRegistrationData($metadata, RegistrationTypeEnum::Dynamic); + + // Issue a Registration Access Token; only its hash is persisted, the plaintext is returned once. + $registrationAccessToken = $this->helpers->random()->getIdentifier(); + $client->setRegistrationAccessTokenHash($this->hashToken($registrationAccessToken)); + + $this->clientRepository->add($client); + + $response = $this->buildClientInformationResponse($client); + $response[ClaimsEnum::RegistrationAccessToken->value] = $registrationAccessToken; + + return $this->jsonResponse($response, 201); + } + + /** + * Handle a Client Read Request (Section 4.2) at the Client Configuration Endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + protected function read(ServerRequestInterface $request): ResponseInterface + { + /** @var mixed $clientId */ + $clientId = $request->getQueryParams()[ClaimsEnum::ClientId->value] ?? null; + $token = $this->extractBearerToken($request); + + if (!is_string($clientId) || $clientId === '' || $token === null) { + throw OidcServerException::accessDenied('A valid client_id and Registration Access Token are required.'); + } + + $client = $this->clientRepository->findById($clientId); + $expectedHash = $client?->getRegistrationAccessTokenHash(); + + // Per Section 4.4, never reveal whether a client exists: respond 401 for every failure case (no 404). + if ( + $client === null || + $client->getRegistrationType() !== RegistrationTypeEnum::Dynamic || + $expectedHash === null || + !hash_equals($expectedHash, $this->hashToken($token)) + ) { + throw OidcServerException::accessDenied('Invalid Registration Access Token.'); + } + + return $this->jsonResponse($this->buildClientInformationResponse($client), 200); + } + + /** + * Enforce the configured access-control mode for the registration endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function guardAccess(ServerRequestInterface $request): void + { + if ($this->moduleConfig->getOidcDcrRegistrationAuth() !== DcrRegistrationAuthEnum::InitialAccessToken) { + return; + } + + $token = $this->extractBearerToken($request); + $allowedTokens = $this->moduleConfig->getOidcDcrInitialAccessTokens(); + + if ($token === null) { + throw OidcServerException::accessDenied('A valid Initial Access Token is required.'); + } + + foreach ($allowedTokens as $allowedToken) { + if (hash_equals($allowedToken, $token)) { + return; + } + } + + throw OidcServerException::accessDenied('The provided Initial Access Token is not valid.'); + } + + /** + * Parse and JSON-decode the request body into a metadata array. + * + * @return array + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function parseMetadata(ServerRequestInterface $request): array + { + $body = (string)$request->getBody(); + + try { + /** @var mixed $decoded */ + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw OidcServerException::invalidClientMetadata('The request body must be a valid JSON object.'); + } + + if (!is_array($decoded) || array_is_list($decoded)) { + throw OidcServerException::invalidClientMetadata('The request body must be a JSON object.'); + } + + return $decoded; + } + + /** + * Build the Client Information Response (Section 3.2 / 4.3) from the persisted client. + * + * @return array + */ + protected function buildClientInformationResponse(ClientEntityInterface $client): array + { + $response = [ + ClaimsEnum::ClientId->value => $client->getIdentifier(), + ClaimsEnum::ClientIdIssuedAt->value => $client->getCreatedAt()?->getTimestamp(), + ClaimsEnum::RegistrationClientUri->value => $this->routes->getModuleUrl( + RoutesEnum::Registration->value, + [ClaimsEnum::ClientId->value => $client->getIdentifier()], + ), + ClaimsEnum::RedirectUris->value => $client->getRedirectUris(), + ClaimsEnum::ClientName->value => $client->getName(), + ClaimsEnum::Scope->value => implode(' ', $client->getScopes()), + ]; + + if ($client->isConfidential()) { + $response[ClaimsEnum::ClientSecret->value] = $client->getSecret(); + // 0 indicates the client secret does not expire. + $response[ClaimsEnum::ClientSecretExpiresAt->value] = 0; + } + + if (($idTokenSignedResponseAlg = $client->getIdTokenSignedResponseAlg()) !== null) { + $response[ClaimsEnum::IdTokenSignedResponseAlg->value] = $idTokenSignedResponseAlg; + } + + // Echo back the stored informational ("store & echo") metadata. + $extraMetadata = $client->getExtraMetadata(); + foreach (ClientEntityFactory::STORE_AND_ECHO_METADATA_KEYS as $key) { + if (array_key_exists($key, $extraMetadata)) { + /** @psalm-suppress MixedAssignment */ + $response[$key] = $extraMetadata[$key]; + } + } + + return $response; + } + + protected function extractBearerToken(ServerRequestInterface $request): ?string + { + $header = $request->getHeaderLine('Authorization'); + + if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches) !== 1) { + return null; + } + + $token = trim($matches[1]); + + return $token === '' ? null : $token; + } + + protected function hashToken(string $token): string + { + return hash(self::HASH_ALGORITHM, $token); + } + + /** + * @param array $body + * @throws \JsonException + */ + protected function jsonResponse(array $body, int $status): ResponseInterface + { + $response = $this->psrHttpBridge->getResponseFactory()->createResponse() + ->withStatus($status) + ->withHeader('Cache-Control', 'no-store') + ->withHeader('Pragma', 'no-cache') + ->withHeader('Content-Type', 'application/json'); + + $response->getBody()->write(json_encode($body, JSON_THROW_ON_ERROR)); + + return $response; + } +} diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 72ed1354..4cd6c0d6 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -55,6 +55,11 @@ class ClientEntity implements ClientEntityInterface public const string KEY_EXPIRES_AT = 'expires_at'; public const string KEY_IS_GENERIC = 'is_generic'; public const string KEY_EXTRA_METADATA = 'extra_metadata'; + /** + * Hash of the OpenID Connect Dynamic Client Registration Access Token, used to authenticate read requests at + * the Client Configuration Endpoint. The plaintext token is shown to the client only once (at registration). + */ + public const string KEY_REGISTRATION_ACCESS_TOKEN = 'registration_access_token'; public const string KEY_ALLOWED_RESPONSE_MODES = 'allowed_response_modes'; /** * Per-client Authentication Processing Filters. Stored as an entry inside @@ -120,6 +125,7 @@ class ClientEntity implements ClientEntityInterface private ?DateTimeImmutable $expiresAt; private bool $isGeneric; private ?array $extraMetadata; + private ?string $registrationAccessToken; /** * @param string[] $redirectUri @@ -154,6 +160,7 @@ public function __construct( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ) { $this->identifier = $identifier; $this->secret = $secret; @@ -179,6 +186,7 @@ public function __construct( $this->expiresAt = $expiresAt; $this->isGeneric = $isGeneric; $this->extraMetadata = $extraMetadata; + $this->registrationAccessToken = $registrationAccessToken; } /** @@ -220,6 +228,7 @@ public function getState(): array self::KEY_EXTRA_METADATA => is_null($this->extraMetadata) ? null : json_encode($this->extraMetadata, JSON_THROW_ON_ERROR), + self::KEY_REGISTRATION_ACCESS_TOKEN => $this->registrationAccessToken, ]; } @@ -401,6 +410,20 @@ public function getExtraMetadata(): array return $this->extraMetadata ?? []; } + /** + * Hash of the Registration Access Token associated with this client, or null if none was issued (e.g. clients + * not created via OIDC Dynamic Client Registration). + */ + public function getRegistrationAccessTokenHash(): ?string + { + return $this->registrationAccessToken; + } + + public function setRegistrationAccessTokenHash(?string $registrationAccessTokenHash): void + { + $this->registrationAccessToken = $registrationAccessTokenHash; + } + public function getIdTokenSignedResponseAlg(): ?string { if (!is_array($this->extraMetadata)) { diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index 6d66c544..0b75a115 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -81,6 +81,8 @@ public function isExpired(): bool; public function isGeneric(): bool; public function getExtraMetadata(): array; + public function getRegistrationAccessTokenHash(): ?string; + public function setRegistrationAccessTokenHash(?string $registrationAccessTokenHash): void; public function getIdTokenSignedResponseAlg(): ?string; public function getAllowedResponseModes(): array; public function getRequirePushedAuthorizationRequests(): bool; diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 79224cd4..dfc01fb0 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -21,6 +21,23 @@ class ClientEntityFactory { + /** + * Informational ("store & echo") client metadata that is persisted as-is into the extra metadata blob when + * present in registration data, so it can be echoed back in registration/read responses. These carry no + * behavioral enforcement on the OP. Format/security validation (and impersonation protection) happens at the + * registration boundary; see \SimpleSAML\Module\oidc\Server\Registration\ClientMetadataValidator. + * + * @var string[] + */ + public const array STORE_AND_ECHO_METADATA_KEYS = [ + ClaimsEnum::LogoUri->value, + ClaimsEnum::ClientUri->value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ClaimsEnum::Contacts->value, + ClaimsEnum::ApplicationType->value, + ]; + public function __construct( private readonly SspBridge $sspBridge, private readonly Helpers $helpers, @@ -61,6 +78,7 @@ public function fromData( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ): ClientEntityInterface { return new ClientEntity( $id, @@ -87,6 +105,7 @@ public function fromData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -214,6 +233,10 @@ public function fromRegistrationData( $isGeneric = $existingClient?->isGeneric() ?? false; + // Carry over any Registration Access Token hash from an existing client. For a newly registered client this + // is null here; the registration controller generates and assigns the token after building the entity. + $registrationAccessToken = $existingClient?->getRegistrationAccessTokenHash(); + $extraMetadata = $existingClient?->getExtraMetadata() ?? []; // Handle any other supported client metadata as extra metadata. @@ -243,6 +266,13 @@ public function fromRegistrationData( $extraMetadata[ClaimsEnum::IdTokenSignedResponseAlg->value] = $idTokenSignedResponseAlg; + // Persist informational ("store & echo") metadata so it can be returned in registration/read responses. + foreach (self::STORE_AND_ECHO_METADATA_KEYS as $storeAndEchoKey) { + if (array_key_exists($storeAndEchoKey, $metadata)) { + /** @psalm-suppress MixedAssignment */ + $extraMetadata[$storeAndEchoKey] = $metadata[$storeAndEchoKey]; + } + } return $this->fromData( $id, @@ -269,6 +299,7 @@ public function fromRegistrationData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -404,6 +435,10 @@ public function fromState(array $state): ClientEntityInterface null : json_decode((string)$state[ClientEntity::KEY_EXTRA_METADATA], true, 512, JSON_THROW_ON_ERROR); + $registrationAccessToken = empty($state[ClientEntity::KEY_REGISTRATION_ACCESS_TOKEN]) ? + null : + (string)$state[ClientEntity::KEY_REGISTRATION_ACCESS_TOKEN]; + return $this->fromData( $id, $secret, @@ -429,6 +464,7 @@ public function fromState(array $state): ClientEntityInterface $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index e8fb2b39..9a6d56d8 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -22,6 +22,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -127,6 +128,15 @@ class ModuleConfig final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; + /***************************************************************************************************************** + * OpenID Connect Dynamic Client Registration related config. + ****************************************************************************************************************/ + final public const string OPTION_OIDC_DCR_ENABLED = 'oidc_dcr_enabled'; + final public const string OPTION_OIDC_DCR_REGISTRATION_AUTH = 'oidc_dcr_registration_auth'; + final public const string OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS = 'oidc_dcr_initial_access_tokens'; + final public const string OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED = + 'oidc_dcr_impersonation_protection_enabled'; + final public const string OPTION_PAR_REQUEST_URI_TTL = 'par_request_uri_ttl'; final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'require_pushed_authorization_requests'; final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'require_signed_request_object'; @@ -974,6 +984,66 @@ public function getVciEnabled(): bool } + /***************************************************************************************************************** + * OpenID Connect Dynamic Client Registration related config. + ****************************************************************************************************************/ + + /** + * Master switch for the OIDC Dynamic Client Registration capability. When disabled (default), the registration + * and client-configuration endpoints are not served and `registration_endpoint` is not advertised in OP + * metadata. + */ + public function getOidcDcrEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_OIDC_DCR_ENABLED, false); + } + + /** + * Access-control mode for the registration endpoint: open registration (default) or gated behind an Initial + * Access Token. + */ + public function getOidcDcrRegistrationAuth(): DcrRegistrationAuthEnum + { + return DcrRegistrationAuthEnum::from( + $this->config()->getOptionalString( + self::OPTION_OIDC_DCR_REGISTRATION_AUTH, + DcrRegistrationAuthEnum::Open->value, + ) ?? DcrRegistrationAuthEnum::Open->value, + ); + } + + /** + * Static allow-list of opaque Initial Access Tokens accepted by the registration endpoint when the access mode + * is DcrRegistrationAuthEnum::InitialAccessToken. Issuance is out-of-band (per spec). + * + * @return string[] + */ + public function getOidcDcrInitialAccessTokens(): array + { + $tokens = $this->config()->getOptionalArray(self::OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS, []); + + $stringTokens = []; + /** @var mixed $token */ + foreach ($tokens as $token) { + if (is_string($token) && $token !== '') { + $stringTokens[] = $token; + } + } + + return $stringTokens; + } + + /** + * Whether impersonation protection (OIDC Dynamic Client Registration 1.0, Section 9.1) is enforced. When on + * (default), the host of `logo_uri`, `policy_uri` and `tos_uri` must match the host of one of the registered + * `redirect_uris`, otherwise registration is rejected. + */ + public function getOidcDcrImpersonationProtectionEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED, true); + } + + /** * @throws ConfigurationError * @return non-empty-array diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 11db59a0..7f240daf 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -360,7 +360,8 @@ public function add(ClientEntityInterface $client): void created_at, expires_at, is_generic, - extra_metadata + extra_metadata, + registration_access_token ) VALUES ( :id, @@ -386,7 +387,8 @@ public function add(ClientEntityInterface $client): void :created_at, :expires_at, :is_generic, - :extra_metadata + :extra_metadata, + :registration_access_token ) EOS , @@ -459,7 +461,8 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo created_at = :created_at, expires_at = :expires_at, is_generic = :is_generic, - extra_metadata = :extra_metadata + extra_metadata = :extra_metadata, + registration_access_token = :registration_access_token WHERE id = :id EOF , diff --git a/src/Server/Exceptions/OidcServerException.php b/src/Server/Exceptions/OidcServerException.php index 0c1c1a88..fb1ea89a 100644 --- a/src/Server/Exceptions/OidcServerException.php +++ b/src/Server/Exceptions/OidcServerException.php @@ -366,6 +366,35 @@ public static function invalidClientMetadata( ); } + /** + * Invalid redirect URI error, as defined by the OAuth 2.0 Dynamic Client + * Registration Protocol (RFC 7591, section 3.2.2) and OpenID Connect + * Dynamic Client Registration 1.0 (section 3.3). The value of one or more + * redirect_uris is invalid. + * + * @see https://www.rfc-editor.org/rfc/rfc7591#section-3.2.2 + * + * @param string|null $hint + * @param \Throwable|null $previous + * + * @return self + * @psalm-suppress LessSpecificImplementedReturnType + */ + public static function invalidRedirectUri( + ?string $hint = null, + ?Throwable $previous = null, + ): OidcServerException { + return new self( + 'The value of one or more redirect_uris is invalid.', + 14, + ErrorsEnum::InvalidRedirectUri->value, + 400, + $hint, + null, + $previous, + ); + } + /** * Returns the current payload. * diff --git a/src/Server/Registration/ClientMetadataValidator.php b/src/Server/Registration/ClientMetadataValidator.php new file mode 100644 index 00000000..e9e2c9c9 --- /dev/null +++ b/src/Server/Registration/ClientMetadataValidator.php @@ -0,0 +1,225 @@ +value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ]; + + /** + * All URI metadata fields whose format is validated. + */ + private const array URI_CLAIMS = [ + ClaimsEnum::LogoUri->value, + ClaimsEnum::ClientUri->value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ]; + + public function __construct( + private readonly ModuleConfig $moduleConfig, + ) { + } + + /** + * Validate the incoming registration metadata. Returns the metadata unchanged on success. + * + * @param array $metadata + * @return array + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function validate(array $metadata): array + { + $redirectUris = $this->validateRedirectUris($metadata); + $this->validateInformationalUris($metadata); + $this->validateContacts($metadata); + $this->validateApplicationType($metadata); + + if ($this->moduleConfig->getOidcDcrImpersonationProtectionEnabled()) { + $this->enforceImpersonationProtection($metadata, $redirectUris); + } + + return $metadata; + } + + /** + * redirect_uris is REQUIRED; it must be a non-empty array of valid absolute URIs. + * + * @param array $metadata + * @return string[] the validated redirect URIs + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateRedirectUris(array $metadata): array + { + $redirectUris = $metadata[ClaimsEnum::RedirectUris->value] ?? null; + + if (!is_array($redirectUris) || $redirectUris === []) { + throw OidcServerException::invalidRedirectUri('redirect_uris is required and must be a non-empty array.'); + } + + $validated = []; + /** @var mixed $redirectUri */ + foreach ($redirectUris as $redirectUri) { + // Lenient: a redirect URI must be an absolute URI (have a scheme), but we intentionally do not require + // an http(s) host, so native/custom-scheme and loopback redirect URIs remain valid. + if (!is_string($redirectUri) || !$this->hasScheme($redirectUri)) { + throw OidcServerException::invalidRedirectUri('One or more redirect_uris values are invalid.'); + } + $validated[] = $redirectUri; + } + + return $validated; + } + + /** + * logo_uri, client_uri, policy_uri and tos_uri must be valid absolute URIs when present. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateInformationalUris(array $metadata): void + { + foreach (self::URI_CLAIMS as $claim) { + if (!array_key_exists($claim, $metadata)) { + continue; + } + + /** @var mixed $value */ + $value = $metadata[$claim]; + if (!is_string($value) || !$this->isValidAbsoluteUri($value)) { + throw OidcServerException::invalidClientMetadata(sprintf('Invalid "%s" value.', $claim)); + } + } + } + + /** + * contacts, when present, must be an array of non-empty strings. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateContacts(array $metadata): void + { + if (!array_key_exists(ClaimsEnum::Contacts->value, $metadata)) { + return; + } + + /** @var mixed $contacts */ + $contacts = $metadata[ClaimsEnum::Contacts->value]; + if (!is_array($contacts)) { + throw OidcServerException::invalidClientMetadata('contacts must be an array.'); + } + + /** @var mixed $contact */ + foreach ($contacts as $contact) { + if (!is_string($contact) || $contact === '') { + throw OidcServerException::invalidClientMetadata('contacts must be an array of non-empty strings.'); + } + } + } + + /** + * application_type, when present, must be one of the defined values (web or native). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateApplicationType(array $metadata): void + { + if (!array_key_exists(ClaimsEnum::ApplicationType->value, $metadata)) { + return; + } + + /** @var mixed $applicationType */ + $applicationType = $metadata[ClaimsEnum::ApplicationType->value]; + if ( + !is_string($applicationType) || + ApplicationTypesEnum::tryFrom($applicationType) === null + ) { + throw OidcServerException::invalidClientMetadata('Invalid application_type value.'); + } + } + + /** + * Impersonation protection (OIDC Dynamic Client Registration 1.0, Section 9.1): each protected informational + * URI must share a host with one of the registered redirect_uris, to mitigate a rogue client supplying the + * branding (logo) or links of a legitimate one. + * + * @param string[] $redirectUris + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function enforceImpersonationProtection(array $metadata, array $redirectUris): void + { + $allowedHosts = []; + foreach ($redirectUris as $redirectUri) { + $host = $this->extractHost($redirectUri); + if ($host !== null) { + $allowedHosts[$host] = true; + } + } + + foreach (self::IMPERSONATION_PROTECTED_URI_CLAIMS as $claim) { + if (!array_key_exists($claim, $metadata)) { + continue; + } + + // Format was already validated; value is a valid absolute URI string here. + $host = $this->extractHost((string)$metadata[$claim]); + if ($host === null || !array_key_exists($host, $allowedHosts)) { + throw OidcServerException::invalidClientMetadata(sprintf( + 'The host of "%s" must match the host of one of the redirect_uris ' + . '(impersonation protection is enabled).', + $claim, + )); + } + } + } + + private function isValidAbsoluteUri(string $uri): bool + { + return filter_var($uri, FILTER_VALIDATE_URL) !== false && $this->extractHost($uri) !== null; + } + + /** + * Whether the URI has a (non-empty) scheme component, i.e. is an absolute URI. + */ + private function hasScheme(string $uri): bool + { + $scheme = parse_url($uri, PHP_URL_SCHEME); + + return is_string($scheme) && $scheme !== ''; + } + + /** + * Extract the lower-cased host component of a URI, or null if absent. + */ + private function extractHost(string $uri): ?string + { + $host = parse_url($uri, PHP_URL_HOST); + + return is_string($host) && $host !== '' ? strtolower($host) : null; + } +} diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index a282f109..abe3c833 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -225,6 +225,11 @@ public function migrate(): void $this->version20260608130000(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260608130000')"); } + + if (!in_array('20260624000001', $versions, true)) { + $this->version20260624000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260624000001')"); + } } private function versionsTableName(): string @@ -771,6 +776,20 @@ private function version20260608130000(): void $this->database->write("CREATE INDEX $idxParExpiresAt ON $parTableName (expires_at)"); } + /** + * Add storage for the OpenID Connect Dynamic Client Registration Access Token (a hash of it), used to + * authenticate read requests at the Client Configuration Endpoint. + */ + private function version20260624000001(): void + { + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + ADD registration_access_token VARCHAR(255) NULL +EOT + ,); + } + /** * @param string[] $columnNames diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 83b125a7..f4f24c03 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -62,6 +62,10 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::EndSessionEndpoint->value] = $this->routes->getModuleUrl(RoutesEnum::EndSession->value); $this->metadata[ClaimsEnum::JwksUri->value] = $this->routes->getModuleUrl(RoutesEnum::Jwks->value); + if ($this->moduleConfig->getOidcDcrEnabled()) { + $this->metadata[ClaimsEnum::RegistrationEndpoint->value] = + $this->routes->getModuleUrl(RoutesEnum::Registration->value); + } $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getScopes()); $this->metadata[ClaimsEnum::ResponseTypesSupported->value] = ['code', 'id_token', 'id_token token']; $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; diff --git a/tests/unit/src/Controllers/RegistrationControllerTest.php b/tests/unit/src/Controllers/RegistrationControllerTest.php new file mode 100644 index 00000000..2c425cee --- /dev/null +++ b/tests/unit/src/Controllers/RegistrationControllerTest.php @@ -0,0 +1,232 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(true); + $this->moduleConfigMock->method('getOidcDcrRegistrationAuth')->willReturn(DcrRegistrationAuthEnum::Open); + $this->moduleConfigMock->method('getOidcDcrImpersonationProtectionEnabled')->willReturn(true); + + $this->clientEntityFactoryMock = $this->createMock(ClientEntityFactory::class); + $this->clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->routesMock = $this->createMock(Routes::class); + $this->routesMock->method('getModuleUrl') + ->willReturn('https://op.example.org/oidc/register?client_id=client123'); + $this->loggerMock = $this->createMock(LoggerService::class); + + $this->psrHttpBridge = new PsrHttpBridge( + new HttpFoundationFactory(), + new ServerRequestFactory(), + new ResponseFactory(), + new StreamFactory(), + new UploadedFileFactory(), + ); + $this->clientMetadataValidator = new ClientMetadataValidator($this->moduleConfigMock); + $this->errorResponder = new ErrorResponder($this->psrHttpBridge); + $this->helpers = new Helpers(); + + $this->clientMock = $this->createMock(ClientEntityInterface::class); + $this->clientMock->method('getIdentifier')->willReturn('client123'); + $this->clientMock->method('getCreatedAt') + ->willReturn(new DateTimeImmutable('2026-06-24T00:00:00', new DateTimeZone('UTC'))); + $this->clientMock->method('getRedirectUris')->willReturn(['https://client.example.org/cb']); + $this->clientMock->method('getName')->willReturn('Example'); + $this->clientMock->method('getScopes')->willReturn(['openid']); + $this->clientMock->method('isConfidential')->willReturn(true); + $this->clientMock->method('getSecret')->willReturn('the-secret'); + $this->clientMock->method('getIdTokenSignedResponseAlg')->willReturn(null); + $this->clientMock->method('getExtraMetadata')->willReturn([]); + } + + protected function sut(): RegistrationController + { + return new RegistrationController( + $this->moduleConfigMock, + $this->clientMetadataValidator, + $this->clientEntityFactoryMock, + $this->clientRepositoryMock, + $this->psrHttpBridge, + $this->errorResponder, + $this->helpers, + $this->routesMock, + $this->loggerMock, + ); + } + + protected function postRequest(string $json): Request + { + return Request::create( + 'https://op.example.org/oidc/register', + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $json, + ); + } + + /** + * @return array + */ + protected function decode(Response $response): array + { + /** @var array $decoded */ + $decoded = json_decode((string)$response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + public function testCreateReturns201WithClientIdAndRegistrationAccessToken(): void + { + $this->clientEntityFactoryMock->method('fromRegistrationData')->willReturn($this->clientMock); + $this->clientMock->expects($this->once())->method('setRegistrationAccessTokenHash'); + $this->clientRepositoryMock->expects($this->once())->method('add')->with($this->clientMock); + + $response = $this->sut()->registration( + $this->postRequest('{"redirect_uris":["https://client.example.org/cb"],"client_name":"Example"}'), + ); + + $this->assertSame(201, $response->getStatusCode()); + $body = $this->decode($response); + $this->assertSame('client123', $body['client_id']); + $this->assertArrayHasKey('registration_access_token', $body); + $this->assertArrayHasKey('registration_client_uri', $body); + $this->assertSame('the-secret', $body['client_secret']); + $this->assertSame(0, $body['client_secret_expires_at']); + } + + public function testDisabledFeatureReturns404(): void + { + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getOidcDcrEnabled')->willReturn(false); + $this->moduleConfigMock = $moduleConfigMock; + + $response = $this->sut()->registration($this->postRequest('{"redirect_uris":["https://client.example.org/cb"]}')); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testMissingRedirectUrisReturns400InvalidRedirectUri(): void + { + $response = $this->sut()->registration($this->postRequest('{"client_name":"Example"}')); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('invalid_redirect_uri', $this->decode($response)['error']); + } + + public function testInvalidJsonReturns400InvalidClientMetadata(): void + { + $response = $this->sut()->registration($this->postRequest('not-json')); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('invalid_client_metadata', $this->decode($response)['error']); + } + + public function testInitialAccessTokenModeRejectsMissingToken(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(true); + $this->moduleConfigMock->method('getOidcDcrRegistrationAuth') + ->willReturn(DcrRegistrationAuthEnum::InitialAccessToken); + $this->moduleConfigMock->method('getOidcDcrInitialAccessTokens')->willReturn(['secret-iat']); + + $response = $this->sut()->registration( + $this->postRequest('{"redirect_uris":["https://client.example.org/cb"]}'), + ); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testReadReturns200ForValidToken(): void + { + $token = 'rat-plaintext'; + $this->clientMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Dynamic); + $this->clientMock->method('getRegistrationAccessTokenHash')->willReturn(hash('sha256', $token)); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientMock); + + $request = Request::create('https://op.example.org/oidc/register?client_id=client123', 'GET'); + $request->headers->set('Authorization', 'Bearer ' . $token); + + $response = $this->sut()->registration($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('client123', $this->decode($response)['client_id']); + } + + public function testReadReturns401ForInvalidToken(): void + { + $this->clientMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Dynamic); + $this->clientMock->method('getRegistrationAccessTokenHash')->willReturn(hash('sha256', 'correct-token')); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientMock); + + $request = Request::create('https://op.example.org/oidc/register?client_id=client123', 'GET'); + $request->headers->set('Authorization', 'Bearer wrong-token'); + + $response = $this->sut()->registration($request); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testReadReturns401ForUnknownClient(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $request = Request::create('https://op.example.org/oidc/register?client_id=missing', 'GET'); + $request->headers->set('Authorization', 'Bearer any-token'); + + $response = $this->sut()->registration($request); + + $this->assertSame(401, $response->getStatusCode()); + } +} diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index 94b52c37..dda088cb 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -184,6 +184,7 @@ public function testCanGetState(): void 'expires_at' => null, 'is_generic' => $this->state['is_generic'], 'extra_metadata' => null, + 'registration_access_token' => null, ], ); } diff --git a/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php b/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php index 06e0d2b4..20f67eb3 100644 --- a/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php +++ b/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php @@ -129,6 +129,44 @@ public function testFromRegistrationDataThrowsWhenRedirectUrisMissing(): void $this->sut()->fromRegistrationData([], RegistrationTypeEnum::FederatedAutomatic); } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testFromRegistrationDataSetsDynamicRegistrationType(): void + { + $client = $this->sut()->fromRegistrationData( + [ClaimsEnum::RedirectUris->value => ['https://example.org/cb']], + RegistrationTypeEnum::Dynamic, + ); + + $this->assertSame(RegistrationTypeEnum::Dynamic, $client->getRegistrationType()); + // Newly registered clients carry no Registration Access Token hash until the controller assigns one. + $this->assertNull($client->getRegistrationAccessTokenHash()); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testFromRegistrationDataStoresAndEchoesInformationalMetadata(): void + { + $client = $this->sut()->fromRegistrationData( + [ + ClaimsEnum::RedirectUris->value => ['https://example.org/cb'], + ClaimsEnum::LogoUri->value => 'https://example.org/logo.png', + ClaimsEnum::Contacts->value => ['admin@example.org'], + ClaimsEnum::ApplicationType->value => 'web', + ], + RegistrationTypeEnum::Dynamic, + ); + + $extraMetadata = $client->getExtraMetadata(); + $this->assertSame('https://example.org/logo.png', $extraMetadata[ClaimsEnum::LogoUri->value]); + $this->assertSame(['admin@example.org'], $extraMetadata[ClaimsEnum::Contacts->value]); + $this->assertSame('web', $extraMetadata[ClaimsEnum::ApplicationType->value]); + } + /** * Admin-only client properties (e.g. authproc filters) must NEVER be honored * when supplied through client registration metadata, since an authproc diff --git a/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php b/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php new file mode 100644 index 00000000..d42b63cd --- /dev/null +++ b/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php @@ -0,0 +1,143 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + // Default: impersonation protection on. + $this->moduleConfigMock->method('getOidcDcrImpersonationProtectionEnabled')->willReturn(true); + } + + protected function sut(): ClientMetadataValidator + { + return new ClientMetadataValidator($this->moduleConfigMock); + } + + /** + * Assert that validating the given metadata is rejected with the expected OAuth error code and a hint + * containing the given substring. + */ + protected function assertRejected(array $metadata, string $expectedErrorType, string $expectedHintSubstring): void + { + try { + $this->sut()->validate($metadata); + $this->fail('Expected OidcServerException was not thrown.'); + } catch (OidcServerException $exception) { + $this->assertSame($expectedErrorType, $exception->getErrorType()); + $this->assertStringContainsString($expectedHintSubstring, (string)$exception->getHint()); + } + } + + public function testValidMetadataPasses(): void + { + $metadata = [ + 'redirect_uris' => ['https://client.example.org/cb'], + 'client_name' => 'Example', + 'logo_uri' => 'https://client.example.org/logo.png', + 'policy_uri' => 'https://client.example.org/policy', + 'tos_uri' => 'https://client.example.org/tos', + 'client_uri' => 'https://marketing.example.net/', + 'contacts' => ['admin@example.org'], + 'application_type' => 'web', + ]; + + $this->assertSame($metadata, $this->sut()->validate($metadata)); + } + + public function testNativeRedirectUriIsAllowed(): void + { + $metadata = ['redirect_uris' => ['com.example.app:/callback']]; + + $this->assertSame($metadata, $this->sut()->validate($metadata)); + } + + public function testMissingRedirectUrisIsRejected(): void + { + $this->assertRejected(['client_name' => 'Example'], 'invalid_redirect_uri', 'redirect_uris is required'); + } + + public function testEmptyRedirectUrisIsRejected(): void + { + $this->assertRejected(['redirect_uris' => []], 'invalid_redirect_uri', 'redirect_uris is required'); + } + + public function testRedirectUriWithoutSchemeIsRejected(): void + { + $this->assertRejected(['redirect_uris' => ['not-a-uri']], 'invalid_redirect_uri', 'invalid'); + } + + public function testInvalidLogoUriIsRejected(): void + { + $this->assertRejected( + ['redirect_uris' => ['https://client.example.org/cb'], 'logo_uri' => 'not a url'], + 'invalid_client_metadata', + 'logo_uri', + ); + } + + public function testContactsMustBeArray(): void + { + $this->assertRejected( + ['redirect_uris' => ['https://client.example.org/cb'], 'contacts' => 'admin@example.org'], + 'invalid_client_metadata', + 'contacts', + ); + } + + public function testInvalidApplicationTypeIsRejected(): void + { + $this->assertRejected( + ['redirect_uris' => ['https://client.example.org/cb'], 'application_type' => 'desktop'], + 'invalid_client_metadata', + 'application_type', + ); + } + + public function testImpersonationProtectionRejectsMismatchedHost(): void + { + $this->assertRejected( + ['redirect_uris' => ['https://client.example.org/cb'], 'logo_uri' => 'https://evil.example.com/logo.png'], + 'invalid_client_metadata', + 'impersonation protection', + ); + } + + public function testImpersonationProtectionAllowsClientUriOnDifferentHost(): void + { + // client_uri is intentionally excluded from the host check. + $metadata = [ + 'redirect_uris' => ['https://client.example.org/cb'], + 'client_uri' => 'https://marketing.example.net/', + ]; + + $this->assertSame($metadata, $this->sut()->validate($metadata)); + } + + public function testImpersonationProtectionCanBeDisabled(): void + { + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getOidcDcrImpersonationProtectionEnabled')->willReturn(false); + + $metadata = [ + 'redirect_uris' => ['https://client.example.org/cb'], + 'logo_uri' => 'https://evil.example.com/logo.png', + ]; + + $this->assertSame($metadata, (new ClientMetadataValidator($moduleConfigMock))->validate($metadata)); + } +} diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 920e2b36..fc6e4d6a 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -52,6 +52,7 @@ public function setUp(): void RoutesEnum::Jwks->value => 'http://localhost/jwks', RoutesEnum::EndSession->value => 'http://localhost/end-session', RoutesEnum::PushedAuthorizationRequest->value => 'http://localhost/par', + RoutesEnum::Registration->value => 'http://localhost/register', ]; return $paths[$path] ?? null; @@ -161,6 +162,28 @@ public function testItReturnsExpectedMetadata(): void ); } + public function testAdvertisesRegistrationEndpointWhenDcrEnabled(): void + { + $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(true); + + $metadata = $this->sut()->getMetadata(); + + $this->assertSame( + 'http://localhost/register', + $metadata[ClaimsEnum::RegistrationEndpoint->value] ?? null, + ); + } + + public function testDoesNotAdvertiseRegistrationEndpointWhenDcrDisabled(): void + { + $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(false); + + $this->assertArrayNotHasKey( + ClaimsEnum::RegistrationEndpoint->value, + $this->sut()->getMetadata(), + ); + } + public function testCanShowClaimsSupportedClaim(): void { $this->moduleConfigMock->method('getProtocolDiscoveryShowClaimsSupported')->willReturn(true); From bdb4010ea7f38550086d0d814babce831a088152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 25 Jun 2026 17:52:27 +0200 Subject: [PATCH 2/6] WIP --- config/module_oidc.php.dist | 79 ++++++++++++++++++++++++++++++++++++ docs/3-oidc-configuration.md | 55 ++++++++++--------------- 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 30489c86..571c56ed 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -570,6 +570,85 @@ $config = [ */ ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => 20, + /*************************************************************************** + * (optional) OpenID Connect Dynamic Client Registration (DCR) related + * options. If not enabled (the default), Dynamic Client Registration + * capabilities will be disabled. + **************************************************************************/ + + /** + * Enable or disable OpenID Connect Dynamic Client Registration (DCR), as + * described in the OpenID Connect Dynamic Client Registration 1.0 + * specification (which is also compatible with RFC 7591). Default is + * disabled (false). + * + * When enabled, the module serves: + * - a Client Registration Endpoint (HTTP POST to .../oidc/register) which + * creates a new client from the supplied client metadata and returns its + * client_id, client_secret (for confidential clients), a + * registration_access_token and a registration_client_uri; and + * - a Client Configuration Endpoint (HTTP GET to + * .../oidc/register?client_id=...) which returns the current client + * registration when called with the registration_access_token as an HTTP + * Bearer token. + * + * When enabled, the registration endpoint is also advertised as the + * 'registration_endpoint' claim in the OP discovery metadata. + * + * Note that dynamically registered clients are stored like any other client + * and are visible / manageable in the admin UI. + */ + ModuleConfig::OPTION_OIDC_DCR_ENABLED => false, + + /** + * Access-control mode for the registration (create) endpoint. Only relevant + * if Dynamic Client Registration is enabled. Possible values: + * + * - DcrRegistrationAuthEnum::Open (the default): open registration, meaning + * anyone may register a client without authenticating. In this mode you + * should protect the endpoint from abuse using rate limiting at the + * web-server level. + * - DcrRegistrationAuthEnum::InitialAccessToken: callers must present a + * valid Initial Access Token (provisioned out-of-band) as an HTTP Bearer + * token to register. The accepted tokens are configured using + * the OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS option below. + */ + ModuleConfig::OPTION_OIDC_DCR_REGISTRATION_AUTH => + \SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum::Open->value, + + /** + * Allowlist of Initial Access Tokens (opaque, randomly generated strings) + * accepted by the registration endpoint. This option is only consulted when + * the access mode (OPTION_OIDC_DCR_REGISTRATION_AUTH) is set to + * DcrRegistrationAuthEnum::InitialAccessToken; in 'open' mode it is ignored. + * + * A registration request must then carry one of these tokens as an HTTP + * Bearer token. Use long, high-entropy values and treat them as secrets. + * + * Format: string[] (array of strings) + */ + ModuleConfig::OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS => [ +// 'a-long-random-secret-token', + ], + + /** + * Enable or disable impersonation protection for Dynamic Client + * Registration, as recommended by Section 9.1 of the OpenID Connect Dynamic + * Client Registration 1.0 specification. Default is enabled (true). + * + * When enabled, the host component of the logo_uri, policy_uri and tos_uri + * client metadata values (if provided) must match the host of one of the + * registered redirect_uris. Otherwise, the registration is rejected with an + * 'invalid_client_metadata' error. This mitigates a rogue client trying to + * impersonate a legitimate one by reusing its branding (logo) or links. + * + * You may want to disable this (set to false) if your clients legitimately + * host these resources on a different domain than their redirect URIs (for + * example, on a shared CDN or marketing domain). Note that the client_uri + * (the client home page) is intentionally NOT subject to this check. + */ + ModuleConfig::OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED => true, + /*************************************************************************** * (optional) OpenID Federation-related options. If these are not set, * OpenID Federation capabilities will be disabled. diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 3dd318e9..4f6141d0 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -14,6 +14,7 @@ It complements the inline comments in `config/module_oidc.php`. - Attribute translation - Auth Proc filters (OIDC) - Client registration permissions +- OpenID Connect Dynamic Client Registration - Running multiple OPs on one server ## Caching protocol artifacts @@ -369,40 +370,26 @@ by [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/ope `registration_access_token` as a bearer token. When enabled, the registration endpoint is advertised as `registration_endpoint` -in the OP discovery metadata. - -The feature is **disabled by default**. Configure it in `module_oidc.php`: - -```php - true, - - // Access-control mode for the registration endpoint: - // - 'open' (default): anyone may register (rely on deployment rate limiting); - // - 'initial_access_token': callers must present a configured bearer token. - \SimpleSAML\Module\oidc\ModuleConfig::OPTION_OIDC_DCR_REGISTRATION_AUTH => - \SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum::Open->value, - - // Allow-list of Initial Access Tokens, consulted only in 'initial_access_token' mode. - \SimpleSAML\Module\oidc\ModuleConfig::OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS => [ - // 'a-long-random-secret-token', - ], - - // Impersonation protection (spec Section 9.1). When on (default), the host of - // logo_uri / policy_uri / tos_uri must match the host of one of the registered - // redirect_uris, otherwise registration is rejected with invalid_client_metadata. - // Turn off if your clients legitimately host these on a different (e.g. CDN) domain. - \SimpleSAML\Module\oidc\ModuleConfig::OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED => true, -]; -``` - -Note that dynamically registered clients are stored like any other client and -are visible in the admin UI. Open registration lets anyone create a client, so -protect the endpoint with rate limiting at the web-server level, or require an -Initial Access Token. +in the OP discovery metadata. Dynamically registered clients are stored like any +other client and are visible in the admin UI. + +The feature is **disabled by default**. It is configured through the following +options in `config/module_oidc.php` (see the inline comments there for the full +details and defaults): + +- `OPTION_OIDC_DCR_ENABLED` — master switch for the feature. +- `OPTION_OIDC_DCR_REGISTRATION_AUTH` — access-control mode: `open` registration + (the default) or `initial_access_token` (require a bearer Initial Access + Token). +- `OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS` — the accepted Initial Access Tokens, + consulted only in `initial_access_token` mode. +- `OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED` — when on (the default), + the host of `logo_uri` / `policy_uri` / `tos_uri` must match the host of one of + the registered `redirect_uris` (spec Section 9.1). + +> **Security note:** open registration lets anyone create a client, so protect +> the endpoint with rate limiting at the web-server level, or require an Initial +> Access Token. ## Running multiple OPs on one server From 694f9fac3509683d303520c13841e66b9cf02387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 25 Jun 2026 17:56:08 +0200 Subject: [PATCH 3/6] WIP --- routing/routes/routes.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 97f76b6a..916f5f4e 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -106,8 +106,10 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); - // OpenID Connect Dynamic Client Registration. POST registers a new client (create); GET reads an existing - // registration (Client Configuration Endpoint), authenticated with the Registration Access Token. + // OpenID Connect Dynamic Client Registration. + // POST registers a new client (create); GET reads an existing registration + // (Client Configuration Endpoint), authenticated with the Registration + // Access Token. $routes->add(RoutesEnum::Registration->name, RoutesEnum::Registration->value) ->controller([RegistrationController::class, 'registration']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); From 3610df1ea5565471ae2e9ffb5845208f0b663a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 25 Jun 2026 18:26:28 +0200 Subject: [PATCH 4/6] WIP --- src/Controllers/RegistrationController.php | 113 +++++++----------- .../Entities/ClientEntityFactory.php | 10 +- src/Helpers/Http.php | 26 ++++ src/Services/Api/Authorization.php | 16 +-- .../RegistrationControllerTest.php | 34 +++--- tests/unit/src/Helpers/HttpTest.php | 21 ++++ 6 files changed, 116 insertions(+), 104 deletions(-) diff --git a/src/Controllers/RegistrationController.php b/src/Controllers/RegistrationController.php index 35004fd3..38b8a375 100644 --- a/src/Controllers/RegistrationController.php +++ b/src/Controllers/RegistrationController.php @@ -5,9 +5,6 @@ namespace SimpleSAML\Module\oidc\Controllers; use League\OAuth2\Server\Exception\OAuthServerException; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; @@ -29,8 +26,9 @@ /** * OpenID Connect Dynamic Client Registration 1.0 endpoint. * - * Implements the Client Registration Endpoint (Section 3, create) and the Client Configuration Endpoint - * (Section 4, read). Read requests are authenticated with the Registration Access Token issued at registration. + * Implements the Client Registration Endpoint (Section 3, create) and the + * Client Configuration Endpoint (Section 4, read). Read requests are + * authenticated with the Registration Access Token issued at registration. */ class RegistrationController { @@ -41,7 +39,6 @@ public function __construct( private readonly ClientMetadataValidator $clientMetadataValidator, private readonly ClientEntityFactory $clientEntityFactory, private readonly ClientRepository $clientRepository, - private readonly PsrHttpBridge $psrHttpBridge, private readonly ErrorResponder $errorResponder, private readonly Helpers $helpers, private readonly Routes $routes, @@ -50,15 +47,25 @@ public function __construct( } /** - * HttpFoundation entry point wired in routes.php. + * Entry point wired in routes.php. Dispatches POST (create) and GET (read). */ public function registration(Request $request): Response { try { - $psrRequest = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); - $psrResponse = $this->handle($psrRequest); + if (!$this->moduleConfig->getOidcDcrEnabled()) { + $this->logger->error('RegistrationController: registration endpoint is disabled.'); + return $this->routes->newResponse('', Response::HTTP_NOT_FOUND); + } - return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse); + return match (strtoupper($request->getMethod())) { + HttpMethodsEnum::POST->value => $this->register($request), + HttpMethodsEnum::GET->value => $this->read($request), + default => $this->routes->newResponse( + '', + Response::HTTP_METHOD_NOT_ALLOWED, + ['Allow' => HttpMethodsEnum::GET->value . ', ' . HttpMethodsEnum::POST->value], + ), + }; } catch (OAuthServerException $exception) { return $this->errorResponder->forExceptionJson($exception); } catch (\Throwable $exception) { @@ -72,32 +79,12 @@ public function registration(Request $request): Response } } - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException - */ - protected function handle(ServerRequestInterface $request): ResponseInterface - { - if (!$this->moduleConfig->getOidcDcrEnabled()) { - return $this->psrHttpBridge->getResponseFactory()->createResponse()->withStatus(404); - } - - return match (strtoupper($request->getMethod())) { - HttpMethodsEnum::POST->value => $this->register($request), - HttpMethodsEnum::GET->value => $this->read($request), - default => $this->psrHttpBridge->getResponseFactory()->createResponse() - ->withStatus(405) - ->withHeader('Allow', HttpMethodsEnum::GET->value . ', ' . HttpMethodsEnum::POST->value), - }; - } - /** * Handle a Client Registration Request (Section 3.1). * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException */ - protected function register(ServerRequestInterface $request): ResponseInterface + protected function register(Request $request): Response { $this->guardAccess($request); @@ -106,7 +93,8 @@ protected function register(ServerRequestInterface $request): ResponseInterface $client = $this->clientEntityFactory->fromRegistrationData($metadata, RegistrationTypeEnum::Dynamic); - // Issue a Registration Access Token; only its hash is persisted, the plaintext is returned once. + // Issue a Registration Access Token; only its hash is persisted, + // the plaintext is returned once. $registrationAccessToken = $this->helpers->random()->getIdentifier(); $client->setRegistrationAccessTokenHash($this->hashToken($registrationAccessToken)); @@ -115,20 +103,20 @@ protected function register(ServerRequestInterface $request): ResponseInterface $response = $this->buildClientInformationResponse($client); $response[ClaimsEnum::RegistrationAccessToken->value] = $registrationAccessToken; - return $this->jsonResponse($response, 201); + return $this->jsonResponse($response, Response::HTTP_CREATED); } /** - * Handle a Client Read Request (Section 4.2) at the Client Configuration Endpoint. + * Handle a Client Read Request (Section 4.2) at the Client Configuration + * Endpoint. * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException */ - protected function read(ServerRequestInterface $request): ResponseInterface + protected function read(Request $request): Response { /** @var mixed $clientId */ - $clientId = $request->getQueryParams()[ClaimsEnum::ClientId->value] ?? null; - $token = $this->extractBearerToken($request); + $clientId = $request->query->all()[ClaimsEnum::ClientId->value] ?? null; + $token = $this->helpers->http()->getBearerToken($request->headers->get('Authorization')); if (!is_string($clientId) || $clientId === '' || $token === null) { throw OidcServerException::accessDenied('A valid client_id and Registration Access Token are required.'); @@ -137,7 +125,8 @@ protected function read(ServerRequestInterface $request): ResponseInterface $client = $this->clientRepository->findById($clientId); $expectedHash = $client?->getRegistrationAccessTokenHash(); - // Per Section 4.4, never reveal whether a client exists: respond 401 for every failure case (no 404). + // Per Section 4.4, never reveal whether a client exists: respond 401 + // for every failure case (not 404). if ( $client === null || $client->getRegistrationType() !== RegistrationTypeEnum::Dynamic || @@ -147,7 +136,7 @@ protected function read(ServerRequestInterface $request): ResponseInterface throw OidcServerException::accessDenied('Invalid Registration Access Token.'); } - return $this->jsonResponse($this->buildClientInformationResponse($client), 200); + return $this->jsonResponse($this->buildClientInformationResponse($client), Response::HTTP_OK); } /** @@ -155,13 +144,13 @@ protected function read(ServerRequestInterface $request): ResponseInterface * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ - protected function guardAccess(ServerRequestInterface $request): void + protected function guardAccess(Request $request): void { if ($this->moduleConfig->getOidcDcrRegistrationAuth() !== DcrRegistrationAuthEnum::InitialAccessToken) { return; } - $token = $this->extractBearerToken($request); + $token = $this->helpers->http()->getBearerToken($request->headers->get('Authorization')); $allowedTokens = $this->moduleConfig->getOidcDcrInitialAccessTokens(); if ($token === null) { @@ -180,12 +169,11 @@ protected function guardAccess(ServerRequestInterface $request): void /** * Parse and JSON-decode the request body into a metadata array. * - * @return array * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ - protected function parseMetadata(ServerRequestInterface $request): array + protected function parseMetadata(Request $request): array { - $body = (string)$request->getBody(); + $body = $request->getContent(); try { /** @var mixed $decoded */ @@ -202,9 +190,8 @@ protected function parseMetadata(ServerRequestInterface $request): array } /** - * Build the Client Information Response (Section 3.2 / 4.3) from the persisted client. - * - * @return array + * Build the Client Information Response (Section 3.2 / 4.3) from the + * persisted client. */ protected function buildClientInformationResponse(ClientEntityInterface $client): array { @@ -242,19 +229,6 @@ protected function buildClientInformationResponse(ClientEntityInterface $client) return $response; } - protected function extractBearerToken(ServerRequestInterface $request): ?string - { - $header = $request->getHeaderLine('Authorization'); - - if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches) !== 1) { - return null; - } - - $token = trim($matches[1]); - - return $token === '' ? null : $token; - } - protected function hashToken(string $token): string { return hash(self::HASH_ALGORITHM, $token); @@ -262,18 +236,13 @@ protected function hashToken(string $token): string /** * @param array $body - * @throws \JsonException */ - protected function jsonResponse(array $body, int $status): ResponseInterface + protected function jsonResponse(array $body, int $status): Response { - $response = $this->psrHttpBridge->getResponseFactory()->createResponse() - ->withStatus($status) - ->withHeader('Cache-Control', 'no-store') - ->withHeader('Pragma', 'no-cache') - ->withHeader('Content-Type', 'application/json'); - - $response->getBody()->write(json_encode($body, JSON_THROW_ON_ERROR)); - - return $response; + return $this->routes->newJsonResponse( + $body, + $status, + ['Cache-Control' => 'no-store', 'Pragma' => 'no-cache'], + ); } } diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index dfc01fb0..040a22d1 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -22,10 +22,12 @@ class ClientEntityFactory { /** - * Informational ("store & echo") client metadata that is persisted as-is into the extra metadata blob when - * present in registration data, so it can be echoed back in registration/read responses. These carry no - * behavioral enforcement on the OP. Format/security validation (and impersonation protection) happens at the - * registration boundary; see \SimpleSAML\Module\oidc\Server\Registration\ClientMetadataValidator. + * Informational ("store & echo") client metadata that is persisted as-is + * into the extra metadata blob when present in registration data, so it + * can be echoed back in registration/read responses. These carry no + * behavioral enforcement on the OP. Format/security validation + * (and impersonation protection) happens at the registration boundary; + * see \SimpleSAML\Module\oidc\Server\Registration\ClientMetadataValidator. * * @var string[] */ diff --git a/src/Helpers/Http.php b/src/Helpers/Http.php index 8ed69d59..511ba963 100644 --- a/src/Helpers/Http.php +++ b/src/Helpers/Http.php @@ -38,4 +38,30 @@ public function getAllRequestParamsBasedOnAllowedMethods( default => null, }; } + + /** + * Extract a Bearer token from an Authorization header value (RFC 6750, + * Section 2.1), or null if no (non-empty) Bearer token is present. The + * "Bearer" scheme is matched case-insensitively. + * + * This operates on the raw header string (rather than a request object) so + * it can be used uniformly regardless of the HTTP request abstraction in + * use (PSR-7 ServerRequestInterface, Symfony HttpFoundation Request, ...). + * Callers pass the header value, e.g. PSR `$request->getHeaderLine('Authorization')` + * or Symfony `$request->headers->get('Authorization')`. + */ + public function getBearerToken(?string $authorizationHeaderValue): ?string + { + if ($authorizationHeaderValue === null) { + return null; + } + + if (preg_match('/^Bearer\s+(.+)$/i', $authorizationHeaderValue, $matches) !== 1) { + return null; + } + + $token = trim($matches[1]); + + return $token === '' ? null : $token; + } } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 5e33f670..9f6b72c6 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -7,6 +7,7 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; +use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; @@ -23,6 +24,7 @@ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly SspBridge $sspBridge, protected readonly RequestParamsResolver $requestParamsResolver, + protected readonly Helpers $helpers, ) { } @@ -79,17 +81,9 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope protected function findToken(Request $request): ?string { - if ( - is_string($authorizationHeader = $request->headers->get(self::KEY_AUTHORIZATION)) - && str_starts_with($authorizationHeader, 'Bearer ') - ) { - return trim( - (string) preg_replace( - '/^\s*Bearer\s/', - '', - (string)$request->headers->get(self::KEY_AUTHORIZATION), - ), - ); + $bearerToken = $this->helpers->http()->getBearerToken($request->headers->get(self::KEY_AUTHORIZATION)); + if ($bearerToken !== null) { + return $bearerToken; } // Fallback to token parameter. diff --git a/tests/unit/src/Controllers/RegistrationControllerTest.php b/tests/unit/src/Controllers/RegistrationControllerTest.php index 2c425cee..a0aca74b 100644 --- a/tests/unit/src/Controllers/RegistrationControllerTest.php +++ b/tests/unit/src/Controllers/RegistrationControllerTest.php @@ -6,10 +6,6 @@ use DateTimeImmutable; use DateTimeZone; -use Laminas\Diactoros\ResponseFactory; -use Laminas\Diactoros\ServerRequestFactory; -use Laminas\Diactoros\StreamFactory; -use Laminas\Diactoros\UploadedFileFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -28,7 +24,7 @@ use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -36,7 +32,7 @@ #[UsesClass(ClientMetadataValidator::class)] #[UsesClass(OidcServerException::class)] #[UsesClass(ErrorResponder::class)] -#[UsesClass(PsrHttpBridge::class)] +#[UsesClass(Helpers::class)] class RegistrationControllerTest extends TestCase { protected MockObject $moduleConfigMock; @@ -45,7 +41,6 @@ class RegistrationControllerTest extends TestCase protected MockObject $routesMock; protected MockObject $loggerMock; protected MockObject $clientMock; - protected PsrHttpBridge $psrHttpBridge; protected ClientMetadataValidator $clientMetadataValidator; protected ErrorResponder $errorResponder; protected Helpers $helpers; @@ -59,20 +54,24 @@ protected function setUp(): void $this->clientEntityFactoryMock = $this->createMock(ClientEntityFactory::class); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->routesMock = $this->createMock(Routes::class); $this->routesMock->method('getModuleUrl') ->willReturn('https://op.example.org/oidc/register?client_id=client123'); + $this->routesMock->method('newJsonResponse')->willReturnCallback( + fn(?array $data = null, int $status = 200, array $headers = [], bool $json = false): JsonResponse => + new JsonResponse($data, $status, $headers, $json), + ); + $this->routesMock->method('newResponse')->willReturnCallback( + fn(?string $content = '', int $status = 200, array $headers = []): Response => + new Response((string)$content, $status, $headers), + ); + $this->loggerMock = $this->createMock(LoggerService::class); - $this->psrHttpBridge = new PsrHttpBridge( - new HttpFoundationFactory(), - new ServerRequestFactory(), - new ResponseFactory(), - new StreamFactory(), - new UploadedFileFactory(), - ); + // ErrorResponder::forExceptionJson builds the JSON response itself and does not use the bridge. + $this->errorResponder = new ErrorResponder($this->createMock(PsrHttpBridge::class)); $this->clientMetadataValidator = new ClientMetadataValidator($this->moduleConfigMock); - $this->errorResponder = new ErrorResponder($this->psrHttpBridge); $this->helpers = new Helpers(); $this->clientMock = $this->createMock(ClientEntityInterface::class); @@ -95,7 +94,6 @@ protected function sut(): RegistrationController $this->clientMetadataValidator, $this->clientEntityFactoryMock, $this->clientRepositoryMock, - $this->psrHttpBridge, $this->errorResponder, $this->helpers, $this->routesMock, @@ -152,7 +150,9 @@ public function testDisabledFeatureReturns404(): void $moduleConfigMock->method('getOidcDcrEnabled')->willReturn(false); $this->moduleConfigMock = $moduleConfigMock; - $response = $this->sut()->registration($this->postRequest('{"redirect_uris":["https://client.example.org/cb"]}')); + $response = $this->sut()->registration( + $this->postRequest('{"redirect_uris":["https://client.example.org/cb"]}'), + ); $this->assertSame(404, $response->getStatusCode()); } diff --git a/tests/unit/src/Helpers/HttpTest.php b/tests/unit/src/Helpers/HttpTest.php index 2e4143e5..83169b3e 100644 --- a/tests/unit/src/Helpers/HttpTest.php +++ b/tests/unit/src/Helpers/HttpTest.php @@ -84,4 +84,25 @@ public function testGerAllRequestParamsBasedOnAllowedMethodsReturnsNullForNonAll ), ); } + + public function testCanGetBearerToken(): void + { + $this->assertSame('abc123', $this->sut()->getBearerToken('Bearer abc123')); + } + + public function testGetBearerTokenIsCaseInsensitiveAndTrimsToken(): void + { + $this->assertSame('abc123', $this->sut()->getBearerToken('bearer abc123 ')); + } + + public function testGetBearerTokenReturnsNullWhenMissingOrNotBearer(): void + { + $this->assertNull($this->sut()->getBearerToken('Basic dXNlcjpwYXNz')); + $this->assertNull($this->sut()->getBearerToken(null)); + } + + public function testGetBearerTokenReturnsNullForEmptyToken(): void + { + $this->assertNull($this->sut()->getBearerToken('Bearer ')); + } } From 3472a7b7d37d1b4e147069b15b7f35e0da1596f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 26 Jun 2026 09:00:39 +0200 Subject: [PATCH 5/6] WIP --- config/module_oidc.php.dist | 12 ++-- docs/3-oidc-configuration.md | 8 +-- src/Controllers/RegistrationController.php | 9 +-- src/ModuleConfig.php | 55 +++++++++---------- .../Registration/ClientMetadataValidator.php | 2 +- src/Services/OpMetadataService.php | 2 +- .../RegistrationControllerTest.php | 14 ++--- .../ClientMetadataValidatorTest.php | 4 +- .../src/Services/OpMetadataServiceTest.php | 4 +- 9 files changed, 53 insertions(+), 57 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 571c56ed..116987ad 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -598,7 +598,7 @@ $config = [ * Note that dynamically registered clients are stored like any other client * and are visible / manageable in the admin UI. */ - ModuleConfig::OPTION_OIDC_DCR_ENABLED => false, + ModuleConfig::OPTION_DCR_ENABLED => false, /** * Access-control mode for the registration (create) endpoint. Only relevant @@ -611,15 +611,15 @@ $config = [ * - DcrRegistrationAuthEnum::InitialAccessToken: callers must present a * valid Initial Access Token (provisioned out-of-band) as an HTTP Bearer * token to register. The accepted tokens are configured using - * the OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS option below. + * the OPTION_DCR_INITIAL_ACCESS_TOKENS option below. */ - ModuleConfig::OPTION_OIDC_DCR_REGISTRATION_AUTH => + ModuleConfig::OPTION_DCR_REGISTRATION_AUTH => \SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum::Open->value, /** * Allowlist of Initial Access Tokens (opaque, randomly generated strings) * accepted by the registration endpoint. This option is only consulted when - * the access mode (OPTION_OIDC_DCR_REGISTRATION_AUTH) is set to + * the access mode (OPTION_DCR_REGISTRATION_AUTH) is set to * DcrRegistrationAuthEnum::InitialAccessToken; in 'open' mode it is ignored. * * A registration request must then carry one of these tokens as an HTTP @@ -627,7 +627,7 @@ $config = [ * * Format: string[] (array of strings) */ - ModuleConfig::OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS => [ + ModuleConfig::OPTION_DCR_INITIAL_ACCESS_TOKENS => [ // 'a-long-random-secret-token', ], @@ -647,7 +647,7 @@ $config = [ * example, on a shared CDN or marketing domain). Note that the client_uri * (the client home page) is intentionally NOT subject to this check. */ - ModuleConfig::OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED => true, + ModuleConfig::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED => true, /*************************************************************************** * (optional) OpenID Federation-related options. If these are not set, diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 4f6141d0..7cf951ec 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -377,13 +377,13 @@ The feature is **disabled by default**. It is configured through the following options in `config/module_oidc.php` (see the inline comments there for the full details and defaults): -- `OPTION_OIDC_DCR_ENABLED` — master switch for the feature. -- `OPTION_OIDC_DCR_REGISTRATION_AUTH` — access-control mode: `open` registration +- `OPTION_DCR_ENABLED` — master switch for the feature. +- `OPTION_DCR_REGISTRATION_AUTH` — access-control mode: `open` registration (the default) or `initial_access_token` (require a bearer Initial Access Token). -- `OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS` — the accepted Initial Access Tokens, +- `OPTION_DCR_INITIAL_ACCESS_TOKENS` — the accepted Initial Access Tokens, consulted only in `initial_access_token` mode. -- `OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED` — when on (the default), +- `OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED` — when on (the default), the host of `logo_uri` / `policy_uri` / `tos_uri` must match the host of one of the registered `redirect_uris` (spec Section 9.1). diff --git a/src/Controllers/RegistrationController.php b/src/Controllers/RegistrationController.php index 38b8a375..f2fc12d7 100644 --- a/src/Controllers/RegistrationController.php +++ b/src/Controllers/RegistrationController.php @@ -52,7 +52,7 @@ public function __construct( public function registration(Request $request): Response { try { - if (!$this->moduleConfig->getOidcDcrEnabled()) { + if (!$this->moduleConfig->getDcrEnabled()) { $this->logger->error('RegistrationController: registration endpoint is disabled.'); return $this->routes->newResponse('', Response::HTTP_NOT_FOUND); } @@ -146,12 +146,12 @@ protected function read(Request $request): Response */ protected function guardAccess(Request $request): void { - if ($this->moduleConfig->getOidcDcrRegistrationAuth() !== DcrRegistrationAuthEnum::InitialAccessToken) { + if ($this->moduleConfig->getDcrRegistrationAuth() !== DcrRegistrationAuthEnum::InitialAccessToken) { return; } $token = $this->helpers->http()->getBearerToken($request->headers->get('Authorization')); - $allowedTokens = $this->moduleConfig->getOidcDcrInitialAccessTokens(); + $allowedTokens = $this->moduleConfig->getDcrInitialAccessTokens(); if ($token === null) { throw OidcServerException::accessDenied('A valid Initial Access Token is required.'); @@ -234,9 +234,6 @@ protected function hashToken(string $token): string return hash(self::HASH_ALGORITHM, $token); } - /** - * @param array $body - */ protected function jsonResponse(array $body, int $status): Response { return $this->routes->newJsonResponse( diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 9a6d56d8..afb561d5 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -127,16 +127,11 @@ class ModuleConfig final public const string OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; - - /***************************************************************************************************************** - * OpenID Connect Dynamic Client Registration related config. - ****************************************************************************************************************/ - final public const string OPTION_OIDC_DCR_ENABLED = 'oidc_dcr_enabled'; - final public const string OPTION_OIDC_DCR_REGISTRATION_AUTH = 'oidc_dcr_registration_auth'; - final public const string OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS = 'oidc_dcr_initial_access_tokens'; - final public const string OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED = - 'oidc_dcr_impersonation_protection_enabled'; - + final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; + final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; + final public const string OPTION_DCR_INITIAL_ACCESS_TOKENS = 'dcr_initial_access_tokens'; + final public const string OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED = + 'dcr_impersonation_protection_enabled'; final public const string OPTION_PAR_REQUEST_URI_TTL = 'par_request_uri_ttl'; final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'require_pushed_authorization_requests'; final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'require_signed_request_object'; @@ -672,7 +667,7 @@ public function getProtocolUserEntityCacheDuration(): DateInterval } /** - * Get cache duration for client entities (user data), with given default + * Get cache duration for client entities (user data), with the given default * * @throws \Exception */ @@ -989,38 +984,41 @@ public function getVciEnabled(): bool ****************************************************************************************************************/ /** - * Master switch for the OIDC Dynamic Client Registration capability. When disabled (default), the registration - * and client-configuration endpoints are not served and `registration_endpoint` is not advertised in OP + * Master switch for the OIDC Dynamic Client Registration capability. When + * disabled (default), the registration and client-configuration endpoints + * are not served, and `registration_endpoint` is not advertised in OP * metadata. */ - public function getOidcDcrEnabled(): bool + public function getDcrEnabled(): bool { - return $this->config()->getOptionalBoolean(self::OPTION_OIDC_DCR_ENABLED, false); + return $this->config()->getOptionalBoolean(self::OPTION_DCR_ENABLED, false); } /** - * Access-control mode for the registration endpoint: open registration (default) or gated behind an Initial - * Access Token. + * Access-control mode for the registration endpoint: open registration + * (default) or gated behind an Initial Access Token. */ - public function getOidcDcrRegistrationAuth(): DcrRegistrationAuthEnum + public function getDcrRegistrationAuth(): DcrRegistrationAuthEnum { return DcrRegistrationAuthEnum::from( $this->config()->getOptionalString( - self::OPTION_OIDC_DCR_REGISTRATION_AUTH, + self::OPTION_DCR_REGISTRATION_AUTH, DcrRegistrationAuthEnum::Open->value, - ) ?? DcrRegistrationAuthEnum::Open->value, + ), ); } /** - * Static allow-list of opaque Initial Access Tokens accepted by the registration endpoint when the access mode - * is DcrRegistrationAuthEnum::InitialAccessToken. Issuance is out-of-band (per spec). + * Static allowlist of opaque Initial Access Tokens accepted by the + * registration endpoint when the access mode is + * DcrRegistrationAuthEnum::InitialAccessToken. Issuance is out-of-band + * (per spec). * * @return string[] */ - public function getOidcDcrInitialAccessTokens(): array + public function getDcrInitialAccessTokens(): array { - $tokens = $this->config()->getOptionalArray(self::OPTION_OIDC_DCR_INITIAL_ACCESS_TOKENS, []); + $tokens = $this->config()->getOptionalArray(self::OPTION_DCR_INITIAL_ACCESS_TOKENS, []); $stringTokens = []; /** @var mixed $token */ @@ -1034,13 +1032,14 @@ public function getOidcDcrInitialAccessTokens(): array } /** - * Whether impersonation protection (OIDC Dynamic Client Registration 1.0, Section 9.1) is enforced. When on - * (default), the host of `logo_uri`, `policy_uri` and `tos_uri` must match the host of one of the registered + * Whether impersonation protection (OIDC Dynamic Client Registration 1.0, + * Section 9.1) is enforced. When on (default), the host of `logo_uri`, + * `policy_uri` and `tos_uri` must match the host of one of the registered * `redirect_uris`, otherwise registration is rejected. */ - public function getOidcDcrImpersonationProtectionEnabled(): bool + public function getDcrImpersonationProtectionEnabled(): bool { - return $this->config()->getOptionalBoolean(self::OPTION_OIDC_DCR_IMPERSONATION_PROTECTION_ENABLED, true); + return $this->config()->getOptionalBoolean(self::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED, true); } diff --git a/src/Server/Registration/ClientMetadataValidator.php b/src/Server/Registration/ClientMetadataValidator.php index e9e2c9c9..f21fcbc6 100644 --- a/src/Server/Registration/ClientMetadataValidator.php +++ b/src/Server/Registration/ClientMetadataValidator.php @@ -61,7 +61,7 @@ public function validate(array $metadata): array $this->validateContacts($metadata); $this->validateApplicationType($metadata); - if ($this->moduleConfig->getOidcDcrImpersonationProtectionEnabled()) { + if ($this->moduleConfig->getDcrImpersonationProtectionEnabled()) { $this->enforceImpersonationProtection($metadata, $redirectUris); } diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index f4f24c03..620f5d08 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -62,7 +62,7 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::EndSessionEndpoint->value] = $this->routes->getModuleUrl(RoutesEnum::EndSession->value); $this->metadata[ClaimsEnum::JwksUri->value] = $this->routes->getModuleUrl(RoutesEnum::Jwks->value); - if ($this->moduleConfig->getOidcDcrEnabled()) { + if ($this->moduleConfig->getDcrEnabled()) { $this->metadata[ClaimsEnum::RegistrationEndpoint->value] = $this->routes->getModuleUrl(RoutesEnum::Registration->value); } diff --git a/tests/unit/src/Controllers/RegistrationControllerTest.php b/tests/unit/src/Controllers/RegistrationControllerTest.php index a0aca74b..ed802653 100644 --- a/tests/unit/src/Controllers/RegistrationControllerTest.php +++ b/tests/unit/src/Controllers/RegistrationControllerTest.php @@ -48,9 +48,9 @@ class RegistrationControllerTest extends TestCase protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(true); - $this->moduleConfigMock->method('getOidcDcrRegistrationAuth')->willReturn(DcrRegistrationAuthEnum::Open); - $this->moduleConfigMock->method('getOidcDcrImpersonationProtectionEnabled')->willReturn(true); + $this->moduleConfigMock->method('getDcrEnabled')->willReturn(true); + $this->moduleConfigMock->method('getDcrRegistrationAuth')->willReturn(DcrRegistrationAuthEnum::Open); + $this->moduleConfigMock->method('getDcrImpersonationProtectionEnabled')->willReturn(true); $this->clientEntityFactoryMock = $this->createMock(ClientEntityFactory::class); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); @@ -147,7 +147,7 @@ public function testCreateReturns201WithClientIdAndRegistrationAccessToken(): vo public function testDisabledFeatureReturns404(): void { $moduleConfigMock = $this->createMock(ModuleConfig::class); - $moduleConfigMock->method('getOidcDcrEnabled')->willReturn(false); + $moduleConfigMock->method('getDcrEnabled')->willReturn(false); $this->moduleConfigMock = $moduleConfigMock; $response = $this->sut()->registration( @@ -176,10 +176,10 @@ public function testInvalidJsonReturns400InvalidClientMetadata(): void public function testInitialAccessTokenModeRejectsMissingToken(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(true); - $this->moduleConfigMock->method('getOidcDcrRegistrationAuth') + $this->moduleConfigMock->method('getDcrEnabled')->willReturn(true); + $this->moduleConfigMock->method('getDcrRegistrationAuth') ->willReturn(DcrRegistrationAuthEnum::InitialAccessToken); - $this->moduleConfigMock->method('getOidcDcrInitialAccessTokens')->willReturn(['secret-iat']); + $this->moduleConfigMock->method('getDcrInitialAccessTokens')->willReturn(['secret-iat']); $response = $this->sut()->registration( $this->postRequest('{"redirect_uris":["https://client.example.org/cb"]}'), diff --git a/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php b/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php index d42b63cd..1aec6c4a 100644 --- a/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php +++ b/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php @@ -20,7 +20,7 @@ protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); // Default: impersonation protection on. - $this->moduleConfigMock->method('getOidcDcrImpersonationProtectionEnabled')->willReturn(true); + $this->moduleConfigMock->method('getDcrImpersonationProtectionEnabled')->willReturn(true); } protected function sut(): ClientMetadataValidator @@ -131,7 +131,7 @@ public function testImpersonationProtectionAllowsClientUriOnDifferentHost(): voi public function testImpersonationProtectionCanBeDisabled(): void { $moduleConfigMock = $this->createMock(ModuleConfig::class); - $moduleConfigMock->method('getOidcDcrImpersonationProtectionEnabled')->willReturn(false); + $moduleConfigMock->method('getDcrImpersonationProtectionEnabled')->willReturn(false); $metadata = [ 'redirect_uris' => ['https://client.example.org/cb'], diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index fc6e4d6a..0bc9f903 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -164,7 +164,7 @@ public function testItReturnsExpectedMetadata(): void public function testAdvertisesRegistrationEndpointWhenDcrEnabled(): void { - $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(true); + $this->moduleConfigMock->method('getDcrEnabled')->willReturn(true); $metadata = $this->sut()->getMetadata(); @@ -176,7 +176,7 @@ public function testAdvertisesRegistrationEndpointWhenDcrEnabled(): void public function testDoesNotAdvertiseRegistrationEndpointWhenDcrDisabled(): void { - $this->moduleConfigMock->method('getOidcDcrEnabled')->willReturn(false); + $this->moduleConfigMock->method('getDcrEnabled')->willReturn(false); $this->assertArrayNotHasKey( ClaimsEnum::RegistrationEndpoint->value, From f0f398d23f3260569ca55df00d55d6b673a19914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 26 Jun 2026 10:39:33 +0200 Subject: [PATCH 6/6] WIP --- src/Controllers/RegistrationController.php | 5 ++++- src/Entities/ClientEntity.php | 1 + tests/unit/src/Entities/ClientEntityTest.php | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Controllers/RegistrationController.php b/src/Controllers/RegistrationController.php index f2fc12d7..effdcbdd 100644 --- a/src/Controllers/RegistrationController.php +++ b/src/Controllers/RegistrationController.php @@ -67,6 +67,9 @@ public function registration(Request $request): Response ), }; } catch (OAuthServerException $exception) { + $this->logger->error( + 'RegistrationController: error processing registration request: ' . $exception->getMessage(), + ); return $this->errorResponder->forExceptionJson($exception); } catch (\Throwable $exception) { $this->logger->error( @@ -93,7 +96,7 @@ protected function register(Request $request): Response $client = $this->clientEntityFactory->fromRegistrationData($metadata, RegistrationTypeEnum::Dynamic); - // Issue a Registration Access Token; only its hash is persisted, + // Issue a Registration Access Token (RAT); only its hash is persisted, // the plaintext is returned once. $registrationAccessToken = $this->helpers->random()->getIdentifier(); $client->setRegistrationAccessTokenHash($this->hashToken($registrationAccessToken)); diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 4cd6c0d6..704244d5 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -266,6 +266,7 @@ public function toArray(): array ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(), ClaimsEnum::RequestUris->value => $this->getRequestUris(), self::KEY_AUTH_PROC_FILTERS => $this->getAuthProcFilters(), + self::KEY_REGISTRATION_ACCESS_TOKEN => $this->registrationAccessToken, ]; } diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index dda088cb..357894dd 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -231,6 +231,7 @@ public function testCanExportAsArray(): void 'require_signed_request_object' => false, 'request_uris' => [], 'authproc' => [], + 'registration_access_token' => null, ], ); }