diff --git a/CHANGELOG.md b/CHANGELOG.md index ee95d84bfa..7faff2edd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Changes: * Because `deleted_at` is part of the PK, no migration is provided. The database engine should not allow this to be null in the first place, so it is probably not nullable already on your db. * The `0000-00-00 00:00:00` is added for clarity/consistency, as this is probably the default behaviour of your database already. * Removed unused index `consent.deleted_at`. Delete this from your production database if it's there. +* Added a specific error page for unsolicited SAML responses (IdP-initiated SSO without a prior AuthnRequest). ## 7.1.0 [SBS](https://github.com/SURFscz/SBS) integration diff --git a/languages/messages.en.php b/languages/messages.en.php index b908b11682..22c77ef2df 100644 --- a/languages/messages.en.php +++ b/languages/messages.en.php @@ -167,6 +167,8 @@ 'error_session_lost_desc' => 'To continue to the service an active session is required. However, your session expired. Perhaps you waited too long with logging in? Please go back to the service and try again. If that doesn\'t work, close your browser first and then try again.', 'error_session_not_started' => 'Error - No session found', 'error_session_not_started_desc' => 'To continue to the service an active session is required. However, no session was found. Your browser must accept cookies. Alternatively, the link you used to get to the service might be wrong. Please go back to the service and try again. If that doesn\'t work, try a different browser.', + 'error_unsolicited_response' => 'Error - Sign-in could not be completed', + 'error_unsolicited_response_desc' => 'Your sign-in could not be completed because the login request was initiated in a way that is not supported. You were sent directly to this application by your identity provider (e.g. via a bookmark, portal tile, or saved link) without first starting a login from this application. This is not supported. Please start again from the service you were trying to access and log in from there.', 'error_authorization_policy_violation' => 'Error - Access denied', 'error_authorization_policy_violation_desc' => 'You cannot use %spName% because %idpName% limits access to it (the "Service Provider") with an authorization policy. Please contact the service desk of %idpName% if you think you should be allowed access to %spName%.', 'error_authorization_policy_violation_desc_no_idp_name' => 'You cannot use %spName% because your %organisationNoun% limits access to it (the "Service Provider") with an authorization policy. Please contact the service desk of your %organisationNoun% if you think you should be allowed access to %spName%.', diff --git a/languages/messages.nl.php b/languages/messages.nl.php index 82224e0d06..f6e68e94ec 100644 --- a/languages/messages.nl.php +++ b/languages/messages.nl.php @@ -167,6 +167,8 @@ 'error_session_lost_desc' => 'Om verder te gaan naar de dienst heb je een actieve sessie nodig, maar deze is verlopen. Heb je misschien te lang gewacht met inloggen? Ga terug naar de dienst en probeer het nog een keer. Als dat niet werkt, sluit je browser af en probeer nogmaals opnieuw in te loggen.', 'error_session_not_started' => 'Fout - Geen sessie gevonden', 'error_session_not_started_desc' => 'Om verder te gaan naar de dienst heb je een actieve sessie nodig, maar we kunnen deze niet vinden. Je browser moet cookies ondersteunen. Ook kan de link die je hebt gebruikt om bij de dienst te komen, verkeerd zijn. Ga terug naar de dienst en probeer het opnieuw. Als dat niet werkt, probeer een andere browser.', + 'error_unsolicited_response' => 'Fout - Inloggen kon niet worden voltooid', + 'error_unsolicited_response_desc' => 'Je inlogpoging kon niet worden voltooid omdat het inlogverzoek op een niet-ondersteunde manier is gestart. Je bent rechtstreeks naar deze toepassing gestuurd door je identiteitsprovider (bijv. via een bladwijzer, portaltegel of opgeslagen link) zonder eerst een login te starten vanuit de dienst zelf. Dit wordt niet ondersteund. Begin opnieuw vanuit de dienst die je wilt gebruiken en log in via die weg.', 'error_authorization_policy_violation' => 'Fout - Geen toegang', 'error_authorization_policy_violation_desc' => 'Neem contact op met de helpdesk van %idpName% als je toegang tot %spName% wilt. Vermeld daarbij dat je probeerde in te loggen op %spName% en dat je werd tegengehouden door een autorisatieregel van %suiteName%, geconfigureerd door %idpName%.', 'error_authorization_policy_violation_desc_no_idp_name' => 'Neem contact op met de helpdesk van je eigen %organisationNoun% als je toegang tot %spName% wilt. Vermeld daarbij dat je probeerde in te loggen op %spName% en dat je werd tegengehouden door een autorisatieregel van %suiteName%, geconfigureerd door jouw eigen %organisationNoun%.', diff --git a/languages/messages.pt.php b/languages/messages.pt.php index ea38e65e10..23350ae6ae 100644 --- a/languages/messages.pt.php +++ b/languages/messages.pt.php @@ -165,6 +165,8 @@ 'error_session_lost_desc' => '

