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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Changes:
* 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).
* A new parameter `wayf.preferred_idp_entity_ids` must be added to `parameters.yml`. To display a set of IdPs prominent at the top of the WAYF, add the entityId's of those IdPs to this parameter.
* To keep the old behaviour, set the value to `[]`

* Stabilized consent checks
* In order to make the consent hashes more robust, a more consistent way of hashing the user attributes has been introduced
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 @@ -187,6 +187,11 @@ parameters:
wayf.display_default_idp_banner_on_wayf: true
wayf.default_idp_entity_id: https://default-idp.dev.openconext.local

## Ordered list of IdP entity IDs to feature prominently at the top of the WAYF.
## These IdPs appear above the search field and are excluded from the regular searchable list.
## An empty list means no behaviour change.
wayf.preferred_idp_entity_ids: []

## Toggle display & content of global site notice
global.site_notice.show: false
global.site_notice.allowed.tags: '<a><u><i><br><wbr><strong><em><blink><marquee><p><ul><ol><dl><li><dd><dt><div><span><blockquote><hr><h2></h2><h3><h4><h5><h6>'
Expand Down
2 changes: 1 addition & 1 deletion config/services/ci/controllers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
engineblock.functional_test.controller.wayf:
class: OpenConext\EngineBlockFunctionalTestingBundle\Controllers\WayfController
arguments:
- '@twig'
- '@OpenConext\EngineBlockBundle\Service\WayfRenderer'

engineblock.functional_test.controller.feedback:
class: OpenConext\EngineBlockFunctionalTestingBundle\Controllers\FeedbackController
Expand Down
11 changes: 11 additions & 0 deletions config/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@ services:
_defaults:
public: true

OpenConext\EngineBlock\Service\Wayf\IdpSplitter:

OpenConext\EngineBlockBundle\Service\WayfViewModelFactory:
arguments:
$wayfExtension: '@OpenConext\EngineBlockBundle\Twig\Extensions\Extension\Wayf'

OpenConext\EngineBlockBundle\Service\WayfRenderer:
autowire: true

OpenConext\EngineBlockBundle\Bridge\EngineBlockBootstrapper:
autowire: true
autoconfigure: true
arguments:
$preferredIdpEntityIds: '%wayf.preferred_idp_entity_ids%'
tags:
- { name: kernel.event_subscriber }

Expand Down
52 changes: 52 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Testing

## WAYF functional-testing page

The functional-testing route renders the WAYF page with synthetic IdP data, controllable via query parameters. Use it for manual verification and as the base URL for Cypress tests.

**Base URL:** `https://engine.dev.openconext.local/functional-testing/wayf`

### Query parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `lang` | string | `en` | Locale (`en` or `nl`) |
| `connectedIdps` | int | `5` | Number of connected IdPs to generate |
| `unconnectedIdps` | int | `0` | Number of unconnected IdPs to generate |
| `randomIdps` | int | `0` | Generate N IdPs with random (Faker) names instead; overrides connected/unconnected |
| `addDiscoveries` | bool | `true` | Add discovery entries to IdP 1 (gives it 3 list entries instead of 1) |
| `preferredIdpEntityIds[]` | string[] | `[]` | Entity IDs to feature in the preferred section (array syntax required) |
| `defaultIdpEntityId` | string | - | Entity ID of the default IdP (shows banner) |
| `showIdPBanner` | bool | `true` | Whether to show the default IdP banner |
| `displayUnconnectedIdpsWayf` | bool | `false` | Show unconnected IdPs with a "Request access" button |
| `backLink` | bool | `false` | Show "Return to service provider" back link |
| `rememberChoiceFeature` | bool | `false` | Show "Remember my choice" checkbox |
| `cutoffPointForShowingUnfilteredIdps` | int | `100` | Hide the IdP list until the user searches when list length exceeds this value |

