Skip to content

Commit 1a36de3

Browse files
committed
Add support for preferred IdPs in WAYF display
Prior to this change, there was no way to configure priority IdPs. This change adds a `wayf.preferred_idp_entity_ids` parameter to configure IdPs that should show prominent in the wayf. IdPs in this list are shown on top of the wayf, outside of the regular list. Resolves #1970
1 parent 0da7533 commit 1a36de3

39 files changed

Lines changed: 853 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ Changes:
2929
* The `0000-00-00 00:00:00` is added for clarity/consistency, as this is probably the default behaviour of your database already.
3030
* Removed unused index `consent.deleted_at`. Delete this from your production database if it's there.
3131
* Added a specific error page for unsolicited SAML responses (IdP-initiated SSO without a prior AuthnRequest).
32+
* 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.
33+
* To keep the old behaviour, set the value to `[]`
3234

35+
**Default behaviour (no change):** when the parameter is absent or empty, the WAYF behaves exactly as before.
3336
* Stabilized consent checks
3437
* In order to make the consent hashes more robust, a more consistent way of hashing the user attributes has been introduced
3538
* This feature automatically migrates from the old hashes to the new hashes upon login.

config/packages/parameters.yml.dist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ parameters:
187187
wayf.display_default_idp_banner_on_wayf: true
188188
wayf.default_idp_entity_id: https://default-idp.dev.openconext.local
189189

190+
## Ordered list of IdP entity IDs to feature prominently at the top of the WAYF.
191+
## These IdPs appear above the search field and are excluded from the regular searchable list.
192+
## An empty list means no behaviour change.
193+
wayf.preferred_idp_entity_ids: []
194+
190195
## Toggle display & content of global site notice
191196
global.site_notice.show: false
192197
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>'

config/services/services.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ services:
22
_defaults:
33
public: true
44

5+
OpenConext\EngineBlock\Service\Wayf\IdpSplitter:
6+
7+
OpenConext\EngineBlockBundle\Service\WayfViewModelFactory:
8+
arguments:
9+
$wayfExtension: '@OpenConext\EngineBlockBundle\Twig\Extensions\Extension\Wayf'
10+
511
OpenConext\EngineBlockBundle\Bridge\EngineBlockBootstrapper:
612
autowire: true
713
autoconfigure: true
14+
arguments:
15+
$preferredIdpEntityIds: '%wayf.preferred_idp_entity_ids%'
816
tags:
917
- { name: kernel.event_subscriber }
1018