Esta ação requer uma sessão ativa, no entanto, não conseguimos encontrar a sessão. Está a aguardar há muito tempo? Feche o browser e tente novamente, ou tente um browser diferente.

', 'error_session_not_started' => 'Erro - a sua sessão não foi encontrada', 'error_session_not_started_desc' => '

Esta ação requer uma sessão ativa, no entanto, não recebemos nenhum cookie de sessão. O browser deve aceitar cookies. Não utilize endereços do marcador ou link. Feche o browser e tente novamente, ou tente um browser diferente.

', + 'error_unsolicited_response' => 'Erro - Não foi possível concluir o acesso', + 'error_unsolicited_response_desc' => 'O seu acesso não pôde ser concluído porque o pedido de autenticação foi iniciado de uma forma não suportada. Foi enviado diretamente para esta aplicação pelo seu fornecedor de identidade (por exemplo, através de um marcador, mosaico do portal ou ligação guardada) sem primeiro iniciar uma sessão a partir desta aplicação. Isso não é suportado. Por favor, comece novamente a partir do serviço ao qual estava a tentar aceder e inicie sessão a partir daí.', 'error_authorization_policy_violation' => 'Erro - Sem acesso', 'error_authorization_policy_violation_desc' => 'Você autenticu-se com sucesso na %idpName%, mas infelizmente você não pode utilizar %spName% (o "Fornecedor de Serviço") porque não tem acesso. A %idpName% limita o acesso a %spName% com uma política de autorização. Entre em contacto com o suporte da %idpName% se acha que deve ser-lhe concedido acesso ao serviço.', 'error_authorization_policy_violation_desc_no_idp_name' => 'Você autenticu-se com sucesso na sua %organisationNoun%, mas infelizmente você não pode utilizar %spName% (o "Fornecedor de Serviço") porque não tem acesso. A sua %organisationNoun% limita o acesso a %spName% com uma política de autorização. Entre em contacto com o suporte da sua %organisationNoun% se acha que deve ser-lhe concedido acesso ao serviço.', diff --git a/library/EngineBlock/Corto/Module/Bindings.php b/library/EngineBlock/Corto/Module/Bindings.php index 81fee7f078..cd52b1412f 100644 --- a/library/EngineBlock/Corto/Module/Bindings.php +++ b/library/EngineBlock/Corto/Module/Bindings.php @@ -335,7 +335,7 @@ public function receiveResponse($serviceEntityId, $expectedDestination) // Make sure it has a InResponseTo (Unsolicited is not supported) but don't actually check that what it's // in response to is actually a message we sent quite yet. if ($sspResponse->getInResponseTo() === null) { - throw new EngineBlock_Corto_Module_Bindings_Exception( + throw new EngineBlock_Corto_Module_Bindings_UnsolicitedAssertionException( 'Unsolicited assertion (no InResponseTo in message) not supported!' ); } diff --git a/library/EngineBlock/Corto/Module/Bindings/UnsolicitedAssertionException.php b/library/EngineBlock/Corto/Module/Bindings/UnsolicitedAssertionException.php new file mode 100644 index 0000000000..19dd81811e --- /dev/null +++ b/library/EngineBlock/Corto/Module/Bindings/UnsolicitedAssertionException.php @@ -0,0 +1,21 @@ +twig->render('@theme/Authentication/View/Feedback/unsolicited-response.html.twig'), + 400 + ); + } + #[Route(path: '/feedback/unknown-error', name: 'feedback_unknown_error', methods: ['GET'])] public function unknownErrorAction() { diff --git a/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php b/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php index c850c056f8..a1b8a7192a 100644 --- a/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php +++ b/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php @@ -35,6 +35,7 @@ use EngineBlock_Corto_Module_Bindings_ClockIssueException; use EngineBlock_Corto_Module_Bindings_SignatureVerificationException; use EngineBlock_Corto_Module_Bindings_UnableToReceiveMessageException; +use EngineBlock_Corto_Module_Bindings_UnsolicitedAssertionException; use EngineBlock_Corto_Module_Bindings_UnsupportedAcsLocationSchemeException; use EngineBlock_Corto_Module_Bindings_UnsupportedBindingException; use EngineBlock_Corto_Module_Bindings_UnsupportedSignatureMethodException; @@ -111,6 +112,9 @@ public function onKernelException(ExceptionEvent $event) if ($exception instanceof EngineBlock_Corto_Module_Bindings_UnableToReceiveMessageException) { $message = 'Unable to receive message'; $redirectToRoute = 'authentication_feedback_unable_to_receive_message'; + } elseif ($exception instanceof EngineBlock_Corto_Module_Bindings_UnsolicitedAssertionException) { + $message = 'Unsolicited assertion (IdP-initiated SSO) not supported'; + $redirectToRoute = 'authentication_feedback_unsolicited_response'; } elseif ($exception instanceof EngineBlock_Corto_Module_Services_SessionLostException) { $message = 'Session lost'; $redirectToRoute = 'authentication_feedback_session_lost'; diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Bindings.feature b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Bindings.feature index 332392c0d6..2ef4efdac4 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Bindings.feature +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Bindings.feature @@ -110,3 +110,11 @@ Feature: Then no RelayState should be present And I pass through EngineBlock Then the url should match "functional-testing/Dummy%20SP/acs" + + Scenario: EngineBlock rejects a SAMLResponse without InResponseTo (IdP-initiated SSO) + Given the IdP omits InResponseTo from its response + When I log in at "Dummy SP" + And I pass through EngineBlock + And I pass through the IdP + Then the url should match "authentication/feedback/unsolicited-response" + And I should see "Error - Sign-in could not be completed" diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockIdpContext.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockIdpContext.php index 71fbc87684..e4e92871bb 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockIdpContext.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockIdpContext.php @@ -301,6 +301,18 @@ public function theIdPIsConfiguredToNotSendAnAssertion() $this->mockIdpRegistry->save(); } + /** + * @Given /^the IdP omits InResponseTo from its response$/ + */ + public function theIdpOmitsInResponseToFromItsResponse(): void + { + $idp = $this->mockIdpRegistry->getOnly(); + + $idp->omitInResponseTo(); + + $this->mockIdpRegistry->save(); + } + /** * @Given /^the IdP "([^"]*)" sends attribute "([^"]*)" with value "([^"]*)"$/ * @param string $idpName diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php index 5757f52ed4..8d7a280ed1 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Mock/MockIdentityProvider.php @@ -39,6 +39,8 @@ class MockIdentityProvider extends AbstractMockEntityRole private $fromTheFuture = false; + private $omitInResponseTo = false; + public function singleSignOnLocation() { return $this->getSsoRole()->getSingleSignOnService()[0]->getLocation(); @@ -341,6 +343,18 @@ public function fromTheFuture() return $this; } + public function omitInResponseTo(): self + { + $this->omitInResponseTo = true; + + return $this; + } + + public function shouldOmitInResponseTo(): bool + { + return $this->omitInResponseTo; + } + public function shouldNotSendAssertions() { return $this->sendAssertions === false; @@ -374,7 +388,7 @@ public function __sleep() $role->setExtensions($extensions); } - return ['name', 'descriptor', 'sendAssertions', 'turnBackTime', 'fromTheFuture']; + return ['name', 'descriptor', 'sendAssertions', 'turnBackTime', 'fromTheFuture', 'omitInResponseTo']; } /** diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/ResponseFactory.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/ResponseFactory.php index d7e63bd1e3..e1ed05b56c 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/ResponseFactory.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/ResponseFactory.php @@ -34,7 +34,11 @@ public function createForEntityWithRequest( // Note that we expect the Mock IdP to always have a 'template' Response. $response = $mockIdp->getResponse(); - $this->setResponseReferencesToRequest($request, $response); + if ($mockIdp->shouldOmitInResponseTo()) { + $response->setInResponseTo(null); + } else { + $this->setResponseReferencesToRequest($request, $response); + } $this->setResponseStatus($mockIdp, $response); diff --git a/tests/unit/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListenerTest.php b/tests/unit/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListenerTest.php new file mode 100644 index 0000000000..a7ab5b4629 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListenerTest.php @@ -0,0 +1,461 @@ +logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $this->urlGenerator = Mockery::mock(UrlGeneratorInterface::class); + $this->errorReporter = Mockery::mock(ErrorReporter::class)->shouldIgnoreMissing(); + $this->engineBlockSingleton = Mockery::mock(EngineBlock_ApplicationSingleton::class); + + $this->listener = new RedirectToFeedbackPageExceptionListener( + $this->engineBlockSingleton, + $this->urlGenerator, + $this->errorReporter, + $this->logger + ); + } + + #[Test] + #[DataProvider('exceptionToRouteProvider')] + public function it_redirects_to_the_correct_feedback_route(Throwable $exception, string $expectedRoute): void + { + $this->urlGenerator + ->shouldReceive('generate') + ->with($expectedRoute, [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + public static function exceptionToRouteProvider(): array + { + return [ + 'unable to receive message' => [ + new EngineBlock_Corto_Module_Bindings_UnableToReceiveMessageException('test'), + 'authentication_feedback_unable_to_receive_message', + ], + 'unsolicited assertion (IdP-initiated SSO)' => [ + new EngineBlock_Corto_Module_Bindings_UnsolicitedAssertionException('test'), + 'authentication_feedback_unsolicited_response', + ], + 'session lost' => [ + new EngineBlock_Corto_Module_Services_SessionLostException('test'), + 'authentication_feedback_session_lost', + ], + 'session not started' => [ + new EngineBlock_Corto_Module_Services_SessionNotStartedException('test'), + 'authentication_feedback_session_not_started', + ], + 'no identity providers' => [ + new EngineBlock_Corto_Module_Service_SingleSignOn_NoIdpsException('test'), + 'authentication_feedback_no_idps', + ], + 'invalid ACS location' => [ + new EngineBlock_Corto_Exception_InvalidAcsLocation('test'), + 'authentication_feedback_invalid_acs_location', + ], + 'missing required fields' => [ + new EngineBlock_Corto_Exception_MissingRequiredFields('test'), + 'authentication_feedback_missing_required_fields', + ], + 'authn context class ref blacklisted' => [ + new EngineBlock_Corto_Exception_AuthnContextClassRefBlacklisted('test'), + 'authentication_authn_context_class_ref_blacklisted', + ], + 'invalid MFA authn context class ref' => [ + new EngineBlock_Corto_Exception_InvalidMfaAuthnContextClassRef('test'), + 'authentication_invalid_mfa_authn_context_class_ref', + ], + 'unsupported binding' => [ + new EngineBlock_Corto_Module_Bindings_UnsupportedBindingException('test'), + 'authentication_feedback_invalid_acs_binding', + ], + 'unsupported ACS location URI scheme' => [ + new EngineBlock_Corto_Module_Bindings_UnsupportedAcsLocationSchemeException('test'), + 'authentication_feedback_unsupported_acs_location_uri_scheme', + ], + 'received error status code' => [ + new EngineBlock_Corto_Exception_ReceivedErrorStatusCode('test'), + 'authentication_feedback_received_error_status_code', + ], + 'signature verification failed' => [ + new EngineBlock_Corto_Module_Bindings_SignatureVerificationException('test'), + 'authentication_feedback_signature_verification_failed', + ], + 'verification failed' => [ + new EngineBlock_Corto_Module_Bindings_VerificationException('test'), + 'authentication_feedback_verification_failed', + ], + 'unknown identity provider signing key' => [ + new EngineBlock_Corto_Exception_UnknownIdentityProviderSigningKey('test', 'https://idp.example.org'), + 'authentication_feedback_unknown_signing_key', + ], + 'unknown requester ID in authn request' => [ + new EngineBlock_Exception_UnknownRequesterIdInAuthnRequest(new ServiceProvider('https://sp.example.org')), + 'authentication_feedback_unknown_requesterid_in_authnrequest', + ], + 'PEP no access' => [ + new EngineBlock_Corto_Exception_PEPNoAccess('test'), + 'authentication_feedback_pep_violation', + ], + 'invalid attribute value' => [ + new EngineBlock_Corto_Exception_InvalidAttributeValue('test', 'urn:attribute', 'bad-value'), + 'authentication_feedback_invalid_attribute_value', + ], + 'stuck in authentication loop' => [ + new StuckInAuthenticationLoopException('test'), + 'authentication_feedback_stuck_in_authentication_loop', + ], + 'authentication session limit exceeded' => [ + new AuthenticationSessionLimitExceededException('test'), + 'authentication_feedback_authentication_limit_exceeded', + ], + 'clock issue' => [ + new EngineBlock_Corto_Module_Bindings_ClockIssueException('test'), + 'authentication_feedback_response_clock_issue', + ], + ]; + } + + #[Test] + #[DataProvider('stepupExceptionToRouteProvider')] + public function it_redirects_stepup_exceptions_to_the_correct_feedback_route( + Throwable $exception, + string $expectedRoute + ): void { + $this->urlGenerator + ->shouldReceive('generate') + ->with($expectedRoute, [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + public static function stepupExceptionToRouteProvider(): array + { + $receivedError = new EngineBlock_Corto_Exception_ReceivedErrorStatusCode('Received error status code'); + $receivedError->setFeedbackStatusCode('urn:oasis:names:tc:SAML:2.0:status:Responder'); + $receivedError->setFeedbackStatusMessage('Authentication failed'); + + return [ + 'user cancelled stepup callout' => [ + new EngineBlock_Corto_Exception_UserCancelledStepupCallout('test', $receivedError), + 'authentication_feedback_stepup_callout_user_cancelled', + ], + 'stepup unmet LOA' => [ + new EngineBlock_Corto_Exception_InvalidStepupLoaLevel('test', $receivedError), + 'authentication_feedback_stepup_callout_unmet_loa', + ], + 'stepup unknown callout response' => [ + new EngineBlock_Corto_Exception_InvalidStepupCalloutResponse('test', $receivedError), + 'authentication_feedback_stepup_callout_unknown', + ], + ]; + } + + #[Test] + public function it_includes_the_signature_method_as_a_route_param(): void + { + $exception = new EngineBlock_Corto_Module_Bindings_UnsupportedSignatureMethodException('rsa-sha1'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with( + 'authentication_feedback_unsupported_signature_method', + ['signature-method' => 'rsa-sha1'], + UrlGeneratorInterface::ABSOLUTE_PATH + ) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + #[Test] + public function it_includes_entity_id_as_a_route_param_for_unknown_service_provider(): void + { + $exception = new EngineBlock_Exception_UnknownServiceProvider('test', 'https://sp.example.org'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with( + 'authentication_feedback_unknown_service_provider', + ['entity-id' => 'https://sp.example.org'], + UrlGeneratorInterface::ABSOLUTE_PATH + ) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + #[Test] + public function it_includes_entity_id_and_destination_as_route_params_for_unknown_identity_provider(): void + { + $exception = new EngineBlock_Exception_UnknownIdentityProvider('test', 'https://idp.example.org', 'https://engine.example.org/sso'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with( + 'authentication_feedback_unknown_identity_provider', + ['entity-id' => 'https://idp.example.org', 'destination' => 'https://engine.example.org/sso'], + UrlGeneratorInterface::ABSOLUTE_PATH + ) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + #[Test] + public function it_includes_key_id_as_a_route_param_for_unknown_key_id(): void + { + $exception = new UnknownKeyIdException('my-key-id'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with( + 'authentication_feedback_unknown_keyid', + ['keyid' => 'my-key-id'], + UrlGeneratorInterface::ABSOLUTE_PATH + ) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + #[Test] + public function it_includes_the_idp_hash_as_a_route_param_for_unknown_preselected_idp(): void + { + $exception = new EngineBlock_Corto_Exception_UnknownPreselectedIdp('test', 'abc123hash'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with( + 'authentication_feedback_unknown_preselected_idp', + ['idp-hash' => 'abc123hash'], + UrlGeneratorInterface::ABSOLUTE_PATH + ) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + } + + #[Test] + public function it_stores_feedback_in_session_for_custom_attribute_manipulator_exception(): void + { + $exception = new EngineBlock_Attributes_Manipulator_CustomException('Custom feedback message'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with('authentication_feedback_custom', [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEventWithSession($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + $this->assertSame( + $exception->getFeedback(), + $event->getRequest()->getSession()->get('feedback_custom') + ); + } + + #[Test] + public function it_stores_message_in_session_for_invalid_binding_exception(): void + { + $exception = new InvalidBindingException('No binding found'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with('authentication_feedback_no_authentication_request_received', [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEventWithSession($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + $this->assertSame('No binding found', $event->getRequest()->getSession()->get('feedback_custom')); + } + + #[Test] + public function it_stores_message_in_session_for_missing_parameter_exception(): void + { + $exception = new MissingParameterException('Required parameter missing'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with('authentication_feedback_no_authentication_request_received', [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEventWithSession($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + $this->assertSame('Required parameter missing', $event->getRequest()->getSession()->get('feedback_custom')); + } + + #[Test] + public function it_stores_message_in_session_for_entity_not_found_exception(): void + { + $exception = new EntityCanNotBeFoundException('Entity not found'); + + $this->urlGenerator + ->shouldReceive('generate') + ->with('authentication_feedback_metadata_entity_not_found', [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->once() + ->andReturn('/feedback'); + + $event = $this->createEventWithSession($exception); + + $this->listener->onKernelException($event); + + $this->assertInstanceOf(RedirectResponse::class, $event->getResponse()); + $this->assertSame('Entity not found', $event->getRequest()->getSession()->get('feedback_custom')); + } + + #[Test] + public function it_does_not_set_a_response_for_unrecognized_exceptions(): void + { + $exception = new RuntimeException('Some unhandled exception'); + $event = $this->createEvent($exception); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + private function createEvent(Throwable $exception): ExceptionEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/'); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } + + private function createEventWithSession(Throwable $exception): ExceptionEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/'); + $request->setSession(new Session(new MockArraySessionStorage())); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } +} diff --git a/theme/base/templates/modules/Authentication/View/Feedback/unsolicited-response.html.twig b/theme/base/templates/modules/Authentication/View/Feedback/unsolicited-response.html.twig new file mode 100644 index 0000000000..e201837af8 --- /dev/null +++ b/theme/base/templates/modules/Authentication/View/Feedback/unsolicited-response.html.twig @@ -0,0 +1,8 @@ +{% extends '@theme/Default/View/Error/error.html.twig' %} + +{% set pageTitle = 'error_unsolicited_response'|trans %} +{% block pageTitle %}{{ pageTitle }}{% endblock %} +{% block title %}{{ parent() }}{% endblock %} +{% block pageHeading %}{{ pageTitle }}{% endblock %} + +{% block errorMessage %}{{ 'error_unsolicited_response_desc'|trans }}{% endblock %} diff --git a/theme/openconext/templates/modules/Authentication/View/Feedback/unsolicited-response.html.twig b/theme/openconext/templates/modules/Authentication/View/Feedback/unsolicited-response.html.twig new file mode 100644 index 0000000000..44b8dce4a2 --- /dev/null +++ b/theme/openconext/templates/modules/Authentication/View/Feedback/unsolicited-response.html.twig @@ -0,0 +1,8 @@ +{% extends '@theme/Default/View/Error/error.html.twig' %} + +{% set pageTitle = 'error_unsolicited_response'|trans %} +{% block pageTitle %}{{ pageTitle }}{% endblock %} +{% block title %}{{ parent() }} - {{ pageTitle }} {% endblock %} +{% block pageHeading %}{{ pageTitle }}{% endblock %} + +{% block errorMessage %}{{ 'error_unsolicited_response_desc'|trans }}{% endblock %}