Skip to content

Commit 16f8e36

Browse files
authored
Merge pull request #1985 from OpenConext/feature/1970-preferred-idps-wayf
Add support for preferred IdPs in WAYF display
2 parents 6b14bd4 + 4f20aa3 commit 16f8e36

49 files changed

Lines changed: 1374 additions & 226 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

3739
* Stabilized consent checks
3840
* In order to make the consent hashes more robust, a more consistent way of hashing the user attributes has been introduced

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/ci/controllers.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ services:
1919
engineblock.functional_test.controller.wayf:
2020
class: OpenConext\EngineBlockFunctionalTestingBundle\Controllers\WayfController
2121
arguments:
22-
- '@twig'
22+
- '@OpenConext\EngineBlockBundle\Service\WayfRenderer'
2323

2424
engineblock.functional_test.controller.feedback:
2525
class: OpenConext\EngineBlockFunctionalTestingBundle\Controllers\FeedbackController

config/services/services.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@ 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+
11+
OpenConext\EngineBlockBundle\Service\WayfRenderer:
12+
autowire: true
13+
514
OpenConext\EngineBlockBundle\Bridge\EngineBlockBootstrapper:
615
autowire: true
716
autoconfigure: true
17+
arguments:
18+
$preferredIdpEntityIds: '%wayf.preferred_idp_entity_ids%'
819
tags:
920
- { name: kernel.event_subscriber }
1021

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: 27 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OpenConext\EngineBlock\Metadata\Factory\Factory\ServiceProviderFactory;
2121
use OpenConext\EngineBlock\Metadata\Discovery;
2222
use OpenConext\EngineBlock\Metadata\X509\KeyPairFactory;
23+
use OpenConext\EngineBlock\Service\Wayf\WayfIdp;
2324
use OpenConext\EngineBlockBundle\Service\DiscoverySelectionService;
2425
use SAML2\AuthnRequest;
2526
use SAML2\Constants;
@@ -462,8 +463,6 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
462463

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

465-
$cookies = $container->getSymfonyRequest()->cookies->all();
466-
467466
if ($request->isDebugRequest()) {
468467
$serviceProvider = $this->getEngineSpRole($this->_server);
469468
} else {
@@ -477,28 +476,21 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
477476
$container->getDefaultIdPEntityId()
478477
);
479478

480-
$defaultIdPInIdPList = $this->isDefaultIdPPresent($idpList);
481-
$showDefaultIdpBanner = (bool) $container->shouldDisplayDefaultIdpBannerOnWayf() && $defaultIdPInIdPList;
482-
483-
$rememberChoiceFeature = $container->getRememberChoice();
484-
485-
$output = $this->twig->render(
486-
'@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-
]
479+
$diContainerRuntime = $application->getDiContainerRuntime();
480+
481+
$output = $diContainerRuntime->wayfRenderer->render(
482+
idpList: $idpList,
483+
preferredIdpEntityIds: $diContainerRuntime->getPreferredIdpEntityIds(),
484+
action: $action,
485+
currentLocale: $currentLocale,
486+
defaultIdpEntityId: $container->getDefaultIdPEntityId(),
487+
shouldDisplayBanner: (bool) $container->shouldDisplayDefaultIdpBannerOnWayf(),
488+
backLink: $container->isUiOptionReturnToSpActive(),
489+
cutoffPoint: $container->getCutoffPointForShowingUnfilteredIdps(),
490+
rememberChoice: $container->getRememberChoice(),
491+
showRequestAccess: $serviceProvider->getCoins()->displayUnconnectedIdpsWayf(),
492+
requestId: $request->getId(),
493+
serviceProvider: $serviceProvider,
502494
);
503495
$this->_server->sendOutput($output);
504496
}
@@ -546,21 +538,21 @@ protected function _transformIdpsForWayf(array $idpEntityIds, $isDebugRequest, $
546538
private function buildIdp(
547539
?string $name,
548540
string $logo,
549-
$keywords,
541+
array $keywords,
550542
string $entityId,
551543
bool $isAccessible,
552544
bool $isDefaultIdP,
553545
?string $discoveryHash
554-
): array {
555-
return array(
556-
'Name' => $name,
557-
'Logo' => $logo,
558-
'Keywords' => $keywords,
559-
'Access' => $isAccessible ? '1' : '0',
560-
'ID' => md5($entityId),
561-
'EntityID' => $entityId,
562-
self::IS_DEFAULT_IDP_KEY => $isDefaultIdP,
563-
'DiscoveryHash' => $discoveryHash,
546+
): WayfIdp {
547+
return new WayfIdp(
548+
name: $name,
549+
logo: $logo,
550+
keywords: $keywords,
551+
accessible: $isAccessible,
552+
id: md5($entityId),
553+
entityId: $entityId,
554+
isDefaultIdp: $isDefaultIdP,
555+
discoveryHash: $discoveryHash,
564556
);
565557
}
566558
/**
@@ -675,14 +667,4 @@ protected function getEngineSpRole(EngineBlock_Corto_ProxyServer $proxyServer)
675667
$serviceProvider = $this->_serviceProviderFactory->createEngineBlockEntityFrom($keyId);
676668
return ServiceProvider::fromServiceProviderEntity($serviceProvider);
677669
}
678-
679-
private function isDefaultIdPPresent(array $idpList): bool
680-
{
681-
foreach ($idpList as $idp) {
682-
if ($idp[self::IS_DEFAULT_IDP_KEY] === true) {
683-
return true;
684-
}
685-
}
686-
return false;
687-
}
688670
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
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 WayfIdp[] $idpList Full transformed IdP list
28+
* @param array $preferredEntityIds Ordered list of entity IDs to feature at the top
29+
*/
30+
public function split(array $idpList, array $preferredEntityIds): WayfSplitResult
31+
{
32+
if (empty($preferredEntityIds)) {
33+
return new WayfSplitResult(preferred: [], regular: $idpList);
34+
}
35+
36+
$orderMap = array_flip($preferredEntityIds);
37+
$preferredBuckets = array_fill(0, count($preferredEntityIds), []);
38+
$regular = [];
39+
40+
foreach ($idpList as $idp) {
41+
$entityId = $idp->entityId;
42+
if (isset($orderMap[$entityId])) {
43+
if ($idp->accessible) {
44+
$preferredBuckets[$orderMap[$entityId]][] = $idp;
45+
}
46+
// Unconnected preferred IdPs are excluded from both sections.
47+
} else {
48+
$regular[] = $idp;
49+
}
50+
}
51+
52+
$mergeArgs = array_values(array_filter($preferredBuckets));
53+
$preferred = empty($mergeArgs) ? [] : array_merge(...$mergeArgs);
54+
55+
return new WayfSplitResult(preferred: $preferred, regular: $regular);
56+
}
57+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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\EngineBlock\Service\Wayf;
22+
23+
final class WayfIdp
24+
{
25+
public function __construct(
26+
public readonly ?string $name,
27+
public readonly string $logo,
28+
/** @var string[] */
29+
public readonly array $keywords,
30+
public readonly bool $accessible,
31+
public readonly string $id,
32+
public readonly string $entityId,
33+
public readonly bool $isDefaultIdp,
34+
public readonly ?string $discoveryHash,
35+
) {
36+
}
37+
38+
public function toArray(): array
39+
{
40+
return [
41+
'Name' => $this->name,
42+
'Logo' => $this->logo,
43+
'Keywords' => $this->keywords,
44+
'Access' => $this->accessible ? '1' : '0',
45+
'ID' => $this->id,
46+
'EntityID' => $this->entityId,
47+
'isDefaultIdp' => $this->isDefaultIdp,
48+
'DiscoveryHash' => $this->discoveryHash,
49+
];
50+
}
51+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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\EngineBlock\Service\Wayf;
22+
23+
final class WayfSplitResult
24+
{
25+
/**
26+
* @param WayfIdp[] $preferred
27+
* @param WayfIdp[] $regular
28+
*/
29+
public function __construct(
30+
public readonly array $preferred,
31+
public readonly array $regular,
32+
) {
33+
}
34+
35+
public function hasPreferred(): bool
36+
{
37+
return !empty($this->preferred);
38+
}
39+
40+
public function containsInPreferred(string $entityId): bool
41+
{
42+
return array_any($this->preferred, static fn($idp) => $idp->entityId === $entityId);
43+
}
44+
}

src/OpenConext/EngineBlockBundle/Bridge/DiContainerRuntime.php

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

1919
namespace OpenConext\EngineBlockBundle\Bridge;
2020

21+
use OpenConext\EngineBlockBundle\Service\WayfRenderer;
2122
use Twig\Environment;
2223

2324
/**
@@ -29,7 +30,15 @@
2930
final readonly class DiContainerRuntime
3031
{
3132

32-
public function __construct(public Environment $twig)
33+
public function __construct(
34+
public Environment $twig,
35+
public WayfRenderer $wayfRenderer,
36+
private array $preferredIdpEntityIds = [],
37+
) {
38+
}
39+
40+
public function getPreferredIdpEntityIds(): array
3341
{
42+
return $this->preferredIdpEntityIds;
3443
}
3544
}

0 commit comments

Comments
 (0)