diff --git a/ci/qa-config/rector.php b/ci/qa-config/rector.php index 687ba884ee..ffe582ce30 100644 --- a/ci/qa-config/rector.php +++ b/ci/qa-config/rector.php @@ -33,4 +33,5 @@ \Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector::class, \Rector\Php81\Rector\Property\ReadOnlyPropertyRector::class, \Rector\DeadCode\Rector\StaticCall\RemoveParentCallWithoutParentRector::class, + \Rector\Php82\Rector\Class_\ReadOnlyClassRector::class, ]); diff --git a/config/packages/engineblock_features.yaml b/config/packages/engineblock_features.yaml index 467d8c1908..8e7ed423c1 100644 --- a/config/packages/engineblock_features.yaml +++ b/config/packages/engineblock_features.yaml @@ -15,3 +15,4 @@ parameters: eb.feature_enable_idp_initiated_flow: "%feature_enable_idp_initiated_flow%" eb.stepup.sfo.override_engine_entityid: "%feature_stepup_sfo_override_engine_entityid%" eb.stepup.send_user_attributes: "%feature_stepup_send_user_attributes%" + eb.feature_enable_sram_interrupt: "%feature_enable_sram_interrupt%" diff --git a/config/packages/parameters.yml.dist b/config/packages/parameters.yml.dist index f8fe5741e8..cfc3509c5c 100644 --- a/config/packages/parameters.yml.dist +++ b/config/packages/parameters.yml.dist @@ -223,6 +223,7 @@ parameters: feature_enable_idp_initiated_flow: true feature_stepup_sfo_override_engine_entityid: false feature_stepup_send_user_attributes: false + feature_enable_sram_interrupt: false ########################################################################################## ## PROFILE SETTINGS @@ -307,3 +308,20 @@ parameters: # used in the authentication log record. The attributeName will be searched in the response attributes and if present # the log data will be enriched. The values of the response attributes are the final values after ARP and Attribute Manipulation. auth.log.attributes: [] + + ########################################################################################## + ## SRAM Settings + ########################################################################################## + ## Config for connecting with SBS server + ## base_url must end with /. Locations must not start with /. + sram.api_token: xxx + sram.base_url: 'https://engine.dev.openconext.local/functional-testing/' + sram.authz_location: authz + sram.attributes_location: attributes + sram.interrupt_location: interrupt + sram.verify_peer: false + sram.allowed_attributes: + - 'urn:mace:dir:attribute-def:eduPersonEntitlement' + - 'urn:mace:dir:attribute-def:eduPersonPrincipalName' + - 'urn:mace:dir:attribute-def:uid' + - 'urn:oid:1.3.6.1.4.1.24552.500.1.1.1.13' diff --git a/config/routes/functional_testing/functional_testing.yml b/config/routes/functional_testing/functional_testing.yml index ccf8164843..0ef35f93e5 100644 --- a/config/routes/functional_testing/functional_testing.yml +++ b/config/routes/functional_testing/functional_testing.yml @@ -69,3 +69,18 @@ functional_testing_gateway: path: "/gateway/second-factor-only/single-sign-on" defaults: _controller: engineblock.functional_test.controller.stepup_mock::ssoAction + +functional_testing_sram_authz: + path: "/authz" + defaults: + _controller: engineblock.functional_test.controller.sbs::authzAction + +functional_testing_sram_interrupt: + path: "/interrupt" + defaults: + _controller: engineblock.functional_test.controller.sbs::interruptAction + +functional_testing_sram_attributes: + path: "/attributes" + defaults: + _controller: engineblock.functional_test.controller.sbs::attributesAction diff --git a/config/services/ci/controllers.yml b/config/services/ci/controllers.yml index dfc1a56123..27a303cbc7 100644 --- a/config/services/ci/controllers.yml +++ b/config/services/ci/controllers.yml @@ -50,3 +50,9 @@ services: - '@OpenConext\EngineBlock\Validator\UnsolicitedSsoRequestValidator' - '@OpenConext\EngineBlock\Service\AuthenticationStateHelper' - '@engineblock.functional_testing.fixture.features' + + engineblock.functional_test.controller.sbs: + class: OpenConext\EngineBlockFunctionalTestingBundle\Controllers\SbsController + arguments: + - '@engineblock.functional_testing.fixture.sbs_client_state_manager' + - '@engineblock.functional_testing.data_store.sbs_server_state' diff --git a/config/services/ci/services.yml b/config/services/ci/services.yml index 3a1c81c6e2..d7a6a75f4e 100644 --- a/config/services/ci/services.yml +++ b/config/services/ci/services.yml @@ -7,6 +7,8 @@ parameters: engineblock.functional_testing.attribute_aggregation_data_store.file: "/tmp/eb-fixtures/attribute_aggregation.json" engineblock.functional_testing.stepup_gateway_mock_data_store.file: "/tmp/eb-fixtures/stepup_gateway_mock.json" engineblock.functional_testing.translator_mock_data_store.file: "/tmp/eb-fixtures/translator_mock.json" + engineblock.functional_testing.sbs_client_state_manager_data_store.file: "/tmp/eb-fixtures/sbs_client_state_manager.json" + engineblock.functional_testing.sbs_controller_data_store.file: "/tmp/eb-fixtures/sbs_server_state.json" services: _defaults: @@ -58,6 +60,11 @@ services: - '@engineblock.mock_entities.sp_factory' - "@engineblock.compat.application" + engineblock.functional_testing.fixture.sbs_client_state_manager: + class: OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\SbsClientStateManager + arguments: + - "@engineblock.functional_testing.data_store.sbs_client_state_mananger" + #endregion Fixtures #region Data Stores @@ -77,6 +84,14 @@ services: class: OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\DataStore\JsonDataStore arguments: ['%engineblock.functional_testing.authentication_loop_guard_data_store.file%'] + engineblock.functional_testing.data_store.sbs_client_state_mananger: + class: OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\DataStore\JsonDataStore + arguments: ['%engineblock.functional_testing.sbs_client_state_manager_data_store.file%'] + + engineblock.functional_testing.data_store.sbs_server_state: + class: OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\DataStore\JsonDataStore + arguments: [ '%engineblock.functional_testing.sbs_controller_data_store.file%' ] + engineblock.function_testing.data_store.attribute_aggregation_client: class: OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\DataStore\JsonDataStore arguments: ['%engineblock.functional_testing.attribute_aggregation_data_store.file%'] diff --git a/config/services/services.yml b/config/services/services.yml index 9896af82a0..a5afac948b 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -342,3 +342,33 @@ services: symfony.mailer: public: true alias: mailer + + engineblock.sbs.sbs_client: + class: OpenConext\EngineBlockBundle\Sbs\SbsClient + arguments: + - "@engineblock.sbs.http_client" + - "%sram.base_url%" + - "%sram.authz_location%" + - "%sram.attributes_location%" + - "%sram.interrupt_location%" + - "%sram.api_token%" + - "%sram.verify_peer%" + + engineblock.sbs.http_client: + class: OpenConext\EngineBlock\Http\HttpClient + arguments: + - "@engineblock.sbs.guzzle_http_client" + + engineblock.sbs.guzzle_http_client: + class: GuzzleHttp\Client + arguments: + - base_uri: "%sram.base_url%/" + options: + headers: + Authentication: "%sram.api_token%" + timeout: "%http_client.timeout%" + + engineblock.sbs.attribute_merger: + class: OpenConext\EngineBlockBundle\Sbs\SbsAttributeMerger + arguments: + - "%sram.allowed_attributes%" diff --git a/config/services_ci.yaml b/config/services_ci.yaml index c2b9a59d0c..74d2666f86 100644 --- a/config/services_ci.yaml +++ b/config/services_ci.yaml @@ -67,3 +67,7 @@ services: OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext: tags: ['fob.context'] + + OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\SbsClientStateManager: + arguments: + - "@engineblock.functional_testing.data_store.sbs_client_state_mananger" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5c8f9bd3b8..959903ab61 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,6 @@ services: mariadb: image: mariadb:10.6 - restart: always container_name: eb-db-test environment: MYSQL_ROOT_PASSWORD: "root" @@ -62,5 +61,4 @@ services: - ../theme:/theme volumes: - eb-mysql-data: eb-mysql-test-data: diff --git a/docs/filter_commands.md b/docs/filter_commands.md index 07a5807dfe..3ba4328606 100644 --- a/docs/filter_commands.md +++ b/docs/filter_commands.md @@ -1,8 +1,8 @@ # EngineBlock Input and Output Command Chains EngineBlock pre-processes incoming and outgoing SAML Responses using so-called Filters. These filters provide specific, -critical functionality, by invoking a sequence of Filter Commands. However, it is not easily discoverable what these -Filters and Filter Commands exactly do and how they work. This document outlines how these Filters and Filter Commands +critical functionality, by invoking a sequence of Filter Commands. However, it is not easily discoverable what these +Filters and Filter Commands exactly do and how they work. This document outlines how these Filters and Filter Commands work and what each filter command does. The chains are: @@ -13,11 +13,11 @@ The specific commands can be found in the [`library\EngineBlock\Corto\Filter\Com ## Input and Output Filters -These are called by [`ProxyServer`][ps], through [`filterOutputAssertionAttributes`][fOAA] and +These are called by [`ProxyServer`][ps], through [`filterOutputAssertionAttributes`][fOAA] and [`filterInputAssertionAttributes`][fIAA] calling [`callAttributeFilter`][cAF], which invokes the actual Filter Commands. Each Filter then executes Filter Commands in a specified order for Input (between receiving Assertion from IdP and -Consent) and Output (after Consent, before sending Response to SP). +Consent) and Output (after Consent, before sending Response to SP). What the filter does is: ``` Loop over given Filter Commands, for each Command: @@ -30,7 +30,7 @@ Loop over given Filter Commands, for each Command: set the collabPersonId (either: string stored in session, string found in Response, string found in responseAttributes, string found in nameId response or null, in that order) execute the command ``` -During the loop, the Response, responseAttributes and collabPersonId are retrieved from the previous command and are +During the loop, the Response, responseAttributes and collabPersonId are retrieved from the previous command and are used by the commands that follows. A command can also stop filtering by calling `$this->stopFiltering();` @@ -67,7 +67,7 @@ Uses: - EngineBlock_Saml2_ResponseAnnotationDecorator - responseAttributes -### NormalizeAttributes +### NormalizeAttributes Convert all OID attributes to URN and remove the OID variant Depends on: @@ -193,7 +193,7 @@ Modifies: See: [Engineblock Attribute Aggregation](attribute_aggregation.md) for more information. ### EnforcePolicy -Makes a call to the external PolicyDecisionPoint service. This returns a response which details whether or not the +Makes a call to the external PolicyDecisionPoint service. This returns a response which details whether or not the current User is allowed access to the Service Provider. For more information see [the PDP repository README][pdp-repo] Depends On: @@ -343,8 +343,18 @@ Uses: - OpenConext\EngineBlock\Metadata\Entity\IdentityProvider - EngineBlock_Saml2_AuthnRequestAnnotationDecorator +### SRAM test filter +SRAM integration. +In order to facilitate fine-grained access to SRAM, EB integrates with SRAM through the SBS service. +This process is only enabled if both the `feature_enable_sram_interrupt` feature flag is enabled and the `collabEnabled` coin of the SP is true. +If enabled, the SramInterruptFilter will call SBS with the sessionId. +If the sessionId is known in SBS, EB will merge the attributes supplied by SBS into the Auth request. +IF the sessionId is unknown, later in the Consume Assertion process, the browser will be redirected to SBS, +which will redirect back to EB after a successful check. Then the attributes from SBS will be merged after all. + +See https://github.com/OpenConext/OpenConext-engineblock/issues/1804 for details. [input]: https://github.com/OpenConext/OpenConext-engineblock/tree/master/library/EngineBlock/Corto/Filter/Input.php diff --git a/library/EngineBlock/Application/DiContainer.php b/library/EngineBlock/Application/DiContainer.php index b4ded1095f..85367ae01a 100644 --- a/library/EngineBlock/Application/DiContainer.php +++ b/library/EngineBlock/Application/DiContainer.php @@ -26,6 +26,8 @@ use OpenConext\EngineBlock\Stepup\StepupEntityFactory; use OpenConext\EngineBlock\Stepup\StepupGatewayCallOutHelper; use OpenConext\EngineBlock\Validator\AllowedSchemeValidator; +use OpenConext\EngineBlockBundle\Sbs\SbsAttributeMerger; +use OpenConext\EngineBlockBundle\Sbs\SbsClientInterface; use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface; use Symfony\Component\Mailer\MailerInterface; use Twig\Environment; @@ -309,6 +311,16 @@ protected function getSymfonyContainer() return $this->container; } + public function getSbsAttributeMerger(): SbsAttributeMerger + { + return $this->container->get('engineblock.sbs.attribute_merger'); + } + + public function getSbsClient(): SbsClientInterface + { + return $this->container->get('engineblock.sbs.sbs_client'); + } + public function getPdpClient() { return $this->container->get(\OpenConext\EngineBlockBundle\Pdp\PdpClient::class); diff --git a/library/EngineBlock/Application/ErrorHandler.php b/library/EngineBlock/Application/ErrorHandler.php index d33eb4cdbd..83c0be8c3b 100644 --- a/library/EngineBlock/Application/ErrorHandler.php +++ b/library/EngineBlock/Application/ErrorHandler.php @@ -62,7 +62,6 @@ public function exception(Throwable $e) foreach ($this->_exitHandlers as $exitHandler) { $exitHandler($e); } - throw $e; $this->_application->reportError($e); diff --git a/library/EngineBlock/Application/TestDiContainer.php b/library/EngineBlock/Application/TestDiContainer.php index a2957d1427..68d39f0c35 100644 --- a/library/EngineBlock/Application/TestDiContainer.php +++ b/library/EngineBlock/Application/TestDiContainer.php @@ -16,7 +16,6 @@ * limitations under the License. */ -use OpenConext\EngineBlock\Stepup\StepupEndpoint; use OpenConext\EngineBlockBundle\Pdp\PdpClientInterface; /** @@ -49,7 +48,7 @@ public function getPdpClient() return $this->pdpClient ?? parent::getPdpClient(); } - public function setPdpClient(PdpClientInterface $pdpClient) + public function setPdpClient(?PdpClientInterface $pdpClient) { $this->pdpClient = $pdpClient; } diff --git a/library/EngineBlock/Corto/Adapter.php b/library/EngineBlock/Corto/Adapter.php index 3f825f277f..64e6812886 100644 --- a/library/EngineBlock/Corto/Adapter.php +++ b/library/EngineBlock/Corto/Adapter.php @@ -127,6 +127,11 @@ public function processWayf() $this->_callCortoServiceUri('continueToIdp'); } + public function processSramInterrupt() + { + $this->_callCortoServiceUri('SramInterruptService'); + } + public function processConsent() { $this->_callCortoServiceUri('processConsentService'); diff --git a/library/EngineBlock/Corto/Filter/Command/SramInterruptFilter.php b/library/EngineBlock/Corto/Filter/Command/SramInterruptFilter.php new file mode 100644 index 0000000000..6a4de6b0d8 --- /dev/null +++ b/library/EngineBlock/Corto/Filter/Command/SramInterruptFilter.php @@ -0,0 +1,135 @@ +_responseAttributes; + } + + public function getResponse() + { + return $this->_response; + } + + public function execute(): void + { + if (!$this->featureConfiguration->isEnabled('eb.feature_enable_sram_interrupt')) { + return; + } + + $serviceProvider = EngineBlock_SamlHelper::findRequesterServiceProvider( + $this->_serviceProvider, + $this->_request, + $this->_server->getRepository(), + $this->logger, + ); + + if ($serviceProvider === null) { + $serviceProvider = $this->_serviceProvider; + } + + if ($serviceProvider->getCoins()->collabEnabled() === false) { + $this->logger->notice("No SBS interrupt for serviceProvider: " . $serviceProvider->entityId); + + return; + } + + $this->logger->notice("SBS interrupt for serviceProvider: " . $serviceProvider->entityId); + + try { + $request = $this->buildRequest($serviceProvider); + + $interruptResponse = $this->sbsClient->authz($request); + + if ($interruptResponse->msg === Msg::Interrupt) { + $this->logger->info("SBS interrupt reason: " . $interruptResponse->message); + $this->_response->setSramInterruptNonce($interruptResponse->nonce); + + return; + } + + if ($interruptResponse->msg === Msg::Authorized && !empty($interruptResponse->attributes)) { + $this->_responseAttributes = $this->attributeMerger->mergeAttributes($this->_responseAttributes, $interruptResponse->attributes); + + return; + } + + $this->logger->error(sprintf('SBS Authz returned error: %s', $interruptResponse->message)); + + throw new InvalidSbsResponseException('SBS Authz returned error.'); + } catch (Throwable $e){ + throw new EngineBlock_Exception_SbsCheckFailed('The SBS server could not be queried: ' . $e->getMessage()); + } + } + + private function buildRequest(ServiceProvider $serviceProvider): AuthzRequest + { + $attributes = $this->getResponseAttributes(); + $id = $this->_request->getId(); + + $userId = $this->_collabPersonId ?? ""; + $eppn = $attributes['urn:mace:dir:attribute-def:eduPersonPrincipalName'][0] ?? ""; + $continueUrl = $this->_server->getUrl('SramInterruptService', '') . "?ID=$id"; + $serviceId = $serviceProvider->entityId; + $issuerId = $this->_identityProvider->entityId; + + + return new AuthzRequest( + $userId, + $eppn, + $continueUrl, + $serviceId, + $issuerId + ); + } + + public function getResponseAttributeValueTypes(): array + { + /** + * Since we do not know the types of the attributes received from SRAM, they are reset here, like in AttributeManipulations + */ + + return []; + } +} diff --git a/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php b/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php index bb81bdbfe5..d5aed296f2 100644 --- a/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php +++ b/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php @@ -16,14 +16,28 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; +use OpenConext\EngineBlockBundle\Configuration\FeatureConfigurationInterface; + /** * Validate if the IDP sending this response is allowed to connect to the SP that made the request. **/ class EngineBlock_Corto_Filter_Command_ValidateAllowedConnection extends EngineBlock_Corto_Filter_Command_Abstract { + public function __construct(private readonly FeatureConfigurationInterface $featureConfiguration) + { + } + public function execute() { $sp = $this->_serviceProvider; + + // As part of the SBS integration, the Trusted proxy check should not be executed if the `collabEnabled` coin is enabled for the SP + // and the `feature_enable_sram_interrupt` is enabled. + if ($this->sbsFlowActive($sp)) { + return; + } + // When dealing with an SP that acts as a trusted proxy, we should perform the validatoin on the proxying SP // and not the proxy itself. if ($sp->getCoins()->isTrustedProxy()) { @@ -41,4 +55,13 @@ public function execute() ); } } + + private function sbsFlowActive(ServiceProvider $sp): bool + { + if ($this->featureConfiguration->isEnabled('eb.feature_enable_sram_interrupt') === false) { + return false; + } + + return $sp->getCoins()->collabEnabled(); + } } diff --git a/library/EngineBlock/Corto/Filter/Input.php b/library/EngineBlock/Corto/Filter/Input.php index 3c55a067d6..7c2d098219 100644 --- a/library/EngineBlock/Corto/Filter/Input.php +++ b/library/EngineBlock/Corto/Filter/Input.php @@ -71,7 +71,7 @@ public function getCommands() new EngineBlock_Corto_Filter_Command_VerifyShibMdScopingAllowsSubjectId($logger, $blockUsersOnViolation), // Check whether this IdP is allowed to send a response to the destination SP - new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(), + new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection($featureConfiguration), // Require valid UID and SchacHomeOrganization new EngineBlock_Corto_Filter_Command_ValidateRequiredAttributes(), @@ -90,6 +90,15 @@ public function getCommands() $diContainer->getAttributeAggregationClient() ), + // Check if we need to callout to SRAM to enforce AUP's + // Add SRAM attributes if not + new EngineBlock_Corto_Filter_Command_SramInterruptFilter( + $diContainer->getSbsClient(), + $featureConfiguration, + $diContainer->getSbsAttributeMerger(), + $logger, + ), + // Check if the Policy Decision Point needs to be consulted for this request new EngineBlock_Corto_Filter_Command_EnforcePolicy(), diff --git a/library/EngineBlock/Corto/Module/Bindings.php b/library/EngineBlock/Corto/Module/Bindings.php index 1a47e8aefb..0c035c57e3 100644 --- a/library/EngineBlock/Corto/Module/Bindings.php +++ b/library/EngineBlock/Corto/Module/Bindings.php @@ -615,7 +615,7 @@ public function send( EngineBlock_Saml2_MessageAnnotationDecorator $message, AbstractRole $remoteEntity, bool $useDefaultKey = false - ) { + ): void { $bindingUrn = $message->getDeliverByBinding(); $sspMessage = $message->getSspMessage(); diff --git a/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php b/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php index 42f99d996e..2d7bd959c2 100644 --- a/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php +++ b/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php @@ -91,6 +91,10 @@ public function serve($serviceName, Request $httpRequest) $application = EngineBlock_ApplicationSingleton::getInstance(); $log = $application->getLogInstance(); + if(!$receivedRequest instanceof EngineBlock_Saml2_AuthnRequestAnnotationDecorator){ + throw new RuntimeException('Request cannot be empty at this stage'); + } + if ($receivedResponse->isTransparentErrorResponse()) { $log->info('Response contains an error response status code, SP is configured with transparent_authn_context.'); $response = $this->_server->createTransparentErrorResponse($receivedRequest, $receivedResponse); @@ -164,58 +168,33 @@ public function serve($serviceName, Request $httpRequest) } // Keep track of what IDP was used for this SP. This way the user does - // not have to go trough the WAYF again when logging into this service + // not have to go through the WAYF again when logging into this service // or another service. EngineBlock_Corto_Model_Response_Cache::rememberIdp($receivedRequest, $receivedResponse); $originalAssertions = clone $receivedResponse->getAssertions()[0]; $this->_server->filterInputAssertionAttributes($receivedResponse, $receivedRequest); - // Add the consent step - $currentProcessStep = $this->_processingStateHelper->addStep( - $receivedRequest->getId(), - ProcessingStateHelperInterface::STEP_CONSENT, - $this->getEngineSpRole($this->_server), - $receivedResponse - ); - // When dealing with an SP that acts as a trusted proxy, we should use the proxying SP and not the proxy itself. - if ($sp->getCoins()->isTrustedProxy()) { - // Overwrite the trusted proxy SP instance with that of the SP that uses the trusted proxy. - $sp = $this->_server->findOriginalServiceProvider($receivedRequest, $log); + if($this->_server->shouldPerformSramCallout($receivedResponse) === true){ + $this->_server->addSramStep($receivedResponse, $receivedRequest); } - $pdpLoas = $receivedResponse->getPdpRequestedLoas(); - $loaRepository = $application->getDiContainer()->getLoaRepository(); - $authnRequestLoas = $receivedRequest->getStepupObligations($loaRepository->getStepUpLoas()); - // Goto consent if no Stepup authentication is needed - if (!$this->_stepupGatewayCallOutHelper->shouldUseStepup($idp, $sp, $authnRequestLoas, $pdpLoas)) { - $this->_server->sendConsentAuthenticationRequest($receivedResponse, $receivedRequest, $currentProcessStep->getRole(), $this->getAuthenticationState()); + $this->_server->addConsentProcessStep($receivedResponse, $receivedRequest); + + if($this->_server->shouldUseStepup($receivedResponse, $receivedRequest)){ + $this->_server->handleStepupAuthenticationCallout($receivedResponse, $receivedRequest, $originalAssertions); + return; } - $log->info('Handle Stepup authentication callout'); + if($this->_server->shouldPerformSramCallout($receivedResponse) === true){ + $this->_server->handleSramInterruptCallout($receivedResponse); - // Add Stepup authentication step - $currentProcessStep = $this->_processingStateHelper->addStep( - $receivedRequest->getId(), - ProcessingStateHelperInterface::STEP_STEPUP, - $application->getDiContainer()->getStepupIdentityProvider($this->_server), - $receivedResponse - ); - - // Get mapped AuthnClassRef and get NameId - $nameId = clone $receivedResponse->getNameId(); - $authnClassRef = $this->_stepupGatewayCallOutHelper->getStepupLoa($idp, $sp, $authnRequestLoas, $pdpLoas); + return; + } - $this->_server->sendStepupAuthenticationRequest( - $receivedRequest, - $currentProcessStep->getRole(), - $authnClassRef, - $nameId, - $sp->getCoins()->isStepupForceAuthn(), - $originalAssertions - ); + $this->_server->handleConsentAuthenticationCallout($receivedResponse, $receivedRequest); } /** diff --git a/library/EngineBlock/Corto/Module/Service/SramInterrupt.php b/library/EngineBlock/Corto/Module/Service/SramInterrupt.php new file mode 100644 index 0000000000..75a3b4fe13 --- /dev/null +++ b/library/EngineBlock/Corto/Module/Service/SramInterrupt.php @@ -0,0 +1,89 @@ +_server = $server; + $this->_processingStateHelper = $processingStateHelper; + $this->_sbsAttributeMerger = $sbsAttributeMerger; + } + + /** + * route that receives the user when they get back from their SBS interrupt, + * and resumes the AuthN flow. + */ + public function serve($serviceName, Request $httpRequest): void + { + $id = $httpRequest->get('ID'); + + $nextProcessStep = $this->_processingStateHelper->getStepByRequestId( + $id, + ProcessingStateHelperInterface::STEP_SRAM + ); + + $receivedResponse = $nextProcessStep->getResponse(); + $receivedRequest = $this->_server->getReceivedRequestFromResponse($receivedResponse); + $originalAssertion = clone $receivedResponse->getAssertions()[0]; + + if(!$receivedRequest instanceof EngineBlock_Saml2_AuthnRequestAnnotationDecorator){ + throw new RuntimeException('Request cannot be empty at this stage'); + } + + $attributes = $receivedResponse->getAssertion()->getAttributes(); + $nonce = $receivedResponse->getSramInterruptNonce(); + + $request = new AttributesRequest($nonce); + $interruptResponse = $this->getSbsClient()->requestAttributesFor($request); + + if (!empty($interruptResponse->attributes)) { + $attributes = $this->_sbsAttributeMerger->mergeAttributes($attributes, $interruptResponse->attributes); + $receivedResponse->getAssertion()->setAttributes($attributes); + + // After updating the attributes, reset the types, so SAML2 will set them + $receivedResponse->getAssertion()->setAttributesValueTypes([]); + } + + $this->_server->addConsentProcessStep($receivedResponse, $receivedRequest); + + $this->_server->handleConsentAuthenticationCallout($receivedResponse, $receivedRequest); + } + + private function getSbsClient() + { + return EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()->getSbsClient(); + } +} diff --git a/library/EngineBlock/Corto/Module/Service/StepupAssertionConsumer.php b/library/EngineBlock/Corto/Module/Service/StepupAssertionConsumer.php index 62b7d55d66..07b2c9100d 100644 --- a/library/EngineBlock/Corto/Module/Service/StepupAssertionConsumer.php +++ b/library/EngineBlock/Corto/Module/Service/StepupAssertionConsumer.php @@ -87,6 +87,10 @@ public function serve($serviceName, Request $httpRequest) $receivedResponse = $this->_server->getBindingsModule()->receiveResponse($serviceEntityId, $expectedDestination); $receivedRequest = $this->_server->getReceivedRequestFromResponse($receivedResponse); + if(!$receivedRequest instanceof EngineBlock_Saml2_AuthnRequestAnnotationDecorator){ + throw new RuntimeException('Request cannot be empty at this stage'); + } + $this->verifyReceivedLoa($receivedRequest, $receivedResponse, $log); // Update the AuthnContextClassRef to the loa returned @@ -136,15 +140,18 @@ public function serve($serviceName, Request $httpRequest) $this->verifyReceivedNameID($originalReceivedResponse, $receivedResponse); } + if ($this->_processingStateHelper->hasStepRequestById($receivedRequest->getId(), ProcessingStateHelperInterface::STEP_SRAM) === true) { + $this->_server->handleSramInterruptCallout($receivedResponse); + + return; + } + $nextProcessStep = $this->_processingStateHelper->getStepByRequestId( $receivedRequest->getId(), ProcessingStateHelperInterface::STEP_CONSENT ); - $this->_server->sendConsentAuthenticationRequest($originalReceivedResponse, $receivedRequest, $nextProcessStep->getRole(), $this->getAuthenticationState()); - - return; } /** diff --git a/library/EngineBlock/Corto/Module/Services.php b/library/EngineBlock/Corto/Module/Services.php index 763469a5aa..80459ab183 100644 --- a/library/EngineBlock/Corto/Module/Services.php +++ b/library/EngineBlock/Corto/Module/Services.php @@ -96,6 +96,12 @@ private function factoryService($className, EngineBlock_Corto_ProxyServer $serve $diContainer->getAuthenticationStateHelper(), $diContainer->getProcessingStateHelper() ); + case EngineBlock_Corto_Module_Service_SramInterrupt::class : + return new EngineBlock_Corto_Module_Service_SramInterrupt( + $server, + $diContainer->getProcessingStateHelper(), + $diContainer->getSbsAttributeMerger() + ); case EngineBlock_Corto_Module_Service_AssertionConsumer::class : return new EngineBlock_Corto_Module_Service_AssertionConsumer( $server, diff --git a/library/EngineBlock/Corto/ProxyServer.php b/library/EngineBlock/Corto/ProxyServer.php index 8e596dff65..8ec67673a7 100644 --- a/library/EngineBlock/Corto/ProxyServer.php +++ b/library/EngineBlock/Corto/ProxyServer.php @@ -27,8 +27,10 @@ use OpenConext\EngineBlock\Metadata\Service; use OpenConext\EngineBlock\Metadata\TransparentMfaEntity; use OpenConext\EngineBlock\Stepup\StepupGsspUserAttributeExtension; +use OpenConext\EngineBlock\Metadata\X509\KeyPairFactory; use OpenConext\EngineBlockBundle\Authentication\AuthenticationState; use OpenConext\EngineBlockBundle\Exception\UnknownKeyIdException; +use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; use OpenConext\Value\Saml\Entity; use OpenConext\Value\Saml\EntityId; use OpenConext\Value\Saml\EntityType; @@ -43,6 +45,7 @@ use SAML2\XML\saml\SubjectConfirmation; use SAML2\XML\saml\SubjectConfirmationData; use Twig\Environment; +use Symfony\Component\HttpFoundation\RedirectResponse; class EngineBlock_Corto_ProxyServer { @@ -73,7 +76,8 @@ class EngineBlock_Corto_ProxyServer 'idpMetadataService' => '/authentication/idp/metadata', 'spMetadataService' => '/authentication/sp/metadata', 'stepupMetadataService' => '/authentication/stepup/metadata', - 'singleLogoutService' => '/logout' + 'singleLogoutService' => '/logout', + 'SramInterruptService' => '/authentication/idp/process-sraminterrupt' ); // Todo: Make this mapping obsolete by updating all proxyserver getUrl callers. If they would reference the correct @@ -92,7 +96,8 @@ class EngineBlock_Corto_ProxyServer 'idpMetadataService' => 'metadata_idp', 'spMetadataService' => 'metadata_sp', 'stepupMetadataService' => 'metadata_stepup', - 'singleLogoutService' => 'authentication_logout' + 'singleLogoutService' => 'authentication_logout', + 'SramInterruptService' => 'authentication_idp_process_sraminterrupt' ); protected $_servicesNotNeedingSession = array( @@ -140,11 +145,14 @@ class EngineBlock_Corto_ProxyServer * @var Environment */ private $twig; + private EngineBlock_Application_DiContainer $_diContainer; public function __construct(Environment $twig) { $this->_server = $this; $this->twig = $twig; + + $this->_diContainer = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); } //////// GETTERS / SETTERS ///////// @@ -474,7 +482,7 @@ public function sendStepupAuthenticationRequest( NameID $nameId, bool $isForceAuthn, Assertion $originalAssertion - ) { + ): void { $ebRequest = EngineBlock_Saml2_AuthnRequestFactory::createFromRequest( $spRequest, $identityProvider, @@ -551,6 +559,156 @@ public function sendStepupAuthenticationRequest( $this->getBindingsModule()->send($ebRequest, $identityProvider, true); } + public function shouldPerformSramCallout( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + ): bool + { + return $receivedResponse->getSramInterruptNonce() !== ''; + } + + public function handleSramInterruptCallout( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + ): void { + $this->getLogger()->info('Handle SRAM interrupt callout'); + $nonce = $receivedResponse->getSramInterruptNonce(); + + $sbsClient = $this->_diContainer->getSbsClient(); + $redirect_url = $sbsClient->getInterruptLocationLink($nonce); + $this->redirect($redirect_url, ''); + } + + public function handleStepupAuthenticationCallout( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + EngineBlock_Saml2_AuthnRequestAnnotationDecorator $receivedRequest, + $originalAssertion, + ): void { + $logger = $this->getLogger(); + $logger->info('Handle Stepup authentication callout'); + + // Add Stepup authentication step + $currentProcessStep = $this->_diContainer->getProcessingStateHelper()->addStep( + $receivedRequest->getId(), + ProcessingStateHelperInterface::STEP_STEPUP, + $this->_diContainer->getStepupIdentityProvider($this), + $receivedResponse + ); + + if ($receivedRequest->isDebugRequest()) { + $sp = $this->getEngineSpRole(); + } else { + $issuer = $receivedRequest->getIssuer() ? $receivedRequest->getIssuer()->getValue() : ''; + $sp = $this->getRepository()->fetchServiceProviderByEntityId($issuer); + } + + $issuer = $receivedResponse->getIssuer() ? $receivedResponse->getIssuer()->getValue() : ''; + $idp = $this->getRepository()->fetchIdentityProviderByEntityId($issuer); + + // When dealing with an SP that acts as a trusted proxy, we should use the proxying SP and not the proxy itself. + if ($sp->getCoins()->isTrustedProxy()) { + // Overwrite the trusted proxy SP instance with that of the SP that uses the trusted proxy. + $sp = $this->findOriginalServiceProvider($receivedRequest, $logger); + } + + $pdpLoas = $receivedResponse->getPdpRequestedLoas(); + $loaRepository = $this->_diContainer->getLoaRepository(); + $authnRequestLoas = $receivedRequest->getStepupObligations($loaRepository->getStepUpLoas()); + + // Get mapped AuthnClassRef and get NameId + $nameId = clone $receivedResponse->getNameId(); + $authnClassRef = $this->_diContainer->getStepupGatewayCallOutHelper()->getStepupLoa($idp, $sp, $authnRequestLoas, $pdpLoas); + + $this->sendStepupAuthenticationRequest( + $receivedRequest, + $currentProcessStep->getRole(), + $authnClassRef, + $nameId, + $sp->getCoins()->isStepupForceAuthn(), + $originalAssertion, + ); + } + + public function getEngineSpRole(): ServiceProvider + { + $keyId = $this->getKeyId(); + if (!$keyId) { + $keyId = KeyPairFactory::DEFAULT_KEY_PAIR_IDENTIFIER; + } + + $serviceProvider = $this->_diContainer->getServiceProviderFactory()->createEngineBlockEntityFrom($keyId); + return ServiceProvider::fromServiceProviderEntity($serviceProvider); + } + + public function shouldUseStepup( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + EngineBlock_Saml2_AuthnRequestAnnotationDecorator $receivedRequest, + ): bool + { + $issuer = $receivedResponse->getIssuer() ? $receivedResponse->getIssuer()->getValue() : ''; + $idp = $this->getRepository()->fetchIdentityProviderByEntityId($issuer); + + if ($receivedRequest->isDebugRequest()) { + $sp = $this->getEngineSpRole(); + } else { + $issuer = $receivedRequest->getIssuer() ? $receivedRequest->getIssuer()->getValue() : ''; + $sp = $this->getRepository()->fetchServiceProviderByEntityId($issuer); + } + + // When dealing with an SP that acts as a trusted proxy, we should use the proxying SP and not the proxy itself. + if ($sp->getCoins()->isTrustedProxy()) { + // Overwrite the trusted proxy SP instance with that of the SP that uses the trusted proxy. + $sp = $this->_server->findOriginalServiceProvider($receivedRequest, $this->getLogger()); + } + + $pdpLoas = $receivedResponse->getPdpRequestedLoas(); + $loaRepository = $this->_diContainer->getLoaRepository(); + $authnRequestLoas = $receivedRequest->getStepupObligations($loaRepository->getStepUpLoas()); + + return $this->_diContainer->getStepupGatewayCallOutHelper()->shouldUseStepup($idp, $sp, $authnRequestLoas, $pdpLoas); + } + + public function handleConsentAuthenticationCallout( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + EngineBlock_Saml2_AuthnRequestAnnotationDecorator $receivedRequest + ): void { + $logger = $this->getLogger(); + $logger->info('Handle Consent authentication callout'); + + + $this->sendConsentAuthenticationRequest( + $receivedResponse, + $receivedRequest, + $this->getEngineSpRole(), + $this->_diContainer->getAuthenticationStateHelper()->getAuthenticationState(), + ); + } + + public function addConsentProcessStep( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + EngineBlock_Saml2_AuthnRequestAnnotationDecorator $receivedRequest, + ) + { + // Add the consent step + $this->_diContainer->getProcessingStateHelper()->addStep( + $receivedRequest->getId(), + ProcessingStateHelperInterface::STEP_CONSENT, + $this->getEngineSpRole(), + $receivedResponse + ); + } + + public function addSramStep( + EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, + EngineBlock_Saml2_AuthnRequestAnnotationDecorator $receivedRequest, + ): void { + // Add the SRAM step + $this->_diContainer->getProcessingStateHelper()->addStep( + $receivedRequest->getId(), + ProcessingStateHelperInterface::STEP_SRAM, + $this->getEngineSpRole(), + $receivedResponse + ); + } + function sendConsentAuthenticationRequest( EngineBlock_Saml2_ResponseAnnotationDecorator $receivedResponse, EngineBlock_Saml2_AuthnRequestAnnotationDecorator $receivedRequest, diff --git a/library/EngineBlock/Exception/SbsCheckFailed.php b/library/EngineBlock/Exception/SbsCheckFailed.php new file mode 100644 index 0000000000..250f442279 --- /dev/null +++ b/library/EngineBlock/Exception/SbsCheckFailed.php @@ -0,0 +1,21 @@ +_serializableSspMessageXml); } } + + public function setSramInterruptNonce(string $SramInterruptNonce): void + { + $this->SramInterruptNonce = $SramInterruptNonce; + } + + public function getSramInterruptNonce(): string + { + return $this->SramInterruptNonce; + } + } diff --git a/src/OpenConext/EngineBlock/Exception/InvalidSramConfigurationException.php b/src/OpenConext/EngineBlock/Exception/InvalidSramConfigurationException.php new file mode 100644 index 0000000000..91ee2b8f82 --- /dev/null +++ b/src/OpenConext/EngineBlock/Exception/InvalidSramConfigurationException.php @@ -0,0 +1,26 @@ +httpClient->request('POST', $resource, [ 'exceptions' => false, 'body' => $data, - 'headers' => $headers + 'headers' => $headers, + 'verify' => $verify, ]); $statusCode = $response->getStatusCode(); diff --git a/src/OpenConext/EngineBlock/Metadata/Coins.php b/src/OpenConext/EngineBlock/Metadata/Coins.php index c9d3005d54..e5d9e53f75 100644 --- a/src/OpenConext/EngineBlock/Metadata/Coins.php +++ b/src/OpenConext/EngineBlock/Metadata/Coins.php @@ -240,9 +240,9 @@ public function signatureMethod() return $this->getValue('signatureMethod', XMLSecurityKey::RSA_SHA256); } - public function collabEnabled() + public function collabEnabled(): bool { - return $this->getValue('collabEnabled', false); + return $this->getValue('collabEnabled', false) === true; } public function mfaEntities(): MfaEntityCollection diff --git a/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php b/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php index 9e0d31929e..bf539609ff 100644 --- a/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php +++ b/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php @@ -74,7 +74,7 @@ public function getStepByRequestId($requestId, $name) { $processing = $this->session->get(self::SESSION_KEY); if (empty($processing)) { - throw new EngineBlock_Corto_Module_Services_SessionLostException('Session lost after consent'); + throw new EngineBlock_Corto_Module_Services_SessionLostException('Session lost'); } if (!isset($processing[$requestId])) { throw new EngineBlock_Corto_Module_Services_SessionLostException( @@ -83,13 +83,28 @@ public function getStepByRequestId($requestId, $name) } if (!isset($processing[$requestId][$name])) { throw new EngineBlock_Corto_Module_Services_Exception( - sprintf('Process step requested for ResponseID "%s" not found', $requestId) + sprintf('Process step requested for ResponseID "%s" with "%s" not found', $requestId, $name) ); } return $processing[$requestId][$name]; } + public function hasStepRequestById(string $requestId, string $name): bool + { + $processing = $this->session->get(self::SESSION_KEY); + if (empty($processing)) { + return false; + } + if (!isset($processing[$requestId])) { + return false; + } + if (!isset($processing[$requestId][$name])) { + return false; + } + + return true; + } /** * @param string $name diff --git a/src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php b/src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php index d88a14e252..0dae556ec2 100644 --- a/src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php +++ b/src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php @@ -30,6 +30,7 @@ interface ProcessingStateHelperInterface { const STEP_CONSENT = 'consent'; const STEP_STEPUP = 'stepup'; + const STEP_SRAM = 'sram'; /** * @param string $requestId @@ -53,6 +54,8 @@ public function addStep( */ public function getStepByRequestId($requestId, $name); + public function hasStepRequestById(string $requestId, string $name): bool; + /** * @param $name * @param $requestId diff --git a/src/OpenConext/EngineBlockBundle/Configuration/FeatureConfiguration.php b/src/OpenConext/EngineBlockBundle/Configuration/FeatureConfiguration.php index dfd001b185..f632968c50 100644 --- a/src/OpenConext/EngineBlockBundle/Configuration/FeatureConfiguration.php +++ b/src/OpenConext/EngineBlockBundle/Configuration/FeatureConfiguration.php @@ -29,7 +29,7 @@ class FeatureConfiguration implements FeatureConfigurationInterface private $features; /** - * @param Feature[] $features indexed by feature key + * @param array $features indexed by feature key */ public function __construct(array $features) { diff --git a/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php b/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php index 9866bd8f74..e6da271247 100644 --- a/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php +++ b/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php @@ -48,6 +48,7 @@ public function __construct() $this->setFeature(new Feature('eb.stepup.sfo.override_engine_entityid', false)); $this->setFeature(new Feature('eb.feature_enable_idp_initiated_flow', true)); $this->setFeature(new Feature('eb.stepup.send_user_attributes', true)); + $this->setFeature(new Feature('eb.feature_enable_sram_interrupt', true)); } public function setFeature(Feature $feature): void diff --git a/src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php b/src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php index 70f1699310..3f88102fdb 100644 --- a/src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php +++ b/src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php @@ -216,6 +216,20 @@ public function processConsentAction() * @return Response * @throws \EngineBlock_Exception * + * @Route("/authentication/idp/process-sraminterrupt", name="authentication_idp_process_sraminterrupt", methods={"GET"}) + */ + public function processSramInterrupt(Request $request) + { + $proxyServer = new EngineBlock_Corto_Adapter(); + $proxyServer->processSramInterrupt(); + + return ResponseFactory::fromEngineBlockResponse($this->engineBlockApplicationSingleton->getHttpResponse()); + } + + /** + * @param Request $request + * @return Response + * @throws \EngineBlock_Exception * @Route("/authentication/idp/requestAccess", name="authentication_idp_request_access", methods={"GET"}) */ public function requestAccessAction(Request $request) diff --git a/src/OpenConext/EngineBlockBundle/Exception/InvalidSbsResponseException.php b/src/OpenConext/EngineBlockBundle/Exception/InvalidSbsResponseException.php new file mode 100644 index 0000000000..9e15bd0d66 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Exception/InvalidSbsResponseException.php @@ -0,0 +1,23 @@ +attributes = $jsonData['attributes']; + + return $response; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/AuthzResponse.php b/src/OpenConext/EngineBlockBundle/Sbs/AuthzResponse.php new file mode 100644 index 0000000000..fc6dee0e1d --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/AuthzResponse.php @@ -0,0 +1,65 @@ +msg = $msg; + $this->nonce = $jsonData['nonce'] ?? null; + $this->message = $jsonData['message'] ?? ''; + + if (isset($jsonData['attributes']) && is_array($jsonData['attributes'])) { + $this->attributes = $jsonData['attributes']; + } else { + $this->attributes = []; + } + } + + public static function fromData(array $jsonData) : AuthzResponse + { + if (!isset($jsonData['msg'])) { + throw new InvalidSbsResponseException('Key: "msg" was not found in the SBS response'); + } + + try { + $msg = Msg::from($jsonData['msg']); + } catch (\ValueError $e) { + throw new InvalidSbsResponseException(sprintf('"%s" is not a valid msg', $jsonData['msg'])); + } + + if ($msg === Msg::Interrupt && !isset($jsonData['nonce'])) { + throw new InvalidSbsResponseException('Key: "nonce" was not found in the SBS response'); + } + + if ($msg === Msg::Authorized && !isset($jsonData['attributes'])) { + throw new InvalidSbsResponseException('Key: "attributes" was not found in the SBS response'); + } + + return new self($msg, $jsonData); + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/Dto/AttributesRequest.php b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AttributesRequest.php new file mode 100644 index 0000000000..290849f75c --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AttributesRequest.php @@ -0,0 +1,37 @@ + $this->nonce, + ]; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/Dto/AuthzRequest.php b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AuthzRequest.php new file mode 100644 index 0000000000..d8a403ed07 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AuthzRequest.php @@ -0,0 +1,50 @@ + $this->userId, + 'eppn' => $this->eduPersonPrincipalName, + 'continue_url' => $this->continueUrl, + 'service_id' => $this->serviceId, + 'issuer_id' => $this->issuerId + ]; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/Msg.php b/src/OpenConext/EngineBlockBundle/Sbs/Msg.php new file mode 100644 index 0000000000..4d4b10c423 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/Msg.php @@ -0,0 +1,38 @@ + self::Interrupt, + 'authorized' => self::Authorized, + 'error' => self::Error, + default => throw new InvalidArgumentException('Invalid SBS msg: ' . $string), + }; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMerger.php b/src/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMerger.php new file mode 100644 index 0000000000..dc8e66b6fb --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMerger.php @@ -0,0 +1,83 @@ + + */ + private array $allowedAttributeNames; + + public function __construct(array $allowedAttributeNames) + { + $this->allowedAttributeNames = $allowedAttributeNames; + } + + public function mergeAttributes(array $samlAttributes, array $sbsAttributes): array + { + $validAttributes = $this->validSbsAttributes($sbsAttributes); + + foreach ($validAttributes as $key => $value) { + if (!isset($samlAttributes[$key])) { + $samlAttributes[$key] = $value; + continue; + } + + if (is_array($value) && is_array($samlAttributes[$key])) { + // Merge and remove duplicates if both values are arrays + $samlAttributes[$key] = array_unique(array_merge($samlAttributes[$key], $value)); + continue; + } + + $samlAttributes[$key] = $value; + } + + return $samlAttributes; + } + + /** + * @SuppressWarnings(PHPMD.UnusedLocalVariable) $value is never used in the foreach + */ + private function validSbsAttributes(array $sbsAttributes): array + { + $validAttributes = []; + $invalidKeys = []; + + foreach ($sbsAttributes as $key => $value) { + if (in_array($key, $this->allowedAttributeNames, true)) { + $validAttributes[$key] = $value; + } else { + $invalidKeys[] = $key; + } + } + + if (!empty($invalidKeys)) { + $application = EngineBlock_ApplicationSingleton::getInstance(); + $log = $application->getLogInstance(); + $log->warning(sprintf('Attributes "%s" is not allowed to be overwritten by SBS.', implode(', ', $invalidKeys))); + } + + return $validAttributes; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/SbsClient.php b/src/OpenConext/EngineBlockBundle/Sbs/SbsClient.php new file mode 100644 index 0000000000..d51ea930c2 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/SbsClient.php @@ -0,0 +1,94 @@ +httpClient->post( + json_encode($request, JSON_THROW_ON_ERROR), + $this->buildUrl($this->authzLocation), + [], + $this->requestHeaders(), + $this->verifyPeer + ); + + if (!is_array($jsonData)) { + throw new InvalidSbsResponseException('Received non-array from SBS server: ' . get_debug_type($jsonData)); + } + + return AuthzResponse::fromData($jsonData); + } + + public function requestAttributesFor(AttributesRequest $request): AttributesResponse + { + $jsonData = $this->httpClient->post( + json_encode($request, JSON_THROW_ON_ERROR), + $this->buildUrl($this->attributesLocation), + [], + $this->requestHeaders(), + $this->verifyPeer + ); + + if (!is_array($jsonData)) { + throw new InvalidSbsResponseException('Received non-array from SBS server: ' . get_debug_type($jsonData)); + } + + return AttributesResponse::fromData($jsonData); + } + + private function requestHeaders(): array + { + return [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => $this->apiToken, + ]; + } + + public function getInterruptLocationLink(string $nonce): string + { + return $this->buildUrl($this->interruptLocation) . "?nonce=" . $nonce; + } + + private function buildUrl(string $path): string + { + $baseUrl = rtrim($this->sbsBaseUrl, '/'); + $path = '/' . ltrim($path, '/'); + return $baseUrl . $path; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/SbsClientInterface.php b/src/OpenConext/EngineBlockBundle/Sbs/SbsClientInterface.php new file mode 100644 index 0000000000..0f85dc791a --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/SbsClientInterface.php @@ -0,0 +1,31 @@ +sbsClientStateManager = $sbsClientStateManager; + $this->dataStore = $dataStore; + } + + /** + * The endpoint Engine calls to see if the user is 'known' in SBS + */ + public function authzAction(Request $request): JsonResponse + { + $this->dataStore->save(json_decode($request->getContent(), true)); + return new JsonResponse($this->sbsClientStateManager->getPreparedAuthzResponse()); + } + + /** + * The endpoint the browser is redirected to if the user is 'unknown' in SBS + */ + public function interruptAction(Request $request): Response + { + $storedData = $this->dataStore->load(); + $returnUrl = $storedData['continue_url']; + + // url contains the ID=, so the session is preserved + return new Response(sprintf( + 'Continue', + $returnUrl + )); + } + + /** + * The endpoint called by Engine to fetch the attributes from SBS, to be merged into the SAML request + * This gets called after the interrupt, or when the user is already authorized in SBS. + */ + public function attributesAction() + { + return new JsonResponse($this->sbsClientStateManager->getPreparedAttributesResponse()); + } +} diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/EngineBlockContext.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/EngineBlockContext.php index 05dc879894..e8013cf25e 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/EngineBlockContext.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/EngineBlockContext.php @@ -23,11 +23,13 @@ use DOMDocument; use DOMElement; use DOMXPath; +use OpenConext\EngineBlockBundle\Sbs\Msg; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\DataStore\AbstractDataStore; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\FunctionalTestingAttributeAggregationClient; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\FunctionalTestingAuthenticationLoopGuard; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\FunctionalTestingFeatureConfiguration; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\FunctionalTestingPdpClient; +use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\SbsClientStateManager; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\ServiceRegistryFixture; use OpenConext\EngineBlockFunctionalTestingBundle\Mock\EntityRegistry; use OpenConext\EngineBlockFunctionalTestingBundle\Mock\MockIdentityProvider; @@ -44,6 +46,7 @@ * @SuppressWarnings(PHPMD.TooManyMethods) Both set up and tasks can be a lot... * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Due to all integration specific features * @SuppressWarnings(PHPMD.ExcessivePublicCount) Both set up and tasks can be a lot... + * @SuppressWarnings(PHPMD.TooManyFields) Both set up and tasks can be a lot... */ class EngineBlockContext extends AbstractSubContext { @@ -116,6 +119,10 @@ class EngineBlockContext extends AbstractSubContext * @var string */ private $currentRequestId = ''; + /** + * @var SbsClientStateManager + */ + private $sbsClientStateManager; private AbstractDataStore $dataStore; @@ -141,6 +148,7 @@ public function __construct( FunctionalTestingAuthenticationLoopGuard $authenticationLoopGuard, FunctionalTestingAttributeAggregationClient $attributeAggregationClient, AbstractDataStore $authGuardDataStore, + SbsClientStateManager $sbsClientStateManager ) { $this->serviceRegistryFixture = $serviceRegistry; $this->engineBlock = $engineBlock; @@ -151,6 +159,7 @@ public function __construct( $this->authenticationLoopGuard = $authenticationLoopGuard; $this->attributeAggregationClient = $attributeAggregationClient; $this->dataStore = $authGuardDataStore; + $this->sbsClientStateManager = $sbsClientStateManager; } /** @@ -192,6 +201,16 @@ public function iPassThroughEngineblock() $mink->pressButton('Submit'); } + /** + * @Given /^I pass through SBS/ + */ + public function iPassThroughSBS() + { + $mink = $this->getMinkContext(); + + $mink->clickLink('Continue'); + } + /** * @Given /^EngineBlock raises an unexpected error$/ */ @@ -746,6 +765,44 @@ public function aaReturnsAttributes(TableNode $attributes) } } + /** + * @Given /^the sbs server will trigger the "([^"]*)" authz flow when called$/ + */ + public function primeAuthzResponse(string $msgString): void + { + $msg = Msg::fromString($msgString); + if ($msg === Msg::Error) { + $this->sbsClientStateManager->prepareAuthzResponse(Msg::Error); + return; + } + + $this->sbsClientStateManager->prepareAuthzResponse($msg); + } + + /** + * @Given /^the sbs server will trigger the 'authorized' authz flow and will return invalid attributes$/ + */ + public function authzWillReturnInvalidAttributes(): void + { + $this->sbsClientStateManager->prepareAuthzResponse(Msg::Authorized, ['attributes' => ['foo' => ['bar' => 'baz']]]); + } + + /** + * @Given /^the sbs server will return valid attributes/ + */ + public function attributesWillReturnValidAttributes(): void + { + $this->sbsClientStateManager->prepareAttributesResponse($this->sbsClientStateManager->getValidMockAttributes()); + } + + /** + * @Given /^the sbs server will return invalid attributes/ + */ + public function attributesWillReturnInvalidAttributes(): void + { + $this->sbsClientStateManager->prepareAttributesResponse(['msg' => 'error', 'message' => 'something went wrong']); + } + /** * @Given /^I should see ART code "([^"]*)"$/ */ diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockSpContext.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockSpContext.php index ef5fb57cb4..7252f5a6af 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockSpContext.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockSpContext.php @@ -159,6 +159,18 @@ public function spDoesNotRequireConsent($spName) ->save(); } + /** + * @Given /^the SP "([^"]*)" requires SRAM collaboration$/ + * @param string $spName + */ + public function theSpRequiresSbsCollaboration(string $spName) + { + $sp = $this->mockSpRegistry->get($spName); + + $this->serviceRegistryFixture->setSpCollabEnabled($sp->entityId()); + $this->serviceRegistryFixture->save(); + } + /** * @When /^I log in at "([^"]*)"$/ */ diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/StepupContext.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/StepupContext.php index 560b24e0d8..882f49ea93 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/StepupContext.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/StepupContext.php @@ -23,6 +23,7 @@ use OpenConext\EngineBlockFunctionalTestingBundle\Mock\EntityRegistry; use OpenConext\EngineBlockFunctionalTestingBundle\Mock\MockIdentityProvider; use OpenConext\EngineBlockFunctionalTestingBundle\Mock\MockServiceProvider; +use RuntimeException; class StepupContext extends AbstractSubContext { @@ -66,9 +67,19 @@ public function __construct( */ public function stepupWillsSuccessfullyVerifyAUser() { - $mink = $this->getMinkContext(); + $page = $this->getMinkContext()->getSession()->getPage(); + $form = $page->find('css', 'form[action*="/authentication/stepup/consume-assertion"]'); + + if ($form === null) { + throw new RuntimeException('No form found for "/authentication/stepup/consume-assertion"'); + } + + $button = $form->find('css', 'input[type="submit"][value="Submit-success"]'); + if ($button === null) { + throw new RuntimeException('No form with submit button found for "/authentication/stepup/consume-assertion"'); + } - $mink->pressButton('Submit-success'); + $button->click(); } /** diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/Discoveries.feature b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Discoveries.feature similarity index 100% rename from src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/Discoveries.feature rename to src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Discoveries.feature diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/SbsFlowIntegration.feature b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/SbsFlowIntegration.feature new file mode 100644 index 0000000000..1e54654568 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/SbsFlowIntegration.feature @@ -0,0 +1,189 @@ +Feature: + In order to support SRAM integration + As EngineBlock + I want to support SBS checks and merge attributes + + Background: + Given an EngineBlock instance on "dev.openconext.local" + And no registered SPs + And no registered Idps + And an Identity Provider named "SSO-IdP" + And a Service Provider named "SSO-SP" + + Scenario: If the SBS authz check returns 'interrupt', the browser is redirected to SBS + Given the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "interrupt" authz flow when called + And the sbs server will return valid attributes + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + Then the url should match "/functional-testing/interrupt" + And I pass through SBS + Then the url should match "/authentication/idp/process-sraminterrupt" + And the response should contain "Review your information that will be shared." + And the response should contain "test_user@test.sram.surf.nl" + And the response should contain "Proceed to SSO-SP" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + Then the response should contain "ssh_key1" + And the response should contain "ssh_key2" + + Scenario: If the SBS authz check returns 'authorized', the attributes are merged, and the browser is not redirected. + Given the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "authorized" authz flow when called + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + Then the url should match "/authentication/sp/consume-assertion" + And the response should contain "Review your information that will be shared." + And the response should contain "test_user@test.sram.surf.nl" + And the response should contain "Proceed to SSO-SP" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + Then the response should contain "ssh_key1" + And the response should contain "ssh_key2" + + Scenario: If the SBS authz check returns an invalid response, the flow is halted. + Given the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "error" authz flow when called + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + Then the url should match "/feedback/unknown-error" + And the response should contain "Logging in has failed" + + Scenario: Stepup authentication combined with SBS 'authorized' flow merges attributes and shows them on consent screen + Given the SP "SSO-SP" requires Stepup LoA "http://dev.openconext.local/assurance/loa2" + And the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "authorized" authz flow when called + And the sbs server will return valid attributes + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + And Stepup will successfully verify a user + Then the url should match "/authentication/stepup/consume-assertion" + And the response should contain "Review your information that will be shared." + And the response should contain "test_user@test.sram.surf.nl" + And the response should contain "ssh_key1" + And the response should contain "ssh_key2" + And the response should contain "Proceed to SSO-SP" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + And the response should contain "ssh_key1" + And the response should contain "ssh_key2" + + Scenario: Stepup authentication combined with SBS 'interrupt' flow redirects to SBS then shows merged attributes on consent + Given the SP "SSO-SP" requires Stepup LoA "http://dev.openconext.local/assurance/loa2" + And the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "interrupt" authz flow when called + And the sbs server will return valid attributes + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + And Stepup will successfully verify a user + Then the url should match "/functional-testing/interrupt" + And I pass through SBS + Then the url should match "/authentication/idp/process-sraminterrupt" + And the response should contain "Review your information that will be shared." + And the response should contain "test_user@test.sram.surf.nl" + And the response should contain "ssh_key1" + And the response should contain "ssh_key2" + And the response should contain "Proceed to SSO-SP" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + And the response should contain "ssh_key1" + And the response should contain "ssh_key2" + + Scenario: If no suitable stepup can be given, sbs interrupt is not executed + Given the SP "SSO-SP" requires Stepup LoA "http://dev.openconext.local/assurance/loa2" + And the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "interrupt" authz flow when called + And the sbs server will return valid attributes + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + And Stepup will fail if the LoA can not be given + Then I should see "Error - No suitable token found" + And the url should match "/feedback/stepup-callout-unmet-loa" + And the response status code should be 400 + + Scenario: SBS flow is skipped when feature is disabled + Given the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is disabled + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + Then the url should match "/authentication/sp/consume-assertion" + And the response should contain "Review your information that will be shared." + And the response should not contain "test_user@test.sram.surf.nl" + And the response should not contain "ssh_key1" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + And the response should not contain "ssh_key1" + + Scenario: SBS flow is skipped when SP does not require SRAM collaboration + Given feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "authorized" authz flow when called + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + Then the url should match "/authentication/sp/consume-assertion" + And the response should contain "Review your information that will be shared." + And the response should not contain "test_user@test.sram.surf.nl" + And the response should not contain "ssh_key1" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + And the response should not contain "ssh_key1" + + Scenario: SBS 'authorized' flow works with trusted proxy + Given an Identity Provider named "Trusted-IdP" + And a Service Provider named "Proxy-SP" + And a Service Provider named "End-SP" + And the SP "End-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "authorized" authz flow when called + And the sbs server will return valid attributes + And SP "Proxy-SP" is authenticating for SP "End-SP" + And SP "Proxy-SP" is a trusted proxy + And SP "Proxy-SP" signs its requests + When I log in at "Proxy-SP" + And I select "Trusted-IdP" on the WAYF + And I pass through EngineBlock + And I pass through the IdP + Then the response should contain "Review your information that will be shared." + And the response should contain "test_user@test.sram.surf.nl" + And the response should contain "ssh_key1" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/Proxy-SP/acs" + And the response should contain "ssh_key1" + And the response should contain "ssh_key2" + + Scenario: SBS attributes respect attribute release policy + Given the SP "SSO-SP" requires SRAM collaboration + And feature "eb.feature_enable_sram_interrupt" is enabled + And the sbs server will trigger the "authorized" authz flow when called + And the sbs server will return valid attributes + And SP "SSO-SP" allows no attributes + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + Then the response should contain "Review your information that will be shared." + And the response should not contain "ssh_key1" + And the response should not contain "test_user@test.sram.surf.nl" + When I give my consent + And I pass through EngineBlock + Then the url should match "/functional-testing/SSO-SP/acs" + And the response should not contain "ssh_key1" diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/DataStore/AbstractDataStore.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/DataStore/AbstractDataStore.php index aee892ce3f..8d56b1d1ed 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/DataStore/AbstractDataStore.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/DataStore/AbstractDataStore.php @@ -38,6 +38,11 @@ public function __construct($filePath) $this->filePath = basename($filePath); $adapter = new Local($directory); $this->fileSystem = new Filesystem($adapter); + + if (!$this->fileSystem->fileExists($this->filePath)) { + $this->save([]); + $this->ensureWwwCanWrite($filePath); + } } /** @@ -72,9 +77,26 @@ public function load($default = []) public function save($data) { $this->fileSystem->write($this->filePath, $this->encode($data)); + + // Because the serialization of IdP / SP mocks is destructive, use the restored data after saving. + // If this data is not used, the destructed object remains in memory. + $fileContents = $this->fileSystem->read($this->filePath); + + return $this->decode($fileContents); } abstract protected function encode($data); abstract protected function decode($data); + + /** + * The db is created during the behat process, which runs as root. + * But requests from the webserver should also be able to update the state. + */ + private function ensureWwwCanWrite(string $absolutePath): void + { + chown($absolutePath, 'www-data'); + chgrp($absolutePath, 'www-data'); + chmod($absolutePath, 0664); + } } diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingPdpClient.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingPdpClient.php index ded6b3dd66..43e9cc868b 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingPdpClient.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingPdpClient.php @@ -92,7 +92,7 @@ public function requestDecisionFor(Request $request) : PolicyDecision $pdpResponse->status = new Status(); $pdpResponse->status->statusDetail = << diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/SbsClientStateManager.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/SbsClientStateManager.php new file mode 100644 index 0000000000..6633b715aa --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/SbsClientStateManager.php @@ -0,0 +1,111 @@ +dataStore = $dataStore; + } + + public function prepareAuthzResponse(Msg $msg, ?array $attributes = null): void + { + if ($msg === Msg::Interrupt) { + $this->authz = [ + 'msg' => $msg->value, + 'nonce' => 'my-nonce', + ]; + } elseif ($msg === Msg::Authorized) { + $this->authz = [ + 'msg' => $msg->value, + ]; + $this->authz += $attributes ?? $this->getValidMockAttributes(); + } elseif ($msg === Msg::Error) { + $this->authz = [ + 'msg' => $msg->value, + ]; + } else { + throw new InvalidArgumentException(sprintf('"%s" is not a valid authz message.', $msg->value)); + } + + $this->save(); + } + + public function getPreparedAuthzResponse(): array + { + return $this->dataStore->load()['authz']; + } + + /** + * @return array[] + */ + public function getValidMockAttributes(): array + { + return [ + "attributes" => [ + "urn:mace:dir:attribute-def:eduPersonEntitlement" => ["user_aff1@test.sram.surf.nl", "user_aff2@test.sram.surf.nl"], + "urn:mace:dir:attribute-def:eduPersonPrincipalName" => ["test_user@test.sram.surf.nl"], + "urn:mace:dir:attribute-def:uid" => ["test_user"], + "urn:oid:1.3.6.1.4.1.24552.500.1.1.1.13" => ["ssh_key1", "ssh_key2"], + ], + ]; + } + + public function prepareAttributesResponse(array $attributes): void + { + $this->attributes = $attributes; + $this->save(); + } + + public function getPreparedAttributesResponse(): array + { + return $this->dataStore->load()['attributes']; + } + + private function save() + { + $this->dataStore->save([ + 'authz' => $this->authz, + 'attributes' => $this->attributes, + ]); + } +} diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php index 994ee97d29..5937cdc117 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php @@ -260,6 +260,13 @@ public function setSpEntityNoConsent($entityId) return $this; } + public function setSpCollabEnabled($entityId) + { + $this->setCoin($this->getServiceProvider($entityId), 'collabEnabled', true); + + return $this; + } + public function setSpEntityWantsSignature($entityId) { $this->getServiceProvider($entityId)->requestsMustBeSigned = true; diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/EntityRegistry.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/EntityRegistry.php index a1a2552099..6bd0a0ac86 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/EntityRegistry.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/EntityRegistry.php @@ -67,7 +67,7 @@ public function clear() public function save() { - $this->dataStore->save($this->parameters); + $this->parameters = $this->dataStore->save($this->parameters); return $this; } diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php index a21d74ef76..5757f52ed4 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php @@ -23,10 +23,13 @@ use RobRichards\XMLSecLibs\XMLSecurityKey; use RuntimeException; use SAML2\Constants; +use SAML2\DOMDocumentFactory; +use SAML2\Response as SAMLResponse; use SAML2\XML\md\IDPSSODescriptor; /** * @SuppressWarnings(PHPMD.TooManyPublicMethods) Allows for better control + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class MockIdentityProvider extends AbstractMockEntityRole { @@ -347,4 +350,68 @@ protected function getRoleClass() { return IDPSSODescriptor::class; } + + /** + * Handle serialization of the MockIdentityProvider. + * Convert the SAMLResponse (which contains non-serializable DOMDocument) to XML string. + * + * @return array + */ + public function __sleep() + { + $role = $this->getSsoRole(); + $extensions = $role->getExtensions(); + + // Convert SAMLResponse to XML if it exists + if (isset($extensions['SAMLResponse']) && $extensions['SAMLResponse'] instanceof SAMLResponse) { + $samlResponse = $extensions['SAMLResponse']; + $xml = $samlResponse->toUnsignedXML()->ownerDocument->saveXML(); + + // Store the XML and RelayState temporarily in the extensions + $extensions['_SAMLResponseXML'] = $xml; + $extensions['_SAMLResponseRelayState'] = $samlResponse->getRelayState(); + unset($extensions['SAMLResponse']); + $role->setExtensions($extensions); + } + + return ['name', 'descriptor', 'sendAssertions', 'turnBackTime', 'fromTheFuture']; + } + + /** + * Handle deserialization of the MockIdentityProvider. + * Reconstruct the SAMLResponse from the stored XML string. + */ + public function __wakeup() + { + $role = $this->getSsoRole(); + $extensions = $role->getExtensions(); + + // Reconstruct SAMLResponse from XML if it was serialized + if (isset($extensions['_SAMLResponseXML'])) { + $xml = $extensions['_SAMLResponseXML']; + + // Parse the XML to get the DOMElement + $document = DOMDocumentFactory::fromString($xml); + $messageDomElement = $document->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response')->item(0); + + if ($messageDomElement) { + // Create a custom Response instance by passing the DOMElement to the constructor + // This properly initializes all the parent class properties + $samlResponse = new Response($messageDomElement); + + // Restore RelayState if it was stored + if (isset($extensions['_SAMLResponseRelayState']) && $extensions['_SAMLResponseRelayState'] !== null) { + $samlResponse->setRelayState($extensions['_SAMLResponseRelayState']); + } + + // DO NOT set the XML string - let the Response object generate signed XML dynamically + // when toXml() is called with the signature keys that will be set by ResponseFactory + + // Restore it to the extensions + unset($extensions['_SAMLResponseXML'], $extensions['_SAMLResponseRelayState']); + $extensions['SAMLResponse'] = $samlResponse; + $role->setExtensions($extensions); + } + } + } } diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockServiceProvider.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockServiceProvider.php index 91c18fdffe..aa157a4c39 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockServiceProvider.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockServiceProvider.php @@ -20,6 +20,7 @@ use OpenConext\EngineBlockFunctionalTestingBundle\Saml2\AuthnRequest; use SAML2\AuthnRequest as SAMLAuthnRequest; +use SAML2\DOMDocumentFactory; use SAML2\XML\md\SPSSODescriptor; /** @@ -180,4 +181,66 @@ public function setAuthnContextClassRef($classRef) ) ); } + + /** + * Handle serialization of the MockServiceProvider. + * Convert the SAMLRequest (which contains non-serializable DOMDocument) to XML string. + * + * @return array + */ + public function __sleep() + { + $extensions = $this->descriptor->getExtensions(); + + // Convert SAMLRequest to XML if it exists + if (isset($extensions['SAMLRequest']) && $extensions['SAMLRequest'] instanceof SAMLAuthnRequest) { + $samlRequest = $extensions['SAMLRequest']; + $xml = $samlRequest->toUnsignedXML()->ownerDocument->saveXML(); + + // Store the XML and RelayState temporarily in the extensions + $extensions['_SAMLRequestXML'] = $xml; + $extensions['_SAMLRequestRelayState'] = $samlRequest->getRelayState(); + unset($extensions['SAMLRequest']); + $this->descriptor->setExtensions($extensions); + } + + return ['name', 'descriptor']; + } + + /** + * Handle deserialization of the MockServiceProvider. + * Reconstruct the SAMLRequest from the stored XML string. + */ + public function __wakeup() + { + $extensions = $this->descriptor->getExtensions(); + + // Reconstruct SAMLRequest from XML if it was serialized + if (isset($extensions['_SAMLRequestXML'])) { + $xml = $extensions['_SAMLRequestXML']; + + // Parse the XML to get the DOMElement + $document = DOMDocumentFactory::fromString($xml); + $messageDomElement = $document->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'AuthnRequest')->item(0); + + if ($messageDomElement) { + // Create a custom AuthnRequest instance by passing the DOMElement to the constructor + // This properly initializes all the parent class properties + $samlRequest = new AuthnRequest($messageDomElement); + + // Restore RelayState if it was stored + if (isset($extensions['_SAMLRequestRelayState']) && $extensions['_SAMLRequestRelayState'] !== null) { + $samlRequest->setRelayState($extensions['_SAMLRequestRelayState']); + } + + // DO NOT set the XML string - let the AuthnRequest object generate signed XML dynamically + // when toXml() is called with the signature keys if signing is configured + + // Restore it to the extensions + unset($extensions['_SAMLRequestXML'], $extensions['_SAMLRequestRelayState']); + $extensions['SAMLRequest'] = $samlRequest; + $this->descriptor->setExtensions($extensions); + } + } + } } diff --git a/tests/behat.yml b/tests/behat.yml index 7df80fd6c4..aa9ecbf600 100644 --- a/tests/behat.yml +++ b/tests/behat.yml @@ -19,6 +19,7 @@ default: pdpClient: '@engineblock.functional_testing.fixture.pdp_client' authenticationLoopGuard: '@engineblock.functional_testing.fixture.authentication_loop_guard' attributeAggregationClient: '@engineblock.functional_testing.fixture.attribute_aggregation_client' + sbsClientStateManager: '@engineblock.functional_testing.fixture.sbs_client_state_manager' - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MockIdpContext: serviceRegistryFixture: '@engineblock.functional_testing.fixture.service_registry' engineBlock: '@engineblock.functional_testing.service.engine_block' @@ -41,8 +42,8 @@ default: mockIdpRegistry: '@engineblock.mock_entities.idp_registry' serviceRegistryFixture: '@engineblock.functional_testing.fixture.service_registry' - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\TranslationContext: - mockTranslator: '@engineblock.functional_testing.mock.translator' - - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext + mockTranslator: '@engineblock.functional_testing.mock.translator' + - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext: functional: mink_session: chrome mink_javascript_session: chrome diff --git a/tests/library/EngineBlock/Test/Corto/Filter/Command/EnforcePolicyTest.php b/tests/library/EngineBlock/Test/Corto/Filter/Command/EnforcePolicyTest.php index 67d2398626..cf7dbfa2a9 100644 --- a/tests/library/EngineBlock/Test/Corto/Filter/Command/EnforcePolicyTest.php +++ b/tests/library/EngineBlock/Test/Corto/Filter/Command/EnforcePolicyTest.php @@ -181,4 +181,13 @@ private function mockIdentityProviderWithoutPolicyEnforcement(): IdentityProvide $idp->shouldReceive('getCoins->policyEnforcementDecisionRequired')->andReturn(false); return $idp; } + protected function tearDown(): void + { + /** @var EngineBlock_Application_TestDiContainer $container */ + $container = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); + $container->setPdpClient(null); + + parent::tearDown(); + } + } diff --git a/tests/library/EngineBlock/Test/Corto/Filter/Command/SramInterruptFilterTest.php b/tests/library/EngineBlock/Test/Corto/Filter/Command/SramInterruptFilterTest.php new file mode 100644 index 0000000000..d532e1e9be --- /dev/null +++ b/tests/library/EngineBlock/Test/Corto/Filter/Command/SramInterruptFilterTest.php @@ -0,0 +1,309 @@ +sp = new ServiceProvider('SP'); + + $this->repository = Mockery::mock(MetadataRepositoryInterface::class); + $this->repository->shouldReceive('findServiceProviderByEntityId') + ->andReturn($this->sp); + } + + public function testItDoesNothingIfFeatureFlagNotEnabled(): void + { + $sbsClient = Mockery::mock(SbsClientInterface::class); + + $sramFilter = new EngineBlock_Corto_Filter_Command_SramInterruptFilter( + $sbsClient, + new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => false]), + new SbsAttributeMerger([]), + new NullLogger(), + ); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $sramFilter->execute(); + $this->assertEmpty($sramFilter->getResponseAttributes()); + } + + public function testItDoesNothingIfSpDoesNotHaveCollabEnabled(): void + { + $sbsClient = Mockery::mock(SbsClientInterface::class); + + $sramFilter = new EngineBlock_Corto_Filter_Command_SramInterruptFilter( + $sbsClient, + new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => true]), + new SbsAttributeMerger([]), + new NullLogger(), + ); + + $server = Mockery::mock(EngineBlock_Corto_ProxyServer::class); + $server->shouldReceive('getRepository') + ->andReturn($this->repository); + + $sramFilter->setProxyServer($server); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $sbsClient->shouldNotReceive('authz'); + + $sp = $this->mockServiceProvider('spEntityId'); + $sp->expects('getCoins->collabEnabled')->andReturn(false); + + $sramFilter->setServiceProvider($sp); + + $sramFilter->execute(); + $this->assertEmpty($sramFilter->getResponseAttributes()); + } + + public function testItAddsNonceWhenMessageInterrupt(): void + { + $sbsClient = Mockery::mock(SbsClientInterface::class); + + $sramFilter = new EngineBlock_Corto_Filter_Command_SramInterruptFilter( + $sbsClient, + new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => true]), + new SbsAttributeMerger([]), + new NullLogger(), + ); + + $initialAttributes = ['urn:mace:dir:attribute-def:uid' => ['userIdValue']]; + $sramFilter->setResponseAttributes($initialAttributes); + + $server = Mockery::mock(EngineBlock_Corto_ProxyServer::class); + $server->expects('getUrl')->andReturn('https://example.org'); + $server->shouldReceive('getRepository') + ->andReturn($this->repository); + + $sramFilter->setProxyServer($server); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $sramFilter->setResponse(new EngineBlock_Saml2_ResponseAnnotationDecorator(new Response())); + + $response = AuthzResponse::fromData([ + 'msg' => 'interrupt', + 'nonce' => 'hash123', + 'attributes' => [ + 'dummy' => 'attributes', + ] + ]); + + $expectedRequest = new AuthzRequest('', '', 'https://example.org?ID=', 'spEntityId', 'idpEntityId'); + + $sbsClient->shouldReceive('authz') + ->withArgs(function ($args) use ($expectedRequest) { + + return $args->userId === $expectedRequest->userId + && $args->eduPersonPrincipalName === $expectedRequest->eduPersonPrincipalName + && strpos($args->continueUrl, $expectedRequest->continueUrl) === 0 + && $args->serviceId === $expectedRequest->serviceId + && $args->issuerId === $expectedRequest->issuerId; + }) + ->andReturn($response); + + /** @var \Mockery\Mock|ServiceProvider $sp */ + $sp = $this->mockServiceProvider('spEntityId'); + $sp->expects('getCoins->collabEnabled')->andReturn(true); + $sramFilter->setServiceProvider($sp); + + /** @var \Mockery\Mock|IdentityProvider $sp */ + $idp = $this->mockIdentityProvider('idpEntityId'); + $sramFilter->setIdentityProvider($idp); + + $sramFilter->execute(); + $this->assertSame($initialAttributes, $sramFilter->getResponseAttributes()); + $this->assertSame('hash123', $sramFilter->getResponse()->getSramInterruptNonce()); + } + + public function testItAddsSramAttributesOnStatusAuthorized(): void + { + $sbsClient = Mockery::mock(SbsClientInterface::class); + + $attributeMerger = new SbsAttributeMerger([ + 'urn:mace:dir:attribute-def:uid', + 'urn:mace:dir:attribute-def:eduPersonEntitlement', + ]); + + $sramFilter = new EngineBlock_Corto_Filter_Command_SramInterruptFilter( + $sbsClient, + new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => true]), + $attributeMerger, + new NullLogger() + ); + + $initialAttributes = ['urn:mace:dir:attribute-def:uid' => ['userIdValue']]; + $sramFilter->setResponseAttributes($initialAttributes); + + $server = Mockery::mock(EngineBlock_Corto_ProxyServer::class); + $server->expects('getUrl')->andReturn('https://example.org'); + $server->shouldReceive('getRepository') + ->andReturn($this->repository); + + $sramFilter->setProxyServer($server); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $sramFilter->setResponse(new EngineBlock_Saml2_ResponseAnnotationDecorator(new Response())); + + + $response = AuthzResponse::fromData([ + 'msg' => 'authorized', + 'nonce' => 'hash123', + 'attributes' => [ + 'urn:mace:dir:attribute-def:uid' => ['userIdValue'], + 'urn:mace:dir:attribute-def:eduPersonEntitlement' => 'attributes', + ], + ]); + + $expectedRequest = new AuthzRequest('', '', 'https://example.org?ID=', 'spEntityId', 'idpEntityId'); + + $sbsClient->shouldReceive('authz') + ->withArgs(function ($args) use ($expectedRequest) { + + return $args->userId === $expectedRequest->userId + && $args->eduPersonPrincipalName === $expectedRequest->eduPersonPrincipalName + && str_starts_with($args->continueUrl, $expectedRequest->continueUrl) + && $args->serviceId === $expectedRequest->serviceId + && $args->issuerId === $expectedRequest->issuerId; + }) + ->andReturn($response); + + /** @var \Mockery\Mock|ServiceProvider $sp */ + $sp = $this->mockServiceProvider('spEntityId'); + $sp->expects('getCoins->collabEnabled')->andReturn(true); + $sramFilter->setServiceProvider($sp); + + /** @var \Mockery\Mock|IdentityProvider $sp */ + $idp = $this->mockIdentityProvider('idpEntityId'); + $sramFilter->setIdentityProvider($idp); + + + $expectedAttributes = [ + 'urn:mace:dir:attribute-def:uid' => ['userIdValue'], + 'urn:mace:dir:attribute-def:eduPersonEntitlement' => 'attributes', + ]; + + $sramFilter->execute(); + $this->assertSame($expectedAttributes, $sramFilter->getResponseAttributes()); + $this->assertSame('', $sramFilter->getResponse()->getSramInterruptNonce()); + } + + public function testThrowsEngineBlockExceptionIfPolicyCannotBeChecked() + { + $this->expectException(EngineBlock_Exception_SbsCheckFailed::class); + $this->expectExceptionMessage('The SBS server could not be queried: Server could not be reached.'); + + $sbsClient = Mockery::mock(SbsClientInterface::class); + $sbsClient->expects('authz')->andThrows(new InvalidSbsResponseException('Server could not be reached.')); + + $sramFilter = new EngineBlock_Corto_Filter_Command_SramInterruptFilter( + $sbsClient, + new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => true]), + new SbsAttributeMerger([]), + new NullLogger(), + ); + + $initialAttributes = ['urn:mace:dir:attribute-def:uid' => ['userIdValue']]; + $sramFilter->setResponseAttributes($initialAttributes); + + $server = Mockery::mock(EngineBlock_Corto_ProxyServer::class); + $server->expects('getUrl')->andReturn('https://example.org'); + $server->shouldReceive('getRepository') + ->andReturn($this->repository); + + $sramFilter->setProxyServer($server); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $sramFilter->setResponse(new EngineBlock_Saml2_ResponseAnnotationDecorator(new Response())); + + + /** @var \Mockery\Mock|ServiceProvider $sp */ + $sp = $this->mockServiceProvider('spEntityId'); + $sp->expects('getCoins->collabEnabled')->andReturn(true); + $sramFilter->setServiceProvider($sp); + + /** @var \Mockery\Mock|IdentityProvider $sp */ + $idp = $this->mockIdentityProvider('idpEntityId'); + $sramFilter->setIdentityProvider($idp); + + $sramFilter->execute(); + } + + private function mockServiceProvider(string $entityId): ServiceProvider + { + $sp = Mockery::mock(ServiceProvider::class); + $sp->entityId = $entityId; + $sp->shouldReceive('getCoins->isTrustedProxy')->andReturn(false); + $sp->shouldReceive('getCoins->policyEnforcementDecisionRequired')->andReturn(true); + return $sp; + } + + private function mockIdentityProvider(string $entityId): IdentityProvider + { + $idp = Mockery::mock(IdentityProvider::class); + $idp->entityId = $entityId; + return $idp; + } + + private function mockRequest(): EngineBlock_Saml2_AuthnRequestAnnotationDecorator + { + $assertion = new Assertion(); + $request = new AuthnRequest(); + $response = new SAML2\Response(); + $response->setAssertions(array($assertion)); + return new EngineBlock_Saml2_AuthnRequestAnnotationDecorator($request); + } +} diff --git a/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php b/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php index fa21557415..996020bdf2 100644 --- a/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php +++ b/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php @@ -22,6 +22,7 @@ use Monolog\Logger; use OpenConext\EngineBlock\Metadata\Entity\IdentityProvider; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; +use OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\TestCase; use SAML2\Assertion; @@ -64,7 +65,7 @@ public function testItShouldRunInNormalConditions() { $this->expectNotToPerformAssertions(); - $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(); + $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => false])); $verifier->setResponse($this->response); $sp = new ServiceProvider('FoobarSP'); $sp->allowAll = true; @@ -73,10 +74,21 @@ public function testItShouldRunInNormalConditions() $verifier->execute(); } + public function testItShouldReturnNullIfSramIsEnabled() + { + $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => true])); + $verifier->setResponse($this->response); + $sp = new ServiceProvider('FoobarSP'); + $sp->allowAll = true; + $verifier->setIdentityProvider(new IdentityProvider('OpenConext')); + $verifier->setServiceProvider($sp); + $this->assertNull($verifier->execute()); + } + #[DoesNotPerformAssertions] public function testItShouldRunInNormalConditionsWithTrustedProxy() { - $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(); + $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => false])); $verifier->setResponse($this->response); $sp = m::mock(ServiceProvider::class); $sp->shouldReceive('isAllowed')->andReturn(true); @@ -86,6 +98,7 @@ public function testItShouldRunInNormalConditionsWithTrustedProxy() $verifier->setProxyServer($server); $verifier->setRequest(m::mock(EngineBlock_Saml2_AuthnRequestAnnotationDecorator::class)); $sp->shouldReceive('getCoins->isTrustedProxy')->andReturn(true); + $sp->shouldReceive('getCoins->collabEnabled')->andReturn(false); $server->shouldReceive('findOriginalServiceProvider')->andReturn($sp); $server->shouldReceive('getLogger')->andReturn($logger); $verifier->setIdentityProvider(new IdentityProvider('OpenConext')); @@ -95,7 +108,7 @@ public function testItShouldRunInNormalConditionsWithTrustedProxy() public function testNotAllowed() { - $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(); + $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => false])); $verifier->setResponse($this->response); $sp = new ServiceProvider('FoobarSP'); $sp->allowAll = false; @@ -105,4 +118,21 @@ public function testNotAllowed() self::expectExceptionMessage('Disallowed response by SP configuration. Response from IdP "OpenConext" to SP "FoobarSP"'); $verifier->execute(); } + + public function testIsAllowedWhenCollabEnabledCoinIsTrueEvenWhenNotAllowed() + { + $verifier = new EngineBlock_Corto_Filter_Command_ValidateAllowedConnection(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => true])); + $verifier->setResponse($this->response); + + $sp = new ServiceProvider( + entityId: 'FoobarSP', + collabEnabled: true + ); + $sp->allowAll = false; + + $verifier->setIdentityProvider(new IdentityProvider('OpenConext')); + $verifier->setServiceProvider($sp); + $verifier->execute(); + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php b/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php index a7e53b6da1..18095329ff 100644 --- a/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php @@ -96,7 +96,7 @@ public function setUp(): void public function testSessionLostExceptionIfNoSession() { $this->expectException(EngineBlock_Corto_Module_Services_SessionLostException::class); - $this->expectExceptionMessage('Session lost after consent'); + $this->expectExceptionMessage('Session lost'); $sessionData = [ 'Processing' => [], diff --git a/tests/library/EngineBlock/Test/Corto/ProxyServerTest.php b/tests/library/EngineBlock/Test/Corto/ProxyServerTest.php index f1a7add364..9249f288b2 100644 --- a/tests/library/EngineBlock/Test/Corto/ProxyServerTest.php +++ b/tests/library/EngineBlock/Test/Corto/ProxyServerTest.php @@ -48,6 +48,28 @@ public function testDefaultNameIDPolicy() $this->assertSame(['AllowCreate' => true], $nameIdPolicy); } + public function testAllowCreateIsSet() + { + $proxyServer = $this->factoryProxyServer(); + + $originalRequest = $this->factoryOriginalRequest(); + $identityProvider = $proxyServer->getRepository()->fetchIdentityProviderByEntityId('testIdp'); + /** @var AuthnRequest $enhancedRequest */ + $enhancedRequest = EngineBlock_Saml2_AuthnRequestFactory::createFromRequest( + $originalRequest, + $identityProvider, + $proxyServer + ); + + $nameIdPolicy = $enhancedRequest->getNameIdPolicy(); + + $this->assertContains( + 'AllowCreate', + array_keys($nameIdPolicy), + 'The NameIDPolicy should contain the key "AllowCreate"', + ); + } + public function testNameIDFormatIsSetFromRemoteMetaData() { $proxyServer = $this->factoryProxyServer(); diff --git a/tests/unit/OpenConext/EngineBlockBundle/Sbs/AttributesResponseTest.php b/tests/unit/OpenConext/EngineBlockBundle/Sbs/AttributesResponseTest.php new file mode 100644 index 0000000000..8f50d759d9 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Sbs/AttributesResponseTest.php @@ -0,0 +1,54 @@ + ['key1' => 'value1', 'key2' => 'value2']]; + + $response = AttributesResponse::fromData($jsonData); + + $this->assertInstanceOf(AttributesResponse::class, $response); + $this->assertEquals($jsonData['attributes'], $response->attributes); + } + + public function testFromDataMissingAttributes() + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('Key: Attributes was not found in the SBS attributes response'); + + $jsonData = ['someOtherKey' => []]; + AttributesResponse::fromData($jsonData); + } + + public function testFromDataAttributesNotArray() + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('Key: Attributes was not an array in the SBS attributes response'); + + $jsonData = ['attributes' => 'not_an_array']; + AttributesResponse::fromData($jsonData); + } +} diff --git a/tests/unit/OpenConext/EngineBlockBundle/Sbs/AuthzResponseTest.php b/tests/unit/OpenConext/EngineBlockBundle/Sbs/AuthzResponseTest.php new file mode 100644 index 0000000000..f668e31e11 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Sbs/AuthzResponseTest.php @@ -0,0 +1,100 @@ + Msg::Authorized->value, + 'attributes' => ['role' => 'admin'] + ]; + + $response = AuthzResponse::fromData($jsonData); + + $this->assertEquals(Msg::Authorized, $response->msg); + $this->assertEquals(['role' => 'admin'], $response->attributes); + $this->assertNull($response->nonce); + } + + public function testFromDataValidInterruptResponse(): void + { + $jsonData = [ + 'msg' => Msg::Interrupt->value, + 'nonce' => 'random_nonce' + ]; + + $response = AuthzResponse::fromData($jsonData); + + $this->assertEquals(Msg::Interrupt, $response->msg); + $this->assertEquals('random_nonce', $response->nonce); + $this->assertEmpty($response->attributes); + } + + public function testFromDataMissingMsgThrowsException(): void + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('Key: "msg" was not found in the SBS response'); + + AuthzResponse::fromData([]); + } + + public function testFromDataInvalidMsgThrowsException(): void + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('"INVALID" is not a valid msg'); + + AuthzResponse::fromData(['msg' => 'INVALID']); + } + + public function testFromDataInterruptWithoutNonceThrowsException(): void + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('Key: "nonce" was not found in the SBS response'); + + AuthzResponse::fromData(['msg' => Msg::Interrupt->value]); + } + + public function testFromDataAuthorizedWithoutAttributesThrowsException(): void + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('Key: "attributes" was not found in the SBS response'); + + AuthzResponse::fromData(['msg' => Msg::Authorized->value]); + } + + public function testFromDataAttributesNotArrayDefaultsToEmpty(): void + { + $jsonData = [ + 'msg' => Msg::Authorized->value, + 'attributes' => 'invalid_type' + ]; + + $response = AuthzResponse::fromData($jsonData); + + $this->assertEquals(Msg::Authorized, $response->msg); + $this->assertEmpty($response->attributes); + } +} diff --git a/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMergerTest.php b/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMergerTest.php new file mode 100644 index 0000000000..d7fbb2ea08 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMergerTest.php @@ -0,0 +1,107 @@ + '1234', + "eduPersonEntitlement" => ["user_aff1@test.nl"], + "eduPersonPrincipalName" => ["test_user@test.nl"], + "uid" => ["test_user"], + "original" => ['bar', 'soap'], + "myString" => 'foobar', + ]; + + $sbsAttributes = [ + "uuid" => '5678', + "eduPersonEntitlement" => ["user_aff2@test.nl"], + "eduPersonPrincipalName" => ["test_user@test.nl"], + "sshkey" => ["ssh_key1", "ssh_key2"] + ]; + + $expectedResult = [ + "uuid" => '5678', + "eduPersonEntitlement" => ["user_aff1@test.nl", "user_aff2@test.nl"], + "eduPersonPrincipalName" => ["test_user@test.nl"], + "uid" => ["test_user"], + "sshkey" => ["ssh_key1", "ssh_key2"], + "original" => ['bar', 'soap'], + "myString" => 'foobar', + ]; + + $this->assertEquals($expectedResult, $merger->mergeAttributes($samlAttributes, $sbsAttributes)); + } + + public function testMergeAttributesWithInvalidKeysThrowsException(): void + { + $allowedAttributes = [ + 'email', + 'name' + ]; + $merger = new SbsAttributeMerger($allowedAttributes); + + $samlAttributes = [ + 'email' => ['user@example.com'], + 'role' => ['admin'] + ]; + + $sbsAttributes = [ + 'role' => ['user'] + ]; + + $expectedResult = [ + 'email' => ['user@example.com'], + 'role' => ['admin'] + ]; + + $this->assertEquals($expectedResult, $merger->mergeAttributes($samlAttributes, $sbsAttributes)); + } + + public function testMergeAttributesWithEmptySbsAttributes(): void + { + $allowedAttributes = [ + 'email', + 'name' + ]; + $merger = new SbsAttributeMerger($allowedAttributes); + + $samlAttributes = [ + 'email' => ['user@example.com'], + 'role' => ['admin'] + ]; + + $sbsAttributes = []; + + $this->assertEquals($samlAttributes, $merger->mergeAttributes($samlAttributes, $sbsAttributes)); + } +} diff --git a/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsClientTest.php b/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsClientTest.php new file mode 100644 index 0000000000..4c0fd087dc --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsClientTest.php @@ -0,0 +1,102 @@ +guzzleMock = $this->createMock(ClientInterface::class); + $this->httpClient = $this->createMock(HttpClient::class); + + $this->sbsClient = new SbsClient( + $this->httpClient, + 'https://sbs.example.com/', + '/authz', + '/authz', + '/interrupt', + 'Bearer test_token', + true + ); + } + + public function testAuthz(): void + { + $requestMock = $this->createMock(AuthzRequest::class); + $jsonResponse = ['msg' => 'interrupt', 'nonce' => 'hash']; + + $this->httpClient->expects($this->once()) + ->method('post') + ->with( + $this->anything(), + 'https://sbs.example.com/authz', + [], + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer test_token', + ] + ) + ->willReturn($jsonResponse); + + $authzResponse = $this->sbsClient->authz($requestMock); + + $this->assertInstanceOf(AuthzResponse::class, $authzResponse); + } + + public function testRequestAttributesFor(): void + { + $requestMock = $this->createMock(AttributesRequest::class); + $jsonResponse = [ + 'msg' => 'authorized', + 'attributes' => ['name' => 'value'] + ]; + + $this->httpClient->expects($this->once()) + ->method('post') + ->with( + $this->anything(), + 'https://sbs.example.com/authz', + [], + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer test_token', + ] + ) + ->willReturn($jsonResponse); + + $attributesResponse = $this->sbsClient->requestAttributesFor($requestMock); + + $this->assertInstanceOf(AttributesResponse::class, $attributesResponse); + } +}