From a1f4b862abf5081a465a3ee6831a0e503afd2ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 9 Jan 2026 14:21:08 +0100 Subject: [PATCH 01/17] Introduce client param id_token_signed_response_alg --- docker/conformance.sql | 29 +++++++++++++---- docs/6-oidc-upgrade.md | 7 +++-- src/Controllers/Admin/ClientController.php | 25 ++++++++++++--- src/Entities/ClientEntity.php | 31 +++++++++++++++++++ .../Interfaces/ClientEntityInterface.php | 3 ++ .../Entities/ClientEntityFactory.php | 23 ++++++++++++++ src/Forms/ClientForm.php | 11 +++++++ src/Repositories/ClientRepository.php | 9 ++++-- src/Services/DatabaseMigration.php | 15 +++++++++ templates/clients/includes/form.twig | 9 ++++++ templates/clients/show.twig | 8 +++++ templates/config/migrations.twig | 2 +- tests/unit/src/Entities/ClientEntityTest.php | 2 ++ 13 files changed, 158 insertions(+), 16 deletions(-) diff --git a/docker/conformance.sql b/docker/conformance.sql index bf09b946..2037da69 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -14,6 +14,22 @@ INSERT INTO oidc_migration_versions VALUES('20210916153400'); INSERT INTO oidc_migration_versions VALUES('20210916173400'); INSERT INTO oidc_migration_versions VALUES('20240603141400'); INSERT INTO oidc_migration_versions VALUES('20240605145700'); +INSERT INTO oidc_migration_versions VALUES('20240820132400'); +INSERT INTO oidc_migration_versions VALUES('20240828153300'); +INSERT INTO oidc_migration_versions VALUES('20240830153300'); +INSERT INTO oidc_migration_versions VALUES('20240902120000'); +INSERT INTO oidc_migration_versions VALUES('20240905120000'); +INSERT INTO oidc_migration_versions VALUES('20240906120000'); +INSERT INTO oidc_migration_versions VALUES('20250818163000'); +INSERT INTO oidc_migration_versions VALUES('20250908163000'); +INSERT INTO oidc_migration_versions VALUES('20250912163000'); +INSERT INTO oidc_migration_versions VALUES('20250913163000'); +INSERT INTO oidc_migration_versions VALUES('20250915163000'); +INSERT INTO oidc_migration_versions VALUES('20250916163000'); +INSERT INTO oidc_migration_versions VALUES('20250917163000'); +INSERT INTO oidc_migration_versions VALUES('20251021000001'); +INSERT INTO oidc_migration_versions VALUES('20251021000002'); +INSERT INTO oidc_migration_versions VALUES('20260109000001'); CREATE TABLE oidc_user ( id VARCHAR(191) PRIMARY KEY NOT NULL, claims TEXT, @@ -44,15 +60,16 @@ CREATE TABLE oidc_client ( created_at TIMESTAMP NULL DEFAULT NULL, expires_at TIMESTAMP NULL DEFAULT NULL, is_federated BOOLEAN NOT NULL DEFAULT false, - is_generic BOOLEAN NOT NULL DEFAULT false + is_generic BOOLEAN NOT NULL DEFAULT false, + extra_metadata TEXT NULL ); -- Used 'httpd' host for back-channel logout url (https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout) -- since this is the hostname of conformance server while running in container environment -INSERT INTO oidc_client VALUES('_55a99a1d298da921cb27d700d4604352e51171ebc4','_8967dd97d07cc59db7055e84ac00e79005157c1132','Conformance Client 1',replace('Client 1 for Conformance Testing https://openid.net/certification/connect_op_testing/\n','\n',char(10)),'example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone","offline_access"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); -INSERT INTO oidc_client VALUES('_34efb61060172a11d62101bc804db789f8f9100b0e','_91a4607a1c10ba801268929b961b3f6c067ff82d21','Conformance Client 2','','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","offline_access"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); -INSERT INTO oidc_client VALUES('_0afb7d18e54b2de8205a93e38ca119e62ee321d031','_944e73bbeec7850d32b68f1b5c780562c955967e4e','Conformance Client 3','Client for client_secret_post','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); -INSERT INTO oidc_client VALUES('_8957eda35234902ba8343c0cdacac040310f17dfca','_322d16999f9da8b5abc9e9c0c08e853f60f4dc4804','RP-Initiated Logout Client','Client for testing RP-Initiated Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]',NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); -INSERT INTO oidc_client VALUES('_9fe2f7589ece1b71f5ef75a91847d71bc5125ec2a6','_3c0beb20194179c01d7796c6836f62801e9ed4b368','Back-Channel Logout Client','Client for testing Back-Channel Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); +INSERT INTO oidc_client VALUES('_55a99a1d298da921cb27d700d4604352e51171ebc4','_8967dd97d07cc59db7055e84ac00e79005157c1132','Conformance Client 1',replace('Client 1 for Conformance Testing https://openid.net/certification/connect_op_testing/\n','\n',char(10)),'example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone","offline_access"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false, NULL); +INSERT INTO oidc_client VALUES('_34efb61060172a11d62101bc804db789f8f9100b0e','_91a4607a1c10ba801268929b961b3f6c067ff82d21','Conformance Client 2','','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","offline_access"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false, NULL); +INSERT INTO oidc_client VALUES('_0afb7d18e54b2de8205a93e38ca119e62ee321d031','_944e73bbeec7850d32b68f1b5c780562c955967e4e','Conformance Client 3','Client for client_secret_post','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false, NULL); +INSERT INTO oidc_client VALUES('_8957eda35234902ba8343c0cdacac040310f17dfca','_322d16999f9da8b5abc9e9c0c08e853f60f4dc4804','RP-Initiated Logout Client','Client for testing RP-Initiated Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]',NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false, NULL); +INSERT INTO oidc_client VALUES('_9fe2f7589ece1b71f5ef75a91847d71bc5125ec2a6','_3c0beb20194179c01d7796c6836f62801e9ed4b368','Back-Channel Logout Client','Client for testing Back-Channel Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false, NULL); CREATE TABLE oidc_access_token ( id VARCHAR(191) PRIMARY KEY NOT NULL, scopes TEXT, diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 7604c11c..213e590f 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -7,13 +7,16 @@ apply those relevant to your deployment. New features: +- Clients can now be configured with new properties: + - ID Token Signing Algorithm (id_token_signed_response_alg) - Initial support for OpenID for Verifiable Credential Issuance (OpenID4VCI). Note that the implementation is experimental. You should not use -it in production yet. +it in production. New configuration options: -- Several new options regarding support for OpenID4VCI. +- Several new options are available in module config file regarding support for +OpenID4VCI. Major impact changes: diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index ec0f2c74..6cbf67eb 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -25,6 +25,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -118,7 +119,7 @@ public function resetSecret(Request $request): Response $this->clientRepository->update($client, $authedUserId); $message = Translate::noop('Client secret has been reset.'); - $this->logger->info($message, $client->getState()); + $this->logger->info($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); return $this->routes->newRedirectResponseToModuleUrl( @@ -181,14 +182,14 @@ public function add(): Response if ($this->clientRepository->findById($client->getIdentifier())) { $message = Translate::noop('Client with generated ID already exists.'); - $this->logger->warning($message, $client->getState()); + $this->logger->warning($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); } elseif ( ($entityIdentifier = $client->getEntityIdentifier()) && $this->clientRepository->findByEntityIdentifier($entityIdentifier) ) { $message = Translate::noop('Client with given entity identifier already exists.'); - $this->logger->warning($message, $client->getState()); + $this->logger->warning($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); } else { $this->clientRepository->add($client); @@ -199,7 +200,7 @@ public function add(): Response /** @var string[] $allowedOrigins */ $this->allowedOriginRepository->set($client->getIdentifier(), $allowedOrigins); $message = Translate::noop('Client has been added.'); - $this->logger->info($message, $client->getState()); + $this->logger->info($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); return $this->routes->newRedirectResponseToModuleUrl( @@ -238,6 +239,9 @@ public function edit(Request $request): Response $clientData = $originalClient->toArray(); $clientData['allowed_origin'] = $clientAllowedOrigins; + + // Handle extra metadata + $form->setDefaults($clientData); if ($form->isSuccess()) { @@ -252,6 +256,7 @@ public function edit(Request $request): Response $originalClient->getCreatedAt(), $originalClient->getExpiresAt(), $originalClient->getOwner(), + $originalClient->isGeneric(), ); // We have to make sure that the Entity Identifier is unique. @@ -311,6 +316,7 @@ protected function buildClientEntityFromFormData( ?\DateTimeImmutable $createdAt = null, ?\DateTimeImmutable $expiresAt = null, ?string $owner = null, + bool $isGeneric = false, ): ClientEntityInterface { /** @var array $data */ $data = $form->getValues('array'); @@ -344,6 +350,15 @@ protected function buildClientEntityFromFormData( null : (string)$data[ClientEntity::KEY_SIGNED_JWKS_URI]; $isFederated = (bool)$data[ClientEntity::KEY_IS_FEDERATED]; + $idTokenSignedResponseAlg = isset($data[ClaimsEnum::IdTokenSignedResponseAlg->value]) && + is_string($data[ClaimsEnum::IdTokenSignedResponseAlg->value]) ? + $data[ClaimsEnum::IdTokenSignedResponseAlg->value] : + null; + + $extraMetadata = [ + ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg, + ]; + return $this->clientEntityFactory->fromData( $identifier, $secret, @@ -368,6 +383,8 @@ protected function buildClientEntityFromFormData( $createdAt, $expiresAt, $isFederated, + $isGeneric, + $extraMetadata, ); } } diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index ef1da0a4..3e901beb 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -21,6 +21,7 @@ use League\OAuth2\Server\Entities\Traits\EntityTrait; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; class ClientEntity implements ClientEntityInterface @@ -52,6 +53,7 @@ class ClientEntity implements ClientEntityInterface public const KEY_EXPIRES_AT = 'expires_at'; public const KEY_IS_FEDERATED = 'is_federated'; public const KEY_IS_GENERIC = 'is_generic'; + public const KEY_EXTRA_METADATA = 'extra_metadata'; private string $secret; @@ -95,6 +97,7 @@ class ClientEntity implements ClientEntityInterface private ?DateTimeImmutable $expiresAt; private bool $isFederated; private bool $isGeneric; + private ?array $extraMetadata; /** * @param string[] $redirectUri @@ -129,6 +132,7 @@ public function __construct( ?DateTimeImmutable $expiresAt = null, bool $isFederated = false, bool $isGeneric = false, + ?array $extraMetadata = null, ) { $this->identifier = $identifier; $this->secret = $secret; @@ -154,6 +158,7 @@ public function __construct( $this->expiresAt = $expiresAt; $this->isFederated = $isFederated; $this->isGeneric = $isGeneric; + $this->extraMetadata = $extraMetadata; } /** @@ -193,6 +198,9 @@ public function getState(): array self::KEY_EXPIRES_AT => $this->getExpiresAt()?->format('Y-m-d H:i:s'), self::KEY_IS_FEDERATED => $this->isFederated(), self::KEY_IS_GENERIC => $this->isGeneric(), + self::KEY_EXTRA_METADATA => is_null($this->extraMetadata) ? + null : + json_encode($this->extraMetadata, JSON_THROW_ON_ERROR), ]; } @@ -223,6 +231,9 @@ public function toArray(): array self::KEY_EXPIRES_AT => $this->expiresAt, self::KEY_IS_FEDERATED => $this->isFederated, self::KEY_IS_GENERIC => $this->isGeneric, + + // Extra metadata + ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(), ]; } @@ -366,4 +377,24 @@ public function isGeneric(): bool { return $this->isGeneric; } + + public function getExtraMetadata(): array + { + return $this->extraMetadata ?? []; + } + + public function getIdTokenSignedResponseAlg(): ?string + { + if (!is_array($this->extraMetadata)) { + return null; + } + + $idTokenSignedResponseAlg = $this->extraMetadata['id_token_signed_response_alg'] ?? null; + + if (!is_string($idTokenSignedResponseAlg)) { + return null; + } + + return $idTokenSignedResponseAlg; + } } diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index b14ca517..ac72cf71 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -80,4 +80,7 @@ public function getExpiresAt(): ?DateTimeImmutable; public function isExpired(): bool; public function isFederated(): bool; public function isGeneric(): bool; + + public function getExtraMetadata(): array; + public function getIdTokenSignedResponseAlg(): ?string; } diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 549ff663..4322c21f 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -67,6 +67,7 @@ public function fromData( ?DateTimeImmutable $expiresAt = null, bool $isFederated = false, bool $isGeneric = false, + ?array $extraMetadata = null, ): ClientEntityInterface { return new ClientEntity( $id, @@ -93,6 +94,7 @@ public function fromData( $expiresAt, $isFederated, $isGeneric, + $extraMetadata, ); } @@ -196,6 +198,20 @@ public function fromRegistrationData( $isFederated = $existingClient?->isFederated() ?? false; $isGeneric = $existingClient?->isGeneric() ?? false; + $extraMetadata = $existingClient?->getExtraMetadata() ?? []; + + // Handle any other supported client metadata as extra metadata. + // id_token_signed_response_alg + $idTokenSignedResponseAlg = isset($metadata[ClaimsEnum::IdTokenSignedResponseAlg->value]) && + is_string($metadata[ClaimsEnum::IdTokenSignedResponseAlg->value]) ? + $metadata[ClaimsEnum::IdTokenSignedResponseAlg->value] : + $existingClient?->getIdTokenSignedResponseAlg(); + + // TODO mivanci Check if id_token_signed_response_alg is supported. + + $extraMetadata[ClaimsEnum::IdTokenSignedResponseAlg->value] = $idTokenSignedResponseAlg; + + return $this->fromData( $id, $secret, @@ -221,6 +237,7 @@ public function fromRegistrationData( $expiresAt, $isFederated, $isGeneric, + $extraMetadata, ); } @@ -361,6 +378,11 @@ public function fromState(array $state): ClientEntityInterface $isFederated = (bool)$state[ClientEntity::KEY_IS_FEDERATED]; $isGeneric = (bool)$state[ClientEntity::KEY_IS_GENERIC]; + /** @var ?mixed[] $extraMetadata */ + $extraMetadata = empty($state[ClientEntity::KEY_EXTRA_METADATA]) ? + null : + json_decode((string)$state[ClientEntity::KEY_EXTRA_METADATA], true, 512, JSON_THROW_ON_ERROR); + return $this->fromData( $id, $secret, @@ -386,6 +408,7 @@ public function fromState(array $state): ClientEntityInterface $expiresAt, $isFederated, $isGeneric, + $extraMetadata, ); } diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index 1b59c65d..96a55347 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; use Traversable; @@ -278,6 +279,10 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $signedJwksUri = trim((string)$values['signed_jwks_uri']); $values['signed_jwks_uri'] = empty($signedJwksUri) ? null : $signedJwksUri; + $idTokenSignedResponseAlg = trim((string)$values[ClaimsEnum::IdTokenSignedResponseAlg->value]); + $values[ClaimsEnum::IdTokenSignedResponseAlg->value] = empty($idTokenSignedResponseAlg) ? + null : $idTokenSignedResponseAlg; + return $values; } @@ -414,6 +419,12 @@ protected function buildForm(): void $this->addCheckbox('is_federated', '{oidc:client:is_federated}') ->setHtmlAttribute('class', 'full-width'); + + // TODO mivanci Properly fetch the list of supported algos + $this->addSelect('id_token_signed_response_alg', Translate::noop('ID Token Signing Algorithm')) + ->setHtmlAttribute('class', 'full-width') + ->setItems(['RS256'], false) + ->setPrompt(Translate::noop('-')); } /** diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index e8087edc..718a0e1f 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -362,7 +362,8 @@ public function add(ClientEntityInterface $client): void created_at, expires_at, is_federated, - is_generic + is_generic, + extra_metadata ) VALUES ( :id, @@ -388,7 +389,8 @@ public function add(ClientEntityInterface $client): void :created_at, :expires_at, :is_federated, - :is_generic + :is_generic, + :extra_metadata ) EOS , @@ -461,7 +463,8 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo created_at = :created_at, expires_at = :expires_at, is_federated = :is_federated, - is_generic = :is_generic + is_generic = :is_generic, + extra_metadata = :extra_metadata WHERE id = :id EOF , diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 06020ef2..ae026915 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -209,6 +209,11 @@ public function migrate(): void $this->version20251021000002(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20251021000002')"); } + + if (!in_array('20260109000001', $versions, true)) { + $this->version20260109000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260109000001')"); + } } private function versionsTableName(): string @@ -708,6 +713,16 @@ private function version20251021000002(): void ,); } + private function version20260109000001(): void + { + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + ADD extra_metadata TEXT NULL +EOT + ,); + } + /** * @param string[] $columnNames */ diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig index cf085f33..27cce2a5 100644 --- a/templates/clients/includes/form.twig +++ b/templates/clients/includes/form.twig @@ -141,6 +141,15 @@ {{ form.jwks.getError }} {% endif %} + + {{ form.id_token_signed_response_alg.control | raw }} + + {% trans %}JWS alg algorithm for signing the ID Token issued to this Client. If not set, the default one from configuration file will be used.{% endtrans %} + + {% if form.id_token_signed_response_alg.hasErrors %} + {{ form.id_token_signed_response_alg.getError }} + {% endif %} +

{{ 'OpenID Federation Related Properties'|trans }}

diff --git a/templates/clients/show.twig b/templates/clients/show.twig index f9b2d5ff..5aa6fc71 100644 --- a/templates/clients/show.twig +++ b/templates/clients/show.twig @@ -205,6 +205,14 @@ {% endif %} + + + {{ 'ID Token Signing Algorithm'|trans }} + + + {{ client.idTokenSignedResponseAlg|default('N/A'|trans) }} + + {{ 'Owner'|trans }} diff --git a/templates/config/migrations.twig b/templates/config/migrations.twig index 127b11bc..a3850af8 100644 --- a/templates/config/migrations.twig +++ b/templates/config/migrations.twig @@ -22,7 +22,7 @@ {% endif %}
- {{ 'Before running the migrations, make sure that the database user has proper privileges to change the scheme (for example, alter, create, drop, index). After running the migrations, it is a good practice to remove those privileges.'|trans }} + {{ 'Before running the migrations, make sure that the database user has proper privileges to change the scheme (for example, alter, create, drop, index). After running the migrations, it is a good practice to remove those privileges.'|trans|raw }}
{% endblock oidcContent -%} diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index 0ed42a75..ba082935 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -187,6 +187,7 @@ public function testCanGetState(): void 'expires_at' => null, 'is_federated' => $this->state['is_federated'], 'is_generic' => $this->state['is_generic'], + 'extra_metadata' => null, ], ); } @@ -224,6 +225,7 @@ public function testCanExportAsArray(): void 'expires_at' => null, 'is_federated' => false, 'is_generic' => false, + 'id_token_signed_response_alg' => null, ], ); } From ba8437539f0b3bc21ed63cace0ce7ed674e22daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 9 Jan 2026 16:37:04 +0100 Subject: [PATCH 02/17] WIP key pairs --- config/module_oidc.php.dist | 46 ++++++++++++++++++++++++++++++++----- src/ModuleConfig.php | 2 ++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 300ed79a..ded0ddd1 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -22,6 +22,7 @@ declare(strict_types=1); */ use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\ValueAbstracts\SignatureKeyPairConfig; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; @@ -51,6 +52,12 @@ $config = [ ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, + // Token signer, with given default. + // See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer + ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, +// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, +// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, + /** * (optional) Key rollover settings related to OIDC protocol. If set, this new private / public key pair will only * be published on JWKS endpoint as available, so Relying Parties can pick them up for future use. The signing @@ -63,6 +70,39 @@ $config = [ // ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module.key', // ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', + /** + * Default protocol (Connect) signature algorithm and key-pair definition. + * This algorithm and key will be used, for example, to sign ID Token JWS, + * if no other algorithm is negotiated with the client. + */ + ModuleConfig::DEFAULT_PROTOCOL_SIGNATURE_KEY_PAIR => [ + 'algorithm' => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + 'privateKeyFilename' => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, + 'publicKeyFilename' => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, +// 'privateKeyPassword' => 'private-key-password', // Optional +// 'keyId' => 'rsa-connect-signing-key-2026', // Optional + ], + + /** + * Additionally supported protocol (Connect) signing algorithms and + * key-pairs. These entries will be used in signing algorithm negotiation + * with the client. The order in which the entries are set is important, + * as the entries set first will have higher priority during negotiation. + * + * You can also use this config option to advertise any + * (new) keys, for example, for key-rollover scenarios. Just add those + * entries last. + */ + ModuleConfig::ADDITIONAL_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + 'algorithm' => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, + 'privateKeyFilename' => 'oidc_module_ec256.key', + 'publicKeyFilename' => 'oidc_module_ec256.pub', +// 'privateKeyPassword' => 'private-key-password', // Optional +// 'keyId' => 'ec-connect-signing-key-01', // Optional + ], + ], + /** * Token related options. */ @@ -72,12 +112,6 @@ $config = [ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', // 1 month ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', // 1 hour, - // Token signer, with given default. - // See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer - ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, -// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, -// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, - /** * Authentication related options. */ diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index a8e13824..325d7559 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -115,6 +115,8 @@ class ModuleConfig final public const OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI = 'allow_non_registered_clients_for_vci'; final public const OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI = 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; + final public const DEFAULT_PROTOCOL_SIGNATURE_KEY_PAIR = 'default_protocol_signature_key_pair'; + final public const ADDITIONAL_PROTOCOL_SIGNATURE_KEY_PAIRS = 'additional_protocol_signature_key_pairs'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ From 54f1f210d920c32cdfb66726998b46432f32205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 10 Jan 2026 18:27:50 +0100 Subject: [PATCH 03/17] WIP sig algs --- config/module_oidc.php.dist | 77 +++++++++----- src/Factories/FederationFactory.php | 19 +--- src/ModuleConfig.php | 157 +++++++++++++++++++++++++--- src/Services/OpMetadataService.php | 27 +++-- 4 files changed, 212 insertions(+), 68 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index ded0ddd1..a3fe7f32 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -22,7 +22,6 @@ declare(strict_types=1); */ use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\ValueAbstracts\SignatureKeyPairConfig; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; @@ -71,35 +70,59 @@ $config = [ // ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', /** - * Default protocol (Connect) signature algorithm and key-pair definition. - * This algorithm and key will be used, for example, to sign ID Token JWS, - * if no other algorithm is negotiated with the client. - */ - ModuleConfig::DEFAULT_PROTOCOL_SIGNATURE_KEY_PAIR => [ - 'algorithm' => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, - 'privateKeyFilename' => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - 'publicKeyFilename' => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, -// 'privateKeyPassword' => 'private-key-password', // Optional -// 'keyId' => 'rsa-connect-signing-key-2026', // Optional - ], - - /** - * Additionally supported protocol (Connect) signing algorithms and - * key-pairs. These entries will be used in signing algorithm negotiation - * with the client. The order in which the entries are set is important, - * as the entries set first will have higher priority during negotiation. + * Protocol (Connect) signature algorithm and key-pair definitions, + * representing supported algorithms for signing, for example, ID Token JWS. + * The order in which the entries are set is important. The entry set + * first will have higher priority during signing algorithm negotiation + * with the client. If the client doesn't designate desired signing + * algorithm, the first algorithm in the list will be used for signing (the + * first entry represents default algorithm and signing key). Note that + * the OpenID Connect specification designates `RS256` as the signing + * algorithm that should be used by default, so you would probably want + * to use that algorithm as the default (first) one. However, you are free + * to set other default (first) algorithm as needed. + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Just add those entries later in + * the list, so they can be published on the OP discovery endpoint. * - * You can also use this config option to advertise any - * (new) keys, for example, for key-rollover scenarios. Just add those - * entries last. + * The format is array of associative arrays, where each array value + * consists of the following properties (keys): + * - ModuleConfig::KEY_ALGORITHM - \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum case + * representing the algorithm. + * - ModuleConfig::KEY_PRIVATE_KEY_FILENAME - the name of the file + * containing private key inPEM format, which is available in SSP `cert` + * folder. + * - ModuleConfig::KEY_PUBLIC_KEY_FILENAME - the name of the file containing + * corresponding public key in PEM format, which is available in SSP `cert` + * folder. + * - ModuleConfig::KEY_PRIVATE_KEY_PASSWORD - private key password, if + * needed. + * - ModuleConfig::KEY_KEY_ID - Optional string representing key identifier. + * Use if you need to manually set key identifiers to be published. If not + * set, a public key thumbprint will be generated on the fly and used as a + * key ID. + * + * Note: in v7 of the module, a new way of automatic key ID generation is + * used. In previous versions, a hash of a public key file was used as a + * key ID. In v7, a public key thumbprint is used. If you are migrating from + * previous version of the module, and you want to keep the old signing key, + * you should manually set the key ID to the previous value, so that clients + * know that the key did not change. */ - ModuleConfig::ADDITIONAL_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'rsa-connect-signing-key-2026', // Optional + ], [ - 'algorithm' => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, - 'privateKeyFilename' => 'oidc_module_ec256.key', - 'publicKeyFilename' => 'oidc_module_ec256.pub', -// 'privateKeyPassword' => 'private-key-password', // Optional -// 'keyId' => 'ec-connect-signing-key-01', // Optional + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_ec256.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_ec256.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'ec-connect-signing-key-01', // Optional ], ], diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index 8df2a264..d14a7534 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -7,10 +7,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Federation; -use SimpleSAML\OpenID\SupportedAlgorithms; class FederationFactory { @@ -27,22 +24,8 @@ public function __construct( */ public function build(): Federation { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), - SignatureAlgorithmEnum::RS384, - SignatureAlgorithmEnum::RS512, - SignatureAlgorithmEnum::ES256, - SignatureAlgorithmEnum::ES384, - SignatureAlgorithmEnum::ES512, - SignatureAlgorithmEnum::PS256, - SignatureAlgorithmEnum::PS384, - SignatureAlgorithmEnum::PS512, - ), - ); - return new Federation( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), cache: $this->federationCache?->cache, logger: $this->loggerService, diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 325d7559..a282306a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -24,14 +24,26 @@ use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ScopesEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\ValueAbstracts; +use SimpleSAML\OpenID\ValueAbstracts\KeyPairFilenameConfig; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfig; class ModuleConfig { final public const MODULE_NAME = 'oidc'; protected const KEY_DESCRIPTION = 'description'; + public const KEY_ALGORITHM = 'algorithm'; + public const KEY_PRIVATE_KEY_FILENAME = 'private_key_filename'; + public const KEY_PUBLIC_KEY_FILENAME = 'public_key_filename'; + public const KEY_PRIVATE_KEY_PASSWORD = 'private_key_password'; + public const KEY_KEY_ID = 'key_id'; /** * Default file name for module configuration. Can be overridden in constructor, for example, for testing purposes. @@ -115,8 +127,7 @@ class ModuleConfig final public const OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI = 'allow_non_registered_clients_for_vci'; final public const OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI = 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; - final public const DEFAULT_PROTOCOL_SIGNATURE_KEY_PAIR = 'default_protocol_signature_key_pair'; - final public const ADDITIONAL_PROTOCOL_SIGNATURE_KEY_PAIRS = 'additional_protocol_signature_key_pairs'; + final public const OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -147,6 +158,7 @@ class ModuleConfig * @var Configuration SimpleSAMLphp configuration instance. */ private readonly Configuration $sspConfig; + protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; /** * @throws \Exception @@ -155,7 +167,8 @@ public function __construct( string $fileName = self::DEFAULT_FILE_NAME, // Primarily used for easy (unit) testing overrides. array $overrides = [], // Primarily used for easy (unit) testing overrides. ?Configuration $sspConfig = null, - private readonly SspBridge $sspBridge = new SspBridge(), + protected readonly SspBridge $sspBridge = new SspBridge(), + protected readonly ValueAbstracts $valueAbstracts = new ValueAbstracts(), ) { $this->moduleConfig = Configuration::loadFromArray( array_merge(Configuration::getConfig($fileName)->toArray(), $overrides), @@ -198,31 +211,31 @@ function (array $scope, string $name): void { $acrValuesSupported = $this->getAcrValuesSupported(); foreach ($acrValuesSupported as $acrValueSupported) { - if (! is_string($acrValueSupported)) { + if (!is_string($acrValueSupported)) { throw new ConfigurationError('Config option acrValuesSupported should contain strings only.'); } } $authSourcesToAcrValuesMap = $this->getAuthSourcesToAcrValuesMap(); foreach ($authSourcesToAcrValuesMap as $authSource => $acrValues) { - if (! is_string($authSource)) { + if (!is_string($authSource)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have string keys ' . 'indicating auth sources.'); } - if (! is_array($acrValues)) { + if (!is_array($acrValues)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have array ' . 'values containing supported ACRs for each auth source key.'); } /** @psalm-suppress MixedAssignment */ foreach ($acrValues as $acrValue) { - if (! is_string($acrValue)) { + if (!is_string($acrValue)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have array ' . 'values with strings only.'); } - if (! in_array($acrValue, $acrValuesSupported, true)) { + if (!in_array($acrValue, $acrValuesSupported, true)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have ' . 'supported ACR values only.'); } @@ -231,8 +244,8 @@ function (array $scope, string $name): void { $forcedAcrValueForCookieAuthentication = $this->getForcedAcrValueForCookieAuthentication(); - if (! is_null($forcedAcrValueForCookieAuthentication)) { - if (! in_array($forcedAcrValueForCookieAuthentication, $acrValuesSupported, true)) { + if (!is_null($forcedAcrValueForCookieAuthentication)) { + if (!in_array($forcedAcrValueForCookieAuthentication, $acrValuesSupported, true)) { throw new ConfigurationError('Config option forcedAcrValueForCookieAuthentication should have' . ' null value or string value indicating particular supported ACR.'); } @@ -294,8 +307,8 @@ protected function instantiateSigner(string $className): Signer ****************************************************************************************************************/ /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @return non-empty-string + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function getIssuer(): string { @@ -345,6 +358,120 @@ public function getUserIdentifierAttribute(): string return $this->config()->getString(ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE); } + public function getSupportedAlgorithms(): SupportedAlgorithms + { + return new SupportedAlgorithms( + new SignatureAlgorithmBag( + SignatureAlgorithmEnum::RS256, + SignatureAlgorithmEnum::RS384, + SignatureAlgorithmEnum::RS512, + SignatureAlgorithmEnum::ES256, + SignatureAlgorithmEnum::ES384, + SignatureAlgorithmEnum::ES512, + SignatureAlgorithmEnum::PS256, + SignatureAlgorithmEnum::PS384, + SignatureAlgorithmEnum::PS512, + ), + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag + { + if ($this->protocolSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->protocolSignatureKeyPairBag; + } + + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS); + + if (empty($signatureKeyPairs)) { + throw new ConfigurationError('At least one protocol signature key-pair pair should be provided.'); + } + + $signatureKeyPairConfigBag = new ValueAbstracts\SignatureKeyPairConfigBag(); + + foreach ($signatureKeyPairs as $signatureKeyPair) { + if (!is_array($signatureKeyPair)) { + throw new ConfigurationError( + 'Invalid value for signature key pair. Expected array, got "' . + var_export($signatureKeyPair, true) . '".', + ); + } + + $algorithm = $signatureKeyPair[self::KEY_ALGORITHM] ?? null; + if (!$algorithm instanceof SignatureAlgorithmEnum) { + throw new ConfigurationError( + 'Invalid protocol signature algorithm encountered. Expected instance of ' . + SignatureAlgorithmEnum::class, + ); + } + + $privateKeyFilename = $signatureKeyPair[self::KEY_PRIVATE_KEY_FILENAME] ?? null; + if ((!is_string($privateKeyFilename)) || $privateKeyFilename === '') { + throw new ConfigurationError( + sprintf( + 'Unexpected value for private key filename. Expected a non-empty string, got "%s".', + var_export($privateKeyFilename, true), + ), + ); + } + + $publicKeyFilename = $signatureKeyPair[self::KEY_PUBLIC_KEY_FILENAME] ?? null; + if ((!is_string($publicKeyFilename)) || $publicKeyFilename === '') { + throw new ConfigurationError( + sprintf( + 'Unexpected value for public key filename. Expected a non-empty string, got "%s".', + var_export($publicKeyFilename, true), + ), + ); + } + + $privateKeyPassword = $signatureKeyPair[self::KEY_PRIVATE_KEY_PASSWORD] ?? null; + if ( + ((!is_string($privateKeyPassword)) && (!is_null($privateKeyPassword))) || + $privateKeyPassword === '' + ) { + throw new ConfigurationError( + sprintf( + 'Unexpected value for private key password. Expected a non-empty string or null, got "%s".', + var_export($privateKeyPassword, true), + ), + ); + } + + $keyId = $signatureKeyPair[self::KEY_KEY_ID] ?? null; + if ( + ((!is_string($keyId)) && (!is_null($keyId))) || + $keyId === '' + ) { + throw new ConfigurationError( + sprintf( + 'Unexpected value for key ID signature key pair. Expected a string or null, got "%s".', + var_export($keyId, true), + ), + ); + } + + + $signatureKeyPairConfigBag->add(new SignatureKeyPairConfig( + $algorithm, + new KeyPairFilenameConfig( + $this->sspBridge->utils()->config()->getCertPath($privateKeyFilename), + $this->sspBridge->utils()->config()->getCertPath($publicKeyFilename), + $privateKeyPassword, + $keyId, + ), + )); + } + + return $this->protocolSignatureKeyPairBag = $this->valueAbstracts + ->signatureKeyPairBagFactory() + ->fromConfig($signatureKeyPairConfigBag); + } + /** * Get signer for OIDC protocol. * @@ -364,9 +491,9 @@ public function getProtocolSigner(): Signer /** * Get the path to the private key used in OIDC protocol. - * @throws \Exception * @return non-empty-string The file system path * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType + * @throws \Exception */ public function getProtocolPrivateKeyPath(): string { @@ -454,7 +581,7 @@ public function getForcedAcrValueForCookieAuthentication(): ?string return null; } - return (string) $value; + return (string)$value; } /** @@ -806,9 +933,9 @@ public function getFederationTrustAnchors(): array } /** - * @throws \SimpleSAML\Error\ConfigurationError * @return non-empty-array * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType + * @throws \SimpleSAML\Error\ConfigurationError */ public function getFederationTrustAnchorIds(): array { @@ -940,7 +1067,7 @@ public function getCredentialConfigurationIdsSupported(): array */ public function getVciScopes(): array { - if (! $this->getVerifiableCredentialEnabled()) { + if (!$this->getVerifiableCredentialEnabled()) { return []; } diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 5986de2a..9fd8a8b7 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -7,9 +7,11 @@ use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; /** * OpenID Provider Metadata Service - provides information about OIDC authentication server. @@ -37,7 +39,19 @@ public function __construct( */ private function initMetadata(): void { - $signer = $this->moduleConfig->getProtocolSigner(); + $protocolSignatureAlgorithmNames = array_values( + array_map( + fn(SignatureKeyPair $signatureKeyPair): string => $signatureKeyPair->getSignatureAlgorithm()->value, + $this->moduleConfig->getProtocolSignatureKeyPairBag()->getAll(), + ), + ); + + $supportedSignatureAlgorithmNames = array_values( + array_map( + fn(SignatureAlgorithmEnum $signatureAlgorithm): string => $signatureAlgorithm->value, + $this->moduleConfig->getSupportedAlgorithms()->getSignatureAlgorithmBag()->getAll(), + ), + ); $this->metadata = []; $this->metadata[ClaimsEnum::Issuer->value] = $this->moduleConfig->getIssuer(); @@ -53,22 +67,19 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getScopes()); $this->metadata[ClaimsEnum::ResponseTypesSupported->value] = ['code', 'token', 'id_token', 'id_token token']; $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; - $this->metadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] = [ - $signer->algorithmId(), - ]; + $this->metadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] = $protocolSignatureAlgorithmNames; $this->metadata[ClaimsEnum::CodeChallengeMethodsSupported->value] = ['plain', 'S256']; $this->metadata[ClaimsEnum::TokenEndpointAuthMethodsSupported->value] = [ TokenEndpointAuthMethodsEnum::ClientSecretPost->value, TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value, ]; - $this->metadata[ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value] = [ - $signer->algorithmId(), - ]; + $this->metadata[ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value] = + $supportedSignatureAlgorithmNames; $this->metadata[ClaimsEnum::RequestParameterSupported->value] = true; $this->metadata[ClaimsEnum::RequestObjectSigningAlgValuesSupported->value] = [ 'none', - $signer->algorithmId(), + ...$supportedSignatureAlgorithmNames, ]; $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = false; From 6f2c287d0cb347ea71c7f4b9c8c801f9ce2553fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 10 Jan 2026 20:24:43 +0100 Subject: [PATCH 04/17] WIP sig algs --- src/Services/OpMetadataService.php | 3 ++ .../src/Services/OpMetadataServiceTest.php | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 9fd8a8b7..3a16dced 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -39,6 +39,7 @@ public function __construct( */ private function initMetadata(): void { + // Signature algorithms that this OP can use to sign JWS artifacts. $protocolSignatureAlgorithmNames = array_values( array_map( fn(SignatureKeyPair $signatureKeyPair): string => $signatureKeyPair->getSignatureAlgorithm()->value, @@ -46,6 +47,8 @@ private function initMetadata(): void ), ); + // Signature algorithms that this OP can use to validate signature on + // signed JWS artifacts. $supportedSignatureAlgorithmNames = array_values( array_map( fn(SignatureAlgorithmEnum $signatureAlgorithm): string => $signatureAlgorithm->value, diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 43ce4fa5..3b0bb335 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -11,7 +11,12 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Services\OpMetadataService @@ -20,6 +25,10 @@ class OpMetadataServiceTest extends TestCase { protected MockObject $moduleConfigMock; protected MockObject $claimTranslatorExtractorMock; + protected MockObject $signatureAlgorithmBag; + protected MockObject $supportedAlgorithmsMock; + protected MockObject $signatureKeyPairBagMock; + protected MockObject $signatureKeyPairMock; /** * @throws \Exception @@ -51,6 +60,28 @@ public function setUp(): void $this->moduleConfigMock->method('getProtocolSigner')->willReturn($signer); $this->claimTranslatorExtractorMock = $this->createMock(ClaimTranslatorExtractor::class); + + $this->signatureAlgorithmBag = $this->createMock(SignatureAlgorithmBag::class); + $this->signatureAlgorithmBag->method('getAll') + ->willReturn([SignatureAlgorithmEnum::RS256]); + + $this->supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); + $this->supportedAlgorithmsMock->method('getSignatureAlgorithmBag') + ->willReturn($this->signatureAlgorithmBag); + + $this->moduleConfigMock->method('getSupportedAlgorithms') + ->willReturn($this->supportedAlgorithmsMock); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairBagMock->method('getAll') + ->willReturn([$this->signatureKeyPairMock]); + + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->signatureKeyPairBagMock); } /** From fee9631ded573c19992ce3b360300ed1357b7209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 12 Jan 2026 11:55:48 +0100 Subject: [PATCH 05/17] Get rid of old JWKS service --- config/module_oidc.php.dist | 35 ++- docker/ssp/module_oidc.php | 10 + .../Federation/EntityStatementController.php | 30 ++- src/Controllers/JwksController.php | 19 +- src/ModuleConfig.php | 244 ++++++++++++------ src/Services/Container.php | 3 - src/Services/JsonWebKeySetService.php | 133 ---------- src/Services/OpMetadataService.php | 21 +- .../EntityStatementControllerTest.php | 12 +- .../src/Controllers/JwksControllerTest.php | 34 ++- .../src/Services/JsonWebKeySetServiceTest.php | 175 ------------- .../src/Services/OpMetadataServiceTest.php | 6 +- 12 files changed, 285 insertions(+), 437 deletions(-) delete mode 100644 src/Services/JsonWebKeySetService.php delete mode 100644 tests/unit/src/Services/JsonWebKeySetServiceTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index a3fe7f32..1b43c5d2 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -46,13 +46,17 @@ $config = [ * sign ID Token JWT. */ // (optional) The private key passphrase. + /** @deprecated */ // ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret', // The certificate and private key filenames, with given defaults. + /** @deprecated */ ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, + // Token signer, with given default. // See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer + /** @deprecated */ ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, // ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, // ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, @@ -65,6 +69,7 @@ $config = [ * PKI options. */ // // (optional) The (new) private key passphrase. + /** @deprecated */ // ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', // ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module.key', // ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', @@ -83,7 +88,7 @@ $config = [ * to set other default (first) algorithm as needed. * You can also use this config option to advertise any (new) keys, for * example, for key-rollover scenarios. Just add those entries later in - * the list, so they can be published on the OP discovery endpoint. + * the list, so they can be published on the OP JWKS discovery endpoint. * * The format is array of associative arrays, where each array value * consists of the following properties (keys): @@ -516,6 +521,7 @@ $config = [ // The federation private key passphrase (optional). // ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret', // The federation certificate and private key filenames, with given defaults. + /** @deprecated */ ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => @@ -526,11 +532,38 @@ $config = [ * on how this works. */ // The federation (new) private key passphrase (optional). + /** @deprecated */ // ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', // ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module_federation.key', // ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', + /** + * Federation signature algorithm and key-pair definitions, representing + * supported algorithms for signing, for example, Entity Statements. + * The first algorithm in the list will be used for signing (the + * first entry represents default algorithm and signing key). + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Just add those entries later in + * the list, so they can be published in Federation JWKS. + * + * Note that these keys SHOULD NOT be the same as the ones used in the + * protocol (Connect) itself. + * + * The format is the same as for the protocol (Connect) signature key pairs + * (option ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS) + */ + ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME, +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'ec-connect-signing-key-01', // Optional + ], + ], + // Federation token signer, with given default. + /** @deprecated */ ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, // Federation entity statement duration which determines the Expiration Time (exp) claim set in entity diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index bd16a2d7..364f2783 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -23,6 +23,16 @@ ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'rsa-connect-signing-key-2026', // Optional + ], + ], + ModuleConfig::OPTION_AUTH_SOURCE => 'example-userpass', ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index a7918b37..95d6091a 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -8,7 +8,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; -use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; @@ -25,6 +24,7 @@ use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwk; +use SimpleSAML\OpenID\Jwks; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -37,17 +37,17 @@ class EntityStatementController * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function __construct( - private readonly ModuleConfig $moduleConfig, - private readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, - private readonly JsonWebKeySetService $jsonWebKeySetService, - private readonly OpMetadataService $opMetadataService, - private readonly ClientRepository $clientRepository, - private readonly Helpers $helpers, - private readonly Routes $routes, - private readonly Federation $federation, - private readonly Jwk $jwk, - private readonly LoggerService $loggerService, - private readonly ?FederationCache $federationCache, + protected readonly ModuleConfig $moduleConfig, + protected readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, + protected readonly Jwks $jwks, + protected readonly OpMetadataService $opMetadataService, + protected readonly ClientRepository $clientRepository, + protected readonly Helpers $helpers, + protected readonly Routes $routes, + protected readonly Federation $federation, + protected readonly Jwk $jwk, + protected readonly LoggerService $loggerService, + protected readonly ?FederationCache $federationCache, ) { if (!$this->moduleConfig->getFederationEnabled()) { throw OidcServerException::forbidden('federation capabilities not enabled'); @@ -82,6 +82,10 @@ public function configuration(): Response ), ]; + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getFederationSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + $payload = [ ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iat->value => $currentTimestamp, @@ -91,7 +95,7 @@ public function configuration(): Response ClaimsEnum::Exp->value => $this->helpers->dateTime()->getUtc()->add( $this->moduleConfig->getFederationEntityStatementDuration(), )->getTimestamp(), - ClaimsEnum::Jwks->value => ['keys' => array_values($this->jsonWebKeySetService->federationKeys()),], + ClaimsEnum::Jwks->value => $jwks, ClaimsEnum::Metadata->value => [ EntityTypesEnum::FederationEntity->value => [ // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters diff --git a/src/Controllers/JwksController.php b/src/Controllers/JwksController.php index d2e12ad1..9d71524f 100644 --- a/src/Controllers/JwksController.php +++ b/src/Controllers/JwksController.php @@ -18,22 +18,29 @@ use Laminas\Diactoros\Response\JsonResponse; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Jwks; use Symfony\Component\HttpFoundation\Response; class JwksController { public function __construct( - private readonly JsonWebKeySetService $jsonWebKeySetService, - private readonly PsrHttpBridge $psrHttpBridge, + protected readonly PsrHttpBridge $psrHttpBridge, + protected readonly ModuleConfig $moduleConfig, + protected readonly Jwks $jwks, ) { } + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ public function __invoke(): JsonResponse { - return new JsonResponse([ - 'keys' => array_values($this->jsonWebKeySetService->protocolKeys()), - ]); + return new JsonResponse( + $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(), + ); } public function jwks(): Response diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index a282306a..22e168e7 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -34,6 +34,7 @@ use SimpleSAML\OpenID\ValueAbstracts\KeyPairFilenameConfig; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfig; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; class ModuleConfig { @@ -128,6 +129,7 @@ class ModuleConfig final public const OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI = 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; final public const OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; + final public const OPTION_FEDERATION_SIGNATURE_KEY_PAIRS = 'federation_signature_key_pairs'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -159,6 +161,7 @@ class ModuleConfig */ private readonly Configuration $sspConfig; protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; + protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; /** * @throws \Exception @@ -391,81 +394,7 @@ public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag throw new ConfigurationError('At least one protocol signature key-pair pair should be provided.'); } - $signatureKeyPairConfigBag = new ValueAbstracts\SignatureKeyPairConfigBag(); - - foreach ($signatureKeyPairs as $signatureKeyPair) { - if (!is_array($signatureKeyPair)) { - throw new ConfigurationError( - 'Invalid value for signature key pair. Expected array, got "' . - var_export($signatureKeyPair, true) . '".', - ); - } - - $algorithm = $signatureKeyPair[self::KEY_ALGORITHM] ?? null; - if (!$algorithm instanceof SignatureAlgorithmEnum) { - throw new ConfigurationError( - 'Invalid protocol signature algorithm encountered. Expected instance of ' . - SignatureAlgorithmEnum::class, - ); - } - - $privateKeyFilename = $signatureKeyPair[self::KEY_PRIVATE_KEY_FILENAME] ?? null; - if ((!is_string($privateKeyFilename)) || $privateKeyFilename === '') { - throw new ConfigurationError( - sprintf( - 'Unexpected value for private key filename. Expected a non-empty string, got "%s".', - var_export($privateKeyFilename, true), - ), - ); - } - - $publicKeyFilename = $signatureKeyPair[self::KEY_PUBLIC_KEY_FILENAME] ?? null; - if ((!is_string($publicKeyFilename)) || $publicKeyFilename === '') { - throw new ConfigurationError( - sprintf( - 'Unexpected value for public key filename. Expected a non-empty string, got "%s".', - var_export($publicKeyFilename, true), - ), - ); - } - - $privateKeyPassword = $signatureKeyPair[self::KEY_PRIVATE_KEY_PASSWORD] ?? null; - if ( - ((!is_string($privateKeyPassword)) && (!is_null($privateKeyPassword))) || - $privateKeyPassword === '' - ) { - throw new ConfigurationError( - sprintf( - 'Unexpected value for private key password. Expected a non-empty string or null, got "%s".', - var_export($privateKeyPassword, true), - ), - ); - } - - $keyId = $signatureKeyPair[self::KEY_KEY_ID] ?? null; - if ( - ((!is_string($keyId)) && (!is_null($keyId))) || - $keyId === '' - ) { - throw new ConfigurationError( - sprintf( - 'Unexpected value for key ID signature key pair. Expected a string or null, got "%s".', - var_export($keyId, true), - ), - ); - } - - - $signatureKeyPairConfigBag->add(new SignatureKeyPairConfig( - $algorithm, - new KeyPairFilenameConfig( - $this->sspBridge->utils()->config()->getCertPath($privateKeyFilename), - $this->sspBridge->utils()->config()->getCertPath($publicKeyFilename), - $privateKeyPassword, - $keyId, - ), - )); - } + $signatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag($signatureKeyPairs); return $this->protocolSignatureKeyPairBag = $this->valueAbstracts ->signatureKeyPairBagFactory() @@ -683,6 +612,29 @@ public function getFederationEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_FEDERATION_ENABLED, false); } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getFederationSignatureKeyPairBag(): SignatureKeyPairBag + { + if ($this->federationSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->federationSignatureKeyPairBag; + } + + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS); + + if (empty($signatureKeyPairs)) { + throw new ConfigurationError('At least one federation signature key-pair pair should be provided.'); + } + + $signatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag($signatureKeyPairs); + + return $this->federationSignatureKeyPairBag = $this->valueAbstracts + ->signatureKeyPairBagFactory() + ->fromConfig($signatureKeyPairConfigBag); + } + /** * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError @@ -1234,4 +1186,146 @@ public function getDefaultUsersEmailAttributeName(): string { return $this->config()->getOptionalString(self::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME, 'mail'); } + + /** + * @throws ConfigurationError + * @return array{ + * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, + * private_key_filename: non-empty-string, + * public_key_filename: non-empty-string, + * private_key_password: ?non-empty-string, + * key_id: ?non-empty-string + * } + * + */ + protected function getValidateSignatureKeyPairArray(mixed $signatureKeyPair): array + { + if (!is_array($signatureKeyPair)) { + throw new ConfigurationError( + 'Invalid value for signature key pair. Expected array, got "' . + var_export($signatureKeyPair, true) . '".', + ); + } + + $algorithm = $signatureKeyPair[self::KEY_ALGORITHM] ?? null; + if (!$algorithm instanceof SignatureAlgorithmEnum) { + throw new ConfigurationError( + 'Invalid protocol signature algorithm encountered. Expected instance of ' . + SignatureAlgorithmEnum::class, + ); + } + + $privateKeyFilename = $signatureKeyPair[self::KEY_PRIVATE_KEY_FILENAME] ?? null; + if ((!is_string($privateKeyFilename)) || $privateKeyFilename === '') { + throw new ConfigurationError( + sprintf( + 'Unexpected value for private key filename. Expected a non-empty string, got "%s".', + var_export($privateKeyFilename, true), + ), + ); + } + $privateKeyFilename = $this->sspBridge->utils()->config()->getCertPath($privateKeyFilename); + if (!file_exists($privateKeyFilename)) { + throw new ConfigurationError( + sprintf( + 'Private key file does not exist: %s', + $privateKeyFilename, + ), + ); + } + /** @var non-empty-string $privateKeyFilename */ + + $publicKeyFilename = $signatureKeyPair[self::KEY_PUBLIC_KEY_FILENAME] ?? null; + if ((!is_string($publicKeyFilename)) || $publicKeyFilename === '') { + throw new ConfigurationError( + sprintf( + 'Unexpected value for public key filename. Expected a non-empty string, got "%s".', + var_export($publicKeyFilename, true), + ), + ); + } + $publicKeyFilename = $this->sspBridge->utils()->config()->getCertPath($publicKeyFilename); + if (!file_exists($publicKeyFilename)) { + throw new ConfigurationError( + sprintf( + 'Public key file does not exist: %s', + $publicKeyFilename, + ), + ); + } + /** @var non-empty-string $publicKeyFilename */ + + $privateKeyPassword = $signatureKeyPair[self::KEY_PRIVATE_KEY_PASSWORD] ?? null; + if ( + ((!is_string($privateKeyPassword)) && (!is_null($privateKeyPassword))) || + $privateKeyPassword === '' + ) { + throw new ConfigurationError( + sprintf( + 'Unexpected value for private key password. Expected a non-empty string or null, got "%s".', + var_export($privateKeyPassword, true), + ), + ); + } + + $keyId = $signatureKeyPair[self::KEY_KEY_ID] ?? null; + if ( + ((!is_string($keyId)) && (!is_null($keyId))) || + $keyId === '' + ) { + throw new ConfigurationError( + sprintf( + 'Unexpected value for key ID signature key pair. Expected a string or null, got "%s".', + var_export($keyId, true), + ), + ); + } + + + return [ + self::KEY_ALGORITHM => $algorithm, + self::KEY_PRIVATE_KEY_FILENAME => $privateKeyFilename, + self::KEY_PUBLIC_KEY_FILENAME => $publicKeyFilename, + self::KEY_PRIVATE_KEY_PASSWORD => $privateKeyPassword, + self::KEY_KEY_ID => $keyId, + ]; + } + + /** + * @throws ConfigurationError + * @psalm-suppress MixedAssignment + */ + protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): SignatureKeyPairConfigBag + { + $signatureKeyPairConfigBag = new SignatureKeyPairConfigBag(); + + foreach ($signatureKeyPairs as $signatureKeyPair) { + /** + * @var SignatureAlgorithmEnum $algorithm + * @var non-empty-string $privateKeyFilename + * @var non-empty-string $publicKeyFilename + * @var ?non-empty-string $privateKeyPassword + * @var ?non-empty-string $keyId + */ + [ + self::KEY_ALGORITHM => $algorithm, + self::KEY_PRIVATE_KEY_FILENAME => $privateKeyFilename, + self::KEY_PUBLIC_KEY_FILENAME => $publicKeyFilename, + self::KEY_PRIVATE_KEY_PASSWORD => $privateKeyPassword, + self::KEY_KEY_ID => $keyId, + ] = $this->getValidateSignatureKeyPairArray($signatureKeyPair); + + $signatureKeyPairConfigBag->add(new SignatureKeyPairConfig( + $algorithm, + new KeyPairFilenameConfig( + $privateKeyFilename, + $publicKeyFilename, + $privateKeyPassword, + $keyId, + ), + )); + } + + return $signatureKeyPairConfigBag; + } } diff --git a/src/Services/Container.php b/src/Services/Container.php index 9a375595..1c5c619e 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -165,9 +165,6 @@ public function __construct() ); $this->services[FormFactory::class] = $formFactory; - $jsonWebKeySetService = new JsonWebKeySetService($moduleConfig); - $this->services[JsonWebKeySetService::class] = $jsonWebKeySetService; - $sessionService = new SessionService($session); $this->services[SessionService::class] = $sessionService; diff --git a/src/Services/JsonWebKeySetService.php b/src/Services/JsonWebKeySetService.php deleted file mode 100644 index 8bb56868..00000000 --- a/src/Services/JsonWebKeySetService.php +++ /dev/null @@ -1,133 +0,0 @@ -prepareProtocolJwkSet(); - - $this->prepareFederationJwkSet(); - } - - /** - * @return \Jose\Component\Core\JWK[] - */ - public function protocolKeys(): array - { - return $this->protocolJwkSet->all(); - } - - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - public function federationKeys(): array - { - if (is_null($this->federationJwkSet)) { - throw OidcServerException::serverError('OpenID Federation public key not set.'); - } - - return $this->federationJwkSet->all(); - } - - /** - * @throws \ReflectionException - * @throws \SimpleSAML\Error\Exception - */ - protected function prepareProtocolJwkSet(): void - { - $protocolPublicKeyPath = $this->moduleConfig->getProtocolCertPath(); - - if (!file_exists($protocolPublicKeyPath)) { - throw new Error\Exception("OIDC protocol public key file does not exists: $protocolPublicKeyPath."); - } - - $jwk = JWKFactory::createFromKeyFile($protocolPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), - ]); - - $keys = [$jwk]; - - if ( - ($protocolNewPublicKeyPath = $this->moduleConfig->getProtocolNewCertPath()) && - file_exists($protocolNewPublicKeyPath) - ) { - $newJwk = JWKFactory::createFromKeyFile($protocolNewPublicKeyPath, null, [ - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), - ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), - ]); - - $keys[] = $newJwk; - } - - $this->protocolJwkSet = new JWKSet($keys); - } - - protected function prepareFederationJwkSet(): void - { - $federationPublicKeyPath = $this->moduleConfig->getFederationCertPath(); - - if (!file_exists($federationPublicKeyPath)) { - return; - } - - $federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), - ]); - - $keys = [$federationJwk]; - - if ( - ($federationNewPublicKeyPath = $this->moduleConfig->getFederationNewCertPath()) && - file_exists($federationNewPublicKeyPath) - ) { - $federationNewJwk = JWKFactory::createFromKeyFile($federationNewPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationNewPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), - ]); - - $keys[] = $federationNewJwk; - } - - $this->federationJwkSet = new JWKSet($keys); - } -} diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 3a16dced..0c39c827 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -7,11 +7,9 @@ use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; -use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; /** * OpenID Provider Metadata Service - provides information about OIDC authentication server. @@ -40,21 +38,16 @@ public function __construct( private function initMetadata(): void { // Signature algorithms that this OP can use to sign JWS artifacts. - $protocolSignatureAlgorithmNames = array_values( - array_map( - fn(SignatureKeyPair $signatureKeyPair): string => $signatureKeyPair->getSignatureAlgorithm()->value, - $this->moduleConfig->getProtocolSignatureKeyPairBag()->getAll(), - ), - ); + $protocolSignatureAlgorithmNames = $this->moduleConfig + ->getProtocolSignatureKeyPairBag() + ->getAllAlgorithmNamesUnique(); // Signature algorithms that this OP can use to validate signature on // signed JWS artifacts. - $supportedSignatureAlgorithmNames = array_values( - array_map( - fn(SignatureAlgorithmEnum $signatureAlgorithm): string => $signatureAlgorithm->value, - $this->moduleConfig->getSupportedAlgorithms()->getSignatureAlgorithmBag()->getAll(), - ), - ); + $supportedSignatureAlgorithmNames = $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(); $this->metadata = []; $this->metadata[ClaimsEnum::Issuer->value] = $this->moduleConfig->getIssuer(); diff --git a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php index f9603d3a..64e8f1e3 100644 --- a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php +++ b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php @@ -12,7 +12,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; -use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; @@ -20,13 +19,14 @@ use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwk; +use SimpleSAML\OpenID\Jwks; #[CoversClass(EntityStatementController::class)] class EntityStatementControllerTest extends TestCase { protected MockObject $moduleConfigMock; protected MockObject $jsonWebTokenBuilderServiceMock; - protected MockObject $jsonWebKeySetServiceMock; + protected MockObject $jwksMock; protected MockObject $opMetadataServiceMock; protected MockObject $clientRepositoryMock; protected MockObject $helpersMock; @@ -40,7 +40,7 @@ protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->jsonWebTokenBuilderServiceMock = $this->createMock(JsonWebTokenBuilderService::class); - $this->jsonWebKeySetServiceMock = $this->createMock(JsonWebKeySetService::class); + $this->jwksMock = $this->createMock(Jwks::class); $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); $this->helpersMock = $this->createMock(Helpers::class); @@ -54,7 +54,7 @@ protected function setUp(): void protected function sut( ?ModuleConfig $moduleConfig = null, ?JsonWebTokenBuilderService $jsonWebTokenBuilderService = null, - ?JsonWebKeySetService $jsonWebKeySetService = null, + ?Jwks $jwks = null, ?OpMetadataService $opMetadataService = null, ?ClientRepository $clientRepository = null, ?Helpers $helpers = null, @@ -66,7 +66,7 @@ protected function sut( ): EntityStatementController { $moduleConfig ??= $this->moduleConfigMock; $jsonWebTokenBuilderService ??= $this->jsonWebTokenBuilderServiceMock; - $jsonWebKeySetService ??= $this->jsonWebKeySetServiceMock; + $jwks ??= $this->jwksMock; $opMetadataService ??= $this->opMetadataServiceMock; $clientRepository ??= $this->clientRepositoryMock; $helpers ??= $this->helpersMock; @@ -79,7 +79,7 @@ protected function sut( return new EntityStatementController( $moduleConfig, $jsonWebTokenBuilderService, - $jsonWebKeySetService, + $jwks, $opMetadataService, $clientRepository, $helpers, diff --git a/tests/unit/src/Controllers/JwksControllerTest.php b/tests/unit/src/Controllers/JwksControllerTest.php index 4b3267e1..675621b6 100644 --- a/tests/unit/src/Controllers/JwksControllerTest.php +++ b/tests/unit/src/Controllers/JwksControllerTest.php @@ -9,7 +9,10 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Controllers\JwksController; -use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jwks\Factories\JwksDecoratorFactory; +use SimpleSAML\OpenID\Jwks\JwksDecorator; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -18,19 +21,23 @@ */ class JwksControllerTest extends TestCase { - protected MockObject $jsonWebKeySetServiceMock; + protected MockObject $moduleConfigMock; + protected MockObject $jwks; protected MockObject $serverRequestMock; protected MockObject $psrHttpBridgeMock; protected MockObject $symfonyResponseMock; protected MockObject $responseHeaderBagMock; protected MockObject $httpFoundationFactoryMock; + protected MockObject $jwksDecoratorFactoryMock; + protected MockObject $jwksDecoratorMock; /** * @throws \Exception */ protected function setUp(): void { - $this->jsonWebKeySetServiceMock = $this->createMock(JsonWebKeySetService::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->jwks = $this->createMock(Jwks::class); $this->serverRequestMock = $this->createMock(ServerRequest::class); $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); @@ -41,18 +48,27 @@ protected function setUp(): void $this->httpFoundationFactoryMock = $this->createMock(HttpFoundationFactory::class); $this->httpFoundationFactoryMock->method('createResponse')->willReturn($this->symfonyResponseMock); $this->psrHttpBridgeMock->method('getHttpFoundationFactory')->willReturn($this->httpFoundationFactoryMock); + + $this->jwksDecoratorMock = $this->createMock(JwksDecorator::class); + $this->jwksDecoratorFactoryMock = $this->createMock(JwksDecoratorFactory::class); + $this->jwksDecoratorFactoryMock->method('fromJwkDecorators')->willReturn($this->jwksDecoratorMock); + + $this->jwks->method('jwksDecoratorFactory')->willReturn($this->jwksDecoratorFactoryMock); } protected function mock( - ?JsonWebKeySetService $jsonWebKeySetService = null, ?PsrHttpBridge $psrHttpBridge = null, + ?ModuleConfig $moduleConfig = null, + ?Jwks $jwks = null, ): JwksController { - $jsonWebKeySetService ??= $this->jsonWebKeySetServiceMock; $psrHttpBridge ??= $this->psrHttpBridgeMock; + $moduleConfig ??= $this->moduleConfigMock; + $jwks ??= $this->jwks; return new JwksController( - $jsonWebKeySetService, $psrHttpBridge, + $moduleConfig, + $jwks, ); } @@ -67,7 +83,7 @@ public function testItIsInitializable(): void public function testItReturnsJsonKeys(): void { $keys = [ - 0 => [ + 'keys' => [ 'kty' => 'RSA', 'n' => 'n', 'e' => 'e', @@ -77,10 +93,10 @@ public function testItReturnsJsonKeys(): void ], ]; - $this->jsonWebKeySetServiceMock->expects($this->once())->method('protocolKeys')->willReturn($keys); + $this->jwksDecoratorMock->expects($this->once())->method('jsonSerialize')->willReturn($keys); $this->assertSame( - ['keys' => $keys], + $keys, $this->mock()->__invoke()->getPayload(), ); } diff --git a/tests/unit/src/Services/JsonWebKeySetServiceTest.php b/tests/unit/src/Services/JsonWebKeySetServiceTest.php deleted file mode 100644 index 4aca2450..00000000 --- a/tests/unit/src/Services/JsonWebKeySetServiceTest.php +++ /dev/null @@ -1,175 +0,0 @@ - 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - $pkGenerateNew = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - $pkGenerateFederation = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - $pkGenerateFederationNew = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - // get the public key - $pkGenerateDetails = openssl_pkey_get_details($pkGenerate); - $pkGenerateDetailsNew = openssl_pkey_get_details($pkGenerateNew); - $pkGenerateDetailsFederation = openssl_pkey_get_details($pkGenerateFederation); - $pkGenerateDetailsFederationNew = openssl_pkey_get_details($pkGenerateFederationNew); - self::$pkGeneratePublic = $pkGenerateDetails['key']; - self::$pkGeneratePublicNew = $pkGenerateDetailsNew['key']; - self::$pkGeneratePublicFederation = $pkGenerateDetailsFederation['key']; - self::$pkGeneratePublicFederationNew = $pkGenerateDetailsFederationNew['key']; - - file_put_contents(sys_get_temp_dir() . '/oidc_module.crt', self::$pkGeneratePublic); - file_put_contents(sys_get_temp_dir() . '/new_oidc_module.crt', self::$pkGeneratePublicNew); - file_put_contents(sys_get_temp_dir() . '/oidc_module_federation.crt', self::$pkGeneratePublicFederation); - file_put_contents( - sys_get_temp_dir() . '/new_oidc_module_federation.crt', - self::$pkGeneratePublicFederationNew, - ); - - Configuration::setPreLoadedConfig( - Configuration::loadFromArray([ - ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', - ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', - ]), - ModuleConfig::DEFAULT_FILE_NAME, - ); - } - - /** - * @return void - */ - public static function tearDownAfterClass(): void - { - Configuration::clearInternalState(); - unlink(sys_get_temp_dir() . '/oidc_module.crt'); - unlink(sys_get_temp_dir() . '/new_oidc_module.crt'); - unlink(sys_get_temp_dir() . '/oidc_module_federation.crt'); - unlink(sys_get_temp_dir() . '/new_oidc_module_federation.crt'); - } - - /** - * @return void - * @throws \SimpleSAML\Error\Exception - */ - public function testProtocolKeys() - { - $config = [ - 'certdir' => sys_get_temp_dir(), - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - $kid = FingerprintGenerator::forString(self::$pkGeneratePublic); - $jwk = JWKFactory::createFromKey(self::$pkGeneratePublic, null, [ - 'kid' => $kid, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $kidNew = FingerprintGenerator::forString(self::$pkGeneratePublicNew); - $jwkNew = JWKFactory::createFromKey(self::$pkGeneratePublicNew, null, [ - 'kid' => $kidNew, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $JWKSet = new JWKSet([$jwk, $jwkNew]); - - $jsonWebKeySetService = new JsonWebKeySetService(new ModuleConfig()); - - $this->assertEquals($JWKSet->all(), $jsonWebKeySetService->protocolKeys()); - } - - /** - * @throws \SimpleSAML\Error\Exception - */ - public function testProtocolCertificateFileNotFound(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/OIDC protocol public key file does not exists/'); - - $config = [ - 'certdir' => __DIR__, - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - new JsonWebKeySetService(new ModuleConfig()); - } - - public function testFederationKeys(): void - { - $config = [ - 'certdir' => sys_get_temp_dir(), - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - $kid = FingerprintGenerator::forString(self::$pkGeneratePublicFederation); - $jwk = JWKFactory::createFromKey(self::$pkGeneratePublicFederation, null, [ - 'kid' => $kid, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $kidNew = FingerprintGenerator::forString(self::$pkGeneratePublicFederationNew); - $jwkNew = JWKFactory::createFromKey(self::$pkGeneratePublicFederationNew, null, [ - 'kid' => $kidNew, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $JWKSet = new JWKSet([$jwk, $jwkNew]); - - $jsonWebKeySetService = new JsonWebKeySetService(new ModuleConfig()); - - $this->assertEquals($JWKSet->all(), $jsonWebKeySetService->federationKeys()); - } -} diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 3b0bb335..514b8ca2 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -62,8 +62,8 @@ public function setUp(): void $this->claimTranslatorExtractorMock = $this->createMock(ClaimTranslatorExtractor::class); $this->signatureAlgorithmBag = $this->createMock(SignatureAlgorithmBag::class); - $this->signatureAlgorithmBag->method('getAll') - ->willReturn([SignatureAlgorithmEnum::RS256]); + $this->signatureAlgorithmBag->method('getAllNamesUnique') + ->willReturn(['RS256']); $this->supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); $this->supportedAlgorithmsMock->method('getSignatureAlgorithmBag') @@ -79,6 +79,8 @@ public function setUp(): void $this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); $this->signatureKeyPairBagMock->method('getAll') ->willReturn([$this->signatureKeyPairMock]); + $this->signatureKeyPairBagMock->method('getAllAlgorithmNamesUnique') + ->willReturn(['RS256']); $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); From 97dc0c63532daf3ca1d360a1fa2c39bdabfcfa30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 12 Jan 2026 16:48:03 +0100 Subject: [PATCH 06/17] WIP --- src/Controllers/EndSessionController.php | 38 +++++++ src/Factories/CoreFactory.php | 19 +--- .../RelyingPartyAssociationInterface.php | 13 +++ .../Associations/RelyingPartyAssociation.php | 11 ++ src/Server/Grants/ImplicitGrant.php | 4 +- src/Server/ResponseTypes/TokenResponse.php | 5 +- src/Services/AuthenticationService.php | 1 + src/Services/Container.php | 9 +- src/Services/IdTokenBuilder.php | 106 +++++++++++++++++- src/Services/LogoutTokenBuilder.php | 62 ++++++++-- 10 files changed, 230 insertions(+), 38 deletions(-) diff --git a/src/Controllers/EndSessionController.php b/src/Controllers/EndSessionController.php index 267aa57f..63797a74 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -65,6 +65,8 @@ public function __invoke(ServerRequestInterface $request): Response (string)$idTokenHint->claims()->get('sid'); } + $this->loggerService->debug('EndSession: ID Token Hint Session ID: ' . $sidClaim ?? 'N/A'); + // Check if RP is requesting logout for session that previously existed (not this current session). // Claim 'sid' from 'id_token_hint' logout parameter indicates for which session should log out be // performed (sid is session ID used when ID token was issued during authn). If the requested @@ -73,19 +75,31 @@ public function __invoke(ServerRequestInterface $request): Response $sidClaim !== null && $this->sessionService->getCurrentSession()->getSessionId() !== $sidClaim ) { + $this->loggerService->debug('Not current session: ' . $sidClaim); try { if (($sidSession = $this->sessionService->getSessionById($sidClaim)) !== null) { + $this->loggerService->debug('Found session for ID: ' . $sidClaim); $sidSessionValidAuthorities = $sidSession->getAuthorities(); if (! empty($sidSessionValidAuthorities)) { + $this->loggerService->debug( + 'Valid session authorities: ' . implode(', ', $sidSessionValidAuthorities), + ); $wasLogoutActionCalled = true; // Create a SessionLogoutTicket so that the sid is available in the static logoutHandler() $this->sessionLogoutTicketStoreBuilder->getInstance()->add($sidClaim); // Initiate logout for every valid auth source for the requested session. foreach ($sidSessionValidAuthorities as $authSourceId) { + $this->loggerService->debug( + 'Initiating logout for auth source ID: ' . $authSourceId, + ); $sidSession->doLogout($authSourceId); } + } else { + $this->loggerService->debug('Session authorities not found for ID: ' . $sidClaim); } + } else { + $this->loggerService->debug('Session not found for ID: ' . $sidClaim); } } catch (Throwable $exception) { $this->loggerService->warning( @@ -96,13 +110,19 @@ public function __invoke(ServerRequestInterface $request): Response $currentSessionValidAuthorities = $this->sessionService->getCurrentSession()->getAuthorities(); if (!empty($currentSessionValidAuthorities)) { + $this->loggerService->debug( + 'Current session authorities: ' . implode(', ', $currentSessionValidAuthorities), + ); $wasLogoutActionCalled = true; // Initiate logout for every valid auth source for the current session. foreach ($this->sessionService->getCurrentSession()->getAuthorities() as $authSourceId) { $this->sessionService->getCurrentSession()->doLogout($authSourceId); } + } else { + $this->loggerService->debug('Current session authorities not found for ID: ' . $sidClaim); } + $this->loggerService->debug('Was logout action called: ' . var_export($wasLogoutActionCalled, true)); // Set indication for OIDC initiated logout back to false, so that the logoutHandler() method does not // run for other logout initiated actions, like (currently) re-authentication... $this->sessionService->setIsOidcInitiatedLogout(false); @@ -189,14 +209,32 @@ public static function logoutHandler(): void protected function resolveResponse(LogoutRequest $logoutRequest, bool $wasLogoutActionCalled): Response { if (($postLogoutRedirectUri = $logoutRequest->getPostLogoutRedirectUri()) !== null) { + $this->loggerService->debug( + 'Logout request includes post-logout redirect URI: ' . $postLogoutRedirectUri, + ); + if ($logoutRequest->getState() !== null) { + $this->loggerService->debug( + 'Appending logout request state: ' . $logoutRequest->getState(), + ); $postLogoutRedirectUri .= (!str_contains($postLogoutRedirectUri, '?')) ? '?' : '&'; $postLogoutRedirectUri .= http_build_query(['state' => $logoutRequest->getState()]); + } else { + $this->loggerService->debug( + 'No state provided for post logout', + ); } + $this->loggerService->debug( + 'Final post logout redirect URI: ' . $postLogoutRedirectUri, + ); return new RedirectResponse($postLogoutRedirectUri); } + $this->loggerService->debug( + 'No post logout redirect URI provided for logout. Showing template.', + ); + return $this->templateFactory->build( templateName: 'oidc:/logout.twig', data: [ diff --git a/src/Factories/CoreFactory.php b/src/Factories/CoreFactory.php index 0708ac87..dba9cd26 100644 --- a/src/Factories/CoreFactory.php +++ b/src/Factories/CoreFactory.php @@ -6,10 +6,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Core; -use SimpleSAML\OpenID\SupportedAlgorithms; class CoreFactory { @@ -25,22 +22,8 @@ public function __construct( */ public function build(): Core { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), - SignatureAlgorithmEnum::RS384, - SignatureAlgorithmEnum::RS512, - SignatureAlgorithmEnum::ES256, - SignatureAlgorithmEnum::ES384, - SignatureAlgorithmEnum::ES512, - SignatureAlgorithmEnum::PS256, - SignatureAlgorithmEnum::PS384, - SignatureAlgorithmEnum::PS512, - ), - ); - return new Core( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), logger: $this->loggerService, ); } diff --git a/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php b/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php index 8baf4eb1..c0274000 100644 --- a/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php +++ b/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php @@ -14,4 +14,17 @@ public function getSessionId(): ?string; public function setSessionId(?string $sessionId): void; public function getBackChannelLogoutUri(): ?string; public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void; + + /** + * Get id_token_signed_response_alg metadata parameter used by the client. + * + * @return string|null + */ + public function getClientIdTokenSignedResponseAlg(): ?string; + + /** + * Set id_token_signed_response_alg metadata parameter used by the client. + * @param string|null $idTokenSignedResponseAlg + */ + public function setClientIdTokenSignedResponseAlg(?string $idTokenSignedResponseAlg): void; } diff --git a/src/Server/Associations/RelyingPartyAssociation.php b/src/Server/Associations/RelyingPartyAssociation.php index cebbffff..5ce20b77 100644 --- a/src/Server/Associations/RelyingPartyAssociation.php +++ b/src/Server/Associations/RelyingPartyAssociation.php @@ -16,6 +16,7 @@ public function __construct( * Registered back-channel logout URI for the client. */ protected ?string $backChannelLogoutUri = null, + protected ?string $idTokenSignedResponseAlg = null, ) { } @@ -58,4 +59,14 @@ public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void { $this->backChannelLogoutUri = $backChannelLogoutUri; } + + public function getClientIdTokenSignedResponseAlg(): ?string + { + return $this->idTokenSignedResponseAlg; + } + + public function setClientIdTokenSignedResponseAlg(?string $idTokenSignedResponseAlg): void + { + $this->idTokenSignedResponseAlg = $idTokenSignedResponseAlg; + } } diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index fc088e97..2677ca98 100644 --- a/src/Server/Grants/ImplicitGrant.php +++ b/src/Server/Grants/ImplicitGrant.php @@ -261,7 +261,7 @@ private function completeOidcAuthorizationRequest(AuthorizationRequest $authoriz $responseParams['expires_in'] = $accessToken->getExpiryDateTime()->getTimestamp() - time(); } - $idToken = $this->idTokenBuilder->build( + $idToken = $this->idTokenBuilder->buildFor( $user, $accessToken, $authorizationRequest->getAddClaimsToIdToken(), @@ -272,7 +272,7 @@ private function completeOidcAuthorizationRequest(AuthorizationRequest $authoriz $authorizationRequest->getSessionId(), ); - $responseParams['id_token'] = $idToken->toString(); + $responseParams['id_token'] = $idToken->getToken(); $response = new RedirectResponse(); diff --git a/src/Server/ResponseTypes/TokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php index 96fbaee7..19ecdace 100644 --- a/src/Server/ResponseTypes/TokenResponse.php +++ b/src/Server/ResponseTypes/TokenResponse.php @@ -126,7 +126,8 @@ protected function prepareIdTokenExtraParam(AccessTokenEntity $accessToken): arr throw OidcServerException::accessDenied('No user available for provided user identifier.'); } - $token = $this->idTokenBuilder->build( + //$token = $this->idTokenBuilder->build( + $token = $this->idTokenBuilder->buildFor( $userEntity, $accessToken, false, @@ -138,7 +139,7 @@ protected function prepareIdTokenExtraParam(AccessTokenEntity $accessToken): arr ); return [ - 'id_token' => $token->toString(), + 'id_token' => $token->getToken(), ]; } diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php index 59bb7716..39392b90 100644 --- a/src/Services/AuthenticationService.php +++ b/src/Services/AuthenticationService.php @@ -312,6 +312,7 @@ protected function addRelyingPartyAssociation(ClientEntityInterface $oidcClient, (string)($claims['sub'] ?? $user->getIdentifier()), $this->getSessionId(), $oidcClient->getBackChannelLogoutUri(), + $oidcClient->getIdTokenSignedResponseAlg(), ), ); } diff --git a/src/Services/Container.php b/src/Services/Container.php index 1c5c619e..f4c491dc 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -423,10 +423,15 @@ public function __construct() $requestRuleManager = new RequestRulesManager($requestRules, $loggerService); $this->services[RequestRulesManager::class] = $requestRuleManager; - $idTokenBuilder = new IdTokenBuilder($jsonWebTokenBuilderService, $claimTranslatorExtractor); + $idTokenBuilder = new IdTokenBuilder( + $jsonWebTokenBuilderService, + $claimTranslatorExtractor, + $core, + $moduleConfig, + ); $this->services[IdTokenBuilder::class] = $idTokenBuilder; - $logoutTokenBuilder = new LogoutTokenBuilder($jsonWebTokenBuilderService); + $logoutTokenBuilder = new LogoutTokenBuilder($moduleConfig, $loggerService); $this->services[LogoutTokenBuilder::class] = $logoutTokenBuilder; $sessionLogoutTicketStoreDb = new LogoutTicketStoreDb($database); diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index ac5f7c7c..89980365 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -13,21 +13,123 @@ use League\OAuth2\Server\Entities\UserEntityInterface; use RuntimeException; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClaimSetInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\EntityStringRepresentationInterface; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\IdToken; class IdTokenBuilder { public function __construct( - private readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, - private readonly ClaimTranslatorExtractor $claimExtractor, + protected readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, + protected readonly ClaimTranslatorExtractor $claimExtractor, + protected readonly Core $core, + protected readonly ModuleConfig $moduleConfig, ) { } + /** + * @psalm-suppress MixedAssignment + */ + public function buildFor( + UserEntityInterface $userEntity, + AccessTokenEntity $accessToken, + bool $addClaimsFromScopes, + bool $addAccessTokenHash, + ?string $nonce, + ?int $authTime, + ?string $acr, + ?string $sessionId, + ): IdToken { + if (!is_a($userEntity, ClaimSetInterface::class)) { + throw new RuntimeException('UserEntity must implement ClaimSetInterface'); + } + + $client = $accessToken->getClient(); + if (! $client instanceof ClientEntity) { + throw new RuntimeException('Client is expected to be instance of ' . ClientEntity::class); + } + + $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); + + // ID Token signing algorithm that the client wants. + $clientIdTokenSignedResponseAlg = $client->getIdTokenSignedResponseAlg(); + + if (is_string($clientIdTokenSignedResponseAlg)) { + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstByAlgorithmOrFail( + SignatureAlgorithmEnum::from($clientIdTokenSignedResponseAlg), + ); + } + + $currentTimestamp = $this->core->helpers()->dateTime()->getUtc()->getTimestamp(); + + $payload = array_filter([ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => $this->core->helpers()->random()->string(), + ClaimsEnum::Aud->value => $client->getIdentifier(), + ClaimsEnum::Nbf->value => $currentTimestamp, + ClaimsEnum::Exp->value => $accessToken->getExpiryDateTime()->getTimestamp(), + ClaimsEnum::Sub->value => $this->core->helpers()->type()->ensureNonEmptyString( + $userEntity->getIdentifier(), + ), + ClaimsEnum::Nonce->value => $nonce, + ClaimsEnum::AuthTime->value => $authTime, + ClaimsEnum::ATHash->value => $addAccessTokenHash ? + $this->generateAccessTokenHash( + $accessToken, + $protocolSignatureKeyPair->getSignatureAlgorithm()->value, + ) : + null, + ClaimsEnum::Acr->value => $acr, + ClaimsEnum::Sid->value => $sessionId, + ]); + + // Reduce the number of claims by provided scope. + $claims = $this->claimExtractor->extract( + $accessToken->getScopes(), + $userEntity->getClaims(), + ); + $requestedClaims = $accessToken->getRequestedClaims(); + $additionalClaims = $this->claimExtractor->extractAdditionalIdTokenClaims( + $requestedClaims, + $userEntity->getClaims(), + ); + $claims = array_merge($additionalClaims, $claims); + + foreach ($claims as $claimName => $claimValue) { + if ( + is_string($claimName) && + $claimName !== '' && + ($addClaimsFromScopes || array_key_exists($claimName, $additionalClaims)) + ) { + $payload[$claimName] = $claimValue; + } + } + + $header = [ + ClaimsEnum::Kid->value => $protocolSignatureKeyPair->getKeyPair()->getKeyId(), + ]; + + return $this->core->idTokenFactory()->fromData( + $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(), + $protocolSignatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + ); + } + /** * @throws \Exception * @psalm-suppress ArgumentTypeCoercion + * @deprecated Since v7 + * @see self::buildFor() */ public function build( UserEntityInterface $userEntity, diff --git a/src/Services/LogoutTokenBuilder.php b/src/Services/LogoutTokenBuilder.php index d561dfc5..8d5b0975 100644 --- a/src/Services/LogoutTokenBuilder.php +++ b/src/Services/LogoutTokenBuilder.php @@ -4,14 +4,28 @@ namespace SimpleSAML\Module\oidc\Services; +use SimpleSAML\Module\oidc\Factories\CoreFactory; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; +use SimpleSAML\OpenID\Core; use stdClass; class LogoutTokenBuilder { + protected Core $core; + public function __construct( - protected JsonWebTokenBuilderService $jsonWebTokenBuilderService = new JsonWebTokenBuilderService(), + protected ModuleConfig $moduleConfig = new ModuleConfig(), + protected LoggerService $loggerService = new LoggerService(), + ?CoreFactory $coreFactory = null, ) { + $this->core = ($coreFactory ?? new CoreFactory( + $this->moduleConfig, + $this->loggerService, + ))->build(); } /** @@ -21,18 +35,42 @@ public function __construct( */ public function forRelyingPartyAssociation(RelyingPartyAssociationInterface $relyingPartyAssociation): string { - $logoutTokenBuilder = $this->jsonWebTokenBuilderService - ->getProtocolJwtBuilder() - ->withHeader('typ', 'logout+jwt') - ->permittedFor($relyingPartyAssociation->getClientId()) - ->relatedTo($relyingPartyAssociation->getUserId()) - ->withClaim('events', ['http://schemas.openid.net/event/backchannel-logout' => new stdClass()]) - ; - - if ($relyingPartyAssociation->getSessionId() !== null) { - $logoutTokenBuilder = $logoutTokenBuilder->withClaim('sid', $relyingPartyAssociation->getSessionId()); + $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); + + // ID Token signing algorithm that the client wants. As per spec, the + // same algorithm should be used for Logout Token. + if (is_string($idTokenSignedResponseAlg = $relyingPartyAssociation->getClientIdTokenSignedResponseAlg())) { + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstByAlgorithmOrFail( + SignatureAlgorithmEnum::from($idTokenSignedResponseAlg), + ); } - return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($logoutTokenBuilder)->toString(); + $currentTimestamp = $this->core->helpers()->dateTime()->getUtc()->getTimestamp(); + + $payload = array_filter([ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Exp->value => $this->core->helpers()->dateTime()->getUtc()->add( + $this->moduleConfig->getAccessTokenDuration(), + )->getTimestamp(), + ClaimsEnum::Jti->value => $this->core->helpers()->random()->string(), + ClaimsEnum::Aud->value => $relyingPartyAssociation->getClientId(), + ClaimsEnum::Sub->value => $relyingPartyAssociation->getUserId(), + ClaimsEnum::Events->value => ['http://schemas.openid.net/event/backchannel-logout' => new stdClass()], + ClaimsEnum::Sid->value => $relyingPartyAssociation->getSessionId(), + ]); + + $header = [ + ClaimsEnum::Kid->value => $protocolSignatureKeyPair->getKeyPair()->getKeyId(), + ClaimsEnum::Typ->value => JwtTypesEnum::LogoutJwt->value, + ]; + + return $this->core->logoutTokenFactory()->fromData( + $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(), + $protocolSignatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + )->getToken(); } } From a5d9c9bc7f1fdbae2856e926abb3ff251483e5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 13 Jan 2026 10:46:08 +0100 Subject: [PATCH 07/17] WIP --- src/Controllers/EndSessionController.php | 14 +- .../ResponseTypes/TokenResponseTest.php | 106 ++++------- .../src/Services/LogoutTokenBuilderTest.php | 171 ++++++++++++------ 3 files changed, 160 insertions(+), 131 deletions(-) diff --git a/src/Controllers/EndSessionController.php b/src/Controllers/EndSessionController.php index 63797a74..e48f25cd 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -65,7 +65,9 @@ public function __invoke(ServerRequestInterface $request): Response (string)$idTokenHint->claims()->get('sid'); } - $this->loggerService->debug('EndSession: ID Token Hint Session ID: ' . $sidClaim ?? 'N/A'); + $this->loggerService->debug( + 'EndSession: ID Token Hint Session ID: ' . var_export($sidClaim, true), + ); // Check if RP is requesting logout for session that previously existed (not this current session). // Claim 'sid' from 'id_token_hint' logout parameter indicates for which session should log out be @@ -119,7 +121,9 @@ public function __invoke(ServerRequestInterface $request): Response $this->sessionService->getCurrentSession()->doLogout($authSourceId); } } else { - $this->loggerService->debug('Current session authorities not found for ID: ' . $sidClaim); + $this->loggerService->debug( + 'Current session authorities not found for ID: ' . var_export($sidClaim, true), + ); } $this->loggerService->debug('Was logout action called: ' . var_export($wasLogoutActionCalled, true)); @@ -213,12 +217,12 @@ protected function resolveResponse(LogoutRequest $logoutRequest, bool $wasLogout 'Logout request includes post-logout redirect URI: ' . $postLogoutRedirectUri, ); - if ($logoutRequest->getState() !== null) { + if (($logoutState = $logoutRequest->getState()) !== null) { $this->loggerService->debug( - 'Appending logout request state: ' . $logoutRequest->getState(), + 'Appending logout request state: ' . $logoutState, ); $postLogoutRedirectUri .= (!str_contains($postLogoutRedirectUri, '?')) ? '?' : '&'; - $postLogoutRedirectUri .= http_build_query(['state' => $logoutRequest->getState()]); + $postLogoutRedirectUri .= http_build_query(['state' => $logoutState]); } else { $this->loggerService->debug( 'No state provided for post logout', diff --git a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index c389b565..99b3ddac 100644 --- a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -7,18 +7,7 @@ use DateTimeImmutable; use Exception; use Laminas\Diactoros\Response; -use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Encoding\JoseEncoder; -use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Token\Parser; -use Lcobucci\JWT\Validation\Constraint\IdentifiedBy; -use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\PermittedFor; -use Lcobucci\JWT\Validation\Constraint\RelatedTo; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use Lcobucci\JWT\Validation\Validator; use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -36,6 +25,12 @@ use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse @@ -61,6 +56,11 @@ class TokenResponseTest extends TestCase protected IdTokenBuilder $idTokenBuilder; protected Stub $claimSetEntityFactoryStub; protected MockObject $loggerMock; + protected MockObject $coreMock; + protected MockObject $protocolSignatureKeyPairBagMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $idTokenMock; + protected MockObject $signatureKeyPairMock; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -117,12 +117,31 @@ protected function setUp(): void $this->claimSetEntityFactoryStub = $this->createStub(ClaimSetEntityFactory::class); + $this->idTokenFactoryMock = $this->createMock(IdTokenFactory::class); + + $this->coreMock = $this->createMock(Core::class); + $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); + $this->idTokenBuilder = new IdTokenBuilder( new JsonWebTokenBuilderService($this->moduleConfigMock), new ClaimTranslatorExtractor(self::USER_ID_ATTR, $this->claimSetEntityFactoryStub), + $this->coreMock, + $this->moduleConfigMock, ); $this->loggerMock = $this->createMock(LoggerService::class); + + $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + $this->protocolSignatureKeyPairBagMock->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); + + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); + + $this->idTokenMock = $this->createMock(IdToken::class); } protected function prepareMockedInstance(): TokenResponse @@ -157,6 +176,11 @@ public function testItCanGenerateResponse(): void { $this->accessTokenEntityMock->method('getRequestedClaims')->willReturn([]); $this->accessTokenEntityMock->method('getScopes')->willReturn($this->scopes); + $this->idTokenFactoryMock->method('fromData') + ->willReturn($this->idTokenMock); + $this->idTokenMock->expects($this->once()) + ->method('getToken') + ->willReturn('token'); $idTokenResponse = $this->prepareMockedInstance(); $idTokenResponse->setAccessToken($this->accessTokenEntityMock); $response = $idTokenResponse->generateHttpResponse(new Response()); @@ -191,6 +215,11 @@ public function testItCanGenerateResponseWithIndividualRequestedClaims(): void $this->accessTokenEntityMock->method('getScopes')->willReturn( [new ScopeEntity('openid')], ); + $this->idTokenFactoryMock->method('fromData') + ->willReturn($this->idTokenMock); + $this->idTokenMock->expects($this->once()) + ->method('getToken') + ->willReturn('token'); $idTokenResponse->setAccessToken($this->accessTokenEntityMock); $response = $idTokenResponse->generateHttpResponse(new Response()); @@ -234,61 +263,6 @@ protected function shouldHaveValidIdToken(string $body, $expectedClaims = []): b ); } - // Check ID token - $validator = new Validator(); - /** @var Plain $token */ - $token = (new Parser(new JoseEncoder()))->parse($result['id_token']); - - $validator->assert( - $token, - new IdentifiedBy(self::TOKEN_ID), - new IssuedBy(self::ISSUER), - new PermittedFor(self::CLIENT_ID), - new RelatedTo(self::SUBJECT), - new StrictValidAt(SystemClock::fromUTC()), - new SignedWith( - new Sha256(), - InMemory::plainText(file_get_contents($this->certFolder . '/oidc_module.crt')), - ), - ); - - if ($token->headers()->get('kid') !== self::KEY_ID) { - throw new Exception( - 'Wrong key id. Expected ' . self::KEY_ID . ' was ' . $token->headers()->get('kid'), - ); - } - $expectedClaimsKeys = array_keys($expectedClaims); - $expectedClaimsKeys = ['iss', 'iat', 'jti', 'aud', 'nbf', 'exp', 'sub', 'at_hash', ...$expectedClaimsKeys]; - $claims = array_keys($token->claims()->all()); - if ($claims !== $expectedClaimsKeys) { - throw new Exception( - 'missing expected claim. Got ' . var_export($claims, true) - . ' need ' . var_export($expectedClaimsKeys, true), - ); - } - foreach ($expectedClaims as $claim => $value) { - $valFromToken = $token->claims()->get($claim); - if ($value !== $valFromToken) { - throw new Exception( - 'Expected claim value ' . var_export($value, true) - . ' got ' . var_export($valFromToken, true), - ); - } - } - - $dateWithNoMicroseconds = ['nbf', 'exp', 'iat']; - foreach ($dateWithNoMicroseconds as $key) { - /** - * @var DateTimeImmutable $val - */ - $val = $token->claims()->get($key); - //Get format representing microseconds - $val = $val->format('u'); - if ($val !== '000000') { - throw new Exception("Value for '$key' has microseconds. micros '$val'"); - } - } - return true; } } diff --git a/tests/unit/src/Services/LogoutTokenBuilderTest.php b/tests/unit/src/Services/LogoutTokenBuilderTest.php index fe7b9218..62840cb0 100644 --- a/tests/unit/src/Services/LogoutTokenBuilderTest.php +++ b/tests/unit/src/Services/LogoutTokenBuilderTest.php @@ -4,19 +4,21 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\PermittedFor; -use Lcobucci\JWT\Validation\Constraint\RelatedTo; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use PHPUnit\Framework\MockObject\Stub; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\oidc\Factories\CoreFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\LogoutTokenBuilder; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\LogoutTokenFactory; +use SimpleSAML\OpenID\Jwk\JwkDecorator; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Services\LogoutTokenBuilder @@ -38,21 +40,19 @@ class LogoutTokenBuilderTest extends TestCase /** * @var mixed */ - private Stub $moduleConfigStub; + private MockObject $moduleConfigMock; /** * @var mixed */ - private Stub $relyingPartyAssociationStub; - private JsonWebTokenBuilderService $jsonWebTokenBuilderService; + private MockObject $relyingPartyAssociationMock; + private MockObject $loggerServiceMock; + private MockObject $coreFactoryMock; + private MockObject $protocolSignatureKeyPairBagMock; + private MockObject $signatureKeyPairMock; + private MockObject $coreMock; + private MockObject $logoutTokenFactoryMock; - public static function setUpBeforeClass(): void - { - self::$certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; - self::$privateKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - self::$publicKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME; - self::$signerSha256 = new Sha256(); - } /** * @throws \ReflectionException @@ -61,59 +61,110 @@ public static function setUpBeforeClass(): void */ public function setUp(): void { - $this->moduleConfigStub = $this->createStub(ModuleConfig::class); - $this->moduleConfigStub->method('getProtocolSigner')->willReturn(self::$signerSha256); - $this->moduleConfigStub->method('getProtocolPrivateKeyPath')->willReturn(self::$privateKeyPath); - $this->moduleConfigStub->method('getProtocolCertPath')->willReturn(self::$publicKeyPath); - $this->moduleConfigStub->method('getIssuer')->willReturn(self::$selfUrlHost); - - $this->relyingPartyAssociationStub = $this->createStub(RelyingPartyAssociationInterface::class); - $this->relyingPartyAssociationStub->method('getClientId')->willReturn(self::$clientId); - $this->relyingPartyAssociationStub->method('getUserId')->willReturn(self::$userId); - $this->relyingPartyAssociationStub->method('getSessionId')->willReturn(self::$sessionId); - $this->relyingPartyAssociationStub + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->relyingPartyAssociationMock = $this->createMock(RelyingPartyAssociationInterface::class); + $this->relyingPartyAssociationMock->method('getClientId')->willReturn(self::$clientId); + $this->relyingPartyAssociationMock->method('getUserId')->willReturn(self::$userId); + $this->relyingPartyAssociationMock->method('getSessionId')->willReturn(self::$sessionId); + $this->relyingPartyAssociationMock ->method('getBackChannelLogoutUri') ->willReturn(self::$backChannelLogoutUri); - $this->jsonWebTokenBuilderService = new JsonWebTokenBuilderService($this->moduleConfigStub); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + + $this->coreFactoryMock = $this->createMock(CoreFactory::class); + + $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->coreMock = $this->createMock(Core::class); + $this->coreFactoryMock->method('build')->willReturn($this->coreMock); + + $this->logoutTokenFactoryMock = $this->createMock(LogoutTokenFactory::class); + + $this->coreMock->method('logoutTokenFactory')->willReturn($this->logoutTokenFactoryMock); + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?LoggerService $loggerService = null, + ?CoreFactory $coreFactory = null, + ): LogoutTokenBuilder { + $moduleConfig ??= $this->moduleConfigMock; + $loggerService ??= $this->loggerServiceMock; + $coreFactory ??= $this->coreFactoryMock; + + return new LogoutTokenBuilder( + $moduleConfig, + $loggerService, + $coreFactory, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(LogoutTokenBuilder::class, $this->sut()); } /** * @throws \ReflectionException * @throws \Exception */ - public function testCanGenerateSignedTokenForRelyingPartyAssociation(): void + public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void { - $logoutTokenBuilder = new LogoutTokenBuilder($this->jsonWebTokenBuilderService); - - $token = $logoutTokenBuilder->forRelyingPartyAssociation($this->relyingPartyAssociationStub); - - // Check token validity - $jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file( - $this->moduleConfigStub->getProtocolPrivateKeyPath(), - $this->moduleConfigStub->getProtocolPrivateKeyPassPhrase() ?? '', - ), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ); + $this->moduleConfigMock->expects($this->once()) + ->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); + + $this->protocolSignatureKeyPairBagMock->expects($this->once()) + ->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); + + $this->moduleConfigMock->expects($this->once()) + ->method('getIssuer') + ->willReturn('issuerId'); + + $this->logoutTokenFactoryMock->expects($this->once()) + ->method('fromData') + ->with( + $this->isInstanceOf(JwkDecorator::class), + $this->isInstanceOf(SignatureAlgorithmEnum::class), + $this->arrayHasKey(ClaimsEnum::Iss->value), + $this->arrayHasKey(ClaimsEnum::Kid->value), + ); + + $this->sut()->forRelyingPartyAssociation($this->relyingPartyAssociationMock); + } - $parsedToken = $jwtConfig->parser()->parse($token); - - $this->assertTrue( - $jwtConfig->validator()->validate( - $parsedToken, - new IssuedBy(self::$selfUrlHost), - new PermittedFor(self::$clientId), - new RelatedTo(self::$userId), - new SignedWith( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ), - ), - ); + public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): void + { + $this->moduleConfigMock->expects($this->once()) + ->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); + + $this->protocolSignatureKeyPairBagMock->expects($this->once()) + ->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); + + $this->relyingPartyAssociationMock->expects($this->once()) + ->method('getClientIdTokenSignedResponseAlg') + ->willReturn('ES256'); - $this->assertTrue($parsedToken->headers()->has('typ')); - $this->assertSame($parsedToken->headers()->get('typ'), self::$logoutTokenType); + $negotiatedSignatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $negotiatedSignatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::ES256); + + $this->protocolSignatureKeyPairBagMock->expects($this->once()) + ->method('getFirstByAlgorithmOrFail') + ->with(SignatureAlgorithmEnum::ES256) + ->willReturn($negotiatedSignatureKeyPairMock); + + $this->sut()->forRelyingPartyAssociation( + $this->relyingPartyAssociationMock, + ); } } From 63c41ebe27ef14f86f14e86de0df23c03326ad40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 13 Jan 2026 13:31:30 +0100 Subject: [PATCH 08/17] WIP --- config/module_oidc.php.dist | 1 - docs/6-oidc-upgrade.md | 12 ++- src/Controllers/EndSessionController.php | 7 +- src/Factories/RequestRulesManagerFactory.php | 8 +- src/Server/AuthorizationServer.php | 2 +- .../RequestRules/Rules/IdTokenHintRule.php | 56 +++++------- .../Rules/PostLogoutRedirectUriRule.php | 16 +--- src/Server/RequestTypes/LogoutRequest.php | 8 +- src/Services/Container.php | 8 +- src/Services/LogoutTokenBuilder.php | 2 +- templates/config/federation.twig | 22 +++-- templates/config/protocol.twig | 23 +++-- .../Controllers/EndSessionControllerTest.php | 19 ++-- tests/unit/src/ModuleConfigTest.php | 6 -- .../Rules/IdTokenHintRuleTest.php | 89 +++++++++---------- .../Rules/PostLogoutRedirectUriRuleTest.php | 27 +++--- .../Server/RequestTypes/LogoutRequestTest.php | 4 +- 17 files changed, 145 insertions(+), 165 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 1b43c5d2..24e9fa80 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -47,7 +47,6 @@ $config = [ */ // (optional) The private key passphrase. /** @deprecated */ -// ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret', // The certificate and private key filenames, with given defaults. /** @deprecated */ ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 213e590f..f7cd2a1b 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -7,6 +7,9 @@ apply those relevant to your deployment. New features: +- Instance can now be configured to support multiple protocol (Connect) and +Federation signing algorithms and key pairs. This was introduced in order to +support signature algorithm negotiation with the clients. - Clients can now be configured with new properties: - ID Token Signing Algorithm (id_token_signed_response_alg) - Initial support for OpenID for Verifiable Credential Issuance @@ -15,11 +18,16 @@ it in production. New configuration options: -- Several new options are available in module config file regarding support for -OpenID4VCI. +- ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS - enables defining multiple +protocol (Connect) related signing algorithms and key pairs. +- ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS - enables defining +multiple Federation related signing algorithms and key pairs. +- Several new options regarding experimental support for OpenID4VCI. Major impact changes: +- The following configuration options are removed: + - - In v6 of the module, when defining custom scopes, there was a possibility to use standard claims with the 'are_multiple_claim_values_allowed' option. This would allow multiple values (array of values) for standard claims which diff --git a/src/Controllers/EndSessionController.php b/src/Controllers/EndSessionController.php index e48f25cd..ebd2c202 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -60,9 +61,9 @@ public function __invoke(ServerRequestInterface $request): Response // If id_token_hint was provided, resolve session ID $idTokenHint = $logoutRequest->getIdTokenHint(); if ($idTokenHint !== null) { - $sidClaim = empty($idTokenHint->claims()->get('sid')) ? - null : - (string)$idTokenHint->claims()->get('sid'); + /** @psalm-suppress MixedAssignment */ + $sidClaim = $idTokenHint->getPayloadClaim(ClaimsEnum::Sid->value); + $sidClaim = is_string($sidClaim) && $sidClaim !== '' ? $sidClaim : null; } $this->loggerService->debug( diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 27de4e3c..8e865d41 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -44,7 +44,9 @@ use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Jwks; class RequestRulesManagerFactory { @@ -57,7 +59,6 @@ public function __construct( private readonly ScopeRepository $scopeRepository, private readonly CodeChallengeVerifiersRepository $codeChallengeVerifiersRepository, private readonly ClaimTranslatorExtractor $claimTranslatorExtractor, - private readonly CryptKeyFactory $cryptKeyFactory, private readonly RequestParamsResolver $requestParamsResolver, private readonly ClientEntityFactory $clientEntityFactory, private readonly Federation $federation, @@ -65,6 +66,8 @@ public function __construct( private readonly JwksResolver $jwksResolver, private readonly FederationParticipationValidator $federationParticipationValidator, private readonly SspBridge $sspBridge, + private readonly Jwks $jwks, + private readonly Core $core, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, ) { @@ -132,7 +135,8 @@ private function getDefaultRules(): array $this->requestParamsResolver, $this->helpers, $this->moduleConfig, - $this->cryptKeyFactory, + $this->jwks, + $this->core, ), new PostLogoutRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->clientRepository), new UiLocalesRule($this->requestParamsResolver, $this->helpers), diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 0c0367b4..f0cc0585 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -185,7 +185,7 @@ public function validateLogoutRequest(ServerRequestInterface $request): LogoutRe throw new BadRequest($reason); } - /** @var \Lcobucci\JWT\UnencryptedToken|null $idTokenHint */ + /** @var \SimpleSAML\OpenID\Core\IdToken|null $idTokenHint */ $idTokenHint = $resultBag->getOrFail(IdTokenHintRule::class)->getValue(); /** @var string|null $postLogoutRedirectUri */ $postLogoutRedirectUri = $resultBag->getOrFail(PostLogoutRedirectUriRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/IdTokenHintRule.php b/src/Server/RequestRules/Rules/IdTokenHintRule.php index c1160b01..8feccbf2 100644 --- a/src/Server/RequestRules/Rules/IdTokenHintRule.php +++ b/src/Server/RequestRules/Rules/IdTokenHintRule.php @@ -4,12 +4,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\SignedWith; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Factories\CryptKeyFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -20,15 +15,17 @@ use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; -use Throwable; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Jwks; class IdTokenHintRule extends AbstractRule { public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, - protected ModuleConfig $moduleConfig, - protected CryptKeyFactory $cryptKeyFactory, + protected readonly ModuleConfig $moduleConfig, + protected readonly Jwks $jwks, + protected readonly Core $core, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -58,16 +55,6 @@ public function checkRule( return new Result($this->getKey(), $idTokenHintParam); } - // TODO v7 mivanci Fix: unmockable services... inject instead. - $privateKey = $this->cryptKeyFactory->buildPrivateKey(); - $publicKey = $this->cryptKeyFactory->buildPublicKey(); - /** @psalm-suppress ArgumentTypeCoercion */ - $jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfig->getProtocolSigner(), - InMemory::plainText($privateKey->getKeyContents(), $privateKey->getPassPhrase() ?? ''), - InMemory::plainText($publicKey->getKeyContents()), - ); - if (empty($idTokenHintParam)) { throw OidcServerException::invalidRequest( ParamsEnum::IdTokenHint->value, @@ -78,23 +65,25 @@ public function checkRule( ); } - try { - /** @var \Lcobucci\JWT\UnencryptedToken $idTokenHint */ - $idTokenHint = $jwtConfig->parser()->parse($idTokenHintParam); + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + + $idTokenHint = $this->core->idTokenFactory()->fromToken($idTokenHintParam); - /** @psalm-suppress ArgumentTypeCoercion */ - $jwtConfig->validator()->assert( - $idTokenHint, - new IssuedBy($this->moduleConfig->getIssuer()), - // Note: although logout spec does not mention it, validating signature seems like an important check - // to make. However, checking the signature in a key roll-over scenario will fail for ID tokens - // signed with previous key... - new SignedWith( - $this->moduleConfig->getProtocolSigner(), - InMemory::plainText($publicKey->getKeyContents()), - ), + if ($idTokenHint->getIssuer() !== $this->moduleConfig->getIssuer()) { + throw OidcServerException::invalidRequest( + ParamsEnum::IdTokenHint->value, + 'Invalid ID Token Hint Issuer', + null, + null, + $state, ); - } catch (Throwable $exception) { + } + + try { + $idTokenHint->verifyWithKeySet($jwks); + } catch (\Throwable $exception) { throw OidcServerException::invalidRequest( ParamsEnum::IdTokenHint->value, $exception->getMessage(), @@ -104,6 +93,7 @@ public function checkRule( ); } + return new Result($this->getKey(), $idTokenHint); } } diff --git a/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php b/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php index d27dace8..258d8186 100644 --- a/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php @@ -41,7 +41,7 @@ public function checkRule( /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); - /** @var \Lcobucci\JWT\UnencryptedToken|null $idTokenHint */ + /** @var \SimpleSAML\OpenID\Core\IdToken|null $idTokenHint */ $idTokenHint = $currentResultBag->getOrFail(IdTokenHintRule::class)->getValue(); $postLogoutRedirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( @@ -61,19 +61,7 @@ public function checkRule( throw OidcServerException::invalidRequest('id_token_hint', $hint); } - $claims = $idTokenHint->claims()->all(); - - if (empty($claims['aud'])) { - throw OidcServerException::invalidRequest( - ParamsEnum::IdTokenHint->value, - 'aud claim not present', - null, - null, - $state, - ); - } - /** @var string[] $auds */ - $auds = is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $auds = $idTokenHint->getAudience(); $isPostLogoutRedirectUriRegistered = false; foreach ($auds as $aud) { diff --git a/src/Server/RequestTypes/LogoutRequest.php b/src/Server/RequestTypes/LogoutRequest.php index ab825bd1..140a5861 100644 --- a/src/Server/RequestTypes/LogoutRequest.php +++ b/src/Server/RequestTypes/LogoutRequest.php @@ -4,7 +4,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestTypes; -use Lcobucci\JWT\UnencryptedToken; +use SimpleSAML\OpenID\Core\IdToken; class LogoutRequest { @@ -14,7 +14,7 @@ public function __construct( * current authenticated session with the Client. This is used as an indication of the identity of the * End-User that the RP is requesting be logged out by the OP. */ - protected ?UnencryptedToken $idTokenHint = null, + protected ?IdToken $idTokenHint = null, /** * URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been * performed. The value MUST have been previously registered with the OP.An id_token_hint is also @@ -35,12 +35,12 @@ public function __construct( ) { } - public function getIdTokenHint(): ?UnencryptedToken + public function getIdTokenHint(): ?IdToken { return $this->idTokenHint; } - public function setIdTokenHint(?UnencryptedToken $idTokenHint): LogoutRequest + public function setIdTokenHint(?IdToken $idTokenHint): LogoutRequest { $this->idTokenHint = $idTokenHint; return $this; diff --git a/src/Services/Container.php b/src/Services/Container.php index f4c491dc..f4b02758 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -406,7 +406,13 @@ public function __construct() new AddClaimsToIdTokenRule($requestParamsResolver, $helpers), new RequiredNonceRule($requestParamsResolver, $helpers), new ResponseTypeRule($requestParamsResolver, $helpers), - new IdTokenHintRule($requestParamsResolver, $helpers, $moduleConfig, $cryptKeyFactory), + new IdTokenHintRule( + $requestParamsResolver, + $helpers, + $moduleConfig, + $jwks, + $core, + ), new PostLogoutRedirectUriRule($requestParamsResolver, $helpers, $clientRepository), new UiLocalesRule($requestParamsResolver, $helpers), new AcrValuesRule($requestParamsResolver, $helpers), diff --git a/src/Services/LogoutTokenBuilder.php b/src/Services/LogoutTokenBuilder.php index 8d5b0975..230ad882 100644 --- a/src/Services/LogoutTokenBuilder.php +++ b/src/Services/LogoutTokenBuilder.php @@ -52,7 +52,7 @@ public function forRelyingPartyAssociation(RelyingPartyAssociationInterface $rel ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iat->value => $currentTimestamp, ClaimsEnum::Exp->value => $this->core->helpers()->dateTime()->getUtc()->add( - $this->moduleConfig->getAccessTokenDuration(), + $this->moduleConfig->getAuthCodeDuration(), )->getTimestamp(), ClaimsEnum::Jti->value => $this->core->helpers()->random()->string(), ClaimsEnum::Aud->value => $relyingPartyAssociation->getClientId(), diff --git a/templates/config/federation.twig b/templates/config/federation.twig index 0474898c..a49e9472 100644 --- a/templates/config/federation.twig +++ b/templates/config/federation.twig @@ -54,18 +54,16 @@ {{ moduleConfig.getFederationEntityStatementDuration|date("%mm %dd %hh %i' %s''") }}

-

{{ 'PKI'|trans }}

-

- {{ 'Private Key'|trans }}: {{ moduleConfig.getFederationPrivateKeyPath }} -
- {{ 'Private Key Password Set'|trans }}: - {{ moduleConfig.getFederationPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} -
- {{ 'Public Key'|trans }}: {{ moduleConfig.getFederationCertPath }} -

-

- {{ 'Signing Algorithm'|trans }}: {{ moduleConfig.getFederationSigner.algorithmId }} -

+

{{ 'Signature algorithms and public keys'|trans }}

+ + {% for signatureKeyPair in moduleConfig.getFederationSignatureKeyPairBag.getAll %} +

+ - {{ 'Algorithm'|trans }}: {{ signatureKeyPair.getSignatureAlgorithm.value }} + + {{- signatureKeyPair.getKeyPair.getPublicKey.jsonSerialize|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+ {% endfor %}

{{ 'Trust Anchors'|trans }}

{% if moduleConfig.getFederationTrustAnchors is not empty %} diff --git a/templates/config/protocol.twig b/templates/config/protocol.twig index 0d5c256f..081f755b 100644 --- a/templates/config/protocol.twig +++ b/templates/config/protocol.twig @@ -28,18 +28,17 @@ {{ moduleConfig.getRefreshTokenDuration|date("%mm %dd %hh %i' %s''") }}

-

{{ 'PKI'|trans }}

-

- {{ 'Private Key'|trans }}: {{ moduleConfig.getProtocolPrivateKeyPath }} -
- {{ 'Private Key Password Set'|trans }}: - {{ moduleConfig.getProtocolPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} -
- {{ 'Public Key'|trans }}: {{ moduleConfig.getProtocolCertPath }} -

-

- {{ 'Signing Algorithm'|trans }}: {{ moduleConfig.getProtocolSigner.algorithmId }} -

+

{{ 'Signature algorithms and public keys'|trans }}

+ + {% for signatureKeyPair in moduleConfig.getProtocolSignatureKeyPairBag.getAll %} +

+ - {{ 'Algorithm'|trans }}: {{ signatureKeyPair.getSignatureAlgorithm.value }} + + {{- signatureKeyPair.getKeyPair.getPublicKey.jsonSerialize|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+ {% endfor %} +

{{ 'Authentication'|trans }}

diff --git a/tests/unit/src/Controllers/EndSessionControllerTest.php b/tests/unit/src/Controllers/EndSessionControllerTest.php index 1d62f7fd..4cac5640 100644 --- a/tests/unit/src/Controllers/EndSessionControllerTest.php +++ b/tests/unit/src/Controllers/EndSessionControllerTest.php @@ -6,8 +6,6 @@ use Exception; use Laminas\Diactoros\ServerRequest; -use Lcobucci\JWT\Token\DataSet; -use Lcobucci\JWT\UnencryptedToken; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -22,6 +20,8 @@ use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core\IdToken; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; @@ -41,7 +41,7 @@ class EndSessionControllerTest extends TestCase protected Stub $dataSetStub; protected MockObject $currentSessionMock; protected MockObject $sessionMock; - protected DataSet $dataSet; + protected array $dataSet = ['sid' => '123']; protected Stub $loggerServiceStub; protected Stub $sessionLogoutTicketStoreDbStub; protected MockObject $loggerServiceMock; @@ -61,8 +61,7 @@ public function setUp(): void $this->currentSessionMock = $this->createMock(Session::class); $this->sessionMock = $this->createMock(Session::class); $this->logoutRequestStub = $this->createStub(LogoutRequest::class); - $this->idTokenHintStub = $this->createStub(UnencryptedToken::class); - $this->dataSet = new DataSet(['sid' => '123'], ''); + $this->idTokenHintStub = $this->createStub(IdToken::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); $this->sessionLogoutTicketStoreDbStub = $this->createStub(LogoutTicketStoreDb::class); $this->templateFactoryStub = $this->createStub(TemplateFactory::class); @@ -118,7 +117,10 @@ public function testCallLogoutForSessionIdInIdTokenHint(): void $this->sessionServiceStub->method('getCurrentSession')->willReturn($this->currentSessionMock); $this->sessionMock->method('getAuthorities')->willReturn(['authId1', 'authId2']); $this->sessionServiceStub->method('getSessionById')->willReturn($this->sessionMock); - $this->idTokenHintStub->method('claims')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayload')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayloadClaim') + ->with(ClaimsEnum::Sid->value) + ->willReturn('123'); $this->logoutRequestStub->method('getIdTokenHint')->willReturn($this->idTokenHintStub); $this->authorizationServerStub->method('validateLogoutRequest')->willReturn($this->logoutRequestStub); $this->sessionLogoutTicketStoreBuilderStub->method('getInstance') @@ -143,7 +145,10 @@ public function testLogsIfSessionFromIdTokenHintNotFound(): void $this->sessionServiceStub->method('getCurrentSession')->willReturn($this->currentSessionMock); $this->sessionMock->method('getAuthorities')->willReturn(['authId1', 'authId2']); $this->sessionServiceStub->method('getSessionById')->willThrowException(new Exception()); - $this->idTokenHintStub->method('claims')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayload')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayloadClaim') + ->with(ClaimsEnum::Sid->value) + ->willReturn('123'); $this->logoutRequestStub->method('getIdTokenHint')->willReturn($this->idTokenHintStub); $this->authorizationServerStub->method('validateLogoutRequest')->willReturn($this->logoutRequestStub); $this->sessionLogoutTicketStoreBuilderStub->method('getInstance') diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 94b84f8d..d9b92ca5 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -177,12 +177,6 @@ public function testCanGetProtocolSigner(): void $this->assertInstanceOf(Signer::class, $this->sut()->getProtocolSigner()); } - public function testCanGetProtocolPrivateKeyPassphrase(): void - { - $this->overrides[ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE] = 'test'; - $this->assertNotEmpty($this->sut()->getProtocolPrivateKeyPassPhrase()); - } - public function testCanGetAuthProcFilters(): void { $this->assertIsArray($this->sut()->getAuthProcFilters()); diff --git a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php index bee541f9..05b77cce 100644 --- a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php @@ -4,15 +4,12 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\UnencryptedToken; use League\OAuth2\Server\CryptKey; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Factories\CryptKeyFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; @@ -20,6 +17,10 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\Jwks; use Throwable; /** @@ -30,7 +31,6 @@ class IdTokenHintRuleTest extends TestCase protected Stub $requestStub; protected Stub $resultBagStub; protected Stub $moduleConfigStub; - protected Stub $cryptKeyFactoryStub; protected static string $certFolder; protected static string $privateKeyPath; @@ -39,20 +39,14 @@ class IdTokenHintRuleTest extends TestCase protected static CryptKey $publicKey; protected static string $issuer = 'https://example.org'; - private Configuration $jwtConfig; protected Stub $loggerServiceStub; protected Stub $requestParamsResolverStub; protected Helpers $helpers; - - public static function setUpBeforeClass(): void - { - self::$certFolder = dirname(__DIR__, 6) . '/docker/ssp/'; - self::$privateKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - self::$publicKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME; - self::$privateKey = new CryptKey(self::$privateKeyPath, null, false); - self::$publicKey = new CryptKey(self::$publicKeyPath, null, false); - } + protected MockObject $jwksMock; + protected MockObject $coreMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $idTokenMock; /** * @throws \ReflectionException @@ -68,39 +62,38 @@ protected function setUp(): void $this->moduleConfigStub->method('getProtocolSigner')->willReturn(new Sha256()); $this->moduleConfigStub->method('getIssuer')->willReturn(self::$issuer); - $this->cryptKeyFactoryStub = $this->createStub(CryptKeyFactory::class); - $this->cryptKeyFactoryStub->method('buildPrivateKey')->willReturn(self::$privateKey); - $this->cryptKeyFactoryStub->method('buildPublicKey')->willReturn(self::$publicKey); - - $this->jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::plainText(self::$privateKey->getKeyContents()), - InMemory::plainText(self::$publicKey->getKeyContents()), - ); - $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpers = new Helpers(); + + $this->jwksMock = $this->createMock(Jwks::class); + $this->coreMock = $this->createMock(Core::class); + $this->idTokenFactoryMock = $this->createMock(IdTokenFactory::class); + $this->idTokenMock = $this->createMock(IdToken::class); + $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); } protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, ?ModuleConfig $moduleConfig = null, - ?CryptKeyFactory $cryptKeyFactory = null, + ?Jwks $jwks = null, + ?Core $core = null, ): IdTokenHintRule { $requestParamsResolver ??= $this->requestParamsResolverStub; $helpers ??= $this->helpers; $moduleConfig ??= $this->moduleConfigStub; - $cryptKeyFactory ??= $this->cryptKeyFactoryStub; + $jwks ??= $this->jwksMock; + $core ??= $this->coreMock; return new IdTokenHintRule( $requestParamsResolver, $helpers, $moduleConfig, - $cryptKeyFactory, + $jwks, + $core, ); } @@ -139,14 +132,14 @@ public function testCheckRuleThrowsForMalformedIdToken(): void */ public function testCheckRuleThrowsForIdTokenWithInvalidSignature(): void { - $invalidSignatureJwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnIiwic3ViIjo' . - 'iMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.JGJ_KSiXiRsgVc5nYFTSqbaeeM3UA5DGnOTaz3' . - 'UqbyHM0ogO3rq_-8FwLRzGk-7942U6rQ1ARziLsYYsUtH7yaUTWi6xSvh_mJQuF8hl_X3OghJWeXWms42OjAkHXtB-H7LQ_bEQNV' . - 'nF8XYLsq06MoHeHxAnDkVpVcZyDrmhauAqV1PTWsC9FMMKaxfoVsIbeQ0-PV_gAgzSK5-T0bliXPUdWFjvPXJ775jqqy4ZyNJYh' . - '1_rZ1WyOrJu7AHkT9R4FNQNCw40BRzfI3S_OYBNirKAh5G0sctNwEEaJL_a3lEwKYSC-d_sZ6WBvFP8B138b7T6nPzI71tvfXE' . - 'Ru7Q7rA'; - - $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn($invalidSignatureJwt); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') + ->willReturn('invalid-it-token'); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenMock->method('verifyWithKeySet') + ->willThrowException(new \Exception('invalid-signature')); + $this->idTokenFactoryMock->method('fromToken') + ->with('invalid-it-token') + ->willReturn($this->idTokenMock); $this->expectException(Throwable::class); $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub); } @@ -158,12 +151,13 @@ public function testCheckRuleThrowsForIdTokenWithInvalidSignature(): void public function testCheckRuleThrowsForIdTokenWithInvalidIssuer(): void { $this->requestStub->method('getMethod')->willReturn('GET'); + $this->idTokenMock->method('getIssuer')->willReturn('invalid'); + $this->idTokenFactoryMock->method('fromToken') + ->with('id-token') + ->willReturn($this->idTokenMock); - $invalidIssuerJwt = $this->jwtConfig->builder()->issuedBy('invalid')->getToken( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::plainText(self::$privateKey->getKeyContents()), - )->toString(); - $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn($invalidIssuerJwt); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') + ->willReturn('id-token'); $this->expectException(Throwable::class); $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub); } @@ -175,15 +169,14 @@ public function testCheckRuleThrowsForIdTokenWithInvalidIssuer(): void */ public function testCheckRulePassesForValidIdToken(): void { - $idToken = $this->jwtConfig->builder()->issuedBy(self::$issuer)->getToken( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::plainText(self::$privateKey->getKeyContents()), - )->toString(); - - $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn($idToken); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') + ->willReturn('id-token'); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenFactoryMock->method('fromToken') + ->willReturn($this->idTokenMock); $result = $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub) ?? new Result(IdTokenHintRule::class); - $this->assertInstanceOf(UnencryptedToken::class, $result->getValue()); + $this->assertInstanceOf(IdToken::class, $result->getValue()); } } diff --git a/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php index b14a3a57..7bf7d279 100644 --- a/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php @@ -8,6 +8,7 @@ use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use League\OAuth2\Server\CryptKey; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -22,6 +23,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core\IdToken; use Throwable; /** @@ -47,6 +49,7 @@ class PostLogoutRedirectUriRuleTest extends TestCase protected Stub $loggerServiceStub; protected Stub $requestParamsResolverStub; protected Helpers $helpers; + protected MockObject $idTokenMock; public static function setUpBeforeClass(): void { @@ -78,6 +81,8 @@ protected function setUp(): void $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpers = new Helpers(); + + $this->idTokenMock = $this->createMock(IdToken::class); } protected function sut( @@ -183,13 +188,8 @@ public function testCheckRuleThrowsWhenPostLogoutRegisteredUriNotRegistered(): v $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') ->willReturn(self::$postLogoutRedirectUri); - $jwt = $this->jwtConfig->builder() - ->issuedBy(self::$issuer) - ->permittedFor('client-id') - ->getToken( - new Sha256(), - InMemory::plainText(self::$privateKey->getKeyContents()), - ); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenMock->method('getAudience')->willReturn(['client-id']); $this->clientStub->method('getPostLogoutRedirectUri')->willReturn([ 'https://some-other-uri', @@ -199,7 +199,7 @@ public function testCheckRuleThrowsWhenPostLogoutRegisteredUriNotRegistered(): v $this->resultBagStub->method('getOrFail')->willReturnOnConsecutiveCalls( new Result(StateRule::class), - new Result(IdTokenHintRule::class, $jwt), + new Result(IdTokenHintRule::class, $this->idTokenMock), ); $this->expectException(Throwable::class); @@ -217,13 +217,8 @@ public function testCheckRuleReturnsForRegisteredPostLogoutRedirectUri(): void $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') ->willReturn(self::$postLogoutRedirectUri); - $jwt = $this->jwtConfig->builder() - ->issuedBy(self::$issuer) - ->permittedFor('client-id') - ->getToken( - new Sha256(), - InMemory::plainText(self::$privateKey->getKeyContents()), - ); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenMock->method('getAudience')->willReturn(['client-id']); $this->clientStub->method('getPostLogoutRedirectUri')->willReturn([ self::$postLogoutRedirectUri, @@ -233,7 +228,7 @@ public function testCheckRuleReturnsForRegisteredPostLogoutRedirectUri(): void $this->resultBagStub->method('getOrFail')->willReturnOnConsecutiveCalls( new Result(StateRule::class), - new Result(IdTokenHintRule::class, $jwt), + new Result(IdTokenHintRule::class, $this->idTokenMock), ); $result = $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub) ?? diff --git a/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php b/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php index 93fd8283..9f5edd34 100644 --- a/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php +++ b/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php @@ -4,10 +4,10 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestTypes; -use Lcobucci\JWT\UnencryptedToken; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; +use SimpleSAML\OpenID\Core\IdToken; /** * @covers \SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest @@ -25,7 +25,7 @@ class LogoutRequestTest extends TestCase */ protected function setUp(): void { - $this->idTokenHintStub = $this->createStub(UnencryptedToken::class); + $this->idTokenHintStub = $this->createStub(IdToken::class); } public function testConstructWithoutParams(): void From f82f65e0cef693594c393eb5c3d8c2aecaa89d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 13 Jan 2026 14:09:50 +0100 Subject: [PATCH 09/17] WIP --- conformance-tests/conformance-rp-initiated-logout-ci.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conformance-tests/conformance-rp-initiated-logout-ci.json b/conformance-tests/conformance-rp-initiated-logout-ci.json index 34cc99a9..24d3ef54 100644 --- a/conformance-tests/conformance-rp-initiated-logout-ci.json +++ b/conformance-tests/conformance-rp-initiated-logout-ci.json @@ -556,7 +556,7 @@ "xpath", "//*", 10, - "The JWT string is missing the Signature part", + "The algorithm \"none\" is not supported.", "update-image-placeholder" ] ] @@ -620,7 +620,7 @@ "xpath", "//*", 10, - "The token was not issued by the given issuers", + "Issuer claim", "update-image-placeholder" ] ] From 2479e9213aa5dbde6a6cbb37ee4fb52f737dd030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 14 Jan 2026 14:30:25 +0100 Subject: [PATCH 10/17] WIP --- config/module_oidc.php.dist | 15 +- docs/6-oidc-upgrade.md | 11 +- routing/services/services.yml | 18 +- src/Controllers/UserInfoController.php | 2 +- .../CredentialIssuerCredentialController.php | 2 +- src/Entities/AccessTokenEntity.php | 62 ++--- src/Factories/CryptKeyFactory.php | 39 ++- .../Entities/AccessTokenEntityFactory.php | 12 +- src/Factories/FederationFactory.php | 1 + src/Factories/JwsFactory.php | 28 +++ src/Factories/ResourceServerFactory.php | 41 ---- src/ModuleConfig.php | 72 +++++- src/Server/ResourceServer.php | 24 ++ .../Validators/BearerTokenValidator.php | 103 ++------ src/Services/Container.php | 27 ++- .../Controllers/UserInfoControllerTest.php | 2 +- .../src/Entities/AccessTokenEntityTest.php | 45 ++-- .../Validators/BearerTokenValidatorTest.php | 226 +++++++----------- 18 files changed, 366 insertions(+), 364 deletions(-) create mode 100644 src/Factories/JwsFactory.php delete mode 100644 src/Factories/ResourceServerFactory.php create mode 100644 src/Server/ResourceServer.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 24e9fa80..6e2f05d4 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -133,12 +133,25 @@ $config = [ /** * Token related options. */ - // Authorization code and tokens TTL (validity duration), with given examples. For duration format info, check + // Authorization code and tokens TTL (validity duration), with given examples. + // For duration format info, check // https://www.php.net/manual/en/dateinterval.construct.php ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', // 10 minutes ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', // 1 month ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', // 1 hour, + /** + * (optional) Timestamp Validation Leeway - additional time tolerance + * allowed for timestamp validation. This is used when validating + * timestamps like Expiration Time (exp), Issued At (iat), Not + * Before (nbf), and similar claims on JWS artifacts. + * If not set, falls back to 'PT1M' (1 minute). + * + * For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php + */ + ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', + /** * Authentication related options. */ diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index f7cd2a1b..5857c0d9 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -18,9 +18,14 @@ it in production. New configuration options: -- ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS - enables defining multiple -protocol (Connect) related signing algorithms and key pairs. -- ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS - enables defining +- ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS - (required) enables defining +multiple protocol (Connect) related signing algorithms and key pairs. +- ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS - (required if federation +capabilities are enabled) enables defining multiple key pairs for +Federation purposes like signing Entity Statements, publishing new key for +key roll-ower scenarios, etc. +- ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY - optional, used for setting +allowed time tolerance for timestamp validation in artifacts like JWSs. multiple Federation related signing algorithms and key pairs. - Several new options regarding experimental support for OpenID4VCI. diff --git a/routing/services/services.yml b/routing/services/services.yml index b3f284b6..8548e461 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -75,30 +75,20 @@ services: class: League\OAuth2\Server\CryptKey factory: ['@SimpleSAML\Module\oidc\Factories\CryptKeyFactory', 'buildPublicKey'] - SimpleSAML\Module\oidc\Factories\ResourceServerFactory: - arguments: - $publicKey: '@oidc.key.public' SimpleSAML\Module\oidc\Factories\AuthorizationServerFactory: arguments: $privateKey: '@oidc.key.private' SimpleSAML\Module\oidc\Factories\TokenResponseFactory: arguments: $privateKey: '@oidc.key.private' - SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory: - arguments: - $privateKey: '@oidc.key.private' - SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator: - arguments: - $publicKey: '@oidc.key.public' + SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory: ~ + SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator: ~ + SimpleSAML\Module\oidc\Server\ResourceServer: ~ SimpleSAML\Module\oidc\Server\AuthorizationServer: factory: ['@SimpleSAML\Module\oidc\Factories\AuthorizationServerFactory', 'build'] - # OAuth2 Server - League\OAuth2\Server\ResourceServer: - factory: ['@SimpleSAML\Module\oidc\Factories\ResourceServerFactory', 'build'] - # Utils SimpleSAML\Module\oidc\Utils\Debug\ArrayLogger: ~ SimpleSAML\Module\oidc\Utils\FederationParticipationValidator: ~ @@ -134,6 +124,8 @@ services: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] SimpleSAML\OpenID\Jwk: ~ SimpleSAML\OpenID\Did: ~ + SimpleSAML\OpenID\Jws: + factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ] # SSP diff --git a/src/Controllers/UserInfoController.php b/src/Controllers/UserInfoController.php index 982b0eac..f228047a 100644 --- a/src/Controllers/UserInfoController.php +++ b/src/Controllers/UserInfoController.php @@ -18,7 +18,6 @@ use Laminas\Diactoros\Response\JsonResponse; use League\OAuth2\Server\Exception\OAuthServerException; -use League\OAuth2\Server\ResourceServer; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error; @@ -29,6 +28,7 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index ec0f5391..c5a97ab0 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -5,7 +5,6 @@ namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; use Base64Url\Base64Url; -use League\OAuth2\Server\ResourceServer; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; @@ -14,6 +13,7 @@ use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index 2fef7aaa..9b46904c 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -17,9 +17,6 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Token; -use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; @@ -29,7 +26,10 @@ use SimpleSAML\Module\oidc\Entities\Interfaces\EntityStringRepresentationInterface; use SimpleSAML\Module\oidc\Entities\Traits\AssociateWithAuthCodeTrait; use SimpleSAML\Module\oidc\Entities\Traits\RevokeTokenTrait; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\Jws\ParsedJws; use Stringable; /** @@ -63,13 +63,12 @@ public function __construct( OAuth2ClientEntityInterface $clientEntity, array $scopes, DateTimeImmutable $expiryDateTime, - CryptKey $privateKey, - protected JsonWebTokenBuilderService $jsonWebTokenBuilderService, + protected readonly Jws $jws, + protected readonly ModuleConfig $moduleConfig, int|string|null $userIdentifier = null, ?string $authCodeId = null, ?array $requestedClaims = null, ?bool $isRevoked = false, - ?Configuration $jwtConfiguration = null, protected readonly ?FlowTypeEnum $flowTypeEnum = null, protected readonly ?array $authorizationDetails = null, protected readonly ?string $boundClientId = null, @@ -82,14 +81,12 @@ public function __construct( $this->addScope($scope); } $this->setExpiryDateTime($expiryDateTime); - $this->setPrivateKey($privateKey); $this->setUserIdentifier($userIdentifier); $this->setAuthCodeId($authCodeId); $this->setRequestedClaims($requestedClaims ?? []); if ($isRevoked) { $this->revoke(); } - $jwtConfiguration !== null ? $this->jwtConfiguration = $jwtConfiguration : $this->initJwtConfiguration(); } /** @@ -137,7 +134,7 @@ public function getState(): array */ public function __toString(): string { - return $this->stringRepresentation = $this->convertToJWT()->toString(); + return $this->stringRepresentation = $this->convertToJWT()->getToken(); } /** @@ -150,29 +147,40 @@ public function toString(): ?string } /** - * Implemented instead of original AccessTokenTrait::convertToJWT() method in order to remove microseconds from - * timestamps and to add claims like iss, etc., by using our own JWT builder service. + * Implemented instead of original AccessTokenTrait::convertToJWT() method + * in order to remove microseconds from timestamps and to add claims + * like iss, etc. * - * @return \Lcobucci\JWT\Token * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \Exception */ - protected function convertToJWT(): Token + protected function convertToJWT(): ParsedJws { - /** @psalm-suppress ArgumentTypeCoercion */ - $jwtBuilder = $this->jsonWebTokenBuilderService->getProtocolJwtBuilder() - ->permittedFor($this->getClient()->getIdentifier()) - ->identifiedBy((string)$this->getIdentifier()) - ->issuedAt(new DateTimeImmutable()) - ->canOnlyBeUsedAfter(new DateTimeImmutable()) - ->expiresAt($this->getExpiryDateTime()) - ->relatedTo((string) $this->getUserIdentifier()) - ->withClaim('scopes', $this->getScopes()); - if ($this->issuerState !== null) { - $jwtBuilder = $jwtBuilder->withClaim('issuer_state', $this->issuerState); - } + $protocolSignatureKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairBag()->getFirstOrFail(); + $currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp(); + + $payload = array_filter([ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => (string)$this->getIdentifier(), + ClaimsEnum::Aud->value => $this->getClient()->getIdentifier(), + ClaimsEnum::Nbf->value => $currentTimestamp, + ClaimsEnum::Exp->value => $this->expiryDateTime->getTimestamp(), + ClaimsEnum::Sub->value => (string)$this->getUserIdentifier(), + 'scopes' => $this->getScopes(), + ClaimsEnum::IssuerState->value => $this->issuerState, + ]); + + $header = [ + ClaimsEnum::Kid->value => $protocolSignatureKeyPair->getKeyPair()->getKeyId(), + ]; - return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($jwtBuilder); + return $this->jws->parsedJwsFactory()->fromData( + $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(), + $protocolSignatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + ); } public function getFlowTypeEnum(): ?FlowTypeEnum diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index 908d464b..176334fd 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Factories; use League\OAuth2\Server\CryptKey; +use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\ModuleConfig; class CryptKeyFactory @@ -19,9 +20,14 @@ public function __construct( */ public function buildPrivateKey(): CryptKey { + $defaultSignatureKeyPairConfig = $this->getDefaultProtocolSignatureKeyPairConfig(); + + $privateKeyFilename = $defaultSignatureKeyPairConfig[ModuleConfig::KEY_PRIVATE_KEY_FILENAME]; + $privateKeyPassword = $defaultSignatureKeyPairConfig[ModuleConfig::KEY_PRIVATE_KEY_PASSWORD] ?? null; + return new CryptKey( - $this->moduleConfig->getProtocolPrivateKeyPath(), - $this->moduleConfig->getProtocolPrivateKeyPassPhrase(), + $privateKeyFilename, + $privateKeyPassword, true, ); } @@ -31,6 +37,33 @@ public function buildPrivateKey(): CryptKey */ public function buildPublicKey(): CryptKey { - return new CryptKey($this->moduleConfig->getProtocolCertPath(), null, false); + $defaultSignatureKeyPairConfig = $this->getDefaultProtocolSignatureKeyPairConfig(); + $publicKeyFilename = $defaultSignatureKeyPairConfig[ModuleConfig::KEY_PUBLIC_KEY_FILENAME]; + return new CryptKey($publicKeyFilename, null, false); + } + + /** + * @return array{ + * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, + * private_key_filename: non-empty-string, + * public_key_filename: non-empty-string, + * private_key_password: ?non-empty-string, + * key_id: ?non-empty-string + * } + * @throws ConfigurationError + * + */ + protected function getDefaultProtocolSignatureKeyPairConfig(): array + { + $defaultProtocolKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairs(); + + /** @psalm-suppress MixedAssignment */ + $defaultProtocolKeyPair = $defaultProtocolKeyPair[array_key_first($defaultProtocolKeyPair)]; + + if (!is_array($defaultProtocolKeyPair)) { + throw new ConfigurationError('Invalid protocol signature key pairs config.'); + } + + return $this->moduleConfig->getValidatedSignatureKeyPairArray($defaultProtocolKeyPair); } } diff --git a/src/Factories/Entities/AccessTokenEntityFactory.php b/src/Factories/Entities/AccessTokenEntityFactory.php index f0ba1037..266aee84 100644 --- a/src/Factories/Entities/AccessTokenEntityFactory.php +++ b/src/Factories/Entities/AccessTokenEntityFactory.php @@ -5,22 +5,22 @@ namespace SimpleSAML\Module\oidc\Factories\Entities; use DateTimeImmutable; -use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\OpenID\Jws; class AccessTokenEntityFactory { public function __construct( protected readonly Helpers $helpers, - protected readonly CryptKey $privateKey, - protected readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, protected readonly ScopeEntityFactory $scopeEntityFactory, + protected readonly Jws $jws, + protected readonly ModuleConfig $moduleConfig, ) { } @@ -47,8 +47,8 @@ public function fromData( $clientEntity, $scopes, $expiryDateTime, - $this->privateKey, - $this->jsonWebTokenBuilderService, + $this->jws, + $this->moduleConfig, $userIdentifier, $authCodeId, $requestedClaims, diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index d14a7534..65a4c610 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -27,6 +27,7 @@ public function build(): Federation return new Federation( supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), cache: $this->federationCache?->cache, logger: $this->loggerService, defaultTrustMarkStatusEndpointUsagePolicyEnum: diff --git a/src/Factories/JwsFactory.php b/src/Factories/JwsFactory.php new file mode 100644 index 00000000..3d543322 --- /dev/null +++ b/src/Factories/JwsFactory.php @@ -0,0 +1,28 @@ +moduleConfig->getSupportedAlgorithms(), + supportedSerializers: $this->moduleConfig->getSupportedSerializers(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), + logger: $this->loggerService, + ); + } +} diff --git a/src/Factories/ResourceServerFactory.php b/src/Factories/ResourceServerFactory.php deleted file mode 100644 index 12245c9d..00000000 --- a/src/Factories/ResourceServerFactory.php +++ /dev/null @@ -1,41 +0,0 @@ -accessTokenRepository, - $this->publicKey, - $this->authorizationValidator, - ); - } -} diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 22e168e7..82ffabc0 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -29,7 +29,10 @@ use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ScopesEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\Serializers\JwsSerializerBag; +use SimpleSAML\OpenID\Serializers\JwsSerializerEnum; use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\SupportedSerializers; use SimpleSAML\OpenID\ValueAbstracts; use SimpleSAML\OpenID\ValueAbstracts\KeyPairFilenameConfig; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; @@ -130,6 +133,7 @@ class ModuleConfig 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; final public const OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; final public const OPTION_FEDERATION_SIGNATURE_KEY_PAIRS = 'federation_signature_key_pairs'; + final public const OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -161,6 +165,7 @@ class ModuleConfig */ private readonly Configuration $sspConfig; protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $protocolSignatureKeyPairConfigBag = null; protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; /** @@ -378,27 +383,59 @@ public function getSupportedAlgorithms(): SupportedAlgorithms ); } + public function getSupportedSerializers(): SupportedSerializers + { + return new SupportedSerializers( + new JwsSerializerBag( + JwsSerializerEnum::Compact, + ), + ); + } + /** - * @throws \SimpleSAML\Error\ConfigurationError - * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + * @throws ConfigurationError + * @return non-empty-array */ - public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag + public function getProtocolSignatureKeyPairs(): array { - if ($this->protocolSignatureKeyPairBag instanceof SignatureKeyPairBag) { - return $this->protocolSignatureKeyPairBag; - } $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS); if (empty($signatureKeyPairs)) { - throw new ConfigurationError('At least one protocol signature key-pair pair should be provided.'); + throw new ConfigurationError('At least one protocol signature key-pair pair must be provided.'); } - $signatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag($signatureKeyPairs); + return $signatureKeyPairs; + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getProtocolSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag + { + if ($this->protocolSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { + return $this->protocolSignatureKeyPairConfigBag; + } + + return $this->protocolSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( + $this->getProtocolSignatureKeyPairs(), + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag + { + if ($this->protocolSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->protocolSignatureKeyPairBag; + } return $this->protocolSignatureKeyPairBag = $this->valueAbstracts ->signatureKeyPairBagFactory() - ->fromConfig($signatureKeyPairConfigBag); + ->fromConfig($this->getProtocolSignatureKeyPairConfigBag()); } /** @@ -1188,7 +1225,6 @@ public function getDefaultUsersEmailAttributeName(): string } /** - * @throws ConfigurationError * @return array{ * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, * private_key_filename: non-empty-string, @@ -1196,9 +1232,9 @@ public function getDefaultUsersEmailAttributeName(): string * private_key_password: ?non-empty-string, * key_id: ?non-empty-string * } - * + * @throws ConfigurationError * */ - protected function getValidateSignatureKeyPairArray(mixed $signatureKeyPair): array + public function getValidatedSignatureKeyPairArray(mixed $signatureKeyPair): array { if (!is_array($signatureKeyPair)) { throw new ConfigurationError( @@ -1313,7 +1349,7 @@ protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): Signa self::KEY_PUBLIC_KEY_FILENAME => $publicKeyFilename, self::KEY_PRIVATE_KEY_PASSWORD => $privateKeyPassword, self::KEY_KEY_ID => $keyId, - ] = $this->getValidateSignatureKeyPairArray($signatureKeyPair); + ] = $this->getValidatedSignatureKeyPairArray($signatureKeyPair); $signatureKeyPairConfigBag->add(new SignatureKeyPairConfig( $algorithm, @@ -1328,4 +1364,14 @@ protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): Signa return $signatureKeyPairConfigBag; } + + public function getTimestampValidationLeeway(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString( + self::OPTION_TIMESTAMP_VALIDATION_LEEWAY, + 'PT1M', + ), + ); + } } diff --git a/src/Server/ResourceServer.php b/src/Server/ResourceServer.php new file mode 100644 index 00000000..e1b18d44 --- /dev/null +++ b/src/Server/ResourceServer.php @@ -0,0 +1,24 @@ +bearerTokenValidator->validateAuthorization($request); + } +} diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index b1afbd23..35ee4d8f 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -4,90 +4,36 @@ namespace SimpleSAML\Module\oidc\Server\Validators; -use DateInterval; -use DateTimeZone; -use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use Lcobucci\JWT\Validation\RequiredConstraintsViolated; -use League\OAuth2\Server\AuthorizationValidators\BearerTokenValidator as OAuth2BearerTokenValidator; -use League\OAuth2\Server\CryptKey; -use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; -use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface as OAuth2AccessTokenRepositoryInterface; +use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\OpenID\Exceptions\JwsException; +use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jws; use function count; -use function date_default_timezone_get; use function is_array; use function preg_replace; use function trim; -class BearerTokenValidator extends OAuth2BearerTokenValidator +class BearerTokenValidator implements AuthorizationValidatorInterface { - /** @var \Lcobucci\JWT\Configuration */ - protected Configuration $jwtConfiguration; - - /** @var \League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface */ - protected OAuth2AccessTokenRepositoryInterface $accessTokenRepository; - - /** @var \League\OAuth2\Server\CryptKey */ - protected $publicKey; - - /** - * @throws \Exception - */ public function __construct( - AccessTokenRepositoryInterface $accessTokenRepository, - CryptKey $publicKey, + protected readonly AccessTokenRepository $accessTokenRepository, protected readonly ModuleConfig $moduleConfig, - ?DateInterval $jwtValidAtDateLeeway = null, - protected LoggerService $loggerService = new LoggerService(), + protected readonly Jws $jws, + protected readonly Jwks $jwks, + protected readonly LoggerService $loggerService, ) { - parent::__construct($accessTokenRepository, $jwtValidAtDateLeeway); - $this->accessTokenRepository = $accessTokenRepository; - $this->setPublicKey($publicKey); - } - - /** - * Set the public key - * - * @param \League\OAuth2\Server\CryptKey $key - * @throws \Exception - */ - public function setPublicKey(CryptKey $key): void - { - $this->publicKey = $key; - - $this->initJwtConfiguration(); - } - - /** - * Initialise the JWT configuration. - * @throws \Exception - */ - protected function initJwtConfiguration(): void - { - /** @psalm-suppress ArgumentTypeCoercion */ - $this->jwtConfiguration = Configuration::forSymmetricSigner( - $this->moduleConfig->getProtocolSigner(), - InMemory::plainText('empty', 'empty'), - )->withValidationConstraints( - new StrictValidAt(new SystemClock(new DateTimeZone(date_default_timezone_get()))), - new SignedWith( - $this->moduleConfig->getProtocolSigner(), - InMemory::plainText($this->publicKey->getKeyContents(), $this->publicKey->getPassPhrase() ?? ''), - ), - ); } /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function validateAuthorization(ServerRequestInterface $request): ServerRequestInterface { @@ -119,7 +65,7 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe $this->loggerService->warning( 'Apache stripping of Authorization Bearer request header encountered. You should modify your' . ' Apache configuration to preserve to Authorization Bearer token in requests to avoid performance ' . - 'implications. Check the OIDC module README file on how to do that.', + 'implications. Check the OIDC module documentation on how to do that.', ); $jwt = $accessToken; } @@ -130,23 +76,22 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe try { // Attempt to parse the JWT - /** @var \Lcobucci\JWT\Token\Plain $token */ - $token = $this->jwtConfiguration->parser()->parse($jwt); - } catch (\Lcobucci\JWT\Exception $exception) { + $token = $this->jws->parsedJwsFactory()->fromToken($jwt); + } catch (JwsException $exception) { throw OidcServerException::accessDenied($exception->getMessage(), null, $exception); } try { // Attempt to validate the JWT - $constraints = $this->jwtConfiguration->validationConstraints(); - $this->jwtConfiguration->validator()->assert($token, ...$constraints); - } catch (RequiredConstraintsViolated) { + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + $token->verifyWithKeySet($jwks); + } catch (JwsException) { throw OidcServerException::accessDenied('Access token could not be verified'); } - $claims = $token->claims(); - - if (is_null($jti = $claims->get('jti')) || empty($jti) || !is_string($jti)) { + if (is_null($jti = $token->getJwtId()) || empty($jti)) { throw OidcServerException::accessDenied('Access token malformed (jti missing or unexpected type)'); } @@ -158,9 +103,9 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe // Return the request with additional attributes return $request ->withAttribute('oauth_access_token_id', $jti) - ->withAttribute('oauth_client_id', $this->convertSingleRecordAudToString($claims->get('aud'))) - ->withAttribute('oauth_user_id', $claims->get('sub')) - ->withAttribute('oauth_scopes', $claims->get('scopes')); + ->withAttribute('oauth_client_id', $this->convertSingleRecordAudToString($token->getAudience())) + ->withAttribute('oauth_user_id', $token->getSubject()) + ->withAttribute('oauth_scopes', $token->getPayloadClaim('scopes')); } protected function getTokenFromAuthorizationBearer(string $authorizationHeader): string @@ -190,6 +135,6 @@ protected function convertSingleRecordAudToString(mixed $aud): array|string } } - throw OidcServerException::accessDenied('Unexpected sub claim value.'); + throw OidcServerException::accessDenied('Unexpected aud claim value.'); } } diff --git a/src/Services/Container.php b/src/Services/Container.php index f4b02758..44ee6cdf 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -20,7 +20,6 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\StreamFactory; use Laminas\Diactoros\UploadedFileFactory; -use League\OAuth2\Server\ResourceServer; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseFactoryInterface; @@ -55,8 +54,8 @@ use SimpleSAML\Module\oidc\Factories\Grant\PreAuthCodeGrantFactory; use SimpleSAML\Module\oidc\Factories\Grant\RefreshTokenGrantFactory; use SimpleSAML\Module\oidc\Factories\JwksFactory; +use SimpleSAML\Module\oidc\Factories\JwsFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; -use SimpleSAML\Module\oidc\Factories\ResourceServerFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Factories\TokenResponseFactory; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; @@ -98,6 +97,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; +use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; @@ -114,6 +114,7 @@ use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jws; use SimpleSAML\Session; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -282,17 +283,23 @@ public function __construct() $cryptKeyFactory = new CryptKeyFactory($moduleConfig); - $publicKey = $cryptKeyFactory->buildPublicKey(); $privateKey = $cryptKeyFactory->buildPrivateKey(); $jsonWebTokenBuilderService = new JsonWebTokenBuilderService($moduleConfig); $this->services[JsonWebTokenBuilderService::class] = $jsonWebTokenBuilderService; + $jwsFactory = new JwsFactory($moduleConfig, $loggerService); + $this->services[JwsFactory::class] = $jwsFactory; + + $jws = $jwsFactory->build(); + $this->services[Jws::class] = $jws; + + $accessTokenEntityFactory = new AccessTokenEntityFactory( $helpers, - $privateKey, - $jsonWebTokenBuilderService, $scopeEntityFactory, + $jws, + $moduleConfig, ); $this->services[AccessTokenEntityFactory::class] = $accessTokenEntityFactory; @@ -531,17 +538,17 @@ public function __construct() $bearerTokenValidator = new BearerTokenValidator( $accessTokenRepository, - $publicKey, $moduleConfig, + $jws, + $jwks, + $loggerService, ); $this->services[BearerTokenValidator::class] = $bearerTokenValidator; - $resourceServerFactory = new ResourceServerFactory( - $accessTokenRepository, - $publicKey, + $resourceServer = new ResourceServer( $bearerTokenValidator, ); - $this->services[ResourceServer::class] = $resourceServerFactory->build(); + $this->services[ResourceServer::class] = $resourceServer; $httpFoundationFactory = new HttpFoundationFactory(); $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; diff --git a/tests/unit/src/Controllers/UserInfoControllerTest.php b/tests/unit/src/Controllers/UserInfoControllerTest.php index 2b1a0e5c..53b9fcd5 100644 --- a/tests/unit/src/Controllers/UserInfoControllerTest.php +++ b/tests/unit/src/Controllers/UserInfoControllerTest.php @@ -5,7 +5,6 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; use Laminas\Diactoros\ServerRequest; -use League\OAuth2\Server\ResourceServer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -18,6 +17,7 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; diff --git a/tests/unit/src/Entities/AccessTokenEntityTest.php b/tests/unit/src/Entities/AccessTokenEntityTest.php index b7d6d1c1..d7e37417 100644 --- a/tests/unit/src/Entities/AccessTokenEntityTest.php +++ b/tests/unit/src/Entities/AccessTokenEntityTest.php @@ -6,15 +6,16 @@ use DateTimeImmutable; use DateTimeZone; -use Lcobucci\JWT\UnencryptedToken; -use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Entities\AccessTokenEntity @@ -36,19 +37,17 @@ class AccessTokenEntityTest extends TestCase protected ScopeEntity $scopeEntityOpenId; - /** - * @var \SimpleSAML\Module\oidc\Entities\ScopeEntity - */ protected ScopeEntity $scopeEntityProfile; - protected CryptKey $privateKey; - protected MockObject $jsonWebTokenBuilderServiceMock; protected MockObject $unencryptedTokenMock; protected DateTimeImmutable $expiryDateTime; -// protected Stub $jwtConfigurationStub; + + protected MockObject $moduleConfigMock; + protected MockObject $jwsMock; + protected MockObject $signatureKeyPairMock; + protected MockObject $signatureKeyPairBagMock; /** * @throws \Exception - * @throws \JsonException */ protected function setUp(): void { @@ -70,15 +69,19 @@ protected function setUp(): void $this->expiryDateTime = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) ->add(new \DateInterval('PT1M')); - $this->jsonWebTokenBuilderServiceMock = $this->createMock(JsonWebTokenBuilderService::class); - $this->unencryptedTokenMock = $this->createMock(UnencryptedToken::class); - $this->jsonWebTokenBuilderServiceMock->method('getSignedProtocolJwt') - ->willReturn($this->unencryptedTokenMock); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->jwsMock = $this->createMock(Jws::class); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairBagMock->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); - //$this->jwtConfigurationStub = $this->createStub(\Lcobucci\JWT\Configuration::class); // Final class :( - $certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; - $privateKeyPath = $certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - $this->privateKey = new CryptKey($privateKeyPath); + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->signatureKeyPairBagMock); } public function mock(): AccessTokenEntity @@ -88,13 +91,12 @@ public function mock(): AccessTokenEntity $this->clientEntityStub, $this->scopes, $this->expiryDateTime, - $this->privateKey, - $this->jsonWebTokenBuilderServiceMock, + $this->jwsMock, + $this->moduleConfigMock, $this->userId, $this->authCodeId, $this->requestedClaims, $this->isRevoked, - // $this->jwtConfigurationStub, ); } @@ -126,7 +128,6 @@ public function testHasProperState(): void */ public function testHasImmutableStringRepresentation(): void { - $this->unencryptedTokenMock->method('toString')->willReturn('token'); $instance = $this->mock(); $this->assertNull($instance->toString()); diff --git a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php index d24ed378..162cce45 100644 --- a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php +++ b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php @@ -6,44 +6,42 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\StreamFactory; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; -use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; - -use function chmod; +use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\OpenID\Exceptions\JwsException; +use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory; +use SimpleSAML\OpenID\Jws\ParsedJws; /** * @covers \SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator */ class BearerTokenValidatorTest extends TestCase { - protected BearerTokenValidator $bearerTokenValidator; - protected static string $privateKeyPath; - protected static CryptKey $privateCryptKey; - protected static ?string $privateKey = null; - protected static string $publicKey; - protected static CryptKey $publicCryptKey; - protected static string $publicKeyPath; protected MockObject $accessTokenRepositoryMock; - protected static array $accessTokenState; - protected static AccessTokenEntity $accessTokenEntity; - protected static string $accessToken; - protected static ClientEntityInterface $clientEntity; + protected array $accessTokenState; + protected AccessTokenEntity $accessTokenEntityMock; + protected string $accessToken; + protected ClientEntityInterface $clientEntityMock; protected ServerRequestInterface $serverRequest; protected MockObject $publicKeyMock; protected MockObject $moduleConfigMock; + protected MockObject $jwsMock; + protected MockObject $jwksMock; + protected MockObject $loggerServiceMock; + protected MockObject $parsedJwsFactoryMock; + protected MockObject $parsedJwsMock; + protected string $clientId; /** * @throws \Exception @@ -53,98 +51,64 @@ public function setUp(): void $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class); $this->serverRequest = new ServerRequest(); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->moduleConfigMock->method('getProtocolSigner')->willReturn(new Sha256()); - $this->bearerTokenValidator = new BearerTokenValidator( - $this->accessTokenRepositoryMock, - self::$publicCryptKey, - $this->moduleConfigMock, - ); - } - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException - */ - public static function setUpBeforeClass(): void - { - $tempDir = sys_get_temp_dir(); + $this->jwsMock = $this->createMock(Jws::class); + $this->jwksMock = $this->createMock(Jwks::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); - // Plant certdir config for JsonWebTokenBuilderService (since we don't inject it) - $config = [ - 'certdir' => $tempDir, - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - self::$publicKeyPath = $tempDir . '/oidc_module.crt'; - self::$privateKeyPath = $tempDir . '/oidc_module.key'; - - $pkGenerate = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - // get the private key - openssl_pkey_export($pkGenerate, self::$privateKey); - - // get the public key - self::$publicKey = openssl_pkey_get_details($pkGenerate)['key']; - - file_put_contents(self::$publicKeyPath, self::$publicKey); - file_put_contents(self::$privateKeyPath, self::$privateKey); - chmod(self::$publicKeyPath, 0600); - chmod(self::$privateKeyPath, 0600); - - self::$publicCryptKey = new CryptKey(self::$publicKeyPath); - self::$privateCryptKey = new CryptKey(self::$privateKeyPath); - - self::$clientEntity = new ClientEntity( - 'client1123', - 'secret1', - 'name1', - 'desc1', - ['redirect-uri'], - ['openid'], - true, - ); + $this->clientEntityMock = $this->createMock(ClientEntity::class); + $this->clientId = 'clientId'; + $this->clientEntityMock->method('getIdentifier')->willReturn($this->clientId); - self::$accessTokenState = [ + $this->accessTokenState = [ 'id' => 'accessToken123', 'scopes' => '{"openid":"openid","profile":"profile"}', 'expires_at' => date('Y-m-d H:i:s', time() + 60), 'user_id' => 'user123', - 'client_id' => self::$clientEntity->getIdentifier(), + 'client_id' => $this->clientId, 'is_revoked' => false, 'auth_code_id' => 'authCode123', ]; - self::$accessTokenEntity = new AccessTokenEntity( - 'accessToken123', - self::$clientEntity, - [new ScopeEntity('openid'), new ScopeEntity('profile')], - (new \DateTimeImmutable())->add(new \DateInterval('PT60S')), - self::$privateCryptKey, - new JsonWebTokenBuilderService(), - 'user123', - 'authCode123', - ); + $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); + + $this->accessToken = 'token'; + + $this->parsedJwsFactoryMock = $this->createMock(ParsedJwsFactory::class); + $this->jwsMock->method('parsedJwsFactory')->willReturn($this->parsedJwsFactoryMock); - self::$accessToken = (string) self::$accessTokenEntity; + $this->parsedJwsMock = $this->createMock(ParsedJws::class); + $this->parsedJwsMock->method('getJwtId')->willReturn('accessToken123'); + $this->parsedJwsMock->method('getAudience')->willReturn([$this->clientId]); } - /** - * @return void - */ - public static function tearDownAfterClass(): void - { - unlink(self::$publicKeyPath); - unlink(self::$privateKeyPath); + protected function sut( + ?AccessTokenRepository $accessTokenRepository = null, + ?ModuleConfig $moduleConfig = null, + ?Jws $jws = null, + ?Jwks $jwks = null, + ?LoggerService $loggerService = null, + ): BearerTokenValidator { + $accessTokenRepository ??= $this->accessTokenRepositoryMock; + $moduleConfig ??= $this->moduleConfigMock; + $jws ??= $this->jwsMock; + $jwks ??= $this->jwksMock; + $loggerService ??= $this->loggerServiceMock; + + return new BearerTokenValidator( + $accessTokenRepository, + $moduleConfig, + $jws, + $jwks, + $loggerService, + ); } public function testValidatorThrowsForNonExistentAccessToken() { $this->expectException(OidcServerException::class); - $this->bearerTokenValidator->validateAuthorization($this->serverRequest); + $this->sut()->validateAuthorization($this->serverRequest); } /** @@ -152,12 +116,16 @@ public function testValidatorThrowsForNonExistentAccessToken() */ public function testValidatesForAuthorizationHeader() { - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . self::$accessToken); + $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $this->accessToken); - $validatedServerRequest = $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($this->parsedJwsMock); + + $validatedServerRequest = $this->sut()->validateAuthorization($serverRequest); $this->assertSame( - self::$accessTokenState['id'], + $this->accessTokenState['id'], $validatedServerRequest->getAttribute('oauth_access_token_id'), ); } @@ -167,7 +135,7 @@ public function testValidatesForAuthorizationHeader() */ public function testValidatesForPostBodyParam() { - $bodyArray = ['access_token' => self::$accessToken]; + $bodyArray = ['access_token' => $this->accessToken]; $tempStream = (new StreamFactory())->createStream(http_build_query($bodyArray)); $serverRequest = $this->serverRequest @@ -176,10 +144,14 @@ public function testValidatesForPostBodyParam() ->withBody($tempStream) ->withParsedBody($bodyArray); - $validatedServerRequest = $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($this->parsedJwsMock); + + $validatedServerRequest = $this->sut()->validateAuthorization($serverRequest); $this->assertSame( - self::$accessTokenState['id'], + $this->accessTokenState['id'], $validatedServerRequest->getAttribute('oauth_access_token_id'), ); } @@ -188,35 +160,13 @@ public function testThrowsForUnparsableAccessToken() { $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . 'invalid'); - $this->expectException(OidcServerException::class); - - $this->bearerTokenValidator->validateAuthorization($serverRequest); - } - - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException - */ - public function testThrowsForExpiredAccessToken() - { - $accessTokenEntity = new AccessTokenEntity( - 'accessToken123', - self::$clientEntity, - [new ScopeEntity('openid'), new ScopeEntity('profile')], - (new \DateTimeImmutable())->sub(new \DateInterval('PT60S')), - self::$privateCryptKey, - new JsonWebTokenBuilderService(), - 'user123', - 'authCode123', - ); - - $accessToken = (string) $accessTokenEntity; - - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $accessToken); + $this->parsedJwsFactoryMock->method('fromToken') + ->with('invalid') + ->willThrowException(new JwsException('Unparsable')); $this->expectException(OidcServerException::class); - $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->sut()->validateAuthorization($serverRequest); } /** @@ -227,17 +177,15 @@ public function testThrowsForRevokedAccessToken() { $this->accessTokenRepositoryMock->method('isAccessTokenRevoked')->willReturn(true); - $bearerTokenValidator = new BearerTokenValidator( - $this->accessTokenRepositoryMock, - self::$publicCryptKey, - $this->moduleConfigMock, - ); + $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $this->accessToken); - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . self::$accessToken); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($this->parsedJwsMock); $this->expectException(OidcServerException::class); - $bearerTokenValidator->validateAuthorization($serverRequest); + $this->sut()->validateAuthorization($serverRequest); } /** @@ -246,23 +194,15 @@ public function testThrowsForRevokedAccessToken() */ public function testThrowsForEmptyAccessTokenJti() { - $accessTokenEntity = new AccessTokenEntity( - '', - self::$clientEntity, - [new ScopeEntity('openid'), new ScopeEntity('profile')], - (new \DateTimeImmutable())->add(new \DateInterval('PT60S')), - self::$privateCryptKey, - new JsonWebTokenBuilderService(), - 'user123', - 'authCode123', - ); - - $accessToken = (string) $accessTokenEntity; + $accessToken = $this->createMock(ParsedJws::class); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($accessToken); - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $accessToken); + $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $this->accessToken); $this->expectException(OidcServerException::class); - $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->sut()->validateAuthorization($serverRequest); } } From 27cab9b673183da1e06a54963ae3c10736a2242d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 14 Jan 2026 16:29:19 +0100 Subject: [PATCH 11/17] WIP --- config/module_oidc.php.dist | 570 ++++++++++-------- docker/ssp/module_oidc.php | 2 - docs/6-oidc-upgrade.md | 38 +- .../Federation/EntityStatementController.php | 109 ++-- ...redentialIssuerConfigurationController.php | 6 +- .../CredentialIssuerCredentialController.php | 22 +- src/Factories/JwksFactory.php | 11 +- .../VerifiableCredentialsFactory.php | 20 +- src/ModuleConfig.php | 172 ------ src/Services/Container.php | 4 - src/Services/IdTokenBuilder.php | 129 +--- src/Services/JsonWebTokenBuilderService.php | 163 ----- tests/config/module_oidc.php | 12 - .../EntityStatementControllerTest.php | 11 - tests/unit/src/ModuleConfigTest.php | 78 --- .../Rules/IdTokenHintRuleTest.php | 2 - .../ResponseTypes/TokenResponseTest.php | 11 - .../JsonWebTokenBuilderServiceTest.php | 120 ---- .../src/Services/OpMetadataServiceTest.php | 5 - 19 files changed, 419 insertions(+), 1066 deletions(-) delete mode 100644 src/Services/JsonWebTokenBuilderService.php delete mode 100644 tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 6e2f05d4..1e6daf16 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -27,10 +27,6 @@ use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; use SimpleSAML\OpenID\Codebooks\LanguageTagsEnum; -/* - * Note: In v5 of this module, all config keys have been moved to constants for easier handling and verification. - * However, all the key values have been preserved from previous module versions. - */ $config = [ /** * (optional) Issuer (OP) identifier which will be used as an issuer (iss) claim in tokens. If not set, it will @@ -41,37 +37,6 @@ $config = [ */ // ModuleConfig::OPTION_ISSUER => 'https://op.example.org', - /** - * PKI (public / private key) settings related to OIDC protocol. These keys will be used, for example, to - * sign ID Token JWT. - */ - // (optional) The private key passphrase. - /** @deprecated */ - // The certificate and private key filenames, with given defaults. - /** @deprecated */ - ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, - - - // Token signer, with given default. - // See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer - /** @deprecated */ - ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, -// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, -// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, - - /** - * (optional) Key rollover settings related to OIDC protocol. If set, this new private / public key pair will only - * be published on JWKS endpoint as available, so Relying Parties can pick them up for future use. The signing - * of artifacts will still be done using the 'current' private key (settings above). After some time, when all - * RPs have fetched all public keys from JWKS endpoint, simply set these new keys as active values for above - * PKI options. - */ -// // (optional) The (new) private key passphrase. - /** @deprecated */ -// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', -// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module.key', -// ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', /** * Protocol (Connect) signature algorithm and key-pair definitions, @@ -131,11 +96,10 @@ $config = [ ], /** - * Token related options. + * Authorization code and tokens TTL (validity duration), with given + * examples. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php */ - // Authorization code and tokens TTL (validity duration), with given examples. - // For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', // 10 minutes ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', // 1 month ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', // 1 hour, @@ -153,19 +117,24 @@ $config = [ ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', /** - * Authentication related options. + * The default authentication source to be used for authentication if the + * authentication source is not specified on particular client. */ - // The default authentication source to be used for authentication if the auth source is not specified on - // particular client. ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', - // The attribute name that contains the user identifier returned from IdP. By default, this attribute will be - // dynamically added to the 'sub' claim in the attribute-to-claim translation table (you will probably want - // to use this attribute as the 'sub' claim since it designates unique identifier for the user). + /** + * The attribute name that contains the user identifier returned from IdP. + * By default, this attribute will be dynamically added to the 'sub' + * claim in the attribute-to-claim translation table (you will probably want + * to use this attribute as the 'sub' claim since it designates unique + * identifier for the user). + */ ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', - // The default translate table from SAML attributes to OIDC claims. If you don't want to support specific default - // claim, set it to an empty array. + /** + * The default translate table from SAML attributes to OIDC claims. If you + * don't want to support specific default claim, set it to an empty array. + */ ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE => [ /* * The basic format is @@ -180,13 +149,15 @@ $config = [ * ] * ] * - * For convenience the default type is "string" so type does not need to be defined. - * If "attributes" is not set, then it is assumed that the rest of the values are saml - * attribute names. + * For convenience the default type is "string" so type does not need + * to be defined. If "attributes" is not set, then it is assumed that + * the rest of the values are saml attribute names. * - * Note on 'sub' claim: by default, the list of attributes for 'sub' claim will also contain attribute defined - * in 'useridattr' setting. You will probably want to use this attribute as the 'sub' claim since it - * designates unique identifier for the user, However, override as necessary. + * Note on 'sub' claim: by default, the list of attributes for 'sub' + * claim will also contain attribute defined in 'useridattr' setting. + * You will probably want to use this attribute as the 'sub' claim since + * it designates unique identifier for the user, However, override as + * necessary. */ // 'sub' => [ // 'attribute-defined-in-useridattr', // will be dynamically added if the list for 'sub' claim is not set. @@ -270,7 +241,10 @@ $config = [ // ], ], - // Optional custom scopes. You can create as many scopes as you want and assign claims to them. + /** + * Optional custom scopes. You can create as many scopes as you want and + * assign claims to them. + */ ModuleConfig::OPTION_AUTH_CUSTOM_SCOPES => [ // 'private' => [ // The key represents the scope name. // 'description' => 'private scope', @@ -280,13 +254,16 @@ $config = [ // ], ], - // Optional list of the Authentication Context Class References that this OP supports. - // If populated, this list will be available in OP discovery document (OP Metadata) as 'acr_values_supported'. - // @see https://datatracker.ietf.org/doc/html/rfc6711 - // @see https://www.iana.org/assignments/loa-profiles/loa-profiles.xhtml - // @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken (acr claim) - // @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values parameter) - // Syntax: string[] (array of strings) + /** + * Optional list of the Authentication Context Class References that this OP + * supports. If populated, this list will be available in OP discovery + * document (OP Metadata) as 'acr_values_supported'. + * @see https://datatracker.ietf.org/doc/html/rfc6711 + * @see https://www.iana.org/assignments/loa-profiles/loa-profiles.xhtml + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken (acr claim) + * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values parameter) + * Syntax: string[] (array of strings) + */ ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED => [ // 'https://refeds.org/assurance/profile/espresso', // 'https://refeds.org/assurance/profile/cappuccino', @@ -302,9 +279,13 @@ $config = [ // '...', ], - // If this OP supports ACRs, indicate which usable auth source supports which ACRs. - // Order of ACRs is important, more important ones being first. - // Syntax: array (array with auth source as key and value being array of ACR values as strings) + /** + * If this OP supports ACRs, indicate which usable auth source supports + * which ACRs. Order of ACRs is important, more important ones being first. + * Syntax: array (array with auth source as key and value + * being array of ACR values as strings) + */ + ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP => [ // 'example-userpass' => ['1', '0'], // 'default-sp' => ['http://id.incommon.org/assurance/bronze', '2', '1', '0'], @@ -320,54 +301,78 @@ $config = [ // ], ], - // If this OP supports ACRs, indicate if authentication using cookie should be forced to specific ACR value. - // If this option is set to null, no specific ACR will be forced for cookie authentication and the resulting ACR - // will be one of the ACRs supported on used auth source during authentication, that is, session creation. - // If this option is set to specific ACR, with ACR value being one of the ACR value this OP supports, it will be - // set to that ACR for cookie authentication. - // For example, OIDC Core Spec notes that authentication using a long-lived browser cookie is one example where - // the use of "level 0" is appropriate: + /** + * If this OP supports ACRs, indicate if authentication using cookie should + * be forced to specific ACR value. If this option is set to null, no + * specific ACR will be forced for cookie authentication and the resulting + * ACR will be one of the ACRs supported on used auth source during + * authentication, that is, session creation. If this option is set to + * specific ACR, with ACR value being one of the ACR value this OP supports, + * it will be set to that ACR for cookie authentication. + * For example, OIDC Core Spec notes that authentication using a long-lived + * browser cookie is one example where the use of "level 0" is appropriate: + */ // ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => '0', ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => null, - // Choose if OP discovery document will include 'claims_supported' claim, which is recommended per OpenID Connect - // Discovery specification https://openid.net/specs/openid-connect-discovery-1_0.html. The list will include all - // claims for which "SAML attribute to OIDC claim translation" has been defined above. + /** + * Choose if OP discovery document will include 'claims_supported' claim, + * which is recommended per OpenID Connect Discovery specification + * https://openid.net/specs/openid-connect-discovery-1_0.html. The list will + * include all claims for which "SAML attribute to OIDC claim translation" + * has been defined above. + */ ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => false, - // Settings regarding Authentication Processing Filters. - // Note: OIDC authN state array will not contain all the keys which are available during SAML authN, - // like Service Provider metadata, etc. - // - // At the moment, the following SAML authN data will be available during OIDC authN in the sate array: - // - ['Attributes'], ['Authority'], ['AuthnInstant'], ['Expire'] - // Source and destination will have entity IDs corresponding to the OP issuer ID and Client ID respectively. - // - ['Source']['entityid'] - contains OpenId Provider issuer ID - // - ['Destination']['entityid'] - contains Relying Party (OIDC Client) ID - // In addition to that, the following OIDC related data will be available in the state array: - // - ['Oidc']['OpenIdProviderMetadata'] - contains information otherwise available from the OIDC configuration URL. - // - ['Oidc']['RelyingPartyMetadata'] - contains information about the OIDC client making the authN request. - // - ['Oidc']['AuthorizationRequestParameters'] - contains relevant authorization request query parameters. - // - // List of authproc filters which will run for every OIDC authN. Add filters as described in docs for SAML authproc - // @see https://simplesamlphp.org/docs/stable/simplesamlphp-authproc + /** + * Settings regarding Authentication Processing Filters. + * Note: OIDC authN state array will not contain all the keys which are + * available during SAML authN, like Service Provider metadata, etc. + * + * At the moment, the following SAML authN data will be available during + * OIDC authN in the sate array: + * - ['Attributes'], ['Authority'], ['AuthnInstant'], ['Expire'] + * Source and destination will have entity IDs corresponding to the OP + * issuer ID and Client ID respectively. + * - ['Source']['entityid'] - contains OpenId Provider issuer ID + * - ['Destination']['entityid'] - contains Relying Party (OIDC Client) ID + * In addition to that, the following OIDC related data will be available + * in the state array: + * - ['Oidc']['OpenIdProviderMetadata'] - contains information otherwise + * available from the OIDC configuration URL. + * - ['Oidc']['RelyingPartyMetadata'] - contains information about the OIDC + * client making the authN request. + * - ['Oidc']['AuthorizationRequestParameters'] - contains relevant + * authorization request query parameters. + * + * List of authproc filters which will run for every OIDC authN. Add filters + * as described in docs for SAML authproc. + * @see https://simplesamlphp.org/docs/stable/simplesamlphp-authproc + */ ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS => [ // Add authproc filters here ], - // (optional) Dedicated OIDC protocol cache adapter, used to cache artifacts like access tokens, authorization - // codes, refresh tokens, client data, user data, etc. It will also be used for token reuse check in protocol - // context. Setting this option is recommended in production environments. If set to null, no caching will - // be used. Can be set to any Symfony Cache Adapter class, like in examples below. If set, make sure to - // also give proper adapter arguments for its instantiation below. - // @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + /** + * (optional) Dedicated OIDC protocol cache adapter, used to cache artifacts + * like access tokens, authorization codes, refresh tokens, client data, + * user data, etc. It will also be used for token reuse check in protocol + * context. Setting this option is recommended in production environments. + * If set to null, no caching will be used. Can be set to any Symfony Cache + * Adapter class, like in examples below. If set, make sure to also give + * proper adapter arguments for its instantiation below. + * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + */ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => null, // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\FilesystemAdapter::class, // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\MemcachedAdapter::class, - // Protocol cache adapter arguments used for adapter instantiation. Refer to documentation for particular - // adapter on which arguments are needed to create its instance, in the order of constructor arguments. - // See examples below. + /** + * Protocol cache adapter arguments used for adapter instantiation. Refer + * to documentation for particular adapter on which arguments are needed + * to create its instance, in the order of constructor arguments. See + * examples below. + */ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // Adapter arguments here... ], @@ -393,83 +398,112 @@ $config = [ // ], /** - * Protocol cache duration for particular entities. This is only relevant if protocol cache adapter is set up. - * For duration format info, check https://www.php.net/manual/en/dateinterval.construct.php. - */ - // Cache duration for user entities (authenticated users data). If not set, cache duration will be the same as - // session duration. + * Protocol cache duration for user entities (authenticated users data). + * If not set, cache duration will be the same as session duration. + * This is only relevant if protocol cache adapter is set up. For duration + * format info, check + * https://www.php.net/manual/en/dateinterval.construct.php. + */ // ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => 'PT1H', // 1 hour ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, // Fallback to session duration - // Cache duration for client entities, with given default. + /** + * Protocol cache duration for client entities, with given default. + * This is only relevant if protocol cache adapter is set up. For duration + * format info, check + * https://www.php.net/manual/en/dateinterval.construct.php. + */ ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', // 10 minutes - // Cache duration for Authorization Code, Access Token, and Refresh Token will fall back to their TTL. + /** + * Note: cache duration for Authorization Code, Access Token, and Refresh Token + * will fall back to their TTL. + */ /** - * Cron related options. + * Cron tag used to run storage cleanup script using the cron module. */ - // Cron tag used to run storage cleanup script using the cron module. ModuleConfig::OPTION_CRON_TAG => 'hourly', /** - * Admin backend UI related options. + * Permissions which let the module expose functionality to specific users. In the below configuration, a user's + * eduPersonEntitlement attribute is examined. If the user tries to do something that requires the 'client' + * permission (such as registering their own client), then they will need one of the eduPersonEntitlements + * from the `client` permission array. A permission can be disabled by commenting it out. */ - // Permissions which let the module expose functionality to specific users. In the below configuration, a user's - // eduPersonEntitlement attribute is examined. If the user tries to do something that requires the 'client' - // permission (such as registering their own client), then they will need one of the eduPersonEntitlements - // from the `client` permission array. A permission can be disabled by commenting it out. ModuleConfig::OPTION_ADMIN_UI_PERMISSIONS => [ // Attribute to inspect to determine user's permissions 'attribute' => 'eduPersonEntitlement', - // Which entitlements allow for registering, editing, delete a client. OIDC clients are owned by the creator + // Which entitlements allow for registering, editing, delete a client. + // OIDC clients are owned by the creator 'client' => ['urn:example:oidc:manage:client'], ], - // Pagination options. + /** + * Pagination options, for example, on client listing page. + */ ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => 20, + /*************************************************************************** + * (optional) OpenID Federation related options. If these are not set, + * OpenID Federation capabilities will be disabled. + **************************************************************************/ + /** - * (optional) OpenID Federation related options. If these are not set, OpenID Federation capabilities will be - * disabled. + * Enable or disable federation capabilities. Default is disabled (false). */ - - // Enable or disable federation capabilities. Default is disabled (false). ModuleConfig::OPTION_FEDERATION_ENABLED => false, - // Trust Anchors which are valid for this entity. The key represents the Trust Anchor Entity ID, while the value can - // be the Trust Anchor's JWKS JSON object string value, or null. If JWKS is provided, it will be used to validate - // Trust Anchor Configuration Statement in addition to using JWKS acquired during Trust Chain resolution. If - // JWKS is not provided (value null), the validity of Trust Anchor Configuration Statement will "only" be - // validated by the JWKS acquired during Trust Chain resolution, meaning that security will rely "only" - // on protection implied from using TLS on endpoints used during Trust Chain resolution. + /** + * Trust Anchors which are valid for this entity. The key represents the + * Trust Anchor Entity ID, while the value can be the Trust Anchor's JWKS + * JSON object string value, or null. If JWKS is provided, it will be used + * to validate Trust Anchor Configuration Statement in addition to using + * JWKS acquired during Trust Chain resolution. If JWKS is not provided + * (value null), the validity of Trust Anchor Configuration Statement will + * "only" be validated by the JWKS acquired during Trust Chain resolution, + * meaning that security will rely "only" on protection implied from using + * TLS on endpoints used during Trust Chain resolution. + */ ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [ // phpcs:ignore // 'https://ta.example.org/' => '{"keys":[{"kty": "RSA","alg": "RS256","use": "sig","kid": "Nzb...9Xs","e": "AQAB","n": "pnXB...ub9J"}]}', // 'https://ta2.example.org/' => null, ], - // Federation authority hints. An array of strings representing the Entity Identifiers of Intermediate Entities - // (or Trust Anchors). Required if this entity has a Superior entity above it. + /** + * Federation authority hints. An array of strings representing the Entity + * Identifiers of Intermediate Entities (or Trust Anchors). Required if + * this entity has a Superior entity above it. + */ ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ // 'https://intermediate.example.org/', ], - // (optional) Federation Trust Mark tokens. An array of tokens (signed JWTs), each representing a Trust Mark - // issued to this entity. This option is primarily intended for long-lasting or non-expiring tokens, so it - // is not necessary to dynamically fetch / refresh them. + /** + * (optional) Federation Trust Mark tokens. An array of tokens + * (signed JWTs), each representing a Trust Mark issued to this entity. + * This option is primarily intended for long-lasting or non-expiring + * tokens, so it is not necessary to dynamically fetch / refresh them. + */ ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS => [ // 'eyJ...GHg', ], - // (optional) Federation Trust Marks for dynamic fetching. An array of key-value pairs, where key is Trust Mark Type - // and value is Trust Mark Issuer ID, each representing a Trust Mark issued to this entity. Each Trust Mark Type - // in this array will be dynamically fetched from the noted Trust Mark Issuer as necessary. If federation - // caching is enabled (recommended), fetched Trust Marks will also be cached until their expiry. + /** + * (optional) Federation Trust Marks for dynamic fetching. An array of + * key-value pairs, where key is Trust Mark Type and value is Trust Mark + * Issuer ID, each representing a Trust Mark issued to this entity. Each + * Trust Mark Type in this array will be dynamically fetched from the noted + * Trust Mark Issuer as necessary. If federation caching is enabled + * (recommended), fetched Trust Marks will also be cached until their + * expiry. + */ ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS => [ // 'trust-mark-type' => 'trust-mark-issuer-id', ], - // (optional) Federation participation limit by Trust Marks. This is an array with the following format: + // (optional) Federation participation limit by Trust Marks. This is an + // array with the following format: // [ // 'trust-anchor-id' => [ // 'limit-id' => [ @@ -478,10 +512,12 @@ $config = [ // ], // ], // ], - // Check example below on how this can be used. If federation participation limit is configured for particular - // Trust Anchor ID, at least one combination of "limit ID" => "trust mark list" should be defined. + // Check example below on how this can be used. If federation participation + // limit is configured for particular Trust Anchor ID, at least one + // combination of "limit ID" => "trust mark list" should be defined. ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS => [ - // We are limiting federation participation using Trust Marks for 'https://ta.example.org/'. + // We are limiting federation participation using Trust Marks for + // 'https://ta.example.org/'. 'https://ta.example.org/' => [ // Entities must have (at least) one Trust Mark from the list below. \SimpleSAML\Module\oidc\Codebooks\LimitsEnum::OneOf->value => [ @@ -496,58 +532,52 @@ $config = [ ], ], - // (optional) Trust Mark Status Endpoint Usage Policy. Check the TrustMarkStatusEndpointUsagePolicyEnum for the - // available options. Default is RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, meaning that the - // Trust Mark Status Endpoint will be used to check the status of non-expiring Trust Marks if the - // Trust Mark Status Endpoint is provided by the Trust Mark Issuer. + /** + * (optional) Trust Mark Status Endpoint Usage Policy. Check the + * TrustMarkStatusEndpointUsagePolicyEnum for the available options. Default + * is RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, meaning that + * the Trust Mark Status Endpoint will be used to check the status of + * non-expiring Trust Marks if the Trust Mark Status Endpoint is provided + * by the Trust Mark Issuer. + */ ModuleConfig::OPTION_FEDERATION_TRUST_MARK_STATUS_ENDPOINT_USAGE_POLICY => \SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, - // (optional) Dedicated federation cache adapter, used to cache federation artifacts like trust chains, entity - // statements, etc. It will also be used for token reuse check in federation context. Setting this option is - // recommended in production environments. If set to null, no caching will be used. Can be set to any - // Symfony Cache Adapter class. If set, make sure to also give proper adapter arguments for its - // instantiation below. See examples for protocol cache adapter option. - // @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + /** + * (optional) Dedicated federation cache adapter, used to cache federation + * artifacts like trust chains, entity statements, etc. It will also be + * used for token reuse check in federation context. Setting this option is + * recommended in production environments. If set to null, no caching will + * be used. Can be set to any Symfony Cache Adapter class. If set, make + * sure to also give proper adapter arguments for its instantiation below. + * See examples for protocol cache adapter option. + * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + */ ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => null, - // Federation cache adapter arguments used for adapter instantiation. Refer to documentation for particular - // adapter on which arguments are needed to create its instance, in the order of constructor arguments. - // See examples for protocol cache adapter option. + /** + * Federation cache adapter arguments used for adapter instantiation. Refer + * to documentation for particular adapter on which arguments are needed to + * create its instance, in the order of constructor arguments. + * See examples for protocol cache adapter option. + */ ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [ // Adapter arguments here... ], - // Maximum federation cache duration for fetched artifacts. Federation cache duration will typically be resolved - // based on the expiry of the fetched artifact. For example, when caching fetched entity statements, cache - // duration will be based on the 'exp' claim (expiration time). Since those claims are set by issuer (can - // be long), it could be desirable to limit the maximum time, so that items in cache get refreshed more - // regularly (and changes propagate more quickly). This is only relevant if federation cache adapter - // is set up. For duration format info, check https://www.php.net/manual/en/dateinterval.construct.php. - ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours - /** - * PKI settings related to OpenID Federation. These keys will be used, for example, to sign federation - * entity statements. Note that these keys SHOULD NOT be the same as the ones used in OIDC protocol itself. + * Maximum federation cache duration for fetched artifacts. Federation cache + * duration will typically be resolved based on the expiry of the fetched + * artifact. For example, when caching fetched entity statements, cache + * duration will be based on the 'exp' claim (expiration time). Since those + * claims are set by issuer (can be long), it could be desirable to limit + * the maximum time, so that items in cache get refreshed more regularly + * (and changes propagate more quickly). This is only relevant if federation + * cache adapter is set up. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php. */ - // The federation private key passphrase (optional). -// ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret', - // The federation certificate and private key filenames, with given defaults. - /** @deprecated */ - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, + ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours - /** - * (optional) Key rollover settings related to OpenID Federation. Check the OIDC protocol key rollover description - * on how this works. - */ - // The federation (new) private key passphrase (optional). - /** @deprecated */ -// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', -// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module_federation.key', -// ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', /** * Federation signature algorithm and key-pair definitions, representing @@ -568,29 +598,33 @@ $config = [ [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME, + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, // ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional // ModuleConfig::KEY_KEY_ID => 'ec-connect-signing-key-01', // Optional ], ], - // Federation token signer, with given default. - /** @deprecated */ - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, - - // Federation entity statement duration which determines the Expiration Time (exp) claim set in entity - // statement JWSs published by this OP. If not set, default of 1 day will be used. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php + /** + * Federation entity statement duration which determines the Expiration Time + * (exp) claim set in entity statement JWSs published by this OP. If not + * set, default of 1 day will be used. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php + */ ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION => 'P1D', // 1 day - // Cache duration for federation entity statements produced by this OP. This can be used to avoid calculating JWS - // signature on every HTTP request for OP Configuration statement, Subordinate Statements... This is only - // relevant if federation cache adapter is set up. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php. + /** + * Cache duration for federation entity statements produced by this OP. + * This can be used to avoid calculating JWS signature on every HTTP request + * for OP Configuration statement, Subordinate Statements... This is only + * relevant if federation cache adapter is set up. For duration format info, + * check https://www.php.net/manual/en/dateinterval.construct.php. + */ ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED => 'PT2M', // 2 minutes - // Common federation entity parameters: - // https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + /** + * Common federation entity parameters: + * https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + */ ModuleConfig::OPTION_ORGANIZATION_NAME => null, ModuleConfig::OPTION_DISPLAY_NAME => null, ModuleConfig::OPTION_DESCRIPTION => null, @@ -604,42 +638,48 @@ $config = [ ModuleConfig::OPTION_POLICY_URI => null, ModuleConfig::OPTION_INFORMATION_URI => null, ModuleConfig::OPTION_ORGANIZATION_URI => null, - /** - * @deprecated In Draft-43 of OIDFed specification, metadata claim 'homepage_uri' has been renamed to - * 'organization_uri'. Use 'organization_uri' instead. - */ - ModuleConfig::OPTION_HOMEPAGE_URI => null, + /*************************************************************************** + * (optional) OpenID Verifiable Credential related options. If these are + * not set, OpenID Verifiable Credential capabilities will be disabled. + **************************************************************************/ + /** - * (optional) OpenID Verifiable Credential related options. If these are not set, OpenID Verifiable - * Credential capabilities will be disabled. + * Enable or disable verifiable credentials capabilities. Default is + * disabled (false). */ - - // Enable or disable verifiable credentials capabilities. Default is disabled (false). ModuleConfig::OPTION_VERIFIABLE_CREDENTIAL_ENABLED => false, - // Allow or disallow non-registered clients to request verifiable credentials. Default is disallowed (false). + /** + * Allow or disallow non-registered clients to request verifiable + * credentials. Default is disallowed (false). + */ ModuleConfig::OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI => false, - // Allowed redirect URI prefixes for non-registered clients. By default, this is set to - // 'openid-credential-offer://' to allow only redirect URIs with this prefix. - // - // Example: - // [ - // 'https://example.org/redirect', - // 'https://example.org/redirect2', - // ] - // + /** + * Allowed redirect URI prefixes for non-registered clients. By default, this is set to + * 'openid-credential-offer://' to allow only redirect URIs with this prefix. + * + * Example: + * [ + * 'https://example.org/redirect', + * 'https://example.org/redirect2', + * ] + */ ModuleConfig::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI => [ 'openid-credential-offer://', ], - // (optional) Credential configuration statements, as per `credential_configurations_supported` claim definition in - // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#credential-issuer-parameters. - // Check the example below on how this can be used. + /** + * (optional) Credential configuration statements, as per + * `credential_configurations_supported` claim definition in + * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#credential-issuer-parameters. + * Check the example below on how this can be used. + */ ModuleConfig::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ - // Sample for 'jwt_vc_json' format with notes about required and optional fields. + // Sample for 'jwt_vc_json' format with notes about required and + // optional fields. 'ResearchAndScholarshipCredentialJwtVcJson' => [ // REQUIRED ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, @@ -649,7 +689,8 @@ $config = [ // OPTIONAL // cryptographic_binding_methods_supported - // OPTIONAL - will be set / overridden to the protocol signing algorithm. + // OPTIONAL - will be set / overridden to the protocol signing + // algorithm. // credential_signing_alg_values_supported // OPTIONAL @@ -685,7 +726,8 @@ $config = [ /** * https://refeds.org/category/research-and-scholarship * - * The R&S attribute bundle consists (abstractly) of the following required data elements: + * The R&S attribute bundle consists (abstractly) of the + * following required data elements: * * shared user identifier * person name @@ -695,25 +737,29 @@ $config = [ * * affiliation * - * where shared user identifier is a persistent, non-reassigned, non-targeted identifier - * defined to be either of the following: + * where shared user identifier is a persistent, non-reassigned, + * non-targeted identifier defined to be either of the + * following: * * eduPersonPrincipalName (if non-reassigned) * eduPersonPrincipalName + eduPersonTargetedID * - * and where person name is defined to be either (or both) of the following: + * and where person name is defined to be either (or both) of + * the following: * * displayName * givenName + sn * * and where email address is defined to be the mail attribute, + * and where affiliation is defined to be the + * eduPersonScopedAffiliation attribute. * - * and where affiliation is defined to be the eduPersonScopedAffiliation attribute. - * - * All of the above attributes are defined or referenced in the [eduPerson] specification. The - * specific naming and format of these attributes is guided by the protocol in use. For SAML - * 2.0 the [SAMLAttr] profile MUST be used. This specification may be extended to reference - * other protocol-specific formulations as circumstances warrant. + * All of the above attributes are defined or referenced in the + * [eduPerson] specification. The specific naming and format of + * these attributes is guided by the protocol in use. For SAML + * 2.0 the [SAMLAttr] profile MUST be used. This specification + * may be extended to reference other protocol-specific + * formulations as circumstances warrant. */ [ // REQUIRED @@ -798,7 +844,8 @@ $config = [ ], ], - // Sample for 'dc+sd-jwt' format without notes about required and optional fields. + // Sample for 'dc+sd-jwt' format without notes about required and + // optional fields. 'ResearchAndScholarshipCredentialDcSdJwt' => [ ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::DcSdJwt->value, // In earlier drafts it was vc+sd-jwt. @@ -886,15 +933,18 @@ $config = [ ], ], - // Mapping of user attributes to a credential claim path, per credential configuration ID. - // Note that the path must be present in the credential configuration supported above. - // This is an array of arrays, with the following format: - // [ - // 'credential-configuration-id' => [ - // ['user-attribute-name' => ['path-element', 'path-element', ...]], - // '...', - // ], - // ], + /** + * Mapping of user attributes to a credential claim path, per credential + * configuration ID. Note that the path must be present in the credential + * configuration supported above. This is an array of arrays, with the + * following format: + * [ + * 'credential-configuration-id' => [ + * ['user-attribute-name' => ['path-element', 'path-element', ...]], + * '...', + * ], + * ], + */ ModuleConfig::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP => [ 'ResearchAndScholarshipCredentialJwtVcJson' => [ ['eduPersonPrincipalName' => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName']], @@ -916,29 +966,48 @@ $config = [ ], ], - // Map of authentication sources and user's email attribute names. This enables you to define a specific attribute - // name which contains the user's email address, per authentication source. This is used, for example, to send - // Transaction Code in the case of pre-authorized codes for verifiable credential issuance. If not set, the - // default user's email attribute name will be used (see the option below). - // - // Format is: 'authentication-source-id' => 'email-attribute-name'. + /** + * Map of authentication sources and user's email attribute names. This + * enables you to define a specific attribute name which contains the + * user's email address, per authentication source. This is used, for + * example, to send Transaction Code in the case of pre-authorized + * codes for verifiable credential issuance. If not set, the default + * user's email attribute name will be used (see the option below). + * + * Format is: 'authentication-source-id' => 'email-attribute-name'. + */ ModuleConfig::OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP => [ 'example-auth-source-id' => 'mail', ], - // The default name of the attribute which contains the user's email address. If not set, it will - // fall back to 'mail'. + /** + * The default name of the attribute which contains the user's email + * address. If not set, it will fall back to 'mail'. + */ ModuleConfig::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME => 'mail', /** - * (optional) API-related options. + * (optional) Issuer State TTL (validity duration), with the given example. + * If not set, falls back to Authorization Code TTL. For duration format + * info, check https://www.php.net/manual/en/dateinterval.construct.php */ + ModuleConfig::OPTION_ISSUER_STATE_TTL => 'PT10M', // 10 minutes + + + /*************************************************************************** + * (optional) API-related options. + **************************************************************************/ - // (optional) Enable or disable API capabilities. Default is disabled (false). + /** + * (optional) Enable or disable API capabilities. Default is disabled + * (false). + */ ModuleConfig::OPTION_API_ENABLED => false, - // List of API tokens which can be used to access API endpoints based on given scopes. - // The format is: ['token' => [ApiScopesEnum]] + /** + * List of API tokens which can be used to access API endpoints based on + * given scopes. The format is: ['token' => [ApiScopesEnum]] + */ ModuleConfig::OPTION_API_TOKENS => [ // 'strong-random-token-string' => [ // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. @@ -946,9 +1015,4 @@ $config = [ // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer, // Gives access to the credential offer endpoint. // ], ], - - // (optional) Issuer State TTL (validity duration), with the given example. If not set, falls back to - // Authorization Code TTL. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php - ModuleConfig::OPTION_ISSUER_STATE_TTL => 'PT10M', // 10 minutes ]; diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index 364f2783..5aacdb73 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -21,8 +21,6 @@ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, - ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 5857c0d9..3e603143 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -11,28 +11,54 @@ New features: Federation signing algorithms and key pairs. This was introduced in order to support signature algorithm negotiation with the clients. - Clients can now be configured with new properties: - - ID Token Signing Algorithm (id_token_signed_response_alg) + - ID Token Signing Algorithm (`id_token_signed_response_alg`) - Initial support for OpenID for Verifiable Credential Issuance (OpenID4VCI). Note that the implementation is experimental. You should not use it in production. New configuration options: -- ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS - (required) enables defining +- `ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` - (required) enables defining multiple protocol (Connect) related signing algorithms and key pairs. -- ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS - (required if federation +- `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` - (required if federation capabilities are enabled) enables defining multiple key pairs for Federation purposes like signing Entity Statements, publishing new key for key roll-ower scenarios, etc. -- ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY - optional, used for setting +- `ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY` - optional, used for setting allowed time tolerance for timestamp validation in artifacts like JWSs. multiple Federation related signing algorithms and key pairs. - Several new options regarding experimental support for OpenID4VCI. Major impact changes: -- The following configuration options are removed: - - +- The following configuration options related to protocol (Connect) +signature algorithm and key pair are removed: + - `ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME` + - `ModuleConfig::OPTION_TOKEN_SIGNER` + - `ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME` + + Instead of those options, now you must use option + `ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` in which you can define + all supported signature keys for protocol (Connect) purposes. +- The following configuration options related to Federation signature algorithm +and key pair are removed: + - `ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME` + - `ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER` + - `ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME` + + Instead of those options, now you must use option + `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` in which you can define + all the supported signature keys for Federation purposes. +- Removed config option `ModuleConfig::OPTION_HOMEPAGE_URI`. Use +`ModuleConfig::OPTION_ORGANIZATION_URI` instead. - In v6 of the module, when defining custom scopes, there was a possibility to use standard claims with the 'are_multiple_claim_values_allowed' option. This would allow multiple values (array of values) for standard claims which diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index 95d6091a..7e089776 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -8,22 +8,17 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\Module\oidc\Utils\FingerprintGenerator; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Codebooks\ErrorsEnum; use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum; -use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Federation; -use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\Jwks; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,14 +33,12 @@ class EntityStatementController */ public function __construct( protected readonly ModuleConfig $moduleConfig, - protected readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, protected readonly Jwks $jwks, protected readonly OpMetadataService $opMetadataService, protected readonly ClientRepository $clientRepository, protected readonly Helpers $helpers, protected readonly Routes $routes, protected readonly Federation $federation, - protected readonly Jwk $jwk, protected readonly LoggerService $loggerService, protected readonly ?FederationCache $federationCache, ) { @@ -76,12 +69,6 @@ public function configuration(): Response $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); - $header = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile( - $this->moduleConfig->getFederationCertPath(), - ), - ]; - $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( ...$this->moduleConfig->getFederationSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(); @@ -89,7 +76,7 @@ public function configuration(): Response $payload = [ ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iat->value => $currentTimestamp, - ClaimsEnum::Jti->value => $this->helpers->random()->getIdentifier(), + ClaimsEnum::Jti->value => $this->federation->helpers()->random()->string(), // This is entity configuration (statement about itself). ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Exp->value => $this->helpers->dateTime()->getUtc()->add( @@ -211,16 +198,21 @@ public function configuration(): Response // Remaining claims, add if / when ready. // * crit + $signingKeyPair = $this->moduleConfig + ->getFederationSignatureKeyPairBag() + ->getFirstOrFail(); + + $header = [ + ClaimsEnum::Kid->value => $signingKeyPair->getKeyPair()->getKeyId(), + ]; + /** @psalm-suppress ArgumentTypeCoercion */ $entityConfigurationToken = $this->federation->entityStatementFactory()->fromData( - $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( - $this->moduleConfig->getFederationPrivateKeyPath(), - ), - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), + $signingKeyPair->getKeyPair()->getPrivateKey(), + $signingKeyPair->getSignatureAlgorithm(), $payload, $header, - ) - ->getToken(); + )->getToken(); $this->federationCache?->set( $entityConfigurationToken, @@ -274,37 +266,38 @@ public function fetch(Request $request): Response ); } - $builder = $this->jsonWebTokenBuilderService->getFederationJwtBuilder() - ->withHeader(ClaimsEnum::Typ->value, JwtTypesEnum::EntityStatementJwt->value) - ->relatedTo($subject) - ->expiresAt( - $this->helpers->dateTime()->getUtc()->add($this->moduleConfig->getFederationEntityStatementDuration()), - )->withClaim( - ClaimsEnum::Jwks->value, - $jwks, - ) - ->withClaim( - ClaimsEnum::Metadata->value, - [ - EntityTypesEnum::OpenIdRelyingParty->value => [ - ClaimsEnum::ClientName->value => $client->getName(), - ClaimsEnum::ClientId->value => $client->getIdentifier(), - ClaimsEnum::RedirectUris->value => $client->getRedirectUris(), - ClaimsEnum::Scope->value => implode(' ', $client->getScopes()), - ClaimsEnum::ClientRegistrationTypes->value => $client->getClientRegistrationTypes(), - // Optional claims... - ...(array_filter( - [ - ClaimsEnum::BackChannelLogoutUri->value => $client->getBackChannelLogoutUri(), - ClaimsEnum::PostLogoutRedirectUris->value => $client->getPostLogoutRedirectUri(), - ], - )), - // TODO v7 mivanci Continue - // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata - // https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#client-metadata - ], + $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); + + $payload = [ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => $this->helpers->random()->getIdentifier(), + + ClaimsEnum::Sub->value => $subject, + ClaimsEnum::Exp->value => $this->helpers->dateTime()->getUtc()->add( + $this->moduleConfig->getFederationEntityStatementDuration(), + )->getTimestamp(), + ClaimsEnum::Jwks->value => $jwks, + ClaimsEnum::Metadata->value => [ + EntityTypesEnum::OpenIdRelyingParty->value => [ + ClaimsEnum::ClientName->value => $client->getName(), + ClaimsEnum::ClientId->value => $client->getIdentifier(), + ClaimsEnum::RedirectUris->value => $client->getRedirectUris(), + ClaimsEnum::Scope->value => implode(' ', $client->getScopes()), + ClaimsEnum::ClientRegistrationTypes->value => $client->getClientRegistrationTypes(), + // Optional claims... + ...(array_filter( + [ + ClaimsEnum::BackChannelLogoutUri->value => $client->getBackChannelLogoutUri(), + ClaimsEnum::PostLogoutRedirectUris->value => $client->getPostLogoutRedirectUri(), + ], + )), + // TODO v7 mivanci Continue + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + // https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#client-metadata ], - ); + ], + ]; // TODO v7 mivanci Continue // Note: claims which can be present in subordinate statements: @@ -312,9 +305,21 @@ public function fetch(Request $request): Response // * constraints // * metadata_policy_crit - $jws = $this->jsonWebTokenBuilderService->getSignedFederationJwt($builder); + $signingKeyPair = $this->moduleConfig + ->getFederationSignatureKeyPairBag() + ->getFirstOrFail(); + + + $header = [ + ClaimsEnum::Kid->value => $signingKeyPair->getKeyPair()->getKeyId(), + ]; - $subordinateStatementToken = $jws->toString(); + $subordinateStatementToken = $this->federation->entityStatementFactory()->fromData( + $signingKeyPair->getKeyPair()->getPrivateKey(), + $signingKeyPair->getSignatureAlgorithm(), + $payload, + $header, + )->getToken(); $this->federationCache?->set( $subordinateStatementToken, diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 95959a5e..d6320574 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -40,7 +40,7 @@ public function configuration(): Response { // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p - $signer = $this->moduleConfig->getProtocolSigner(); + $signatureKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairBag()->getFirstOrFail(); $credentialConfigurationsSupported = $this->moduleConfig->getCredentialConfigurationsSupported(); @@ -50,12 +50,12 @@ public function configuration(): Response if (is_array($credentialConfiguration)) { // Draft 17 $credentialConfiguration[ClaimsEnum::CredentialSigningAlgValuesSupported->value] = [ - $signer->algorithmId(), + $signatureKeyPair->getSignatureAlgorithm()->value, ]; // Earlier drafts // TODO mivanci Delete CryptographicSuitesSupported once we are on the final draft. $credentialConfiguration[ClaimsEnum::CryptographicSuitesSupported->value] = [ - $signer->algorithmId(), + $signatureKeyPair->getSignatureAlgorithm()->value, ]; $credentialConfigurationsSupported[$credentialConfigurationId] = $credentialConfiguration; } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index c5a97ab0..bea39c49 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -17,7 +17,6 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\AtContextsEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; @@ -27,7 +26,6 @@ use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Exceptions\OpenId4VciProofException; use SimpleSAML\OpenID\Exceptions\OpenIdException; -use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\VerifiableCredentials; use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; use Symfony\Component\HttpFoundation\Request; @@ -50,7 +48,6 @@ public function __construct( protected readonly Routes $routes, protected readonly PsrHttpBridge $psrHttpBridge, protected readonly VerifiableCredentials $verifiableCredentials, - protected readonly Jwk $jwk, protected readonly LoggerService $loggerService, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly UserRepository $userRepository, @@ -592,18 +589,13 @@ public function credential(Request $request): Response $sub, ); - $signingKey = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( - $this->moduleConfig->getProtocolPrivateKeyPath(), - null, - ); + $protocolSignatureKeyPair = $this->moduleConfig + ->getProtocolSignatureKeyPairBag() + ->getFirstOrFail(); - $publicKey = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( - $this->moduleConfig->getProtocolCertPath(), - null, - [ - //ClaimsEnum::Use->value => 'sig', - ], - ); + $signingKey = $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(); + + $publicKey = $protocolSignatureKeyPair->getKeyPair()->getPublicKey(); $base64PublicKey = json_encode($publicKey->jwk()->all(), JSON_UNESCAPED_SLASHES); $base64PublicKey = Base64Url::encode($base64PublicKey); @@ -613,7 +605,7 @@ public function credential(Request $request): Response $issuedAt = new \DateTimeImmutable(); $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); - $signatureAlgorithm = SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()); + $signatureAlgorithm = $protocolSignatureKeyPair->getSignatureAlgorithm(); $verifiableCredential = null; diff --git a/src/Factories/JwksFactory.php b/src/Factories/JwksFactory.php index 5991e17e..2e277c54 100644 --- a/src/Factories/JwksFactory.php +++ b/src/Factories/JwksFactory.php @@ -7,10 +7,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Jwks; -use SimpleSAML\OpenID\SupportedAlgorithms; class JwksFactory { @@ -27,14 +24,8 @@ public function __construct( */ public function build(): Jwks { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), - ), - ); - return new Jwks( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), cache: $this->federationCache?->cache, logger: $this->loggerService, diff --git a/src/Factories/VerifiableCredentialsFactory.php b/src/Factories/VerifiableCredentialsFactory.php index 6ffb2e51..869af3a3 100644 --- a/src/Factories/VerifiableCredentialsFactory.php +++ b/src/Factories/VerifiableCredentialsFactory.php @@ -6,9 +6,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; -use SimpleSAML\OpenID\SupportedAlgorithms; use SimpleSAML\OpenID\VerifiableCredentials; class VerifiableCredentialsFactory @@ -25,23 +22,8 @@ public function __construct( */ public function build(): VerifiableCredentials { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()), - SignatureAlgorithmEnum::RS256, - SignatureAlgorithmEnum::RS384, - SignatureAlgorithmEnum::RS512, - SignatureAlgorithmEnum::ES256, - SignatureAlgorithmEnum::ES384, - SignatureAlgorithmEnum::ES512, - SignatureAlgorithmEnum::PS256, - SignatureAlgorithmEnum::PS384, - SignatureAlgorithmEnum::PS512, - ), - ); - return new VerifiableCredentials( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), logger: $this->loggerService, ); } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 82ffabc0..997f2c33 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -18,7 +18,6 @@ use DateInterval; use Lcobucci\JWT\Signer; -use Lcobucci\JWT\Signer\Rsa\Sha256; use ReflectionClass; use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; @@ -48,21 +47,13 @@ class ModuleConfig public const KEY_PUBLIC_KEY_FILENAME = 'public_key_filename'; public const KEY_PRIVATE_KEY_PASSWORD = 'private_key_password'; public const KEY_KEY_ID = 'key_id'; - - /** - * Default file name for module configuration. Can be overridden in constructor, for example, for testing purposes. - */ final public const DEFAULT_FILE_NAME = 'module_oidc.php'; - final public const OPTION_PKI_PRIVATE_KEY_PASSPHRASE = 'pass_phrase'; - final public const OPTION_PKI_PRIVATE_KEY_FILENAME = 'privatekey'; final public const DEFAULT_PKI_PRIVATE_KEY_FILENAME = 'oidc_module.key'; - final public const OPTION_PKI_CERTIFICATE_FILENAME = 'certificate'; final public const DEFAULT_PKI_CERTIFICATE_FILENAME = 'oidc_module.crt'; final public const OPTION_TOKEN_AUTHORIZATION_CODE_TTL = 'authCodeDuration'; final public const OPTION_TOKEN_REFRESH_TOKEN_TTL = 'refreshTokenDuration'; final public const OPTION_TOKEN_ACCESS_TOKEN_TTL = 'accessTokenDuration'; - final public const OPTION_TOKEN_SIGNER = 'signer'; final public const OPTION_AUTH_SOURCE = 'auth'; final public const OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE = 'useridattr'; final public const OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE = 'translate'; @@ -74,11 +65,7 @@ class ModuleConfig final public const OPTION_CRON_TAG = 'cron_tag'; final public const OPTION_ADMIN_UI_PERMISSIONS = 'permissions'; final public const OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE = 'items_per_page'; - final public const OPTION_FEDERATION_TOKEN_SIGNER = 'federation_token_signer'; - final public const OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE = 'federation_private_key_passphrase'; - final public const OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME = 'federation_private_key_filename'; final public const DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME = 'oidc_module_federation.key'; - final public const OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME = 'federation_certificate_filename'; final public const DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME = 'oidc_module_federation.crt'; final public const OPTION_ISSUER = 'issuer'; final public const OPTION_FEDERATION_ENTITY_STATEMENT_DURATION = 'federation_entity_statement_duration'; @@ -91,7 +78,6 @@ class ModuleConfig final public const OPTION_LOGO_URI = 'logo_uri'; final public const OPTION_POLICY_URI = 'policy_uri'; final public const OPTION_INFORMATION_URI = 'information_uri'; - final public const OPTION_HOMEPAGE_URI = 'homepage_uri'; final public const OPTION_ORGANIZATION_URI = 'organization_uri'; final public const OPTION_FEDERATION_ENABLED = 'federation_enabled'; final public const OPTION_FEDERATION_CACHE_ADAPTER = 'federation_cache_adapter'; @@ -111,13 +97,6 @@ class ModuleConfig final public const OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION = 'protocol_client_entity_cache_duration'; final public const OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED = 'protocol_discover_show_claims_supported'; - final public const OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE = 'new_private_key_passphrase'; - final public const OPTION_PKI_NEW_PRIVATE_KEY_FILENAME = 'new_privatekey'; - final public const OPTION_PKI_NEW_CERTIFICATE_FILENAME = 'new_certificate'; - - final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE = 'federation_new_private_key_passphrase'; - final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME = 'federation_new_private_key_filename'; - final public const OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME = 'federation_new_certificate_filename'; final public const OPTION_VERIFIABLE_CREDENTIAL_ENABLED = 'verifiable_credentials_enabled'; final public const OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED = 'credential_configurations_supported'; final public const OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = @@ -438,79 +417,6 @@ public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag ->fromConfig($this->getProtocolSignatureKeyPairConfigBag()); } - /** - * Get signer for OIDC protocol. - * - * @throws \ReflectionException - * @throws \Exception - */ - public function getProtocolSigner(): Signer - { - /** @psalm-var class-string $signerClassname */ - $signerClassname = $this->config()->getOptionalString( - self::OPTION_TOKEN_SIGNER, - Sha256::class, - ); - - return $this->instantiateSigner($signerClassname); - } - - /** - * Get the path to the private key used in OIDC protocol. - * @return non-empty-string The file system path - * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType - * @throws \Exception - */ - public function getProtocolPrivateKeyPath(): string - { - $keyName = $this->config()->getOptionalString( - self::OPTION_PKI_PRIVATE_KEY_FILENAME, - self::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - ); - return $this->sspBridge->utils()->config()->getCertPath($keyName); - } - - /** - * Get the OIDC protocol private key passphrase. - * @return ?string - * @throws \Exception - */ - public function getProtocolPrivateKeyPassPhrase(): ?string - { - return $this->config()->getOptionalString(self::OPTION_PKI_PRIVATE_KEY_PASSPHRASE, null); - } - - /** - * Get the path to the public certificate used in OIDC protocol. - * @return non-empty-string The file system path - * @throws \Exception - * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType - */ - public function getProtocolCertPath(): string - { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_CERTIFICATE_FILENAME, - self::DEFAULT_PKI_CERTIFICATE_FILENAME, - ); - return $this->sspBridge->utils()->config()->getCertPath($certName); - } - - /** - * Get the path to the new public certificate to be used in OIDC protocol. - * @return ?string Null if not set, or file system path - * @throws \Exception - */ - public function getProtocolNewCertPath(): ?string - { - $certName = $this->config()->getOptionalString(self::OPTION_PKI_NEW_CERTIFICATE_FILENAME, null); - - if (is_string($certName)) { - return $this->sspBridge->utils()->config()->getCertPath($certName); - } - - return null; - } - /** * Get supported Authentication Context Class References (ACRs). * @@ -672,69 +578,6 @@ public function getFederationSignatureKeyPairBag(): SignatureKeyPairBag ->fromConfig($signatureKeyPairConfigBag); } - /** - * @throws \ReflectionException - * @throws \SimpleSAML\Error\ConfigurationError - */ - public function getFederationSigner(): Signer - { - /** @psalm-var class-string $signerClassname */ - $signerClassname = $this->config()->getOptionalString( - self::OPTION_FEDERATION_TOKEN_SIGNER, - Sha256::class, - ); - - return $this->instantiateSigner($signerClassname); - } - - public function getFederationPrivateKeyPath(): string - { - $keyName = $this->config()->getOptionalString( - self::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - self::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ); - - return $this->sspBridge->utils()->config()->getCertPath($keyName); - } - - public function getFederationPrivateKeyPassPhrase(): ?string - { - return $this->config()->getOptionalString(self::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE, null); - } - - /** - * Return the path to the federation public certificate - * @throws \Exception - */ - public function getFederationCertPath(): string - { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME, - self::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - ); - - return $this->sspBridge->utils()->config()->getCertPath($certName); - } - - /** - * Return the path to the new federation public certificate - * @return ?string The file system path or null if not set. - * @throws \Exception - */ - public function getFederationNewCertPath(): ?string - { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME, - null, - ); - - if (is_string($certName)) { - return $this->sspBridge->utils()->config()->getCertPath($certName); - } - - return null; - } - /** * @throws \Exception */ @@ -867,21 +710,6 @@ public function getInformationUri(): ?string ); } - /** - * @return string|null - * TODO mivanci v7 Remove in next major release, as well as config constant. - * In Draft-43 of OIDFed specification, metadata claim 'homepage_uri' has been renamed to - * 'organization_uri'. Use 'organization_uri' instead. - * @see self::getOrganizationUri() - */ - public function getHomepageUri(): ?string - { - return $this->config()->getOptionalString( - self::OPTION_HOMEPAGE_URI, - null, - ); - } - public function getOrganizationUri(): ?string { return $this->config()->getOptionalString( diff --git a/src/Services/Container.php b/src/Services/Container.php index 44ee6cdf..1d0f792d 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -285,9 +285,6 @@ public function __construct() $privateKey = $cryptKeyFactory->buildPrivateKey(); - $jsonWebTokenBuilderService = new JsonWebTokenBuilderService($moduleConfig); - $this->services[JsonWebTokenBuilderService::class] = $jsonWebTokenBuilderService; - $jwsFactory = new JwsFactory($moduleConfig, $loggerService); $this->services[JwsFactory::class] = $jwsFactory; @@ -437,7 +434,6 @@ public function __construct() $this->services[RequestRulesManager::class] = $requestRuleManager; $idTokenBuilder = new IdTokenBuilder( - $jsonWebTokenBuilderService, $claimTranslatorExtractor, $core, $moduleConfig, diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index 89980365..d8d6e132 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -5,10 +5,6 @@ namespace SimpleSAML\Module\oidc\Services; use Base64Url\Base64Url; -use DateTimeImmutable; -use Lcobucci\JWT\Builder; -use Lcobucci\JWT\Token\RegisteredClaims; -use Lcobucci\JWT\UnencryptedToken; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\UserEntityInterface; use RuntimeException; @@ -26,7 +22,6 @@ class IdTokenBuilder { public function __construct( - protected readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, protected readonly ClaimTranslatorExtractor $claimExtractor, protected readonly Core $core, protected readonly ModuleConfig $moduleConfig, @@ -125,132 +120,10 @@ public function buildFor( ); } - /** - * @throws \Exception - * @psalm-suppress ArgumentTypeCoercion - * @deprecated Since v7 - * @see self::buildFor() - */ - public function build( - UserEntityInterface $userEntity, - AccessTokenEntity $accessToken, - bool $addClaimsFromScopes, - bool $addAccessTokenHash, - ?string $nonce, - ?int $authTime, - ?string $acr, - ?string $sessionId, - ): UnencryptedToken { - if (false === is_a($userEntity, ClaimSetInterface::class)) { - throw new RuntimeException('UserEntity must implement ClaimSetInterface'); - } - - // Add required id_token claims - $builder = $this->getBuilder($accessToken, $userEntity); - - if (null !== $nonce) { - $builder = $builder->withClaim('nonce', $nonce); - } - - if (null !== $authTime) { - $builder = $builder->withClaim('auth_time', $authTime); - } - - if ($addAccessTokenHash) { - $builder = $builder->withClaim( - 'at_hash', - $this->generateAccessTokenHash( - $accessToken, - $this->jsonWebTokenBuilderService->getProtocolSigner()->algorithmId(), - ), - ); - } - - if (null !== $acr) { - $builder = $builder->withClaim('acr', $acr); - } - - if (null !== $sessionId) { - $builder = $builder->withClaim('sid', $sessionId); - } - - // Need a claim factory here to reduce the number of claims by provided scope. - $claims = $this->claimExtractor->extract($accessToken->getScopes(), $userEntity->getClaims()); - $requestedClaims = $accessToken->getRequestedClaims(); - $additionalClaims = $this->claimExtractor->extractAdditionalIdTokenClaims( - $requestedClaims, - $userEntity->getClaims(), - ); - $claims = array_merge($additionalClaims, $claims); - - /** - * @var string $claimName - * @var mixed $claimValue - */ - foreach ($claims as $claimName => $claimValue) { - switch ($claimName) { - case RegisteredClaims::AUDIENCE: - if (is_array($claimValue)) { - /** @psalm-suppress MixedAssignment */ - foreach ($claimValue as $aud) { - $builder = $builder->permittedFor((string)$aud); - } - } else { - $builder = $builder->permittedFor((string)$claimValue); - } - break; - case RegisteredClaims::EXPIRATION_TIME: - /** @noinspection PhpUnnecessaryStringCastInspection */ - $builder = $builder->expiresAt(new DateTimeImmutable('@' . (string)$claimValue)); - break; - case RegisteredClaims::ID: - $builder = $builder->identifiedBy((string)$claimValue); - break; - case RegisteredClaims::ISSUED_AT: - /** @noinspection PhpUnnecessaryStringCastInspection */ - $builder = $builder->issuedAt(new DateTimeImmutable('@' . (string)$claimValue)); - break; - case RegisteredClaims::ISSUER: - $builder = $builder->issuedBy((string)$claimValue); - break; - case RegisteredClaims::NOT_BEFORE: - /** @noinspection PhpUnnecessaryStringCastInspection */ - $builder = $builder->canOnlyBeUsedAfter(new DateTimeImmutable('@' . (string)$claimValue)); - break; - case RegisteredClaims::SUBJECT: - $builder = $builder->relatedTo((string)$claimValue); - break; - default: - if ($addClaimsFromScopes || array_key_exists($claimName, $additionalClaims)) { - $builder = $builder->withClaim($claimName, $claimValue); - } - } - } - - return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($builder); - } - - /** - * @throws \League\OAuth2\Server\Exception\OAuthServerException - */ - protected function getBuilder( - AccessTokenEntityInterface $accessToken, - UserEntityInterface $userEntity, - ): Builder { - /** @psalm-suppress ArgumentTypeCoercion */ - return $this->jsonWebTokenBuilderService - ->getProtocolJwtBuilder() - ->permittedFor($accessToken->getClient()->getIdentifier()) - ->identifiedBy($accessToken->getIdentifier()) - ->canOnlyBeUsedAfter(new DateTimeImmutable('now')) - ->expiresAt($accessToken->getExpiryDateTime()) - ->relatedTo((string)$userEntity->getIdentifier()); - } - /** * @param string $jwsAlgorithm JWS Algorithm designation (like RS256, RS384...) */ - protected function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, string $jwsAlgorithm): string + public function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, string $jwsAlgorithm): string { $validBitLengths = [256, 384, 512]; diff --git a/src/Services/JsonWebTokenBuilderService.php b/src/Services/JsonWebTokenBuilderService.php deleted file mode 100644 index c371a10c..00000000 --- a/src/Services/JsonWebTokenBuilderService.php +++ /dev/null @@ -1,163 +0,0 @@ -protocolJwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfig->getProtocolSigner(), - InMemory::file( - $this->moduleConfig->getProtocolPrivateKeyPath(), - $this->moduleConfig->getProtocolPrivateKeyPassPhrase() ?? '', - ), - InMemory::plainText('empty', 'empty'), - ); - - // According to OpenID Federation specification, we need to use different signing keys for federation related - // functions. Since we won't force OP implementor to enable federation support, this part is optional. - if ($this->moduleConfig->getFederationEnabled()) { - $this->federationJwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfig->getFederationSigner(), - InMemory::file( - $this->moduleConfig->getFederationPrivateKeyPath(), - $this->moduleConfig->getFederationPrivateKeyPassPhrase() ?? '', - ), - InMemory::plainText('empty', 'empty'), - ); - } - } - - /** - * Get JWT Builder which uses OIDC protocol related signing configuration. - * - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - public function getProtocolJwtBuilder(): Builder - { - return $this->getDefaultJwtBuilder($this->protocolJwtConfig); - } - - /** - * Get JWT Builder which uses OpenID Federation related signing configuration. - * - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - public function getFederationJwtBuilder(): Builder - { - if (is_null($this->federationJwtConfig)) { - throw OidcServerException::serverError('Federation JWT PKI configuration is not set.'); - } - - return $this->getDefaultJwtBuilder($this->federationJwtConfig); - } - - /** - * Get default JWT Builder by using the provided configuration, with predefined claims like iss, iat, jti. - * - * @throws OidcServerException - */ - public function getDefaultJwtBuilder(Configuration $configuration): Builder - { - /** @psalm-suppress ArgumentTypeCoercion */ - // Ignore microseconds when handling dates. - return $configuration->builder(ChainedFormatter::withUnixTimestampDates()) - ->issuedBy($this->moduleConfig->getIssuer()) - ->issuedAt(new DateTimeImmutable('now')) - ->identifiedBy($this->helpers->random()->getIdentifier()); - } - - /** - * Get signed JWT using the OIDC protocol JWT signing configuration. - * - * @throws \Exception - */ - public function getSignedProtocolJwt(Builder $builder): UnencryptedToken - { - $headers = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($this->moduleConfig->getProtocolCertPath()), - ]; - - return $this->getSignedJwt($builder, $this->protocolJwtConfig, $headers); - } - - /** - * Get signed JWT using the OpenID Federation JWT signing configuration. - * - * @throws \Exception - */ - public function getSignedFederationJwt(Builder $builder): UnencryptedToken - { - if (is_null($this->federationJwtConfig)) { - throw OidcServerException::serverError('Federation JWT PKI configuration is not set.'); - } - - $headers = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($this->moduleConfig->getFederationCertPath()), - ]; - - return $this->getSignedJwt($builder, $this->federationJwtConfig, $headers); - } - - /** - * Get signed JWT for provided builder and JWT signing configuration, and optionally with any additional headers to - * include. - */ - public function getSignedJwt( - Builder $builder, - Configuration $jwtConfig, - array $headers = [], - ): UnencryptedToken { - /** - * @var non-empty-string $headerKey - * @psalm-suppress MixedAssignment - */ - foreach ($headers as $headerKey => $headerValue) { - $builder = $builder->withHeader($headerKey, $headerValue); - } - - return $builder->getToken($jwtConfig->signer(), $jwtConfig->signingKey()); - } - - /** - * @throws \ReflectionException - */ - public function getProtocolSigner(): Signer - { - return $this->moduleConfig->getProtocolSigner(); - } -} diff --git a/tests/config/module_oidc.php b/tests/config/module_oidc.php index c481f5f7..74cf67db 100644 --- a/tests/config/module_oidc.php +++ b/tests/config/module_oidc.php @@ -13,7 +13,6 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -use Lcobucci\JWT\Signer\Rsa\Sha256; use SimpleSAML\Module\oidc\ModuleConfig; $config = [ @@ -23,8 +22,6 @@ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', @@ -98,14 +95,6 @@ ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'abc123', - ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_ORGANIZATION_NAME => 'Foo corp', ModuleConfig::OPTION_DISPLAY_NAME => 'Foo corp', ModuleConfig::OPTION_DESCRIPTION => 'Foo provider', @@ -116,6 +105,5 @@ ModuleConfig::OPTION_LOGO_URI => 'https://example.org/logo', ModuleConfig::OPTION_POLICY_URI => 'https://example.org/policy', ModuleConfig::OPTION_INFORMATION_URI => 'https://example.org/info', - ModuleConfig::OPTION_HOMEPAGE_URI => 'https://example.org', ModuleConfig::OPTION_ORGANIZATION_URI => 'https://example.org', ]; diff --git a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php index 64e8f1e3..0c7416dd 100644 --- a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php +++ b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php @@ -12,20 +12,17 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Federation; -use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\Jwks; #[CoversClass(EntityStatementController::class)] class EntityStatementControllerTest extends TestCase { protected MockObject $moduleConfigMock; - protected MockObject $jsonWebTokenBuilderServiceMock; protected MockObject $jwksMock; protected MockObject $opMetadataServiceMock; protected MockObject $clientRepositoryMock; @@ -39,53 +36,45 @@ class EntityStatementControllerTest extends TestCase protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->jsonWebTokenBuilderServiceMock = $this->createMock(JsonWebTokenBuilderService::class); $this->jwksMock = $this->createMock(Jwks::class); $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); $this->helpersMock = $this->createMock(Helpers::class); $this->routesMock = $this->createMock(Routes::class); $this->federationMock = $this->createMock(Federation::class); - $this->jwkMock = $this->createMock(Jwk::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); $this->federationCacheMock = $this->createMock(FederationCache::class); } protected function sut( ?ModuleConfig $moduleConfig = null, - ?JsonWebTokenBuilderService $jsonWebTokenBuilderService = null, ?Jwks $jwks = null, ?OpMetadataService $opMetadataService = null, ?ClientRepository $clientRepository = null, ?Helpers $helpers = null, ?Routes $routes = null, ?Federation $federation = null, - ?Jwk $jwk = null, ?LoggerService $loggerService = null, ?FederationCache $federationCache = null, ): EntityStatementController { $moduleConfig ??= $this->moduleConfigMock; - $jsonWebTokenBuilderService ??= $this->jsonWebTokenBuilderServiceMock; $jwks ??= $this->jwksMock; $opMetadataService ??= $this->opMetadataServiceMock; $clientRepository ??= $this->clientRepositoryMock; $helpers ??= $this->helpersMock; $routes ??= $this->routesMock; $federation ??= $this->federationMock; - $jwk ??= $this->jwkMock; $loggerService ??= $this->loggerServiceMock; $federationCache ??= $this->federationCacheMock; return new EntityStatementController( $moduleConfig, - $jsonWebTokenBuilderService, $jwks, $opMetadataService, $clientRepository, $helpers, $routes, $federation, - $jwk, $loggerService, $federationCache, ); diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index d9b92ca5..c79e9513 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -5,8 +5,6 @@ namespace SimpleSAML\Test\Module\oidc\unit; use DateInterval; -use Lcobucci\JWT\Signer; -use Lcobucci\JWT\Signer\Rsa\Sha256; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -18,7 +16,6 @@ use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\Utils\Config; use SimpleSAML\Utils\HTTP; -use stdClass; #[CoversClass(ModuleConfig::class)] class ModuleConfigTest extends TestCase @@ -36,8 +33,6 @@ class ModuleConfigTest extends TestCase ModuleConfig::OPTION_CRON_TAG => 'hourly', - ModuleConfig::OPTION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', @@ -55,12 +50,6 @@ class ModuleConfigTest extends TestCase ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => null, - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'abc123', - ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ 'abc123', ], @@ -135,28 +124,6 @@ public function testCanGetCommonOptions(): void ); } - /** - * @throws \Exception - */ - public function testSigningKeyNameCanBeCustomized(): void - { - // Test default cert and pem - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, - $this->sut()->getProtocolCertPath(), - ); - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - $this->sut()->getProtocolPrivateKeyPath(), - ); - - // Set customized - $this->overrides[ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME] = 'myPrivateKey.key'; - $this->overrides[ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME] = 'myCertificate.crt'; - $this->assertStringContainsString('myCertificate.crt', $this->sut()->getProtocolCertPath()); - $this->assertStringContainsString('myPrivateKey.key', $this->sut()->getProtocolPrivateKeyPath()); - } - public function testCanGetSspConfig(): void { $this->assertInstanceOf(Configuration::class, $this->sut()->sspConfig()); @@ -172,11 +139,6 @@ public function testCanGetOpenIdScopes(): void $this->assertNotEmpty($this->sut()->getScopes()); } - public function testCanGetProtocolSigner(): void - { - $this->assertInstanceOf(Signer::class, $this->sut()->getProtocolSigner()); - } - public function testCanGetAuthProcFilters(): void { $this->assertIsArray($this->sut()->getAuthProcFilters()); @@ -219,16 +181,6 @@ public function testCanGetUserIdentifierAttribute(): void public function testCanGetCommonFederationOptions(): void { $this->assertFalse($this->sut()->getFederationEnabled()); - $this->assertInstanceOf(Signer::class, $this->sut()->getFederationSigner()); - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - $this->sut()->getFederationPrivateKeyPath(), - ); - $this->assertNotEmpty($this->sut()->getFederationPrivateKeyPassPhrase()); - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - $this->sut()->getFederationCertPath(), - ); $this->assertNotEmpty($this->sut()->getFederationEntityStatementDuration()); $this->assertNotEmpty($this->sut()->getFederationEntityStatementCacheDurationForProduced()); $this->assertNotEmpty($this->sut()->getFederationAuthorityHints()); @@ -241,7 +193,6 @@ public function testCanGetCommonFederationOptions(): void $this->assertNotEmpty($this->sut()->getLogoUri()); $this->assertNotEmpty($this->sut()->getPolicyUri()); $this->assertNotEmpty($this->sut()->getInformationUri()); - $this->assertNotEmpty($this->sut()->getHomepageUri()); $this->assertNotEmpty($this->sut()->getOrganizationUri()); $this->assertNotEmpty($this->sut()->getFederationCacheAdapterClass()); $this->assertIsArray($this->sut()->getFederationCacheAdapterArguments()); @@ -360,13 +311,6 @@ public function testThrowsIForcedAcrValueForCookieAuthenticationNotAllowed(): vo $this->sut(); } - public function testThrowsIfInvalidSignerProvided(): void - { - $this->overrides[ModuleConfig::OPTION_TOKEN_SIGNER] = stdClass::class; - $this->expectException(ConfigurationError::class); - $this->sut()->getProtocolSigner(); - } - public function testCanGetEncryptionKey(): void { $this->sspBridgeUtilsConfigMock->expects($this->once())->method('getSecretSalt') @@ -394,28 +338,6 @@ public function testCanGetProtocolDiscoveryShowClaimsSupported(): void ); } - public function testCanGetProtocolNewCertPath(): void - { - $this->assertNull($this->sut()->getProtocolNewCertPath()); - - $sut = $this->sut( - overrides: [ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new-cert'], - ); - - $this->assertStringContainsString('new-cert', $sut->getProtocolNewCertPath()); - } - - public function testCanGetFederationNewCertPath(): void - { - $this->assertNull($this->sut()->getFederationNewCertPath()); - - $sut = $this->sut( - overrides: [ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new-cert'], - ); - - $this->assertStringContainsString('new-cert', $sut->getFederationNewCertPath()); - } - public function testCanGetFederationDynamicTrustMarks(): void { $this->assertNull($this->sut()->getFederationDynamicTrustMarks()); diff --git a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php index 05b77cce..d8137a8e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php @@ -4,7 +4,6 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; -use Lcobucci\JWT\Signer\Rsa\Sha256; use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -59,7 +58,6 @@ protected function setUp(): void $this->resultBagStub = $this->createStub(ResultBagInterface::class); $this->moduleConfigStub = $this->createStub(ModuleConfig::class); - $this->moduleConfigStub->method('getProtocolSigner')->willReturn(new Sha256()); $this->moduleConfigStub->method('getIssuer')->willReturn(self::$issuer); $this->loggerServiceStub = $this->createStub(LoggerService::class); diff --git a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index 99b3ddac..cf9a1b24 100644 --- a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -7,7 +7,6 @@ use DateTimeImmutable; use Exception; use Laminas\Diactoros\Response; -use Lcobucci\JWT\Signer\Rsa\Sha256; use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -22,7 +21,6 @@ use SimpleSAML\Module\oidc\Repositories\Interfaces\IdentityProviderInterface; use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -100,15 +98,7 @@ protected function setUp(): void ->willReturn($this->userEntity); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->moduleConfigMock->method('getProtocolSigner')->willReturn(new Sha256()); $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); - $this->moduleConfigMock->method('getProtocolCertPath') - ->willReturn($this->certFolder . '/oidc_module.crt'); - $this->moduleConfigMock->method('getProtocolPrivateKeyPath') - ->willReturn($this->certFolder . '/oidc_module.key'); - $this->moduleConfigMock - ->expects($this->atLeast(1)) - ->method('getProtocolPrivateKeyPassPhrase'); $this->sspConfigurationMock = $this->createMock(Configuration::class); $this->moduleConfigMock->method('config') ->willReturn($this->sspConfigurationMock); @@ -123,7 +113,6 @@ protected function setUp(): void $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); $this->idTokenBuilder = new IdTokenBuilder( - new JsonWebTokenBuilderService($this->moduleConfigMock), new ClaimTranslatorExtractor(self::USER_ID_ATTR, $this->claimSetEntityFactoryStub), $this->coreMock, $this->moduleConfigMock, diff --git a/tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php b/tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php deleted file mode 100644 index db69df27..00000000 --- a/tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php +++ /dev/null @@ -1,120 +0,0 @@ -moduleConfigStub = $this->createStub(ModuleConfig::class); - $this->moduleConfigStub->method('getProtocolSigner')->willReturn(self::$signerSha256); - $this->moduleConfigStub->method('getProtocolPrivateKeyPath')->willReturn(self::$privateKeyPath); - $this->moduleConfigStub->method('getProtocolCertPath')->willReturn(self::$publicKeyPath); - $this->moduleConfigStub->method('getIssuer')->willReturn(self::$selfUrlHost); - } - - /** - * @throws \ReflectionException - * @throws \League\OAuth2\Server\Exception\OAuthServerException - */ - public function testCanCreateBuilderInstance(): void - { - $builderService = new JsonWebTokenBuilderService($this->moduleConfigStub); - - $this->assertInstanceOf( - Builder::class, - $builderService->getProtocolJwtBuilder(), - ); - } - - /** - * @throws \ReflectionException - * @throws \League\OAuth2\Server\Exception\OAuthServerException - * @throws \Exception - */ - public function testCanGenerateSignedJwtToken(): void - { - $builderService = new JsonWebTokenBuilderService($this->moduleConfigStub); - $tokenBuilder = $builderService->getProtocolJwtBuilder(); - - $unencryptedToken = $builderService->getSignedProtocolJwt($tokenBuilder); - - $this->assertInstanceOf(UnencryptedToken::class, $unencryptedToken); - $this->assertSame(self::$selfUrlHost, $unencryptedToken->claims()->get('iss')); - - // Check token signature - $token = $unencryptedToken->toString(); - - $jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file( - $this->moduleConfigStub->getProtocolPrivateKeyPath(), - $this->moduleConfigStub->getProtocolPrivateKeyPassPhrase() ?? '', - ), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ); - - $parsedToken = $jwtConfig->parser()->parse($token); - - $this->assertTrue( - $jwtConfig->validator()->validate( - $parsedToken, - new IssuedBy(self::$selfUrlHost), - new SignedWith( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ), - ), - ); - } - - /** - * @throws \ReflectionException - */ - public function testCanReturnCurrentSigner(): void - { - $this->assertSame( - self::$signerSha256, - (new JsonWebTokenBuilderService($this->moduleConfigStub))->getProtocolSigner(), - ); - } -} diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 514b8ca2..f354b630 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -4,7 +4,6 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; -use Lcobucci\JWT\Signer\Rsa; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; @@ -55,10 +54,6 @@ public function setUp(): void }); $this->moduleConfigMock->method('getAcrValuesSupported')->willReturn(['1']); - $signer = $this->createMock(Rsa::class); - $signer->method('algorithmId')->willReturn('RS256'); - $this->moduleConfigMock->method('getProtocolSigner')->willReturn($signer); - $this->claimTranslatorExtractorMock = $this->createMock(ClaimTranslatorExtractor::class); $this->signatureAlgorithmBag = $this->createMock(SignatureAlgorithmBag::class); From 9a4e9e3d3054faa3a7c8d572f48b9f389b359182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 16 Jan 2026 12:15:59 +0100 Subject: [PATCH 12/17] WIP --- config/module_oidc.php.dist | 156 +++++++++++------- docker/ssp/module_oidc.php | 2 +- docs/1-oidc.md | 3 +- docs/2-oidc-installation.md | 124 ++++++++++---- docs/6-oidc-upgrade.md | 13 +- .../VerifiableCredentailsTestController.php | 2 +- .../Api/VciCredentialOfferApiController.php | 2 +- src/Controllers/JwksController.php | 2 +- ...redentialIssuerConfigurationController.php | 5 +- .../CredentialIssuerCredentialController.php | 19 ++- src/Entities/AccessTokenEntity.php | 2 +- src/Factories/CredentialOfferUriFactory.php | 2 +- src/Factories/CryptKeyFactory.php | 2 +- .../Entities/ClientEntityFactory.php | 2 +- .../Entities/IssuerStateEntityFactory.php | 2 +- src/ModuleConfig.php | 109 ++++++------ .../Rules/ClientRedirectUriRule.php | 4 +- src/Server/RequestRules/Rules/ClientRule.php | 2 +- .../RequestRules/Rules/IdTokenHintRule.php | 2 +- .../Validators/BearerTokenValidator.php | 2 +- src/Services/IdTokenBuilder.php | 29 ++-- src/Services/LogoutTokenBuilder.php | 2 +- src/Services/OpMetadataService.php | 2 +- .../src/Entities/AccessTokenEntityTest.php | 2 +- .../ResponseTypes/TokenResponseTest.php | 10 +- .../src/Services/LogoutTokenBuilderTest.php | 18 +- .../src/Services/OpMetadataServiceTest.php | 2 +- 27 files changed, 318 insertions(+), 204 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 1e6daf16..ae881666 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -37,9 +37,8 @@ $config = [ */ // ModuleConfig::OPTION_ISSUER => 'https://op.example.org', - /** - * Protocol (Connect) signature algorithm and key-pair definitions, + * Connect protocol signature algorithm and key-pair definitions, * representing supported algorithms for signing, for example, ID Token JWS. * The order in which the entries are set is important. The entry set * first will have higher priority during signing algorithm negotiation @@ -74,22 +73,24 @@ $config = [ * Note: in v7 of the module, a new way of automatic key ID generation is * used. In previous versions, a hash of a public key file was used as a * key ID. In v7, a public key thumbprint is used. If you are migrating from - * previous version of the module, and you want to keep the old signing key, - * you should manually set the key ID to the previous value, so that clients - * know that the key did not change. + * a previous version of the module, and you want to keep the old signing + * key, you should manually set the key ID to the previous value + * so that clients know that the key did not change. */ - ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + ModuleConfig::OPTION_CONNECT_SIGNATURE_KEY_PAIRS => [ [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, - ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_rsa_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_rsa_01.pub', // ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional // ModuleConfig::KEY_KEY_ID => 'rsa-connect-signing-key-2026', // Optional ], + // Example for additionally supported ES256 algorithm with EC keys. + // Delete it if not needed: [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, - ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_ec256.key', - ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_ec256.pub', + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_ec_p256_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_ec_p256_01.pub', // ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional // ModuleConfig::KEY_KEY_ID => 'ec-connect-signing-key-01', // Optional ], @@ -439,12 +440,12 @@ $config = [ ], /** - * Pagination options, for example, on client listing page. + * Pagination options, for example, on the client listing page. */ ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => 20, /*************************************************************************** - * (optional) OpenID Federation related options. If these are not set, + * (optional) OpenID Federation-related options. If these are not set, * OpenID Federation capabilities will be disabled. **************************************************************************/ @@ -453,6 +454,31 @@ $config = [ */ ModuleConfig::OPTION_FEDERATION_ENABLED => false, + /** + * Federation signature algorithm and key-pair definitions, representing + * supported algorithms for signing, for example, Entity Statements. + * The first algorithm in the list will be used for signing (the + * first entry represents the default algorithm and signing key). + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Add those entries later in + * the list, so they can be published in Federation JWKS. + * + * Note that these keys SHOULD NOT be the same as the ones used in the + * protocol (Connect) itself. + * + * The format is the same as for the protocol (Connect) signature key pairs + * (option ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS) + */ + ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_federation_rsa_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_federation_rsa_01.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'rsa-federation-signing-key-01', // Optional + ], + ], + /** * Trust Anchors which are valid for this entity. The key represents the * Trust Anchor Entity ID, while the value can be the Trust Anchor's JWKS @@ -502,19 +528,22 @@ $config = [ // 'trust-mark-type' => 'trust-mark-issuer-id', ], - // (optional) Federation participation limit by Trust Marks. This is an - // array with the following format: - // [ - // 'trust-anchor-id' => [ - // 'limit-id' => [ - // 'trust-mark-type', - // 'trust-mark-type-2', - // ], - // ], - // ], - // Check example below on how this can be used. If federation participation - // limit is configured for particular Trust Anchor ID, at least one - // combination of "limit ID" => "trust mark list" should be defined. + /** + * (optional) Federation participation limit by Trust Marks. This is an + * array with the following format: + * [ + * 'trust-anchor-id' => [ + * 'limit-id' => [ + * 'trust-mark-type', + * 'trust-mark-type-2', + * ], + * ], + * ], + * Check the example below on how this can be used. If a federation + * participation limit is configured for a particular Trust Anchor ID, at + * least one combination of "limit ID" => "trust mark list" should be + * defined. + */ ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS => [ // We are limiting federation participation using Trust Marks for // 'https://ta.example.org/'. @@ -578,32 +607,6 @@ $config = [ */ ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours - - /** - * Federation signature algorithm and key-pair definitions, representing - * supported algorithms for signing, for example, Entity Statements. - * The first algorithm in the list will be used for signing (the - * first entry represents default algorithm and signing key). - * You can also use this config option to advertise any (new) keys, for - * example, for key-rollover scenarios. Just add those entries later in - * the list, so they can be published in Federation JWKS. - * - * Note that these keys SHOULD NOT be the same as the ones used in the - * protocol (Connect) itself. - * - * The format is the same as for the protocol (Connect) signature key pairs - * (option ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS) - */ - ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ - [ - ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, - ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, -// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional -// ModuleConfig::KEY_KEY_ID => 'ec-connect-signing-key-01', // Optional - ], - ], - /** * Federation entity statement duration which determines the Expiration Time * (exp) claim set in entity statement JWSs published by this OP. If not @@ -649,13 +652,38 @@ $config = [ * Enable or disable verifiable credentials capabilities. Default is * disabled (false). */ - ModuleConfig::OPTION_VERIFIABLE_CREDENTIAL_ENABLED => false, + ModuleConfig::OPTION_VCI_ENABLED => false, + + /** + * Verifiable Credential signature algorithm and key-pair definitions, + * representing supported algorithms for signing verifiable credentials. + * The first algorithm in the list will be used for signing (the + * first entry represents the default algorithm and signing key). + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Add those entries later in + * the list, so they can be published in appropriate JWKS. + * + * Note that these keys SHOULD NOT be the same as the ones used in the + * protocol (Connect) itself. + * + * The format is the same as for the protocol (Connect) signature key pairs + * (option ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS) + */ + ModuleConfig::OPTION_VCI_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_vci_ec_p256_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_vci_ec_p256_01.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'ec-vci-signing-key-01', // Optional + ], + ], /** * Allow or disallow non-registered clients to request verifiable * credentials. Default is disallowed (false). */ - ModuleConfig::OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI => false, + ModuleConfig::OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS => false, /** * Allowed redirect URI prefixes for non-registered clients. By default, this is set to @@ -667,7 +695,7 @@ $config = [ * 'https://example.org/redirect2', * ] */ - ModuleConfig::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI => [ + ModuleConfig::OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS => [ 'openid-credential-offer://', ], @@ -677,7 +705,7 @@ $config = [ * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#credential-issuer-parameters. * Check the example below on how this can be used. */ - ModuleConfig::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ // Sample for 'jwt_vc_json' format with notes about required and // optional fields. 'ResearchAndScholarshipCredentialJwtVcJson' => [ @@ -945,7 +973,7 @@ $config = [ * ], * ], */ - ModuleConfig::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP => [ + ModuleConfig::OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP => [ 'ResearchAndScholarshipCredentialJwtVcJson' => [ ['eduPersonPrincipalName' => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName']], ['eduPersonTargetedID' => [ClaimsEnum::Credential_Subject->value, 'eduPersonTargetedID']], @@ -966,12 +994,19 @@ $config = [ ], ], + /** + * (optional) Issuer State TTL (validity duration), with the given example. + * If not set, falls back to Authorization Code TTL. For duration format + * info, check https://www.php.net/manual/en/dateinterval.construct.php + */ + ModuleConfig::OPTION_VCI_ISSUER_STATE_TTL => 'PT10M', // 10 minutes + /** * Map of authentication sources and user's email attribute names. This * enables you to define a specific attribute name which contains the * user's email address, per authentication source. This is used, for * example, to send Transaction Code in the case of pre-authorized - * codes for verifiable credential issuance. If not set, the default + * codes for Verifiable Credential Issuance. If not set, the default * user's email attribute name will be used (see the option below). * * Format is: 'authentication-source-id' => 'email-attribute-name'. @@ -986,13 +1021,6 @@ $config = [ */ ModuleConfig::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME => 'mail', - /** - * (optional) Issuer State TTL (validity duration), with the given example. - * If not set, falls back to Authorization Code TTL. For duration format - * info, check https://www.php.net/manual/en/dateinterval.construct.php - */ - ModuleConfig::OPTION_ISSUER_STATE_TTL => 'PT10M', // 10 minutes - /*************************************************************************** * (optional) API-related options. diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index 5aacdb73..aa87c6df 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -21,7 +21,7 @@ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + ModuleConfig::OPTION_CONNECT_SIGNATURE_KEY_PAIRS => [ [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, diff --git a/docs/1-oidc.md b/docs/1-oidc.md index 60fbdc51..a5891f74 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -13,8 +13,7 @@ Supported flows: ## Note on OpenID Federation (OIDFed) -OpenID Federation support is in draft, as is the -[specification](https://openid.net/specs/openid-federation-1_0). You can +OpenID Federation support is in draft phase. You can expect breaking changes in future releases related to OIDFed capabilities. OIDFed can be enabled or disabled in the module configuration. diff --git a/docs/2-oidc-installation.md b/docs/2-oidc-installation.md index 776ede2b..001dfc60 100644 --- a/docs/2-oidc-installation.md +++ b/docs/2-oidc-installation.md @@ -22,8 +22,8 @@ cp modules/oidc/config/module_oidc.php.dist config/module_oidc.php ## 3. Configure the database -The module uses SimpleSAMLphp's database feature to store access and -refresh tokens, user data, and other artifacts. Edit `config/config.php` +The module uses SimpleSAMLphp's database feature to store Access and +Refresh tokens, user data, and other artifacts. Edit `config/config.php` and ensure at least the following parameters are set: ```php @@ -34,83 +34,135 @@ and ensure at least the following parameters are set: Note: SQLite, PostgreSQL, and MySQL are supported. -## 4. Create key pairs +## 4. Create signature key pairs -ID and Access tokens are signed JWTs. Create a public/private RSA key -pair for OIDC protocol operations. If you plan to use OpenID Federation, -create a separate key pair for federation operations. +In order to sign JWS artifacts (ID Tokens, Entity Statements, Verifiable +Credentials, etc.), you must create a public / private key pair for each +signature algorithm that you want to support. You should use different +keys for protocol (Connect), Federation and Verifiable Credential (VCI) +operations. You must have at least one algorithm / key-pair for protocol +(Connect), and for Federation and VCI if you use those features. -### RSA key pair generation +### RSA key pair generation, for `RS256/384/512` and `PS256/384/512` algorithms -Generate private keys without a passphrase: +Generate private keys without a password: ```bash -openssl genrsa -out cert/oidc_module.key 3072 -openssl genrsa -out cert/oidc_module_federation.key 3072 +openssl genrsa -out cert/oidc_module_connect_rsa_01.key 3072 +openssl genrsa -out cert/oidc_module_federation_rsa_01.key 3072 +openssl genrsa -out cert/oidc_module_vci_rsa_01.key 3072 ``` -Generate private keys with a passphrase: +Generate private keys with a password: ```bash -openssl genrsa -passout pass:myPassPhrase -out cert/oidc_module.key 3072 -openssl genrsa -passout pass:myPassPhrase -out cert/oidc_module_federation.key 3072 +openssl genrsa -passout pass:somePassword -out cert/oidc_module_connect_rsa_01.key 3072 +openssl genrsa -passout pass:somePassword -out cert/oidc_module_federation_rsa_01.key 3072 +openssl genrsa -passout pass:somePassword -out cert/oidc_module_vci_rsa_01.key 3072 ``` Extract public keys: -Without passphrase: +Without password: ```bash -openssl rsa -in cert/oidc_module.key -pubout -out cert/oidc_module.crt -openssl rsa -in cert/oidc_module_federation.key -pubout -out cert/oidc_module_federation.crt +openssl rsa -in cert/oidc_module_connect_rsa_01.key -pubout -out cert/oidc_module_connect_rsa_01.pub +openssl rsa -in cert/oidc_module_federation_rsa_01.key -pubout -out cert/oidc_module_federation_rsa_01.pub +openssl rsa -in cert/oidc_module_vci_rsa_01.key -pubout -out cert/oidc_module_vci_rsa_01.pub ``` -With a passphrase: +With a password: ```bash -openssl rsa -in cert/oidc_module.key -passin pass:myPassPhrase -pubout -out cert/oidc_module.crt -openssl rsa -in cert/oidc_module_federation.key -passin pass:myPassPhrase -pubout -out cert/oidc_module_federation.crt +openssl rsa -in cert/oidc_module_connect_rsa_01.key -passin pass:somePassword -pubout -out cert/oidc_module_connect_rsa_01.pub +openssl rsa -in cert/oidc_module_federation_rsa_01.key -passin pass:somePassword -pubout -out cert/oidc_module_federation_rsa_01.pub +openssl rsa -in cert/oidc_module_vci_rsa_01.key -passin pass:somePassword -pubout -out cert/oidc_module_vci_rsa_01.pub ``` -If you use different file names or a passphrase, update -`config/module_oidc.php` accordingly. +Enter algorithm, key file names, and a password (if used) in `config/module_oidc.php` accordingly. -### EC key pair generation +### EC key pair generation, per curve for different algorithms If you prefer to use Elliptic Curve Cryptography (ECC) instead of RSA. -Generate private keys without a passphrase: +Generate private EC P‑256 keys without a password, usable for `ES256` algorithm: ```bash -openssl ecparam -name prime256v1 -genkey -noout -out cert/oidc_module.key -openssl ecparam -name prime256v1 -genkey -noout -out cert/oidc_module_federation.key +openssl ecparam -genkey -name prime256v1 -noout -out cert/oidc_module_connect_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 -noout -out cert/oidc_module_federation_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 -noout -out cert/oidc_module_vci_ec_p256_01.key ``` -Generate private keys with a passphrase: +Generate private EC P‑256 keys with a password, usable for `ES256` algorithm: ```bash -openssl ecparam -genkey -name secp384r1 -noout -out cert/oidc_module.key -passout pass:myPassPhrase -openssl ecparam -genkey -name secp384r1 -noout -out cert/oidc_module_federation.key -passout pass:myPassPhrase +openssl ecparam -genkey -name prime256v1 | openssl ec -AES-128-CBC -passout pass:somePassword -out cert/oidc_module_connect_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 | openssl ec -AES-128-CBC -passout pass:somePassword -out cert/oidc_module_federation_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 | openssl ec -AES-128-CBC -passout pass:somePassword -out cert/oidc_module_vci_ec_p256_01.key ``` Extract public keys: -Without passphrase: +Without password: ```bash -openssl ec -in cert/oidc_module.key -pubout -out cert/oidc_module.crt -openssl ec -in cert/oidc_module_federation.key -pubout -out cert/oidc_module_federation.crt +openssl ec -in cert/oidc_module_connect_ec_p256_01.key -pubout -out cert/oidc_module_connect_ec_p256_01.pub +openssl ec -in cert/oidc_module_federation_ec_p256_01.key -pubout -out cert/oidc_module_federation_ec_p256_01.pub +openssl ec -in cert/oidc_module_vci_ec_p256_01.key -pubout -out cert/oidc_module_vci_ec_p256_01.pub ``` -With a passphrase: +With a password: ```bash -openssl ec -in cert/oidc_module.key -passin pass:myPassPhrase -pubout -out cert/oidc_module.crt -openssl ec -in cert/oidc_module.key -passin pass:myPassPhrase -pubout -out cert/oidc_module.crt +openssl ec -in cert/oidc_module_connect_ec_p256_01.key -passin pass:somePassword -pubout -out cert/oidc_module_connect_ec_p256_01.pub +openssl ec -in cert/oidc_module_federation_ec_p256_01.key -passin pass:somePassword -pubout -out cert/oidc_module_federation_ec_p256_01.pub +openssl ec -in cert/oidc_module_vci_ec_p256_01.key -passin pass:somePassword -pubout -out cert/oidc_module_vci_ec_p256_01.pub ``` -If you use different file names or a passphrase, update -`config/module_oidc.php` accordingly. +For other curves, replace the `-name` option value depending on which +algorithm you want to support: +- `-name secp384r1`: usable for `ES384` algorithm +- `-name secp521r1`: usable for `ES512` algorithm + +Enter algorithm, key file names, and a password (if used) in `config/module_oidc.php` accordingly. + +### Ed25519 key pair generation, for `EdDSA` algorithm + +Generate private keys without a password: + +```bash +openssl genpkey -algorithm ED25519 -out cert/oidc_module_connect_ed25519_01.key +openssl genpkey -algorithm ED25519 -out cert/oidc_module_federation_ed25519_01.key +openssl genpkey -algorithm ED25519 -out cert/oidc_module_vci_ed25519_01.key +``` + +Generate private keys with a password: + +```bash +openssl genpkey -algorithm ED25519 -AES-128-CBC -pass pass:somePassword -out cert/oidc_module_connect_ed25519_01.key +openssl genpkey -algorithm ED25519 -AES-128-CBC -pass pass:somePassword -out cert/oidc_module_federation_ed25519_01.key +openssl genpkey -algorithm ED25519 -AES-128-CBC -pass pass:somePassword -out cert/oidc_module_vci_ed25519_01.key +``` + +Extract public keys: + +Without password: + +```bash +openssl pkey -in cert/oidc_module_connect_ed25519_01.key -pubout -out cert/oidc_module_connect_ed25519_01.pub +openssl pkey -in cert/oidc_module_federation_ed25519_01.key -pubout -out cert/oidc_module_federation_ed25519_01.pub +openssl pkey -in cert/oidc_module_vci_ed25519_01.key -pubout -out cert/oidc_module_vci_ed25519_01.pub +``` + +With a password: + +```bash +openssl pkey -in cert/oidc_module_connect_ed25519_01.key -passin pass:somePassword -pubout -out cert/oidc_module_connect_ed25519_01.pub +openssl pkey -in cert/oidc_module_federation_ed25519_01.key -passin pass:somePassword -pubout -out cert/oidc_module_federation_ed25519_01.pub +openssl pkey -in cert/oidc_module_vci_ed25519_01.key -passin pass:somePassword -pubout -out cert/oidc_module_vci_ed25519_01.pub +``` + +Enter algorithm, key file names, and a password (if used) in `config/module_oidc.php` accordingly. ## 5. Enable the module diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 3e603143..5480aeb1 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -57,8 +57,19 @@ and key pair are removed: Instead of those options, now you must use option `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` in which you can define all the supported signature keys for Federation purposes. -- Removed config option `ModuleConfig::OPTION_HOMEPAGE_URI`. Use +- Config option `ModuleConfig::OPTION_HOMEPAGE_URI` is removed. Use `ModuleConfig::OPTION_ORGANIZATION_URI` instead. +- New algorithm for generating Key ID claim value (`kid`) for signature keys +is used. Previously, key ID was based on public key file hash. In v7, key ID +is a thumbprint of the public key as per +https://datatracker.ietf.org/doc/html/rfc7638. If you want to keep using your +current signature keys, you will probably want to keep the old `kid` values, +so that the clients know the keys did not change. You can set the old +`kid` value manually for signature keys in +`ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` and +`ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS`. Once you do a key +roll-over, you can omit setting the `kid` manually, so you start using the +automatically generated thumbprint. - In v6 of the module, when defining custom scopes, there was a possibility to use standard claims with the 'are_multiple_claim_values_allowed' option. This would allow multiple values (array of values) for standard claims which diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index fe5f26d7..9f090d6a 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -100,7 +100,7 @@ public function verifiableCredentialIssuance(Request $request): Response $selectedCredentialConfigurationId = $newCredentialConfigurationId; } - $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + $credentialConfigurationIdsSupported = $this->moduleConfig->getVciCredentialConfigurationIdsSupported(); if (empty($credentialConfigurationIdsSupported)) { $setupErrors[] = 'No credential configuration IDs configured.'; diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index 58f29524..a91472e3 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -81,7 +81,7 @@ public function credentialOffer(Request $request): Response ); } - $credentialConfiguration = $this->moduleConfig->getCredentialConfiguration($credentialConfigurationId); + $credentialConfiguration = $this->moduleConfig->getVciCredentialConfiguration($credentialConfigurationId); if (!is_array($credentialConfiguration)) { $this->loggerService->error( diff --git a/src/Controllers/JwksController.php b/src/Controllers/JwksController.php index 9d71524f..2149c5d7 100644 --- a/src/Controllers/JwksController.php +++ b/src/Controllers/JwksController.php @@ -38,7 +38,7 @@ public function __invoke(): JsonResponse { return new JsonResponse( $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + ...$this->moduleConfig->getConnectSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(), ); } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index d6320574..830d1134 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -40,9 +40,10 @@ public function configuration(): Response { // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p - $signatureKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairBag()->getFirstOrFail(); + // TODO mivanci Add support for multiple signature key pairs. For now, we only support (first) one. + $signatureKeyPair = $this->moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail(); - $credentialConfigurationsSupported = $this->moduleConfig->getCredentialConfigurationsSupported(); + $credentialConfigurationsSupported = $this->moduleConfig->getVciCredentialConfigurationsSupported(); // For now, we only support one credential signing algorithm. /** @psalm-suppress MixedAssignment */ diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index bea39c49..d8732b23 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -323,7 +323,7 @@ public function credential(Request $request): Response ['credentialDefinitionType' => $credentialDefinitionType], ); $fallbackCredentialConfigurationId = - $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( + $this->moduleConfig->getVciCredentialConfigurationIdForCredentialDefinitionType( $credentialDefinitionType, ); } elseif ( @@ -360,7 +360,7 @@ public function credential(Request $request): Response ); } - $resolvedCredentialConfiguration = $this->moduleConfig->getCredentialConfiguration( + $resolvedCredentialConfiguration = $this->moduleConfig->getVciCredentialConfiguration( $resolvedCredentialIdentifier, ); if (!is_array($resolvedCredentialConfiguration)) { @@ -492,12 +492,12 @@ 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->getValidCredentialClaimPathsFor($resolvedCredentialIdentifier); + $validClaimPaths = $this->moduleConfig->getVciValidCredentialClaimPathsFor($resolvedCredentialIdentifier); // Map user attributes to credential claims $credentialSubject = []; // For JwtVcJson $disclosureBag = $this->verifiableCredentials->disclosureBagFactory()->build(); // For DcSdJwt - $attributeToCredentialClaimPathMap = $this->moduleConfig->getUserAttributeToCredentialClaimPathMapFor( + $attributeToCredentialClaimPathMap = $this->moduleConfig->getVciUserAttributeToCredentialClaimPathMapFor( $resolvedCredentialIdentifier, ); foreach ($attributeToCredentialClaimPathMap as $mapEntry) { @@ -589,13 +589,14 @@ public function credential(Request $request): Response $sub, ); - $protocolSignatureKeyPair = $this->moduleConfig - ->getProtocolSignatureKeyPairBag() + // TODO mivanci Add support for multiple signature key pairs. For now, we only support (first) one. + $vciSignatureKeyPair = $this->moduleConfig + ->getVciSignatureKeyPairBag() ->getFirstOrFail(); - $signingKey = $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(); + $signingKey = $vciSignatureKeyPair->getKeyPair()->getPrivateKey(); - $publicKey = $protocolSignatureKeyPair->getKeyPair()->getPublicKey(); + $publicKey = $vciSignatureKeyPair->getKeyPair()->getPublicKey(); $base64PublicKey = json_encode($publicKey->jwk()->all(), JSON_UNESCAPED_SLASHES); $base64PublicKey = Base64Url::encode($base64PublicKey); @@ -605,7 +606,7 @@ public function credential(Request $request): Response $issuedAt = new \DateTimeImmutable(); $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); - $signatureAlgorithm = $protocolSignatureKeyPair->getSignatureAlgorithm(); + $signatureAlgorithm = $vciSignatureKeyPair->getSignatureAlgorithm(); $verifiableCredential = null; diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index 9b46904c..28256f98 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -156,7 +156,7 @@ public function toString(): ?string */ protected function convertToJWT(): ParsedJws { - $protocolSignatureKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairBag()->getFirstOrFail(); + $protocolSignatureKeyPair = $this->moduleConfig->getConnectSignatureKeyPairBag()->getFirstOrFail(); $currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp(); $payload = array_filter([ diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index 5d314ba4..e052ba97 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -111,7 +111,7 @@ public function buildPreAuthorized( throw new RuntimeException('No credential configuration IDs provided.'); } - $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + $credentialConfigurationIdsSupported = $this->moduleConfig->getVciCredentialConfigurationIdsSupported(); if (empty($credentialConfigurationIdsSupported)) { throw new RuntimeException('No credential configuration IDs configured.'); diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index 176334fd..c3788185 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -55,7 +55,7 @@ public function buildPublicKey(): CryptKey */ protected function getDefaultProtocolSignatureKeyPairConfig(): array { - $defaultProtocolKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairs(); + $defaultProtocolKeyPair = $this->moduleConfig->getConnectSignatureKeyPairs(); /** @psalm-suppress MixedAssignment */ $defaultProtocolKeyPair = $defaultProtocolKeyPair[array_key_first($defaultProtocolKeyPair)]; diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 4322c21f..3e2e77c7 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -419,7 +419,7 @@ public function getGenericForVci(): ClientEntityInterface $clientSecret = $this->helpers->random()->getIdentifier(); - $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + $credentialConfigurationIdsSupported = $this->moduleConfig->getVciCredentialConfigurationIdsSupported(); $createdAt = $this->helpers->dateTime()->getUtc(); diff --git a/src/Factories/Entities/IssuerStateEntityFactory.php b/src/Factories/Entities/IssuerStateEntityFactory.php index 7b683dd0..b0e75b43 100644 --- a/src/Factories/Entities/IssuerStateEntityFactory.php +++ b/src/Factories/Entities/IssuerStateEntityFactory.php @@ -32,7 +32,7 @@ public function buildNew( $value ??= hash('sha256', $this->helpers->random()->getIdentifier()); $createdAt ??= $this->helpers->dateTime()->getUtc(); - $expiresAt ??= $createdAt->add($this->moduleConfig->getIssuerStateDuration()); + $expiresAt ??= $createdAt->add($this->moduleConfig->getVciIssuerStateDuration()); return $this->fromData($value, $createdAt, $expiresAt, $isRevoked); } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 997f2c33..e35d12be 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -97,22 +97,23 @@ class ModuleConfig final public const OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION = 'protocol_client_entity_cache_duration'; final public const OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED = 'protocol_discover_show_claims_supported'; - final public const OPTION_VERIFIABLE_CREDENTIAL_ENABLED = 'verifiable_credentials_enabled'; - final public const OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED = 'credential_configurations_supported'; - final public const OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = - 'user_attribute_to_credential_claim_path_map'; + final public const OPTION_VCI_ENABLED = 'vci_enabled'; + final public const OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED = 'vci_credential_configurations_supported'; + final public const OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = + 'vci_user_attribute_to_credential_claim_path_map'; final public const OPTION_API_ENABLED = 'api_enabled'; final public const OPTION_API_TOKENS = 'api_tokens'; final public const OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME = 'users_email_attribute_name'; final public const OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP = 'auth_sources_to_users_email_attribute_name_map'; - final public const OPTION_ISSUER_STATE_TTL = 'issuer_state_ttl'; - final public const OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI = 'allow_non_registered_clients_for_vci'; - final public const OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI = - 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; - final public const OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; + final public const OPTION_VCI_ISSUER_STATE_TTL = 'vci_issuer_state_ttl'; + final public const OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS = 'vci_allow_non_registered_clients'; + final public const OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS = + 'vci_allowed_redirect_uri_prefixes_for_non_registered_clients'; + final public const OPTION_CONNECT_SIGNATURE_KEY_PAIRS = 'connect_signature_key_pairs'; final public const OPTION_FEDERATION_SIGNATURE_KEY_PAIRS = 'federation_signature_key_pairs'; final public const OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; + final public const OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -143,9 +144,10 @@ class ModuleConfig * @var Configuration SimpleSAMLphp configuration instance. */ private readonly Configuration $sspConfig; - protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; - protected ?SignatureKeyPairConfigBag $protocolSignatureKeyPairConfigBag = null; + protected ?SignatureKeyPairBag $connectSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $connectSignatureKeyPairConfigBag = null; protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; + protected ?SignatureKeyPairBag $vciSignatureKeyPairBag = null; /** * @throws \Exception @@ -375,10 +377,10 @@ public function getSupportedSerializers(): SupportedSerializers * @throws ConfigurationError * @return non-empty-array */ - public function getProtocolSignatureKeyPairs(): array + public function getConnectSignatureKeyPairs(): array { - $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS); + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_CONNECT_SIGNATURE_KEY_PAIRS); if (empty($signatureKeyPairs)) { throw new ConfigurationError('At least one protocol signature key-pair pair must be provided.'); @@ -391,14 +393,14 @@ public function getProtocolSignatureKeyPairs(): array * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getProtocolSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag + public function getConnectSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag { - if ($this->protocolSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { - return $this->protocolSignatureKeyPairConfigBag; + if ($this->connectSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { + return $this->connectSignatureKeyPairConfigBag; } - return $this->protocolSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( - $this->getProtocolSignatureKeyPairs(), + return $this->connectSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( + $this->getConnectSignatureKeyPairs(), ); } @@ -406,15 +408,15 @@ public function getProtocolSignatureKeyPairConfigBag(): SignatureKeyPairConfigBa * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag + public function getConnectSignatureKeyPairBag(): SignatureKeyPairBag { - if ($this->protocolSignatureKeyPairBag instanceof SignatureKeyPairBag) { - return $this->protocolSignatureKeyPairBag; + if ($this->connectSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->connectSignatureKeyPairBag; } - return $this->protocolSignatureKeyPairBag = $this->valueAbstracts + return $this->connectSignatureKeyPairBag = $this->valueAbstracts ->signatureKeyPairBagFactory() - ->fromConfig($this->getProtocolSignatureKeyPairConfigBag()); + ->fromConfig($this->getConnectSignatureKeyPairConfigBag()); } /** @@ -831,12 +833,27 @@ public function isFederationParticipationLimitedByTrustMarksFor(string $trustAnc public function getVerifiableCredentialEnabled(): bool { - return $this->config()->getOptionalBoolean(self::OPTION_VERIFIABLE_CREDENTIAL_ENABLED, false); + return $this->config()->getOptionalBoolean(self::OPTION_VCI_ENABLED, false); } - public function getCredentialConfigurationsSupported(): array + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getVciSignatureKeyPairBag(): SignatureKeyPairBag + { + if ($this->vciSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->vciSignatureKeyPairBag; + } + + return $this->vciSignatureKeyPairBag = $this->valueAbstracts + ->signatureKeyPairBagFactory() + ->fromConfig($this->getConnectSignatureKeyPairConfigBag()); + } + + public function getVciCredentialConfigurationsSupported(): array { - return $this->config()->getOptionalArray(self::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); + return $this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); } /** @@ -844,9 +861,9 @@ public function getCredentialConfigurationsSupported(): array * @return mixed[]|null * @throws \SimpleSAML\Error\ConfigurationError */ - public function getCredentialConfiguration(string $credentialConfigurationId): ?array + public function getVciCredentialConfiguration(string $credentialConfigurationId): ?array { - $credentialConfiguration = $this->getCredentialConfigurationsSupported()[$credentialConfigurationId] ?? null; + $credentialConfiguration = $this->getVciCredentialConfigurationsSupported()[$credentialConfigurationId] ?? null; if (is_null($credentialConfiguration)) { return null; @@ -868,11 +885,11 @@ public function getCredentialConfiguration(string $credentialConfigurationId): ? /** * @return array */ - public function getCredentialConfigurationIdsSupported(): array + public function getVciCredentialConfigurationIdsSupported(): array { return array_map( 'strval', - array_keys($this->getCredentialConfigurationsSupported()), + array_keys($this->getVciCredentialConfigurationsSupported()), ); } @@ -889,16 +906,16 @@ public function getVciScopes(): array } $vciScopes = []; - foreach ($this->getCredentialConfigurationIdsSupported() as $credentialConfigurationId) { + foreach ($this->getVciCredentialConfigurationIdsSupported() as $credentialConfigurationId) { $vciScopes[$credentialConfigurationId] = ['description' => $credentialConfigurationId]; } return $vciScopes; } - public function getCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string + public function getVciCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string { foreach ( - $this->getCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration + $this->getVciCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration ) { if (!is_array($credentialConfiguration)) { continue; @@ -925,9 +942,9 @@ public function getCredentialConfigurationIdForCredentialDefinitionType(array $c * Extract and parse the claims path definition from the credential configuration supported. * Returns an array of valid paths for the claims. */ - public function getValidCredentialClaimPathsFor(string $credentialConfigurationId): array + public function getVciValidCredentialClaimPathsFor(string $credentialConfigurationId): array { - $claimsConfig = $this->getCredentialConfigurationsSupported()[$credentialConfigurationId] + $claimsConfig = $this->getVciCredentialConfigurationsSupported()[$credentialConfigurationId] [ClaimsEnum::Claims->value] ?? []; $validPaths = []; @@ -947,15 +964,15 @@ public function getValidCredentialClaimPathsFor(string $credentialConfigurationI return array_filter($validPaths); } - public function getUserAttributeToCredentialClaimPathMap(): array + public function getVciUserAttributeToCredentialClaimPathMap(): array { - return $this->config()->getOptionalArray(self::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []); + return $this->config()->getOptionalArray(self::OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []); } - public function getUserAttributeToCredentialClaimPathMapFor(string $credentialConfigurationId): array + public function getVciUserAttributeToCredentialClaimPathMapFor(string $credentialConfigurationId): array { /** @psalm-suppress MixedAssignment */ - $map = $this->getUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; + $map = $this->getVciUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; if (is_array($map)) { return $map; @@ -970,28 +987,28 @@ public function getUserAttributeToCredentialClaimPathMapFor(string $credentialCo * @return DateInterval * @throws \Exception */ - public function getIssuerStateDuration(): DateInterval + public function getVciIssuerStateDuration(): DateInterval { - $issuerStateDuration = $this->config()->getOptionalString(self::OPTION_ISSUER_STATE_TTL, null); + $issuerStateDuration = $this->config()->getOptionalString(self::OPTION_VCI_ISSUER_STATE_TTL, null); if (is_null($issuerStateDuration)) { return $this->getAuthCodeDuration(); } return new DateInterval( - $this->config()->getString(self::OPTION_ISSUER_STATE_TTL), + $this->config()->getString(self::OPTION_VCI_ISSUER_STATE_TTL), ); } - public function getAllowNonRegisteredClientsForVci(): bool + public function getVciAllowNonRegisteredClients(): bool { - return $this->config()->getOptionalBoolean(self::OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI, false); + return $this->config()->getOptionalBoolean(self::OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS, false); } - public function getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci(): array + public function getVciAllowedRedirectUriPrefixesForNonRegisteredClients(): array { return $this->config()->getOptionalArray( - self::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI, + self::OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS, ['openid-credential-offer://',], ); } diff --git a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php index ddc67e4e..537c994d 100644 --- a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -73,7 +73,7 @@ public function checkRule( if ( $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && $this->moduleConfig->getVerifiableCredentialEnabled() && - $this->moduleConfig->getAllowNonRegisteredClientsForVci() + $this->moduleConfig->getVciAllowNonRegisteredClients() ) { $loggerService->debug( 'RedirectUriRule: Verifiable Credential capabilities with non-registered clients are enabled. ' . @@ -82,7 +82,7 @@ public function checkRule( /** @psalm-suppress MixedAssignment */ foreach ( - $this->moduleConfig->getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci( + $this->moduleConfig->getVciAllowedRedirectUriPrefixesForNonRegisteredClients( ) as $clientRedirectUriPrefix ) { if (str_starts_with($redirectUri, (string)$clientRedirectUriPrefix)) { diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 0d89c2ae..4c00a8a1 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -125,7 +125,7 @@ public function checkRule( if ( $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && $this->moduleConfig->getVerifiableCredentialEnabled() && - $this->moduleConfig->getAllowNonRegisteredClientsForVci() + $this->moduleConfig->getVciAllowNonRegisteredClients() ) { $this->loggerService->debug( 'ClientRule: Verifiable Credential capabilities with non-registered clients are enabled. ' . diff --git a/src/Server/RequestRules/Rules/IdTokenHintRule.php b/src/Server/RequestRules/Rules/IdTokenHintRule.php index 8feccbf2..3955adcd 100644 --- a/src/Server/RequestRules/Rules/IdTokenHintRule.php +++ b/src/Server/RequestRules/Rules/IdTokenHintRule.php @@ -66,7 +66,7 @@ public function checkRule( } $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + ...$this->moduleConfig->getConnectSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(); $idTokenHint = $this->core->idTokenFactory()->fromToken($idTokenHintParam); diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 35ee4d8f..7a30bc5c 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -84,7 +84,7 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe try { // Attempt to validate the JWT $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + ...$this->moduleConfig->getConnectSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(); $token->verifyWithKeySet($jwks); } catch (JwsException) { diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index d8d6e132..2396fff4 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -50,7 +50,7 @@ public function buildFor( throw new RuntimeException('Client is expected to be instance of ' . ClientEntity::class); } - $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); + $protocolSignatureKeyPairBag = $this->moduleConfig->getConnectSignatureKeyPairBag(); $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); // ID Token signing algorithm that the client wants. @@ -121,16 +121,25 @@ public function buildFor( } /** - * @param string $jwsAlgorithm JWS Algorithm designation (like RS256, RS384...) + * @param string $jwsAlgorithm JWS Algorithm designation (like RS256, + * RS384...). */ public function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, string $jwsAlgorithm): string { - $validBitLengths = [256, 384, 512]; + if ($jwsAlgorithm === SignatureAlgorithmEnum::EdDSA->value) { + $hashAlgorithm = 'sha512'; + $hashByteLength = 32; // 256 bits / 8 + } else { + $validBitLengths = [256, 384, 512]; - $jwsAlgorithmBitLength = (int) substr($jwsAlgorithm, 2); + $jwsAlgorithmBitLength = (int) substr($jwsAlgorithm, 2); - if (!in_array($jwsAlgorithmBitLength, $validBitLengths, true)) { - throw new RuntimeException(sprintf('JWS algorithm not supported (%s)', $jwsAlgorithm)); + if (!in_array($jwsAlgorithmBitLength, $validBitLengths, true)) { + throw new RuntimeException(sprintf('JWS algorithm not supported (%s)', $jwsAlgorithm)); + } + + $hashAlgorithm = 'sha' . $jwsAlgorithmBitLength; + $hashByteLength = $jwsAlgorithmBitLength / 2 / 8; } if ($accessToken instanceof EntityStringRepresentationInterface === false) { @@ -138,14 +147,10 @@ public function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, EntityStringRepresentationInterface::class); } - // Try to use toString() so that it uses the string representation if it was already cast to string, - // otherwise, use the cast version. + // Try to use toString() so that it uses the string representation if + // it was already cast to string, otherwise, use the cast version. $accessTokenString = $accessToken->toString() ?? (string) $accessToken; - $hashAlgorithm = 'sha' . $jwsAlgorithmBitLength; - - $hashByteLength = $jwsAlgorithmBitLength / 2 / 8; - return Base64Url::encode( substr( hash( diff --git a/src/Services/LogoutTokenBuilder.php b/src/Services/LogoutTokenBuilder.php index 230ad882..c2f6ad6f 100644 --- a/src/Services/LogoutTokenBuilder.php +++ b/src/Services/LogoutTokenBuilder.php @@ -35,7 +35,7 @@ public function __construct( */ public function forRelyingPartyAssociation(RelyingPartyAssociationInterface $relyingPartyAssociation): string { - $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); + $protocolSignatureKeyPairBag = $this->moduleConfig->getConnectSignatureKeyPairBag(); $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); // ID Token signing algorithm that the client wants. As per spec, the diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 0c39c827..6d38740b 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -39,7 +39,7 @@ private function initMetadata(): void { // Signature algorithms that this OP can use to sign JWS artifacts. $protocolSignatureAlgorithmNames = $this->moduleConfig - ->getProtocolSignatureKeyPairBag() + ->getConnectSignatureKeyPairBag() ->getAllAlgorithmNamesUnique(); // Signature algorithms that this OP can use to validate signature on diff --git a/tests/unit/src/Entities/AccessTokenEntityTest.php b/tests/unit/src/Entities/AccessTokenEntityTest.php index d7e37417..b056fe5a 100644 --- a/tests/unit/src/Entities/AccessTokenEntityTest.php +++ b/tests/unit/src/Entities/AccessTokenEntityTest.php @@ -80,7 +80,7 @@ protected function setUp(): void $this->signatureKeyPairBagMock->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); - $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + $this->moduleConfigMock->method('getConnectSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); } diff --git a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index cf9a1b24..f1916576 100644 --- a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -55,7 +55,7 @@ class TokenResponseTest extends TestCase protected Stub $claimSetEntityFactoryStub; protected MockObject $loggerMock; protected MockObject $coreMock; - protected MockObject $protocolSignatureKeyPairBagMock; + protected MockObject $connectSignatureKeyPairBagMock; protected MockObject $idTokenFactoryMock; protected MockObject $idTokenMock; protected MockObject $signatureKeyPairMock; @@ -120,15 +120,15 @@ protected function setUp(): void $this->loggerMock = $this->createMock(LoggerService::class); - $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->connectSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); $this->signatureKeyPairMock->method('getSignatureAlgorithm') ->willReturn(SignatureAlgorithmEnum::RS256); - $this->protocolSignatureKeyPairBagMock->method('getFirstOrFail') + $this->connectSignatureKeyPairBagMock->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); - $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') - ->willReturn($this->protocolSignatureKeyPairBagMock); + $this->moduleConfigMock->method('getConnectSignatureKeyPairBag') + ->willReturn($this->connectSignatureKeyPairBagMock); $this->idTokenMock = $this->createMock(IdToken::class); } diff --git a/tests/unit/src/Services/LogoutTokenBuilderTest.php b/tests/unit/src/Services/LogoutTokenBuilderTest.php index 62840cb0..1b19d44e 100644 --- a/tests/unit/src/Services/LogoutTokenBuilderTest.php +++ b/tests/unit/src/Services/LogoutTokenBuilderTest.php @@ -48,7 +48,7 @@ class LogoutTokenBuilderTest extends TestCase private MockObject $relyingPartyAssociationMock; private MockObject $loggerServiceMock; private MockObject $coreFactoryMock; - private MockObject $protocolSignatureKeyPairBagMock; + private MockObject $connectSignatureKeyPairBagMock; private MockObject $signatureKeyPairMock; private MockObject $coreMock; private MockObject $logoutTokenFactoryMock; @@ -75,7 +75,7 @@ public function setUp(): void $this->coreFactoryMock = $this->createMock(CoreFactory::class); - $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->connectSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); $this->signatureKeyPairMock->method('getSignatureAlgorithm') @@ -117,10 +117,10 @@ public function testCanCreateInstance(): void public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void { $this->moduleConfigMock->expects($this->once()) - ->method('getProtocolSignatureKeyPairBag') - ->willReturn($this->protocolSignatureKeyPairBagMock); + ->method('getConnectSignatureKeyPairBag') + ->willReturn($this->connectSignatureKeyPairBagMock); - $this->protocolSignatureKeyPairBagMock->expects($this->once()) + $this->connectSignatureKeyPairBagMock->expects($this->once()) ->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); @@ -143,10 +143,10 @@ public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): void { $this->moduleConfigMock->expects($this->once()) - ->method('getProtocolSignatureKeyPairBag') - ->willReturn($this->protocolSignatureKeyPairBagMock); + ->method('getConnectSignatureKeyPairBag') + ->willReturn($this->connectSignatureKeyPairBagMock); - $this->protocolSignatureKeyPairBagMock->expects($this->once()) + $this->connectSignatureKeyPairBagMock->expects($this->once()) ->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); @@ -158,7 +158,7 @@ public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): $negotiatedSignatureKeyPairMock->method('getSignatureAlgorithm') ->willReturn(SignatureAlgorithmEnum::ES256); - $this->protocolSignatureKeyPairBagMock->expects($this->once()) + $this->connectSignatureKeyPairBagMock->expects($this->once()) ->method('getFirstByAlgorithmOrFail') ->with(SignatureAlgorithmEnum::ES256) ->willReturn($negotiatedSignatureKeyPairMock); diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index f354b630..231d4ea9 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -77,7 +77,7 @@ public function setUp(): void $this->signatureKeyPairBagMock->method('getAllAlgorithmNamesUnique') ->willReturn(['RS256']); - $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + $this->moduleConfigMock->method('getConnectSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); } From 3b63f596271e37a5690bf1ff8eee90f6bb29fb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 16 Jan 2026 13:06:15 +0100 Subject: [PATCH 13/17] WIP --- config/module_oidc.php.dist | 141 +++++++++--------- docker/ssp/module_oidc.php | 2 +- src/Controllers/JwksController.php | 2 +- src/Entities/AccessTokenEntity.php | 2 +- src/Factories/CryptKeyFactory.php | 2 +- src/ModuleConfig.php | 66 ++++++-- .../RequestRules/Rules/IdTokenHintRule.php | 2 +- .../Validators/BearerTokenValidator.php | 2 +- src/Services/IdTokenBuilder.php | 2 +- src/Services/LogoutTokenBuilder.php | 2 +- src/Services/OpMetadataService.php | 2 +- .../AccessTokenRepositoryTest.php | 17 ++- .../src/Entities/AccessTokenEntityTest.php | 2 +- .../ResponseTypes/TokenResponseTest.php | 10 +- .../src/Services/LogoutTokenBuilderTest.php | 18 +-- .../src/Services/OpMetadataServiceTest.php | 2 +- 16 files changed, 158 insertions(+), 116 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index ae881666..0e0d70a4 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -29,22 +29,24 @@ use SimpleSAML\OpenID\Codebooks\LanguageTagsEnum; $config = [ /** - * (optional) Issuer (OP) identifier which will be used as an issuer (iss) claim in tokens. If not set, it will - * fall back to current HTTP scheme, host and port number if no standard port is used. - * Description of issuer from OIDC Core specification: "Verifiable Identifier for an Issuer. An Issuer Identifier - * is a case-sensitive URL using the https scheme that contains scheme, host, and optionally, port number and - * path components and no query or fragment components." + * (optional) Issuer (OP) identifier which will be used as an issuer (iss) + * claim in tokens. If not set, it will fall back to the current HTTP + * scheme, host and port number if no standard port is used. + * Description of the issuer from OIDC Core specification: "Verifiable + * Identifier for an Issuer. An Issuer Identifier is a case-sensitive URL + * using the https scheme that contains scheme, host, and optionally, + * port number and path components and no query or fragment components." */ // ModuleConfig::OPTION_ISSUER => 'https://op.example.org', /** - * Connect protocol signature algorithm and key-pair definitions, + * Protocol (Connect) signature algorithm and key-pair definitions, * representing supported algorithms for signing, for example, ID Token JWS. * The order in which the entries are set is important. The entry set * first will have higher priority during signing algorithm negotiation - * with the client. If the client doesn't designate desired signing + * with the client. If the client doesn't designate the desired signing * algorithm, the first algorithm in the list will be used for signing (the - * first entry represents default algorithm and signing key). Note that + * first entry represents the default algorithm and signing key). Note that * the OpenID Connect specification designates `RS256` as the signing * algorithm that should be used by default, so you would probably want * to use that algorithm as the default (first) one. However, you are free @@ -77,7 +79,7 @@ $config = [ * key, you should manually set the key ID to the previous value * so that clients know that the key did not change. */ - ModuleConfig::OPTION_CONNECT_SIGNATURE_KEY_PAIRS => [ + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_rsa_01.key', @@ -119,7 +121,7 @@ $config = [ /** * The default authentication source to be used for authentication if the - * authentication source is not specified on particular client. + * authentication source is not specified on a particular client. */ ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', @@ -127,14 +129,14 @@ $config = [ * The attribute name that contains the user identifier returned from IdP. * By default, this attribute will be dynamically added to the 'sub' * claim in the attribute-to-claim translation table (you will probably want - * to use this attribute as the 'sub' claim since it designates unique + * to use this attribute as the 'sub' claim since it designates a unique * identifier for the user). */ ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', /** * The default translate table from SAML attributes to OIDC claims. If you - * don't want to support specific default claim, set it to an empty array. + * don't want to support a specific default claim, set it to an empty array. */ ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE => [ /* @@ -142,7 +144,7 @@ $config = [ * * 'claimName' => [ * 'type' => 'string|int|bool|json', - * // For non JSON types + * // For non-JSON types * 'attributes' => ['samlAttribute1', 'samlAttribute2'] * // For JSON types * 'claims => [ @@ -150,14 +152,15 @@ $config = [ * ] * ] * - * For convenience the default type is "string" so type does not need - * to be defined. If "attributes" is not set, then it is assumed that - * the rest of the values are saml attribute names. + * For convenience the default type is "string" so the type does not + * need to be defined. If the "attributes" key is not set, then it is + * assumed that the rest of the values are SAML attribute names. * - * Note on 'sub' claim: by default, the list of attributes for 'sub' - * claim will also contain attribute defined in 'useridattr' setting. + * Note on the 'sub' claim: by default, the list of attributes for 'sub' + * claim will also contain an attribute defined in the + * `ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE` setting. * You will probably want to use this attribute as the 'sub' claim since - * it designates unique identifier for the user, However, override as + * it designates a unique identifier for the user. However, override as * necessary. */ // 'sub' => [ @@ -190,7 +193,7 @@ $config = [ // 'description', // ], // 'picture' => [ -// // Empty. Previously 'jpegPhoto' however spec calls for a URL to photo, not an actual photo. +// // Empty. Previously 'jpegPhoto' however, spec calls for a URL to a photo, not an actual photo. // ], // 'website' => [ // // Empty @@ -218,7 +221,7 @@ $config = [ // 'type' => 'bool', // 'attributes' => [], // ], -// // address is a json object. Set the 'formatted' sub-claim to postalAddress +// // address is a JSON object. Set the 'formatted' subclaim to postalAddress // 'address' => [ // 'type' => 'json', // 'claims' => [ @@ -257,7 +260,7 @@ $config = [ /** * Optional list of the Authentication Context Class References that this OP - * supports. If populated, this list will be available in OP discovery + * supports. If populated, this list will be available in the OP discovery * document (OP Metadata) as 'acr_values_supported'. * @see https://datatracker.ietf.org/doc/html/rfc6711 * @see https://www.iana.org/assignments/loa-profiles/loa-profiles.xhtml @@ -283,8 +286,8 @@ $config = [ /** * If this OP supports ACRs, indicate which usable auth source supports * which ACRs. Order of ACRs is important, more important ones being first. - * Syntax: array (array with auth source as key and value - * being array of ACR values as strings) + * Syntax: array (array with an auth source as a key and + * value being array of ACR values as strings) */ ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP => [ @@ -305,20 +308,20 @@ $config = [ /** * If this OP supports ACRs, indicate if authentication using cookie should * be forced to specific ACR value. If this option is set to null, no - * specific ACR will be forced for cookie authentication and the resulting - * ACR will be one of the ACRs supported on used auth source during + * specific ACR will be forced for cookie authentication, and the resulting + * ACR will be one of the ACRs supported on a used auth source during * authentication, that is, session creation. If this option is set to - * specific ACR, with ACR value being one of the ACR value this OP supports, - * it will be set to that ACR for cookie authentication. + * a specific ACR, with ACR value being one of the ACR values this OP + * supports, it will be set to that ACR for cookie authentication. * For example, OIDC Core Spec notes that authentication using a long-lived - * browser cookie is one example where the use of "level 0" is appropriate: + * browser cookie is one example where the use of "level 0" is appropriate. */ // ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => '0', ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => null, /** - * Choose if OP discovery document will include 'claims_supported' claim, - * which is recommended per OpenID Connect Discovery specification + * Choose if an OP discovery document will include the 'claims_supported' + * claim, which is recommended per OpenID Connect Discovery specification * https://openid.net/specs/openid-connect-discovery-1_0.html. The list will * include all claims for which "SAML attribute to OIDC claim translation" * has been defined above. @@ -327,11 +330,11 @@ $config = [ /** * Settings regarding Authentication Processing Filters. - * Note: OIDC authN state array will not contain all the keys which are + * Note: An OIDC authN state array will not contain all the keys which are * available during SAML authN, like Service Provider metadata, etc. * * At the moment, the following SAML authN data will be available during - * OIDC authN in the sate array: + * OIDC authN in the state array: * - ['Attributes'], ['Authority'], ['AuthnInstant'], ['Expire'] * Source and destination will have entity IDs corresponding to the OP * issuer ID and Client ID respectively. @@ -357,11 +360,12 @@ $config = [ /** * (optional) Dedicated OIDC protocol cache adapter, used to cache artifacts * like access tokens, authorization codes, refresh tokens, client data, - * user data, etc. It will also be used for token reuse check in protocol - * context. Setting this option is recommended in production environments. - * If set to null, no caching will be used. Can be set to any Symfony Cache - * Adapter class, like in examples below. If set, make sure to also give - * proper adapter arguments for its instantiation below. + * user data, etc. It will also be used for token reuse check in the + * protocol context. Setting this option is recommended in production + * environments. If set to null, no caching will be used. Can be set to + * any Symfony Cache Adapter class, like in the examples below. If set, + * make sure to also give proper adapter arguments for its instantiation + * below. * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters */ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => null, @@ -370,7 +374,7 @@ $config = [ /** * Protocol cache adapter arguments used for adapter instantiation. Refer - * to documentation for particular adapter on which arguments are needed + * to documentation for a particular adapter on which arguments are needed * to create its instance, in the order of constructor arguments. See * examples below. */ @@ -380,13 +384,13 @@ $config = [ // Example for FileSystemAdapter: // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // 'openidFederation', // Namespace, subdirectory of main cache directory -// 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) -// '/path/to/main/cache/directory' // Must be writable. Can be set to null to use system temporary directory. +// 60 * 60 * 6, // Default lifetime in seconds (used when a particular cache item doesn't define its own lifetime) +// '/path/to/main/cache/directory' // Must be writable. Can be set to null to use the system temporary directory. // ], // Example for MemcachedAdapter: // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // // First argument is a connection instance, so we can use the helper method to create it. In this example a -// // single server is used. Refer to documentation on how to use multiple servers, and / or to provide other +// // single server is used. Refer to documentation on how to use multiple servers and / or to provide other // // options. // \Symfony\Component\Cache\Adapter\MemcachedAdapter::createConnection( // 'memcached://localhost' @@ -395,47 +399,49 @@ $config = [ // // 'memcached://localhost:11222?socket_recv_size=1&socket_send_size=2' // ), // 'openidProtocol', // Namespace, key prefix. -// 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) +// 60 * 60 * 6, // Default lifetime in seconds (used when a particular cache item doesn't define its own lifetime) // ], /** * Protocol cache duration for user entities (authenticated users data). * If not set, cache duration will be the same as session duration. - * This is only relevant if protocol cache adapter is set up. For duration + * This is only relevant if a protocol cache adapter is set up. For duration * format info, check * https://www.php.net/manual/en/dateinterval.construct.php. */ // ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => 'PT1H', // 1 hour ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, // Fallback to session duration /** - * Protocol cache duration for client entities, with given default. - * This is only relevant if protocol cache adapter is set up. For duration + * Protocol cache duration for client entities, with a given default. + * This is only relevant if a protocol cache adapter is set up. For duration * format info, check * https://www.php.net/manual/en/dateinterval.construct.php. */ ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', // 10 minutes /** - * Note: cache duration for Authorization Code, Access Token, and Refresh Token - * will fall back to their TTL. + * Note: cache duration for Authorization Code, Access Token, and Refresh + * Token will fall back to their TTL. */ /** - * Cron tag used to run storage cleanup script using the cron module. + * Cron tag used to run a storage cleanup script using the cron module. */ ModuleConfig::OPTION_CRON_TAG => 'hourly', /** - * Permissions which let the module expose functionality to specific users. In the below configuration, a user's - * eduPersonEntitlement attribute is examined. If the user tries to do something that requires the 'client' - * permission (such as registering their own client), then they will need one of the eduPersonEntitlements - * from the `client` permission array. A permission can be disabled by commenting it out. + * Permissions which let the module expose functionality to specific users. + * In the below configuration, a user's eduPersonEntitlement attribute is + * examined. If the user tries to do something that requires the 'client' + * permission (such as registering their own client), then they will need + * one of the eduPersonEntitlements from the `client` permission array. + * A permission can be disabled by commenting it out. */ ModuleConfig::OPTION_ADMIN_UI_PERMISSIONS => [ // Attribute to inspect to determine user's permissions 'attribute' => 'eduPersonEntitlement', - // Which entitlements allow for registering, editing, delete a client. - // OIDC clients are owned by the creator + // Which entitlements allow for registering, editing, deleting a client? + // The creator owns OIDC clients 'client' => ['urn:example:oidc:manage:client'], ], @@ -579,16 +585,16 @@ $config = [ * recommended in production environments. If set to null, no caching will * be used. Can be set to any Symfony Cache Adapter class. If set, make * sure to also give proper adapter arguments for its instantiation below. - * See examples for protocol cache adapter option. + * See examples for a protocol cache adapter option. * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters */ ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => null, /** * Federation cache adapter arguments used for adapter instantiation. Refer - * to documentation for particular adapter on which arguments are needed to - * create its instance, in the order of constructor arguments. - * See examples for protocol cache adapter option. + * to documentation for a particular adapter on which arguments are needed + * to create its instance, in the order of constructor arguments. + * See examples for a protocol cache adapter option. */ ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [ // Adapter arguments here... @@ -600,9 +606,9 @@ $config = [ * artifact. For example, when caching fetched entity statements, cache * duration will be based on the 'exp' claim (expiration time). Since those * claims are set by issuer (can be long), it could be desirable to limit - * the maximum time, so that items in cache get refreshed more regularly - * (and changes propagate more quickly). This is only relevant if federation - * cache adapter is set up. For duration format info, check + * the maximum time so that items in the cache get refreshed more regularly + * (and changes propagate more quickly). This is only relevant if a + * federation cache adapter is set up. For duration format info, check * https://www.php.net/manual/en/dateinterval.construct.php. */ ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours @@ -610,7 +616,7 @@ $config = [ /** * Federation entity statement duration which determines the Expiration Time * (exp) claim set in entity statement JWSs published by this OP. If not - * set, default of 1 day will be used. For duration format info, check + * set, a default of 1 day will be used. For duration format info, check * https://www.php.net/manual/en/dateinterval.construct.php */ ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION => 'P1D', // 1 day @@ -618,9 +624,9 @@ $config = [ /** * Cache duration for federation entity statements produced by this OP. * This can be used to avoid calculating JWS signature on every HTTP request - * for OP Configuration statement, Subordinate Statements... This is only - * relevant if federation cache adapter is set up. For duration format info, - * check https://www.php.net/manual/en/dateinterval.construct.php. + * for an OP Configuration statement, Subordinate Statements... This is only + * relevant if a federation cache adapter is set up. For duration format + * info, check https://www.php.net/manual/en/dateinterval.construct.php. */ ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED => 'PT2M', // 2 minutes @@ -686,7 +692,8 @@ $config = [ ModuleConfig::OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS => false, /** - * Allowed redirect URI prefixes for non-registered clients. By default, this is set to + * Allowed redirect URI prefixes for non-registered clients. By default, + * this is set to * 'openid-credential-offer://' to allow only redirect URIs with this prefix. * * Example: diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index aa87c6df..5aacdb73 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -21,7 +21,7 @@ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_CONNECT_SIGNATURE_KEY_PAIRS => [ + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ [ ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, diff --git a/src/Controllers/JwksController.php b/src/Controllers/JwksController.php index 2149c5d7..9d71524f 100644 --- a/src/Controllers/JwksController.php +++ b/src/Controllers/JwksController.php @@ -38,7 +38,7 @@ public function __invoke(): JsonResponse { return new JsonResponse( $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getConnectSignatureKeyPairBag()->getAllPublicKeys(), + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(), ); } diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index 28256f98..9b46904c 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -156,7 +156,7 @@ public function toString(): ?string */ protected function convertToJWT(): ParsedJws { - $protocolSignatureKeyPair = $this->moduleConfig->getConnectSignatureKeyPairBag()->getFirstOrFail(); + $protocolSignatureKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairBag()->getFirstOrFail(); $currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp(); $payload = array_filter([ diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index c3788185..176334fd 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -55,7 +55,7 @@ public function buildPublicKey(): CryptKey */ protected function getDefaultProtocolSignatureKeyPairConfig(): array { - $defaultProtocolKeyPair = $this->moduleConfig->getConnectSignatureKeyPairs(); + $defaultProtocolKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairs(); /** @psalm-suppress MixedAssignment */ $defaultProtocolKeyPair = $defaultProtocolKeyPair[array_key_first($defaultProtocolKeyPair)]; diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index e35d12be..3765723e 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -110,7 +110,7 @@ class ModuleConfig final public const OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS = 'vci_allow_non_registered_clients'; final public const OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS = 'vci_allowed_redirect_uri_prefixes_for_non_registered_clients'; - final public const OPTION_CONNECT_SIGNATURE_KEY_PAIRS = 'connect_signature_key_pairs'; + final public const OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; final public const OPTION_FEDERATION_SIGNATURE_KEY_PAIRS = 'federation_signature_key_pairs'; final public const OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; final public const OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; @@ -144,10 +144,11 @@ class ModuleConfig * @var Configuration SimpleSAMLphp configuration instance. */ private readonly Configuration $sspConfig; - protected ?SignatureKeyPairBag $connectSignatureKeyPairBag = null; - protected ?SignatureKeyPairConfigBag $connectSignatureKeyPairConfigBag = null; + protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $protocolSignatureKeyPairConfigBag = null; protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; protected ?SignatureKeyPairBag $vciSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $vciSignatureKeyPairConfigBag = null; /** * @throws \Exception @@ -377,10 +378,10 @@ public function getSupportedSerializers(): SupportedSerializers * @throws ConfigurationError * @return non-empty-array */ - public function getConnectSignatureKeyPairs(): array + public function getProtocolSignatureKeyPairs(): array { - $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_CONNECT_SIGNATURE_KEY_PAIRS); + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS); if (empty($signatureKeyPairs)) { throw new ConfigurationError('At least one protocol signature key-pair pair must be provided.'); @@ -393,14 +394,14 @@ public function getConnectSignatureKeyPairs(): array * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getConnectSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag + public function getProtocolSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag { - if ($this->connectSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { - return $this->connectSignatureKeyPairConfigBag; + if ($this->protocolSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { + return $this->protocolSignatureKeyPairConfigBag; } - return $this->connectSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( - $this->getConnectSignatureKeyPairs(), + return $this->protocolSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( + $this->getProtocolSignatureKeyPairs(), ); } @@ -408,15 +409,15 @@ public function getConnectSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getConnectSignatureKeyPairBag(): SignatureKeyPairBag + public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag { - if ($this->connectSignatureKeyPairBag instanceof SignatureKeyPairBag) { - return $this->connectSignatureKeyPairBag; + if ($this->protocolSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->protocolSignatureKeyPairBag; } - return $this->connectSignatureKeyPairBag = $this->valueAbstracts + return $this->protocolSignatureKeyPairBag = $this->valueAbstracts ->signatureKeyPairBagFactory() - ->fromConfig($this->getConnectSignatureKeyPairConfigBag()); + ->fromConfig($this->getProtocolSignatureKeyPairConfigBag()); } /** @@ -836,6 +837,39 @@ public function getVerifiableCredentialEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_VCI_ENABLED, false); } + + /** + * @throws ConfigurationError + * @return non-empty-array + */ + public function getVciSignatureKeyPairs(): array + { + + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_VCI_SIGNATURE_KEY_PAIRS); + + if (empty($signatureKeyPairs)) { + throw new ConfigurationError('At least one VCI signature key-pair pair must be provided.'); + } + + return $signatureKeyPairs; + } + + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getVciSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag + { + if ($this->vciSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { + return $this->vciSignatureKeyPairConfigBag; + } + + return $this->vciSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( + $this->getVciSignatureKeyPairs(), + ); + } + /** * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion @@ -848,7 +882,7 @@ public function getVciSignatureKeyPairBag(): SignatureKeyPairBag return $this->vciSignatureKeyPairBag = $this->valueAbstracts ->signatureKeyPairBagFactory() - ->fromConfig($this->getConnectSignatureKeyPairConfigBag()); + ->fromConfig($this->getVciSignatureKeyPairConfigBag()); } public function getVciCredentialConfigurationsSupported(): array diff --git a/src/Server/RequestRules/Rules/IdTokenHintRule.php b/src/Server/RequestRules/Rules/IdTokenHintRule.php index 3955adcd..8feccbf2 100644 --- a/src/Server/RequestRules/Rules/IdTokenHintRule.php +++ b/src/Server/RequestRules/Rules/IdTokenHintRule.php @@ -66,7 +66,7 @@ public function checkRule( } $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getConnectSignatureKeyPairBag()->getAllPublicKeys(), + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(); $idTokenHint = $this->core->idTokenFactory()->fromToken($idTokenHintParam); diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 7a30bc5c..35ee4d8f 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -84,7 +84,7 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe try { // Attempt to validate the JWT $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getConnectSignatureKeyPairBag()->getAllPublicKeys(), + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), )->jsonSerialize(); $token->verifyWithKeySet($jwks); } catch (JwsException) { diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index 2396fff4..87adba25 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -50,7 +50,7 @@ public function buildFor( throw new RuntimeException('Client is expected to be instance of ' . ClientEntity::class); } - $protocolSignatureKeyPairBag = $this->moduleConfig->getConnectSignatureKeyPairBag(); + $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); // ID Token signing algorithm that the client wants. diff --git a/src/Services/LogoutTokenBuilder.php b/src/Services/LogoutTokenBuilder.php index c2f6ad6f..230ad882 100644 --- a/src/Services/LogoutTokenBuilder.php +++ b/src/Services/LogoutTokenBuilder.php @@ -35,7 +35,7 @@ public function __construct( */ public function forRelyingPartyAssociation(RelyingPartyAssociationInterface $relyingPartyAssociation): string { - $protocolSignatureKeyPairBag = $this->moduleConfig->getConnectSignatureKeyPairBag(); + $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); // ID Token signing algorithm that the client wants. As per spec, the diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 6d38740b..0c39c827 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -39,7 +39,7 @@ private function initMetadata(): void { // Signature algorithms that this OP can use to sign JWS artifacts. $protocolSignatureAlgorithmNames = $this->moduleConfig - ->getConnectSignatureKeyPairBag() + ->getProtocolSignatureKeyPairBag() ->getAllAlgorithmNamesUnique(); // Signature algorithms that this OP can use to validate signature on diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 6ace21ac..a2bd4845 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -4,7 +4,6 @@ namespace SimpleSAML\Test\Module\oidc\integration\Repositories; -use League\OAuth2\Server\CryptKey; use PDO; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -28,7 +27,7 @@ use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\OpenID\Jws; use Testcontainers\Container\MySQLContainer; use Testcontainers\Container\PostgresContainer; use Testcontainers\Wait\WaitForHealthCheck; @@ -66,7 +65,8 @@ class AccessTokenRepositoryTest extends TestCase protected MockObject $accessTokenEntityMock; protected array $accessTokenState; protected AccessTokenEntityFactory $accessTokenEntityFactory; - protected CryptKey $privateKey; + protected MockObject $jwsMock; + protected MockObject $moduleConfigMock; public static function setUpBeforeClass(): void { @@ -125,14 +125,15 @@ public function setUp(): void $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); $this->accessTokenEntityFactoryMock = $this->createMock(AccessTokenEntityFactory::class); - $certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; - $privateKeyPath = $certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - $this->privateKey = new CryptKey($privateKeyPath); + + $this->jwsMock = $this->createMock(Jws::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->accessTokenEntityFactory = new AccessTokenEntityFactory( new Helpers(), - $this->privateKey, - $this->createMock(JsonWebTokenBuilderService::class), new ScopeEntityFactory(), + $this->jwsMock, + $this->moduleConfigMock, ); } diff --git a/tests/unit/src/Entities/AccessTokenEntityTest.php b/tests/unit/src/Entities/AccessTokenEntityTest.php index b056fe5a..d7e37417 100644 --- a/tests/unit/src/Entities/AccessTokenEntityTest.php +++ b/tests/unit/src/Entities/AccessTokenEntityTest.php @@ -80,7 +80,7 @@ protected function setUp(): void $this->signatureKeyPairBagMock->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); - $this->moduleConfigMock->method('getConnectSignatureKeyPairBag') + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); } diff --git a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index f1916576..cf9a1b24 100644 --- a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -55,7 +55,7 @@ class TokenResponseTest extends TestCase protected Stub $claimSetEntityFactoryStub; protected MockObject $loggerMock; protected MockObject $coreMock; - protected MockObject $connectSignatureKeyPairBagMock; + protected MockObject $protocolSignatureKeyPairBagMock; protected MockObject $idTokenFactoryMock; protected MockObject $idTokenMock; protected MockObject $signatureKeyPairMock; @@ -120,15 +120,15 @@ protected function setUp(): void $this->loggerMock = $this->createMock(LoggerService::class); - $this->connectSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); $this->signatureKeyPairMock->method('getSignatureAlgorithm') ->willReturn(SignatureAlgorithmEnum::RS256); - $this->connectSignatureKeyPairBagMock->method('getFirstOrFail') + $this->protocolSignatureKeyPairBagMock->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); - $this->moduleConfigMock->method('getConnectSignatureKeyPairBag') - ->willReturn($this->connectSignatureKeyPairBagMock); + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); $this->idTokenMock = $this->createMock(IdToken::class); } diff --git a/tests/unit/src/Services/LogoutTokenBuilderTest.php b/tests/unit/src/Services/LogoutTokenBuilderTest.php index 1b19d44e..62840cb0 100644 --- a/tests/unit/src/Services/LogoutTokenBuilderTest.php +++ b/tests/unit/src/Services/LogoutTokenBuilderTest.php @@ -48,7 +48,7 @@ class LogoutTokenBuilderTest extends TestCase private MockObject $relyingPartyAssociationMock; private MockObject $loggerServiceMock; private MockObject $coreFactoryMock; - private MockObject $connectSignatureKeyPairBagMock; + private MockObject $protocolSignatureKeyPairBagMock; private MockObject $signatureKeyPairMock; private MockObject $coreMock; private MockObject $logoutTokenFactoryMock; @@ -75,7 +75,7 @@ public function setUp(): void $this->coreFactoryMock = $this->createMock(CoreFactory::class); - $this->connectSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); $this->signatureKeyPairMock->method('getSignatureAlgorithm') @@ -117,10 +117,10 @@ public function testCanCreateInstance(): void public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void { $this->moduleConfigMock->expects($this->once()) - ->method('getConnectSignatureKeyPairBag') - ->willReturn($this->connectSignatureKeyPairBagMock); + ->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); - $this->connectSignatureKeyPairBagMock->expects($this->once()) + $this->protocolSignatureKeyPairBagMock->expects($this->once()) ->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); @@ -143,10 +143,10 @@ public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): void { $this->moduleConfigMock->expects($this->once()) - ->method('getConnectSignatureKeyPairBag') - ->willReturn($this->connectSignatureKeyPairBagMock); + ->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); - $this->connectSignatureKeyPairBagMock->expects($this->once()) + $this->protocolSignatureKeyPairBagMock->expects($this->once()) ->method('getFirstOrFail') ->willReturn($this->signatureKeyPairMock); @@ -158,7 +158,7 @@ public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): $negotiatedSignatureKeyPairMock->method('getSignatureAlgorithm') ->willReturn(SignatureAlgorithmEnum::ES256); - $this->connectSignatureKeyPairBagMock->expects($this->once()) + $this->protocolSignatureKeyPairBagMock->expects($this->once()) ->method('getFirstByAlgorithmOrFail') ->with(SignatureAlgorithmEnum::ES256) ->willReturn($negotiatedSignatureKeyPairMock); diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 231d4ea9..f354b630 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -77,7 +77,7 @@ public function setUp(): void $this->signatureKeyPairBagMock->method('getAllAlgorithmNamesUnique') ->willReturn(['RS256']); - $this->moduleConfigMock->method('getConnectSignatureKeyPairBag') + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); } From 714823d9417386e0b4cec629c3533e32a3da1919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 16 Jan 2026 14:58:17 +0100 Subject: [PATCH 14/17] WIP --- .../VerifiableCredentailsTestController.php | 2 +- .../Api/VciCredentialOfferApiController.php | 2 +- ...redentialIssuerConfigurationController.php | 2 +- .../CredentialIssuerCredentialController.php | 2 +- .../JwtVcIssuerConfigurationController.php | 2 +- src/Factories/AuthorizationServerFactory.php | 2 +- src/ModuleConfig.php | 27 +-- .../Rules/AuthorizationDetailsRule.php | 2 +- .../Rules/ClientRedirectUriRule.php | 2 +- src/Server/RequestRules/Rules/ClientRule.php | 2 +- src/Services/OpMetadataService.php | 2 +- tests/cert/oidc_module.crt | 9 + tests/cert/oidc_module.key | 27 +++ tests/config/module_oidc.php | 16 ++ tests/unit/src/ModuleConfigTest.php | 195 +++++++++++++++++- 15 files changed, 260 insertions(+), 34 deletions(-) create mode 100644 tests/cert/oidc_module.crt create mode 100644 tests/cert/oidc_module.key diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 9f090d6a..0965f7e3 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -44,7 +44,7 @@ public function verifiableCredentialIssuance(Request $request): Response { $setupErrors = []; - if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + if (!$this->moduleConfig->getVciEnabled()) { $setupErrors[] = 'Verifiable Credential functionalities are not enabled.'; } diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index a91472e3..77d4b52e 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -33,7 +33,7 @@ public function __construct( throw OidcServerException::forbidden('API capabilities not enabled.'); } - if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + if (!$this->moduleConfig->getVciEnabled()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 830d1134..8b795710 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -30,7 +30,7 @@ public function __construct( protected readonly Routes $routes, protected readonly LoggerService $loggerService, ) { - if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + if (!$this->moduleConfig->getVciEnabled()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index d8732b23..4b34e6af 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -54,7 +54,7 @@ public function __construct( protected readonly Did $did, protected readonly IssuerStateRepository $issuerStateRepository, ) { - if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + if (!$this->moduleConfig->getVciEnabled()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php index 410a9cf6..b4b6e984 100644 --- a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -31,7 +31,7 @@ public function __construct( protected readonly Routes $routes, protected readonly LoggerService $loggerService, ) { - if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + if (!$this->moduleConfig->getVciEnabled()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index a49f81a4..9970b948 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -76,7 +76,7 @@ public function build(): AuthorizationServer $this->moduleConfig->getAccessTokenDuration(), ); - if ($this->moduleConfig->getVerifiableCredentialEnabled()) { + if ($this->moduleConfig->getVciEnabled()) { $authorizationServer->enableGrantType( $this->preAuthCodeGrant, $this->moduleConfig->getAccessTokenDuration(), diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 3765723e..55d22640 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -17,8 +17,6 @@ namespace SimpleSAML\Module\oidc; use DateInterval; -use Lcobucci\JWT\Signer; -use ReflectionClass; use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; @@ -275,23 +273,6 @@ public function getModuleUrl(?string $path = null): string return $base; } - /** - * @param class-string $className - * @throws \SimpleSAML\Error\ConfigurationError - * @throws \ReflectionException - */ - protected function instantiateSigner(string $className): Signer - { - $class = new ReflectionClass($className); - $signer = $class->newInstance(); - - if (!$signer instanceof Signer) { - throw new ConfigurationError(sprintf('Unsupported signer class provided (%s).', $className)); - } - - return $signer; - } - /***************************************************************************************************************** * OpenID Connect related config. ****************************************************************************************************************/ @@ -361,6 +342,7 @@ public function getSupportedAlgorithms(): SupportedAlgorithms SignatureAlgorithmEnum::PS256, SignatureAlgorithmEnum::PS384, SignatureAlgorithmEnum::PS512, + SignatureAlgorithmEnum::EdDSA, ), ); } @@ -380,7 +362,6 @@ public function getSupportedSerializers(): SupportedSerializers */ public function getProtocolSignatureKeyPairs(): array { - $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS); if (empty($signatureKeyPairs)) { @@ -832,7 +813,7 @@ public function isFederationParticipationLimitedByTrustMarksFor(string $trustAnc * OpenID Verifiable Credential Issuance related config. ****************************************************************************************************************/ - public function getVerifiableCredentialEnabled(): bool + public function getVciEnabled(): bool { return $this->config()->getOptionalBoolean(self::OPTION_VCI_ENABLED, false); } @@ -935,7 +916,7 @@ public function getVciCredentialConfigurationIdsSupported(): array */ public function getVciScopes(): array { - if (!$this->getVerifiableCredentialEnabled()) { + if (!$this->getVciEnabled()) { return []; } @@ -1190,7 +1171,7 @@ public function getValidatedSignatureKeyPairArray(mixed $signatureKeyPair): arra ) { throw new ConfigurationError( sprintf( - 'Unexpected value for key ID signature key pair. Expected a string or null, got "%s".', + 'Unexpected value for key ID signature key pair. Expected a non-empty string or null, got "%s".', var_export($keyId, true), ), ); diff --git a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php index 58470163..4b4ff89f 100644 --- a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php +++ b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php @@ -75,7 +75,7 @@ public function checkRule( // Since we only use AuthorizationDetailsRule for VCI, we will throw as per RAR spec. // https://www.rfc-editor.org/rfc/rfc9396.html#name-authorization-error-respons - if (! $this->moduleConfig->getVerifiableCredentialEnabled()) { + if (! $this->moduleConfig->getVciEnabled()) { $loggerService->error('AuthorizationDetailsRule: Rich Authorization Requests are not used by this server.'); throw OidcServerException::invalidRequest( 'authorization_details', diff --git a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php index 537c994d..3c00c763 100644 --- a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -72,7 +72,7 @@ public function checkRule( } catch (\Throwable $exception) { if ( $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && - $this->moduleConfig->getVerifiableCredentialEnabled() && + $this->moduleConfig->getVciEnabled() && $this->moduleConfig->getVciAllowNonRegisteredClients() ) { $loggerService->debug( diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 4c00a8a1..72a899b7 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -124,7 +124,7 @@ public function checkRule( if ( $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && - $this->moduleConfig->getVerifiableCredentialEnabled() && + $this->moduleConfig->getVciEnabled() && $this->moduleConfig->getVciAllowNonRegisteredClients() ) { $this->loggerService->debug( diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 0c39c827..91537d7f 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -83,7 +83,7 @@ private function initMetadata(): void GrantTypesEnum::AuthorizationCode->value, GrantTypesEnum::RefreshToken->value, ]; - if ($this->moduleConfig->getVerifiableCredentialEnabled()) { + if ($this->moduleConfig->getVciEnabled()) { $grantTypesSupported[] = GrantTypesEnum::PreAuthorizedCode->value; } $this->metadata[ClaimsEnum::GrantTypesSupported->value] = $grantTypesSupported; diff --git a/tests/cert/oidc_module.crt b/tests/cert/oidc_module.crt new file mode 100644 index 00000000..af6b560a --- /dev/null +++ b/tests/cert/oidc_module.crt @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq2EgIfAMG2XpfjRCAUHQ +QqLAbrOL4s+JwL0X3jO2imVzbBp9MadLzGQhczyhKHM0B9YumHSwNjD5hrQQso02 +CaGientCzhm9PqerjffZ+0B4+FfGws0ozwyfA7hW1arhBx92D8r7Hw0HHu1QwSQ2 +N4zlmCea7shWTC4CoO8ECJcPDYe2/wABLCPZm0dcv/sPYln7HAiukI2fxwMpf3yQ +XihcQQXdHHoOncUn7QlibUQj//Zxk0obEkJAUyqxFKa+3cuToFkfSH7bGs8YnI3q +y/YzmsutI3keeEIQOTNAtLgauqZ4CW+Your+9vsUXaNjNshnObNHWLPc37DJaRyA +pwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/cert/oidc_module.key b/tests/cert/oidc_module.key new file mode 100644 index 00000000..3b0e8bbb --- /dev/null +++ b/tests/cert/oidc_module.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAq2EgIfAMG2XpfjRCAUHQQqLAbrOL4s+JwL0X3jO2imVzbBp9 +MadLzGQhczyhKHM0B9YumHSwNjD5hrQQso02CaGientCzhm9PqerjffZ+0B4+FfG +ws0ozwyfA7hW1arhBx92D8r7Hw0HHu1QwSQ2N4zlmCea7shWTC4CoO8ECJcPDYe2 +/wABLCPZm0dcv/sPYln7HAiukI2fxwMpf3yQXihcQQXdHHoOncUn7QlibUQj//Zx +k0obEkJAUyqxFKa+3cuToFkfSH7bGs8YnI3qy/YzmsutI3keeEIQOTNAtLgauqZ4 +CW+Your+9vsUXaNjNshnObNHWLPc37DJaRyApwIDAQABAoIBAE0+msARNTPILITQ +wwtUAa13M+rxjFRvnLQ9xptFjbo1Xd/U1Kbjs9ttKlKJek4EFuiNVjUrKx1R17Yq +RPhlg3y12MkB86t3mH+8DSwREbQYbC3rSlAVLpacJrQDi0gFHCYcvRcDM0rckWAU +MPjM/I7vN7Dr8P49WABAILku4g+IYaEDUXMGjNnLwgbK6nXCyOn9pwKSqA0SYltI +X63Ba1N13oCigR7UtHvHcRHEWzPkBJO5/pLnNfF+0Tn2z7WXVe5oxCar9nTrUfIP +aspwSjGam7cFbyutnlRLX5/Rkk7r4PkXShP++08GxYTzV7nLhwpN33W0F6B8HLEH +Nu56ihECgYEA20t5gbnRF6QFabJ277uTJ24jdpitB5UOIA8+K9/cPnBsSCfWjexY +Nl0UYuJap3W80dL6lV8VuJZZ0ojAxE+Qwh04MZZXq/Egxn6l+6TNeUCbBHaaj1pl +X0vdxIpC5BKmXhwLLtuqzVg7t3O1NK0c0tZ+kipUA7DenVT2ZHGCyA8CgYEAyBCH +/GFbqsq3Oq8CURZ9osVq5eNXrXeiIp3OlbpywDYFNPLg91mck71gT6btZeuaAH7/ +q7unxZ007ADAoKGPvRKtGa25RB5Vy+dC7F4EDdzz/UBh+fl2zg33W7toh9Kb2OGd +HycEWgU4z+ZpC5fH0v892TlAqPQTFQx+F9rx5ekCgYEAsme7qWtHjUkWUkArfKuI +cyqqVUCufB2qiTB9bupHXtDNdwJaDco6lbex7yShhd1GSRmwXTcnD63Z02sIEG1+ +oj1tSwI5vxuDg5jjZk9UDpIdy0rGQVvUXuv0toGZG72Edcmw22VAlqByrLPIttsj +OO/htv4SrZIF+c92SI8ES8cCgYEAmxktwzva69pCGE2K10A/YAv6ZoRL+aAwYvPC +LxOfWGHIwZa1Tyz6lRKQcs+vZX80IcRTA1j0pN/OIlQnAaCepW6wIaMraKK30t7T +ZBkyvWiZArGCA2AheXccV9I/JGTjC01FGNyPpBY+R/aRYzpk4K+dzCR1e0XU8VGB +A49qTtkCgYEAhQqvYFBlPo5T/W6tWxLLcrvUXIUL2zt4qpKKvdKXTbYwWyUaVp6H +Bu/fG1KQotNwpKDwgIb9Zv/obyW9wFhhNpobvvI273wyFZMps5bu50WIOggFErak +GwPXiagzBlhklsfR+TpFYQ6MTo3ymhLy2+wVIOqV/tjUaRkT7kNk98U= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tests/config/module_oidc.php b/tests/config/module_oidc.php index 74cf67db..0a40d13b 100644 --- a/tests/config/module_oidc.php +++ b/tests/config/module_oidc.php @@ -18,6 +18,14 @@ $config = [ ModuleConfig::OPTION_ISSUER => 'http://test.issuer', + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ], + ], + ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', @@ -58,6 +66,14 @@ ModuleConfig::OPTION_FEDERATION_ENABLED => false, + ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ], + ], + ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [ // phpcs:ignore 'https://ta.example.org/' => '{"keys":[{"kty": "RSA","alg": "RS256","use": "sig","kid": "Nzb...9Xs","e": "AQAB","n": "pnXB...ub9J"}]}', diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index c79e9513..d99d1268 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -13,7 +13,13 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\SupportedSerializers; +use SimpleSAML\OpenID\ValueAbstracts; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; use SimpleSAML\Utils\Config; use SimpleSAML\Utils\HTTP; @@ -27,6 +33,14 @@ class ModuleConfigTest extends TestCase protected array $moduleConfig = [ ModuleConfig::OPTION_ISSUER => 'http://test.issuer', + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_rsa_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_rsa_01.pub', + ], + ], + ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', @@ -64,6 +78,7 @@ class ModuleConfigTest extends TestCase private MockObject $sspBridgeUtilsHttpMock; private MockObject $sspBridgeModuleMock; private MockObject $sspBridgeUtilsConfigMock; + private MockObject $valueAbstractMock; protected function setUp(): void { @@ -77,7 +92,9 @@ protected function setUp(): void $this->sspBridgeUtilsConfigMock = $this->createMock(Config::class); $this->sspBridgeUtilsConfigMock->method('getCertPath') - ->willReturnCallback(fn(string $filename): string => '/path/to/cert' . $filename); + ->willReturnCallback( + fn(string $filename): string => dirname(__DIR__, 2) . '/cert/' . $filename + ); $this->sspBridgeUtilsHttpMock = $this->createMock(HTTP::class); $this->sspBridgeModuleMock = $this->createMock(SspBridge\Module::class); @@ -89,6 +106,8 @@ protected function setUp(): void $this->sspBridgeUtilsMock->method('http')->willReturn($this->sspBridgeUtilsHttpMock); $this->sspBridgeUtilsMock->method('config')->willReturn($this->sspBridgeUtilsConfigMock); + + $this->valueAbstractMock = $this->createMock(ValueAbstracts::class); } protected function sut( @@ -96,17 +115,20 @@ protected function sut( ?array $overrides = null, ?Configuration $sspConfig = null, ?SspBridge $sspBridge = null, + ?ValueAbstracts $valueAbstracts = null, ): ModuleConfig { $fileName ??= $this->fileName; $overrides ??= $this->overrides; $sspConfig ??= $this->sspConfigMock; $sspBridge ??= $this->sspBridgeMock; + $valueAbstracts ??= $this->valueAbstractMock; return new ModuleConfig( $fileName, $overrides, $sspConfig, $sspBridge, + $valueAbstracts, ); } @@ -118,12 +140,58 @@ public function testCanGetCommonOptions(): void $this->assertInstanceOf(DateInterval::class, $this->sut()->getAccessTokenDuration()); $this->assertInstanceOf(DateInterval::class, $this->sut()->getRefreshTokenDuration()); + $this->assertInstanceOf(SupportedAlgorithms::class, $this->sut()->getSupportedAlgorithms()); + $this->assertInstanceOf(SupportedSerializers::class, $this->sut()->getSupportedSerializers()); + $this->assertSame( $this->moduleConfig[ModuleConfig::OPTION_AUTH_SOURCE], $this->sut()->getDefaultAuthSourceId(), ); } + public function testCanGetProtocolSignatureKeyPairs(): void + { + $this->assertNotEmpty($this->sut()->getProtocolSignatureKeyPairs()); + } + + public function testGetProtocolSignatureKeyPairsThrowsOnInvalidConfigValue(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('At least one '); + + $this->sut( + overrides: [ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => []], + )->getProtocolSignatureKeyPairs(); + } + + public function testCanGetProtocolSignatureKeyPairConfigBag(): void + { + $sut = $this->sut(); + + $this->assertInstanceOf( + SignatureKeyPairConfigBag::class, + $sut->getProtocolSignatureKeyPairConfigBag(), + ); + $this->assertInstanceOf( + SignatureKeyPairConfigBag::class, + $sut->getProtocolSignatureKeyPairConfigBag(), + ); + } + + public function testCanGetProtocolSignatureKeyPairgBag(): void + { + $sut = $this->sut(); + + $this->assertInstanceOf( + SignatureKeyPairBag::class, + $sut->getProtocolSignatureKeyPairBag(), + ); + $this->assertInstanceOf( + SignatureKeyPairBag::class, + $sut->getProtocolSignatureKeyPairBag(), + ); + } + public function testCanGetSspConfig(): void { $this->assertInstanceOf(Configuration::class, $this->sut()->sspConfig()); @@ -199,6 +267,26 @@ public function testCanGetCommonFederationOptions(): void $this->assertNotEmpty($this->sut()->getFederationCacheMaxDurationForFetched()); $this->assertNotEmpty($this->sut()->getFederationTrustAnchors()); $this->assertNotEmpty($this->sut()->getFederationTrustAnchorIds()); + + $this->assertInstanceOf(DateInterval::class, $this->sut()->getTimestampValidationLeeway()); + } + + public function testCanGetFederationSignatureKeyPairBag(): void + { + $sut = $this->sut(); + $this->assertInstanceOf(SignatureKeyPairBag::class, $sut->getFederationSignatureKeyPairBag()); + $this->assertInstanceOf(SignatureKeyPairBag::class, $sut->getFederationSignatureKeyPairBag()); + } + + public function testGetFederationSignatureKeyPairBagThrowsOnInvalidConfigValue(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('At least one '); + + $this->sut( + overrides: [ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => []], + )->getFederationSignatureKeyPairBag(); + } public function testKeywordsCanBeNull(): void @@ -414,4 +502,109 @@ public function testCanGetFederationTrustMarkStatusEndpointUsagePolicy(): void $sut->getFederationTrustMarkStatusEndpointUsagePolicy(), ); } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidValue(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Invalid value'); + + $this->sut()->getValidatedSignatureKeyPairArray('invalid'); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidSignature(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => 'invalid', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Invalid protocol signature algorithm'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPrivateKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Unexpected value for private key filename'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPrivateKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'non-existing.key', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Private key file does not exist'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPublicKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Unexpected value for public key filename'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPublicKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'non-existing.pub', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Public key file does not exist'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyPasswordString(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Expected a non-empty string'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyKeyIdString(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'password', + ModuleConfig::KEY_KEY_ID => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Expected a non-empty string'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } } From 31270b475b835565a1374148b7bfd688c4571d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 16 Jan 2026 15:23:20 +0100 Subject: [PATCH 15/17] WIP --- docs/6-oidc-upgrade.md | 8 ++++---- tests/unit/src/ModuleConfigTest.php | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 5480aeb1..fa76fca2 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -7,9 +7,9 @@ apply those relevant to your deployment. New features: -- Instance can now be configured to support multiple protocol (Connect) and -Federation signing algorithms and key pairs. This was introduced in order to -support signature algorithm negotiation with the clients. +- Instance can now be configured to support multiple algorithms and signature +keys for protocol (Connect), Federation, and VCI purposes. This was introduced +to support signature algorithm negotiation with the clients. - Clients can now be configured with new properties: - ID Token Signing Algorithm (`id_token_signed_response_alg`) - Initial support for OpenID for Verifiable Credential Issuance @@ -111,7 +111,7 @@ find appropriate. - Entity Identifier - Supported OpenID Federation Registration Types - Federation JWKS - - Protocol JWKS, JWKS URI and Signed JWKS URI, + - Protocol JWKS, JWKS URI, and Signed JWKS URI, - Registration type (manual, federated_automatic, or other in the future) - Is Federated flag (indicates participation in federation context) - Timestamps: created_at, updated_at, expires_at diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index d99d1268..80e090a6 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -93,7 +93,7 @@ protected function setUp(): void $this->sspBridgeUtilsConfigMock = $this->createMock(Config::class); $this->sspBridgeUtilsConfigMock->method('getCertPath') ->willReturnCallback( - fn(string $filename): string => dirname(__DIR__, 2) . '/cert/' . $filename + fn(string $filename): string => dirname(__DIR__, 2) . '/cert/' . $filename, ); $this->sspBridgeUtilsHttpMock = $this->createMock(HTTP::class); @@ -286,7 +286,6 @@ public function testGetFederationSignatureKeyPairBagThrowsOnInvalidConfigValue() $this->sut( overrides: [ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => []], )->getFederationSignatureKeyPairBag(); - } public function testKeywordsCanBeNull(): void From 24bd194392e2f54bba1254ff16b7b212b742293c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 16 Jan 2026 15:27:22 +0100 Subject: [PATCH 16/17] WIP --- docs/6-oidc-upgrade.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index fa76fca2..b78d1e9c 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -18,20 +18,24 @@ it in production. New configuration options: -- `ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` - (required) enables defining -multiple protocol (Connect) related signing algorithms and key pairs. -- `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` - (required if federation -capabilities are enabled) enables defining multiple key pairs for +- `ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` - (required) enables +defining multiple protocol (Connect) related signing algorithms and key pairs. +- `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` - (required if +federation capabilities are enabled) enables defining multiple key pairs for Federation purposes like signing Entity Statements, publishing new key for key roll-ower scenarios, etc. -- `ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY` - optional, used for setting -allowed time tolerance for timestamp validation in artifacts like JWSs. -multiple Federation related signing algorithms and key pairs. +- `ModuleConfig::OPTION_VCI_SIGNATURE_KEY_PAIRS` - (required if VCI +capabilities are enabled) enables defining multiple key pairs for +VCI purposes like signing Verifiable Credentials, publishing new key for +key roll-ower scenarios, etc. +- `ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY` - optional, used for +setting allowed time tolerance for timestamp validation in artifacts like JWSs. +multiple Federation-related signing algorithms and key pairs. - Several new options regarding experimental support for OpenID4VCI. Major impact changes: -- The following configuration options related to protocol (Connect) +- The following configuration options related to the protocol (Connect) signature algorithm and key pair are removed: - `ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE` - `ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME` From d25d3f5ccd09bb39959bfd2dd4dd7e217d8a616b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 16 Jan 2026 18:53:45 +0100 Subject: [PATCH 17/17] WIP --- .../unit/src/Services/IdTokenBuilderTest.php | 194 +++++++++++++++++- 1 file changed, 189 insertions(+), 5 deletions(-) diff --git a/tests/unit/src/Services/IdTokenBuilderTest.php b/tests/unit/src/Services/IdTokenBuilderTest.php index 5afeb866..a36c364b 100644 --- a/tests/unit/src/Services/IdTokenBuilderTest.php +++ b/tests/unit/src/Services/IdTokenBuilderTest.php @@ -4,15 +4,199 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use DateTimeImmutable; +use League\OAuth2\Server\Entities\UserEntityInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Entities\ClientEntity; +use SimpleSAML\Module\oidc\Entities\ScopeEntity; +use SimpleSAML\Module\oidc\Entities\UserEntity; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; -/** - * @covers \SimpleSAML\Module\oidc\Services\IdTokenBuilder - */ +#[CoversClass(IdTokenBuilder::class)] class IdTokenBuilderTest extends TestCase { - public function testIncomplete(): never + protected MockObject $claimTranslatorExtractorMock; + protected MockObject $coreMock; + protected MockObject $moduleConfigMock; + protected MockObject $protocolSignatureKeyBagMock; + protected MockObject $protocolSignatureKeyPairMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $userEntityMock; + protected MockObject $accessTokenEntityMock; + protected MockObject $clientEntityMock; + protected MockObject $accessTokenExpiryDateTimeMock; + protected MockObject $scopeEntityMock; + + protected function setUp(): void + { + $this->claimTranslatorExtractorMock = $this->createMock(ClaimTranslatorExtractor::class); + $this->coreMock = $this->createMock(Core::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->protocolSignatureKeyBagMock = $this->createMock(SignatureKeyPairBag::class); + + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyBagMock); + + $this->protocolSignatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->protocolSignatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->protocolSignatureKeyBagMock->method('getFirstOrFail') + ->willReturn($this->protocolSignatureKeyPairMock); + + + $this->idTokenFactoryMock = $this->createMock(IdTokenFactory::class); + $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); + + $this->userEntityMock = $this->createMock(UserEntity::class); + $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); + + + $this->clientEntityMock = $this->createMock(ClientEntity::class); + $this->accessTokenEntityMock->method('getClient')->willReturn($this->clientEntityMock); + + $this->accessTokenExpiryDateTimeMock = $this->createMock(DateTimeImmutable::class); + $this->accessTokenEntityMock->method('getExpiryDateTime') + ->willReturn($this->accessTokenExpiryDateTimeMock); + + $this->scopeEntityMock = $this->createMock(ScopeEntity::class); + $this->accessTokenEntityMock->method('getScopes')->willReturn([$this->scopeEntityMock]); + } + + protected function sut( + ?ClaimTranslatorExtractor $claimTranslatorExtractor = null, + ?Core $core = null, + ?ModuleConfig $moduleConfig = null, + ): IdTokenBuilder { + $claimTranslatorExtractor ??= $this->claimTranslatorExtractorMock; + $core ??= $this->coreMock; + $moduleConfig ??= $this->moduleConfigMock; + + return new IdTokenBuilder( + $claimTranslatorExtractor, + $core, + $moduleConfig, + ); + } + + public function testCanCreateInstance(): void { - $this->markTestIncomplete(); + $this->assertInstanceOf(IdTokenBuilder::class, $this->sut()); + } + + public function testCanBuild(): void + { + $this->moduleConfigMock->expects($this->once())->method('getIssuer') + ->willReturn('issuer'); + $this->idTokenFactoryMock->expects($this->once())->method('fromData') + ->with( + $this->anything(), + SignatureAlgorithmEnum::RS256, + $this->arrayHasKey(ClaimsEnum::Iss->value), + ); + + $this->claimTranslatorExtractorMock->expects($this->once()) + ->method('extract') + ->willReturn(['foo' => 'bar']); + + $this->claimTranslatorExtractorMock->expects($this->once()) + ->method('extractAdditionalIdTokenClaims') + ->willReturn(['additional' => 'claim']); + + $this->assertInstanceOf( + IdToken::class, + $this->sut()->buildFor( + $this->userEntityMock, + $this->accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ), + ); + } + + public function testWillNegotiateIdTokenSignatureAlgorithm(): void + { + $this->clientEntityMock->method('getIdTokenSignedResponseAlg') + ->willReturn(SignatureAlgorithmEnum::ES256->value); + + $ecSignatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $ecSignatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::ES256); + + $this->protocolSignatureKeyBagMock->expects($this->once()) + ->method('getFirstByAlgorithmOrFail') + ->with(SignatureAlgorithmEnum::ES256) + ->willReturn($ecSignatureKeyPairMock); + + $this->assertInstanceOf( + IdToken::class, + $this->sut()->buildFor( + $this->userEntityMock, + $this->accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ), + ); + } + + public function testThrowsForInvalidUserEntity(): void + { + $userEntityInterfaceMock = $this->createMock(UserEntityInterface::class); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ClaimSetInterface'); + + $this->sut()->buildFor( + $userEntityInterfaceMock, + $this->accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ); + } + + public function testThrowsForInvalidClientEntity(): void + { + $accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); + $accessTokenEntityMock->method('getClient')->willReturn( + $this->createMock(\League\OAuth2\Server\Entities\ClientEntityInterface::class), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ClientEntity'); + + $this->sut()->buildFor( + $this->userEntityMock, + $accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ); } }