diff --git a/composer.json b/composer.json index 91ed0ae0..777f7816 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": "~0.2.3", + "simplesamlphp/openid": "~v0.3.0", "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 bafdff71..b3a45d5c 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -120,6 +120,72 @@ $config = [ */ ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', + /** + * Pushed Authorization Request (PAR) request URI expiration TTL. + * This is the time for which a PAR request URI will be valid. + * + * For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php + * + * Default: PT10M (10 minutes) + */ + ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // 10 minutes + + /** + * Require Pushed Authorization Request (PAR) globally. If set to true, + * the OP will reject authorization requests which do not use PAR, i.e., + * requests which do not have a valid request_uri corresponding + * to a previously pushed authorization request. + * + * Default: false + */ + ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, + + /** + * Whether to support passing the Request Object by reference using the + * https request_uri parameter (JAR / OpenID Federation) by reference. + * Note that the client must have its request URIs registered beforehand. + * For OpenID Federation Automatic Registration, see the option + * OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES. + * + * Set to `false` to remove any risk of DoS / SSRF attacks by disabling + * outbound fetches to given request URIs. Note that this does not + * affect Pushed Authorization Request URIs (urn form), which are + * always supported. + * + * Default: true + */ + ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => true, + + /** + * Timeout for fetching request_uri, in seconds. + * + * Default: 5 seconds + */ + ModuleConfig::OPTION_REQUEST_URI_FETCH_TIMEOUT => 5, + + /** + * Maximum allowed response size for request_uri, in bytes. + * + * Default: 102400 bytes (100KB) + */ + ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, + + /** + * Enforces signature validation for all Request Objects. OpenID Connect + * Core allows Request Objects to be unsigned, but when this option is + * set to true, the OP will reject any Request Object that does not + * contain a valid signature. + * + * Note: in order for this to work, all Relying Parties must support + * signing Request Objects, and the OP must have their corresponding + * signing keys configured. Of course, this is only relevant if they + * use Request Object in authorization requests. + * + * Default: false + */ + ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, + /** * The default authentication source to be used for authentication if the * authentication source is not specified on a particular client. @@ -649,6 +715,25 @@ $config = [ ModuleConfig::OPTION_INFORMATION_URI => null, ModuleConfig::OPTION_ORGANIZATION_URI => null, + /** + * Allowlist for fetching the Request Object by reference for OpenID + * Federation candidates (clients not yet registered in storage). + * Registered (non-federation) clients are not affected with + * this option (for them the request_uri must match their + * registered request_uris exactly). + * + * Examples: + * - [] (empty array, the default): deny all federation-candidate fetches, + * - ['https://rp.example.org/', ...]: allow only request_uris starting with one of the given prefixes, + * - null: explicitly allow any request_uri for federation candidates (not recommended). + * + * Note: prefixes should be specific enough to avoid false positives + * (e.g. https://rp.example.org/ with the trailing slash). A loose + * prefix like 'https://' would defeat the purpose, at which point + * `null` is the clearer way to say "allow any." + */ + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [], + /*************************************************************************** * (optional) OpenID Verifiable Credential related options. If these are diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index f1520849..133613fc 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\Container; @@ -69,6 +70,10 @@ function oidc_hook_cron(array &$croninfo): void $issuerStateRepository = $container->get(IssuerStateRepository::class); $issuerStateRepository->removeInvalid(); + /** @var \SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository $parRepository */ + $parRepository = $container->get(PushedAuthorizationRequestRepository::class); + $parRepository->removeExpired(); + $croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.'; } catch (Exception $e) { $message = 'Module `oidc` clean up cron script failed: ' . $e->getMessage(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index b14f724c..0be7317f 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -20,6 +20,7 @@ use SimpleSAML\Module\oidc\Controllers\JwksController; 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\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -111,6 +112,10 @@ $routes->add(RoutesEnum::OAuth2Configuration->name, RoutesEnum::OAuth2Configuration->value) ->controller(OAuth2ServerConfigurationController::class); + $routes->add(RoutesEnum::PushedAuthorizationRequest->name, RoutesEnum::PushedAuthorizationRequest->value) + ->controller([PushedAuthorizationController::class, 'par']) + ->methods([HttpMethodsEnum::POST->value]); + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ diff --git a/routing/services/services.yml b/routing/services/services.yml index ffcad634..f5ee2c0b 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -134,6 +134,8 @@ services: SimpleSAML\OpenID\Did: ~ SimpleSAML\OpenID\Jws: factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ] + SimpleSAML\OpenID\RequestObject: + factory: [ '@SimpleSAML\Module\oidc\Factories\RequestObjectFactory', 'build' ] # SSP diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index f08f3ca8..9f86c543 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -49,6 +49,7 @@ enum RoutesEnum: string // OAuth 2.0 Authorization Server Metadata https://www.rfc-editor.org/rfc/rfc8414.html case OAuth2Configuration = '.well-known/oauth-authorization-server'; + case PushedAuthorizationRequest = 'par'; /***************************************************************************************************************** * OpenID Federation diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 3251e661..fb1a093b 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -354,8 +354,17 @@ protected function buildClientEntityFromFormData( $data[ClaimsEnum::IdTokenSignedResponseAlg->value] : null; + $requirePushedAuth = (bool)($data[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false); + $requireSignedReqObj = (bool)($data[ClaimsEnum::RequireSignedRequestObject->value] ?? false); + /** @var mixed $rawRequestUris */ + $rawRequestUris = $data[ClaimsEnum::RequestUris->value] ?? null; + $requestUris = is_array($rawRequestUris) ? $rawRequestUris : []; + $extraMetadata = [ ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg, + ClaimsEnum::RequirePushedAuthorizationRequests->value => $requirePushedAuth, + ClaimsEnum::RequireSignedRequestObject->value => $requireSignedReqObj, + ClaimsEnum::RequestUris->value => $requestUris, ]; $allowedResponseModes = is_array($data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES]) ? diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php new file mode 100644 index 00000000..ac118136 --- /dev/null +++ b/src/Controllers/PushedAuthorizationController.php @@ -0,0 +1,227 @@ +logger->debug('PushedAuthorizationController::__invoke'); + + if (strtoupper($request->getMethod()) !== HttpMethodsEnum::POST->value) { + return $this->psrHttpBridge->getResponseFactory()->createResponse() + ->withStatus(405) + ->withHeader('Allow', HttpMethodsEnum::POST->value); + } + + // Authenticate the client in the same way as at the token endpoint. + $resolvedAuth = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); + if (is_null($resolvedAuth)) { + throw OidcServerException::accessDenied('Client authentication failed.'); + } + + $client = $resolvedAuth->getClient(); + + if ($resolvedAuth->getClientAuthenticationMethod()->isNone() && $client->isConfidential()) { + throw OidcServerException::accessDenied('Confidential client must authenticate.'); + } + + $bodyParams = $request->getParsedBody(); + $bodyParams = is_array($bodyParams) ? $bodyParams : []; + + // The request_uri authorization request parameter must not be used in pushed authorization requests. + if (array_key_exists(ParamsEnum::RequestUri->value, $bodyParams)) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'The request_uri parameter must not be used in pushed authorization requests.', + ); + } + + // Validate the pushed params as we would an authorization request sent to the authorization endpoint. + // Note that the rules transparently take the Request Object (request param) into account, with + // RequestObjectRule doing its validation (signature, signed-required policy...). + $resultBag = new ResultBag(); + $resultBag->add(new Result(ClientRule::class, $client)); + $this->requestRulesManager->predefineResultBag($resultBag); + + $this->requestRulesManager->setData('default_scope', ''); + $this->requestRulesManager->setData('scope_delimiter_string', ' '); + + $rulesToExecute = [ + StateRule::class, + ClientRedirectUriRule::class, + RequestObjectRule::class, + ResponseModeRule::class, + ScopeRule::class, + RequiredOpenIdScopeRule::class, + CodeChallengeRule::class, + CodeChallengeMethodRule::class, + ]; + + $resultBag = $this->requestRulesManager->check( + $request, + $rulesToExecute, + new QueryResponseMode(), + [HttpMethodsEnum::POST], + ); + + $parameters = $this->resolveParametersToPersist($resultBag, $bodyParams, $client->getIdentifier()); + + $parEntity = $this->pushedAuthorizationRequestEntityFactory->fromData( + $client->getIdentifier(), + $parameters, + ); + + $this->pushedAuthorizationRequestRepository->persist($parEntity); + + $responseBody = json_encode( + [ + 'request_uri' => $parEntity->getRequestUri(), + 'expires_in' => $this->helpers->dateTime()->getSecondsToExpirationTime( + $parEntity->getExpiresAt()->getTimestamp(), + ), + ], + JSON_THROW_ON_ERROR, + ); + + $response = $this->psrHttpBridge->getResponseFactory()->createResponse() + ->withStatus(201) + ->withHeader('Cache-Control', 'no-cache, no-store') + ->withHeader('Content-Type', 'application/json'); + + $response->getBody()->write($responseBody); + + return $response; + } + + /** + * Resolve the authorization request parameters which are to be persisted + * for later use at the authorization endpoint. + * + * @param mixed[] $bodyParams + * @return mixed[] + * @throws \League\OAuth2\Server\Exception\OAuthServerException + */ + protected function resolveParametersToPersist( + ResultBagInterface $resultBag, + array $bodyParams, + string $clientId, + ): array { + // If a body client_id param was provided, it must match the authenticated client. + if ( + array_key_exists(ParamsEnum::ClientId->value, $bodyParams) && + $bodyParams[ParamsEnum::ClientId->value] !== $clientId + ) { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'The client_id parameter does not match the authenticated client.', + ); + } + + $requestObjectResult = $resultBag->get(RequestObjectRule::class); + + if ($requestObjectResult !== null) { + // Request Object (JAR) was used. Per RFC 9126, all authorization request parameters must appear + // as claims of the Request Object, so only use its (validated) payload. + /** @psalm-suppress MixedAssignment */ + $parameters = $resultBag->getOrFail(RequestObjectRule::class)->getValue(); + $parameters = is_array($parameters) ? $parameters : []; + + /** @psalm-suppress MixedAssignment */ + $clientIdClaim = $parameters[ParamsEnum::ClientId->value] ?? null; + if (!is_null($clientIdClaim) && $clientIdClaim !== $clientId) { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'The client_id claim in request object does not match the authenticated client.', + ); + } + } else { + // Plain pushed authorization request. Make sure not to persist client authentication related + // params (they are not part of the authorization request itself). + $parameters = $bodyParams; + unset( + $parameters[ParamsEnum::ClientSecret->value], + $parameters[ParamsEnum::ClientAssertion->value], + $parameters[ParamsEnum::ClientAssertionType->value], + ); + } + + unset( + $parameters[ParamsEnum::Request->value], + $parameters[ParamsEnum::RequestUri->value], + ); + + // Bind the parameters to the authenticated client. + $parameters[ParamsEnum::ClientId->value] = $clientId; + + return $parameters; + } + + public function par(Request $request): Response + { + try { + $psrRequest = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + $psrResponse = $this->__invoke($psrRequest); + return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse); + } catch (OAuthServerException $exception) { + // Per RFC 9126, the error response format is the one specified for the token endpoint, so make + // sure we never redirect (regardless of any redirect URI contained in the exception). + return $this->errorResponder->forExceptionJson($exception); + } catch (\Throwable $exception) { + $this->logger->error( + 'PushedAuthorizationController: error processing request: ' . $exception->getMessage(), + ); + return $this->errorResponder->forExceptionJson( + OidcServerException::serverError('Unable to process pushed authorization request.'), + ); + } + } +} diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index d6de1b6d..d006f44f 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -232,6 +232,9 @@ public function toArray(): array // Extra metadata ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(), + ClaimsEnum::RequirePushedAuthorizationRequests->value => $this->getRequirePushedAuthorizationRequests(), + ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(), + ClaimsEnum::RequestUris->value => $this->getRequestUris(), ]; } @@ -406,4 +409,48 @@ public function getAllowedResponseModes(): array ResponseModesEnum::FormPost->value, ]; } + + public function getRequirePushedAuthorizationRequests(): bool + { + if (!is_array($this->extraMetadata)) { + return false; + } + + return (bool)($this->extraMetadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false); + } + + public function getRequireSignedRequestObject(): bool + { + if (!is_array($this->extraMetadata)) { + return false; + } + + return (bool)($this->extraMetadata[ClaimsEnum::RequireSignedRequestObject->value] ?? false); + } + + /** + * @return string[] + */ + public function getRequestUris(): array + { + if (!is_array($this->extraMetadata)) { + return []; + } + + /** @var mixed $uris */ + $uris = $this->extraMetadata[ClaimsEnum::RequestUris->value] ?? null; + if (!is_array($uris)) { + return []; + } + + $stringUris = []; + /** @var mixed $uri */ + foreach ($uris as $uri) { + if (is_string($uri)) { + $stringUris[] = $uri; + } + } + + return $stringUris; + } } diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index 47bc6f15..9ca01687 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -83,4 +83,10 @@ public function isGeneric(): bool; public function getExtraMetadata(): array; public function getIdTokenSignedResponseAlg(): ?string; public function getAllowedResponseModes(): array; + public function getRequirePushedAuthorizationRequests(): bool; + public function getRequireSignedRequestObject(): bool; + /** + * @return string[] + */ + public function getRequestUris(): array; } diff --git a/src/Entities/PushedAuthorizationRequestEntity.php b/src/Entities/PushedAuthorizationRequestEntity.php new file mode 100644 index 00000000..99b899ed --- /dev/null +++ b/src/Entities/PushedAuthorizationRequestEntity.php @@ -0,0 +1,79 @@ +requestUri; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isConsumed(): bool + { + return $this->isConsumed; + } + + public function consume(): void + { + $this->isConsumed = true; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $this->expiresAt < $now; + } + + /** + * @throws \JsonException + */ + public function getState(): array + { + return [ + 'request_uri' => $this->requestUri, + 'client_id' => $this->clientId, + 'parameters' => json_encode($this->parameters, JSON_THROW_ON_ERROR), + 'expires_at' => $this->expiresAt->format(DateFormatsEnum::DB_DATETIME->value), + 'is_consumed' => $this->isConsumed, + ]; + } +} diff --git a/src/Factories/CoreFactory.php b/src/Factories/CoreFactory.php index dba9cd26..ef454c8b 100644 --- a/src/Factories/CoreFactory.php +++ b/src/Factories/CoreFactory.php @@ -17,13 +17,15 @@ public function __construct( } /** - * @throws \ReflectionException - * @throws \SimpleSAML\Error\ConfigurationError + * Builds a new Core instance. + * + * @return Core */ public function build(): Core { return new Core( supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), logger: $this->loggerService, ); } diff --git a/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php new file mode 100644 index 00000000..40f1bbbb --- /dev/null +++ b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php @@ -0,0 +1,85 @@ +helpers->random()->getIdentifier(32); + + $expiresAt ??= $this->helpers->dateTime()->getUtc() + ->add($this->moduleConfig->getParRequestUriTtl()); + + return new PushedAuthorizationRequestEntity( + $requestUri, + $clientId, + $parameters, + $expiresAt, + ); + } + + /** + * @param mixed[] $state + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException + * @throws \Exception + */ + public function fromState(array $state): PushedAuthorizationRequestEntity + { + if ( + !is_string($requestUri = $state['request_uri']) || + !is_string($clientId = $state['client_id']) || + !is_string($parametersJson = $state['parameters']) || + !is_string($expiresAt = $state['expires_at']) + ) { + throw new OpenIdException('Invalid Pushed Authorization Request Entity state.'); + } + + /** @psalm-suppress MixedAssignment */ + $parameters = json_decode($parametersJson, true, 512, JSON_THROW_ON_ERROR); + + $isConsumed = (bool)($state['is_consumed'] ?? true); + + return new PushedAuthorizationRequestEntity( + $requestUri, + $clientId, + is_array($parameters) ? $parameters : [], + $this->helpers->dateTime()->getUtc($expiresAt), + $isConsumed, + ); + } +} diff --git a/src/Factories/RequestObjectFactory.php b/src/Factories/RequestObjectFactory.php new file mode 100644 index 00000000..65fd09a5 --- /dev/null +++ b/src/Factories/RequestObjectFactory.php @@ -0,0 +1,32 @@ +moduleConfig->getSupportedAlgorithms(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), + logger: $this->loggerService, + ); + } +} diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 952485d4..699488fc 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; @@ -29,6 +30,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; @@ -74,6 +76,7 @@ public function __construct( private readonly Jwks $jwks, private readonly Core $core, private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, + private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, private readonly QueryResponseMode $queryResponseMode, @@ -113,7 +116,18 @@ private function getDefaultRules(): array $this->federationCache, ), new ClientRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), - new RequestObjectRule($this->requestParamsResolver, $this->helpers, $this->jwksResolver), + new RequestObjectRule( + $this->requestParamsResolver, + $this->helpers, + $this->jwksResolver, + $this->moduleConfig, + ), + new RequestUriRule( + $this->requestParamsResolver, + $this->helpers, + $this->pushedAuthorizationRequestRepository, + $this->moduleConfig, + ), new ResponseModeRule( $this->requestParamsResolver, $this->helpers, diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index c76b18bc..1953faf0 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -178,6 +178,20 @@ public function validateJwksUri(Form $form): void } } + + public function validateRequestUris(Form $form): void + { + $values = $form->getValues(self::TYPE_ARRAY); + $requestUris = $this->helpers->str()->convertTextToArray( + (string)($values[ClaimsEnum::RequestUris->value] ?? ''), + ); + foreach ($requestUris as $uri) { + if (!str_starts_with(strtolower($uri), 'https://')) { + $this->addError('Request URI must be an HTTPS URL: ' . $uri); + } + } + } + public function validateJwks(mixed $jwks): void { if (is_null($jwks)) { @@ -217,7 +231,8 @@ protected function validateByMatchingRegex( public function getValues(string|object|bool|null $returnType = null, ?array $controls = null): array { - $values = parent::getValues(self::TYPE_ARRAY); + /** @psalm-suppress RedundantCast */ + $values = (array)parent::getValues(self::TYPE_ARRAY); // Sanitize redirect_uri and allowed_origin $values['redirect_uri'] = $this->helpers->str()->convertTextToArray((string)$values['redirect_uri']); @@ -277,6 +292,12 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $signedJwksUri = trim((string)$values['signed_jwks_uri']); $values['signed_jwks_uri'] = empty($signedJwksUri) ? null : $signedJwksUri; + /** @var mixed $requestUrisValue */ + $requestUrisValue = $values[ClaimsEnum::RequestUris->value] ?? ''; + $values[ClaimsEnum::RequestUris->value] = $this->helpers->str()->convertTextToArray( + is_string($requestUrisValue) ? $requestUrisValue : '', + ); + $idTokenSignedResponseAlg = trim((string)$values[ClaimsEnum::IdTokenSignedResponseAlg->value]); $values[ClaimsEnum::IdTokenSignedResponseAlg->value] = empty($idTokenSignedResponseAlg) ? null : $idTokenSignedResponseAlg; @@ -342,6 +363,19 @@ public function setDefaults(object|array $values, bool $erase = false): static $values['auth_source'] = null; } + $requestUris = isset($values[ClaimsEnum::RequestUris->value]) && + is_array($values[ClaimsEnum::RequestUris->value]) ? + $values[ClaimsEnum::RequestUris->value] : + []; + $stringUris = []; + /** @var mixed $uri */ + foreach ($requestUris as $uri) { + if (is_string($uri)) { + $stringUris[] = $uri; + } + } + $values[ClaimsEnum::RequestUris->value] = implode("\n", $stringUris); + $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] = is_array( $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES], ) ? $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : []; @@ -368,6 +402,7 @@ protected function buildForm(): void $this->onValidate[] = $this->validateFederationJwks(...); $this->onValidate[] = $this->validateProtocolJwks(...); $this->onValidate[] = $this->validateJwksUri(...); + $this->onValidate[] = $this->validateRequestUris(...); $this->setMethod('POST'); $this->addComponent($this->csrfProtection, Form::ProtectorId); @@ -442,6 +477,14 @@ protected function buildForm(): void 3, )->setHtmlAttribute('class', 'full-width') ->setRequired(Translate::noop('At least one response mode is required.')); + + $this->addCheckbox( + ClaimsEnum::RequirePushedAuthorizationRequests->value, + 'Require Pushed Authorization Requests (PAR)', + ); + $this->addCheckbox(ClaimsEnum::RequireSignedRequestObject->value, 'Require Signed Request Object'); + $this->addTextArea(ClaimsEnum::RequestUris->value, 'Request URIs (OIDC Core / JAR, one per line)', null, 5) + ->setHtmlAttribute('class', 'full-width'); } /** diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 947492c0..79746d1a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -123,6 +123,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'; + 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'; + final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'request_uri_parameter_supported'; + final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = + 'federation_request_uri_allowed_prefixes'; + final public const string OPTION_REQUEST_URI_FETCH_TIMEOUT = 'request_uri_fetch_timeout'; + final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'request_uri_max_size_bytes'; + protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ self::KEY_DESCRIPTION => 'openid', @@ -323,6 +332,82 @@ public function getRefreshTokenDuration(): DateInterval ); } + public function getParRequestUriTtl(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString(self::OPTION_PAR_REQUEST_URI_TTL, 'PT10M'), + ); + } + + public function getRequirePushedAuthorizationRequests(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS, false); + } + + public function getRequireSignedRequestObject(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT, false); + } + + /** + * Whether the OP supports passing the Request Object by reference using the https request_uri parameter + * (JWT-Secured Authorization Request by reference / OpenID Federation Authentication Request by reference). + * Note that this does not affect Pushed Authorization Request URIs (urn form), which are always supported. + */ + public function getRequestUriParameterSupported(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_REQUEST_URI_PARAMETER_SUPPORTED, true); + } + + /** + * Allowed https request_uri prefixes for OpenID Federation candidates (clients not registered in storage, + * or registered through OpenID Federation). For such clients the OP must fetch the Request Object before + * it can establish trust, so this is the SSRF / DoS allowlist for that outbound fetch. Registered + * (non-federation) clients are not affected; for them the request_uri must match their registered + * request_uris exactly. + * + * Semantics: + * - null: explicitly allow any request_uri for federation candidates, + * - non-empty array: allow only request_uris starting with one of the given prefixes, + * - empty array (and the default, when the option is not set): deny all federation-candidate fetches. + * + * @return string[]|null + */ + public function getFederationRequestUriAllowedPrefixes(): ?array + { + // Note: we read the raw config here (instead of getOptionalValue) so we can distinguish an explicit + // null (allow any) from an absent option (deny by default), since SimpleSAML\Configuration treats a + // null value the same as an absent one. + $config = $this->config()->toArray(); + + if (!array_key_exists(self::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES, $config)) { + return []; + } + + /** @var mixed $value */ + $value = $config[self::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES]; + + if (is_null($value)) { + return null; + } + + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, 'is_string')); + } + + public function getRequestUriFetchTimeout(): int + { + return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_FETCH_TIMEOUT, 5); + } + + public function getRequestUriMaxSizeBytes(): int + { + return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_MAX_SIZE_BYTES, 102400); + } + /** * @throws \Exception */ diff --git a/src/Repositories/PushedAuthorizationRequestRepository.php b/src/Repositories/PushedAuthorizationRequestRepository.php new file mode 100644 index 00000000..e51263d2 --- /dev/null +++ b/src/Repositories/PushedAuthorizationRequestRepository.php @@ -0,0 +1,157 @@ +database->applyPrefix(self::TABLE_NAME); + } + + /** + * Persist the Pushed Authorization Request entity in the database. + * + * @throws \JsonException + */ + public function persist(PushedAuthorizationRequestEntity $entity): void + { + $stmt = "INSERT INTO {$this->getTableName()} (request_uri, client_id, parameters, expires_at, is_consumed) " . + "VALUES (:request_uri, :client_id, :parameters, :expires_at, :is_consumed)"; + + $params = $entity->getState(); + $params['is_consumed'] = (int)$params['is_consumed']; + + $this->database->write($stmt, $params); + + $this->protocolCache?->set( + $entity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime($entity->getExpiresAt()->getTimestamp()), + $this->getCacheKey($entity->getRequestUri()), + ); + } + + /** + * Find Pushed Authorization Request entity by request_uri. + * + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException + * @throws \Exception + */ + public function find(string $requestUri): ?PushedAuthorizationRequestEntity + { + /** @var ?array $state */ + $state = $this->protocolCache?->get(null, $this->getCacheKey($requestUri)); + + if (!is_array($state)) { + $stmt = $this->database->read( + "SELECT request_uri, client_id, parameters, expires_at, is_consumed " . + "FROM {$this->getTableName()} WHERE request_uri = :request_uri", + ['request_uri' => $requestUri], + ); + + if (!is_array($state = $stmt->fetch(PDO::FETCH_ASSOC))) { + return null; + } + } + + $entity = $this->pushedAuthorizationRequestEntityFactory->fromState($state); + + $this->protocolCache?->set( + $entity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime($entity->getExpiresAt()->getTimestamp()), + $this->getCacheKey($entity->getRequestUri()), + ); + + return $entity; + } + + /** + * Find Pushed Authorization Request entity which is not consumed nor expired. + * + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException + * @throws \Exception + */ + public function findValid(string $requestUri): ?PushedAuthorizationRequestEntity + { + $entity = $this->find($requestUri); + + if ($entity === null) { + return null; + } + + if ($entity->isConsumed()) { + return null; + } + + if ($entity->isExpired($this->helpers->dateTime()->getUtc())) { + return null; + } + + return $entity; + } + + /** + * Mark the Pushed Authorization Request as consumed (one-time use). Atomic, + * so it can be used as a replay guard: returns true only if this call was + * the one that consumed it. Note that the database is the source of truth + * for consumption (the protocol cache is only a read accelerator), so a + * stale cached entry can never enable a replay. + */ + public function consume(string $requestUri): bool + { + $stmt = "UPDATE {$this->getTableName()} SET is_consumed = 1 " . + "WHERE request_uri = :request_uri AND is_consumed = 0"; + + $affected = $this->database->write($stmt, ['request_uri' => $requestUri]); + + // Invalidate the cached entry, so subsequent finds reflect the consumed state. + $this->protocolCache?->delete($this->getCacheKey($requestUri)); + + return is_int($affected) && $affected > 0; + } + + /** + * Delete expired Pushed Authorization Request records. + */ + public function removeExpired(): void + { + $this->database->write( + "DELETE FROM {$this->getTableName()} WHERE expires_at < :now", + ['now' => $this->helpers->dateTime()->getUtc()->format(DateFormatsEnum::DB_DATETIME->value)], + ); + } +} diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 56e1d7c4..35ed30cb 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; @@ -86,6 +87,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O $rulesToExecute = [ StateRule::class, ClientRule::class, + RequestUriRule::class, ClientRedirectUriRule::class, ResponseModeRule::class, ]; diff --git a/src/Server/RequestRules/Rules/AbstractRule.php b/src/Server/RequestRules/Rules/AbstractRule.php index 3882cdf9..29dd7748 100644 --- a/src/Server/RequestRules/Rules/AbstractRule.php +++ b/src/Server/RequestRules/Rules/AbstractRule.php @@ -4,9 +4,13 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; +use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use SimpleSAML\OpenID\Codebooks\ScopesEnum; abstract class AbstractRule implements RequestRuleInterface { @@ -23,4 +27,26 @@ public function getKey(): string { return static::class; } + + /** + * Check if the authorization request is an OpenID Connect request + * (designated by the openid scope), as opposed to a plain OAuth 2.0 + * request. Scope is resolved from all request params, including the ones + * from Request Object / Request URI, if present. + * + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + protected function isOidcAuthorizationRequest( + ServerRequestInterface $request, + array $allowedServerRequestMethods = [HttpMethodsEnum::GET], + ): bool { + $scope = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::Scope->value, + $request, + $allowedServerRequestMethods, + ) ?? ''; + + return in_array(ScopesEnum::OpenId->value, explode(' ', $scope), true); + } } diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 242c60d0..aef34bb6 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -29,6 +29,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\RequestObject as FederationRequestObject; use Throwable; /** @@ -161,30 +162,27 @@ public function resolveFromFederation( ): ?ClientEntityInterface { $this->loggerService->debug('ClientRule: Resolving client from federation.'); // Federation is enabled. - // Check if we have a request object available. If not, we don't have anything else to do. - $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::Request->value, - $request, - $allowedMethods, - ); - - if (is_null($requestParam)) { - $this->loggerService->error('ClientRule: No request param available, nothing to do.'); + // Check if we have a Request Object available, either passed by value + // (request param) or by reference (https request_uri param). The + // RequestParamsResolver does the heavy lifting (parsing / fetching). + // If not available, we don't have anything else to do. + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedMethods); + + if ($requestObjectBag === null) { + $this->loggerService->error('ClientRule: No request object available, nothing to do.'); return null; } - $this->loggerService->debug('ClientRule: Request param available.', ['requestParam' => $requestParam]); + // We must verify that the Request Object is the one compatible with OpenID Federation specification + // (not only Connect Core or OAuth2 JAR specification). + $requestObject = $requestObjectBag->get(FederationRequestObject::class); - // We have a request object available. We must verify that it is the one compatible with OpenID Federation - // specification (not only Core specification). - try { - $requestObject = $this->requestParamsResolver->parseFederationRequestObjectToken($requestParam); - } catch (Throwable $exception) { - $this->loggerService->error('ClientRule: Request object error: ' . $exception->getMessage()); + if (!$requestObject instanceof FederationRequestObject) { + $this->loggerService->error('ClientRule: Request object is not a Federation Request Object.'); return null; } - $this->loggerService->debug('ClientRule: Request object parsed successfully.'); + $this->loggerService->debug('ClientRule: Federation Request object available.'); // We have a Federation-compatible Request Object. // The Audience (aud) value MUST be or include the OP's Issuer Identifier URL. diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index e4eab6e3..c07372ee 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -5,7 +5,9 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; @@ -17,6 +19,8 @@ use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use SimpleSAML\OpenID\Core\RequestObject as ConnectRequestObject; +use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; class RequestObjectRule extends AbstractRule { @@ -24,6 +28,7 @@ public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, protected JwksResolver $jwksResolver, + protected ModuleConfig $moduleConfig, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -45,34 +50,22 @@ public function checkRule( ): ?ResultInterface { $loggerService->debug('RequestObjectRule::checkRule'); - $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::Request->value, - $request, - $allowedServerRequestMethods, - ); - - if (is_null($requestParam)) { + // A Request Object can be passed by value (request param) or by reference (https request_uri param). + // Either way, the parsing/fetching is done by the RequestParamsResolver; here we only need to know + // whether such a Request Object is present for this request. + if (!$this->hasRequestObjectSource($request, $allowedServerRequestMethods)) { return null; } - // Request param exists. Check if the result bag already has request object resolved. This can happen if the - // request object was used as a way to do automatic client registration in OpenID Federation. - // @see ClientIdRule + // Request object is present. Check if the result bag already has a request object resolved. This can + // happen if the request object was used as a way to do automatic client registration in OpenID + // Federation. + // @see ClientRule::resolveFromFederation() if ($currentResultBag->has($this->getKey())) { $loggerService->debug('Request object has already been resolved, skipping rule ' . $this->getKey()); return null; } - // There is no request object already resolved. We will do it now. - $requestObject = $this->requestParamsResolver->parseRequestObjectToken($requestParam); - - // If request object is not protected (signed), we are allowed to use it as is. - if (!$requestObject->isProtected()) { - return new Result($this->getKey(), $requestObject->getPayload()); - } - - // It is protected, we must validate it. - /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ @@ -80,6 +73,211 @@ public function checkRule( /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); + // Parse it using all available Request Object flavors, so we can differentiate between OpenID Connect + // Core Request Objects (which can be unsigned) and JAR Request Objects (which must be signed). + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedServerRequestMethods); + + // The Request Object source is present, but it could not be parsed (by value) or fetched/parsed (by + // reference). Note that for the by-reference case, RequestUriRule would normally reject this earlier. + if ($requestObjectBag === null) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object could not be parsed or fetched.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { + // This is a plain OAuth 2.0 authorization request, so JAR + // (RFC 9101) rules apply: the Request Object must be a signed JWT + // containing the Client ID claim. + $jarRequestObject = $requestObjectBag->get(JarRequestObject::class); + if (!$jarRequestObject instanceof JarRequestObject) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object is not a valid JAR Request Object (note that it must be signed).', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + if ($jarRequestObject->getClientId() !== $client->getIdentifier()) { + throw OidcServerException::invalidRequest( + 'request', + 'Client ID claim in request object does not match the client_id parameter.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + $this->verifySignature($jarRequestObject, $client, $redirectUri, $stateValue, $responseMode); + + $this->verifyAudience($jarRequestObject, $redirectUri, $stateValue, $responseMode); + $this->verifyIssuer($jarRequestObject, $client, $redirectUri, $stateValue, $responseMode); + + return new Result($this->getKey(), $jarRequestObject->getPayload()); + } + + // This is an OpenID Connect authorization request, so OpenID Connect + // Core rules apply: the Request Object can be unsigned + // (unless policy requires signature). + $requestObject = $requestObjectBag->get(ConnectRequestObject::class); + if (!$requestObject instanceof ConnectRequestObject) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object is not a valid Request Object.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + // If request object is not protected (signed), check if signature is required. + if (!$requestObject->isProtected()) { + $requireSigned = $this->moduleConfig->getRequireSignedRequestObject() || + $client->getRequireSignedRequestObject(); + if ($requireSigned) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object must be signed (alg: none is not allowed).', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + } else { + // It is protected, we must validate the signature. + $this->verifySignature($requestObject, $client, $redirectUri, $stateValue, $responseMode); + } + + $this->verifyAudience($requestObject, $redirectUri, $stateValue, $responseMode); + $this->verifyIssuer($requestObject, $client, $redirectUri, $stateValue, $responseMode); + + return new Result($this->getKey(), $requestObject->getPayload()); + } + + /** + * Check whether the request carries a Request Object, either by value (request param) or by reference + * (https request_uri param). Note that a Pushed Authorization Request URI (urn form) is not a Request + * Object source (it carries previously pushed params, handled by RequestUriRule). + * + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods + */ + protected function hasRequestObjectSource( + ServerRequestInterface $request, + array $allowedServerRequestMethods, + ): bool { + if ( + !is_null($this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Request->value, + $request, + $allowedServerRequestMethods, + )) + ) { + return true; + } + + $requestUri = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::RequestUri->value, + $request, + $allowedServerRequestMethods, + ); + + return is_string($requestUri) && str_starts_with(strtolower($requestUri), 'https://'); + } + + /** + * Validate the Request Object audience (aud) claim. + * + * Unlike the OpenID Federation flavor (handled in ClientRule, where aud is mandatory), the aud claim is + * optional for OpenID Connect Core and JAR (RFC 9101) Request Objects. We therefore only validate it when + * a client actually includes it: in that case it MUST identify this OP (its Issuer Identifier). Rejecting + * a mismatch prevents a Request Object minted for a different Authorization Server from being replayed + * here (OAuth 2.0 Security BCP). An absent aud is tolerated to preserve interoperability. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifyAudience( + ConnectRequestObject|JarRequestObject $requestObject, + string $redirectUri, + ?string $stateValue, + ResponseModeInterface $responseMode, + ): void { + $audience = $requestObject->getAudience(); + + // The claim is optional for these flavors; only validate it when present. + if ($audience === null) { + return; + } + + if (!in_array($this->moduleConfig->getIssuer(), $audience, true)) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object audience (aud) does not include this OP issuer.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + } + + /** + * Validate the Request Object issuer (iss) claim. + * + * Like aud, the iss claim is optional for OpenID Connect Core and JAR (RFC 9101) Request Objects (RFC 9101 + * says a signed object SHOULD contain it). In JWT (RFC 7519) semantics iss identifies the party that + * issued the token, which for a Request Object is the client (the RP). So when a client includes it, iss + * MUST equal the client identifier; a mismatch means the object was not minted by this client. An absent + * iss is tolerated. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifyIssuer( + ConnectRequestObject|JarRequestObject $requestObject, + ClientEntityInterface $client, + string $redirectUri, + ?string $stateValue, + ResponseModeInterface $responseMode, + ): void { + $issuer = $requestObject->getIssuer(); + + // The claim is optional for these flavors; only validate it when present. + if ($issuer === null) { + return; + } + + if ($issuer !== $client->getIdentifier()) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object issuer (iss) does not match the client.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifySignature( + ConnectRequestObject|JarRequestObject $requestObject, + ClientEntityInterface $client, + string $redirectUri, + ?string $stateValue, + ResponseModeInterface $responseMode, + ): void { ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( 'can not validate request object, client JWKS not available', $redirectUri, @@ -99,7 +297,5 @@ public function checkRule( $responseMode, ); } - - return new Result($this->getKey(), $requestObject->getPayload()); } } diff --git a/src/Server/RequestRules/Rules/RequestUriRule.php b/src/Server/RequestRules/Rules/RequestUriRule.php new file mode 100644 index 00000000..82911afc --- /dev/null +++ b/src/Server/RequestRules/Rules/RequestUriRule.php @@ -0,0 +1,254 @@ +debug('RequestUriRule::checkRule'); + + // Note: we are intentionally working with raw request params here (not the merged view which includes + // params resolved from the request_uri itself). + $requestUri = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::RequestUri->value, + $request, + $allowedServerRequestMethods, + ); + + /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + + $isParRequired = $this->moduleConfig->getRequirePushedAuthorizationRequests() || + $client->getRequirePushedAuthorizationRequests(); + + if (is_null($requestUri) || $requestUri === '') { + if ($isParRequired) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed Authorization Request (PAR) is required.', + ); + } + + return null; + } + + $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Request->value, + $request, + $allowedServerRequestMethods, + ); + + if (!is_null($requestParam)) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Params request and request_uri must not be used together.', + ); + } + + $clientIdParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $allowedServerRequestMethods, + ); + + if (is_null($clientIdParam) || $clientIdParam === '') { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'Param client_id is required when using request_uri.', + ); + } + + if (str_starts_with($requestUri, PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX)) { + return $this->checkPushedAuthorizationRequestUri( + $requestUri, + $clientIdParam, + $client, + $loggerService, + ); + } + + if (str_starts_with(strtolower($requestUri), 'https://')) { + return $this->checkHttpsRequestUri( + $requestUri, + $request, + $isParRequired, + $allowedServerRequestMethods, + ); + } + + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Invalid request_uri scheme / format.', + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \Throwable + */ + protected function checkPushedAuthorizationRequestUri( + string $requestUri, + string $clientIdParam, + ClientEntityInterface $client, + LoggerService $loggerService, + ): ResultInterface { + $parEntity = $this->pushedAuthorizationRequestRepository->find($requestUri); + + if ($parEntity === null) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request not found.', + ); + } + + if ($parEntity->isExpired($this->helpers->dateTime()->getUtc())) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request has expired.', + ); + } + + if ($parEntity->isConsumed()) { + $loggerService->warning( + 'RequestUriRule: pushed authorization request replay attempt.', + compact('requestUri'), + ); + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request has already been used.', + ); + } + + // The request_uri value must be bound to the client that posted the authorization request. + if ( + $parEntity->getClientId() !== $clientIdParam || + $parEntity->getClientId() !== $client->getIdentifier() + ) { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'Pushed authorization request is bound to different client.', + ); + } + + // Request URIs are one-time use. Consume it now (atomically, to prevent concurrent replays). + if (!$this->pushedAuthorizationRequestRepository->consume($requestUri)) { + $loggerService->warning( + 'RequestUriRule: pushed authorization request concurrent consumption attempt.', + compact('requestUri'), + ); + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request has already been used.', + ); + } + + return new Result($this->getKey(), $requestUri); + } + + /** + * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \Throwable + */ + protected function checkHttpsRequestUri( + string $requestUri, + ServerRequestInterface $request, + bool $isParRequired, + array $allowedServerRequestMethods, + ): ResultInterface { + if ($isParRequired) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed Authorization Request (PAR) is required.', + ); + } + + if (!$this->moduleConfig->getRequestUriParameterSupported()) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Passing the request object by reference (request_uri) is not supported.', + ); + } + + // Make sure the Request Object behind the request_uri can actually be resolved (fetched and parsed, + // and allowed by registration / federation policy in RequestParamsResolver). The signature and other + // request object validations are then done by the RequestObjectRule (or by ClientRule for the + // federation case). + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedServerRequestMethods); + if ($requestObjectBag === null) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'The request_uri could not be resolved (it may not be allowed for this client, or the fetch ' . + 'failed).', + ); + } + + return new Result($this->getKey(), $requestUri); + } +} diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index 2587aaee..3bdb9723 100644 --- a/src/Server/RequestRules/Rules/ScopeRule.php +++ b/src/Server/RequestRules/Rules/ScopeRule.php @@ -56,8 +56,7 @@ public function checkRule( /** @var non-empty-string $scopeDelimiterString */ $scopeDelimiterString = $data['scope_delimiter_string'] ?? ' '; - $loggerService->debug('ScopeRule: defaultScope: ' . ($defaultScope ? $defaultScope : 'N/A')); - ; + $loggerService->debug('ScopeRule: defaultScope: ' . ($defaultScope ?: 'N/A')); $scopeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::Scope->value, diff --git a/src/Services/Container.php b/src/Services/Container.php index 8f418899..ba43b7ef 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -44,6 +44,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\IssuerStateEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\RefreshTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ScopeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; @@ -56,6 +57,7 @@ use SimpleSAML\Module\oidc\Factories\JwksFactory; use SimpleSAML\Module\oidc\Factories\JwsFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; +use SimpleSAML\Module\oidc\Factories\RequestObjectFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Factories\TokenResponseFactory; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; @@ -67,6 +69,7 @@ use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; @@ -90,6 +93,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; @@ -122,6 +126,7 @@ use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\RequestObject; use SimpleSAML\Session; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -261,9 +266,6 @@ public function __construct() ); $this->services[PsrHttpBridge::class] = $psrHttpBridge; - $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation, $psrHttpBridge); - $this->services[RequestParamsResolver::class] = $requestParamsResolver; - $clientEntityFactory = new ClientEntityFactory( $sspBridge, $helpers, @@ -282,6 +284,37 @@ public function __construct() ); $this->services[ClientRepository::class] = $clientRepository; + $pushedAuthorizationRequestEntityFactory = new PushedAuthorizationRequestEntityFactory( + $moduleConfig, + $helpers, + ); + $this->services[PushedAuthorizationRequestEntityFactory::class] = $pushedAuthorizationRequestEntityFactory; + + $pushedAuthorizationRequestRepository = new PushedAuthorizationRequestRepository( + $moduleConfig, + $database, + $protocolCache, + $pushedAuthorizationRequestEntityFactory, + $helpers, + ); + $this->services[PushedAuthorizationRequestRepository::class] = $pushedAuthorizationRequestRepository; + + $requestObject = (new RequestObjectFactory($moduleConfig, $loggerService))->build(); + $this->services[RequestObject::class] = $requestObject; + + $requestParamsResolver = new RequestParamsResolver( + $helpers, + $core, + $federation, + $psrHttpBridge, + $requestObject, + $moduleConfig, + $clientRepository, + $pushedAuthorizationRequestRepository, + $loggerService, + ); + $this->services[RequestParamsResolver::class] = $requestParamsResolver; + $userEntityFactory = new UserEntityFactory($helpers); $this->services[UserEntityFactory::class] = $userEntityFactory; @@ -447,7 +480,13 @@ public function __construct() $federationCache, ), new ClientRedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), - new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver), + new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver, $moduleConfig), + new RequestUriRule( + $requestParamsResolver, + $helpers, + $pushedAuthorizationRequestRepository, + $moduleConfig, + ), new ResponseModeRule( $requestParamsResolver, $helpers, diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 2eadd226..a282f109 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -23,6 +23,7 @@ use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; @@ -219,6 +220,11 @@ public function migrate(): void $this->version20260218163000(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260218163000')"); } + + if (!in_array('20260608130000', $versions, true)) { + $this->version20260608130000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260608130000')"); + } } private function versionsTableName(): string @@ -739,6 +745,33 @@ private function version20260218163000(): void ,); } + + private function version20260608130000(): void + { + $parTableName = $this->database->applyPrefix(PushedAuthorizationRequestRepository::TABLE_NAME); + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $fkParClient = $this->generateIdentifierName([$parTableName, 'client_id'], 'fk'); + $idxParExpiresAt = $this->generateIdentifierName([$parTableName, 'expires_at'], 'idx'); + + // request_uri is always a fixed-length value: REQUEST_URI_PREFIX (34 chars) + + // bin2hex(random_bytes(32)) (64 chars) = 98 chars. See PushedAuthorizationRequestEntityFactory::fromData(). + $this->database->write(<<< EOT + CREATE TABLE $parTableName ( + request_uri CHAR(98) PRIMARY KEY NOT NULL, + client_id VARCHAR(191) NOT NULL, + parameters TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + is_consumed BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT $fkParClient FOREIGN KEY (client_id) + REFERENCES $clientTableName (id) ON DELETE CASCADE + ) +EOT + ,); + + $this->database->write("CREATE INDEX $idxParExpiresAt ON $parTableName (expires_at)"); + } + + /** * @param string[] $columnNames */ diff --git a/src/Services/ErrorResponder.php b/src/Services/ErrorResponder.php index 0b0cf348..e27d9cb7 100644 --- a/src/Services/ErrorResponder.php +++ b/src/Services/ErrorResponder.php @@ -45,4 +45,27 @@ public function forException(Throwable $exception): Response 500, ); } + + /** + * Create a JSON error response (as specified for the token endpoint), regardless of any redirect URI + * contained in the exception. This is appropriate for endpoints which must not redirect on errors, like + * the Pushed Authorization Request endpoint. + */ + public function forExceptionJson(OAuthServerException $exception): JsonResponse + { + $body = [ + 'error' => $exception->getErrorType(), + 'error_description' => $exception->getMessage(), + ]; + + if (($hint = $exception->getHint()) !== null) { + $body['hint'] = $hint; + } + + return new JsonResponse( + $body, + $exception->getHttpStatusCode(), + ['Cache-Control' => 'no-cache, no-store'], + ); + } } diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index df958b03..1deb84df 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -77,7 +77,14 @@ private function initMetadata(): void 'none', ...$supportedSignatureAlgorithmNames, ]; - $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = false; + $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = + $this->moduleConfig->getRequestUriParameterSupported(); + // The https request_uri values must be pre-registered for the client (request_uris client metadata). + $this->metadata[ClaimsEnum::RequireRequestUriRegistration->value] = true; + $this->metadata[ClaimsEnum::PushedAuthorizationRequestEndpoint->value] = + $this->moduleConfig->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value); + $this->metadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] = + $this->moduleConfig->getRequirePushedAuthorizationRequests(); $grantTypesSupported = [ GrantTypesEnum::AuthorizationCode->value, diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php index 62b174b3..88d48bec 100644 --- a/src/Utils/AuthenticatedOAuth2ClientResolver.php +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -330,9 +330,12 @@ public function forPrivateKeyJwt( // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. + // RFC 9126 (PAR): ...the authorization server MUST accept its issuer identifier, token endpoint URL, + // or pushed authorization request endpoint URL as values that identify it as an intended audience. $expectedAudience = [ $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), + $this->moduleConfig->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value), $this->moduleConfig->getIssuer(), ]; diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 09176946..bf44db12 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -6,24 +6,62 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\RequestObject; +use SimpleSAML\OpenID\RequestObject\RequestObjectBag; use Symfony\Component\HttpFoundation\Request; /** * Resolve authorization params from an HTTP request (based or not based on - * a used method), and from Request Object param if present. + * a used method), from Request Object param if present, and from Request URI + * param (Pushed Authorization Request or Request Object by reference) if + * present. */ class RequestParamsResolver { + /** + * Request Object Bags parsed from a Request Object JWT passed by value (request param), keyed by token. + * + * @var array + */ + protected array $requestObjectBagsByToken = []; + + /** + * Request Object Bags fetched and parsed from an https Request URI passed by reference (request_uri param), + * keyed by request_uri value. + * + * @var array + */ + protected array $requestObjectBagsByUri = []; + + /** + * Params resolved from Pushed Authorization Request URIs (urn form), keyed by request_uri value. + * + * @var array + */ + protected array $pushedAuthorizationRequestParams = []; + public function __construct( protected readonly Helpers $helpers, protected readonly Core $core, protected readonly Federation $federation, protected readonly PsrHttpBridge $psrHttpBridge, + protected readonly RequestObject $requestObject, + protected readonly ModuleConfig $moduleConfig, + protected readonly ClientRepository $clientRepository, + protected readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, + protected readonly LoggerService $loggerService, ) { } @@ -74,6 +112,7 @@ public function getAll(Request|ServerRequestInterface $request): array return array_merge( $requestParams, $this->resolveRequestObjectParams($requestParams), + $this->resolveRequestUriParams($requestParams), ); } @@ -94,6 +133,7 @@ public function getAllBasedOnAllowedMethods( return array_merge( $requestParams, $this->resolveRequestObjectParams($requestParams), + $this->resolveRequestUriParams($requestParams), ); } @@ -161,18 +201,235 @@ public function getFromRequestBasedOnAllowedMethods( } /** - * Check if Request Object is present as a request param and parse it to - * use its claims as params. + * Check if Request Object is present as a request param (passed by value) and parse it to use its claims + * as params. * - * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @return mixed[] */ protected function resolveRequestObjectParams(array $requestParams): array { + if ( + (!array_key_exists(ParamsEnum::Request->value, $requestParams)) || + (!is_string($token = $requestParams[ParamsEnum::Request->value])) || + ($token === '') + ) { + return []; + } + + // Use the OpenID Connect Core flavor for (unverified) param resolution, since it is the most lenient + // one (signature validation and policy checks are done in RequestObjectRule). + return $this->parseRequestObjectBagByToken($token)?->get(Core\RequestObject::class)?->getPayload() ?? []; + } + + /** + * Check if Request URI is present as a request param and resolve its claims to use them as params. For + * Pushed Authorization Request URIs (urn form), params are resolved from the previously pushed (validated) + * authorization request. For https Request URIs, the Request Object is fetched and parsed (if allowed by + * policy), but note that this won't do signature validation of it, nor any policy checks like one-time use + * or expiration. + * + * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule + * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule + * @return mixed[] + */ + protected function resolveRequestUriParams(array $requestParams): array + { + if ( + (!array_key_exists(ParamsEnum::RequestUri->value, $requestParams)) || + (!is_string($requestUri = $requestParams[ParamsEnum::RequestUri->value])) || + ($requestUri === '') + ) { + return []; + } + + // Using both request and request_uri params is not allowed. Don't resolve anything and let the + // RequestUriRule produce the proper error. if (array_key_exists(ParamsEnum::Request->value, $requestParams)) { - return $this->parseRequestObjectToken((string)$requestParams[ParamsEnum::Request->value])->getPayload(); + return []; + } + + // Pushed Authorization Request URI (urn form): resolve from the previously pushed (validated) params. + if (str_starts_with($requestUri, PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX)) { + return $this->resolvePushedAuthorizationRequestParams($requestUri); } - return []; + // https Request URI (by reference): fetch and parse the Request Object (if allowed by policy). + return $this->fetchRequestObjectBagByUri($requestUri, $requestParams) + ?->get(Core\RequestObject::class)?->getPayload() ?? []; + } + + /** + * @return mixed[] + */ + protected function resolvePushedAuthorizationRequestParams(string $requestUri): array + { + if (array_key_exists($requestUri, $this->pushedAuthorizationRequestParams)) { + return $this->pushedAuthorizationRequestParams[$requestUri]; + } + + try { + return $this->pushedAuthorizationRequestParams[$requestUri] = + $this->pushedAuthorizationRequestRepository->findValid($requestUri)?->getParameters() ?? []; + } catch (\Throwable $throwable) { + $this->loggerService->warning( + 'RequestParamsResolver: error resolving pushed authorization request: ' . $throwable->getMessage(), + compact('requestUri'), + ); + return $this->pushedAuthorizationRequestParams[$requestUri] = []; + } + } + + /** + * Resolve the Request Object Bag for the current request, regardless of whether the Request Object was + * passed by value (request param) or by reference (https request_uri param). For Pushed Authorization + * Request URIs (urn form) this returns null, since PAR carries previously pushed params, not a Request + * Object. Note that this won't do signature validation; that is done in RequestObjectRule. + * + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods + */ + public function getRequestObjectBag( + Request|ServerRequestInterface $request, + array $allowedMethods = [HttpMethodsEnum::GET], + ): ?RequestObjectBag { + $requestParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); + + /** @psalm-suppress MixedAssignment */ + if ( + array_key_exists(ParamsEnum::Request->value, $requestParams) && + is_string($token = $requestParams[ParamsEnum::Request->value]) && + $token !== '' + ) { + return $this->parseRequestObjectBagByToken($token); + } + + /** @psalm-suppress MixedAssignment */ + if ( + array_key_exists(ParamsEnum::RequestUri->value, $requestParams) && + is_string($requestUri = $requestParams[ParamsEnum::RequestUri->value]) && + str_starts_with(strtolower($requestUri), 'https://') + ) { + return $this->fetchRequestObjectBagByUri($requestUri, $requestParams); + } + + return null; + } + + /** + * Parse (memoized) the Request Object token using all available Request + * Object flavors (OpenID Connect Core, JAR, OpenID Federation). The + * returned bag contains an entry for every flavor for which the + * token parsed and passed flavor-specific validation, so it can + * be used to differentiate between, for example, OpenID Connect + * Core Request Objects (which can be unsigned) and JAR Request + * Objects (which must be signed). Note that this won't do + * signature validation. + */ + protected function parseRequestObjectBagByToken(string $token): ?RequestObjectBag + { + if (!array_key_exists($token, $this->requestObjectBagsByToken)) { + try { + $this->requestObjectBagsByToken[$token] = $this->requestObject->requestObjectParser() + ->fromToken($token); + } catch (\Throwable $throwable) { + $this->loggerService->warning( + 'RequestParamsResolver: error parsing request object: ' . $throwable->getMessage(), + ); + $this->requestObjectBagsByToken[$token] = null; + } + } + + return $this->requestObjectBagsByToken[$token]; + } + + /** + * Fetch and parse (memoized) the Request Object from the given https + * Request URI, if allowed by policy. + */ + protected function fetchRequestObjectBagByUri(string $requestUri, array $requestParams): ?RequestObjectBag + { + if (array_key_exists($requestUri, $this->requestObjectBagsByUri)) { + return $this->requestObjectBagsByUri[$requestUri]; + } + + if (!$this->isHttpsRequestUriFetchAllowed($requestUri, $requestParams)) { + return $this->requestObjectBagsByUri[$requestUri] = null; + } + + try { + return $this->requestObjectBagsByUri[$requestUri] = $this->requestObject->requestObjectParser() + ->fromRequestUri( + $requestUri, + $this->moduleConfig->getRequestUriFetchTimeout(), + $this->moduleConfig->getRequestUriMaxSizeBytes(), + ); + } catch (\Throwable $throwable) { + $this->loggerService->warning( + 'RequestParamsResolver: error fetching request object from request_uri: ' . $throwable->getMessage(), + compact('requestUri'), + ); + return $this->requestObjectBagsByUri[$requestUri] = null; + } + } + + /** + * Decide whether a https Request URI (Request Object by reference) is + * allowed to be fetched. This is the single authorization point for + * outbound Request Object fetches (SSRF / DoS surface): + * - the OP must support the request_uri parameter (request_uri_parameter_supported), + * - for registered (non-federation) clients, the request_uri must be pre-registered in the client's + * request_uris (RFC 9126 exact-matching), + * - for clients not in storage or registered through OpenID Federation, fetching is allowed when + * federation is enabled and the request_uri is allowed by the federation request_uri prefix allowlist + * (trust is validated after the fetch, in ClientRule). + */ + protected function isHttpsRequestUriFetchAllowed(string $requestUri, array $requestParams): bool + { + if (!$this->moduleConfig->getRequestUriParameterSupported()) { + return false; + } + + if ( + (!array_key_exists(ParamsEnum::ClientId->value, $requestParams)) || + (!is_string($clientId = $requestParams[ParamsEnum::ClientId->value])) || + ($clientId === '') + ) { + return false; + } + + $client = $this->clientRepository->getClientEntity($clientId); + + if ( + $client instanceof ClientEntityInterface && + $client->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic + ) { + return in_array($requestUri, $client->getRequestUris(), true); + } + + // Client not in storage, or registered through OpenID Federation: federation by-reference path. + return $this->moduleConfig->getFederationEnabled() && + $this->isFederationRequestUriAllowed($requestUri); + } + + /** + * Check the federation request_uri against the configured prefix allowlist (SSRF / DoS mitigation for the + * outbound fetch of a not-yet-trusted federation candidate's Request Object). + */ + protected function isFederationRequestUriAllowed(string $requestUri): bool + { + $allowedPrefixes = $this->moduleConfig->getFederationRequestUriAllowedPrefixes(); + + // Null means explicitly allow any request_uri. + if (is_null($allowedPrefixes)) { + return true; + } + + foreach ($allowedPrefixes as $allowedPrefix) { + if ($allowedPrefix !== '' && str_starts_with($requestUri, $allowedPrefix)) { + return true; + } + } + + return false; } /** @@ -182,7 +439,6 @@ protected function resolveRequestObjectParams(array $requestParams): array * @param string $token * @return \SimpleSAML\OpenID\Core\RequestObject * @throws \SimpleSAML\OpenID\Exceptions\JwsException - * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule */ public function parseRequestObjectToken(string $token): Core\RequestObject { diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 68791e1e..667a5e1c 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -218,6 +218,12 @@ public function urlFederationList(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::FederationList->value, $parameters); } + + public function urlPushedAuthorizationRequest(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value, $parameters); + } + /***************************************************************************************************************** * OpenID for Verifiable Credential Issuance URLs. ****************************************************************************************************************/ diff --git a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php new file mode 100644 index 00000000..b2f6b39c --- /dev/null +++ b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php @@ -0,0 +1,317 @@ +authenticatedOAuth2ClientResolverMock = $this->createMock(AuthenticatedOAuth2ClientResolver::class); + $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( + PushedAuthorizationRequestRepository::class, + ); + $this->pushedAuthorizationRequestEntityFactoryMock = $this->createMock( + PushedAuthorizationRequestEntityFactory::class, + ); + $this->requestRulesManagerMock = $this->createMock(RequestRulesManager::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->errorResponderMock = $this->createMock(ErrorResponder::class); + $this->helpers = new Helpers(); + $this->loggerMock = $this->createMock(LoggerService::class); + + $this->serverRequestMock = $this->createMock(ServerRequestInterface::class); + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->responseFactoryMock = $this->createMock(ResponseFactoryInterface::class); + $this->streamMock = $this->createMock(StreamInterface::class); + + $this->responseMock->method('getBody')->willReturn($this->streamMock); + $this->responseMock->method('withStatus')->willReturn($this->responseMock); + $this->responseMock->method('withHeader')->willReturn($this->responseMock); + $this->responseFactoryMock->method('createResponse')->willReturn($this->responseMock); + $this->psrHttpBridgeMock->method('getResponseFactory')->willReturn($this->responseFactoryMock); + + $this->clientMock = $this->createMock(ClientEntityInterface::class); + $this->clientMock->method('getIdentifier')->willReturn('client123'); + + $this->parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); + $this->parEntityMock->method('getRequestUri') + ->willReturn(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'); + $this->parEntityMock->method('getExpiresAt') + ->willReturn(new DateTimeImmutable('+5 minutes', new DateTimeZone('UTC'))); + + $this->resultBagMock = $this->createMock(ResultBag::class); + $this->requestRulesManagerMock->method('check')->willReturn($this->resultBagMock); + } + + protected function sut(): PushedAuthorizationController + { + return new PushedAuthorizationController( + $this->authenticatedOAuth2ClientResolverMock, + $this->pushedAuthorizationRequestRepositoryMock, + $this->pushedAuthorizationRequestEntityFactoryMock, + $this->requestRulesManagerMock, + $this->psrHttpBridgeMock, + $this->errorResponderMock, + $this->helpers, + $this->loggerMock, + ); + } + + protected function prepareAuthenticatedClient( + ClientAuthenticationMethodsEnum $method = ClientAuthenticationMethodsEnum::ClientSecretPost, + ): void { + $resolvedAuth = new ResolvedClientAuthenticationMethod($this->clientMock, $method); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn($resolvedAuth); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf(PushedAuthorizationController::class, $this->sut()); + } + + public function testMethodMustBePost(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('GET'); + + $this->responseMock->expects($this->once())->method('withStatus') + ->with(405)->willReturn($this->responseMock); + $this->responseMock->expects($this->once())->method('withHeader') + ->with('Allow', 'POST')->willReturn($this->responseMock); + + $response = $this->sut()->__invoke($this->serverRequestMock); + $this->assertSame($this->responseMock, $response); + } + + public function testClientAuthenticationFailureThrows(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testConfidentialClientMustAuthenticate(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->clientMock->method('isConfidential')->willReturn(true); + $this->prepareAuthenticatedClient(ClientAuthenticationMethodsEnum::None); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testRejectsRequestUriInBody(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $this->serverRequestMock->method('getParsedBody')->willReturn([ + 'request_uri' => 'some-uri', + ]); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testRejectsClientIdParamWhichDoesNotMatchAuthenticatedClient(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $this->serverRequestMock->method('getParsedBody')->willReturn([ + 'client_id' => 'otherClient', + ]); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testHandlesValidParRequest(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $params = [ + 'client_id' => 'client123', + 'client_secret' => 'verysecret', + 'redirect_uri' => 'https://localhost/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'xyz', + ]; + $this->serverRequestMock->method('getParsedBody')->willReturn($params); + + // Client authentication params must not be persisted, while client_id is bound to the + // authenticated client. + $this->pushedAuthorizationRequestEntityFactoryMock->expects($this->once()) + ->method('fromData') + ->with( + 'client123', + [ + 'client_id' => 'client123', + 'redirect_uri' => 'https://localhost/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'xyz', + ], + ) + ->willReturn($this->parEntityMock); + + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once())->method('persist') + ->with($this->parEntityMock); + + $this->responseMock->expects($this->once())->method('withStatus') + ->with(201)->willReturn($this->responseMock); + + $response = $this->sut()->__invoke($this->serverRequestMock); + $this->assertSame($this->responseMock, $response); + } + + public function testPersistsRequestObjectPayloadOnlyWhenJarIsUsed(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $params = [ + 'request' => 'token', + 'client_secret' => 'verysecret', + 'some_stray_param' => 'value', + ]; + $this->serverRequestMock->method('getParsedBody')->willReturn($params); + + $requestObjectPayload = [ + 'client_id' => 'client123', + 'redirect_uri' => 'https://localhost/callback', + 'response_type' => 'code', + 'scope' => 'openid', + ]; + $requestObjectResult = new Result(RequestObjectRule::class, $requestObjectPayload); + $this->resultBagMock->method('get')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + $this->resultBagMock->method('getOrFail')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + + $this->pushedAuthorizationRequestEntityFactoryMock->expects($this->once()) + ->method('fromData') + ->with('client123', $requestObjectPayload) + ->willReturn($this->parEntityMock); + + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once())->method('persist'); + + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testRejectsRequestObjectClientIdClaimWhichDoesNotMatchAuthenticatedClient(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $this->serverRequestMock->method('getParsedBody')->willReturn(['request' => 'token']); + + $requestObjectResult = new Result(RequestObjectRule::class, ['client_id' => 'otherClient']); + $this->resultBagMock->method('get')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + $this->resultBagMock->method('getOrFail')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testParReturnsJsonErrorResponseForOAuthServerException(): void + { + $requestMock = $this->createMock(Request::class); + $psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $psrHttpFactoryMock->method('createRequest')->willReturn($this->serverRequestMock); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($psrHttpFactoryMock); + + // Make __invoke throw an OidcServerException (client authentication failure). + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn(null); + + $jsonResponse = new JsonResponse(); + $this->errorResponderMock->expects($this->once()) + ->method('forExceptionJson') + ->with($this->isInstanceOf(OAuthServerException::class)) + ->willReturn($jsonResponse); + + $this->assertSame($jsonResponse, $this->sut()->par($requestMock)); + } + + public function testParReturnsGenericJsonErrorResponseForUnexpectedThrowable(): void + { + $requestMock = $this->createMock(Request::class); + $psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $psrHttpFactoryMock->method('createRequest')->willReturn($this->serverRequestMock); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($psrHttpFactoryMock); + + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willThrowException(new \RuntimeException('some internal error')); + + $jsonResponse = new JsonResponse(); + $this->errorResponderMock->expects($this->once()) + ->method('forExceptionJson') + ->with($this->callback( + // Internal error details must not leak to the client. + fn(OAuthServerException $exception): bool => + !str_contains($exception->getMessage(), 'some internal error') && + !str_contains((string)$exception->getHint(), 'some internal error'), + )) + ->willReturn($jsonResponse); + + $this->assertSame($jsonResponse, $this->sut()->par($requestMock)); + } +} diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index acca0582..5e82b3f1 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -221,6 +221,9 @@ public function testCanExportAsArray(): void 'expires_at' => null, 'is_generic' => false, 'id_token_signed_response_alg' => null, + 'require_pushed_authorization_requests' => false, + 'require_signed_request_object' => false, + 'request_uris' => [], ], ); } diff --git a/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php new file mode 100644 index 00000000..ad55707d --- /dev/null +++ b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php @@ -0,0 +1,119 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getParRequestUriTtl')->willReturn(new DateInterval('PT5M')); + $this->helpers = new Helpers(); + } + + protected function sut(): PushedAuthorizationRequestEntityFactory + { + return new PushedAuthorizationRequestEntityFactory( + $this->moduleConfigMock, + $this->helpers, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(PushedAuthorizationRequestEntityFactory::class, $this->sut()); + } + + public function testCanBuildNew(): void + { + $parameters = ['client_id' => 'client123', 'response_type' => 'code']; + + $entity = $this->sut()->fromData('client123', $parameters); + + $this->assertStringStartsWith( + PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX, + $entity->getRequestUri(), + ); + // Random part is 32 bytes, hex encoded. + $this->assertSame( + strlen(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX) + 64, + strlen($entity->getRequestUri()), + ); + $this->assertSame('client123', $entity->getClientId()); + $this->assertSame($parameters, $entity->getParameters()); + $this->assertFalse($entity->isConsumed()); + + $expectedExpiresAt = $this->helpers->dateTime()->getUtc()->add(new DateInterval('PT5M')); + $this->assertEqualsWithDelta( + $expectedExpiresAt->getTimestamp(), + $entity->getExpiresAt()->getTimestamp(), + 5, + ); + } + + public function testBuildNewGeneratesUniqueRequestUris(): void + { + $sut = $this->sut(); + + $this->assertNotSame( + $sut->fromData('client123', [])->getRequestUri(), + $sut->fromData('client123', [])->getRequestUri(), + ); + } + + public function testCanBuildFromState(): void + { + $entity = $this->sut()->fromState([ + 'request_uri' => PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123', + 'client_id' => 'client123', + 'parameters' => '{"response_type":"code"}', + 'expires_at' => '2026-01-01 12:00:00', + 'is_consumed' => 0, + ]); + + $this->assertSame( + PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123', + $entity->getRequestUri(), + ); + $this->assertSame('client123', $entity->getClientId()); + $this->assertSame(['response_type' => 'code'], $entity->getParameters()); + $this->assertFalse($entity->isConsumed()); + + // Stored datetimes are interpreted as UTC. + $this->assertSame( + '2026-01-01 12:00:00', + $entity->getExpiresAt()->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), + ); + } + + public function testFromStateThrowsForInvalidState(): void + { + $this->expectException(OpenIdException::class); + + $this->sut()->fromState([ + 'request_uri' => 123, + 'client_id' => 'client123', + 'parameters' => '{}', + 'expires_at' => '2026-01-01 12:00:00', + ]); + } +} diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 80e090a6..b9feca89 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -415,6 +415,48 @@ public function testCanGetProtocolCacheConfiguration(): void $this->assertInstanceOf(DateInterval::class, $this->sut()->getProtocolClientEntityCacheDuration()); } + public function testCanGetRequestUriParameterSupported(): void + { + // Default. + $this->assertTrue($this->sut()->getRequestUriParameterSupported()); + + $this->assertFalse( + $this->sut( + overrides: [ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => false], + )->getRequestUriParameterSupported(), + ); + } + + public function testGetFederationRequestUriAllowedPrefixesDeniesByDefault(): void + { + // Option absent -> deny all federation-candidate fetches (empty allowlist). + $this->assertSame([], $this->sut()->getFederationRequestUriAllowedPrefixes()); + } + + public function testGetFederationRequestUriAllowedPrefixesCanAllowAny(): void + { + // Explicit null -> allow any. + $this->assertNull( + $this->sut( + overrides: [ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => null], + )->getFederationRequestUriAllowedPrefixes(), + ); + } + + public function testGetFederationRequestUriAllowedPrefixesReturnsConfiguredPrefixes(): void + { + $sut = $this->sut( + overrides: [ + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [ + 'https://rp.example.org/', + 123, // non-string values are filtered out + ], + ], + ); + + $this->assertSame(['https://rp.example.org/'], $sut->getFederationRequestUriAllowedPrefixes()); + } + public function testCanGetProtocolDiscoveryShowClaimsSupported(): void { $this->assertFalse($this->sut()->getProtocolDiscoveryShowClaimsSupported()); diff --git a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php new file mode 100644 index 00000000..9191a7be --- /dev/null +++ b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php @@ -0,0 +1,244 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ]; + + Configuration::loadFromArray($config, '', 'simplesaml'); + (new DatabaseMigration())->migrate(); + } + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getParRequestUriTtl')->willReturn(new DateInterval('PT5M')); + $this->helpers = new Helpers(); + $this->entityFactory = new PushedAuthorizationRequestEntityFactory( + $this->moduleConfigMock, + $this->helpers, + ); + + $this->repository = new PushedAuthorizationRequestRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->entityFactory, + $this->helpers, + ); + } + + public function testGetTableName(): void + { + $this->assertSame('phpunit_oidc_par', $this->repository->getTableName()); + } + + public function testCanPersistAndFind(): void + { + $parameters = ['client_id' => 'client123', 'response_type' => 'code']; + $entity = $this->entityFactory->fromData('client123', $parameters); + + $this->repository->persist($entity); + + $foundEntity = $this->repository->find($entity->getRequestUri()); + + $this->assertInstanceOf(PushedAuthorizationRequestEntity::class, $foundEntity); + $this->assertSame($entity->getRequestUri(), $foundEntity->getRequestUri()); + $this->assertSame('client123', $foundEntity->getClientId()); + $this->assertSame($parameters, $foundEntity->getParameters()); + $this->assertSame( + $entity->getExpiresAt()->getTimestamp(), + $foundEntity->getExpiresAt()->getTimestamp(), + ); + $this->assertFalse($foundEntity->isConsumed()); + } + + public function testFindReturnsNullForUnknownRequestUri(): void + { + $this->assertNull( + $this->repository->find(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'unknown'), + ); + } + + public function testFindValidReturnsEntityForValidRequestUri(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $this->assertInstanceOf( + PushedAuthorizationRequestEntity::class, + $this->repository->findValid($entity->getRequestUri()), + ); + } + + public function testFindValidReturnsNullForExpiredRequestUri(): void + { + $entity = $this->entityFactory->fromData( + 'client123', + [], + $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1M')), + ); + $this->repository->persist($entity); + + $this->assertNull($this->repository->findValid($entity->getRequestUri())); + } + + public function testFindValidReturnsNullForConsumedRequestUri(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $this->assertTrue($this->repository->consume($entity->getRequestUri())); + + $this->assertNull($this->repository->findValid($entity->getRequestUri())); + } + + public function testConsumeReturnsTrueOnlyOnce(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $this->assertTrue($this->repository->consume($entity->getRequestUri())); + // Already consumed, so it can act as an atomic replay guard. + $this->assertFalse($this->repository->consume($entity->getRequestUri())); + } + + public function testConsumeReturnsFalseForUnknownRequestUri(): void + { + $this->assertFalse( + $this->repository->consume(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'unknown'), + ); + } + + public function testCanRemoveExpired(): void + { + $expiredEntity = $this->entityFactory->fromData( + 'client123', + [], + $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1M')), + ); + $this->repository->persist($expiredEntity); + $validEntity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($validEntity); + + $this->repository->removeExpired(); + + $this->assertNull($this->repository->find($expiredEntity->getRequestUri())); + $this->assertInstanceOf( + PushedAuthorizationRequestEntity::class, + $this->repository->find($validEntity->getRequestUri()), + ); + } + + protected function repositoryWithCache(MockObject $protocolCacheMock): PushedAuthorizationRequestRepository + { + return new PushedAuthorizationRequestRepository( + $this->moduleConfigMock, + Database::getInstance(), + $protocolCacheMock, + $this->entityFactory, + $this->helpers, + ); + } + + public function testPersistStoresEntityInCache(): void + { + $entity = $this->entityFactory->fromData('client123', []); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->expects($this->once())->method('set') + ->with( + $entity->getState(), + $this->callback('is_int'), + 'phpunit_oidc_par_' . $entity->getRequestUri(), + ); + + $this->repositoryWithCache($protocolCacheMock)->persist($entity); + } + + public function testFindCanReturnEntityFromCache(): void + { + // Note: this entity is intentionally not persisted to database, so a successful find proves the + // cache path was used. + $entity = $this->entityFactory->fromData('client123', ['response_type' => 'code']); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('get')->willReturn($entity->getState()); + + $foundEntity = $this->repositoryWithCache($protocolCacheMock)->find($entity->getRequestUri()); + + $this->assertInstanceOf(PushedAuthorizationRequestEntity::class, $foundEntity); + $this->assertSame($entity->getRequestUri(), $foundEntity->getRequestUri()); + $this->assertSame(['response_type' => 'code'], $foundEntity->getParameters()); + } + + public function testFindCachesEntityResolvedFromDatabase(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('get')->willReturn(null); + $protocolCacheMock->expects($this->once())->method('set') + ->with( + $entity->getState(), + $this->callback('is_int'), + 'phpunit_oidc_par_' . $entity->getRequestUri(), + ); + + $this->assertInstanceOf( + PushedAuthorizationRequestEntity::class, + $this->repositoryWithCache($protocolCacheMock)->find($entity->getRequestUri()), + ); + } + + public function testConsumeInvalidatesCache(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->expects($this->once())->method('delete') + ->with('phpunit_oidc_par_' . $entity->getRequestUri()); + + $this->assertTrue($this->repositoryWithCache($protocolCacheMock)->consume($entity->getRequestUri())); + } +} diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 56f0679a..3c16ede0 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -11,6 +11,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; @@ -22,23 +23,29 @@ use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Core\RequestObject; +use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; +use SimpleSAML\OpenID\RequestObject\RequestObjectBag; #[CoversClass(RequestObjectRule::class)] class RequestObjectRuleTest extends TestCase { - protected Stub $clientStub; + protected MockObject $clientStub; protected Stub $resultBagStub; protected MockObject $requestParamsResolverMock; protected MockObject $requestObjectMock; + protected MockObject $jarRequestObjectMock; + protected MockObject $requestObjectBagMock; protected Stub $requestStub; protected Stub $loggerServiceStub; protected MockObject $jwksResolverMock; protected Helpers $helpers; protected Stub $responseModeStub; + protected Stub $moduleConfigStub; protected function setUp(): void { - $this->clientStub = $this->createStub(ClientEntityInterface::class); + $this->clientStub = $this->createMock(ClientEntityInterface::class); + $this->clientStub->method('getIdentifier')->willReturn('client123'); $this->resultBagStub = $this->createStub(ResultBag::class); $this->resultBagStub->method('getOrFail')->willReturnMap([ [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], @@ -47,29 +54,64 @@ protected function setUp(): void $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); $this->requestObjectMock = $this->createMock(RequestObject::class); $this->requestObjectMock->method('getPayload')->willReturn(['payload']); + $this->jarRequestObjectMock = $this->createMock(JarRequestObject::class); + $this->jarRequestObjectMock->method('getPayload')->willReturn(['payload']); + $this->requestObjectBagMock = $this->createMock(RequestObjectBag::class); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->jwksResolverMock = $this->createMock(JwksResolver::class); $this->helpers = new Helpers(); $this->responseModeStub = $this->createStub(ResponseModeInterface::class); + $this->moduleConfigStub = $this->createStub(ModuleConfig::class); } protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, ?JwksResolver $jwksResolver = null, + ?ModuleConfig $moduleConfig = null, ): RequestObjectRule { $requestParamsResolver ??= $this->requestParamsResolverMock; $helpers ??= $this->helpers; $jwksResolver ??= $this->jwksResolverMock; + $moduleConfig ??= $this->moduleConfigStub; return new RequestObjectRule( $requestParamsResolver, $helpers, $jwksResolver, + $moduleConfig, ); } + protected function prepareOidcRequest(): void + { + // A `request` param signals a Request Object is present (by value). + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + // OpenID Connect request is designated by the openid scope. + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); + $this->requestObjectBagMock->method('get') + ->willReturnMap([ + [RequestObject::class, $this->requestObjectMock], + ]); + $this->requestParamsResolverMock->method('getRequestObjectBag') + ->willReturn($this->requestObjectBagMock); + } + + protected function prepareOAuth2Request(?JarRequestObject $jarRequestObject = null): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + // No openid scope, so this is a plain OAuth 2.0 request (JAR rules apply). + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('profile'); + $this->requestObjectBagMock->method('get') + ->willReturnMap([ + [RequestObject::class, $this->requestObjectMock], + [JarRequestObject::class, $jarRequestObject], + ]); + $this->requestParamsResolverMock->method('getRequestObjectBag') + ->willReturn($this->requestObjectBagMock); + } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestObjectRule::class, $this->sut()); @@ -87,12 +129,26 @@ public function testRequestParamCanBeAbsent(): void $this->assertNull($result); } - public function testUnprotectedRequestParamCanBeUsed(): void + public function testThrowsWhenRequestObjectSourceIsPresentButBagCannotBeResolved(): void { + // `request` param present (source present), but the resolver could not parse/fetch it (null bag). $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->requestParamsResolverMock->method('getRequestObjectBag')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testUnprotectedRequestParamCanBeUsedForOidcRequest(): void + { + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(false); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $result = $this->sut()->checkRule( $this->requestStub, @@ -108,11 +164,10 @@ public function testUnprotectedRequestParamCanBeUsed(): void public function testMissingClientJwksThrows(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(true); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); - $this->clientStub->expects($this->once())->method('getJwks')->willReturn(null); + $this->jwksResolverMock->expects($this->once())->method('forClient') + ->with($this->clientStub)->willReturn(null); $this->expectException(OidcServerException::class); $this->sut()->checkRule( @@ -126,12 +181,10 @@ public function testMissingClientJwksThrows(): void public function testThrowsForInvalidRequestObject(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(true); $this->requestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']) ->willThrowException(OidcServerException::accessDenied()); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $this->jwksResolverMock->expects($this->once())->method('forClient') ->with($this->clientStub) ->willReturn(['jwks']); @@ -148,11 +201,213 @@ public function testThrowsForInvalidRequestObject(): void public function testReturnsValidRequestObject(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(true); $this->requestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); + + $this->jwksResolverMock->expects($this->once()) + ->method('forClient') + ->with($this->clientStub) + ->willReturn(['jwks']); + + $result = $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + + $this->assertInstanceOf(Result::class, $result); + $this->assertIsArray($result->getValue()); + $this->assertNotEmpty($result->getValue()); + } + + public function testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + + $this->moduleConfigStub->method('getRequireSignedRequestObject')->willReturn(true); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + + $this->moduleConfigStub->method('getRequireSignedRequestObject')->willReturn(false); + $this->clientStub->method('getRequireSignedRequestObject')->willReturn(true); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testAcceptsOidcRequestWhenAudienceIncludesIssuer(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getAudience')->willReturn(['https://op.example.org/']); + $this->moduleConfigStub->method('getIssuer')->willReturn('https://op.example.org/'); + + $result = $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + + $this->assertInstanceOf(Result::class, $result); + } + + public function testThrowsForOidcRequestWhenAudienceDoesNotIncludeIssuer(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getAudience')->willReturn(['https://other-op.example.org/']); + $this->moduleConfigStub->method('getIssuer')->willReturn('https://op.example.org/'); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWhenAudienceDoesNotIncludeIssuer(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); + $this->jarRequestObjectMock->method('verifyWithKeySet')->with(['jwks']); + $this->jarRequestObjectMock->method('getAudience')->willReturn(['https://other-op.example.org/']); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->jwksResolverMock->method('forClient')->with($this->clientStub)->willReturn(['jwks']); + $this->moduleConfigStub->method('getIssuer')->willReturn('https://op.example.org/'); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testAcceptsOidcRequestWhenIssuerMatchesClient(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getIssuer')->willReturn('client123'); + + $result = $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + + $this->assertInstanceOf(Result::class, $result); + } + + public function testThrowsForOidcRequestWhenIssuerDoesNotMatchClient(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getIssuer')->willReturn('otherClient'); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWhenIssuerDoesNotMatchClient(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); + $this->jarRequestObjectMock->method('verifyWithKeySet')->with(['jwks']); + $this->jarRequestObjectMock->method('getIssuer')->willReturn('otherClient'); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->jwksResolverMock->method('forClient')->with($this->clientStub)->willReturn(['jwks']); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWithNonJarRequestObject(): void + { + // For example, an unsigned Request Object is not a valid JAR Request Object. + $this->prepareOAuth2Request(null); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWithMismatchedClientIdClaim(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('otherClient'); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testReturnsValidJarRequestObjectForOAuth2Request(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); + $this->jarRequestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']); + $this->prepareOAuth2Request($this->jarRequestObjectMock); $this->jwksResolverMock->expects($this->once()) ->method('forClient') diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php new file mode 100644 index 00000000..106664b8 --- /dev/null +++ b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php @@ -0,0 +1,289 @@ +clientMock = $this->createMock(ClientEntityInterface::class); + $this->clientMock->method('getIdentifier')->willReturn('client123'); + $this->resultBagMock = $this->createMock(ResultBag::class); + $this->resultBagMock->method('getOrFail')->willReturnMap([ + [ClientRule::class, new Result(ClientRule::class, $this->clientMock)], + ]); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( + PushedAuthorizationRequestRepository::class, + ); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); + $this->parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); + $this->requestStub = $this->createStub(ServerRequestInterface::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + $this->responseModeStub = $this->createStub(ResponseModeInterface::class); + } + + protected function sut(): RequestUriRule + { + return new RequestUriRule( + $this->requestParamsResolverMock, + $this->helpers, + $this->pushedAuthorizationRequestRepositoryMock, + $this->moduleConfigMock, + ); + } + + /** + * Set raw request params which will be resolved from the request itself (not the merged view). + * + * @param array $params + */ + protected function prepareRawParams(array $params): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnCallback(fn(string $paramKey): ?string => $params[$paramKey] ?? null); + } + + protected function checkRule(): mixed + { + return $this->sut()->checkRule( + $this->requestStub, + $this->resultBagMock, + $this->loggerServiceMock, + [], + $this->responseModeStub, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(RequestUriRule::class, $this->sut()); + } + + public function testRequestUriParamCanBeAbsent(): void + { + $this->prepareRawParams([]); + + $this->assertNull($this->checkRule()); + } + + public function testThrowsIfParIsRequiredGloballyButNotUsed(): void + { + $this->prepareRawParams([]); + $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfParIsRequiredForClientButNotUsed(): void + { + $this->prepareRawParams([]); + $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(false); + $this->clientMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfRequestAndRequestUriAreBothPresent(): void + { + $this->prepareRawParams([ + 'request_uri' => self::PAR_REQUEST_URI, + 'request' => 'token', + 'client_id' => 'client123', + ]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfClientIdParamIsMissing(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsForInvalidRequestUriScheme(): void + { + $this->prepareRawParams(['request_uri' => 'urn:other:thing', 'client_id' => 'client123']); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsNotFound(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsExpired(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(true); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsConsumed(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(true); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsBoundToDifferentClient(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(false); + $this->parEntityMock->method('getClientId')->willReturn('otherClient'); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestConsumptionFails(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(false); + $this->parEntityMock->method('getClientId')->willReturn('client123'); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + $this->pushedAuthorizationRequestRepositoryMock->method('consume')->willReturn(false); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testCanUseValidPushedAuthorizationRequestUri(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(false); + $this->parEntityMock->method('getClientId')->willReturn('client123'); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + // Request URI is consumed at validation time (one-time use). + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once()) + ->method('consume') + ->with(self::PAR_REQUEST_URI) + ->willReturn(true); + + $result = $this->checkRule(); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(self::PAR_REQUEST_URI, $result->getValue()); + } + + public function testThrowsForHttpsRequestUriIfParIsRequired(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsForHttpsRequestUriIfNotSupported(): void + { + // Override the default (true) set in setUp via a fresh module config mock. + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(false); + + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + + $sut = new RequestUriRule( + $this->requestParamsResolverMock, + $this->helpers, + $this->pushedAuthorizationRequestRepositoryMock, + $moduleConfigMock, + ); + + $this->expectException(OidcServerException::class); + $sut->checkRule( + $this->requestStub, + $this->resultBagMock, + $this->loggerServiceMock, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForUnresolvableHttpsRequestUri(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + // Resolver could not fetch/parse (or policy denied the fetch) -> null bag. + $this->requestParamsResolverMock->method('getRequestObjectBag')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testCanUseResolvableHttpsRequestUri(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + // Resolver produced a bag; signature/flavor validation is RequestObjectRule's job, not this rule's. + $this->requestParamsResolverMock->method('getRequestObjectBag') + ->willReturn($this->createMock(RequestObjectBag::class)); + + $result = $this->checkRule(); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(self::HTTPS_REQUEST_URI, $result->getValue()); + } +} diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index e6024104..bcdea1e9 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -48,6 +48,7 @@ public function setUp(): void RoutesEnum::UserInfo->value => 'http://localhost/userinfo', RoutesEnum::Jwks->value => 'http://localhost/jwks', RoutesEnum::EndSession->value => 'http://localhost/end-session', + RoutesEnum::PushedAuthorizationRequest->value => 'http://localhost/par', ]; return $paths[$path] ?? null; @@ -80,6 +81,8 @@ public function setUp(): void $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); + + $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); } /** @@ -137,7 +140,10 @@ public function testItReturnsExpectedMetadata(): void ], 'request_parameter_supported' => true, 'request_object_signing_alg_values_supported' => ['none', 'RS256'], - 'request_uri_parameter_supported' => false, + 'request_uri_parameter_supported' => true, + 'require_request_uri_registration' => true, + 'pushed_authorization_request_endpoint' => 'http://localhost/par', + 'require_pushed_authorization_requests' => false, 'grant_types_supported' => ['authorization_code', 'refresh_token'], 'claims_parameter_supported' => true, 'acr_values_supported' => ['1'], diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php index b788ec7c..2557c747 100644 --- a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -67,6 +67,7 @@ protected function setUp(): void ->willReturnMap([ [RoutesEnum::Token->value, self::TOKEN_ENDPOINT], [RoutesEnum::Authorization->value, 'https://example.org/oidc/authorization.php'], + [RoutesEnum::PushedAuthorizationRequest->value, 'https://example.org/oidc/par'], ]); $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index a183bf91..136206d4 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -9,13 +9,24 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Core\Factories\RequestObjectFactory; use SimpleSAML\OpenID\Core\RequestObject; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\RequestObject as RequestObjectFacade; +use SimpleSAML\OpenID\RequestObject\RequestObjectBag; +use SimpleSAML\OpenID\RequestObject\RequestObjectParser; #[CoversClass(RequestParamsResolver::class)] class RequestParamsResolverTest extends TestCase @@ -28,6 +39,12 @@ class RequestParamsResolverTest extends TestCase protected MockObject $requestObjectFactoryMock; protected MockObject $federationMock; protected MockObject $psrHttpBridgeMock; + protected MockObject $requestObjectFacadeMock; + protected MockObject $requestObjectParserMock; + protected MockObject $moduleConfigMock; + protected MockObject $clientRepositoryMock; + protected MockObject $pushedAuthorizationRequestRepositoryMock; + protected MockObject $loggerServiceMock; protected array $queryParams = [ 'a' => 'b', @@ -57,6 +74,19 @@ protected function setUp(): void $this->coreMock->method('requestObjectFactory')->willReturn($this->requestObjectFactoryMock); $this->federationMock = $this->createMock(Federation::class); $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->requestObjectParserMock = $this->createMock(RequestObjectParser::class); + $this->requestObjectFacadeMock = $this->createMock(RequestObjectFacade::class); + $this->requestObjectFacadeMock->method('requestObjectParser') + ->willReturn($this->requestObjectParserMock); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); + $this->moduleConfigMock->method('getRequestUriFetchTimeout')->willReturn(5); + $this->moduleConfigMock->method('getRequestUriMaxSizeBytes')->willReturn(102400); + $this->clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( + PushedAuthorizationRequestRepository::class, + ); + $this->loggerServiceMock = $this->createMock(LoggerService::class); } protected function mock( @@ -70,7 +100,36 @@ protected function mock( $federationMock ??= $this->federationMock; $psrHttpBridgeMock ??= $this->psrHttpBridgeMock; - return new RequestParamsResolver($helpersMock, $coreMock, $federationMock, $psrHttpBridgeMock); + return new RequestParamsResolver( + $helpersMock, + $coreMock, + $federationMock, + $psrHttpBridgeMock, + $this->requestObjectFacadeMock, + $this->moduleConfigMock, + $this->clientRepositoryMock, + $this->pushedAuthorizationRequestRepositoryMock, + $this->loggerServiceMock, + ); + } + + protected function bagWithCore(): MockObject + { + $bag = $this->createMock(RequestObjectBag::class); + $bag->method('get')->willReturnMap([[RequestObject::class, $this->requestObjectMock]]); + + return $bag; + } + + protected function helpersWithParams(array $params): MockObject + { + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($params); + $httpHelperMock->method('getAllRequestParamsBasedOnAllowedMethods')->willReturn($params); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + return $helpersMock; } public function testCanCreateInstance(): void @@ -108,12 +167,9 @@ public function testCanGetAllWithNoRequestObject(): void public function testCanGetAllWithRequestObject(): void { $queryParams = [...$this->queryParams, 'request' => 'token']; + $helpersMock = $this->helpersWithParams($queryParams); - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); - $this->requestObjectMock->expects($this->once())->method('getPayload'); + $this->requestObjectParserMock->method('fromToken')->with('token')->willReturn($this->bagWithCore()); $this->assertSame( array_merge($queryParams, $this->requestObjectParams), @@ -160,4 +216,242 @@ public function testCanGetFromRequestBasedOnAllowedMethods(): void $this->mock()->getFromRequestBasedOnAllowedMethods('a', $this->requestMock), ); } + + public function testCanGetAllWithPushedAuthorizationRequestUri(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + $helpersMock = $this->helpersWithParams($queryParams); + + $parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); + $parEntityMock->method('getParameters')->willReturn($this->requestObjectParams); + + // Resolution is memoized, so the repository is queried only once across repeated getAll() calls. + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once()) + ->method('findValid') + ->with($requestUri) + ->willReturn($parEntityMock); + + $sut = $this->mock($helpersMock); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $sut->getAll($this->requestMock), + ); + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $sut->getAll($this->requestMock), + ); + } + + public function testGetAllResolvesNothingForInvalidPushedAuthorizationRequestUri(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->pushedAuthorizationRequestRepositoryMock->method('findValid')->willReturn(null); + + $this->assertSame( + $queryParams, + $this->mock($helpersMock)->getAll($this->requestMock), + ); + } + + public function testGetAllSkipsRequestUriResolutionIfRequestParamIsAlsoPresent(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'request' => 'token']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->requestObjectParserMock->method('fromToken')->willReturn($this->bagWithCore()); + $this->pushedAuthorizationRequestRepositoryMock->expects($this->never())->method('findValid'); + + $this->mock($helpersMock)->getAll($this->requestMock); + } + + public function testCanGetAllWithHttpsRequestUriForRegisteredClient(): void + { + $requestUri = 'https://client.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; + $helpersMock = $this->helpersWithParams($queryParams); + + $clientEntityMock = $this->createMock(ClientEntityInterface::class); + $clientEntityMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Manual); + $clientEntityMock->method('getRequestUris')->willReturn([$requestUri]); + $this->clientRepositoryMock->method('getClientEntity')->with('client123')->willReturn($clientEntityMock); + + // Fetch is memoized, so the request object is fetched only once across repeated getAll() calls. + $this->requestObjectParserMock->expects($this->once()) + ->method('fromRequestUri') + ->with($requestUri) + ->willReturn($this->bagWithCore()); + + $sut = $this->mock($helpersMock); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $sut->getAll($this->requestMock), + ); + $sut->getAll($this->requestMock); + } + + public function testGetAllDoesNotFetchHttpsRequestUriIfNotRegisteredForClient(): void + { + $requestUri = 'https://client.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; + $helpersMock = $this->helpersWithParams($queryParams); + + $clientEntityMock = $this->createMock(ClientEntityInterface::class); + $clientEntityMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Manual); + $clientEntityMock->method('getRequestUris')->willReturn(['https://client.example.org/other.jwt']); + $this->clientRepositoryMock->method('getClientEntity')->willReturn($clientEntityMock); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testGetAllDoesNotFetchHttpsRequestUriIfNotSupported(): void + { + $requestUri = 'https://client.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; + $helpersMock = $this->helpersWithParams($queryParams); + + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(false); + $this->moduleConfigMock = $moduleConfigMock; + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testCanFetchHttpsRequestUriForFederationClient(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + // Federation candidate: client not in storage, federation enabled, request_uri allowed (null = allow + // any) -> fetch is allowed (trust is validated after the fetch, in ClientRule). + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes')->willReturn(null); + + $this->requestObjectParserMock->expects($this->once()) + ->method('fromRequestUri') + ->with($requestUri) + ->willReturn($this->bagWithCore()); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $this->mock($helpersMock)->getAll($this->requestMock), + ); + } + + public function testCanFetchHttpsRequestUriForFederationClientWithAllowedPrefix(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes') + ->willReturn(['https://rp.example.org/']); + + $this->requestObjectParserMock->expects($this->once()) + ->method('fromRequestUri') + ->with($requestUri) + ->willReturn($this->bagWithCore()); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $this->mock($helpersMock)->getAll($this->requestMock), + ); + } + + public function testDoesNotFetchHttpsRequestUriForFederationClientWithDisallowedPrefix(): void + { + $requestUri = 'https://attacker.example.org/request-object.jwt'; + $queryParams = [ + ...$this->queryParams, + 'request_uri' => $requestUri, + 'client_id' => 'https://attacker.example.org', + ]; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes') + ->willReturn(['https://rp.example.org/']); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testDoesNotFetchHttpsRequestUriForFederationClientWhenPrefixListIsEmpty(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + // Empty allowlist (the default) denies all federation-candidate fetches. + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes')->willReturn([]); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testDoesNotFetchHttpsRequestUriForUnknownClientWhenFederationDisabled(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(false); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testGetRequestObjectBagForRequestParam(): void + { + $queryParams = [...$this->queryParams, 'request' => 'token']; + $helpersMock = $this->helpersWithParams($queryParams); + + $bag = $this->bagWithCore(); + $this->requestObjectParserMock->method('fromToken')->with('token')->willReturn($bag); + + $this->assertSame( + $bag, + $this->mock($helpersMock)->getRequestObjectBag($this->requestMock, [HttpMethodsEnum::GET]), + ); + } + + public function testGetRequestObjectBagReturnsNullForParUrn(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + + $this->assertNull( + $this->mock($this->helpersWithParams($queryParams)) + ->getRequestObjectBag($this->requestMock, [HttpMethodsEnum::GET]), + ); + } + + public function testGetRequestObjectBagReturnsNullWhenNoSource(): void + { + $this->assertNull( + $this->mock($this->helpersWithParams($this->queryParams)) + ->getRequestObjectBag($this->requestMock, [HttpMethodsEnum::GET]), + ); + } }