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/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 df0364d9..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 @@ -355,6 +356,41 @@ 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. 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 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..916f5f4e 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,14 @@ $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..38b8a375 --- /dev/null +++ b/src/Controllers/RegistrationController.php @@ -0,0 +1,248 @@ +moduleConfig->getOidcDcrEnabled()) { + $this->logger->error('RegistrationController: registration endpoint is disabled.'); + return $this->routes->newResponse('', Response::HTTP_NOT_FOUND); + } + + 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) { + $this->logger->error( + 'RegistrationController: error processing registration request: ' . $exception->getMessage(), + ); + + return $this->errorResponder->forExceptionJson( + OidcServerException::serverError('Unable to process the registration request.'), + ); + } + } + + /** + * Handle a Client Registration Request (Section 3.1). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function register(Request $request): Response + { + $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, Response::HTTP_CREATED); + } + + /** + * Handle a Client Read Request (Section 4.2) at the Client Configuration + * Endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function read(Request $request): Response + { + /** @var mixed $clientId */ + $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.'); + } + + $client = $this->clientRepository->findById($clientId); + $expectedHash = $client?->getRegistrationAccessTokenHash(); + + // 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 || + $expectedHash === null || + !hash_equals($expectedHash, $this->hashToken($token)) + ) { + throw OidcServerException::accessDenied('Invalid Registration Access Token.'); + } + + return $this->jsonResponse($this->buildClientInformationResponse($client), Response::HTTP_OK); + } + + /** + * Enforce the configured access-control mode for the registration endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function guardAccess(Request $request): void + { + if ($this->moduleConfig->getOidcDcrRegistrationAuth() !== DcrRegistrationAuthEnum::InitialAccessToken) { + return; + } + + $token = $this->helpers->http()->getBearerToken($request->headers->get('Authorization')); + $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. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function parseMetadata(Request $request): array + { + $body = $request->getContent(); + + 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. + */ + 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 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( + $body, + $status, + ['Cache-Control' => 'no-store', 'Pragma' => 'no-cache'], + ); + } +} 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..040a22d1 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -21,6 +21,25 @@ 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 +80,7 @@ public function fromData( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ): ClientEntityInterface { return new ClientEntity( $id, @@ -87,6 +107,7 @@ public function fromData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -214,6 +235,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 +268,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 +301,7 @@ public function fromRegistrationData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -404,6 +437,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 +466,7 @@ public function fromState(array $state): ClientEntityInterface $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } 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/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/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/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..a0aca74b --- /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->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); + + // 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->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->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/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 ')); + } } 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);