#### Baseline
- [Default (5 connected IdPs)](https://engine.dev.openconext.local/functional-testing/wayf)
- [Dutch locale](https://engine.dev.openconext.local/functional-testing/wayf?lang=nl)
- [10 IdPs](https://engine.dev.openconext.local/functional-testing/wayf?connectedIdps=10&addDiscoveries=false)
- [Random IdPs (Faker names)](https://engine.dev.openconext.local/functional-testing/wayf?randomIdps=8)

#### Cutoff / search
- [Cutoff active - list hidden until search](https://engine.dev.openconext.local/functional-testing/wayf?connectedIdps=6&cutoffPointForShowingUnfilteredIdps=5)

#### Unconnected IdPs / request access
- [Unconnected IdPs visible, no request access](https://engine.dev.openconext.local/functional-testing/wayf?unconnectedIdps=3)
- [Unconnected IdPs + request access button](https://engine.dev.openconext.local/functional-testing/wayf?unconnectedIdps=3&displayUnconnectedIdpsWayf=true)

#### UI features
- [Back link](https://engine.dev.openconext.local/functional-testing/wayf?backLink=true)
- [Remember my choice](https://engine.dev.openconext.local/functional-testing/wayf?rememberChoiceFeature=true)
- [Default IdP banner](https://engine.dev.openconext.local/functional-testing/wayf?defaultIdpEntityId=https%3A%2F%2Fexample.com%2FentityId%2F3&showIdPBanner=true&addDiscoveries=false)

#### Preferred IdPs
- [1 preferred IdP](https://engine.dev.openconext.local/functional-testing/wayf?preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1&addDiscoveries=false)
- [2 preferred IdPs](https://engine.dev.openconext.local/functional-testing/wayf?preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1&preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F2&addDiscoveries=false)
- [Preferred = default IdP > banner suppressed](https://engine.dev.openconext.local/functional-testing/wayf?preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1&defaultIdpEntityId=https%3A%2F%2Fexample.com%2FentityId%2F1&showIdPBanner=true&addDiscoveries=false)
- [Preferred ≠ default IdP > both visible](https://engine.dev.openconext.local/functional-testing/wayf?preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1&defaultIdpEntityId=https%3A%2F%2Fexample.com%2FentityId%2F2&showIdPBanner=true&addDiscoveries=false)
- [Preferred IdP with discoveries (1 IdP > 3 entries)](https://engine.dev.openconext.local/functional-testing/wayf?preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1)


- [All features enabled](https://engine.dev.openconext.local/functional-testing/wayf?connectedIdps=8&unconnectedIdps=2&displayUnconnectedIdpsWayf=true&preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1&defaultIdpEntityId=https%3A%2F%2Fexample.com%2FentityId%2F2&showIdPBanner=true&backLink=true&rememberChoiceFeature=true&addDiscoveries=false)
49 changes: 15 additions & 34 deletions library/EngineBlock/Corto/Module/Service/SingleSignOn.php
Original file line number Diff line number Diff line change
Expand Up @@ -462,8 +462,6 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $

$currentLocale = $container->getLocaleProvider()->getLocale();

$cookies = $container->getSymfonyRequest()->cookies->all();

if ($request->isDebugRequest()) {
$serviceProvider = $this->getEngineSpRole($this->_server);
} else {
Expand All @@ -477,28 +475,21 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
$container->getDefaultIdPEntityId()
);

$defaultIdPInIdPList = $this->isDefaultIdPPresent($idpList);
$showDefaultIdpBanner = (bool) $container->shouldDisplayDefaultIdpBannerOnWayf() && $defaultIdPInIdPList;

$rememberChoiceFeature = $container->getRememberChoice();

$output = $this->twig->render(
'@theme/Authentication/View/Proxy/wayf.html.twig',
[
'action' => $action,
'greenHeader' => $serviceProvider->getDisplayName($currentLocale),
'helpLink' => '/authentication/idp/help-discover?lang=' . $currentLocale,
'backLink' => $container->isUiOptionReturnToSpActive(),
'cutoffPointForShowingUnfilteredIdps' => $container->getCutoffPointForShowingUnfilteredIdps(),
'showIdPBanner' => $showDefaultIdpBanner,
'rememberChoiceFeature' => $rememberChoiceFeature,
'showRequestAccess' => $serviceProvider->getCoins()->displayUnconnectedIdpsWayf(),
'requestId' => $request->getId(),
'serviceProvider' => $serviceProvider,
'idpList' => $idpList,
'cookies' => $cookies,
'showRequestAccessContainer' => true,
]
$diContainerRuntime = $application->getDiContainerRuntime();

$output = $diContainerRuntime->wayfRenderer->render(
idpList: $idpList,
preferredIdpEntityIds: $diContainerRuntime->getPreferredIdpEntityIds(),
action: $action,
currentLocale: $currentLocale,
defaultIdpEntityId: $container->getDefaultIdPEntityId(),
shouldDisplayBanner: (bool) $container->shouldDisplayDefaultIdpBannerOnWayf(),
backLink: $container->isUiOptionReturnToSpActive(),
cutoffPoint: $container->getCutoffPointForShowingUnfilteredIdps(),
rememberChoice: $container->getRememberChoice(),
showRequestAccess: $serviceProvider->getCoins()->displayUnconnectedIdpsWayf(),
requestId: $request->getId(),
serviceProvider: $serviceProvider,
);
$this->_server->sendOutput($output);
}
Expand Down Expand Up @@ -675,14 +666,4 @@ protected function getEngineSpRole(EngineBlock_Corto_ProxyServer $proxyServer)
$serviceProvider = $this->_serviceProviderFactory->createEngineBlockEntityFrom($keyId);
return ServiceProvider::fromServiceProviderEntity($serviceProvider);
}

private function isDefaultIdPPresent(array $idpList): bool
{
foreach ($idpList as $idp) {
if ($idp[self::IS_DEFAULT_IDP_KEY] === true) {
return true;
}
}
return false;
}
}
58 changes: 58 additions & 0 deletions src/OpenConext/EngineBlock/Service/Wayf/IdpSplitter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?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.
*/

namespace OpenConext\EngineBlock\Service\Wayf;

final class IdpSplitter
{
/**
* Splits the full IdP list into preferred (connected, in configured order) and regular (everything else).
* Preferred IdPs that are not connected are excluded from both sections.
*
* @param array $idpList Full transformed IdP list
* @param array $preferredEntityIds Ordered list of entity IDs to feature at the top
* @return array{preferred: array, regular: array}
*/
public function split(array $idpList, array $preferredEntityIds): array
{
if (empty($preferredEntityIds)) {
return ['preferred' => [], 'regular' => $idpList];
}

$orderMap = array_flip($preferredEntityIds);
$preferredBuckets = array_fill(0, count($preferredEntityIds), []);
$regular = [];

foreach ($idpList as $idp) {
$entityId = $idp['EntityID'];
if (isset($orderMap[$entityId])) {
if ($idp['Access'] === '1') {
$preferredBuckets[$orderMap[$entityId]][] = $idp;
}
// Unconnected preferred IdPs are excluded from both sections.
} else {
$regular[] = $idp;
}
}

$mergeArgs = array_values(array_filter($preferredBuckets));
$preferred = empty($mergeArgs) ? [] : array_merge(...$mergeArgs);

return ['preferred' => $preferred, 'regular' => $regular];
}
}
11 changes: 10 additions & 1 deletion src/OpenConext/EngineBlockBundle/Bridge/DiContainerRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

namespace OpenConext\EngineBlockBundle\Bridge;

use OpenConext\EngineBlockBundle\Service\WayfRenderer;
use Twig\Environment;

/**
Expand All @@ -29,7 +30,15 @@
final readonly class DiContainerRuntime
{

public function __construct(public Environment $twig)
public function __construct(
public Environment $twig,
public WayfRenderer $wayfRenderer,
private array $preferredIdpEntityIds = [],
) {
}

public function getPreferredIdpEntityIds(): array
{
return $this->preferredIdpEntityIds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
namespace OpenConext\EngineBlockBundle\Bridge;

use EngineBlock_ApplicationSingleton;
use OpenConext\EngineBlockBundle\Service\WayfRenderer;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;
Expand All @@ -29,8 +30,10 @@ class EngineBlockBootstrapper implements EventSubscriberInterface

public function __construct(
Environment $twig,
WayfRenderer $wayfRenderer,
array $preferredIdpEntityIds = [],
) {
$this->diContainerRuntime = new DiContainerRuntime($twig);
$this->diContainerRuntime = new DiContainerRuntime($twig, $wayfRenderer, $preferredIdpEntityIds);
}

public function onKernelRequest(): void
Expand Down
86 changes: 86 additions & 0 deletions src/OpenConext/EngineBlockBundle/Service/WayfRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?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\Service;

use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
use OpenConext\EngineBlock\Service\Wayf\IdpSplitter;
use Twig\Environment;

class WayfRenderer
{
public function __construct(
private readonly WayfViewModelFactory $factory,
private readonly IdpSplitter $splitter,
private readonly Environment $twig,
) {
}

/**
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*/
public function render(
array $idpList,
array $preferredIdpEntityIds,
string $action,
string $currentLocale,
string $defaultIdpEntityId,
bool $shouldDisplayBanner,
bool $backLink,
int $cutoffPoint,
bool $rememberChoice,
bool $showRequestAccess,
string $requestId,
ServiceProvider $serviceProvider,
): string {
$split = $this->splitter->split($idpList, $preferredIdpEntityIds);
$showPreferredIdps = !empty($split['preferred']);
$isDefaultIdpPreferred = in_array($defaultIdpEntityId, $preferredIdpEntityIds, true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The banner is suppressed when the default IdP is in $preferredIdpEntityIds, but this is the raw config list it doesn't know whether the IdP is actually connected to this SP. IdpSplitter silently drops disconnected preferred IdPs, so the default IdP can be "on the guest list" but never show up to the party.

When this happens the default IdP is invisible everywhere: not in the preferred section (dropped by splitter), not in the regular list (preferred IdPs are excluded from regular), and the banner is suppressed too.

// checks the config — doesn't know if the IdP is connected
$isDefaultIdpPreferred = in_array($defaultIdpEntityId, $preferredIdpEntityIds, true);

// fix: check what the splitter actually produced
$preferredEntityIdsShown = array_column($split['preferred'], 'EntityID');
$isDefaultIdpPreferred = in_array($defaultIdpEntityId, $preferredEntityIdsShown, true);


$showIdPBanner = $shouldDisplayBanner
&& $this->isDefaultIdpPresent($idpList)
&& (!$showPreferredIdps || !$isDefaultIdpPreferred);

$viewModel = $this->factory->create(
idpList: $idpList,
regularIdpList: $split['regular'],
preferredIdpList: $split['preferred'],
showPreferredIdps: $showPreferredIdps,
action: $action,
greenHeader: $serviceProvider->getDisplayName($currentLocale),
helpLink: '/authentication/idp/help-discover?lang=' . $currentLocale,
backLink: $backLink,
cutoffPointForShowingUnfilteredIdps: $cutoffPoint,
showIdPBanner: $showIdPBanner,
rememberChoiceFeature: $rememberChoice,
showRequestAccess: $showRequestAccess,
requestId: $requestId,
serviceProvider: $serviceProvider,
showRequestAccessContainer: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showRequestAccessContainer is hardcoded true the parameter does nothing

WayfRenderer::render() always passes showRequestAccessContainer: true to the factory. It is carried through WayfViewModelFactory and stored in WayfViewModel, but no caller can ever change it.

If it should always be true, can you not hardcode it in the factory?

);

return $this->twig->render('@theme/Authentication/View/Proxy/wayf.html.twig', $viewModel->toArray());
}

private function isDefaultIdpPresent(array $idpList): bool
{
return array_any($idpList, fn($idp) => ($idp['isDefaultIdp'] ?? false) === true);
}
}
Loading