Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Features:
* The per-SP mode does not require `wayf.remember_choice`. Leave `wayf.remember_choice` set to `false` when enabling it.
* The cookie removal page `/authentication/idp/remove-cookies` is now also available in the per-SP mode and lists the
`rememberedidps` cookie. Before, it was only available when `wayf.remember_choice` was `true`.
* Added the endpoint `/reset-remember-wayf`, which removes the per-SP `rememberedidps` cookie and redirects to the URL
configured in `wayf.reset_choice_per_idp_redirect`. This new parameter must not be empty. See
`docs/wayf_remember_choice.md`.

## 7.2.1

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

## Toggle the default IdP quick link banner on the WAYF.
wayf.display_default_idp_banner_on_wayf: true
Expand Down
6 changes: 6 additions & 0 deletions config/services/controllers/authentication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ services:
- '@twig'
- '@OpenConext\EngineBlock\Service\SsoSessionService'

OpenConext\EngineBlockBundle\Controller\ResetRememberedWayfController:
arguments:
$rememberedIdpCookie: '@OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie'
$logger: '@engineblock.compat.logger'
$redirectUrl: '%wayf.reset_choice_per_idp_redirect%'

OpenConext\EngineBlock\Service\RequestAccessMailer:
arguments:
- '@symfony.mailer'
Expand Down
15 changes: 15 additions & 0 deletions docs/wayf_remember_choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ The page `/authentication/idp/remove-cookies` lets a user inspect and remove the
including `rememberchoice` and `rememberedidps`. The page is available when either mode is enabled, and returns a 404
when both are disabled.

## Resetting the remembered choices

In the per-SP mode, other applications (for example a profile page) can let users forget all their remembered choices
by sending them to:

https://<engineblock-host>/reset-remember-wayf

The endpoint removes the `rememberedidps` cookie and redirects the user (HTTP 302) to the URL configured in:

# Where to send the user after the reset. Required, must not be empty.
wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/'

The endpoint only accepts `GET` requests and needs no authentication, because it only clears a cookie in the user's own
browser. It does nothing when the cookie is not present. The `rememberchoice` cookie of the global mode is not touched.

## Switching modes

