diff --git a/UPGRADE.md b/UPGRADE.md index 5c166c34..467a23bc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,9 +1,5 @@ # TODO -- upgrade to v9 of oauth2-server https://github.com/thephpleague/oauth2-server/releases/tag/9.0.0 -- implement key rollover -- implement token introspection -- implement dynamic client registration -- move request rules to templates (generics) for proper static type handling + - remove dependency on laminas/laminas-httphandlerrunner - create a bridge towards SSP utility classes, so they can be easily mocked - move away from SSP database as store; move to DBAL @@ -51,6 +47,7 @@ and optionally a port (as in all previous module versions). - authority hints - federation caching adapter and its arguments - PKI keys - federation keys used for example to sign federation entity statements + - federation participation limiting based on Trust Marks for RPs - signer algorithm - entity statement duration - organization name diff --git a/composer.json b/composer.json index 69de0580..3c229366 100644 --- a/composer.json +++ b/composer.json @@ -80,8 +80,10 @@ }, "scripts": { "pre-commit": [ + "vendor/bin/phpcbf", + "vendor/bin/phpcs -p", "vendor/bin/psalm", - "vendor/bin/phpcs -p" + "vendor/bin/phpunit" ], "tests": [ "vendor/bin/phpunit --no-coverage" diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 6d52f78a..d014c72d 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -63,6 +63,9 @@ $routes->add(RoutesEnum::AdminTestTrustChainResolution->name, RoutesEnum::AdminTestTrustChainResolution->value) ->controller([TestController::class, 'trustChainResolution']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + $routes->add(RoutesEnum::AdminTestTrustMarkValidation->name, RoutesEnum::AdminTestTrustMarkValidation->value) + ->controller([TestController::class, 'trustMarkValidation']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); /***************************************************************************************************************** * OpenID Connect diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 918d320e..f81a98b8 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -26,6 +26,7 @@ enum RoutesEnum: string // Testing case AdminTestTrustChainResolution = 'admin/test/trust-chain-resolution'; + case AdminTestTrustMarkValidation = 'admin/test/trust-mark-validation'; /***************************************************************************************************************** diff --git a/src/Controllers/Admin/TestController.php b/src/Controllers/Admin/TestController.php index 7da4c8d5..e05a6dc1 100644 --- a/src/Controllers/Admin/TestController.php +++ b/src/Controllers/Admin/TestController.php @@ -19,6 +19,8 @@ class TestController { + protected readonly Federation $federationWithArrayLogger; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly TemplateFactory $templateFactory, @@ -28,6 +30,14 @@ public function __construct( protected readonly ArrayLogger $arrayLogger, ) { $this->authorization->requireAdmin(true); + + $this->arrayLogger->setWeight(ArrayLogger::WEIGHT_WARNING); + // Let's create new Federation instance so we can inject our debug logger and go without cache. + $this->federationWithArrayLogger = new Federation( + supportedAlgorithms: $this->federation->supportedAlgorithms(), + cache: null, + logger: $this->arrayLogger, + ); } /** @@ -37,14 +47,6 @@ public function __construct( */ public function trustChainResolution(Request $request): Response { - $this->arrayLogger->setWeight(ArrayLogger::WEIGHT_WARNING); - // Let's create new Federation instance so we can inject our debug logger and go without cache. - $federation = new Federation( - supportedAlgorithms: $this->federation->supportedAlgorithms(), - cache: null, - logger: $this->arrayLogger, - ); - $leafEntityId = $this->moduleConfig->getIssuer(); $trustChainBag = null; $resolvedMetadata = []; @@ -69,7 +71,8 @@ public function trustChainResolution(Request $request): Response $trustAnchorIds = $this->helpers->str()->convertTextToArray($rawTrustAnchorIds); try { - $trustChainBag = $federation->trustChainResolver()->for($leafEntityId, $trustAnchorIds); + $trustChainBag = $this->federationWithArrayLogger->trustChainResolver() + ->for($leafEntityId, $trustAnchorIds); foreach ($trustChainBag->getAll() as $index => $trustChain) { $metadataEntries = []; @@ -94,7 +97,7 @@ public function trustChainResolution(Request $request): Response $trustAnchorIds = implode("\n", $trustAnchorIds); $logMessages = $this->arrayLogger->getEntries(); -//dd($this->arrayLogger->getEntries()); + return $this->templateFactory->build( 'oidc:tests/trust-chain-resolution.twig', compact( @@ -108,4 +111,62 @@ public function trustChainResolution(Request $request): Response RoutesEnum::AdminTestTrustChainResolution->value, ); } + + public function trustMarkValidation(Request $request): Response + { + $trustMarkId = null; + $leafEntityId = null; + $trustAnchorId = null; + $isFormSubmitted = false; + + if ($request->isMethod(Request::METHOD_POST)) { + $isFormSubmitted = true; + + !empty($trustMarkId = $request->request->getString('trustMarkId')) || + throw new OidcException('Empty Trust Mark ID.'); + !empty($leafEntityId = $request->request->getString('leafEntityId')) || + throw new OidcException('Empty leaf entity ID.'); + !empty($trustAnchorId = $request->request->getString('trustAnchorId')) || + throw new OidcException('Empty Trust Anchor ID.'); + + try { + // We should not try to validate Trust Marks until we have resolved trust chain between leaf and TA. + $trustChain = $this->federation->trustChainResolver()->for( + $leafEntityId, + [$trustAnchorId], + )->getShortest(); + + try { + $this->federationWithArrayLogger->trustMarkValidator()->doForTrustMarkId( + $trustMarkId, + $trustChain->getResolvedLeaf(), + $trustChain->getResolvedTrustAnchor(), + ); + } catch (\Throwable $exception) { + $this->arrayLogger->error('Trust Mark validation error: ' . $exception->getMessage()); + } + } catch (TrustChainException $exception) { + $this->arrayLogger->error(sprintf( + 'Could not resolve Trust Chain for leaf entity %s under Trust Anchor %s. Error was %s', + $leafEntityId, + $trustAnchorId, + $exception->getMessage(), + )); + } + } + + $logMessages = $this->arrayLogger->getEntries(); + + return $this->templateFactory->build( + 'oidc:tests/trust-mark-validation.twig', + compact( + 'trustMarkId', + 'leafEntityId', + 'trustAnchorId', + 'logMessages', + 'isFormSubmitted', + ), + RoutesEnum::AdminTestTrustMarkValidation->value, + ); + } } diff --git a/src/Controllers/Federation/Test.php b/src/Controllers/Federation/Test.php index 97eaaee4..8c10fd36 100644 --- a/src/Controllers/Federation/Test.php +++ b/src/Controllers/Federation/Test.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; +use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Core; @@ -37,6 +38,7 @@ public function __construct( protected Database $database, protected ClientEntityFactory $clientEntityFactory, protected CoreFactory $coreFactory, + protected FederationParticipationValidator $federationParticipationValidator, protected \DateInterval $maxCacheDuration = new \DateInterval('PT30S'), ) { } @@ -70,21 +72,40 @@ public function __invoke(): Response $trustChain = $this->federation ->trustChainResolver() ->for( -// 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ALeaf/', + 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ALeaf/', // 'https://trust-anchor.testbed.oidcfed.incubator.geant.org/oidc/rp/', // 'https://relying-party-php.testbed.oidcfed.incubator.geant.org/', - 'https://gorp.testbed.oidcfed.incubator.geant.org', +// 'https://gorp.testbed.oidcfed.incubator.geant.org', // 'https://maiv1.incubator.geant.org', [ - 'https://trust-anchor.testbed.oidcfed.incubator.geant.org/', +// 'https://trust-anchor.testbed.oidcfed.incubator.geant.org/', 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ABTrustAnchor/', - 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/CTrustAnchor/', +// 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/CTrustAnchor/', ], - )->getAll(); -dd($trustChain); + ) + //->getAll(); + ->getShortestByTrustAnchorPriority( + 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ABTrustAnchor/', + ); + $leaf = $trustChain->getResolvedLeaf(); + $trustAnchor = $trustChain->getResolvedTrustAnchor(); + + $this->federationParticipationValidator->validateForAllOfLimit( + ['https://08-dap.localhost.markoivancic.from.hr/openid/entities/ATrustMarkIssuer/trust-mark/member'], + $leaf, + $trustAnchor, + ); + dd($leaf->getPayload()); - $leafFederationJwks = $leaf->getJwks(); + + $this->federation->trustMarkValidator()->fromCacheOrDoForTrustMarkId( + 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ATrustMarkIssuer/trust-mark/member', + $leaf, + $trustAnchor, + ); + +// $leafFederationJwks = $leaf->getJwks(); // dd($leafFederationJwks); // /** @psalm-suppress PossiblyNullArgument */ $resolvedMetadata = $trustChain->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty); diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 0350af95..48986fcb 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -134,6 +134,13 @@ protected function includeDefaultMenuItems(): void Translate::noop('Test Trust Chain Resolution'), ), ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->value), + Translate::noop('Test Trust Mark Validation'), + ), + ); } public function setShowMenu(bool $showMenu): TemplateFactory diff --git a/src/Server/RequestRules/Rules/ClientIdRule.php b/src/Server/RequestRules/Rules/ClientIdRule.php index 199e9cde..9905cf82 100644 --- a/src/Server/RequestRules/Rules/ClientIdRule.php +++ b/src/Server/RequestRules/Rules/ClientIdRule.php @@ -48,6 +48,18 @@ public function __construct( /** * @inheritDoc + * @throws \JsonException + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \Psr\SimpleCache\InvalidArgumentException + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\JwksException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\RequestObjectException + * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException */ public function checkRule( ServerRequestInterface $request, @@ -196,7 +208,7 @@ public function checkRule( $this->helpers->dateTime()->getFromTimestamp($trustChain->getResolvedExpirationTime()), $existingClient, $clientEntityId, - $clientFederationEntity->getJwks(), + $clientFederationEntity->getJwks()->getValue(), $request, ); @@ -209,7 +221,7 @@ public function checkRule( // Check if federation participation is limited by Trust Marks. if ( $this->moduleConfig->isFederationParticipationLimitedByTrustMarksFor( - $trustChain->getResolvedTrustAnchor()->getIssuer(), + $trustAnchorEntityConfiguration->getIssuer(), ) ) { $this->federationParticipationValidator->byTrustMarksFor($trustChain); diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 94c7b183..0a371aa4 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -76,7 +76,7 @@ protected function initJwtConfiguration(): void InMemory::plainText('empty', 'empty'), ); - /** @psalm-suppress ArgumentTypeCoercion */ + /** @psalm-suppress DeprecatedMethod, ArgumentTypeCoercion */ $this->jwtConfiguration->setValidationConstraints( new StrictValidAt(new SystemClock(new DateTimeZone(date_default_timezone_get()))), new SignedWith( diff --git a/src/Services/Container.php b/src/Services/Container.php index 5a4a46cb..8a6c5dae 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -349,6 +349,7 @@ public function __construct() $this->services[JwksResolver::class] = $jwksResolver; $federationParticipationValidator = new FederationParticipationValidator( $moduleConfig, + $federation, $loggerService, ); $this->services[FederationParticipationValidator::class] = $federationParticipationValidator; diff --git a/src/Utils/FederationParticipationValidator.php b/src/Utils/FederationParticipationValidator.php index 06bd37a5..9fd55934 100644 --- a/src/Utils/FederationParticipationValidator.php +++ b/src/Utils/FederationParticipationValidator.php @@ -4,35 +4,203 @@ namespace SimpleSAML\Module\oidc\Utils; +use SimpleSAML\Module\oidc\Codebooks\LimitsEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\OpenID\Exceptions\TrustMarkException; +use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Federation\TrustChain; class FederationParticipationValidator { public function __construct( protected readonly ModuleConfig $moduleConfig, + protected readonly Federation $federation, protected readonly LoggerService $loggerService, ) { } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException + * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException + */ public function byTrustMarksFor(TrustChain $trustChain): void { - $trustAnchor = $trustChain->getResolvedTrustAnchor(); + $leafEntityConfiguration = $trustChain->getResolvedLeaf(); + $trustAnchorEntityConfiguration = $trustChain->getResolvedTrustAnchor(); - $trustMarkLimitsRules = $this->moduleConfig - ->getTrustMarksNeededForFederationParticipationFor($trustAnchor->getIssuer()); + $this->loggerService->debug( + sprintf( + 'Validating federation participation by Trust Marks for leaf %s and Trust Anchor %s.', + $leafEntityConfiguration->getIssuer(), + $trustAnchorEntityConfiguration->getIssuer(), + ), + ); + + $trustMarkLimitsRules = $this->moduleConfig->getTrustMarksNeededForFederationParticipationFor( + $trustAnchorEntityConfiguration->getIssuer(), + ); if (empty($trustMarkLimitsRules)) { - $this->loggerService->debug('No Trust Mark limits emposed for ' . $trustAnchor->getIssuer()); + $this->loggerService->debug( + 'No Trust Mark limits imposed for ' . $trustAnchorEntityConfiguration->getIssuer(), + ); + return; + } + + $this->loggerService->debug( + 'Trust Mark limits for ' . $trustAnchorEntityConfiguration->getIssuer(), + $trustMarkLimitsRules, + ); + + + /** + * @var string $limitId + * @var non-empty-string[] $limitedTrustMarkIds + */ + foreach ($trustMarkLimitsRules as $limitId => $limitedTrustMarkIds) { + $limit = LimitsEnum::from($limitId); + + if ($limit === LimitsEnum::OneOf) { + $this->validateForOneOfLimit( + $limitedTrustMarkIds, + $leafEntityConfiguration, + $trustAnchorEntityConfiguration, + ); + } else { + $this->validateForAllOfLimit( + $limitedTrustMarkIds, + $leafEntityConfiguration, + $trustAnchorEntityConfiguration, + ); + } + } + } + + /** + * @param non-empty-string[] $limitedTrustMarkIds + * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException + */ + public function validateForOneOfLimit( + array $limitedTrustMarkIds, + EntityStatement $leafEntityConfiguration, + EntityStatement $trustAnchorEntityConfiguration, + ): void { + if (empty($limitedTrustMarkIds)) { + $this->loggerService->debug('No Trust Mark limits given for OneOf limit rule, nothing to do.'); + return; + } + + $this->loggerService->debug( + sprintf( + 'Validating that entity %s has at least one valid Trust Mark for Trust Anchor %s.', + $leafEntityConfiguration->getIssuer(), + $trustAnchorEntityConfiguration->getIssuer(), + ), + ['limitedTrustMarkIds' => $limitedTrustMarkIds], + ); + + foreach ($limitedTrustMarkIds as $limitedTrustMarkId) { + try { + $this->federation->trustMarkValidator()->fromCacheOrDoForTrustMarkId( + $limitedTrustMarkId, + $leafEntityConfiguration, + $trustAnchorEntityConfiguration, + ); + + $this->loggerService->debug( + sprintf( + 'Trust Mark ID %s validated using OneOf limit rule for entity %s under Trust Anchor %s.', + $limitedTrustMarkId, + $leafEntityConfiguration->getIssuer(), + $trustAnchorEntityConfiguration->getIssuer(), + ), + ); + return; + } catch (\Throwable $exception) { + $this->loggerService->debug( + sprintf( + 'Trust Mark ID %s validation failed with error: %s. Trying next if available.', + $limitedTrustMarkId, + $exception->getMessage(), + ), + ); + continue; + } + } + + $error = sprintf( + 'Leaf entity %s does not have any valid Trust Marks from the given list (%s). OneOf limit rule failed.', + $leafEntityConfiguration->getIssuer(), + implode(',', $limitedTrustMarkIds), + ); + + $this->loggerService->error($error); + throw new TrustMarkException($error); + } + + /** + * @param non-empty-string[] $limitedTrustMarkIds + * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException + */ + public function validateForAllOfLimit( + array $limitedTrustMarkIds, + EntityStatement $leafEntityConfiguration, + EntityStatement $trustAnchorEntityConfiguration, + ): void { + if (empty($limitedTrustMarkIds)) { + $this->loggerService->debug('No Trust Mark limits given for AllOf limit rule, nothing to do.'); return; } - $this->loggerService->debug('Trust Mark limits for ' . $trustAnchor->getIssuer(), $trustMarkLimitsRules); + $this->loggerService->debug( + sprintf( + 'Validating that entity %s has all valid Trust Marks for Trust Anchor %s.', + $leafEntityConfiguration->getIssuer(), + $trustAnchorEntityConfiguration->getIssuer(), + ), + ['limitedTrustMarkIds' => $limitedTrustMarkIds], + ); - //$leaf = $trustChain->getResolvedLeaf(); - //$leafTrustMarks = $leaf->getTrustMarks(); + foreach ($limitedTrustMarkIds as $limitedTrustMarkId) { + try { + $this->federation->trustMarkValidator()->fromCacheOrDoForTrustMarkId( + $limitedTrustMarkId, + $leafEntityConfiguration, + $trustAnchorEntityConfiguration, + ); + + $this->loggerService->debug( + sprintf( + 'Trust Mark ID %s validated. Trying next if available.', + $limitedTrustMarkId, + ), + ); + } catch (\Throwable $exception) { + $error = sprintf( + 'Trust Mark ID %s validation failed with error: %s. AllOf limit rule failed.', + $limitedTrustMarkId, + $exception->getMessage(), + ); + $this->loggerService->error($error); + throw new TrustMarkException($error); + } + } - // TODO mivanci continue + $this->loggerService->debug( + sprintf( + 'Entity %s has all valid Trust Marks for Trust Anchor %s.', + $leafEntityConfiguration->getIssuer(), + $trustAnchorEntityConfiguration->getIssuer(), + ), + ); } } diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 7b87f514..a9fea448 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -141,6 +141,11 @@ public function urlAdminTestTrustChainResolution(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminTestTrustChainResolution->value, $parameters); } + public function urlAdminTestTrustMarkValidation(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->value, $parameters); + } + /***************************************************************************************************************** * OpenID Connect URLs. ****************************************************************************************************************/ diff --git a/templates/tests/trust-mark-validation.twig b/templates/tests/trust-mark-validation.twig new file mode 100644 index 00000000..9a0cd219 --- /dev/null +++ b/templates/tests/trust-mark-validation.twig @@ -0,0 +1,68 @@ +{% set subPageTitle = 'Test Trust Mark Validation'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + +
+ {{ 'You can use the form below to test Trust Mark validation for particular entity under given Trust Anchor.'|trans }} + {{ 'Log messages will show if any warnings or errors were raised during validation.'|trans }} + {{ 'Note that this will first resolve Trust Chain between given entity and Trust Anchor, and only then do the Trust Mark validation.'|trans }} +
+ + + + {% if isFormSubmitted|default %} + +
+ {% if logMessages|default %}
+
+ {{- logMessages|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}}
+
+ {% else %}
+ {{ 'Trust Mark validation passed (there were no warnings or errors during validation).'|trans }}
+ {% endif %}
+