Skip to content

Commit 1d22124

Browse files
authored
Add endpoint to reset the per-SP WAYF remember-choice cookie (#2094)
* Add endpoint to reset the per-SP WAYF remember-choice cookie Introduce GET /reset-remember-wayf, a public endpoint that lets a user forget their remembered per-SP IdP choices set up in #2065. Visiting the page clears the 'rememberedidps' cookie (if present) and redirects to an operator-configured URL. - ResetRememberedWayfController reuses the existing RememberedIdpCookie service rather than hand-rolling cookie-clearing logic: normalize() computes the valid entry count for logging, clear() removes the cookie with the same SameSite/secure flags used when it was written. Both only run when the cookie is actually present, so a first-time visitor with no cookie is redirected without any log noise. - The controller validates its configured redirect URL at construction time and refuses to serve requests when it is blank, so a missing wayf.reset_choice_per_idp_redirect parameter fails fast instead of silently sending users nowhere. - New config wayf.reset_choice_per_idp_redirect defaults to a working dev URL (matching the convention of other wayf.* dev defaults in parameters.yml.dist) so the endpoint works out of the box in dev/CI, while still requiring operators to set a real destination in production. - The functional test asserts the redirect target against the configured parameter rather than merely checking for a Location header: the app's global exception listener also turns uncaught exceptions into a 302, which would otherwise let a broken configuration pass unnoticed. Refs: #2085 * Remove explanatory prose comment restating the assertion below it * Document the reset endpoint for the per-SP remembered choice # Why is this change needed? Prior to this change, /reset-remember-wayf and the wayf.reset_choice_per_idp_redirect parameter were only described in the comments of parameters.yml.dist. This is an open source project, so operators and integrators need to find the endpoint in the docs and the release notes. # How does it address the issue? This change adds a section to docs/wayf_remember_choice.md and an entry to the CHANGELOG. * Replace Phake with Mockery in ResetRememberedWayfControllerTest # If applied, this commit will... Convert the remaining Phake-based mocking in ResetRememberedWayfControllerTest to Mockery, matching the rest of the test suite. # Why is this change needed? Prior to this change, the test mixed Phake (for CookieService) with Mockery (for LoggerInterface) in the same class. Phake is deprecated in this codebase and only used in a handful of legacy test files, and mixing mocking libraries in one test class adds unnecessary cognitive overhead. # How does it address the issue? This change replaces Phake::mock()/when()/verify() calls with declarative Mockery::mock()->shouldReceive()->with()->andReturn() expectations set up before the action under test runs. The "no interaction expected" case relies on Mockery's default strict behaviour, which throws automatically on any unstubbed method call, so no explicit verification call is needed. # Provide links to any relevant tickets, articles or other resources Addresses review comment on PR #2094.
1 parent 16b9f08 commit 1d22124

7 files changed

Lines changed: 317 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Features:
2323
* The per-SP mode does not require `wayf.remember_choice`. Leave `wayf.remember_choice` set to `false` when enabling it.
2424
* The cookie removal page `/authentication/idp/remove-cookies` is now also available in the per-SP mode and lists the
2525
`rememberedidps` cookie. Before, it was only available when `wayf.remember_choice` was `true`.
26+
* Added the endpoint `/reset-remember-wayf`, which removes the per-SP `rememberedidps` cookie and redirects to the URL
27+
configured in `wayf.reset_choice_per_idp_redirect`. This new parameter must not be empty. See
28+
`docs/wayf_remember_choice.md`.
2629

2730
Updated:
2831

‎config/packages/parameters.yml.dist‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ parameters:
189189
## oldest-expiring entries are evicted first; the newly added choice is always kept and no
190190
## error is raised (see RememberedIdpCookie::pruneOverLimit()).
191191
wayf.remember_choice_per_idp_max: 16
192+
## URL to redirect the user to after visiting /reset-remember-wayf and having their
193+
## per-SP remembered IdP choices cookie removed. Operators should override this with a
194+
## real destination for their deployment; it must never be left empty, as the endpoint
195+
## refuses to serve requests (failing fast at construction time) when it is blank.
196+
wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/'
192197

193198
## Toggle the default IdP quick link banner on the WAYF.
194199
wayf.display_default_idp_banner_on_wayf: true

‎config/services/controllers/authentication.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ services:
7474
- '@twig'
7575
- '@OpenConext\EngineBlock\Service\SsoSessionService'
7676

77+
OpenConext\EngineBlockBundle\Controller\ResetRememberedWayfController:
78+
arguments:
79+
$rememberedIdpCookie: '@OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie'
80+
$logger: '@engineblock.compat.logger'
81+
$redirectUrl: '%wayf.reset_choice_per_idp_redirect%'
82+
7783
OpenConext\EngineBlock\Service\RequestAccessMailer:
7884
arguments:
7985
- '@symfony.mailer'

‎docs/wayf_remember_choice.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ The page `/authentication/idp/remove-cookies` lets a user inspect and remove the
9090
including `rememberchoice` and `rememberedidps`. The page is available when either mode is enabled, and returns a 404
9191
when both are disabled.
9292

93+
## Resetting the remembered choices
94+
95+
In the per-SP mode, other applications (for example a profile page) can let users forget all their remembered choices
96+
by sending them to:
97+
98+
https://<engineblock-host>/reset-remember-wayf
99+
100+
The endpoint removes the `rememberedidps` cookie and redirects the user (HTTP 302) to the URL configured in:
101+
102+
# Where to send the user after the reset. Required, must not be empty.
103+
wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/'
104+
105+
The endpoint only accepts `GET` requests and needs no authentication, because it only clears a cookie in the user's own
106+
browser. It does nothing when the cookie is not present. The `rememberchoice` cookie of the global mode is not touched.
107+
93108
## Switching modes
94109

95110
Cookies written in one mode are not read in the other. After switching, users have to make their choice once more.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2026 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace OpenConext\EngineBlockBundle\Controller;
22+
23+
use InvalidArgumentException;
24+
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
25+
use Psr\Log\LoggerInterface;
26+
use Symfony\Component\HttpFoundation\RedirectResponse;
27+
use Symfony\Component\HttpFoundation\Request;
28+
use Symfony\Component\HttpFoundation\Response;
29+
use Symfony\Component\Routing\Attribute\Route;
30+
31+
final class ResetRememberedWayfController
32+
{
33+
public function __construct(
34+
private readonly RememberedIdpCookie $rememberedIdpCookie,
35+
private readonly LoggerInterface $logger,
36+
private readonly string $redirectUrl,
37+
) {
38+
if ($this->redirectUrl === '') {
39+
throw new InvalidArgumentException(
40+
'The "wayf.reset_choice_per_idp_redirect" parameter must be configured with a redirect URL'
41+
);
42+
}
43+
}
44+
45+
#[Route(path: '/reset-remember-wayf', name: 'reset_remember_wayf', methods: ['GET'])]
46+
public function __invoke(Request $request): RedirectResponse
47+
{
48+
$raw = $request->cookies->get(RememberedIdpCookie::NAME);
49+
50+
if ($raw !== null) {
51+
$entryCount = count($this->rememberedIdpCookie->normalize($raw)['entries']);
52+
$this->rememberedIdpCookie->clear();
53+
$this->logger->info(sprintf(
54+
'WAYF-remember-my-choice cookie removed (had %d entries)',
55+
$entryCount
56+
));
57+
}
58+
59+
return new RedirectResponse($this->redirectUrl, Response::HTTP_FOUND);
60+
}
61+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2026 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace OpenConext\EngineBlockBundle\Tests;
22+
23+
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
24+
use PHPUnit\Framework\Attributes\Test;
25+
use Symfony\Component\BrowserKit\Cookie;
26+
use Symfony\Component\HttpFoundation\Response;
27+
28+
final class ResetRememberedWayfControllerTest extends FunctionalWebTestCase
29+
{
30+
#[Test]
31+
public function visiting_the_endpoint_with_a_remembered_idp_cookie_clears_it_and_redirects(): void
32+
{
33+
$client = self::createClient();
34+
$client->getCookieJar()->set(new Cookie(
35+
RememberedIdpCookie::NAME,
36+
self::encodeEntries([
37+
'https://sp.example.org' => ['idp' => 'https://idp.example.org', 'expires' => time() + 3600],
38+
]),
39+
null,
40+
'/',
41+
'engine.dev.openconext.local'
42+
));
43+
44+
$client->request('GET', 'https://engine.dev.openconext.local/reset-remember-wayf');
45+
46+
$response = $client->getResponse();
47+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
48+
$this->assertSame(
49+
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
50+
$response->headers->get('Location')
51+
);
52+
}
53+
54+
#[Test]
55+
public function visiting_the_endpoint_without_a_remembered_idp_cookie_still_redirects(): void
56+
{
57+
$client = self::createClient();
58+
59+
$client->request('GET', 'https://engine.dev.openconext.local/reset-remember-wayf');
60+
61+
$response = $client->getResponse();
62+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
63+
$this->assertSame(
64+
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
65+
$response->headers->get('Location')
66+
);
67+
}
68+
69+
/** @param array<string, array{idp: string, expires: int}> $entries */
70+
private static function encodeEntries(array $entries): string
71+
{
72+
return base64_encode((string) gzdeflate((string) json_encode($entries)));
73+
}
74+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2026 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace OpenConext\EngineBlockBundle\Tests;
22+
23+
use InvalidArgumentException;
24+
use Mockery;
25+
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
26+
use OpenConext\EngineBlock\Service\CookieService;
27+
use OpenConext\EngineBlock\Service\TimeProvider\TimeProvider;
28+
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
29+
use OpenConext\EngineBlockBundle\Controller\ResetRememberedWayfController;
30+
use PHPUnit\Framework\Attributes\Test;
31+
use PHPUnit\Framework\TestCase;
32+
use Psr\Log\LoggerInterface;
33+
use Symfony\Component\HttpFoundation\Request;
34+
use Symfony\Component\HttpFoundation\Response;
35+
36+
class ResetRememberedWayfControllerTest extends TestCase
37+
{
38+
use MockeryPHPUnitIntegration;
39+
40+
private const string REDIRECT_URL = 'https://sp.example.org/after-reset';
41+
private const int LIFETIME = 7776000;
42+
private const int MAX_ENTRIES = 16;
43+
private const string COOKIE_DOMAIN = 'engine.example.org';
44+
private const string COOKIE_PATH = '/';
45+
46+
#[Test]
47+
public function cookie_with_valid_entries_is_cleared_and_logged_with_entry_count(): void
48+
{
49+
$cookieService = Mockery::mock(CookieService::class);
50+
$cookieService->shouldReceive('clearCookieWithSameSite')
51+
->once()
52+
->with(
53+
RememberedIdpCookie::NAME,
54+
self::COOKIE_PATH,
55+
self::COOKIE_DOMAIN,
56+
true,
57+
true,
58+
'None'
59+
)
60+
->andReturn(true);
61+
62+
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);
63+
$raw = $rememberedIdpCookie->encode([
64+
'https://sp1.example.org' => ['idp' => 'https://idp1.example.org', 'expires' => time() + 3600],
65+
'https://sp2.example.org' => ['idp' => 'https://idp2.example.org', 'expires' => time() + 3600],
66+
]);
67+
68+
$logger = Mockery::mock(LoggerInterface::class);
69+
$logger->shouldReceive('info')
70+
->once()
71+
->with('WAYF-remember-my-choice cookie removed (had 2 entries)');
72+
73+
$controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL);
74+
75+
$response = $controller($this->buildRequest($raw));
76+
77+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
78+
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
79+
}
80+
81+
#[Test]
82+
public function invalid_cookie_is_still_cleared_and_logged_with_zero_entries(): void
83+
{
84+
$cookieService = Mockery::mock(CookieService::class);
85+
$cookieService->shouldReceive('clearCookieWithSameSite')->once()->andReturn(true);
86+
87+
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);
88+
89+
$logger = Mockery::mock(LoggerInterface::class);
90+
$logger->shouldReceive('info')
91+
->once()
92+
->with('WAYF-remember-my-choice cookie removed (had 0 entries)');
93+
94+
$controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL);
95+
96+
$response = $controller($this->buildRequest('not valid base64 or deflated data!'));
97+
98+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
99+
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
100+
}
101+
102+
#[Test]
103+
public function missing_cookie_is_not_cleared_or_logged_but_still_redirects(): void
104+
{
105+
$cookieService = Mockery::mock(CookieService::class);
106+
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);
107+
108+
$logger = Mockery::mock(LoggerInterface::class);
109+
$logger->shouldNotReceive('info');
110+
111+
$controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL);
112+
113+
$response = $controller($this->buildRequest(null));
114+
115+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
116+
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
117+
}
118+
119+
#[Test]
120+
public function constructor_rejects_an_empty_redirect_url(): void
121+
{
122+
$this->expectException(InvalidArgumentException::class);
123+
124+
new ResetRememberedWayfController(
125+
$this->buildRememberedIdpCookie(Mockery::mock(CookieService::class)),
126+
Mockery::mock(LoggerInterface::class),
127+
''
128+
);
129+
}
130+
131+
private function buildRememberedIdpCookie(CookieService $cookieService): RememberedIdpCookie
132+
{
133+
return new RememberedIdpCookie(
134+
new TimeProvider(),
135+
$cookieService,
136+
self::LIFETIME,
137+
self::MAX_ENTRIES,
138+
self::COOKIE_DOMAIN,
139+
self::COOKIE_PATH,
140+
true,
141+
);
142+
}
143+
144+
private function buildRequest(?string $rememberedIdpsCookie): Request
145+
{
146+
$request = Request::create('/reset-remember-wayf');
147+
if ($rememberedIdpsCookie !== null) {
148+
$request->cookies->set(RememberedIdpCookie::NAME, $rememberedIdpsCookie);
149+
}
150+
151+
return $request;
152+
}
153+
}

0 commit comments

Comments
 (0)