Cookies written in one mode are not read in the other. After switching, users have to make their choice once more.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/**
* Copyright 2026 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

declare(strict_types=1);

namespace OpenConext\EngineBlockBundle\Controller;

use InvalidArgumentException;
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ResetRememberedWayfController
{
public function __construct(
private readonly RememberedIdpCookie $rememberedIdpCookie,
private readonly LoggerInterface $logger,
private readonly string $redirectUrl,
) {
if ($this->redirectUrl === '') {
throw new InvalidArgumentException(
'The "wayf.reset_choice_per_idp_redirect" parameter must be configured with a redirect URL'
);
}
}

#[Route(path: '/reset-remember-wayf', name: 'reset_remember_wayf', methods: ['GET'])]
public function __invoke(Request $request): RedirectResponse
{
$raw = $request->cookies->get(RememberedIdpCookie::NAME);

if ($raw !== null) {
$entryCount = count($this->rememberedIdpCookie->normalize($raw)['entries']);
$this->rememberedIdpCookie->clear();
$this->logger->info(sprintf(
'WAYF-remember-my-choice cookie removed (had %d entries)',
$entryCount
));
}

return new RedirectResponse($this->redirectUrl, Response::HTTP_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

/**
* Copyright 2026 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

declare(strict_types=1);

namespace OpenConext\EngineBlockBundle\Tests;

use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\HttpFoundation\Response;

final class ResetRememberedWayfControllerTest extends FunctionalWebTestCase
{
#[Test]
public function visiting_the_endpoint_with_a_remembered_idp_cookie_clears_it_and_redirects(): void
{
$client = self::createClient();
$client->getCookieJar()->set(new Cookie(
RememberedIdpCookie::NAME,
self::encodeEntries([
'https://sp.example.org' => ['idp' => 'https://idp.example.org', 'expires' => time() + 3600],
]),
null,
'/',
'engine.dev.openconext.local'
));

$client->request('GET', 'https://engine.dev.openconext.local/reset-remember-wayf');

$response = $client->getResponse();
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
$response->headers->get('Location')
);
}

#[Test]
public function visiting_the_endpoint_without_a_remembered_idp_cookie_still_redirects(): void
{
$client = self::createClient();

$client->request('GET', 'https://engine.dev.openconext.local/reset-remember-wayf');

$response = $client->getResponse();
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
$response->headers->get('Location')
);
}

/** @param array<string, array{idp: string, expires: int}> $entries */
private static function encodeEntries(array $entries): string
{
return base64_encode((string) gzdeflate((string) json_encode($entries)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

/**
* Copyright 2026 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

declare(strict_types=1);

namespace OpenConext\EngineBlockBundle\Tests;

use InvalidArgumentException;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use OpenConext\EngineBlock\Service\CookieService;
use OpenConext\EngineBlock\Service\TimeProvider\TimeProvider;
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
use OpenConext\EngineBlockBundle\Controller\ResetRememberedWayfController;
use Phake;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ResetRememberedWayfControllerTest extends TestCase
{
use MockeryPHPUnitIntegration;

private const string REDIRECT_URL = 'https://sp.example.org/after-reset';
private const int LIFETIME = 7776000;
private const int MAX_ENTRIES = 16;
private const string COOKIE_DOMAIN = 'engine.example.org';
private const string COOKIE_PATH = '/';

#[Test]
public function cookie_with_valid_entries_is_cleared_and_logged_with_entry_count(): void
{
$cookieService = Phake::mock(CookieService::class);
Phake::when($cookieService)->clearCookieWithSameSite(Phake::anyParameters())->thenReturn(true);

$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);
$raw = $rememberedIdpCookie->encode([
'https://sp1.example.org' => ['idp' => 'https://idp1.example.org', 'expires' => time() + 3600],
'https://sp2.example.org' => ['idp' => 'https://idp2.example.org', 'expires' => time() + 3600],
]);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldReceive('info')
->once()
->with('WAYF-remember-my-choice cookie removed (had 2 entries)');

$controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL);

$response = $controller($this->buildRequest($raw));

$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
Phake::verify($cookieService)->clearCookieWithSameSite(
RememberedIdpCookie::NAME,
self::COOKIE_PATH,
self::COOKIE_DOMAIN,
true,
true,
'None'
);
}

#[Test]
public function invalid_cookie_is_still_cleared_and_logged_with_zero_entries(): void
{
$cookieService = Phake::mock(CookieService::class);
Phake::when($cookieService)->clearCookieWithSameSite(Phake::anyParameters())->thenReturn(true);

$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldReceive('info')
->once()
->with('WAYF-remember-my-choice cookie removed (had 0 entries)');

$controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL);

$response = $controller($this->buildRequest('not valid base64 or deflated data!'));

$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
Phake::verify($cookieService)->clearCookieWithSameSite(Phake::anyParameters());
}

#[Test]
public function missing_cookie_is_not_cleared_or_logged_but_still_redirects(): void
{
$cookieService = Phake::mock(CookieService::class);
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldNotReceive('info');

$controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL);

$response = $controller($this->buildRequest(null));

$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
Phake::verifyNoInteraction($cookieService);
}

#[Test]
public function constructor_rejects_an_empty_redirect_url(): void
{
$this->expectException(InvalidArgumentException::class);

new ResetRememberedWayfController(
$this->buildRememberedIdpCookie(Phake::mock(CookieService::class)),
Mockery::mock(LoggerInterface::class),
''
);
}

private function buildRememberedIdpCookie(CookieService $cookieService): RememberedIdpCookie
{
return new RememberedIdpCookie(
new TimeProvider(),
$cookieService,
self::LIFETIME,
self::MAX_ENTRIES,
self::COOKIE_DOMAIN,
self::COOKIE_PATH,
true,
);
}

private function buildRequest(?string $rememberedIdpsCookie): Request
{
$request = Request::create('/reset-remember-wayf');
if ($rememberedIdpsCookie !== null) {
$request->cookies->set(RememberedIdpCookie::NAME, $rememberedIdpsCookie);
}

return $request;
}
}
Loading