diff --git a/composer.json b/composer.json index 49b5e769..d73a5c28 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/container": "^2.0", "psr/log": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~v0.1.1", + "simplesamlphp/openid": "~0.2.0", "spomky-labs/base64url": "^2.0", "symfony/expression-language": "^7.4", "symfony/psr-http-message-bridge": "^7.4", diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index d9a5ee7c..a4a3288c 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -104,8 +104,31 @@ table.client-table { font-weight: bolder; } -.confirm-action {} + form.pure-form-stacked .full-width { width: 100%; } + +/* Form loading state */ +.form-loading-spinner { + display: inline-block; + width: 0.85em; + height: 0.85em; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: form-spinner-rotate 0.7s linear infinite; + vertical-align: middle; + margin-right: 0.4em; + opacity: 0.8; +} + +@keyframes form-spinner-rotate { + to { transform: rotate(360deg); } +} + +button[disabled].loading { + opacity: 0.7; + cursor: not-allowed; +} diff --git a/public/assets/js/src/default.js b/public/assets/js/src/default.js index e6eb33fc..81fe2a09 100644 --- a/public/assets/js/src/default.js +++ b/public/assets/js/src/default.js @@ -1,4 +1,3 @@ - (function() { // Attach `confirm-action` click event to all elements with the `confirm-action` class. @@ -19,4 +18,17 @@ } }); }); + + // Handle forms with loading state + document.querySelectorAll('form.form-with-loading-state').forEach(form => { + form.addEventListener('submit', function (event) { + const submitter = event.submitter || this.querySelector('button[type="submit"]'); + if (submitter) { + const loadingText = submitter.getAttribute('data-loading-text') || 'Processing...'; + submitter.disabled = true; + submitter.classList.add('loading'); + submitter.innerHTML = ` ${loadingText}`; + } + }); + }); })(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 82bbb0b2..b14f724c 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -77,6 +77,9 @@ $routes->add(RoutesEnum::AdminTestTrustMarkValidation->name, RoutesEnum::AdminTestTrustMarkValidation->value) ->controller([FederationTestController::class, 'trustMarkValidation']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + $routes->add(RoutesEnum::AdminTestFederationDiscovery->name, RoutesEnum::AdminTestFederationDiscovery->value) + ->controller([FederationTestController::class, 'federationDiscovery']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); $routes->add( RoutesEnum::AdminTestVerifiableCredentialIssuance->name, RoutesEnum::AdminTestVerifiableCredentialIssuance->value, diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 0a3a70ce..f08f3ca8 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -28,6 +28,7 @@ enum RoutesEnum: string // Testing case AdminTestTrustChainResolution = 'admin/test/trust-chain-resolution'; case AdminTestTrustMarkValidation = 'admin/test/trust-mark-validation'; + case AdminTestFederationDiscovery = 'admin/test/federation-discovery'; case AdminTestVerifiableCredentialIssuance = 'admin/test/verifiable-credential-issuance'; diff --git a/src/Controllers/Admin/FederationTestController.php b/src/Controllers/Admin/FederationTestController.php index f9d60ebc..68231397 100644 --- a/src/Controllers/Admin/FederationTestController.php +++ b/src/Controllers/Admin/FederationTestController.php @@ -169,4 +169,135 @@ public function trustMarkValidation(Request $request): Response RoutesEnum::AdminTestTrustMarkValidation->value, ); } + + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + public function federationDiscovery(Request $request): Response + { + $trustAnchorId = null; + $isFormSubmitted = false; + $entities = []; + $forceRefresh = false; + $filterEntityTypes = []; + $filterTrustMarkTypes = ''; + $filterQuery = ''; + $sortBy = 'entity_id'; + $sortOrder = 'asc'; + $pageLimit = 50; + $pageFrom = null; + $nextPageToken = null; + $totalCount = 0; + + if ($request->isMethod(Request::METHOD_POST)) { + $isFormSubmitted = true; + + !empty($trustAnchorId = $request->request->getString('trustAnchorId')) || + throw new OidcException('Empty Trust Anchor ID.'); + + $forceRefresh = $request->request->getBoolean('forceRefresh'); + /** @var string[] $filterEntityTypes */ + $filterEntityTypes = $request->request->all('filterEntityTypes'); + $filterTrustMarkTypes = $request->request->getString('filterTrustMarkTypes'); + $filterQuery = $request->request->getString('filterQuery'); + $sortBy = $request->request->getString('sortBy', 'entity_id'); + $sortOrder = $request->request->getString('sortOrder', 'asc'); + /** @var 'asc'|'desc' $sortOrder */ + $sortOrder = in_array($sortOrder, ['asc', 'desc']) ? $sortOrder : 'asc'; + $pageLimit = $request->request->getInt('pageLimit', 50); + $pageFrom = $request->request->get('pageFrom'); + $pageFrom = is_string($pageFrom) ? $pageFrom : null; + + try { + $entityCollection = $this->federationWithArrayLogger->federationDiscovery()->discover( + trustAnchorId: $trustAnchorId, + forceRefresh: $forceRefresh, + ); + + // 1. Filtering + $criteria = array_filter([ + 'entity_type' => $filterEntityTypes, + 'trust_mark_type' => $this->helpers->str()->convertTextToArray($filterTrustMarkTypes), + 'query' => $filterQuery, + ]); + if (!empty($criteria)) { + $entityCollection->filter($criteria); + } + + $totalCount = count($entityCollection->getEntities()); + + // 2. Sorting + $claimPaths = match ($sortBy) { + 'display_name' => [ + ['metadata', EntityTypesEnum::OpenIdProvider->value, 'display_name'], + ['metadata', EntityTypesEnum::FederationEntity->value, 'display_name'], + ['metadata', EntityTypesEnum::OpenIdRelyingParty->value, 'display_name'], + ], + 'organization_name' => [ + ['metadata', EntityTypesEnum::OpenIdProvider->value, 'organization_name'], + ['metadata', EntityTypesEnum::FederationEntity->value, 'organization_name'], + ['metadata', EntityTypesEnum::OpenIdRelyingParty->value, 'organization_name'], + ], + default => [['sub']], + }; + $entityCollection->sort($claimPaths, $sortOrder); + + // 3. Pagination + /** @var positive-int $pageLimit */ + $entityCollection->paginate($pageLimit, $pageFrom); + + $nextPageToken = $entityCollection->getNextPageToken(); + + foreach ($entityCollection->getEntities() as $id => $payload) { + $entities[] = [ + 'id' => $id, + 'payload' => $payload, + ]; + } + } catch (\Throwable $exception) { + $this->arrayLogger->error(sprintf( + 'Error during entity discovery under Trust Anchor %s. Error was %s', + $trustAnchorId, + $exception->getMessage(), + )); + } + } + + $logMessages = $this->arrayLogger->getEntries(); + + try { + $trustAnchorIds = $this->moduleConfig->getFederationTrustAnchorIds(); + } catch (\Throwable $exception) { + $this->arrayLogger->error('Module config error: ' . $exception->getMessage()); + $trustAnchorIds = []; + } + + $entityTypeOptions = array_map(fn (EntityTypesEnum $enum) => $enum->value, EntityTypesEnum::cases()); + + return $this->templateFactory->build( + 'oidc:tests/federation-discovery.twig', + compact( + 'trustAnchorId', + 'logMessages', + 'isFormSubmitted', + 'entities', + 'trustAnchorIds', + 'forceRefresh', + 'filterEntityTypes', + 'filterTrustMarkTypes', + 'filterQuery', + 'sortBy', + 'sortOrder', + 'pageLimit', + 'pageFrom', + 'nextPageToken', + 'totalCount', + 'entityTypeOptions', + ), + RoutesEnum::AdminTestFederationDiscovery->value, + ); + } } diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index 55d3784f..1b840d7b 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -95,8 +95,6 @@ public function configuration(): Response ClaimsEnum::OrganizationUri->value => $this->moduleConfig->getOrganizationUri(), ], )), - ClaimsEnum::FederationFetchEndpoint->value => $this->routes->urlFederationFetch(), - ClaimsEnum::FederationListEndpoint->value => $this->routes->urlFederationList(), // TODO v7 mivanci Add when ready. Use ClaimsEnum for keys. // https://openid.net/specs/openid-federation-1_0.html#name-federation-entity //'federation_resolve_endpoint', diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 3595a3a1..932be023 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -524,13 +524,20 @@ public function credential(Request $request): Response // Get valid claim paths so we can check if the user attribute is allowed to be included in the credential, // as per the credential configuration supported configuration. $validClaimPaths = $this->moduleConfig->getVciValidCredentialClaimPathsFor($resolvedCredentialIdentifier); - + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Valid claim paths for credential configuration ', + ['validClaimPaths' => $validClaimPaths], + ); // Map user attributes to credential claims $credentialSubject = []; // For JwtVcJson $disclosureBag = $this->verifiableCredentials->disclosureBagFactory()->build(); // For DcSdJwt $attributeToCredentialClaimPathMap = $this->moduleConfig->getVciUserAttributeToCredentialClaimPathMapFor( $resolvedCredentialIdentifier, ); + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Attribute to credential claim path map', + ['attributeToCredentialClaimPathMap' => $attributeToCredentialClaimPathMap], + ); foreach ($attributeToCredentialClaimPathMap as $mapEntry) { if (!is_array($mapEntry)) { $this->loggerService->warning( @@ -542,6 +549,11 @@ public function credential(Request $request): Response continue; } + $this->loggerService->debug( + 'Map entry: ', + ['mapEntry' => $mapEntry], + ); + $userAttributeName = key($mapEntry); if (!is_string($userAttributeName)) { $this->loggerService->warning( @@ -553,6 +565,10 @@ public function credential(Request $request): Response continue; } + $this->loggerService->debug( + 'User attribute name: ' . $userAttributeName, + ); + /** @psalm-suppress MixedAssignment */ $credentialClaimPath = current($mapEntry); if (!is_array($credentialClaimPath)) { @@ -574,6 +590,11 @@ public function credential(Request $request): Response continue; } + $this->loggerService->debug( + 'Credential claim path', + ['credentialClaimPath' => $credentialClaimPath], + ); + if (!isset($userAttributes[$userAttributeName])) { $this->loggerService->warning( 'Attribute "%s" does not exist in user attributes.', @@ -590,6 +611,7 @@ public function credential(Request $request): Response $userAttributes[$userAttributeName]; if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { + $this->loggerService->debug('JwtVcJson format detected, adding user attribute to credential subject.'); $this->verifiableCredentials->helpers()->arr()->setNestedValue( $credentialSubject, $attributeValue, @@ -598,6 +620,11 @@ public function credential(Request $request): Response } if (in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true)) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Processing SD JWT credential format ID ' + . $credentialFormatId, + ); + // For now, we will only support disclosures for object properties. $claimName = array_pop($credentialClaimPath); if (!is_string($claimName)) { @@ -611,8 +638,17 @@ public function credential(Request $request): Response continue; } - if ($credentialFormatId === CredentialFormatIdentifiersEnum::VcSdJwt->value) { + $this->loggerService->debug('Claim name: ' . $claimName); + + if ( + $credentialFormatId === CredentialFormatIdentifiersEnum::VcSdJwt->value && + !in_array(ClaimsEnum::Credential_Subject->value, $credentialClaimPath, true) + ) { + $this->loggerService->debug('VC SD JWT - adding credential subject to claim path for claim "%s".'); array_unshift($credentialClaimPath, ClaimsEnum::Credential_Subject->value); + $this->loggerService->debug( + 'Credential claim path for credential subject: ' . print_r($credentialClaimPath, true), + ); } /** @psalm-suppress ArgumentTypeCoercion */ @@ -722,14 +758,16 @@ public function credential(Request $request): Response // Always start with the VCDM 2.0 base context URL (mandatory). $atContext = [AtContextsEnum::W3OrgNsCredentialsV2->value]; - // If a JSON-LD context document is configured for this credential, append the module-hosted - // context URL so that verifiers can resolve the custom credential subject terms. + // If a JSON-LD context document is configured for this credential, + // append the module-hosted context URL so that verifiers can + // resolve the custom credential subject terms. if ($this->moduleConfig->getVciCredentialJsonLdContextFor($resolvedCredentialIdentifier) !== null) { $atContext[] = $this->routes->urlCredentialJsonLdContext($resolvedCredentialIdentifier); } - // Append any additional context URLs declared in the credential configuration's @context field - // (skipping the base W3C URL, which is already first in the list). + // Append any additional context URLs declared in the credential + // configuration's @context field (skipping the base W3C URL, + // which is already first in the list). /** @psalm-suppress MixedAssignment */ $configuredContexts = $resolvedCredentialConfiguration[ClaimsEnum::AtContext->value] ?? []; if (is_array($configuredContexts)) { @@ -776,6 +814,7 @@ public function credential(Request $request): Response [ ClaimsEnum::Kid->value => $issuerDid . '#0', ], + disclosureBag: $disclosureBag, ); } diff --git a/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php b/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php index 2d13f754..f78bb382 100644 --- a/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php +++ b/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php @@ -27,7 +27,6 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; /** @@ -81,7 +80,7 @@ public function context(string $credentialConfigurationId): Response return $this->routes->newResponse(null, Response::HTTP_NOT_FOUND); } - return new JsonResponse( + return $this->routes->newJsonResponse( $contextDocument, Response::HTTP_OK, ['Content-Type' => 'application/ld+json'], diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 7e26f6e5..382d8845 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -142,6 +142,13 @@ protected function includeDefaultMenuItems(): void ), ); + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminTestFederationDiscovery->value), + Translate::noop('Test Federation Discovery'), + ), + ); + $this->oidcMenu->addItem( $this->oidcMenu->buildItem( $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigVerifiableCredential->value), diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index ff5b2711..ab37629d 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -146,6 +146,11 @@ public function urlAdminTestTrustMarkValidation(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->value, $parameters); } + public function urlAdminTestFederationDiscovery(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminTestFederationDiscovery->value, $parameters); + } + public function urlAdminTestVerifiableCredentialIssuance(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value, $parameters); diff --git a/templates/tests/federation-discovery.twig b/templates/tests/federation-discovery.twig new file mode 100644 index 00000000..b588c257 --- /dev/null +++ b/templates/tests/federation-discovery.twig @@ -0,0 +1,181 @@ +{% set subPageTitle = 'Test Federation Discovery'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + +
+ {{ 'You can use the form below to test Federation Discovery under given Trust Anchor.'|trans }} + {{ 'Log messages will show if any warnings or errors were raised during the process.'|trans }} +
++ {% if trustAnchorIds|default %} + {{ 'Curently configured Trust Anchors for this entity:'|trans }} +
+ {% if logMessages|default %}
+
+ {{- logMessages|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}}
+
+ {% else %}
+ {{ 'Federation discovery passed (there were no warnings or errors during the process).'|trans }}
+ {% endif %}
+
{{ 'Total matching entities:'|trans }} {{ totalCount }}
+| {{ 'Entity ID'|trans }} | +{{ 'Display Name'|trans }} | +{{ 'Types'|trans }} | +
|---|---|---|
| {{ entity.id }} | ++ {% set displayName = '' %} + {% for type in entityTypeOptions %} + {% if entity.payload.metadata[type].display_name is defined and displayName == '' %} + {% set displayName = entity.payload.metadata[type].display_name %} + {% endif %} + {% endfor %} + {{ displayName }} + | ++ {% set types = [] %} + {% for type in entityTypeOptions %} + {% if entity.payload.metadata[type] is defined %} + {% set types = types|merge([type]) %} + {% endif %} + {% endfor %} + {{ types|join(', ') }} + | +
{{ 'No entities were found matching the criteria.'|trans }}
+ {% endif %} + + {% endif %} + +{% endblock oidcContent -%} diff --git a/templates/tests/trust-chain-resolution.twig b/templates/tests/trust-chain-resolution.twig index fde0f21d..9f4a4d9c 100644 --- a/templates/tests/trust-chain-resolution.twig +++ b/templates/tests/trust-chain-resolution.twig @@ -12,7 +12,7 @@ diff --git a/templates/tests/trust-mark-validation.twig b/templates/tests/trust-mark-validation.twig index d426889d..788ce174 100644 --- a/templates/tests/trust-mark-validation.twig +++ b/templates/tests/trust-mark-validation.twig @@ -12,7 +12,7 @@ diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig index fdec0b70..5d40c967 100644 --- a/templates/tests/verifiable-credential-issuance.twig +++ b/templates/tests/verifiable-credential-issuance.twig @@ -19,7 +19,7 @@ {{ 'You will be presented with a Credential Offer which you can use to test credential issuance.'|trans }} - {% else %}