diff --git a/app/config/config.yml b/app/config/config.yml index 9c69ca34f1..7103d27d19 100644 --- a/app/config/config.yml +++ b/app/config/config.yml @@ -29,7 +29,7 @@ open_conext_engine_block: eb.enable_sso_session_cookie: "%feature_enable_sso_session_cookie%" eb.feature_enable_idp_initiated_flow: "%feature_enable_idp_initiated_flow%" eb.stepup.sfo.override_engine_entityid: "%feature_stepup_sfo_override_engine_entityid%" - + eb.feature_enable_sram_interrupt: "%feature_enable_sram_interrupt%" swiftmailer: transport: "%mailer_transport%" diff --git a/app/config/parameters.yml.dist b/app/config/parameters.yml.dist index fadfe89f8c..8f77b54cfc 100644 --- a/app/config/parameters.yml.dist +++ b/app/config/parameters.yml.dist @@ -231,6 +231,7 @@ parameters: feature_enable_consent: true feature_stepup_sfo_override_engine_entityid: false feature_enable_idp_initiated_flow: true + feature_enable_sram_interrupt: false ########################################################################################## ## PROFILE SETTINGS @@ -311,3 +312,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/docker/docker-compose.yml b/docker/docker-compose.yml index 93270ca474..fcf9aae10f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,7 +4,6 @@ services: mariadb: image: mariadb:10.2 - restart: always container_name: eb-db-test environment: MYSQL_ROOT_PASSWORD: "root" @@ -62,5 +61,5 @@ services: - ../theme:/theme volumes: - eb-mysql-data: + # eb-mysql-data: eb-mysql-test-data: diff --git a/docs/filter_commands.md b/docs/filter_commands.md index 07a5807dfe..188ac8ac08 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,7 +343,8 @@ Uses: - OpenConext\EngineBlock\Metadata\Entity\IdentityProvider - EngineBlock_Saml2_AuthnRequestAnnotationDecorator - +### SRAM test filter +When enabled and the SP has the collab_enabled coin, the SBS integration flow will be activated allowing SRAM integration. diff --git a/library/EngineBlock/Application/DiContainer.php b/library/EngineBlock/Application/DiContainer.php index f30156478f..4045920a65 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; class EngineBlock_Application_DiContainer extends Pimple @@ -306,6 +308,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('engineblock.pdp.pdp_client'); diff --git a/library/EngineBlock/Application/TestDiContainer.php b/library/EngineBlock/Application/TestDiContainer.php index 39246a54b9..fe68c4d254 100644 --- a/library/EngineBlock/Application/TestDiContainer.php +++ b/library/EngineBlock/Application/TestDiContainer.php @@ -17,7 +17,9 @@ */ use OpenConext\EngineBlock\Stepup\StepupEndpoint; +use OpenConext\EngineBlockBundle\Configuration\FeatureConfigurationInterface; use OpenConext\EngineBlockBundle\Pdp\PdpClientInterface; +use OpenConext\EngineBlockBundle\Sbs\SbsClientInterface; /** * Creates mocked versions of dependencies for unit testing @@ -29,6 +31,16 @@ class EngineBlock_Application_TestDiContainer extends EngineBlock_Application_Di */ private $pdpClient; + /** + * @var SbsClientInterface|null + */ + private $sbsClient; + + /** + * @var FeatureConfigurationInterface|null + */ + private $featureConfiguration; + public function getXmlConverter() { return Phake::mock('EngineBlock_Corto_XmlToArray'); @@ -49,11 +61,31 @@ public function getPdpClient() return $this->pdpClient ?? parent::getPdpClient(); } - public function setPdpClient(PdpClientInterface $pdpClient) + public function setPdpClient(?PdpClientInterface $pdpClient) { $this->pdpClient = $pdpClient; } + public function setSbsClient(?SbsClientInterface $sbsClient) + { + $this->sbsClient = $sbsClient; + } + + public function getSbsClient(): SbsClientInterface + { + return $this->sbsClient ?? parent::getSbsClient(); + } + + public function setFeatureConfiguration(?FeatureConfigurationInterface $featureConfiguration) + { + $this->featureConfiguration = $featureConfiguration; + } + + public function getFeatureConfiguration(): FeatureConfigurationInterface + { + return $this->featureConfiguration ?? parent::getFeatureConfiguration(); + } + public function getConsentFactory() { $consentFactoryMock = Phake::mock('EngineBlock_Corto_Model_Consent_Factory'); diff --git a/library/EngineBlock/Corto/Adapter.php b/library/EngineBlock/Corto/Adapter.php index 3f825f277f..a61627a54f 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..8d7a226f08 --- /dev/null +++ b/library/EngineBlock/Corto/Filter/Command/SRAMInterruptFilter.php @@ -0,0 +1,122 @@ +_responseAttributes; + } + + public function getResponse() + { + return $this->_response; + } + + public function execute(): void + { + $log = EngineBlock_ApplicationSingleton::getLog(); + + if (!$this->getFeatureConfiguration()->isEnabled('eb.feature_enable_sram_interrupt')) { + return; + } + + $serviceProvider = EngineBlock_SamlHelper::findRequesterServiceProvider( + $this->_serviceProvider, + $this->_request, + $this->_server->getRepository(), + $this->logger + ); + + if (!$serviceProvider) { + $serviceProvider = $this->_serviceProvider; + } + + if ($serviceProvider->getCoins()->collabEnabled() === false) { + $log->notice("No SBS interrupt for serviceProvider: " . $serviceProvider->entityId); + return; + } + + $log->notice("SBS interrupt for serviceProvider: " . $serviceProvider->entityId); + + try { + $request = $this->buildRequest($serviceProvider); + + $interruptResponse = $this->getSbsClient()->authz($request); + + if ($interruptResponse->msg === 'interrupt') { + $log->info("SBS interrupt reason: " . $interruptResponse->message); + $this->_response->setSRAMInterruptNonce($interruptResponse->nonce); + } elseif ($interruptResponse->msg === 'authorized' && !empty($interruptResponse->attributes)) { + $this->_responseAttributes = $this->getSbsAttributeMerger()->mergeAttributes($this->_responseAttributes, $interruptResponse->attributes); + } else { + throw new InvalidSbsResponseException(sprintf('Invalid SBS response received: %s', $interruptResponse->msg)); + } + } catch (Throwable $e){ + throw new EngineBlock_Exception_SbsCheckFailed('The SBS server could not be queried: ' . $e->getMessage()); + } + } + + private function getSbsClient() + { + return EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()->getSbsClient(); + } + + private function getFeatureConfiguration(): FeatureConfigurationInterface + { + return EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()->getFeatureConfiguration(); + } + + private function getSbsAttributeMerger(): SbsAttributeMerger + { + return EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()->getSbsAttributeMerger(); + } + + /** + * @return AuthzRequest + * @throws EngineBlock_Corto_ProxyServer_Exception + */ + private function buildRequest($serviceProvider): AuthzRequest + { + $attributes = $this->getResponseAttributes(); + $id = $this->_request->getId(); + + $user_id = $this->_collabPersonId ?? ""; + $eppn = $attributes['urn:mace:dir:attribute-def:eduPersonPrincipalName'][0] ?? ""; + $continue_url = $this->_server->getUrl('SRAMInterruptService', '') . "?ID=$id"; + $service_id = $serviceProvider->entityId; + $issuer_id = $this->_identityProvider->entityId; + + return AuthzRequest::create( + $user_id, + $eppn, + $continue_url, + $service_id, + $issuer_id + ); + } +} diff --git a/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php b/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php index bb81bdbfe5..ec03586f70 100644 --- a/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php +++ b/library/EngineBlock/Corto/Filter/Command/ValidateAllowedConnection.php @@ -16,6 +16,8 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; + /** * Validate if the IDP sending this response is allowed to connect to the SP that made the request. **/ @@ -24,6 +26,11 @@ class EngineBlock_Corto_Filter_Command_ValidateAllowedConnection extends EngineB public function execute() { $sp = $this->_serviceProvider; + + 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 +48,9 @@ public function execute() ); } } + + private function sbsFlowActive(ServiceProvider $sp) + { + return $sp->getCoins()->collabEnabled(); + } } diff --git a/library/EngineBlock/Corto/Filter/Input.php b/library/EngineBlock/Corto/Filter/Input.php index 3c55a067d6..4db94f46f4 100644 --- a/library/EngineBlock/Corto/Filter/Input.php +++ b/library/EngineBlock/Corto/Filter/Input.php @@ -90,6 +90,10 @@ 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(), + // 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/Service/AssertionConsumer.php b/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php index 0ca31bce62..26b81c6290 100644 --- a/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php +++ b/library/EngineBlock/Corto/Module/Service/AssertionConsumer.php @@ -170,51 +170,19 @@ public function serve($serviceName, Request $httpRequest) $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); + //Send SRAM Interrupt call + if ($this->_server->handleSRAMInterruptCallout($receivedResponse, $receivedRequest)) { + return; } - $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()); + // Handle Consent authentication callout + if ($this->_server->handleConsentAuthenticationCallout($receivedResponse, $receivedRequest)) { return; } - $log->info('Handle Stepup authentication callout'); - - // 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); - - $this->_server->sendStepupAuthenticationRequest( - $receivedRequest, - $currentProcessStep->getRole(), - $authnClassRef, - $nameId, - $sp->getCoins()->isStepupForceAuthn() - ); - } + // Handle Stepup authentication callout + $this->_server->handleStepupAuthenticationCallout($receivedResponse, $receivedRequest); + } /** * @return AuthenticationState diff --git a/library/EngineBlock/Corto/Module/Service/SRAMInterrupt.php b/library/EngineBlock/Corto/Module/Service/SRAMInterrupt.php new file mode 100644 index 0000000000..fc9698d36f --- /dev/null +++ b/library/EngineBlock/Corto/Module/Service/SRAMInterrupt.php @@ -0,0 +1,119 @@ +_server = $server; + $this->_authenticationStateHelper = $stateHelper; + $this->_processingStateHelper = $processingStateHelper; + $this->_stepupGatewayCallOutHelper = $stepupGatewayCallOutHelper; + $this->_sbsAttributeMerger = $sbsAttributeMerger; + } + + /** + * route that receives the user when they get back from their SBS interrupt, + * fetches the attributes from SBS, + * and resumes the AuthN flow. + * + * @param $serviceName + * @param Request $httpRequest + */ + public function serve($serviceName, Request $httpRequest) + { + $application = EngineBlock_ApplicationSingleton::getInstance(); + + // Get active request + $id = $httpRequest->get('ID'); + + $nextProcessStep = $this->_processingStateHelper->getStepByRequestId( + $id, + ProcessingStateHelperInterface::STEP_SRAM + ); + + $receivedResponse = $nextProcessStep->getResponse(); + $receivedRequest = $this->_server->getReceivedRequestFromResponse($receivedResponse); + + $attributes = $receivedResponse->getAssertion()->getAttributes(); + $nonce = $receivedResponse->getSRAMInterruptNonce(); + + $request = AttributesRequest::create($nonce); + $interruptResponse = $this->getSbsClient()->requestAttributesFor($request); + + if (!empty($interruptResponse->attributes)) { + $attributes = $this->_sbsAttributeMerger->mergeAttributes($attributes, $interruptResponse->attributes); + $receivedResponse->getAssertion()->setAttributes($attributes); + } + + /* + * Continue to Consent/StepUp + */ + + if ($this->_server->handleConsentAuthenticationCallout($receivedResponse, $receivedRequest)) { + return; + } + + $this->_server->handleStepupAuthenticationCallout($receivedResponse, $receivedRequest); + } + + private function getSbsClient() + { + return EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()->getSbsClient(); + } +} diff --git a/library/EngineBlock/Corto/Module/Services.php b/library/EngineBlock/Corto/Module/Services.php index d700844d3a..92114999af 100644 --- a/library/EngineBlock/Corto/Module/Services.php +++ b/library/EngineBlock/Corto/Module/Services.php @@ -113,6 +113,14 @@ 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->getAuthenticationStateHelper(), + $diContainer->getProcessingStateHelper(), + $diContainer->getStepupGatewayCallOutHelper(), + $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 2cb5e45099..745467a16f 100644 --- a/library/EngineBlock/Corto/ProxyServer.php +++ b/library/EngineBlock/Corto/ProxyServer.php @@ -26,8 +26,10 @@ use OpenConext\EngineBlock\Metadata\MfaEntity; use OpenConext\EngineBlock\Metadata\Service; use OpenConext\EngineBlock\Metadata\TransparentMfaEntity; +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; @@ -71,7 +73,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 @@ -90,7 +93,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( @@ -124,6 +128,8 @@ class EngineBlock_Corto_ProxyServer protected $_templateSource; protected $_processingMode = false; + protected $_diContainer = null; + /** * @var EngineBlock_Saml2_AuthnRequestAnnotationDecorator */ @@ -142,6 +148,7 @@ class EngineBlock_Corto_ProxyServer public function __construct(Twig_Environment $twig) { $this->_server = $this; + $this->_diContainer = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); $this->twig = $twig; } @@ -351,6 +358,20 @@ public function getRepository() return $this->_repository; } + /** + * @return ServiceProvider + */ + public function getEngineSpRole() + { + $keyId = $this->getKeyId(); + if (!$keyId) { + $keyId = KeyPairFactory::DEFAULT_KEY_PAIR_IDENTIFIER; + } + + $serviceProvider = $this->_diContainer->getServiceProviderFactory()->createEngineBlockEntityFrom($keyId); + return ServiceProvider::fromServiceProviderEntity($serviceProvider); + } + //////// MAIN ///////// public function serve($serviceName, $remoteIdpMd5 = "") @@ -400,6 +421,131 @@ public function setRemoteIdpMd5($remoteIdPMd5) return $this; } + +//////// CALLOUT HANDLERS //////// + + function handleSRAMInterruptCallout( + $receivedResponse, + $receivedRequest + ) { + $logger = $this->getLogger(); + $logger->info('Handle SRAM interrupt callout'); + + if ("" != $receivedResponse->getSRAMInterruptNonce()) { + + // Add the SRAM step + $this->_diContainer->getProcessingStateHelper()->addStep( + $receivedRequest->getId(), + ProcessingStateHelperInterface::STEP_SRAM, + $this->getEngineSpRole(), + $receivedResponse + ); + + // Redirect to SRAM + $this->sendSRAMInterruptRequest($receivedResponse, $receivedRequest); + + return true; + } + + return false; + } + + function handleStepupAuthenticationCallout( + $receivedResponse, + $receivedRequest + ) { + $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() + ); + } + + function handleConsentAuthenticationCallout( + $receivedResponse, + $receivedRequest + // $currentProcessStep + ) { + $logger = $this->getLogger(); + $logger->info('Handle Consent authentication callout'); + + // Add the consent step + $currentProcessStep = $this->_diContainer->getProcessingStateHelper()->addStep( + $receivedRequest->getId(), + ProcessingStateHelperInterface::STEP_CONSENT, + $this->getEngineSpRole(), + $receivedResponse + ); + + $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()); + + $shouldUseStepup = $this->_diContainer->getStepupGatewayCallOutHelper()->shouldUseStepup($idp, $sp, $authnRequestLoas, $pdpLoas); + + // Goto consent if no Stepup authentication is needed + if (!$shouldUseStepup) { + $this->sendConsentAuthenticationRequest($receivedResponse, $receivedRequest, $currentProcessStep->getRole(), $this->_diContainer->getAuthenticationStateHelper()->getAuthenticationState()); + return true; + } + + return false; + } + + //////// REQUEST HANDLING ///////// public function sendAuthenticationRequest( @@ -465,7 +611,15 @@ public function sendAuthenticationRequest( $this->getBindingsModule()->send($ebRequest, $identityProvider); } - public function sendStepupAuthenticationRequest( + function sendSRAMInterruptRequest($response, $request) { + $nonce = $response->getSRAMInterruptNonce(); + + $sbsClient = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()->getSbsClient(); + $redirect_url = $sbsClient->getInterruptLocationLink($nonce); + $this->redirect($redirect_url, ''); + } + + function sendStepupAuthenticationRequest( EngineBlock_Saml2_AuthnRequestAnnotationDecorator $spRequest, IdentityProvider $identityProvider, Loa $authnContextClassRef, 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 @@ +isTransparentErrorResponse = $isTransparentErrorResponse; } + + public function setSRAMInterruptNonce(string $SRAMInterruptNonce): void + { + $this->SRAMInterruptNonce = $SRAMInterruptNonce; + } + + public function getSRAMInterruptNonce(): string + { + return $this->SRAMInterruptNonce; + } + } diff --git a/sbs-stub/requirements.txt b/sbs-stub/requirements.txt new file mode 100644 index 0000000000..7e1060246f --- /dev/null +++ b/sbs-stub/requirements.txt @@ -0,0 +1 @@ +flask diff --git a/sbs-stub/sbs.py b/sbs-stub/sbs.py new file mode 100755 index 0000000000..848457d465 --- /dev/null +++ b/sbs-stub/sbs.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +import json +import logging +import secrets + +from flask import Flask, Response, request, render_template + +logging.getLogger().setLevel(logging.DEBUG) +logging.getLogger('flask_pyoidc').setLevel(logging.ERROR) +logging.getLogger('oic').setLevel(logging.ERROR) +logging.getLogger('jwkest').setLevel(logging.ERROR) +logging.getLogger('urllib3').setLevel(logging.ERROR) +logging.getLogger('werkzeug').setLevel(logging.ERROR) + +app = Flask(__name__, template_folder='templates', static_folder='static') + +nonces = {} + + +def debug(request): + for header in request.headers: + logging.debug(header) + logging.debug(f'request.args: {request.args}') + logging.debug(f'request.data: {request.data}') + logging.debug(f'request.form: {request.form}') + logging.debug(f'request.json: {request.json}') + + +@app.route('/', defaults={'path': ''}) +@app.route('/') +def catch_all(path): + logging.debug(f'-> {path}') + debug(request) + response = Response(status=200) + return response + + +@app.route('/api/users/authz_eb', methods=['POST']) +def authz(): + logging.debug('-> /api/users/authz_eb') + debug(request) + + uid = request.json.get('user_id') + continue_url = request.json.get('continue_url') + service_entity_id = request.json.get('service_id') + issuer_id = request.json.get('issuer_id') + + nonce = secrets.token_urlsafe() + nonces[nonce] = (uid, continue_url, service_entity_id, issuer_id) + + response = Response(status=200) + body = { + 'msg': 'interrupt', + # 'msg': 'authorized', + # 'msg': 'error', + 'nonce': nonce, + 'message': 'Foobar message', + 'attributes': { + 'urn:mace:dir:attribute-def:eduPersonEntitlement': [ + uid, + nonce, + 'urn:foobar' + ], + 'urn:mace:dir:attribute-def:uid': ['SBS-uid'], + 'urn:oid:1.3.6.1.4.1.24552.500.1.1.1.13': ['someKey'], + } + } + + logging.debug(f'<- {body}') + response.data = json.dumps(body) + + return response + + +@app.route('/api/users/interrupt', methods=['GET']) +def interrupt(): + logging.debug('-> /api/users/interrupt') + nonce = request.args.get('nonce') + (uid, continue_url, service_entity_id, issuer_id) = nonces.get(nonce, ('unknown', '/', '/', '')) + response = render_template('interrupt.j2', uid=uid, + service_entity_id=service_entity_id, issuer_id=issuer_id, url=continue_url) + + return response + + +@app.route('/api/users/attributes_eb', methods=['POST']) +def attributes(): + logging.debug('-> /api/users/attributes_eb') + debug(request) + + nonce = request.json.get('nonce') + (uid, _, _, _) = nonces.pop(nonce) + + response = Response(status=200) + body = { + 'attributes': { + 'urn:mace:dir:attribute-def:eduPersonEntitlement': [ + uid, + nonce, + 'urn:foobar', + ] + } + } + + logging.debug(f'<- {body}') + response.data = json.dumps(body) + + return response + + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=12345, debug=True) diff --git a/sbs-stub/start b/sbs-stub/start new file mode 100755 index 0000000000..d0a61dad17 --- /dev/null +++ b/sbs-stub/start @@ -0,0 +1,2 @@ +#!/bin/sh +./venv/bin/python sbs.py diff --git a/sbs-stub/templates/interrupt.j2 b/sbs-stub/templates/interrupt.j2 new file mode 100644 index 0000000000..cd9a506668 --- /dev/null +++ b/sbs-stub/templates/interrupt.j2 @@ -0,0 +1,9 @@ + + +