docs/testing.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Testing
2+
3+
## WAYF functional-testing page
4+
5+
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.
6+
7+
**Base URL:** `https://engine.dev.openconext.local/functional-testing/wayf`
8+
9+
### Query parameters
10+
11+
| Parameter | Type | Default | Description |
12+
|---|---|---|---|
13+
| `lang` | string | `en` | Locale (`en` or `nl`) |
14+
| `connectedIdps` | int | `5` | Number of connected IdPs to generate |
15+
| `unconnectedIdps` | int | `0` | Number of unconnected IdPs to generate |
16+
| `randomIdps` | int | `0` | Generate N IdPs with random (Faker) names instead; overrides connected/unconnected |
17+
| `addDiscoveries` | bool | `true` | Add discovery entries to IdP 1 (gives it 3 list entries instead of 1) |
18+
| `preferredIdpEntityIds[]` | string[] | `[]` | Entity IDs to feature in the preferred section (array syntax required) |
19+
| `defaultIdpEntityId` | string | - | Entity ID of the default IdP (shows banner) |
20+
| `showIdPBanner` | bool | `true` | Whether to show the default IdP banner |
21+
| `displayUnconnectedIdpsWayf` | bool | `false` | Show unconnected IdPs with a "Request access" button |
22+
| `backLink` | bool | `false` | Show "Return to service provider" back link |
23+
| `rememberChoiceFeature` | bool | `false` | Show "Remember my choice" checkbox |
24+
| `cutoffPointForShowingUnfilteredIdps` | int | `100` | Hide the IdP list until the user searches when list length exceeds this value |
25+
26+
#### Baseline
27+
- [Default (5 connected IdPs)](https://engine.dev.openconext.local/functional-testing/wayf)
28+
- [Dutch locale](https://engine.dev.openconext.local/functional-testing/wayf?lang=nl)
29+
- [10 IdPs](https://engine.dev.openconext.local/functional-testing/wayf?connectedIdps=10&addDiscoveries=false)
30+
- [Random IdPs (Faker names)](https://engine.dev.openconext.local/functional-testing/wayf?randomIdps=8)
31+
32+
#### Cutoff / search
33+
- [Cutoff active - list hidden until search](https://engine.dev.openconext.local/functional-testing/wayf?connectedIdps=6&cutoffPointForShowingUnfilteredIdps=5)
34+
35+
#### Unconnected IdPs / request access
36+
- [Unconnected IdPs visible, no request access](https://engine.dev.openconext.local/functional-testing/wayf?unconnectedIdps=3)
37+
- [Unconnected IdPs + request access button](https://engine.dev.openconext.local/functional-testing/wayf?unconnectedIdps=3&displayUnconnectedIdpsWayf=true)
38+
39+
#### UI features
40+
- [Back link](https://engine.dev.openconext.local/functional-testing/wayf?backLink=true)
41+
- [Remember my choice](https://engine.dev.openconext.local/functional-testing/wayf?rememberChoiceFeature=true)
42+
- [Default IdP banner](https://engine.dev.openconext.local/functional-testing/wayf?defaultIdpEntityId=https%3A%2F%2Fexample.com%2FentityId%2F3&showIdPBanner=true&addDiscoveries=false)
43+
44+
#### Preferred IdPs
45+
- [1 preferred IdP](https://engine.dev.openconext.local/functional-testing/wayf?preferredIdpEntityIds%5B%5D=https%3A%2F%2Fexample.com%2FentityId%2F1&addDiscoveries=false)
46+
- [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)
47+
- [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)
48+
- [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)
49+
- [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)
50+
51+
52+
- [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)

library/EngineBlock/Corto/Module/Service/SingleSignOn.php

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -462,8 +462,6 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
462462

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

465-
$cookies = $container->getSymfonyRequest()->cookies->all();
466-
467465
if ($request->isDebugRequest()) {
468466
$serviceProvider = $this->getEngineSpRole($this->_server);
469467
} else {
@@ -478,27 +476,39 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
478476
);
479477

480478
$defaultIdPInIdPList = $this->isDefaultIdPPresent($idpList);
481-
$showDefaultIdpBanner = (bool) $container->shouldDisplayDefaultIdpBannerOnWayf() && $defaultIdPInIdPList;
479+
480+
$diContainerRuntime = $application->getDiContainerRuntime();
481+
$preferredIdpEntityIds = $diContainerRuntime->getPreferredIdpEntityIds();
482+
$split = $diContainerRuntime->idpSplitter->split($idpList, $preferredIdpEntityIds);
483+
$showPreferredIdps = !empty($split['preferred']);
484+
$isDefaultIdpPreferred = in_array($container->getDefaultIdPEntityId(), $preferredIdpEntityIds, true);
485+
$showDefaultIdpBanner = (bool) $container->shouldDisplayDefaultIdpBannerOnWayf()
486+
&& $defaultIdPInIdPList
487+
&& (!$showPreferredIdps || !$isDefaultIdpPreferred);
482488

483489
$rememberChoiceFeature = $container->getRememberChoice();
484490

491+
$viewModel = $diContainerRuntime->wayfViewModelFactory->create(
492+
idpList: $idpList,
493+
regularIdpList: $split['regular'],
494+
preferredIdpList: $split['preferred'],
495+
showPreferredIdps: $showPreferredIdps,
496+
action: $action,
497+
greenHeader: $serviceProvider->getDisplayName($currentLocale),
498+
helpLink: '/authentication/idp/help-discover?lang=' . $currentLocale,
499+
backLink: $container->isUiOptionReturnToSpActive(),
500+
cutoffPointForShowingUnfilteredIdps: $container->getCutoffPointForShowingUnfilteredIdps(),
501+
showIdPBanner: $showDefaultIdpBanner,
502+
rememberChoiceFeature: $rememberChoiceFeature,
503+
showRequestAccess: $serviceProvider->getCoins()->displayUnconnectedIdpsWayf(),
504+
requestId: $request->getId(),
505+
serviceProvider: $serviceProvider,
506+
showRequestAccessContainer: true,
507+
);
508+
485509
$output = $this->twig->render(
486510
'@theme/Authentication/View/Proxy/wayf.html.twig',
487-
[
488-
'action' => $action,
489-
'greenHeader' => $serviceProvider->getDisplayName($currentLocale),
490-
'helpLink' => '/authentication/idp/help-discover?lang=' . $currentLocale,
491-
'backLink' => $container->isUiOptionReturnToSpActive(),
492-
'cutoffPointForShowingUnfilteredIdps' => $container->getCutoffPointForShowingUnfilteredIdps(),
493-
'showIdPBanner' => $showDefaultIdpBanner,
494-
'rememberChoiceFeature' => $rememberChoiceFeature,
495-
'showRequestAccess' => $serviceProvider->getCoins()->displayUnconnectedIdpsWayf(),
496-
'requestId' => $request->getId(),
497-
'serviceProvider' => $serviceProvider,
498-
'idpList' => $idpList,
499-
'cookies' => $cookies,
500-
'showRequestAccessContainer' => true,
501-
]
511+
$viewModel->toArray()
502512
);
503513
$this->_server->sendOutput($output);
504514
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2025 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+
namespace OpenConext\EngineBlock\Service\Wayf;
20+
21+
final class IdpSplitter
22+
{
23+
/**
24+
* Splits the full IdP list into preferred (connected, in configured order) and regular (everything else).
25+
* Preferred IdPs that are not connected are excluded from both sections.
26+
*
27+
* @param array $idpList Full transformed IdP list
28+
* @param array $preferredEntityIds Ordered list of entity IDs to feature at the top
29+
* @return array{preferred: array, regular: array}
30+
*/
31+
public function split(array $idpList, array $preferredEntityIds): array
32+
{
33+
if (empty($preferredEntityIds)) {
34+
return ['preferred' => [], 'regular' => $idpList];
35+
}
36+
37+
$orderMap = array_flip($preferredEntityIds);
38+
$preferredBuckets = array_fill(0, count($preferredEntityIds), []);
39+
$regular = [];
40+
41+
foreach ($idpList as $idp) {
42+
$entityId = $idp['EntityID'];
43+
if (isset($orderMap[$entityId])) {
44+
if ($idp['Access'] === '1') {
45+
$preferredBuckets[$orderMap[$entityId]][] = $idp;
46+
}
47+
// Unconnected preferred IdPs are excluded from both sections.
48+
} else {
49+
$regular[] = $idp;
50+
}
51+
}
52+
53+
$mergeArgs = array_values(array_filter($preferredBuckets));
54+
$preferred = empty($mergeArgs) ? [] : array_merge(...$mergeArgs);
55+
56+
return ['preferred' => $preferred, 'regular' => $regular];
57+
}
58+
}

src/OpenConext/EngineBlockBundle/Bridge/DiContainerRuntime.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
namespace OpenConext\EngineBlockBundle\Bridge;
2020

21+
use OpenConext\EngineBlock\Service\Wayf\IdpSplitter;
22+
use OpenConext\EngineBlockBundle\Service\WayfViewModelFactory;
2123
use Twig\Environment;
2224

2325
/**
@@ -29,7 +31,16 @@
2931
final readonly class DiContainerRuntime
3032
{
3133

32-
public function __construct(public Environment $twig)
34+
public function __construct(
35+
public Environment $twig,
36+
public IdpSplitter $idpSplitter,
37+
public WayfViewModelFactory $wayfViewModelFactory,
38+
private array $preferredIdpEntityIds = [],
39+
) {
40+
}
41+
42+
public function getPreferredIdpEntityIds(): array
3343
{
44+
return $this->preferredIdpEntityIds;
3445
}
3546
}

src/OpenConext/EngineBlockBundle/Bridge/EngineBlockBootstrapper.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
namespace OpenConext\EngineBlockBundle\Bridge;
2020

2121
use EngineBlock_ApplicationSingleton;
22+
use OpenConext\EngineBlock\Service\Wayf\IdpSplitter;
23+
use OpenConext\EngineBlockBundle\Service\WayfViewModelFactory;
2224
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
2325
use Symfony\Component\HttpKernel\KernelEvents;
2426
use Twig\Environment;
@@ -29,8 +31,11 @@ class EngineBlockBootstrapper implements EventSubscriberInterface
2931

3032
public function __construct(
3133
Environment $twig,
34+
IdpSplitter $idpSplitter,
35+
WayfViewModelFactory $wayfViewModelFactory,
36+
array $preferredIdpEntityIds = [],
3237
) {
33-
$this->diContainerRuntime = new DiContainerRuntime($twig);
38+
$this->diContainerRuntime = new DiContainerRuntime($twig, $idpSplitter, $wayfViewModelFactory, $preferredIdpEntityIds);
3439
}
3540

3641
public function onKernelRequest(): void
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2025 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\Service;
22+
23+
use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
24+
use OpenConext\EngineBlockBundle\Twig\Extensions\Extension\Wayf;
25+
use OpenConext\EngineBlockBundle\ViewModel\WayfViewModel;
26+
27+
class WayfViewModelFactory
28+
{
29+
public function __construct(
30+
private readonly Wayf $wayfExtension,
31+
) {
32+
}
33+
34+
/**
35+
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
36+
*/
37+
public function create(
38+
array $idpList,
39+
array $regularIdpList,
40+
array $preferredIdpList,
41+
bool $showPreferredIdps,
42+
string $action,
43+
string $greenHeader,
44+
string $helpLink,
45+
bool $backLink,
46+
int $cutoffPointForShowingUnfilteredIdps,
47+
bool $showIdPBanner,
48+
bool $rememberChoiceFeature,
49+
bool $showRequestAccess,
50+
string $requestId,
51+
ServiceProvider $serviceProvider,
52+
bool $showRequestAccessContainer,
53+
): WayfViewModel {
54+
return new WayfViewModel(
55+
action: $action,
56+
greenHeader: $greenHeader,
57+
helpLink: $helpLink,
58+
backLink: $backLink,
59+
cutoffPointForShowingUnfilteredIdps: $cutoffPointForShowingUnfilteredIdps,
60+
showIdPBanner: $showIdPBanner,
61+
rememberChoiceFeature: $rememberChoiceFeature,
62+
showRequestAccess: $showRequestAccess,
63+
showRequestAccessContainer: $showRequestAccessContainer,
64+
requestId: $requestId,
65+
serviceProvider: $serviceProvider,
66+
connectedIdps: $this->wayfExtension->getConnectedIdps($idpList),
67+
regularConnectedIdps: $this->wayfExtension->getConnectedIdps($regularIdpList),
68+
preferredConnectedIdps: $this->wayfExtension->getConnectedIdps($preferredIdpList),
69+
showPreferredIdps: $showPreferredIdps,
70+
idpList: $idpList,
71+
regularIdpList: $regularIdpList,
72+
preferredIdpList: $preferredIdpList,
73+
);
74+
}
75+
}

src/OpenConext/EngineBlockBundle/Twig/Extensions/Extension/Locale.php

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,9 @@ public function getQueryStringFor($locale)
7575
$params
7676
);
7777

78-
$query = '';
79-
foreach ($params as $key => $value) {
80-
$query .= (strlen($query) == 0) ? '?' : '&' ;
81-
$query .= $key. '=' .urlencode($value);
82-
}
78+
$query = http_build_query($params);
8379

84-
return $query;
80+
return strlen($query) > 0 ? '?' . $query : '';
8581
}
8682

8783
#[AsTwigFunction(name: 'locale')]

0 commit comments

Comments
 (0)