Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 24 additions & 1 deletion public/assets/css/src/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 13 additions & 1 deletion public/assets/js/src/default.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

(function() {

// Attach `confirm-action` click event to all elements with the `confirm-action` class.
Expand All @@ -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 = `<span class="form-loading-spinner"></span> ${loadingText}`;
}
});
});
})();
3 changes: 3 additions & 0 deletions routing/routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/Codebooks/RoutesEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down
131 changes: 131 additions & 0 deletions src/Controllers/Admin/FederationTestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
2 changes: 0 additions & 2 deletions src/Controllers/Federation/EntityStatementController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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)) {
Expand All @@ -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.',
Expand All @@ -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,
Expand All @@ -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)) {
Expand All @@ -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 */
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -776,6 +814,7 @@ public function credential(Request $request): Response
[
ClaimsEnum::Kid->value => $issuerDid . '#0',
],
disclosureBag: $disclosureBag,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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'],
Expand Down
7 changes: 7 additions & 0 deletions src/Factories/TemplateFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions src/Utils/Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading