Skip to content

Commit

Permalink
Feat: Implement Discovery support
Browse files Browse the repository at this point in the history
Prior to this change, there were cases where it wasn't clear which
IdP end users should use. In these scenario's the users needed an IdP
which was not recognisable for them.

This change adds support for discovery IdP entries.
Which are additional names / ways of finding an IdP in the WAYF.
These can be configured in Manage.

A discovery requires at least an english name, but can also include
keywords or a custom logo, which is used on the consent page as well.

Resolves #1338
  • Loading branch information
johanib committed Feb 19, 2025
1 parent 96e0f7c commit 151a528
Show file tree
Hide file tree
Showing 41 changed files with 1,340 additions and 79 deletions.
2 changes: 1 addition & 1 deletion app/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ doctrine:
# when true, queries are logged to a 'doctrine' monolog channel
logging: '%kernel.debug%'
profiling: '%kernel.debug%'
server_version: 5.5
server_version: '10.6.20-MariaDB'
mapping_types:
enum: string
types:
Expand Down
7 changes: 7 additions & 0 deletions ci/qa/phpcbf.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e

cd $(dirname $0)/../../

echo -e "\nPHP CodeSniffer\n"
./vendor/bin/phpcbf --standard=ci/qa-config/phpcs.xml src
28 changes: 28 additions & 0 deletions database/DoctrineMigrations/Version20250206095609.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types=1);

namespace OpenConext\EngineBlock\Doctrine\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250206095609 extends AbstractMigration
{
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

$this->addSql('ALTER TABLE sso_provider_roles_eb5 ADD idp_discoveries LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:json)\'');
}

public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

$this->addSql('ALTER TABLE sso_provider_roles_eb5 DROP idp_discoveries');
}
}
8 changes: 8 additions & 0 deletions library/EngineBlock/Application/DiContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,14 @@ public function getProcessingStateHelper()
return $this->container->get('engineblock.service.processing_state_helper');
}

/**
* @return \OpenConext\EngineBlockBundle\Service\DiscoverySelectionService
*/
public function getDiscoverySelectionService()
{
return $this->container->get('engineblock.service.discovery_selection_service');
}

public function getMfaHelper(): MfaHelperInterface
{
return $this->container->get('engineblock.service.mfa_helper');
Expand Down
15 changes: 14 additions & 1 deletion library/EngineBlock/Corto/Module/Service/ProvideConsent.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface;
use OpenConext\EngineBlock\Service\ConsentServiceInterface;
use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface;
use OpenConext\EngineBlockBundle\Service\DiscoverySelectionService;
use Psr\Log\LogLevel;
use SAML2\Constants;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment;
Expand Down Expand Up @@ -69,7 +71,8 @@ public function __construct(
ConsentServiceInterface $consentService,
AuthenticationStateHelperInterface $authStateHelper,
Environment $twig,
ProcessingStateHelperInterface $processingStateHelper
ProcessingStateHelperInterface $processingStateHelper,
DiscoverySelectionService $discoverySelectionService
)
{
$this->_server = $server;
Expand All @@ -80,6 +83,7 @@ public function __construct(
$this->twig = $twig;
$this->_processingStateHelper = $processingStateHelper;
$this->logger = EngineBlock_ApplicationSingleton::getLog();
$this->discoverySelectionService = $discoverySelectionService;
}

/**
Expand Down Expand Up @@ -132,6 +136,14 @@ public function serve($serviceName, Request $httpRequest)

$authenticationState = $this->_authenticationStateHelper->getAuthenticationState();

$session = $httpRequest->getSession();
if($session !== null){
$idpDiscovery = $this->discoverySelectionService->getDiscoveryFromRequest($session, $identityProvider);
}else{
$idpDiscovery = null;
$this->logger->log(LogLevel::ERROR, 'Discovery override failure: No session available.');
}

if ($this->isConsentDisabled($spMetadataChain, $identityProvider)) {
if (!$consentRepository->implicitConsentWasGivenFor($serviceProviderMetadata)) {
$consentRepository->giveImplicitConsentFor($serviceProviderMetadata);
Expand Down Expand Up @@ -217,6 +229,7 @@ public function serve($serviceName, Request $httpRequest)
'responseId' => $receivedRequest->getId(),
'sp' => $serviceProviderMetadata,
'idp' => $identityProvider,
'idpDiscovery' => $idpDiscovery,
'idpSupport' => $this->getSupportContact($identityProvider),
'attributes' => $attributes,
'attributeSources' => $this->getAttributeSources($request->getId()),
Expand Down
60 changes: 51 additions & 9 deletions library/EngineBlock/Corto/Module/Service/SingleSignOn.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
use OpenConext\EngineBlock\Metadata\Entity\IdentityProvider;
use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
use OpenConext\EngineBlock\Metadata\Factory\Factory\ServiceProviderFactory;
use OpenConext\EngineBlock\Metadata\Discovery;
use OpenConext\EngineBlock\Metadata\X509\KeyPairFactory;
use OpenConext\EngineBlockBundle\Exception\InvalidArgumentException as EngineBlockBundleInvalidArgumentException;
use OpenConext\EngineBlockBundle\Service\DiscoverySelectionService;
use SAML2\AuthnRequest;
use SAML2\Response;
use SAML2\XML\saml\Issuer;
Expand Down Expand Up @@ -49,16 +51,23 @@ class EngineBlock_Corto_Module_Service_SingleSignOn implements EngineBlock_Corto
*/
private $_serviceProviderFactory;

/**
* @var DiscoverySelectionService
*/
private $discoverySelectionService;

public function __construct(
EngineBlock_Corto_ProxyServer $server,
EngineBlock_Corto_XmlToArray $xmlConverter,
Twig_Environment $twig,
ServiceProviderFactory $serviceProviderFactory
ServiceProviderFactory $serviceProviderFactory,
DiscoverySelectionService $discoverySelectionService
) {
$this->_server = $server;
$this->_xmlConverter = $xmlConverter;
$this->twig = $twig;
$this->_serviceProviderFactory = $serviceProviderFactory;
$this->discoverySelectionService = $discoverySelectionService;
}

public function serve($serviceName, Request $httpRequest)
Expand Down Expand Up @@ -513,21 +522,54 @@ protected function _transformIdpsForWayf(array $idpEntityIds, $isDebugRequest, $

$name = $this->getName($currentLocale, $identityProvider, $additionalInfo);

$wayfIdp = array(
'Name' => $name,
'Logo' => $identityProvider->getMdui()->hasLogo() ? $identityProvider->getMdui()->getLogo()->url : '/images/placeholder.png',
'Keywords' => $this->getKeywords($currentLocale, $identityProvider),
'Access' => $isAccessible ? '1' : '0',
'ID' => md5($identityProvider->entityId),
'EntityID' => $identityProvider->entityId,
self::IS_DEFAULT_IDP_KEY => $isDefaultIdP
$wayfIdp = $this->buildIdp(
$name,
$identityProvider->getMdui()->hasLogo() ? $identityProvider->getMdui()->getLogo()->url : '/images/placeholder.png',
$this->getKeywords($currentLocale, $identityProvider),
$identityProvider->entityId,
$isAccessible,
$isDefaultIdP,
null
);
$wayfIdps[] = $wayfIdp;

foreach ($identityProvider->getDiscoveries() as $discovery) {
/** @var Discovery $discovery */
$wayfIdps[] = $this->buildIdp(
$discovery->getName($currentLocale),
$discovery->hasLogo() ? $discovery->getLogo()->url : '/images/placeholder.png',
$discovery->getKeywordsArray($currentLocale),
$identityProvider->entityId,
$isAccessible,
$isDefaultIdP,
$this->discoverySelectionService->hash($discovery)
);
}
}

return $wayfIdps;
}

private function buildIdp(
?string $name,
string $logo,
$keywords,
string $entityId,
bool $isAccessible,
bool $isDefaultIdP,
?string $discoveryHash
): array {
return array(
'Name' => $name,
'Logo' => $logo,
'Keywords' => $keywords,
'Access' => $isAccessible ? '1' : '0',
'ID' => md5($entityId),
'EntityID' => $entityId,
self::IS_DEFAULT_IDP_KEY => $isDefaultIdP,
'DiscoveryHash' => $discoveryHash,
);
}
/**
* @param Response|EngineBlock_Saml2_ResponseAnnotationDecorator $response
*/
Expand Down
6 changes: 4 additions & 2 deletions library/EngineBlock/Corto/Module/Services.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ private function factoryService($className, EngineBlock_Corto_ProxyServer $serve
$diContainer->getConsentService(),
$diContainer->getAuthenticationStateHelper(),
$diContainer->getTwigEnvironment(),
$diContainer->getProcessingStateHelper()
$diContainer->getProcessingStateHelper(),
$diContainer->getDiscoverySelectionService()
);
case EngineBlock_Corto_Module_Service_ProcessConsent::class :
return new EngineBlock_Corto_Module_Service_ProcessConsent(
Expand Down Expand Up @@ -139,7 +140,8 @@ private function factoryService($className, EngineBlock_Corto_ProxyServer $serve
$server,
$diContainer->getXmlConverter(),
$diContainer->getTwigEnvironment(),
$diContainer->getServiceProviderFactory()
$diContainer->getServiceProviderFactory(),
$diContainer->getDiscoverySelectionService()
);
case EngineBlock_Corto_Module_Service_ContinueToIdp::class :
return new EngineBlock_Corto_Module_Service_ContinueToIdp(
Expand Down
23 changes: 23 additions & 0 deletions src/OpenConext/EngineBlock/Exception/InvalidDiscoveryException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/**
* Copyright 2025 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\Exception;

final class InvalidDiscoveryException extends RuntimeException
{
}
118 changes: 118 additions & 0 deletions src/OpenConext/EngineBlock/Metadata/Discovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php declare(strict_types=1);

/**
* Copyright 2025 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\Metadata;

use JsonSerializable;
use OpenConext\EngineBlock\Assert\Assertion;
use OpenConext\EngineBlock\Exception\InvalidDiscoveryException;

/**
* Value object representing the cosmetic override data when a 'sub-idp' is present
*/
class Discovery implements JsonSerializable
{
/**
* @var string[]
*/
private $names;

/**
* @var string[]
*/
private $keywords;

/**
* @var ?Logo
*/
private $logo;

/**
* @param array<string,string> $names
* @param array<string,string> $keywords
*/
public static function create(array $names, array $keywords, ?Logo $logo): Discovery
{
$discovery = new self;
$discovery->logo = $logo;

$discovery->names = array_filter($names);
$discovery->keywords = array_filter($keywords);

if (!$discovery->isValid()) {
throw new InvalidDiscoveryException('The Discovery does not have a required english name.');
}

return $discovery;
}

public function jsonSerialize()
{
return [
'names' => $this->names,
'keywords' => $this->keywords,
'logo' => $this->logo,
];
}

public function hasLogo(): bool
{
return $this->logo !== null && $this->logo->url !== null;
}

public function getLogo(): ?Logo
{
return $this->logo;
}

public function getLanguage(): string
{
return $this->language;
}

public function getName(string $locale): string
{
if ($locale !== '' && isset($this->names[$locale])) {
return $this->names[$locale];
}

return $this->names['en'] ?? '';
}

public function getKeywords(string $locale): string
{
if ($locale !== '' && isset($this->keywords[$locale])) {
return $this->keywords[$locale];
}

return $this->keywords['en'] ?? '';
}

/**
* @return string[]
*/
public function getKeywordsArray(string $locale): array
{
return explode(' ', $this->getKeywords($locale));
}

public function isValid(): bool
{
return $this->getName('en') !== '';
}
}
Loading

0 comments on commit 151a528

Please sign in to comment.