Hello {{uid}}!!

+

Coming from {{issuer_id}}

+

Going to {{service_entity_id}}

+

Accept AUP

+

Continue

+ + diff --git a/src/OpenConext/EngineBlock/Exception/InvalidSRAMConfigurationException.php b/src/OpenConext/EngineBlock/Exception/InvalidSRAMConfigurationException.php new file mode 100644 index 0000000000..7a3d1ae319 --- /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/Service/ProcessingStateHelper.php b/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php index 14c7d07973..c2f4330550 100644 --- a/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php +++ b/src/OpenConext/EngineBlock/Service/ProcessingStateHelper.php @@ -73,7 +73,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( @@ -82,7 +82,7 @@ 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) ); } diff --git a/src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php b/src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php index d88a14e252..a82cafe25c 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 diff --git a/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php b/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php index 4a13a5a00e..2672b0d01b 100644 --- a/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php +++ b/src/OpenConext/EngineBlockBundle/Configuration/TestFeatureConfiguration.php @@ -47,6 +47,7 @@ public function __construct() $this->setFeature(new Feature('eb.enable_sso_session_cookie', true)); $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.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 5ca07895e4..25abdcebea 100644 --- a/src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php +++ b/src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php @@ -171,6 +171,19 @@ public function processConsentAction() return ResponseFactory::fromEngineBlockResponse($this->engineBlockApplicationSingleton->getHttpResponse()); } + /** + * @param Request $request + * @return Response + * @throws \EngineBlock_Exception + */ + public function processSRAMInterrupt(Request $request) + { + $proxyServer = new EngineBlock_Corto_Adapter(); + $proxyServer->processSRAMInterrupt(); + + return ResponseFactory::fromEngineBlockResponse($this->engineBlockApplicationSingleton->getHttpResponse()); + } + /** * @param Request $request * @return Response 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..d42b16a717 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/AuthzResponse.php @@ -0,0 +1,75 @@ +msg = $jsonData['msg']; + $response->nonce = $jsonData['nonce'] ?? null; + $response->message = $jsonData['message'] ?? null; + + if (is_array($jsonData['attributes'])) { + $response->attributes = $jsonData['attributes']; + } else { + $response->attributes = []; + } + + return $response; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/Dto/AttributesRequest.php b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AttributesRequest.php new file mode 100644 index 0000000000..567988cde6 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AttributesRequest.php @@ -0,0 +1,48 @@ +nonce = $nonce; + + return $request; + } + + public function jsonSerialize() : array + { + return [ + 'nonce' => $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..766f94fa21 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/Dto/AuthzRequest.php @@ -0,0 +1,84 @@ +userId = $userId; + $request->eduPersonPrincipalName = $eppn; + $request->continueUrl = $continueUrl; + $request->serviceId = $serviceId; + $request->issuerId = $issuerId; + + return $request; + } + + public function jsonSerialize() : array + { + return [ + 'user_id' => $this->userId, + 'eppn' => $this->eduPersonPrincipalName, + 'continue_url' => $this->continueUrl, + 'service_id' => $this->serviceId, + 'issuer_id' => $this->issuerId + ]; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMerger.php b/src/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMerger.php new file mode 100644 index 0000000000..962a5cd161 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMerger.php @@ -0,0 +1,83 @@ +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] = $sbsAttributes[$key]; + } 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..d9737c5b74 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/SbsClient.php @@ -0,0 +1,130 @@ +httpClient = $httpClient; + $this->sbsBaseUrl = $sbsBaseUrl; + $this->authzLocation = $authzLocation; + $this->attributesLocation = $attributesLocation; + $this->interruptLocation = $interruptLocation; + $this->apiToken = $apiToken; + $this->verifyPeer = $verifyPeer; + } + + public function authz(AuthzRequest $request): AuthzResponse + { + $jsonData = $this->httpClient->post( + json_encode($request), + $this->authzLocation, + [], + $this->requestHeaders(), + $this->verifyPeer + ); + + if (!is_array($jsonData)) { + throw new InvalidSbsResponseException('Received non-array from SBS server: ' . var_export($jsonData, true)); + } + + return AuthzResponse::fromData($jsonData); + } + + // Attributes use authzLocation !! + public function requestAttributesFor(AttributesRequest $request): AttributesResponse + { + $jsonData = $this->httpClient->post( + json_encode($request), + $this->attributesLocation, + [], + $this->requestHeaders(), + $this->verifyPeer + ); + + if (!is_array($jsonData)) { + throw new InvalidSbsResponseException('Received non-array from SBS server: ' . var_export($jsonData, true)); + } + + 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->sbsBaseUrl . $this->interruptLocation . "?nonce=$nonce"; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Sbs/SbsClientInterface.php b/src/OpenConext/EngineBlockBundle/Sbs/SbsClientInterface.php new file mode 100644 index 0000000000..6ecc1ca01c --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Sbs/SbsClientInterface.php @@ -0,0 +1,37 @@ +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 after the browser has made a trip to the interrupt action + * and has returned to the continue_url + */ + 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 5040c9889a..ae640384b3 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/EngineBlockContext.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/EngineBlockContext.php @@ -23,10 +23,13 @@ use DOMDocument; use DOMElement; use DOMXPath; +use InvalidArgumentException; +use OpenConext\EngineBlockBundle\Sbs\SbsClientInterface; 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; @@ -115,6 +118,10 @@ class EngineBlockContext extends AbstractSubContext * @var string */ private $currentRequestId = ''; + /** + * @var SbsClientStateManager + */ + private $sbsClientStateManager; /** * @param ServiceRegistryFixture $serviceRegistry @@ -136,7 +143,8 @@ public function __construct( FunctionalTestingFeatureConfiguration $features, FunctionalTestingPdpClient $pdpClient, FunctionalTestingAuthenticationLoopGuard $authenticationLoopGuard, - FunctionalTestingAttributeAggregationClient $attributeAggregationClient + FunctionalTestingAttributeAggregationClient $attributeAggregationClient, + SbsClientStateManager $sbsClientStateManager ) { $this->serviceRegistryFixture = $serviceRegistry; $this->engineBlock = $engineBlock; @@ -146,6 +154,7 @@ public function __construct( $this->pdpClient = $pdpClient; $this->authenticationLoopGuard = $authenticationLoopGuard; $this->attributeAggregationClient = $attributeAggregationClient; + $this->sbsClientStateManager = $sbsClientStateManager; } /** @@ -187,6 +196,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$/ */ @@ -738,6 +757,46 @@ public function aaReturnsAttributes(TableNode $attributes) } } + /** + * @Given /^the sbs server will trigger the "([^"]*)" authz flow when called$/ + */ + public function primeAuthzResponse(string $msg): void + { + if ($msg === 'error') { + $this->sbsClientStateManager->prepareAuthzResponse('error'); + return; + } + + if (!in_array($msg, SbsClientInterface::VALID_MESSAGES)) { + throw new InvalidArgumentException("$msg is not a valid message type"); + } + $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(SbsClientInterface::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 dfbd236a2f..60a092c0ee 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockSpContext.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockSpContext.php @@ -147,6 +147,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/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..03f00edbd5 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/SbsFlowIntegration.feature @@ -0,0 +1,95 @@ +Feature: + In order to support SBS 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: If the SBS authz check returns an 'interrupt' response, and the attributes call to sbs returns an invalid response + 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 invalid 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 the sbs server will trigger the "error" authz flow when called + And I pass through SBS + And the response should contain "Logging in has failed" + + Scenario: If the authz call returns unknown attributes, the unknown attributes are ignored + 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 and will return invalid attributes + 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 not contain "foo" + And the response should not contain "baz" + + Scenario: If the sbs flow is active, other filters like PDP are still executed + Given SP "SSO-SP" requires a policy enforcement decision + And pdp gives an IdP specific deny response for "SSO-IdP" + 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 + When I log in at "SSO-SP" + And I pass through EngineBlock + And I pass through the IdP + And I should see "Error - Access denied" + And I should see "Message from your organisation:" + And I should see "Students of SSO-IdP do not have access to this resource" diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingPdpClient.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingPdpClient.php index d1fc16c62d..e35cbe80b1 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..768dd8bbc5 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/SbsClientStateManager.php @@ -0,0 +1,109 @@ +dataStore = $dataStore; + } + + public function prepareAuthzResponse(string $msg, ?array $attributes = null): void + { + if ($msg === SbsClientInterface::INTERRUPT) { + $this->authz = [ + 'msg' => SbsClientInterface::INTERRUPT, + 'nonce' => 'my-nonce', + ]; + } elseif ($msg === SbsClientInterface::AUTHORIZED) { + $this->authz = [ + 'msg' => SbsClientInterface::AUTHORIZED, + ]; + $this->authz += $attributes ?? $this->getValidMockAttributes(); + } elseif ($msg === SbsClientInterface::ERROR) { + $this->authz = [ + 'msg' => SbsClientInterface::ERROR, + ]; + } else { + throw new InvalidArgumentException(sprintf('"%s" is not a valid authz message.', $msg)); + } + + $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 06bb923643..b4f02e2b9a 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php @@ -254,6 +254,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/Resources/config/controllers.yml b/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/controllers.yml index 109fbf9650..515b7e3ea5 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/controllers.yml +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/controllers.yml @@ -34,6 +34,12 @@ services: - "@engineblock.mock_clients.mock_stepup_gateway" - "@twig" + 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' + engineblock.controller.authentication.identity_provider: class: OpenConext\EngineBlockBundle\Controller\IdentityProviderController arguments: diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/routing.yml b/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/routing.yml index 69c67629e7..e6b5da978b 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/routing.yml +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/routing.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/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/services.yml b/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/services.yml index 30668a02fb..177a3d8109 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/services.yml +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Resources/config/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: engineblock.functional_testing.service.engine_block: @@ -55,6 +57,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 @@ -74,6 +81,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/tests/behat-ci.yml b/tests/behat-ci.yml index 551a4c081b..09697d0c87 100644 --- a/tests/behat-ci.yml +++ b/tests/behat-ci.yml @@ -20,6 +20,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' @@ -43,7 +44,7 @@ default: serviceRegistryFixture: '@engineblock.functional_testing.fixture.service_registry' - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\TranslationContext: mockTranslator: '@engineblock.functional_testing.mock.translator' - - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext + - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext: selenium: mink_session: chrome mink_javascript_session: chrome diff --git a/tests/behat.yml b/tests/behat.yml index cfa9d6fc89..eeffc5bc0b 100644 --- a/tests/behat.yml +++ b/tests/behat.yml @@ -20,6 +20,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' @@ -43,7 +44,7 @@ default: serviceRegistryFixture: '@engineblock.functional_testing.fixture.service_registry' - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\TranslationContext: mockTranslator: '@engineblock.functional_testing.mock.translator' - - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext + - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext: selenium: 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 0bedb51f07..2e8a5fbb5c 100644 --- a/tests/library/EngineBlock/Test/Corto/Filter/Command/EnforcePolicyTest.php +++ b/tests/library/EngineBlock/Test/Corto/Filter/Command/EnforcePolicyTest.php @@ -89,4 +89,13 @@ private function mockPdpClientWithException(Throwable $exception): void $container->setPdpClient($pdpClient); } + 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..42e7de295a --- /dev/null +++ b/tests/library/EngineBlock/Test/Corto/Filter/Command/SRAMInterruptFilterTest.php @@ -0,0 +1,324 @@ +sp = new ServiceProvider('SP'); + + $this->repository = Mockery::mock(MetadataRepositoryInterface::class); + $this->repository->shouldReceive('findServiceProviderByEntityId') + ->andReturn($this->sp); + } + + public function testItDoesNothingIfFeatureFlagNotEnabled(): void + { + $sramFilter = new EngineBlock_Corto_Filter_Command_SRAMInterruptFilter(); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $this->mockFeatureConfiguration(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => new Feature('eb.feature_enable_sram_interrupt', false)])); + + $sramFilter->execute(); + $this->assertEmpty($sramFilter->getResponseAttributes()); + } + + public function testItDoesNothingIfSpDoesNotHaveCollabEnabled(): void + { + $sramFilter = new EngineBlock_Corto_Filter_Command_SRAMInterruptFilter(); + + $server = Mockery::mock(EngineBlock_Corto_ProxyServer::class); + $server->shouldReceive('getRepository') + ->andReturn($this->repository); + + $sramFilter->setProxyServer($server); + + $request = $this->mockRequest(); + $sramFilter->setRequest($request); + + $this->mockFeatureConfiguration(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => new Feature('eb.feature_enable_sram_interrupt', true)])); + + /** @var \Mockery\Mock $sbsClient */ + $sbsClient = $this->mockSbsClient(); + $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 + { + $sramFilter = new EngineBlock_Corto_Filter_Command_SRAMInterruptFilter(); + + $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())); + + $this->mockFeatureConfiguration(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => new Feature('eb.feature_enable_sram_interrupt', true)])); + + /** @var \Mockery\Mock $sbsClient */ + $sbsClient = $this->mockSbsClient(); + + $response = new AuthzResponse(); + $response->msg = 'interrupt'; + $response->nonce = 'hash123'; + $response->attributes = [ + 'dummy' => 'attributes', + ]; + + $expectedRequest = new AuthzRequest(); + $expectedRequest->userId = ''; + $expectedRequest->eduPersonPrincipalName = ''; + $expectedRequest->continueUrl = 'https://example.org?ID='; + $expectedRequest->serviceId = 'spEntityId'; + $expectedRequest->issuerId = '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 + { + $sramFilter = new EngineBlock_Corto_Filter_Command_SRAMInterruptFilter(); + + $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())); + + $this->mockFeatureConfiguration(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => new Feature('eb.feature_enable_sram_interrupt', true)])); + + /** @var \Mockery\Mock $sbsClient */ + $sbsClient = $this->mockSbsClient(); + + $response = new AuthzResponse(); + $response->msg = 'authorized'; + $response->nonce = 'hash123'; + $response->attributes = [ + 'urn:mace:dir:attribute-def:uid' => ['userIdValue'], + 'urn:mace:dir:attribute-def:eduPersonEntitlement' => 'attributes', + ]; + + $expectedRequest = new AuthzRequest(); + $expectedRequest->userId = ''; + $expectedRequest->eduPersonPrincipalName = ''; + $expectedRequest->continueUrl = 'https://example.org?ID='; + $expectedRequest->serviceId = 'spEntityId'; + $expectedRequest->issuerId = '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); + + + $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 = $this->mockSbsClient(); + $sbsClient->expects('authz')->andThrows(new InvalidSbsResponseException('Server could not be reached.')); + + $sramFilter = new EngineBlock_Corto_Filter_Command_SRAMInterruptFilter(); + + $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())); + + $this->mockFeatureConfiguration(new FeatureConfiguration(['eb.feature_enable_sram_interrupt' => new Feature('eb.feature_enable_sram_interrupt', true)])); + + /** @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); + } + + private function mockSbsClient() + { + $sbsClient = Mockery::mock(SbsClientInterface::class); + + /** @var EngineBlock_Application_TestDiContainer $container */ + $container = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); + $container->setSbsClient($sbsClient); + + return $sbsClient; + } + + private function mockFeatureConfiguration(FeatureConfiguration $featureConfiguration) + { + /** @var EngineBlock_Application_TestDiContainer $container */ + $container = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); + $container->setFeatureConfiguration($featureConfiguration); + } + + protected function tearDown(): void + { + $container = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); + $container->setSbsClient(null); + + $container = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer(); + $container->setFeatureConfiguration(null); + + parent::tearDown(); + } + +} diff --git a/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php b/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php index 41e579f842..1afd4593df 100644 --- a/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php +++ b/tests/library/EngineBlock/Test/Corto/Filter/Command/ValidateAllowedConnectionTest.php @@ -84,6 +84,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')); @@ -103,4 +104,72 @@ 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(); + $verifier->setResponse($this->response); + + /** + * @TODO Use PHP8 named parameters to pass collabEnabled: true, all other params are default. + */ + $sp = new ServiceProvider( + 'FoobarSP', + null, + null, + null, + null, + null, + false, + [], + [], + '', + '', + '', + false, + '', + '', + '', + '', + '', + '', + null, + '', + '', + '', + null, + [], + false, + '', + '', + [], + false, + [], + false, + null, + true, + false, + false, + null, + false, + false, + false, + false, + '', + null, + null, + null, + null, + null, + null, + false, + 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 0bb4976f83..c97e60991f 100644 --- a/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php @@ -94,7 +94,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 49750aa489..374664f521 100644 --- a/tests/library/EngineBlock/Test/Corto/ProxyServerTest.php +++ b/tests/library/EngineBlock/Test/Corto/ProxyServerTest.php @@ -50,7 +50,6 @@ public function testNameIDFormatIsNotSetByDefault() array_keys($nameIdPolicy), 'The NameIDPolicy should not contain the key "Format"', false, - true, true ); } @@ -75,7 +74,6 @@ public function testAllowCreateIsSet() array_keys($nameIdPolicy), 'The NameIDPolicy should contain the key "AllowCreate"', false, - true, true ); } 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..2bfe86913a --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Sbs/AuthzResponseTest.php @@ -0,0 +1,109 @@ + SbsClientInterface::AUTHORIZED, + 'attributes' => ['role' => 'admin'] + ]; + + $response = AuthzResponse::fromData($jsonData); + + $this->assertInstanceOf(AuthzResponse::class, $response); + $this->assertEquals(SbsClientInterface::AUTHORIZED, $response->msg); + $this->assertEquals(['role' => 'admin'], $response->attributes); + $this->assertNull($response->nonce); + } + + public function testFromDataValidInterruptResponse(): void + { + $jsonData = [ + 'msg' => SbsClientInterface::INTERRUPT, + 'nonce' => 'random_nonce' + ]; + + $response = AuthzResponse::fromData($jsonData); + + $this->assertInstanceOf(AuthzResponse::class, $response); + $this->assertEquals(SbsClientInterface::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('Msg: "INVALID" is not a valid message'); + + 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' => SbsClientInterface::INTERRUPT]); + } + + public function testFromDataAuthorizedWithoutAttributesThrowsException(): void + { + $this->expectException(InvalidSbsResponseException::class); + $this->expectExceptionMessage('Key: "attributes" was not found in the SBS response'); + + AuthzResponse::fromData(['msg' => SbsClientInterface::AUTHORIZED]); + } + + public function testFromDataAttributesNotArrayDefaultsToEmpty(): void + { + $jsonData = [ + 'msg' => SbsClientInterface::AUTHORIZED, + 'attributes' => 'invalid_type' + ]; + + $response = AuthzResponse::fromData($jsonData); + + $this->assertInstanceOf(AuthzResponse::class, $response); + $this->assertEquals(SbsClientInterface::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..b8b7a436fc --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Sbs/SbsAttributeMergerTest.php @@ -0,0 +1,97 @@ + '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..153921bcfe --- /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(), + '/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(), + '/authz', + [], + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer test_token', + ] + ) + ->willReturn($jsonResponse); + + $attributesResponse = $this->sbsClient->requestAttributesFor($requestMock); + + $this->assertInstanceOf(AttributesResponse::class, $attributesResponse); + } +}