From 8d7fd4b8854f2422a46c5bc2b02f079a8131e82b Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Wed, 16 Sep 2026 14:47:13 +0200 Subject: [PATCH 1/4] Add link to reset remembered WAYF login-method choices Add a "Login method choices" section to the bottom of the "my personal data" page that lets a user reset every per-application login-method choice they've made in the WAYF, behind a confirmation popup. Confirming sends the user to EngineBlock's /reset-remember-wayf endpoint (#2085), which clears the cookie and redirects back here. - WayfResetLinkBuilder is a small, pure service that appends a `redirect` query parameter (the absolute URL of this same page) to the configured wayf_reset_url. It's factored out of MyProfileController and unit tested in isolation because this repo has no controller/functional test infrastructure yet, and the interesting logic (building the link) doesn't need a kernel to test. - New wayf_reset_url parameter follows the existing pattern of simple string config values in parameters.yaml(.dist), pointing at EngineBlock's reset endpoint. - The new section reuses the shared modal.html.twig confirmation component (the same one used for deleting a connection), wrapped in a single-iteration `for` loop: the helper unconditionally derives its element ids from `loop.index`, which only exists inside a loop, and its one existing caller is always used from within a real loop over services. - SCSS mirrors the structure/naming of the existing "Download" section on the SURFconext dashboard (mySurfConext__download*), including its IE11 fallback rules, since both are a text explanation next to a single button. - New translation keys were added under the existing profile.my_profile key in all three locales (en/nl/pt). Since exact copy from design isn't available, the wording is a best-effort draft that should be reviewed against the actual mockups; the confirm button text ("Reset all") is the one piece of copy specified by the ticket itself. This depends on OpenConext/OpenConext-engineblock#2097 adding `redirect` support to /reset-remember-wayf; without it the endpoint still works but always redirects to its own configured default instead of back here. Refs: #345 --- assets/css/pages/myProfile/myProfile.scss | 40 +++++++++ config/openconext/parameters.yaml.dist | 5 ++ config/services.yaml | 5 ++ .../Controller/MyProfileController.php | 15 +++- .../Service/WayfResetLinkBuilder.php | 33 +++++++ .../Service/WayfResetLinkBuilderTest.php | 85 +++++++++++++++++++ templates/MyProfile/overview.html.twig | 33 +++++++ translations/messages.en.php | 9 ++ translations/messages.nl.php | 9 ++ translations/messages.pt.php | 9 ++ 10 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php create mode 100644 src/OpenConext/ProfileBundle/Tests/Service/WayfResetLinkBuilderTest.php diff --git a/assets/css/pages/myProfile/myProfile.scss b/assets/css/pages/myProfile/myProfile.scss index f5d0735ec..9569f14ea 100644 --- a/assets/css/pages/myProfile/myProfile.scss +++ b/assets/css/pages/myProfile/myProfile.scss @@ -1,6 +1,7 @@ @import 'attributesTable'; @import '../../helpers/breakpoints'; @import '../../helpers/functions'; +@import '../../helpers/variables'; .content__myProfile { padding-right: calculateRem(70px); @@ -21,3 +22,42 @@ .myProfile__note { margin-bottom: calculateRem(80px); } + +.myProfile__wayfResetExplanation { + color: $titleBlack; + display: flex; + font-size: $f-normal; + + @include screen('mobile') { + flex-direction: column; + } +} + +.myProfile__wayfResetButtonWrapper { + flex: 0 0 max-content; + height: max-content; + min-width: max-content; + text-align: center; + + @include screen('mobile') { + text-align: left; + } +} + +.myProfile__wayfResetButton { + height: 100%; + width: 100%; +} + +.myProfile__wayfResetText { + margin-top: 0; +} + +@include ie11Only('.myProfile__wayfResetButtonWrapper, .myProfile__wayfResetButton, .myProfile__wayfResetText') { + display: inline-block; +} + +@include ie11Only('.myProfile__wayfResetButton') { + height: auto; + min-width: 14rem; +} diff --git a/config/openconext/parameters.yaml.dist b/config/openconext/parameters.yaml.dist index 490737606..dd033f07a 100644 --- a/config/openconext/parameters.yaml.dist +++ b/config/openconext/parameters.yaml.dist @@ -28,6 +28,11 @@ parameters: engineblock_api_password: secret engineblock_api_verify_ssl: true + # The EngineBlock endpoint that clears a user's remembered per-SP WAYF IdP + # choices. Profile links to this with a `redirect` query parameter so the + # user is sent back here afterwards. See OpenConext-engineblock#2085/#345. + wayf_reset_url: "https://engine.dev.openconext.local/reset-remember-wayf" + user_lifecycle_enabled: true user_lifecycle_api_base_url: "https://userlifecycle.dev.openconext.local/" user_lifecycle_api_username: userlifecycle diff --git a/config/services.yaml b/config/services.yaml index 37aa313e3..e52d72237 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,6 +24,11 @@ services: $removeConsentEnabled: '%remove_consent_enabled%' tags: [ 'controller.service_arguments' ] + OpenConext\ProfileBundle\Controller\MyProfileController: + arguments: + $wayfResetUrl: '%wayf_reset_url%' + tags: [ 'controller.service_arguments' ] + OpenConext\ProfileBundle\Controller\InviteRolesController: arguments: $enabled: '%invite_roles_enabled%' diff --git a/src/OpenConext/ProfileBundle/Controller/MyProfileController.php b/src/OpenConext/ProfileBundle/Controller/MyProfileController.php index 1b98d3356..a32583eb0 100644 --- a/src/OpenConext/ProfileBundle/Controller/MyProfileController.php +++ b/src/OpenConext/ProfileBundle/Controller/MyProfileController.php @@ -21,10 +21,12 @@ namespace OpenConext\ProfileBundle\Controller; use OpenConext\ProfileBundle\Service\UserService; +use OpenConext\ProfileBundle\Service\WayfResetLinkBuilder; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class MyProfileController extends AbstractController { @@ -32,6 +34,9 @@ class MyProfileController extends AbstractController public function __construct( private readonly UserService $userService, private readonly LoggerInterface $logger, + private readonly WayfResetLinkBuilder $wayfResetLinkBuilder, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly string $wayfResetUrl, ) { } @@ -47,9 +52,17 @@ public function overview(): Response $user = $this->userService->getUser(); + $wayfResetLink = $this->wayfResetLinkBuilder->build( + $this->wayfResetUrl, + $this->urlGenerator->generate('profile.my_profile_overview', [], UrlGeneratorInterface::ABSOLUTE_URL), + ); + return $this->render( '@OpenConextProfile/MyProfile/overview.html.twig', - ['user' => $user], + [ + 'user' => $user, + 'wayfResetLink' => $wayfResetLink, + ], ); } } diff --git a/src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php b/src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php new file mode 100644 index 000000000..49fd328b8 --- /dev/null +++ b/src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php @@ -0,0 +1,33 @@ +build( + 'https://engine.dev.openconext.local/reset-remember-wayf', + 'https://profile.dev.openconext.local/my-profile', + ); + + $this->assertSame( + 'https://engine.dev.openconext.local/reset-remember-wayf?redirect=https%3A%2F%2Fprofile.dev.openconext.local%2Fmy-profile', + $link, + ); + } + + /** + * @test + * @group Service + */ + public function it_url_encodes_special_characters_in_the_return_url(): void + { + $builder = new WayfResetLinkBuilder(); + + $link = $builder->build( + 'https://engine.dev.openconext.local/reset-remember-wayf', + 'https://profile.dev.openconext.local/my-profile?foo=bar&baz=1', + ); + + $this->assertSame( + 'https://engine.dev.openconext.local/reset-remember-wayf?redirect=' + . rawurlencode('https://profile.dev.openconext.local/my-profile?foo=bar&baz=1'), + $link, + ); + } + + /** + * @test + * @group Service + */ + public function it_uses_an_ampersand_when_the_wayf_reset_url_already_has_a_query_string(): void + { + $builder = new WayfResetLinkBuilder(); + + $link = $builder->build( + 'https://engine.dev.openconext.local/reset-remember-wayf?debug=1', + 'https://profile.dev.openconext.local/my-profile', + ); + + $this->assertSame( + 'https://engine.dev.openconext.local/reset-remember-wayf?debug=1&redirect=https%3A%2F%2Fprofile.dev.openconext.local%2Fmy-profile', + $link, + ); + } +} diff --git a/templates/MyProfile/overview.html.twig b/templates/MyProfile/overview.html.twig index 84f51623e..27db8e312 100644 --- a/templates/MyProfile/overview.html.twig +++ b/templates/MyProfile/overview.html.twig @@ -30,4 +30,37 @@ text: text } %} +
+

{{ 'profile.my_profile.wayf_reset.title'|trans }}

+
+

{{ 'profile.my_profile.wayf_reset.explanation'|trans }}

+
+ {% set modalContent %} +

{{ 'profile.my_profile.wayf_reset.modal_explanation'|trans }}

+ {% endset %} + + {% set buttonRowContent %} + + {{ 'profile.my_profile.wayf_reset.confirm_button'|trans }} + + {% endset %} + + {# The modal helper presupposes a `for` loop context (it derives its element + ids from `loop.index`); wrap it in a single-iteration loop since this page + only ever shows one reset link. #} + {% for _ in [1] %} + {% include "@OpenConextProfile/helpers/modal.html.twig" with { + buttonText: 'profile.my_profile.wayf_reset.link_text'|trans, + linkIdPrefix: 'wayf_reset_', + buttonClass: 'modalWindowButton myProfile__wayfResetButton', + describedBy: 'wayf-reset-description', + modalHeader: 'profile.my_profile.wayf_reset.modal_title'|trans, + modalContent: modalContent, + buttonRowContent: buttonRowContent, + } %} + {% endfor %} +
+
+
+ {% endblock %} diff --git a/translations/messages.en.php b/translations/messages.en.php index 1363e62fe..91f262f36 100644 --- a/translations/messages.en.php +++ b/translations/messages.en.php @@ -185,6 +185,15 @@ 'link_text' => 'Download overview', 'title' => 'Download', ], + + 'wayf_reset' => [ + 'confirm_button' => 'Reset all', + 'explanation' => 'When logging in through %suiteName%, you can choose to always use the same login method for an application. You can reset this choice for all applications here.', + 'link_text' => 'Reset login method choices', + 'modal_explanation' => 'This removes your saved login method choice for every application. The next time you log in, %suiteName% will ask you to choose a login method again.', + 'modal_title' => 'Reset login method choices', + 'title' => 'Login method choices', + ], ], 'my_services' => [ diff --git a/translations/messages.nl.php b/translations/messages.nl.php index d0a0148db..c8fb225db 100644 --- a/translations/messages.nl.php +++ b/translations/messages.nl.php @@ -188,6 +188,15 @@ 'link_text' => 'Download overzicht', 'title' => 'Downloaden', ], + + 'wayf_reset' => [ + 'confirm_button' => 'Alles resetten', + 'explanation' => 'Bij het inloggen via %suiteName% kun je ervoor kiezen om steeds dezelfde inlogmethode voor een applicatie te gebruiken. Je kunt deze keuze hier voor alle applicaties resetten.', + 'link_text' => 'Inlogmethode-keuzes resetten', + 'modal_explanation' => 'Hiermee verwijder je je opgeslagen inlogmethode-keuze voor elke applicatie. De volgende keer dat je inlogt, vraagt %suiteName% je opnieuw om een inlogmethode te kiezen.', + 'modal_title' => 'Inlogmethode-keuzes resetten', + 'title' => 'Inlogmethode-keuzes', + ], ], 'my_services' => [ diff --git a/translations/messages.pt.php b/translations/messages.pt.php index b1fd3bdac..d79d99def 100644 --- a/translations/messages.pt.php +++ b/translations/messages.pt.php @@ -186,6 +186,15 @@ 'link_text' => 'Download visão geral', 'title' => 'Download', ], + + 'wayf_reset' => [ + 'confirm_button' => 'Repor tudo', + 'explanation' => 'Ao iniciar sessão através de %suiteName%, pode optar por usar sempre o mesmo método de início de sessão para um serviço. Pode repor esta escolha aqui para todos os serviços.', + 'link_text' => 'Repor escolhas de método de início de sessão', + 'modal_explanation' => 'Isto remove a sua escolha guardada de método de início de sessão para todos os serviços. Da próxima vez que iniciar sessão, %suiteName% pedir-lhe-á novamente para escolher um método de início de sessão.', + 'modal_title' => 'Repor escolhas de método de início de sessão', + 'title' => 'Escolhas de método de início de sessão', + ], ], 'my_services' => [ From 7b03e6c6523a5309adac6a2d1f6174a0df79afbe Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 22 Sep 2026 15:29:49 +0200 Subject: [PATCH 2/4] Require CSRF protection for the WAYF-reset action # If applied, this commit will Stop the 'reset login method choices' action from being triggerable by a plain, unauthenticated cross-site GET request. # Why is this change needed? Prior to this change, the confirm button in the reset modal was a plain pointing directly at EngineBlock's cross-origin /reset-remember-wayf endpoint. The confirmation modal was a client-side-only gate; the underlying action was a GET with no CSRF token, so it could be triggered by cross-site navigation or an auto-submitting link, silently clearing a user's remembered WAYF choices without their intent. # How does it address the issue? This change replaces the link with a same-origin POST form carrying a Symfony CSRF token. A new route validates the token and only then redirects the browser on to EngineBlock's existing reset endpoint, preserving the same redirect-back behavior for legitimate same-origin submissions. # Provide links to any relevant tickets, articles or other resources Found during review of PR #353. --- .../Controller/MyProfileController.php | 37 +++++- .../Controller/MyProfileControllerTest.php | 124 ++++++++++++++++++ templates/MyProfile/overview.html.twig | 9 +- 3 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 src/OpenConext/ProfileBundle/Tests/Controller/MyProfileControllerTest.php diff --git a/src/OpenConext/ProfileBundle/Controller/MyProfileController.php b/src/OpenConext/ProfileBundle/Controller/MyProfileController.php index a32583eb0..2ff1b3935 100644 --- a/src/OpenConext/ProfileBundle/Controller/MyProfileController.php +++ b/src/OpenConext/ProfileBundle/Controller/MyProfileController.php @@ -24,9 +24,14 @@ use OpenConext\ProfileBundle\Service\WayfResetLinkBuilder; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; class MyProfileController extends AbstractController { @@ -37,6 +42,7 @@ public function __construct( private readonly WayfResetLinkBuilder $wayfResetLinkBuilder, private readonly UrlGeneratorInterface $urlGenerator, private readonly string $wayfResetUrl, + private readonly CsrfTokenManagerInterface $csrfTokenManager, ) { } @@ -52,17 +58,36 @@ public function overview(): Response $user = $this->userService->getUser(); - $wayfResetLink = $this->wayfResetLinkBuilder->build( - $this->wayfResetUrl, - $this->urlGenerator->generate('profile.my_profile_overview', [], UrlGeneratorInterface::ABSOLUTE_URL), - ); - return $this->render( '@OpenConextProfile/MyProfile/overview.html.twig', [ 'user' => $user, - 'wayfResetLink' => $wayfResetLink, ], ); } + + #[Route( + path: "/my-profile/wayf-reset", + name: "profile.my_profile_wayf_reset", + methods: ["POST"], + schemes: "https", + )] + public function resetWayfChoice( + Request $request, + ): RedirectResponse { + $token = new CsrfToken('wayf_reset', (string) $request->request->get('_token')); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new AccessDeniedHttpException('Invalid CSRF token for the WAYF reset action'); + } + + $this->logger->info('Redirecting to EngineBlock to reset the remembered WAYF choice'); + + $wayfResetLink = $this->wayfResetLinkBuilder->build( + $this->wayfResetUrl, + $this->urlGenerator->generate('profile.my_profile_overview', [], UrlGeneratorInterface::ABSOLUTE_URL), + ); + + return new RedirectResponse($wayfResetLink); + } } diff --git a/src/OpenConext/ProfileBundle/Tests/Controller/MyProfileControllerTest.php b/src/OpenConext/ProfileBundle/Tests/Controller/MyProfileControllerTest.php new file mode 100644 index 000000000..776a92bdb --- /dev/null +++ b/src/OpenConext/ProfileBundle/Tests/Controller/MyProfileControllerTest.php @@ -0,0 +1,124 @@ +createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with($this->equalTo(new CsrfToken('wayf_reset', 'valid-token'))) + ->willReturn(true); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->once()) + ->method('generate') + ->with('profile.my_profile_overview', [], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('https://profile.example.org/my-profile'); + + $controller = new MyProfileController( + $this->createUserService(), + $this->createMock(LoggerInterface::class), + new WayfResetLinkBuilder(), + $urlGenerator, + 'https://engine.example.org/reset-remember-wayf', + $csrfTokenManager, + ); + + $response = $controller->resetWayfChoice(new Request([], ['_token' => 'valid-token'])); + + $this->assertSame( + 'https://engine.example.org/reset-remember-wayf?redirect=https%3A%2F%2Fprofile.example.org%2Fmy-profile', + $response->getTargetUrl(), + ); + } + + /** + * @test + * @group Controller + */ + public function it_denies_access_when_the_csrf_token_is_invalid(): void + { + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with($this->equalTo(new CsrfToken('wayf_reset', 'invalid-token'))) + ->willReturn(false); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->never())->method('generate'); + + $controller = new MyProfileController( + $this->createUserService(), + $this->createMock(LoggerInterface::class), + new WayfResetLinkBuilder(), + $urlGenerator, + 'https://engine.example.org/reset-remember-wayf', + $csrfTokenManager, + ); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Invalid CSRF token for the WAYF reset action'); + + $controller->resetWayfChoice(new Request([], ['_token' => 'invalid-token'])); + } + + private function createUserService(): UserService + { + return new UserService( + new SupportContactEmailService($this->createMock(ContactPersonRepositoryInterface::class)), + $this->createMock(UserRepositoryInterface::class), + $this->createMock(AuthenticatedUserProviderInterface::class), + new LocaleService( + $this->createMock(LocaleRepositoryInterface::class), + LocaleSet::create([new Locale('en')]), + new Locale('en'), + ), + new EntityId('https://engine.example.org/authentication/sp/metadata'), + ); + } +} diff --git a/templates/MyProfile/overview.html.twig b/templates/MyProfile/overview.html.twig index 27db8e312..547206486 100644 --- a/templates/MyProfile/overview.html.twig +++ b/templates/MyProfile/overview.html.twig @@ -40,9 +40,12 @@ {% endset %} {% set buttonRowContent %} - - {{ 'profile.my_profile.wayf_reset.confirm_button'|trans }} - +
+ + +
{% endset %} {# The modal helper presupposes a `for` loop context (it derives its element From 8d90d51a1c41ba0c6638f0e485d772dbe242775a Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 6 Oct 2026 11:30:22 +0200 Subject: [PATCH 3/4] Fix modal button layout and document wayf_reset_url param # If applied, this commit will... Fix the reset-choice confirmation button's layout in the modal and add changelog guidance for the new required parameter. # Why is this change needed? Prior to this change, the modal button used a width:100% rule on the form wrapper that broke the button's intended layout, and the CHANGELOG did not mention that the new wayf_reset_url parameter must be set by deployments. # How does it address the issue? This change replaces the width override with display:contents on the form wrapper so the button retains its natural sizing, and adds CHANGELOG entries describing the feature and the required parameter. # Provide links to any relevant tickets, articles or other resources https://github.com/OpenConext/OpenConext-profile/pull/353 --- CHANGELOG.md | 2 ++ assets/css/components/modal-window.scss | 7 ++++--- templates/MyProfile/overview.html.twig | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ff8db92..87479ec09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # Next release +* Add a link on My Profile to reset remembered per-SP WAYF login-method choices #353 +* Deployments must set the new `wayf_reset_url` parameter in `parameters.yaml` (see `config/openconext/parameters.yaml.dist`) to the EngineBlock `/reset-remember-wayf` endpoint. * Upgrade the runtime and build toolchain to PHP 8.5 and Node.js 24. * Refresh the CI Docker base image plus Composer and npm dependencies for the PHP 8.5 build. diff --git a/assets/css/components/modal-window.scss b/assets/css/components/modal-window.scss index 31aed9bc9..690375d9b 100644 --- a/assets/css/components/modal-window.scss +++ b/assets/css/components/modal-window.scss @@ -79,7 +79,8 @@ width: unset; } - &--form { - width: 100%; - } +} + +.modalWindow__button--form { + display: contents; } diff --git a/templates/MyProfile/overview.html.twig b/templates/MyProfile/overview.html.twig index 547206486..0bee4dff8 100644 --- a/templates/MyProfile/overview.html.twig +++ b/templates/MyProfile/overview.html.twig @@ -40,7 +40,7 @@ {% endset %} {% set buttonRowContent %} -
+