From 771bb7c619b8a43dcacddbc325f8e8b83b2e2830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 22 Aug 2026 13:17:11 +0200 Subject: [PATCH 1/3] Remove spomky-labs/base64url dep --- composer.json | 1 - src/Services/IdTokenBuilder.php | 3 +- .../unit/src/Services/IdTokenBuilderTest.php | 89 ++++++++++++++++--- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 11f4e67f..f1f2d870 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,6 @@ "simplesamlphp/composer-module-installer": "^1.3", "simplesamlphp/openid": "~0.6.0", "simplesamlphp/simplesamlphp": "^2.5.3.1", - "spomky-labs/base64url": "^2.0", "symfony/cache": "^7.4", "symfony/expression-language": "^7.4", "symfony/intl": "^7.4", diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index 4153aa6f..446f0e8e 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -4,7 +4,6 @@ namespace SimpleSAML\Module\oidc\Services; -use Base64Url\Base64Url; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\UserEntityInterface; use RuntimeException; @@ -161,7 +160,7 @@ public function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, $accessTokenString = $accessToken->toString(); - return Base64Url::encode( + return $this->core->helpers()->base64Url()->encode( substr( hash( $hashAlgorithm, diff --git a/tests/unit/src/Services/IdTokenBuilderTest.php b/tests/unit/src/Services/IdTokenBuilderTest.php index 774a1fea..573c6165 100644 --- a/tests/unit/src/Services/IdTokenBuilderTest.php +++ b/tests/unit/src/Services/IdTokenBuilderTest.php @@ -5,10 +5,12 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; use DateTimeImmutable; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\UserEntityInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RuntimeException; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\ScopeEntity; @@ -21,6 +23,11 @@ use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\Helpers; +use SimpleSAML\OpenID\Helpers\Base64Url; +use SimpleSAML\OpenID\Helpers\DateTime; +use SimpleSAML\OpenID\Helpers\Random; +use SimpleSAML\OpenID\Helpers\Type; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; @@ -57,14 +64,12 @@ protected function setUp(): void $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); @@ -144,13 +149,13 @@ public function testSubIsCanonicalRegardlessOfClaimRelease(): void $this->userEntityMock->method('getClaims')->willReturn(['uid' => ['raw-identifier']]); // The `sub` payload value passes through the type helper, so make it return the value it is given. - $typeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelperMock = $this->createMock(Type::class); $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); - $dateTimeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\DateTime::class); + $dateTimeHelperMock = $this->createMock(DateTime::class); $dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable()); - $randomHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Random::class); + $randomHelperMock = $this->createMock(Random::class); $randomHelperMock->method('string')->willReturn('random-jti'); - $openIdHelpersMock = $this->createMock(\SimpleSAML\OpenID\Helpers::class); + $openIdHelpersMock = $this->createMock(Helpers::class); $openIdHelpersMock->method('type')->willReturn($typeHelperMock); $openIdHelpersMock->method('dateTime')->willReturn($dateTimeHelperMock); $openIdHelpersMock->method('random')->willReturn($randomHelperMock); @@ -195,13 +200,13 @@ public function testSubIsKeptForFalsyValue(): void $this->userEntityMock->method('getIdentifier')->willReturn('0'); $this->userEntityMock->method('getClaims')->willReturn(['uid' => ['0']]); - $typeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelperMock = $this->createMock(Type::class); $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); - $dateTimeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\DateTime::class); + $dateTimeHelperMock = $this->createMock(DateTime::class); $dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable()); - $randomHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Random::class); + $randomHelperMock = $this->createMock(Random::class); $randomHelperMock->method('string')->willReturn('random-jti'); - $openIdHelpersMock = $this->createMock(\SimpleSAML\OpenID\Helpers::class); + $openIdHelpersMock = $this->createMock(Helpers::class); $openIdHelpersMock->method('type')->willReturn($typeHelperMock); $openIdHelpersMock->method('dateTime')->willReturn($dateTimeHelperMock); $openIdHelpersMock->method('random')->willReturn($randomHelperMock); @@ -264,7 +269,7 @@ public function testWillNegotiateIdTokenSignatureAlgorithm(): void public function testThrowsForInvalidUserEntity(): void { $userEntityInterfaceMock = $this->createMock(UserEntityInterface::class); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('ClaimSetInterface'); $this->sut()->buildFor( @@ -286,7 +291,7 @@ public function testThrowsForInvalidClientEntity(): void $this->createMock(\League\OAuth2\Server\Entities\ClientEntityInterface::class), ); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('ClientEntity'); $this->sut()->buildFor( @@ -300,4 +305,64 @@ public function testThrowsForInvalidClientEntity(): void null, ); } + + public function testGenerateAccessTokenHash(): void + { + $accessTokenMock = $this->createMock(AccessTokenEntity::class); + $accessTokenMock->method('toString')->willReturn('jHkWEdUXMU1BOmNcgmVMJw'); + + $base64UrlHelper = new Base64Url(); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('base64Url')->willReturn($base64UrlHelper); + $this->coreMock->method('helpers')->willReturn($helpersMock); + + $expectedAtHash = $base64UrlHelper->encode( + substr(hash('sha256', 'jHkWEdUXMU1BOmNcgmVMJw', true), 0, 16), + ); + + $this->assertSame( + $expectedAtHash, + $this->sut()->generateAccessTokenHash($accessTokenMock, 'RS256'), + ); + } + + public function testGenerateAccessTokenHashWithEdDsa(): void + { + $accessTokenMock = $this->createMock(AccessTokenEntity::class); + $accessTokenMock->method('toString')->willReturn('jHkWEdUXMU1BOmNcgmVMJw'); + + $base64UrlHelper = new Base64Url(); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('base64Url')->willReturn($base64UrlHelper); + $this->coreMock->method('helpers')->willReturn($helpersMock); + + $expectedAtHash = $base64UrlHelper->encode( + substr(hash('sha512', 'jHkWEdUXMU1BOmNcgmVMJw', true), 0, 32), + ); + + $this->assertSame( + $expectedAtHash, + $this->sut()->generateAccessTokenHash($accessTokenMock, SignatureAlgorithmEnum::EdDSA->value), + ); + } + + public function testGenerateAccessTokenHashThrowsForUnsupportedAlgorithm(): void + { + $accessTokenMock = $this->createMock(AccessTokenEntity::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('JWS algorithm not supported'); + + $this->sut()->generateAccessTokenHash($accessTokenMock, 'UNSUPPORTED'); + } + + public function testGenerateAccessTokenHashThrowsWhenNotEntityStringRepresentationInterface(): void + { + $accessTokenMock = $this->createMock(AccessTokenEntityInterface::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('AccessTokenEntity must implement'); + + $this->sut()->generateAccessTokenHash($accessTokenMock, 'RS256'); + } } From cc901c2c2edf180818cabd9445ef88357068641d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 24 Aug 2026 09:51:33 +0200 Subject: [PATCH 2/3] Initial rector v2 pass --- .github/workflows/test.yaml | 9 +- composer.json | 189 +++++++------ phpunit.integration.xml | 4 +- phpunit.xml | 4 +- rector.php | 37 ++- src/Admin/Authorization.php | 4 + .../AbstractOverviewBuilder.php | 13 + .../FederationOverviewBuilder.php | 16 ++ .../ConfigOverview/GeneralOverviewBuilder.php | 14 + .../ProtocolOverviewBuilder.php | 21 ++ src/Admin/ConfigOverview/Row.php | 6 + src/Admin/ConfigOverview/Section.php | 4 + .../ConfigOverview/VciOverviewBuilder.php | 24 ++ src/Admin/Menu.php | 8 +- src/Admin/Menu/Item.php | 3 + src/Bridges/OAuth2Bridge.php | 15 +- src/Bridges/PsrHttpBridge.php | 9 +- src/Bridges/SspBridge.php | 7 + src/Bridges/SspBridge/Auth.php | 1 + src/Bridges/SspBridge/Locale.php | 1 + src/Bridges/SspBridge/Locale/Language.php | 2 + src/Bridges/SspBridge/Module.php | 4 + src/Bridges/SspBridge/Utils.php | 9 + src/Codebooks/FlowTypeEnum.php | 2 + src/Codebooks/RegistrationTypeEnum.php | 1 + src/Codebooks/StatusListExpiryLaneEnum.php | 1 + src/Controllers/AccessTokenController.php | 3 + src/Controllers/Admin/ClientController.php | 15 +- src/Controllers/Admin/ConfigController.php | 18 +- .../Admin/CredentialStatusController.php | 7 + .../Admin/FederationTestController.php | 14 +- .../VerifiableCredentailsTestController.php | 1 + .../Api/VciCredentialOfferApiController.php | 5 +- .../Api/VciCredentialStatusApiController.php | 4 + src/Controllers/AuthorizationController.php | 11 +- .../ConfigurationDiscoveryController.php | 1 + src/Controllers/EndSessionController.php | 5 + .../Federation/EntityStatementController.php | 7 +- src/Controllers/JwksController.php | 3 + .../OAuth2ServerConfigurationController.php | 1 + .../OAuth2/TokenIntrospectionController.php | 18 +- .../PushedAuthorizationController.php | 6 +- src/Controllers/RegistrationController.php | 19 +- src/Controllers/StatusListController.php | 5 + src/Controllers/UserInfoController.php | 10 +- ...redentialIssuerConfigurationController.php | 1 + .../CredentialIssuerCredentialController.php | 17 +- .../CredentialJsonLdContextController.php | 1 + .../JwtVcIssuerConfigurationController.php | 1 + .../VerifiableCredentials/NonceController.php | 1 + src/Entities/AccessTokenEntity.php | 16 +- src/Entities/AuthCodeEntity.php | 12 +- src/Entities/ClaimSetEntity.php | 4 +- src/Entities/ClientEntity.php | 174 +++++++----- .../Interfaces/AuthCodeEntityInterface.php | 1 + .../Interfaces/ClaimSetEntityInterface.php | 2 +- src/Entities/Interfaces/ClaimSetInterface.php | 2 +- .../Interfaces/ClientEntityInterface.php | 63 +++++ src/Entities/Interfaces/ScopeInterface.php | 2 +- ...TokenAssociatableWithAuthCodeInterface.php | 1 + .../Interfaces/TokenRevokableInterface.php | 1 + src/Entities/IssuerStateEntity.php | 6 + .../PushedAuthorizationRequestEntity.php | 8 + src/Entities/RefreshTokenEntity.php | 5 +- src/Entities/ScopeEntity.php | 8 +- .../Traits/AssociateWithAuthCodeTrait.php | 2 + src/Entities/Traits/OidcAuthCodeTrait.php | 3 + src/Entities/Traits/RevokeTokenTrait.php | 2 + src/Entities/UserEntity.php | 11 +- src/Exceptions/OidcException.php | 4 +- src/Factories/AuthSimpleFactory.php | 6 +- src/Factories/AuthorizationServerFactory.php | 1 + src/Factories/CacheFactory.php | 6 +- .../ClaimTranslatorExtractorFactory.php | 5 + src/Factories/CoreFactory.php | 3 +- src/Factories/CredentialOfferUriFactory.php | 21 +- src/Factories/CryptKeyFactory.php | 5 +- src/Factories/DestinationPolicyFactory.php | 1 + .../Entities/AccessTokenEntityFactory.php | 2 + .../Entities/AuthCodeEntityFactory.php | 2 + .../Entities/ClientEntityFactory.php | 8 +- .../Entities/IssuerStateEntityFactory.php | 9 +- ...ushedAuthorizationRequestEntityFactory.php | 3 + .../Entities/RefreshTokenEntityFactory.php | 2 + src/Factories/Entities/UserEntityFactory.php | 2 + src/Factories/FederationFactory.php | 1 + src/Factories/FormFactory.php | 1 + src/Factories/Grant/AuthCodeGrantFactory.php | 1 + src/Factories/Grant/ImplicitGrantFactory.php | 1 + .../Grant/PreAuthCodeGrantFactory.php | 1 + .../Grant/RefreshTokenGrantFactory.php | 1 + src/Factories/JwksFactory.php | 1 + src/Factories/JwsFactory.php | 1 + src/Factories/RequestObjectFactory.php | 3 +- src/Factories/RequestRulesManagerFactory.php | 8 +- src/Factories/TemplateFactory.php | 12 + src/Factories/TokenResponseFactory.php | 1 + src/Factories/TokenStatusListFactory.php | 1 + .../VerifiableCredentialsFactory.php | 1 + src/Forms/ClientForm.php | 35 ++- src/Forms/Controls/CsrfProtection.php | 1 + src/Forms/CredentialStatusForm.php | 4 + src/Helpers.php | 13 + src/Helpers/Arr.php | 4 + src/Helpers/Client.php | 1 + src/Helpers/DateTime.php | 2 + src/Helpers/Http.php | 2 + src/Helpers/Str.php | 1 + src/ModuleConfig.php | 256 +++++++++++++++++- .../AbstractDatabaseRepository.php | 4 + src/Repositories/AccessTokenRepository.php | 10 + src/Repositories/AllowedOriginRepository.php | 5 + src/Repositories/AuthCodeRepository.php | 10 + src/Repositories/ClientRepository.php | 22 +- .../CodeChallengeVerifiersRepository.php | 4 + .../AccessTokenRepositoryInterface.php | 1 + .../Interfaces/IdentityProviderInterface.php | 2 +- .../RefreshTokenRepositoryInterface.php | 1 + src/Repositories/IssuerStateRepository.php | 9 + .../PushedAuthorizationRequestRepository.php | 7 + src/Repositories/RefreshTokenRepository.php | 10 + src/Repositories/ScopeRepository.php | 2 + src/Repositories/StatusAuditRepository.php | 5 + .../StatusListEntryRepository.php | 20 ++ src/Repositories/StatusListRepository.php | 24 ++ src/Repositories/UserRepository.php | 6 + .../RelyingPartyAssociationInterface.php | 16 ++ .../Associations/RelyingPartyAssociation.php | 10 + src/Server/AuthorizationServer.php | 3 + src/Server/Exceptions/OidcServerException.php | 24 +- src/Server/Grants/AuthCodeGrant.php | 24 +- src/Server/Grants/ImplicitGrant.php | 9 +- src/Server/Grants/PreAuthCodeGrant.php | 9 + src/Server/Grants/RefreshTokenGrant.php | 7 +- .../Grants/Traits/IssueAccessTokenTrait.php | 2 + .../BackChannelLogoutHandler.php | 6 + .../Registration/ClientMetadataValidator.php | 24 ++ .../Interfaces/RequestRuleInterface.php | 7 +- .../Interfaces/ResultBagInterface.php | 12 +- .../RequestRules/RequestRulesManager.php | 15 +- src/Server/RequestRules/Result.php | 2 + src/Server/RequestRules/ResultBag.php | 8 + .../RequestRules/Rules/AbstractRule.php | 4 +- .../RequestRules/Rules/AcrValuesRule.php | 6 +- .../Rules/AddClaimsToIdTokenRule.php | 4 +- .../Rules/AuthorizationDetailsRule.php | 10 +- .../Rules/ClientAuthenticationRule.php | 7 +- .../RequestRules/Rules/ClientIdRule.php | 6 +- .../Rules/ClientRedirectUriRule.php | 10 +- src/Server/RequestRules/Rules/ClientRule.php | 9 +- .../Rules/CodeChallengeMethodRule.php | 7 +- .../RequestRules/Rules/CodeChallengeRule.php | 6 +- .../RequestRules/Rules/CodeVerifierRule.php | 6 +- .../RequestRules/Rules/IdTokenHintRule.php | 12 +- .../RequestRules/Rules/IssuerStateRule.php | 6 +- .../RequestRules/Rules/LoginHintRule.php | 6 +- src/Server/RequestRules/Rules/MaxAgeRule.php | 7 +- .../Rules/PostLogoutRedirectUriRule.php | 7 +- src/Server/RequestRules/Rules/PromptRule.php | 7 +- .../RequestRules/Rules/RequestObjectRule.php | 14 +- .../RequestRules/Rules/RequestUriRule.php | 11 +- .../Rules/RequestedClaimsRule.php | 7 +- .../RequestRules/Rules/RequiredNonceRule.php | 6 +- .../Rules/RequiredOpenIdScopeRule.php | 9 +- .../RequestRules/Rules/ResponseModeRule.php | 6 +- .../RequestRules/Rules/ResponseTypeRule.php | 6 +- .../Rules/ScopeOfflineAccessRule.php | 4 +- src/Server/RequestRules/Rules/ScopeRule.php | 7 +- src/Server/RequestRules/Rules/StateRule.php | 6 +- .../RequestRules/Rules/UiLocalesRule.php | 6 +- .../RequestTypes/AuthorizationRequest.php | 41 +++ src/Server/RequestTypes/LogoutRequest.php | 8 + src/Server/ResourceServer.php | 1 + .../ResponseModes/FormPostResponseMode.php | 9 +- src/Server/ResponseTypes/HtmlResponse.php | 2 + .../Interfaces/AcrResponseTypeInterface.php | 1 + .../AuthTimeResponseTypeInterface.php | 1 + .../Interfaces/NonceResponseTypeInterface.php | 1 + .../SessionIdResponseTypeInterface.php | 1 + src/Server/ResponseTypes/TokenResponse.php | 13 + .../TokenIssuers/RefreshTokenIssuer.php | 1 + .../Validators/BearerTokenValidator.php | 10 +- .../Api/ApiTokenPrincipalResolver.php | 6 + src/Services/Api/Authorization.php | 6 +- src/Services/AuthContextService.php | 4 + src/Services/AuthenticationService.php | 67 +++-- src/Services/DatabaseMigration.php | 48 ++++ src/Services/ErrorResponder.php | 4 + src/Services/ExpiredEntriesCleaner.php | 1 + src/Services/IdTokenBuilder.php | 2 + src/Services/LoggerService.php | 9 + src/Services/LogoutTokenBuilder.php | 2 + src/Services/NonceService.php | 6 +- src/Services/OpMetadataService.php | 3 + src/Services/SessionMessagesService.php | 2 + src/Services/SessionService.php | 14 + src/Services/StateService.php | 9 +- .../StatusIndexAllocatorInterface.php | 2 +- .../StatusListTokenProviderInterface.php | 3 +- .../Contracts/StatusUpdaterInterface.php | 2 + src/StatusList/CredentialStatusIssuer.php | 6 +- src/StatusList/CredentialStatusService.php | 7 +- src/StatusList/DbStatusIndexAllocator.php | 12 + src/StatusList/DbStatusListTokenProvider.php | 10 +- src/StatusList/DbStatusUpdater.php | 7 + src/StatusList/StatusListContentHasher.php | 1 + src/StatusList/StatusListKeyResolver.php | 6 +- src/StatusList/StatusListLifecycle.php | 9 + src/StatusList/StatusListRateLimiter.php | 2 + src/StatusList/StatusListReconciler.php | 3 + src/StatusList/SubjectRefHasher.php | 4 + src/StatusList/Values/AllocationAttempt.php | 2 + .../Values/CredentialStatusChange.php | 5 + .../Values/DatabaseRowValuesTrait.php | 5 + src/StatusList/Values/StatusAllocation.php | 4 + .../Values/StatusListAllocationTarget.php | 3 + .../Values/StatusListEntryRecord.php | 17 +- .../Values/StatusListLifecycleReport.php | 7 + src/StatusList/Values/StatusListPool.php | 25 ++ src/StatusList/Values/StatusListPoolBag.php | 8 + .../StatusListReconciliationCandidate.php | 7 + src/StatusList/Values/StatusListRecord.php | 32 +++ .../Values/StatusListTokenResult.php | 6 + .../Session/LogoutTicketStoreBuilder.php | 4 + src/Stores/Session/LogoutTicketStoreDb.php | 8 + .../Session/LogoutTicketStoreInterface.php | 2 + .../AuthenticatedOAuth2ClientResolver.php | 30 +- src/Utils/ClaimTranslatorExtractor.php | 16 +- src/Utils/DateIntervalFormatter.php | 1 + src/Utils/Debug/ArrayLogger.php | 40 ++- .../FederationParticipationValidator.php | 8 +- src/Utils/FingerprintGenerator.php | 3 +- src/Utils/HttpContentNegotiator.php | 4 + src/Utils/JwksResolver.php | 1 + src/Utils/RequestParamsResolver.php | 30 +- .../ResponseTypeGrantTypeCorrespondence.php | 2 + src/Utils/Routes.php | 29 ++ src/Utils/UiLocalesResolver.php | 4 + src/Utils/VciContextResolver.php | 5 +- .../IntrospectionAuthorization.php | 4 + .../ResolvedClientAuthenticationMethod.php | 2 + tests/config/config.php | 7 +- tests/config/module_oidc.php | 15 +- tests/integration/src/DatabaseContainers.php | 9 + .../AccessTokenRepositoryTest.php | 54 +++- .../src/StatusList/StatusListStorageTest.php | 36 ++- tests/unit/src/Admin/AuthorizationTest.php | 17 ++ .../ConfigOptionCoverageTest.php | 12 + .../FederationOverviewBuilderTest.php | 34 ++- .../GeneralOverviewBuilderTest.php | 26 ++ .../GeneralOverviewTestTrait.php | 3 +- .../OverviewTemplateRenderTest.php | 33 +++ .../ConfigOverview/OverviewTestTrait.php | 7 +- .../ProtocolOverviewBuilderTest.php | 39 +++ .../ConfigOverview/VciOverviewBuilderTest.php | 43 +++ tests/unit/src/Admin/Menu/ItemTest.php | 7 + tests/unit/src/Admin/MenuTest.php | 8 + tests/unit/src/Bridges/OAuth2BridgeTest.php | 5 + tests/unit/src/Bridges/PsrHttpBridgeTest.php | 10 + .../src/Bridges/SspBridge/Auth/SourceTest.php | 3 + tests/unit/src/Bridges/SspBridge/AuthTest.php | 7 +- .../Bridges/SspBridge/Locale/LanguageTest.php | 4 + .../unit/src/Bridges/SspBridge/LocaleTest.php | 7 +- .../Bridges/SspBridge/Module/AdminTest.php | 4 + .../unit/src/Bridges/SspBridge/ModuleTest.php | 9 +- .../unit/src/Bridges/SspBridge/UtilsTest.php | 8 + tests/unit/src/Bridges/SspBridgeTest.php | 19 +- .../Codebooks/RegistrationTypeEnumTest.php | 2 + .../StatusListExpiryLaneEnumTest.php | 5 + tests/unit/src/ConformanceConfigTest.php | 9 + .../Controllers/AccessTokenControllerTest.php | 15 + .../Admin/ClientControllerTest.php | 32 +++ .../Admin/ConfigControllerTest.php | 32 ++- .../Admin/CredentialStatusControllerTest.php | 44 ++- .../VciCredentialStatusApiControllerTest.php | 30 ++ .../AuthorizationControllerTest.php | 78 +++++- .../ConfigurationDiscoveryControllerTest.php | 11 +- .../Controllers/EndSessionControllerTest.php | 35 ++- .../EntityStatementControllerTest.php | 15 + .../src/Controllers/JwksControllerTest.php | 11 + ...Auth2ServerConfigurationControllerTest.php | 13 +- .../TokenIntrospectionControllerTest.php | 85 ++++-- .../PushedAuthorizationControllerTest.php | 32 ++- .../RegistrationControllerTest.php | 24 ++ .../Controllers/StatusListControllerTest.php | 28 ++ .../Controllers/Traits/RequestTraitTest.php | 13 + .../Controllers/UserInfoControllerTest.php | 33 ++- ...ntialIssuerConfigurationControllerTest.php | 17 ++ ...edentialIssuerCredentialControllerTest.php | 39 +++ .../NonceControllerTest.php | 7 + tests/unit/src/DistributedConfigTest.php | 5 + .../src/Entities/AccessTokenEntityTest.php | 22 +- .../unit/src/Entities/AuthCodeEntityTest.php | 18 ++ .../unit/src/Entities/ClaimSetEntityTest.php | 2 + tests/unit/src/Entities/ClientEntityTest.php | 35 +++ .../src/Entities/RefreshTokenEntityTest.php | 10 + tests/unit/src/Entities/ScopeEntityTest.php | 4 + tests/unit/src/Entities/UserEntityTest.php | 8 + .../src/Factories/AuthSimpleFactoryTest.php | 2 + .../ClaimTranslatorExtractorFactoryTest.php | 8 + .../CredentialOfferUriFactoryTest.php | 9 + .../DestinationPolicyFactoryTest.php | 11 + .../Entities/ClientEntityFactoryTest.php | 36 +++ ...dAuthorizationRequestEntityFactoryTest.php | 10 + .../src/Factories/FederationFactoryTest.php | 8 + tests/unit/src/Factories/FormFactoryTest.php | 9 + .../Factories/ProcessingChainFactoryTest.php | 38 ++- .../src/Factories/TemplateFactoryTest.php | 25 +- tests/unit/src/Forms/ClientFormTest.php | 39 ++- .../src/Forms/CredentialStatusFormTest.php | 14 + tests/unit/src/Helpers/ArrTest.php | 7 + tests/unit/src/Helpers/ClientTest.php | 10 + tests/unit/src/Helpers/DateTimeTest.php | 7 +- tests/unit/src/Helpers/HttpTest.php | 12 + tests/unit/src/Helpers/RandomTest.php | 5 + tests/unit/src/Helpers/ScopeTest.php | 8 + tests/unit/src/Helpers/StrTest.php | 4 + tests/unit/src/HelpersTest.php | 38 ++- tests/unit/src/ModuleConfigTest.php | 125 ++++++++- .../AbstractDatabaseRepositoryTest.php | 12 + .../AccessTokenRepositoryTest.php | 42 ++- .../AllowedOriginRepositoryTest.php | 21 +- .../Repositories/AuthCodeRepositoryTest.php | 42 ++- .../src/Repositories/ClientRepositoryTest.php | 50 ++-- .../CodeChallengeVerifiersRepositoryTest.php | 5 + .../IssuerStateRepositoryTest.php | 15 + ...shedAuthorizationRequestRepositoryTest.php | 21 ++ .../RefreshTokenRepositoryTest.php | 51 +++- .../src/Repositories/ScopeRepositoryTest.php | 6 + .../StatusAuditRepositoryTest.php | 20 ++ .../StatusListEntryRepositoryTest.php | 42 +++ .../Repositories/StatusListRepositoryTest.php | 47 ++++ .../src/Repositories/UserRepositoryTest.php | 52 ++-- .../RelyingPartyAssociationTest.php | 6 + .../src/Server/AuthorizationServerTest.php | 2 + .../Exceptions/OidcServerExceptionTest.php | 18 +- .../src/Server/Grants/AuthCodeGrantTest.php | 88 +++++- .../src/Server/Grants/ImplicitGrantTest.php | 37 ++- .../Server/Grants/PreAuthCodeGrantTest.php | 25 ++ .../Server/Grants/RefreshTokenGrantTest.php | 17 +- .../BackChannelLogoutHandlerTest.php | 15 + .../ClientMetadataValidatorTest.php | 53 +++- .../RequestRules/RequestRulesManagerTest.php | 25 +- .../src/Server/RequestRules/ResultBagTest.php | 10 + .../src/Server/RequestRules/ResultTest.php | 17 +- .../RequestRules/Rules/AcrValuesRuleTest.php | 14 + .../Rules/AddClaimsToIdTokenRuleTest.php | 19 +- .../Rules/ClientAuthenticationRuleTest.php | 16 ++ .../RequestRules/Rules/ClientIdRuleTest.php | 12 + .../Rules/ClientRedirectUriRuleTest.php | 25 ++ .../RequestRules/Rules/ClientRuleTest.php | 21 ++ .../Rules/CodeChallengeMethodRuleTest.php | 19 ++ .../Rules/CodeChallengeRuleTest.php | 20 ++ .../Rules/CodeVerifierRuleTest.php | 20 ++ .../Rules/IdTokenHintRuleTest.php | 29 +- .../RequestRules/Rules/LoginHintRuleTest.php | 11 + .../RequestRules/Rules/MaxAgeRuleTest.php | 18 ++ .../Rules/PostLogoutRedirectUriRuleTest.php | 18 ++ .../RequestRules/Rules/PromptRuleTest.php | 20 ++ .../Rules/RedirectUriRuleTest.php | 19 ++ .../Rules/RequestObjectRuleTest.php | 35 +++ .../RequestRules/Rules/RequestUriRuleTest.php | 38 ++- .../Rules/RequestedClaimsRuleTest.php | 14 + .../Rules/RequiredNonceRuleTest.php | 13 + .../Rules/RequiredOpenIdScopeRuleTest.php | 13 + .../Rules/ResponseModeRuleTest.php | 33 ++- .../Rules/ResponseTypeRuleTest.php | 15 +- .../Rules/ScopeOfflineAccessRuleTest.php | 22 ++ .../RequestRules/Rules/ScopeRuleTest.php | 22 +- .../RequestRules/Rules/StateRuleTest.php | 10 + .../RequestRules/Rules/UiLocalesRuleTest.php | 11 + .../RequestTypes/AuthorizationRequestTest.php | 2 + .../Server/RequestTypes/LogoutRequestTest.php | 8 + .../FormPostResponseModeTest.php | 4 + .../ResponseTypes/TokenResponseTest.php | 47 +++- .../Validators/BearerTokenValidatorTest.php | 23 ++ .../Api/ApiTokenPrincipalResolverTest.php | 16 ++ .../src/Services/Api/AuthorizationTest.php | 23 +- .../src/Services/AuthContextServiceTest.php | 23 +- .../Services/AuthenticationServiceTest.php | 98 +++++-- .../unit/src/Services/ErrorResponderTest.php | 9 + .../Services/ExpiredEntriesCleanerTest.php | 9 + .../unit/src/Services/IdTokenBuilderTest.php | 28 +- .../src/Services/LogoutTokenBuilderTest.php | 16 ++ tests/unit/src/Services/NonceServiceTest.php | 50 +++- .../src/Services/OpMetadataServiceTest.php | 25 +- .../Services/SessionMessagesServiceTest.php | 7 + .../unit/src/Services/SessionServiceTest.php | 2 + tests/unit/src/Services/StateServiceTest.php | 5 +- .../StatusList/CredentialStatusIssuerTest.php | 14 + .../CredentialStatusServiceTest.php | 24 ++ .../StatusList/DbStatusIndexAllocatorTest.php | 72 ++++- .../DbStatusListTokenProviderTest.php | 32 +++ .../src/StatusList/DbStatusUpdaterTest.php | 27 ++ .../StatusListContentHasherTest.php | 11 + .../StatusList/StatusListKeyResolverTest.php | 10 + .../StatusList/StatusListLifecycleTest.php | 29 ++ .../StatusList/StatusListRateLimiterTest.php | 14 + .../StatusList/StatusListReconcilerTest.php | 16 ++ .../src/StatusList/SubjectRefHasherTest.php | 10 + .../Values/StatusListLifecycleReportTest.php | 5 + .../Values/StatusListPoolBagTest.php | 9 + .../StatusList/Values/StatusListPoolTest.php | 36 ++- .../Values/StatusListTokenResultTest.php | 9 + .../Session/LogoutTicketStoreBuilderTest.php | 2 + .../Session/LogoutTicketStoreDbTest.php | 5 + .../src/TranslationCatalogCoverageTest.php | 14 + .../AuthenticatedOAuth2ClientResolverTest.php | 73 ++++- .../Utils/ClaimTranslatorExtractorTest.php | 23 +- .../src/Utils/DateIntervalFormatterTest.php | 7 + .../unit/src/Utils/Debug/ArrayLoggerTest.php | 16 +- .../FederationParticipationValidatorTest.php | 22 +- .../src/Utils/HttpContentNegotiatorTest.php | 14 + .../src/Utils/RequestParamsResolverTest.php | 47 +++- ...esponseTypeGrantTypeCorrespondenceTest.php | 5 + .../unit/src/Utils/UiLocalesResolverTest.php | 9 + .../src/Utils/UserIdentifierResolverTest.php | 9 + .../IntrospectionAuthorizationTest.php | 6 + ...ResolvedClientAuthenticationMethodTest.php | 6 + 419 files changed, 5529 insertions(+), 773 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5b63815b..82d7adb8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} extensions: mbstring, xml - tools: composer:v2 + tools: composer:v2, phpcov coverage: pcov - name: Setup problem matchers for PHP @@ -72,8 +72,7 @@ jobs: - name: Merge coverage data if: ${{ matrix.php-versions == '8.5' }} run: | - ./vendor/bin/phpunit-merger log build/logs/partial_junit/ build/logs/junit.xml - ./vendor/bin/phpunit-merger coverage build/logs/partial_clover/ build/logs/clover.xml + phpcov merge --clover build/logs/clover.xml build/logs/partial_clover/ - name: Save coverage data if: ${{ matrix.php-versions == '8.5' }} @@ -202,6 +201,10 @@ jobs: if: always() run: php vendor/bin/phpcs + - name: Rector + if: always() + run: php vendor/bin/rector --dry-run + - name: Psalm if: always() run: php vendor/bin/psalm --show-info=true diff --git a/composer.json b/composer.json index f1f2d870..b0dcad85 100644 --- a/composer.json +++ b/composer.json @@ -1,97 +1,106 @@ { - "name": "simplesamlphp/simplesamlphp-module-oidc", - "description": "A SimpleSAMLphp module adding support for the OpenID Connect protocol", - "type": "simplesamlphp-module", - "keywords": [ "oauth2", "openid", "connect", "oidc", "openid connect" ], - "license": "MIT", - "authors": [ - { - "name": "Spanish Research and Academic Network" - }, - { - "name": "University of Córdoba" - }, - { - "name": "Sergio Gómez", - "email": "sergio@uco.es" - } - ], - "require": { - "php": "^8.3", - "ext-curl": "*", - "ext-json": "*", - "ext-openssl": "*", - "ext-pdo": "*", - "guzzlehttp/guzzle": "^7.0", - "league/oauth2-server": "^9.4", - "nette/forms": "^3", - "nyholm/psr7": "^1.8", - "psr/container": "^2.0", - "psr/log": "^3", - "psr/simple-cache": "^3", - "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~0.6.0", - "simplesamlphp/simplesamlphp": "^2.5.3.1", - "symfony/cache": "^7.4", - "symfony/expression-language": "^7.4", - "symfony/intl": "^7.4", - "symfony/psr-http-message-bridge": "^7.4" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3", - "rector/rector": "^1.2.10", - "simplesamlphp/simplesamlphp-test-framework": "^1.9.3", - "vimeo/psalm": "^6.15.1", - "testcontainers/testcontainers": "^0.2", - "nimut/phpunit-merger": "^2.0" - }, - "conflict": { - "rector/rector": "2.3.0" + "name": "simplesamlphp/simplesamlphp-module-oidc", + "description": "A SimpleSAMLphp module adding support for the OpenID Connect protocol", + "type": "simplesamlphp-module", + "keywords": [ + "oauth2", + "openid", + "connect", + "oidc", + "openid connect", + "federation", + "vci", + "credential issuer" + ], + "license": "MIT", + "authors": [ + { + "name": "Spanish Research and Academic Network" }, - "config": { - "preferred-install": { - "*": "dist" - }, - "sort-packages": true, - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true, - "php-http/discovery": true, - "phpstan/extension-installer": true, - "simplesamlphp/composer-module-installer": true, - "simplesamlphp/composer-xmlprovider-installer": true - }, - "cache-dir": "build/composer" + { + "name": "University of Córdoba" }, - "autoload": { - "psr-4": { - "SimpleSAML\\Module\\oidc\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "SimpleSAML\\Test\\Module\\oidc\\unit\\": "tests/unit/src/", - "SimpleSAML\\Test\\Module\\oidc\\integration\\": "tests/integration/src/" - } + { + "name": "Sergio Gómez", + "email": "sergio@uco.es" + } + ], + "require": { + "php": "^8.3", + "ext-curl": "*", + "ext-json": "*", + "ext-openssl": "*", + "ext-pdo": "*", + "guzzlehttp/guzzle": "^7.0", + "league/oauth2-server": "^9.4", + "nette/forms": "^3", + "nyholm/psr7": "^1.8", + "psr/container": "^2.0", + "psr/log": "^3", + "psr/simple-cache": "^3", + "simplesamlphp/composer-module-installer": "^1.3", + "simplesamlphp/openid": "~0.6.0", + "simplesamlphp/simplesamlphp": "^2.5.3.1", + "symfony/cache": "^7.4", + "symfony/expression-language": "^7.4", + "symfony/intl": "^7.4", + "symfony/psr-http-message-bridge": "^7.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "rector/rector": "^2.0", + "simplesamlphp/simplesamlphp-test-framework": "^1.11.6", + "testcontainers/testcontainers": "^0.2", + "vimeo/psalm": "^6.15.1" + }, + "conflict": { + "rector/rector": "2.3.0" + }, + "config": { + "preferred-install": { + "*": "dist" }, - "extra": { - "branch-alias": { - } + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "php-http/discovery": true, + "phpstan/extension-installer": true, + "simplesamlphp/composer-module-installer": true, + "simplesamlphp/composer-xmlprovider-installer": true }, - "scripts": { - "pre-commit": [ - "vendor/bin/phpcbf -pn || true", - "vendor/bin/phpcs -p", - "vendor/bin/psalm", - "vendor/bin/phpunit" - ], - "tests": [ - "vendor/bin/phpunit --no-coverage" - ], - "integration-tests": [ - "vendor/bin/phpunit --no-configuration -c phpunit.integration.xml --no-coverage" - ], - "unit-tests": [ - "vendor/bin/phpunit --no-coverage" - ] + "cache-dir": "build/composer" + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Module\\oidc\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "SimpleSAML\\Test\\Module\\oidc\\unit\\": "tests/unit/src/", + "SimpleSAML\\Test\\Module\\oidc\\integration\\": "tests/integration/src/" + } + }, + "extra": { + "branch-alias": { + } + }, + "scripts": { + "pre-commit": [ + "vendor/bin/rector", + "vendor/bin/phpcbf -pn || true", + "vendor/bin/phpcs -p", + "vendor/bin/psalm", + "vendor/bin/phpunit" + ], + "tests": [ + "vendor/bin/phpunit --no-coverage" + ], + "integration-tests": [ + "vendor/bin/phpunit --no-configuration -c phpunit.integration.xml --no-coverage" + ], + "unit-tests": [ + "vendor/bin/phpunit --no-coverage" + ] + } } diff --git a/phpunit.integration.xml b/phpunit.integration.xml index a8b863c9..0a181c4a 100644 --- a/phpunit.integration.xml +++ b/phpunit.integration.xml @@ -2,9 +2,11 @@ diff --git a/phpunit.xml b/phpunit.xml index 63561a15..1e62e53b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -2,9 +2,11 @@ diff --git a/rector.php b/rector.php index 5fefbb9f..22352a10 100644 --- a/rector.php +++ b/rector.php @@ -3,33 +3,32 @@ declare(strict_types=1); use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector; +use Rector\CodeQuality\Rector\ClassMethod\OptionalParametersAfterRequiredRector; +use Rector\CodingStyle\Rector\FuncCall\FunctionFirstClassCallableRector; use Rector\Config\RectorConfig; -use Rector\Set\ValueObject\LevelSetList; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->importNames(); - $rectorConfig->disableParallel(); +if (function_exists('sspmodAutoloadPSR4')) { + spl_autoload_unregister('sspmodAutoloadPSR4'); +} - $rectorConfig->bootstrapFiles([ - //__DIR__ . '/vendor/autoload.php', - ]); - - $rectorConfig->paths([ +return RectorConfig::configure() + ->withParallel(timeoutSeconds: 360) + ->withImportNames(importDocBlockNames: false) + ->withPaths([ // TODO v7 mivanci also go trough commented out paths... //__DIR__ . '/docker', //__DIR__ . '/hooks', //__DIR__ . '/public', __DIR__ . '/src', __DIR__ . '/tests', + ]) + ->withPhpSets(php83: true) + ->withSkip([ + FunctionFirstClassCallableRector::class, + OptionalParametersAfterRequiredRector::class, + ]) + ->withRules([ + InlineConstructorDefaultToPropertyRector::class, + DeclareStrictTypesRector::class, ]); - - // register a single rule - $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); - $rectorConfig->rule(DeclareStrictTypesRector::class); - - // define sets of rules - $rectorConfig->sets([ - LevelSetList::UP_TO_PHP_81, - ]); -}; diff --git a/src/Admin/Authorization.php b/src/Admin/Authorization.php index 7bc35220..5dd6b193 100644 --- a/src/Admin/Authorization.php +++ b/src/Admin/Authorization.php @@ -20,12 +20,14 @@ public function __construct( ) { } + public function isAdmin(): bool { $this->loggerService->debug('Authorization::isAdmin'); return $this->sspBridge->utils()->auth()->isAdmin(); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ @@ -60,6 +62,7 @@ public function requireAdmin(bool $forceAdminAuthentication = false): void } } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ @@ -93,6 +96,7 @@ public function requireAdminOrUserWithPermission(string $permission): void $this->requireAdmin(true); } + public function getUserId(): string { return $this->authContextService->getAuthUserId(); diff --git a/src/Admin/ConfigOverview/AbstractOverviewBuilder.php b/src/Admin/ConfigOverview/AbstractOverviewBuilder.php index 3a664c57..a672364b 100644 --- a/src/Admin/ConfigOverview/AbstractOverviewBuilder.php +++ b/src/Admin/ConfigOverview/AbstractOverviewBuilder.php @@ -57,6 +57,7 @@ abstract class AbstractOverviewBuilder 'version', ]; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Routes $routes, @@ -65,6 +66,7 @@ public function __construct( ) { } + protected function buildDurationRow( string $label, DateInterval $duration, @@ -83,6 +85,7 @@ protected function buildDurationRow( ); } + /** * @param array $options */ @@ -109,6 +112,7 @@ protected function buildHttpClientOptionsRow( ); } + /** * Replace every HTTP client option value which is not explicitly allowlisted, so that a * credential carried in these options can not reach the screen. @@ -131,6 +135,7 @@ protected function redactHttpClientOptions(array $options): array return $redacted; } + /** * Row reporting how many secret values are configured, without disclosing any of them. */ @@ -151,6 +156,7 @@ protected function buildSecretCountRow( ); } + /** * Row showing this entity's issuer, which both screens display. * @@ -187,6 +193,7 @@ protected function buildIssuerRow(?string $noteWhenConfigured, string $noteWhenD ); } + /** * Row for an optional single-value entity metadata parameter. */ @@ -206,6 +213,7 @@ protected function buildOptionalTextRow( ); } + /** * Row for an optional URI, rendered as a link when set. */ @@ -224,6 +232,7 @@ protected function buildOptionalUrlRow( ); } + /** * Build a row defensively. * @@ -251,6 +260,7 @@ protected function guardRow(string $label, string $configOption, callable $build } } + /** * Report a config option which could not be resolved, and return a row warning for it. * @@ -280,11 +290,13 @@ protected function describeResolutionError(Throwable $exception, string $configO ); } + protected function yesNo(bool $value): string { return $value ? Translate::noop('Yes') : Translate::noop('No'); } + protected function formatBytes(int $bytes): string { if ($bytes >= 1048576) { @@ -298,6 +310,7 @@ protected function formatBytes(int $bytes): string return sprintf('%d B', $bytes); } + /** * Render a number with at most one decimal place, dropping a trailing '.0'. */ diff --git a/src/Admin/ConfigOverview/FederationOverviewBuilder.php b/src/Admin/ConfigOverview/FederationOverviewBuilder.php index a9616490..74ce7d33 100644 --- a/src/Admin/ConfigOverview/FederationOverviewBuilder.php +++ b/src/Admin/ConfigOverview/FederationOverviewBuilder.php @@ -44,6 +44,7 @@ public function build(array $trustMarks = []): array ]; } + /** * @throws \Exception */ @@ -130,6 +131,7 @@ protected function buildEntitySection(): Section ); } + /** * @throws \Exception */ @@ -150,6 +152,7 @@ protected function buildEndpointsSection(): Section ); } + protected function buildSignatureKeysSection(): Section { $keyPairBag = null; @@ -181,6 +184,7 @@ protected function buildSignatureKeysSection(): Section ); } + /** * @throws \Exception */ @@ -237,6 +241,7 @@ protected function buildTrustAnchorsSection(): Section ); } + /** * @param \SimpleSAML\OpenID\Federation\TrustMark[] $trustMarks * @throws \Exception @@ -332,6 +337,7 @@ protected function buildTrustMarksSection(array $trustMarks): Section ); } + /** * @throws \Exception */ @@ -385,6 +391,7 @@ protected function buildTrustChainLimitsSection(): Section ); } + /** * @throws \Exception */ @@ -438,6 +445,7 @@ protected function buildCacheSection(): Section ); } + /** * @throws \Exception */ @@ -464,6 +472,7 @@ protected function buildOutboundHttpSection(): Section ); } + /** * @param array $trustAnchors Trust Anchor ID to JWKS JSON string, or null. * @return array @@ -486,6 +495,7 @@ protected function buildTrustAnchorList(array $trustAnchors): array return $list; } + /** * @param array $trustAnchorList */ @@ -500,6 +510,7 @@ protected function hasInvalidJwks(array $trustAnchorList): bool return false; } + /** * Read the resolved Trust Marks for display. * @@ -540,6 +551,7 @@ protected function buildTrustMarkList(array $trustMarks, ?int &$unreadableCount return $list; } + /** * Present the dynamic Trust Mark configuration as a map of Trust Mark Type to a single-item * list holding its issuer, matching the StringMap rendering used elsewhere. @@ -558,6 +570,7 @@ protected function buildDynamicTrustMarkMap(array $dynamicTrustMarks): array return $map; } + /** * Normalize the participation limits into plain, displayable arrays. * @@ -595,6 +608,7 @@ protected function buildParticipationLimits(array $participationLimits): array return $limits; } + /** * Whether any participation limit entry has a shape the runtime validator rejects: a Trust * Anchor which does not map to a list of limits, a limit which does not map to a list, or a @@ -626,6 +640,7 @@ protected function hasMalformedParticipationLimits(array $participationLimits): return false; } + /** * Limit identifiers which are configured but which LimitsEnum does not recognize. * @@ -653,6 +668,7 @@ protected function findUnknownParticipationLimitIds(array $participationLimits): return $unknown; } + /** * Human readable description of when the Trust Mark status endpoint is consulted. * diff --git a/src/Admin/ConfigOverview/GeneralOverviewBuilder.php b/src/Admin/ConfigOverview/GeneralOverviewBuilder.php index 01b08196..eeaca5fe 100644 --- a/src/Admin/ConfigOverview/GeneralOverviewBuilder.php +++ b/src/Admin/ConfigOverview/GeneralOverviewBuilder.php @@ -63,9 +63,12 @@ class GeneralOverviewBuilder extends AbstractOverviewBuilder * screen resolves the value exactly as the client registry does. */ protected const int MIN_ITEMS_PER_PAGE = 1; + protected const int MAX_ITEMS_PER_PAGE = 100; + protected const int DEFAULT_ITEMS_PER_PAGE = 20; + public function __construct( ModuleConfig $moduleConfig, Routes $routes, @@ -76,6 +79,7 @@ public function __construct( parent::__construct($moduleConfig, $routes, $dateIntervalFormatter, $logger); } + /** * @return \SimpleSAML\Module\oidc\Admin\ConfigOverview\Section[] */ @@ -87,6 +91,7 @@ public function build(): array ]; } + protected function buildAdministrationUiSection(): Section { return new Section( @@ -97,6 +102,7 @@ protected function buildAdministrationUiSection(): Section ); } + protected function buildStorageCleanupSection(): Section { return new Section( @@ -106,6 +112,7 @@ protected function buildStorageCleanupSection(): Section ); } + /** * Row describing who, besides a SimpleSAMLphp administrator, may use the client registry. */ @@ -146,6 +153,7 @@ function (): Row { ); } + /** * Describe every configured permission, and whether it can grant anything. * @@ -181,6 +189,7 @@ protected function buildPermissionList(array $permissions): array return $list; } + /** * Entitlements a user could present to be granted a permission. * @@ -204,6 +213,7 @@ protected function resolveEntitlements(mixed $value): array return $entitlements; } + protected function buildItemsPerPageRow(): Row { return $this->guardRow( @@ -238,6 +248,7 @@ function (): Row { ); } + protected function buildCronTagRow(): Row { return $this->guardRow( @@ -281,6 +292,7 @@ function (): Row { ); } + /** * Warning for a cron tag which can never reach this module, or null when it can. * @@ -335,6 +347,7 @@ protected function describeUndispatchableCronTag(string $cronTag): ?string return null; } + /** * The cron module has no usable list of tags it may run, so it refuses every one of them. */ @@ -346,6 +359,7 @@ protected function describeUnusableCronTagList(): string ); } + protected function reportUnreadableCronState(Throwable $exception): void { $this->logger->warning( diff --git a/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php b/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php index 79bb04cd..18f90d46 100644 --- a/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php +++ b/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php @@ -33,10 +33,14 @@ class ProtocolOverviewBuilder extends AbstractOverviewBuilder * Custom scope config keys, mirroring ClaimTranslatorExtractorFactory. */ protected const string SCOPE_KEY_DESCRIPTION = 'description'; + protected const string SCOPE_KEY_CLAIMS = 'claims'; + protected const string SCOPE_KEY_CLAIM_NAME_PREFIX = 'claim_name_prefix'; + protected const string SCOPE_KEY_MULTIPLE_CLAIM_VALUES_ALLOWED = 'are_multiple_claim_values_allowed'; + public function __construct( ModuleConfig $moduleConfig, Routes $routes, @@ -47,6 +51,7 @@ public function __construct( parent::__construct($moduleConfig, $routes, $dateIntervalFormatter, $logger); } + /** * @return \SimpleSAML\Module\oidc\Admin\ConfigOverview\Section[] * @throws \Exception @@ -69,6 +74,7 @@ public function build(): array ]; } + /** * @throws \Exception */ @@ -97,6 +103,7 @@ protected function buildEntitySection(): Section ); } + protected function buildEndpointsSection(): Section { $rows = [ @@ -143,6 +150,7 @@ protected function buildEndpointsSection(): Section return new Section(Translate::noop('Endpoints'), 'endpoints', ...$rows); } + /** * @throws \Exception */ @@ -190,6 +198,7 @@ protected function buildTokensSection(): Section ); } + /** * @throws \Exception */ @@ -225,6 +234,7 @@ protected function buildSignatureKeysSection(): Section ); } + /** * @throws \Exception */ @@ -264,6 +274,7 @@ protected function buildAuthenticationSection(): Section ); } + /** * @throws \Exception */ @@ -304,6 +315,7 @@ protected function buildAcrSection(): Section ); } + /** * @throws \Exception */ @@ -360,6 +372,7 @@ protected function buildScopesAndClaimsSection(): Section ); } + /** * @throws \Exception */ @@ -458,6 +471,7 @@ protected function buildRequestObjectSection(): Section ); } + /** * @throws \Exception */ @@ -562,6 +576,7 @@ protected function buildDynamicClientRegistrationSection(): Section ); } + /** * @throws \Exception */ @@ -625,6 +640,7 @@ protected function buildCacheSection(): Section ); } + /** * @throws \Exception */ @@ -663,6 +679,7 @@ protected function buildOutboundHttpSection(): Section ); } + /** * Where this OP is willing to send outbound requests. * @@ -786,6 +803,7 @@ function (): Row { ]; } + /** * @throws \Exception */ @@ -859,6 +877,7 @@ function (): Row { return new Section(Translate::noop('API'), 'api', ...$rows); } + /** * Human readable description of the Dynamic Client Registration access-control mode. */ @@ -870,6 +889,7 @@ protected function describeRegistrationAuth(DcrRegistrationAuthEnum $registratio }; } + /** * Render the configured authproc filters as a list, tolerating both the array form (with a * 'class' key) and the plain string shorthand. @@ -905,6 +925,7 @@ protected function buildAuthProcFilterList(): array return $filters; } + /** * Prepare scope definitions for display, including where each scope comes from and the options * which affect how its claims are emitted. diff --git a/src/Admin/ConfigOverview/Row.php b/src/Admin/ConfigOverview/Row.php index 767bd4c7..7e0cde4b 100644 --- a/src/Admin/ConfigOverview/Row.php +++ b/src/Admin/ConfigOverview/Row.php @@ -29,21 +29,25 @@ public function __construct( ) { } + public function getLabel(): string { return $this->label; } + public function getValue(): mixed { return $this->value; } + public function getValueType(): ConfigOverviewValueTypeEnum { return $this->valueType; } + /** * The ModuleConfig::OPTION_* value this row displays, or null if the row is not tied to a * single config option. @@ -53,6 +57,7 @@ public function getConfigOption(): ?string return $this->configOption; } + /** * Additional context, for example, that the shown value is a fallback rather than a configured * one. @@ -62,6 +67,7 @@ public function getNote(): ?string return $this->note; } + /** * Set when the current value warrants administrator attention, typically a security relevant * setting which deviates from the safe default. diff --git a/src/Admin/ConfigOverview/Section.php b/src/Admin/ConfigOverview/Section.php index 0524f13c..06b320e8 100644 --- a/src/Admin/ConfigOverview/Section.php +++ b/src/Admin/ConfigOverview/Section.php @@ -14,6 +14,7 @@ class Section */ protected array $rows; + /** * @param string $title Section heading. * @param string $anchor Fragment identifier, used for in-page navigation. @@ -26,16 +27,19 @@ public function __construct( $this->rows = $rows; } + public function getTitle(): string { return $this->title; } + public function getAnchor(): string { return $this->anchor; } + /** * @return \SimpleSAML\Module\oidc\Admin\ConfigOverview\Row[] */ diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index 3056e15e..400d9ac1 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -43,6 +43,7 @@ class VciOverviewBuilder extends AbstractOverviewBuilder CredentialFormatIdentifiersEnum::VcSdJwt->value, ]; + /** * @return \SimpleSAML\Module\oidc\Admin\ConfigOverview\Section[] * @throws \Exception @@ -61,6 +62,7 @@ public function build(): array ]; } + /** * Token Status List settings, being what makes issued credentials revocable. */ @@ -262,6 +264,7 @@ function (): Row { return new Section(Translate::noop('Status Lists'), 'statusLists', ...$rows); } + /** * @return array> */ @@ -290,6 +293,7 @@ protected function describeStatusListPools(StatusListPoolBag $poolBag): array return $described; } + /** * @throws \Exception */ @@ -349,6 +353,7 @@ function (): Row { ); } + /** * @throws \Exception */ @@ -425,6 +430,7 @@ protected function buildEndpointsSection(): Section return new Section(Translate::noop('Endpoints'), 'endpoints', ...$rows); } + protected function buildSignatureKeysSection(): Section { $keyPairBag = null; @@ -455,6 +461,7 @@ protected function buildSignatureKeysSection(): Section ); } + /** * @throws \Exception */ @@ -574,6 +581,7 @@ protected function buildCredentialConfigurationsSection(): Section ); } + /** * @throws \Exception */ @@ -681,6 +689,7 @@ protected function buildNonRegisteredClientsSection(): Section ); } + /** * @throws \Exception */ @@ -760,6 +769,7 @@ function (): Row { ); } + /** * @throws \Exception */ @@ -866,6 +876,7 @@ protected function buildCredentialOfferSection(): Section ); } + /** * Prepare the supported credential configurations for display, pulling together the pieces which * are otherwise spread over several config options. @@ -926,6 +937,7 @@ protected function buildCredentialConfigurationList(array $idsWithJsonLdContext, return $configurations; } + /** * Credential configuration IDs which have a usable JSON-LD context document. * @@ -948,6 +960,7 @@ protected function findIdsWithJsonLdContext(array $jsonLdContexts): array return $ids; } + /** * Configured attribute mappings for one credential configuration. * @@ -961,6 +974,7 @@ protected function findAttributeMappingsFor(array $attributeMap, string $credent return is_array($mappings) ? $mappings : []; } + /** * Normalize a configured redirect URI prefix exactly the way ClientRedirectUriRule does, that * is, with a plain (string) cast. @@ -992,6 +1006,7 @@ protected function normalizeRedirectUriPrefix(mixed $prefix): ?string return null; } + /** * Whether any credential configuration declares a format which cannot be issued. */ @@ -1007,6 +1022,7 @@ protected function hasUnsupportedFormat(array $credentialConfigurations): bool return false; } + /** * Whether any credential configuration carries a mapping whose given flag has the given value. */ @@ -1029,6 +1045,7 @@ protected function hasMappingFlag(array $credentialConfigurations, string $flag, return false; } + /** * Display names from the credential metadata, which may carry one entry per locale. * @@ -1062,6 +1079,7 @@ protected function buildDisplayNames(array $configuration): array return $names; } + /** * Claim paths declared by a credential configuration, rendered in dotted form. * @@ -1083,6 +1101,7 @@ protected function buildClaimPaths(array $validClaimPaths): array return $paths; } + /** * Render a declared claim path, in dotted form. Segments which are not scalar are skipped, since * they cannot be part of a usable path. @@ -1104,6 +1123,7 @@ protected function renderClaimPath(array $path): string return implode('.', $segments); } + /** * Render the claim path a mapping actually writes to. * @@ -1136,6 +1156,7 @@ protected function renderEffectiveClaimPath(array $path, ?string $format): strin return implode('.', $segments); } + /** * Effective user attribute to claim path mappings for one credential configuration. * @@ -1190,6 +1211,7 @@ protected function buildAttributeMappings( return $mappings; } + /** * Whether a mapping will actually be applied during issuance. * @@ -1228,6 +1250,7 @@ protected function findIneffectiveMappingReason(mixed $path, array $validClaimPa return null; } + /** * Present the auth source to email attribute map in the shape the StringMap rendering expects. * @@ -1253,6 +1276,7 @@ protected function buildEmailAttributeMap(array $configuredMap): array return $map; } + /** * Whether the configured map holds an entry which the runtime will ignore. */ diff --git a/src/Admin/Menu.php b/src/Admin/Menu.php index 0ccbb8ae..0ee83d7f 100644 --- a/src/Admin/Menu.php +++ b/src/Admin/Menu.php @@ -9,17 +9,19 @@ class Menu { /** - * @var array + * @var array<\SimpleSAML\Module\oidc\Admin\Menu\Item> */ protected array $items = []; protected ?string $activeHrefPath = null; + public function __construct(Item ...$items) { array_push($this->items, ...$items); } + public function addItem(Item $menuItem, ?int $offset = null): void { $offset ??= count($this->items); @@ -27,21 +29,25 @@ public function addItem(Item $menuItem, ?int $offset = null): void array_splice($this->items, $offset, 0, [$menuItem]); } + public function getItems(): array { return $this->items; } + public function setActiveHrefPath(?string $value): void { $this->activeHrefPath = $value; } + public function getActiveHrefPath(): ?string { return $this->activeHrefPath; } + /** * Item factory method for easy injection in tests. */ diff --git a/src/Admin/Menu/Item.php b/src/Admin/Menu/Item.php index 7ce311b6..efa9b939 100644 --- a/src/Admin/Menu/Item.php +++ b/src/Admin/Menu/Item.php @@ -13,16 +13,19 @@ public function __construct( ) { } + public function getHrefPath(): string { return $this->hrefPath; } + public function getLabel(): string { return $this->label; } + public function getIconAssetPath(): ?string { return $this->iconAssetPath; diff --git a/src/Bridges/OAuth2Bridge.php b/src/Bridges/OAuth2Bridge.php index 0eebec4d..dc9c2d22 100644 --- a/src/Bridges/OAuth2Bridge.php +++ b/src/Bridges/OAuth2Bridge.php @@ -6,6 +6,7 @@ use Defuse\Crypto\Crypto; use Defuse\Crypto\Key; +use Exception; use SimpleSAML\Module\oidc\Exceptions\OidcException; use SimpleSAML\Module\oidc\ModuleConfig; @@ -16,14 +17,15 @@ public function __construct( ) { } + /** * Bridge `encrypt` function, which can be used instead of * \League\OAuth2\Server\CryptTrait::encrypt() * * @param string $unencryptedData - * @param Key|string $encryptionKey + * @param \Defuse\Crypto\Key|string $encryptionKey * @return string - * @throws OidcException + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ public function encrypt( string $unencryptedData, @@ -35,19 +37,20 @@ public function encrypt( return $encryptionKey instanceof Key ? Crypto::encrypt($unencryptedData, $encryptionKey) : Crypto::encryptWithPassword($unencryptedData, $encryptionKey); - } catch (\Exception $e) { + } catch (Exception $e) { throw new OidcException('Error encrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); } } + /** * Bridge `decrypt` function, which can be used instead of * \League\OAuth2\Server\CryptTrait::decrypt() * * @param string $encryptedData - * @param Key|string $encryptionKey + * @param \Defuse\Crypto\Key|string $encryptionKey * @return string - * @throws OidcException + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ public function decrypt( string $encryptedData, @@ -59,7 +62,7 @@ public function decrypt( return $encryptionKey instanceof Key ? Crypto::decrypt($encryptedData, $encryptionKey) : Crypto::decryptWithPassword($encryptedData, $encryptionKey); - } catch (\Exception $e) { + } catch (Exception $e) { throw new OidcException('Error decrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); } } diff --git a/src/Bridges/PsrHttpBridge.php b/src/Bridges/PsrHttpBridge.php index 2cf9293b..6459c289 100644 --- a/src/Bridges/PsrHttpBridge.php +++ b/src/Bridges/PsrHttpBridge.php @@ -13,7 +13,8 @@ class PsrHttpBridge { - private PsrHttpFactory $psrHttpFactory; + private readonly PsrHttpFactory $psrHttpFactory; + public function __construct( private readonly HttpFoundationFactory $httpFoundationFactory, @@ -30,31 +31,37 @@ public function __construct( ); } + public function getHttpFoundationFactory(): HttpFoundationFactory { return $this->httpFoundationFactory; } + public function getServerRequestFactory(): ServerRequestFactoryInterface { return $this->serverRequestFactory; } + public function getResponseFactory(): ResponseFactoryInterface { return $this->responseFactory; } + public function getStreamFactory(): StreamFactoryInterface { return $this->streamFactory; } + public function getUploadedFileFactory(): UploadedFileFactoryInterface { return $this->uploadedFileFactory; } + public function getPsrHttpFactory(): PsrHttpFactory { return $this->psrHttpFactory; diff --git a/src/Bridges/SspBridge.php b/src/Bridges/SspBridge.php index 1ff9e3cb..507456bf 100644 --- a/src/Bridges/SspBridge.php +++ b/src/Bridges/SspBridge.php @@ -16,25 +16,32 @@ class SspBridge { protected static ?Auth $auth = null; + protected static ?Utils $utils = null; + protected static ?Module $module = null; + protected static ?Locale $locale = null; + public function utils(): Utils { return self::$utils ??= new Utils(); } + public function module(): Module { return self::$module ??= new Module(); } + public function auth(): Auth { return self::$auth ??= new Auth(); } + public function locale(): Locale { return self::$locale ??= new Locale(); diff --git a/src/Bridges/SspBridge/Auth.php b/src/Bridges/SspBridge/Auth.php index d3be17d2..aa068882 100644 --- a/src/Bridges/SspBridge/Auth.php +++ b/src/Bridges/SspBridge/Auth.php @@ -10,6 +10,7 @@ class Auth { protected static ?Source $source = null; + public function source(): Source { return self::$source ??= new Source(); diff --git a/src/Bridges/SspBridge/Locale.php b/src/Bridges/SspBridge/Locale.php index 808a564b..167b9dbb 100644 --- a/src/Bridges/SspBridge/Locale.php +++ b/src/Bridges/SspBridge/Locale.php @@ -10,6 +10,7 @@ class Locale { protected static ?Language $language = null; + public function language(): Language { return self::$language ??= new Language(); diff --git a/src/Bridges/SspBridge/Locale/Language.php b/src/Bridges/SspBridge/Locale/Language.php index 4bb5389e..ef26a6d4 100644 --- a/src/Bridges/SspBridge/Locale/Language.php +++ b/src/Bridges/SspBridge/Locale/Language.php @@ -14,11 +14,13 @@ public function setLanguageCookie(string $language): void SspLanguage::setLanguageCookie($language); } + public function getLanguageCookie(): ?string { return SspLanguage::getLanguageCookie(); } + /** * Get the languages available in SimpleSAMLphp (configured in language.available and known to the * translation system), as computed by SimpleSAMLphp itself. The Language instance is created without diff --git a/src/Bridges/SspBridge/Module.php b/src/Bridges/SspBridge/Module.php index 198af4ec..c9a489c2 100644 --- a/src/Bridges/SspBridge/Module.php +++ b/src/Bridges/SspBridge/Module.php @@ -12,16 +12,19 @@ class Module { protected static ?SspModule\oidc\Bridges\SspBridge\Module\Admin $admin = null; + public function admin(): Admin { return self::$admin ??= new Admin(); } + public function getModuleUrl(string $resource, array $parameters = []): string { return SspModule::getModuleURL($resource, $parameters); } + /** * @throws \Exception */ @@ -30,6 +33,7 @@ public function isModuleEnabled(string $moduleName): bool return SspModule::isModuleEnabled($moduleName); } + /** * Configuration of a module, read from its file in the SimpleSAMLphp configuration directory. * An empty configuration is returned when the file does not exist. diff --git a/src/Bridges/SspBridge/Utils.php b/src/Bridges/SspBridge/Utils.php index f201faa6..ebbc9dba 100644 --- a/src/Bridges/SspBridge/Utils.php +++ b/src/Bridges/SspBridge/Utils.php @@ -13,31 +13,40 @@ class Utils { protected static ?Config $config = null; + protected static ?HTTP $http = null; + protected static ?Random $random = null; + protected static ?Auth $auth = null; + protected static ?Attributes $attributes = null; + public function config(): Config { return self::$config ??= new Config(); } + public function http(): HTTP { return self::$http ??= new HTTP(); } + public function random(): Random { return self::$random ??= new Random(); } + public function auth(): Auth { return self::$auth ??= new Auth(); } + public function attributes(): Attributes { return self::$attributes ??= new Attributes(); diff --git a/src/Codebooks/FlowTypeEnum.php b/src/Codebooks/FlowTypeEnum.php index 9362a15d..78988944 100644 --- a/src/Codebooks/FlowTypeEnum.php +++ b/src/Codebooks/FlowTypeEnum.php @@ -14,6 +14,7 @@ enum FlowTypeEnum: string case VciAuthorizationCode = 'vci_authorization_code'; case VciPreAuthorizedCode = 'vci_pre_authorized_code'; + public function isOidcFlow(): bool { return match ($this) { @@ -22,6 +23,7 @@ public function isOidcFlow(): bool }; } + public function isVciFlow(): bool { return match ($this) { diff --git a/src/Codebooks/RegistrationTypeEnum.php b/src/Codebooks/RegistrationTypeEnum.php index ad3e5c65..e5ff3063 100644 --- a/src/Codebooks/RegistrationTypeEnum.php +++ b/src/Codebooks/RegistrationTypeEnum.php @@ -12,6 +12,7 @@ enum RegistrationTypeEnum: string case FederatedAutomatic = 'federated_automatic'; case Dynamic = 'dynamic'; + public function description(): string { return match ($this) { diff --git a/src/Codebooks/StatusListExpiryLaneEnum.php b/src/Codebooks/StatusListExpiryLaneEnum.php index 986c26bb..4ace6117 100644 --- a/src/Codebooks/StatusListExpiryLaneEnum.php +++ b/src/Codebooks/StatusListExpiryLaneEnum.php @@ -38,6 +38,7 @@ enum StatusListExpiryLaneEnum: string /** The list holds credentials which never expire, so it has to be served indefinitely. */ case NonExpiring = 'non_expiring'; + /** * The lane a credential belongs in, decided by the expiry it is being issued with. * diff --git a/src/Controllers/AccessTokenController.php b/src/Controllers/AccessTokenController.php index c2b44e8f..81266dd3 100644 --- a/src/Controllers/AccessTokenController.php +++ b/src/Controllers/AccessTokenController.php @@ -19,6 +19,7 @@ class AccessTokenController { use RequestTrait; + public function __construct( private readonly AuthorizationServer $authorizationServer, private readonly AllowedOriginRepository $allowedOriginRepository, @@ -27,6 +28,7 @@ public function __construct( ) { } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException */ @@ -43,6 +45,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface ); } + public function token(Request $request): Response { try { diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index a5bddcab..e1423ae4 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; +use DateTimeImmutable; use Nette\Forms\Form; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Admin\Authorization; @@ -47,6 +48,7 @@ public function __construct( $this->authorization->requireAdminOrUserWithPermission(AuthContextService::PERM_CLIENT); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -63,6 +65,7 @@ protected function getClientFromRequest(Request $request): ClientEntityInterface throw new OidcException('Client not found.'); } + public function index(Request $request): Response { $page = $request->query->getInt('page', 1); @@ -83,6 +86,7 @@ public function index(Request $request): Response ); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ @@ -101,6 +105,7 @@ public function show(Request $request): Response ); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ @@ -128,6 +133,7 @@ public function resetSecret(Request $request): Response ); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ @@ -154,6 +160,7 @@ public function delete(Request $request): Response ); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Error\Exception @@ -224,6 +231,7 @@ public function add(): Response ); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -303,6 +311,7 @@ public function edit(Request $request): Response ); } + /** * TODO v8 mivanci Move to ClientEntityFactory::fromRegistrationData on dynamic client registration implementation. * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException @@ -312,9 +321,9 @@ protected function buildClientEntityFromFormData( string $identifier, string $secret, RegistrationTypeEnum $registrationType, - \DateTimeImmutable $updatedAt, - ?\DateTimeImmutable $createdAt = null, - ?\DateTimeImmutable $expiresAt = null, + DateTimeImmutable $updatedAt, + ?DateTimeImmutable $createdAt = null, + ?DateTimeImmutable $expiresAt = null, ?string $owner = null, bool $isGeneric = false, ): ClientEntityInterface { diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index d10f475d..e916fb21 100644 --- a/src/Controllers/Admin/ConfigController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; +use Exception; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Admin\ConfigOverview\FederationOverviewBuilder; @@ -17,8 +18,9 @@ use SimpleSAML\Module\oidc\Services\DatabaseMigration; use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\TrustMark; use Symfony\Component\HttpFoundation\Response; +use Throwable; class ConfigController { @@ -42,6 +44,7 @@ public function __construct( $this->authorization->requireAdmin(true); } + public function migrations(): Response { return $this->templateFactory->build( @@ -53,6 +56,7 @@ public function migrations(): Response ); } + public function runMigrations(): Response { if ($this->databaseMigration->isMigrated()) { @@ -68,6 +72,7 @@ public function runMigrations(): Response return $this->routes->newRedirectResponseToModuleUrl(RoutesEnum::AdminMigrations->value); } + public function generalSettings(): Response { return $this->templateFactory->build( @@ -80,6 +85,7 @@ public function generalSettings(): Response ); } + /** * @throws \Exception */ @@ -95,13 +101,14 @@ public function protocolSettings(): Response ); } + public function federationSettings(): Response { $trustMarks = []; try { $federation = $this->federationFactory->build(); - } catch (\Throwable) { + } catch (Throwable) { // This screen still has plenty to report without a Federation, and the configuration that // prevented one from being built is itself among what it reports: the option at fault gets a // warning on its own row below, from a builder that logs the detail rather than rendering it. @@ -128,9 +135,7 @@ public function federationSettings(): Response if (is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens())) { $trustMarks = array_map( - function (string $token) use ($federation): Federation\TrustMark { - return $federation->trustMarkFactory()->fromToken($token); - }, + fn(string $token): TrustMark => $federation->trustMarkFactory()->fromToken($token), $trustMarkTokens, ); } @@ -150,7 +155,7 @@ function (string $token) use ($federation): Federation\TrustMark { $this->moduleConfig->getIssuer(), $trustMarkIssuerConfigurationStatement, ); - } catch (\Exception $e) { + } catch (Exception $e) { // Added as two messages rather than one concatenated string. The template // translates each message whole, so a sentence with identifiers and an exception // spliced into it can never match its catalog entry -- it would be marked for @@ -177,6 +182,7 @@ function (string $token) use ($federation): Federation\TrustMark { ); } + /** * @throws \Exception */ diff --git a/src/Controllers/Admin/CredentialStatusController.php b/src/Controllers/Admin/CredentialStatusController.php index f436b01a..1779b0f5 100644 --- a/src/Controllers/Admin/CredentialStatusController.php +++ b/src/Controllers/Admin/CredentialStatusController.php @@ -66,6 +66,7 @@ class CredentialStatusController /** Width of the column an actor reference is stored in. */ protected const int ACTOR_REF_MAX_LENGTH = 191; + /** * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ @@ -87,6 +88,7 @@ public function __construct( $this->authorization->requireAdmin(true); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Error\Exception @@ -133,6 +135,7 @@ public function index(Request $request): Response ); } + /** * @throws \SimpleSAML\Error\Exception */ @@ -175,6 +178,7 @@ public function change(Request $request): Response return $this->redirectToListing($request, $this->applyStatus($credentialId, $status)); } + /** * @return string What to tell the administrator, which is the only answer this surface gives: the * listing it returns to shows the outcome regardless. @@ -229,6 +233,7 @@ protected function applyStatus(string $credentialId, StatusTypeEnum $status): st Translate::noop('The credential already had that status, so nothing was changed.'); } + /** * Who to record as having asked for a change. * @@ -265,6 +270,7 @@ protected function resolveActorRef(): string return self::ACTOR_REF_ADMIN; } + /** * Which statuses each listed credential can actually be moved to, keyed by the list it sits in. * @@ -306,6 +312,7 @@ protected function resolveAllowedStatuses(array $entries): array return $allowed; } + /** * Back to the listing the change was made from, on the page and search it was made from. */ diff --git a/src/Controllers/Admin/FederationTestController.php b/src/Controllers/Admin/FederationTestController.php index 783020fa..8d9dce98 100644 --- a/src/Controllers/Admin/FederationTestController.php +++ b/src/Controllers/Admin/FederationTestController.php @@ -17,11 +17,13 @@ use SimpleSAML\OpenID\Federation; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Throwable; class FederationTestController { protected readonly Federation $federationWithArrayLogger; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly TemplateFactory $templateFactory, @@ -53,6 +55,7 @@ public function __construct( ); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -67,7 +70,7 @@ public function trustChainResolution(Request $request): Response try { $trustAnchorIds = $this->moduleConfig->getFederationTrustAnchorIds(); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->arrayLogger->error('Module config error: ' . $exception->getMessage()); $trustAnchorIds = []; } @@ -93,7 +96,7 @@ public function trustChainResolution(Request $request): Response try { $metadataEntries[$entityTypeEnum->value] = $trustChain->getResolvedMetadata($entityTypeEnum); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->arrayLogger->error( 'Metadata resolving error: ' . $exception->getMessage(), compact('index', 'entityTypeEnum'), @@ -125,6 +128,7 @@ public function trustChainResolution(Request $request): Response ); } + public function trustMarkValidation(Request $request): Response { $trustMarkType = null; @@ -155,7 +159,7 @@ public function trustMarkValidation(Request $request): Response $trustChain->getResolvedLeaf(), $trustChain->getResolvedTrustAnchor(), ); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->arrayLogger->error('Trust Mark validation error: ' . $exception->getMessage()); } } catch (TrustChainException $exception) { @@ -270,7 +274,7 @@ public function federationDiscovery(Request $request): Response 'payload' => $payload, ]; } - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->arrayLogger->error(sprintf( 'Error during entity discovery under Trust Anchor %s. Error was %s', $trustAnchorId, @@ -283,7 +287,7 @@ public function federationDiscovery(Request $request): Response try { $trustAnchorIds = $this->moduleConfig->getFederationTrustAnchorIds(); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->arrayLogger->error('Module config error: ' . $exception->getMessage()); $trustAnchorIds = []; } diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 9b4ebc27..b6e15685 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -37,6 +37,7 @@ public function __construct( $this->authorization->requireAdmin(true); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index a7b8ac83..3def35af 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -19,7 +19,7 @@ class VciCredentialOfferApiController { /** - * @throws OidcServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function __construct( protected readonly ModuleConfig $moduleConfig, @@ -39,8 +39,9 @@ public function __construct( } } + /** - * @throws OidcServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function credentialOffer(Request $request): Response { diff --git a/src/Controllers/Api/VciCredentialStatusApiController.php b/src/Controllers/Api/VciCredentialStatusApiController.php index 1a78e35e..b4c60666 100644 --- a/src/Controllers/Api/VciCredentialStatusApiController.php +++ b/src/Controllers/Api/VciCredentialStatusApiController.php @@ -53,6 +53,7 @@ class VciCredentialStatusApiController final public const string HEADER_WWW_AUTHENTICATE = 'WWW-Authenticate'; + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -69,6 +70,7 @@ public function __construct( } } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -225,6 +227,7 @@ public function credentialStatus(Request $request): Response ]); } + /** * The status names this endpoint accepts, which are the Status Type names in lower case. * @@ -242,6 +245,7 @@ protected function resolveStatus(string $status): ?StatusTypeEnum return null; } + /** * @return string[] */ diff --git a/src/Controllers/AuthorizationController.php b/src/Controllers/AuthorizationController.php index 6b981b7f..a6bb72fb 100644 --- a/src/Controllers/AuthorizationController.php +++ b/src/Controllers/AuthorizationController.php @@ -36,6 +36,7 @@ public function __construct( ) { } + /** * @throws \Exception * @throws \SimpleSAML\Error\AuthSource @@ -91,10 +92,11 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface ); } + /** - * @param Request $request + * @param \Symfony\Component\HttpFoundation\Request $request * - * @return Response + * @return \Symfony\Component\HttpFoundation\Response * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest * @throws \SimpleSAML\Error\Error @@ -124,6 +126,7 @@ public function authorization(Request $request): Response } } + /** * Set the UI language for the current user agent based on the ui_locales authorization request parameter, * if any of the requested languages are available in SimpleSAMLphp. This is done using the standard @@ -164,6 +167,7 @@ protected function setUiLanguage(OAuth2AuthorizationRequestInterface $authorizat $this->sspBridge->locale()->language()->setLanguageCookie($language); } + /** * Validate authorization request after the authn has been performed. For example, check if the * ACR claim has been requested and that authn performed satisfies it. @@ -175,6 +179,7 @@ protected function validatePostAuthnAuthorizationRequest(AuthorizationRequest $a $this->validateAcr($authorizationRequest); } + /** * Validate the `id_token_hint` authorization request parameter (if any) against the authenticated End-User. * @@ -223,6 +228,7 @@ protected function validateIdTokenHint(AuthorizationRequest $authorizationReques ); } + /** * Resolve the redirect URI to use for redirected error responses: the one validated for this request, or the * client's first registered redirect URI as a fallback. @@ -239,6 +245,7 @@ protected function resolveRedirectUri(AuthorizationRequest $authorizationRequest return is_array($clientRedirectUri) ? ($clientRedirectUri[0] ?? null) : $clientRedirectUri; } + /** * @throws \Exception */ diff --git a/src/Controllers/ConfigurationDiscoveryController.php b/src/Controllers/ConfigurationDiscoveryController.php index 54eb5e6f..99534720 100644 --- a/src/Controllers/ConfigurationDiscoveryController.php +++ b/src/Controllers/ConfigurationDiscoveryController.php @@ -13,6 +13,7 @@ public function __construct(private readonly OpMetadataService $opMetadataServic { } + public function __invoke(): JsonResponse { return new JsonResponse( diff --git a/src/Controllers/EndSessionController.php b/src/Controllers/EndSessionController.php index 24f50f9a..0fc52026 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -37,6 +37,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\BadRequest * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -141,6 +142,7 @@ public function __invoke(ServerRequestInterface $request): Response return $this->resolveResponse($logoutRequest, $wasLogoutActionCalled, $uiLanguage); } + /** * Resolve the UI language to use for the logout page based on the ui_locales logout request parameter, if * any of the requested languages are available in SimpleSAMLphp. The resolved language is applied only when @@ -167,6 +169,7 @@ protected function resolveUiLanguage(LogoutRequest $logoutRequest): ?string return $language; } + public function endSession(Request $request): Response { try { @@ -180,6 +183,7 @@ public function endSession(Request $request): Response } } + /** * Logout handler function registered using Session::registerLogoutHandler() during authn. * @throws \Exception @@ -238,6 +242,7 @@ public static function logoutHandler(): void (new BackChannelLogoutHandler())->handle($relyingPartyAssociations); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index 1b840d7b..adedba19 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -19,12 +19,15 @@ use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; use Symfony\Component\HttpFoundation\Response; +use Throwable; class EntityStatementController { protected const string KEY_OP_ENTITY_CONFIGURATION_STATEMENT = 'op_entity_configuration_statement'; + protected const string KEY_RP_SUBORDINATE_ENTITY_STATEMENT = 'rp_subordinate_entity_statement'; + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -43,6 +46,7 @@ public function __construct( } } + /** * Return the JWS with the OP configuration statement. * @@ -171,7 +175,7 @@ public function configuration(): Response ClaimsEnum::TrustMarkType->value => $trustMarkType, ClaimsEnum::TrustMark->value => $trustMarkEntity->getToken(), ]; - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->loggerService->error( 'Error fetching Trust Mark: ' . $exception->getMessage(), [ @@ -218,6 +222,7 @@ public function configuration(): Response return $this->prepareEntityStatementResponse($entityConfigurationToken); } + protected function prepareEntityStatementResponse(string $entityStatementToken): Response { return $this->routes->newResponse( diff --git a/src/Controllers/JwksController.php b/src/Controllers/JwksController.php index 499b8d22..492e31e1 100644 --- a/src/Controllers/JwksController.php +++ b/src/Controllers/JwksController.php @@ -21,6 +21,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -48,6 +49,7 @@ public function __invoke(): JsonResponse ); } + /** * Whether any configured Status List pool expects its tokens to be verified through this key set. * @@ -82,6 +84,7 @@ protected function isAnyStatusListKeyPublished(): bool return false; } + public function jwks(): Response { $response = $this->__invoke(); diff --git a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php index 46b385d0..3e29b92f 100644 --- a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php +++ b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php @@ -21,6 +21,7 @@ public function __construct( ) { } + public function __invoke(): JsonResponse { // We'll reuse OIDC configuration. diff --git a/src/Controllers/OAuth2/TokenIntrospectionController.php b/src/Controllers/OAuth2/TokenIntrospectionController.php index d8ea5e0d..26333a34 100644 --- a/src/Controllers/OAuth2/TokenIntrospectionController.php +++ b/src/Controllers/OAuth2/TokenIntrospectionController.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Controllers\OAuth2; +use Exception; use SimpleSAML\Module\oidc\Bridges\OAuth2Bridge; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; @@ -23,11 +24,12 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Throwable; class TokenIntrospectionController { /** - * @throws OidcServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function __construct( protected readonly ModuleConfig $moduleConfig, @@ -51,6 +53,7 @@ public function __construct( } } + public function __invoke(Request $request): Response { try { @@ -98,11 +101,12 @@ public function __invoke(Request $request): Response $payload = $this->resolveRefreshTokenPayload($tokenParam, $introspectionAuthorization); } - $payload = $payload ?? ['active' => false]; + $payload ??= ['active' => false]; return $this->routes->newJsonResponse($payload); } + /** * Whether the caller is to be told about a token issued to the given client, logging any refusal. * @@ -135,13 +139,14 @@ protected function isTokenIntrospectableBy( return false; } + protected function resolveAccessTokenPayload( string $tokenParam, IntrospectionAuthorization $introspectionAuthorization, ): ?array { try { $accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->loggerService->error('Access token validation failed: ' . $e->getMessage()); return null; } @@ -177,6 +182,7 @@ protected function resolveAccessTokenPayload( ]); } + /** * @psalm-suppress MixedAssignment */ @@ -187,7 +193,7 @@ protected function resolveRefreshTokenPayload( try { $decryptedToken = $this->oAuth2Bridge->decrypt($tokenParam); $tokenData = json_decode($decryptedToken, true, 512, JSON_THROW_ON_ERROR); - } catch (\Exception $e) { + } catch (Exception $e) { $this->loggerService->error('Refresh token decrypting failed: ' . $e->getMessage()); return null; } @@ -252,6 +258,7 @@ protected function resolveRefreshTokenPayload( ]); } + protected function prepareScopeString(array $scopes): string { $scopes = array_filter( @@ -262,6 +269,7 @@ protected function prepareScopeString(array $scopes): string return implode(' ', $scopes); } + /** * Establish who is asking, and with it which tokens they are entitled to be told about. * @@ -270,7 +278,7 @@ protected function prepareScopeString(array $scopes): string * registered itself through Dynamic Client Registration - read the subject, scopes and lifetime of * tokens belonging to every other client of this OP. * - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException * @throws \Exception */ protected function resolveIntrospectionAuthorization(Request $request): IntrospectionAuthorization diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php index 70f8a32c..26656887 100644 --- a/src/Controllers/PushedAuthorizationController.php +++ b/src/Controllers/PushedAuthorizationController.php @@ -33,6 +33,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Throwable; class PushedAuthorizationController { @@ -48,6 +49,7 @@ public function __construct( ) { } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \Throwable @@ -142,6 +144,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface return $response; } + /** * Resolve the authorization request parameters which are to be persisted * for later use at the authorization endpoint. @@ -203,6 +206,7 @@ protected function resolveParametersToPersist( return $parameters; } + public function par(Request $request): Response { try { @@ -213,7 +217,7 @@ public function par(Request $request): Response // Per RFC 9126, the error response format is the one specified for the token endpoint, so make // sure we never redirect (regardless of any redirect URI contained in the exception). return $this->errorResponder->forExceptionJson($exception); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->logger->error( 'PushedAuthorizationController: error processing request: ' . $exception->getMessage(), ); diff --git a/src/Controllers/RegistrationController.php b/src/Controllers/RegistrationController.php index 902c7c2b..9adb459b 100644 --- a/src/Controllers/RegistrationController.php +++ b/src/Controllers/RegistrationController.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Controllers; +use JsonException; use League\OAuth2\Server\Exception\OAuthServerException; use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; @@ -22,6 +23,7 @@ use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Throwable; /** * OpenID Connect Dynamic Client Registration 1.0 endpoint. @@ -36,6 +38,7 @@ class RegistrationController { private const string HASH_ALGORITHM = 'sha256'; + public function __construct( private readonly ModuleConfig $moduleConfig, private readonly ClientMetadataValidator $clientMetadataValidator, @@ -48,6 +51,7 @@ public function __construct( ) { } + /** * Entry point wired in routes.php. Dispatches POST (create) at the * registration endpoint, and GET (read) / PUT (update) / DELETE (delete) at @@ -82,7 +86,7 @@ public function registration(Request $request): Response 'RegistrationController: error processing registration request: ' . $exception->getMessage(), ); return $this->errorResponder->forExceptionJson($exception); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->logger->error( 'RegistrationController: error processing registration request: ' . $exception->getMessage(), ); @@ -93,6 +97,7 @@ public function registration(Request $request): Response } } + /** * Handle a Client Registration Request (Section 3.1). * @@ -123,6 +128,7 @@ protected function register(Request $request): Response ); } + /** * Handle a Client Read Request (Section 4.2) at the Client Configuration * Endpoint. @@ -146,6 +152,7 @@ protected function read(Request $request): Response ); } + /** * Handle a Client Update Request (RFC 7592, Section 2.2) at the Client * Configuration Endpoint. The request fully replaces the client's metadata. @@ -196,6 +203,7 @@ protected function update(Request $request): Response ); } + /** * Handle a Client Delete Request (RFC 7592, Section 2.3) at the Client * Configuration Endpoint. @@ -211,6 +219,7 @@ protected function delete(Request $request): Response return $this->routes->newResponse('', Response::HTTP_NO_CONTENT); } + /** * Authenticate a Client Configuration Endpoint request (read / update / * delete) using the client_id query parameter and the Registration Access @@ -245,6 +254,7 @@ protected function authenticateConfigurationRequest(Request $request): ClientEnt return $client; } + /** * Enforce the configured access-control mode for the registration endpoint. * @@ -272,6 +282,7 @@ protected function guardAccess(Request $request): void throw OidcServerException::accessDenied('The provided Initial Access Token is not valid.'); } + /** * Parse and JSON-decode the request body into a metadata array. * @@ -295,7 +306,7 @@ protected function parseMetadata(Request $request): array try { /** @var mixed $decoded */ $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException) { + } catch (JsonException) { throw OidcServerException::invalidClientMetadata('The request body must be a valid JSON object.'); } @@ -306,6 +317,7 @@ protected function parseMetadata(Request $request): array return $decoded; } + /** * Build the Client Information Response (Section 3.2 / 4.3) from the * persisted client. @@ -369,6 +381,7 @@ protected function buildClientInformationResponse( return $response; } + /** * Mint a fresh Registration Access Token, store only its hash on the client, and return the plaintext (returned * once in the Client Information Response). Used at registration and rotated on each read/update. @@ -381,11 +394,13 @@ protected function issueRegistrationAccessToken(ClientEntityInterface $client): return $registrationAccessToken; } + protected function hashToken(string $token): string { return hash(self::HASH_ALGORITHM, $token); } + protected function jsonResponse(array $body, int $status): Response { return $this->routes->newJsonResponse( diff --git a/src/Controllers/StatusListController.php b/src/Controllers/StatusListController.php index 8d988de2..c54286cf 100644 --- a/src/Controllers/StatusListController.php +++ b/src/Controllers/StatusListController.php @@ -47,6 +47,7 @@ class StatusListController /** Seconds a client is asked to wait after a request this endpoint could not answer. */ protected const int RETRY_AFTER_SECONDS = 30; + public function __construct( protected readonly StatusListTokenProviderInterface $statusListTokenProvider, protected readonly HttpContentNegotiator $httpContentNegotiator, @@ -57,6 +58,7 @@ public function __construct( ) { } + /** * @param string $statusListId URL path parameter injected by the router. * @throws \SimpleSAML\Error\ConfigurationError @@ -107,6 +109,7 @@ public function statusList(Request $request, string $statusListId): Response return $this->respondWith($request, $result); } + /** * @throws \Exception */ @@ -171,6 +174,7 @@ protected function respondWith(Request $request, StatusListTokenResult $result): return $this->routes->newResponse($body, Response::HTTP_OK, $headers); } + /** * Whether the copy the client already holds is the one which would be served. * @@ -204,6 +208,7 @@ protected function isCurrent(?string $ifNoneMatch, string $entityTag): bool return false; } + /** * Headers every response from here carries. * diff --git a/src/Controllers/UserInfoController.php b/src/Controllers/UserInfoController.php index ab0047c3..d74c6cb6 100644 --- a/src/Controllers/UserInfoController.php +++ b/src/Controllers/UserInfoController.php @@ -6,7 +6,7 @@ use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Error; +use SimpleSAML\Error\UserNotFound; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; @@ -25,6 +25,7 @@ class UserInfoController { use RequestTrait; + public function __construct( private readonly ResourceServer $resourceServer, private readonly AccessTokenRepository $accessTokenRepository, @@ -37,6 +38,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\UserNotFound * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -58,7 +60,7 @@ public function __invoke(ServerRequestInterface $request): Response $accessToken = $this->accessTokenRepository->findById($tokenId); if (!$accessToken instanceof AccessTokenEntity) { - throw new Error\UserNotFound('Access token not found'); + throw new UserNotFound('Access token not found'); } $user = $this->getUser($accessToken); @@ -73,6 +75,7 @@ public function __invoke(ServerRequestInterface $request): Response return $this->routes->newJsonResponse($claims); } + public function userInfo(Request $request): Response { try { @@ -89,6 +92,7 @@ public function userInfo(Request $request): Response } } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \SimpleSAML\Error\UserNotFound @@ -98,7 +102,7 @@ private function getUser(AccessTokenEntity $accessToken): UserEntity $userIdentifier = (string) $accessToken->getUserIdentifier(); $user = $this->userRepository->getUserEntityByIdentifier($userIdentifier); if (!$user instanceof UserEntity) { - throw new Error\UserNotFound("User $userIdentifier not found"); + throw new UserNotFound("User $userIdentifier not found"); } return $user; diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 2e86e7ae..e90a35f7 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -39,6 +39,7 @@ public function __construct( } } + public function configuration(): Response { // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index c136eee1..def4edce 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -6,6 +6,8 @@ use DateInterval; use DateTimeImmutable; +use DateTimeInterface; +use Exception; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; @@ -54,6 +56,7 @@ class CredentialIssuerCredentialController */ protected const int CREDENTIAL_ID_RANDOM_BYTES = 32; + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -80,12 +83,13 @@ public function __construct( } } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \ReflectionException - * @throws OpenIdException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ public function credential(Request $request): Response { @@ -534,7 +538,7 @@ public function credential(Request $request): Response ['kid' => $proof->getKeyId(), 'jwk' => $proof->getJsonWebKey()], ); } - } catch (\Exception $e) { + } catch (Exception $e) { $message = 'Error processing proof JWT: ' . $e->getMessage(); $this->loggerService->error($message); return $this->routes->newJsonErrorResponse( @@ -782,7 +786,7 @@ public function credential(Request $request): Response ], //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuer->value => $issuerDid, - ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Issuance_Date->value => $issuedAt->format(DateTimeInterface::RFC3339), ClaimsEnum::Id->value => $vcId, ClaimsEnum::Credential_Subject->value => $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], @@ -792,7 +796,7 @@ public function credential(Request $request): Response // issuance date, which this format already states as both `iat` and `issuanceDate`. if ($expiresAt instanceof DateTimeImmutable) { $verifiableCredentialBody[ClaimsEnum::Expiration_Date->value] = - $expiresAt->format(\DateTimeInterface::RFC3339); + $expiresAt->format(DateTimeInterface::RFC3339); } $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( @@ -864,7 +868,7 @@ public function credential(Request $request): Response $resolvedCredentialIdentifier, ], ClaimsEnum::Issuer->value => $issuerDid, - ClaimsEnum::ValidFrom->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::ValidFrom->value => $issuedAt->format(DateTimeInterface::RFC3339), ClaimsEnum::Credential_Subject->value => $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], ClaimsEnum::Iss->value => $issuerDid, @@ -879,7 +883,7 @@ public function credential(Request $request): Response // The Verifiable Credentials Data Model 2.0 names the end of a credential's validity // `validUntil`, alongside the `validFrom` above, so this format states it both ways. if ($expiresAt instanceof DateTimeImmutable) { - $sdJwtPayload[ClaimsEnum::ValidUntil->value] = $expiresAt->format(\DateTimeInterface::RFC3339); + $sdJwtPayload[ClaimsEnum::ValidUntil->value] = $expiresAt->format(DateTimeInterface::RFC3339); } if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { @@ -927,6 +931,7 @@ public function credential(Request $request): Response ); } + /** * Helper method to set a claim value at a path. Supports creating nested arrays dynamically. * @psalm-suppress UnusedVariable, MixedAssignment diff --git a/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php b/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php index 062a98c5..75814a23 100644 --- a/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php +++ b/src/Controllers/VerifiableCredentials/CredentialJsonLdContextController.php @@ -35,6 +35,7 @@ public function __construct( } } + /** * Return the JSON-LD context document for the given credential configuration ID. * diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php index 20be034a..6e1ba8e0 100644 --- a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -37,6 +37,7 @@ public function __construct( } } + public function configuration(): Response { $configuration = [ diff --git a/src/Controllers/VerifiableCredentials/NonceController.php b/src/Controllers/VerifiableCredentials/NonceController.php index 1f33362e..090101d4 100644 --- a/src/Controllers/VerifiableCredentials/NonceController.php +++ b/src/Controllers/VerifiableCredentials/NonceController.php @@ -25,6 +25,7 @@ public function __construct( } } + /** * @throws \Exception */ diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index 0c7d5251..e72e8f33 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; +use InvalidArgumentException; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; @@ -31,6 +32,7 @@ class AccessTokenEntity implements AccessTokenEntityInterface, EntityStringRepre use RevokeTokenTrait; use AssociateWithAuthCodeTrait; + /** * String representation of access token issued to the client. * @var string|null $stringRepresentation @@ -43,6 +45,7 @@ class AccessTokenEntity implements AccessTokenEntityInterface, EntityStringRepre */ protected array $requestedClaims; + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ @@ -64,7 +67,7 @@ public function __construct( protected readonly ?string $issuerState = null, ) { if ($id === '') { - throw new \InvalidArgumentException('Access token identifier cannot be empty.'); + throw new InvalidArgumentException('Access token identifier cannot be empty.'); } $this->setIdentifier($id); @@ -86,6 +89,7 @@ public function __construct( } } + /** * @return array */ @@ -94,11 +98,13 @@ public function getRequestedClaims(): array return $this->requestedClaims; } + public function setRequestedClaims(array $requestedClaims): void { $this->requestedClaims = $requestedClaims; } + /** * {@inheritdoc} * @throws \JsonException @@ -124,6 +130,7 @@ public function getState(): array ]; } + /** * Generate string representation, save it in a field, and return it. * @return string @@ -134,6 +141,7 @@ public function __toString(): string return $this->toString(); } + /** * Get string representation of access token at the moment of casting it to string. * @return string String representation of the access token. @@ -143,6 +151,7 @@ public function toString(): string return $this->stringRepresentation ??= $this->convertToJWT()->getToken(); } + /** * Implemented instead of original AccessTokenTrait::convertToJWT() method * in order to remove microseconds from timestamps and to add claims @@ -180,26 +189,31 @@ protected function convertToJWT(): ParsedJws ); } + public function getFlowTypeEnum(): ?FlowTypeEnum { return $this->flowTypeEnum; } + public function getAuthorizationDetails(): ?array { return $this->authorizationDetails; } + public function getBoundClientId(): ?string { return $this->boundClientId; } + public function getBoundRedirectUri(): ?string { return $this->boundRedirectUri; } + public function getIssuerState(): ?string { return $this->issuerState; diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index a3a0048e..62fb1e6d 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; +use InvalidArgumentException; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; @@ -21,6 +22,7 @@ class AuthCodeEntity implements AuthCodeEntityInterface, MementoInterface use OidcAuthCodeTrait; use RevokeTokenTrait; + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ @@ -41,7 +43,7 @@ public function __construct( protected readonly ?string $issuerState = null, ) { if ($id === '') { - throw new \InvalidArgumentException('Authorization code identifier cannot be empty.'); + throw new InvalidArgumentException('Authorization code identifier cannot be empty.'); } $this->identifier = $id; @@ -54,6 +56,7 @@ public function __construct( $this->isRevoked = $isRevoked; } + /** * @throws \JsonException */ @@ -79,36 +82,43 @@ public function getState(): array ]; } + public function isVciPreAuthorized(): bool { return $this->flowTypeEnum === FlowTypeEnum::VciPreAuthorizedCode; } + public function getTxCode(): ?string { return $this->txCode; } + public function getFlowTypeEnum(): ?FlowTypeEnum { return $this->flowTypeEnum; } + public function getAuthorizationDetails(): ?array { return $this->authorizationDetails; } + public function getBoundClientId(): ?string { return $this->boundClientId; } + public function getBoundRedirectUri(): ?string { return $this->boundRedirectUri; } + public function getIssuerState(): ?string { return $this->issuerState; diff --git a/src/Entities/ClaimSetEntity.php b/src/Entities/ClaimSetEntity.php index a384db6e..9aaaad69 100644 --- a/src/Entities/ClaimSetEntity.php +++ b/src/Entities/ClaimSetEntity.php @@ -10,7 +10,7 @@ * This file contains modified code from the 'steverhoades/oauth2-openid-connect-server' library * (https://github.com/steverhoades/oauth2-openid-connect-server), with original author, copyright notice and licence: * @author Steve Rhoades - * @copyright (c) 2018 Steve Rhoades + * @copyright (\SimpleSAML\Module\oidc\Entities\c) 2018 Steve Rhoades * @license http://opensource.org/licenses/MIT MIT */ class ClaimSetEntity implements ClaimSetEntityInterface @@ -19,11 +19,13 @@ public function __construct(protected string $scope, protected array $claims) { } + public function getScope(): string { return $this->scope; } + public function getClaims(): array { return $this->claims; diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 672715b9..9ff62d13 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; +use InvalidArgumentException; use League\OAuth2\Server\Entities\Traits\ClientTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; @@ -20,40 +21,67 @@ class ClientEntity implements ClientEntityInterface public const string KEY_ID = 'id'; + public const string KEY_SECRET = 'secret'; + public const string KEY_NAME = 'name'; + public const string KEY_DESCRIPTION = 'description'; + public const string KEY_AUTH_SOURCE = 'auth_source'; + public const string KEY_REDIRECT_URI = 'redirect_uri'; + public const string KEY_SCOPES = 'scopes'; + public const string KEY_IS_ENABLED = 'is_enabled'; + public const string KEY_IS_CONFIDENTIAL = 'is_confidential'; + public const string KEY_OWNER = 'owner'; + public const string KEY_POST_LOGOUT_REDIRECT_URI = 'post_logout_redirect_uri'; + public const string KEY_BACKCHANNEL_LOGOUT_URI = 'backchannel_logout_uri'; + public const string KEY_ENTITY_IDENTIFIER = 'entity_identifier'; + public const string KEY_CLIENT_REGISTRATION_TYPES = 'client_registration_types'; + public const string KEY_FEDERATION_JWKS = 'federation_jwks'; + public const string KEY_JWKS = 'jwks'; + public const string KEY_JWKS_URI = 'jwks_uri'; + public const string KEY_SIGNED_JWKS_URI = 'signed_jwks_uri'; + public const string KEY_REGISTRATION_TYPE = 'registration_type'; + public const string KEY_UPDATED_AT = 'updated_at'; + public const string KEY_CREATED_AT = 'created_at'; + public const string KEY_EXPIRES_AT = 'expires_at'; + public const string KEY_IS_GENERIC = 'is_generic'; + public const string KEY_EXTRA_METADATA = 'extra_metadata'; + /** * Hash of the OpenID Connect Dynamic Client Registration Access Token, used to authenticate read requests at * the Client Configuration Endpoint. The plaintext token is shown to the client only once (at registration). */ public const string KEY_REGISTRATION_ACCESS_TOKEN = 'registration_access_token'; + public const string KEY_ALLOWED_RESPONSE_MODES = 'allowed_response_modes'; + /** * Per-client Authentication Processing Filters. Stored as an entry inside * the extra metadata JSON blob. */ public const string KEY_AUTH_PROC_FILTERS = 'authproc'; + /** * Whether all of the user's (scope-derived) claims should be released in the * ID Token issued to this client, in addition to being available at the @@ -78,49 +106,14 @@ class ClientEntity implements ClientEntityInterface ]; - private string $secret; - - private string $description; - private ?string $authSource = null; - /** - * @var string[] $scopes - */ - private array $scopes; - - private bool $isEnabled = true; - private ?string $owner = null; - /** - * @var string[]|null - */ - private ?array $postLogoutRedirectUri = null; - private ?string $backChannelLogoutUri = null; + private ?string $entityIdentifier = null; - /** - * @var string[]|null - */ - private ?array $clientRegistrationTypes = null; - /** - * @var ?array[]|null - */ - private ?array $federationJwks = null; - /** - * @var ?array[]|null - */ - private ?array $jwks = null; - private ?string $jwksUri = null; - private ?string $signedJwksUri = null; - private RegistrationTypeEnum $registrationType; - private ?DateTimeImmutable $updatedAt; - private ?DateTimeImmutable $createdAt; - private ?DateTimeImmutable $expiresAt; - private bool $isGeneric; - private ?array $extraMetadata; - private ?string $registrationAccessToken; + /** * @param string[] $redirectUri @@ -132,62 +125,46 @@ class ClientEntity implements ClientEntityInterface */ public function __construct( string $identifier, - string $secret, + private string $secret, string $name, - string $description, + private string $description, array $redirectUri, - array $scopes, - bool $isEnabled, + private array $scopes, + private bool $isEnabled, bool $isConfidential = false, ?string $authSource = null, ?string $owner = null, - array $postLogoutRedirectUri = [], + private array $postLogoutRedirectUri = [], ?string $backChannelLogoutUri = null, ?string $entityIdentifier = null, - ?array $clientRegistrationTypes = null, - ?array $federationJwks = null, - ?array $jwks = null, - ?string $jwksUri = null, - ?string $signedJwksUri = null, - RegistrationTypeEnum $registrationType = RegistrationTypeEnum::Manual, - ?DateTimeImmutable $updatedAt = null, - ?DateTimeImmutable $createdAt = null, - ?DateTimeImmutable $expiresAt = null, - bool $isGeneric = false, - ?array $extraMetadata = null, - ?string $registrationAccessToken = null, + private ?array $clientRegistrationTypes = null, + private ?array $federationJwks = null, + private ?array $jwks = null, + private ?string $jwksUri = null, + private ?string $signedJwksUri = null, + private RegistrationTypeEnum $registrationType = RegistrationTypeEnum::Manual, + private ?DateTimeImmutable $updatedAt = null, + private ?DateTimeImmutable $createdAt = null, + private ?DateTimeImmutable $expiresAt = null, + private bool $isGeneric = false, + private ?array $extraMetadata = null, + private ?string $registrationAccessToken = null, ) { if ($identifier === '') { - throw new \InvalidArgumentException('Client identifier cannot be empty.'); + throw new InvalidArgumentException('Client identifier cannot be empty.'); } $this->identifier = $identifier; - $this->secret = $secret; $this->name = $name; - $this->description = $description; $this->authSource = empty($authSource) ? null : $authSource; $this->redirectUri = $redirectUri; - $this->scopes = $scopes; - $this->isEnabled = $isEnabled; $this->isConfidential = $isConfidential; $this->owner = empty($owner) ? null : $owner; - $this->postLogoutRedirectUri = $postLogoutRedirectUri; $this->backChannelLogoutUri = empty($backChannelLogoutUri) ? null : $backChannelLogoutUri; $this->entityIdentifier = empty($entityIdentifier) ? null : $entityIdentifier; - $this->clientRegistrationTypes = $clientRegistrationTypes; - $this->federationJwks = $federationJwks; - $this->jwks = $jwks; - $this->jwksUri = $jwksUri; - $this->signedJwksUri = $signedJwksUri; - $this->registrationType = $registrationType; - $this->updatedAt = $updatedAt; - $this->createdAt = $createdAt; - $this->expiresAt = $expiresAt; - $this->isGeneric = $isGeneric; - $this->extraMetadata = $extraMetadata; - $this->registrationAccessToken = $registrationAccessToken; } + /** * {@inheritdoc} * @throws \JsonException @@ -231,6 +208,7 @@ public function getState(): array ]; } + public function toArray(): array { return [ @@ -285,11 +263,13 @@ public function toArray(): array ]; } + public function getSecret(): string { return $this->secret; } + public function restoreSecret(string $secret): ClientEntityInterface { $this->secret = $secret; @@ -297,51 +277,61 @@ public function restoreSecret(string $secret): ClientEntityInterface return $this; } + public function getDescription(): string { return $this->description; } + public function getAuthSourceId(): ?string { return $this->authSource; } + public function getScopes(): array { return $this->scopes; } + public function isEnabled(): bool { return $this->isEnabled; } + public function getOwner(): ?string { return $this->owner; } + public function getPostLogoutRedirectUri(): array { - return $this->postLogoutRedirectUri ?? []; + return $this->postLogoutRedirectUri; } + public function setPostLogoutRedirectUri(array $postLogoutRedirectUri): void { $this->postLogoutRedirectUri = $postLogoutRedirectUri; } + public function getBackChannelLogoutUri(): ?string { return $this->backChannelLogoutUri; } + public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void { $this->backChannelLogoutUri = $backChannelLogoutUri; } + /** * Get the RP Entity Identifier, as used in OpenID Federation specification. * This is different from the client ID. @@ -351,11 +341,13 @@ public function getEntityIdentifier(): ?string return $this->entityIdentifier; } + public function getRedirectUris(): array { return is_string($this->redirectUri) ? [$this->redirectUri] : $this->redirectUri; } + /** * Get client registration types. * Since this is required property, it will fall back to 'automatic', if not set on client. @@ -371,61 +363,73 @@ public function getClientRegistrationTypes(): array return $this->clientRegistrationTypes; } + public function getFederationJwks(): ?array { return $this->federationJwks; } + public function getJwks(): ?array { return $this->jwks; } + public function getJwksUri(): ?string { return $this->jwksUri; } + public function getSignedJwksUri(): ?string { return $this->signedJwksUri; } + public function getRegistrationType(): RegistrationTypeEnum { return $this->registrationType; } + public function getUpdatedAt(): ?DateTimeImmutable { return $this->updatedAt; } + public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; } + public function getExpiresAt(): ?DateTimeImmutable { return $this->expiresAt; } + public function isExpired(): bool { return $this->expiresAt !== null && $this->expiresAt < new DateTimeImmutable(); } + public function isGeneric(): bool { return $this->isGeneric; } + public function getExtraMetadata(): array { return $this->extraMetadata ?? []; } + /** * Hash of the Registration Access Token associated with this client, or null if none was issued (e.g. clients * not created via OIDC Dynamic Client Registration). @@ -435,11 +439,13 @@ public function getRegistrationAccessTokenHash(): ?string return $this->registrationAccessToken; } + public function setRegistrationAccessTokenHash(?string $registrationAccessTokenHash): void { $this->registrationAccessToken = $registrationAccessTokenHash; } + public function getIdTokenSignedResponseAlg(): ?string { if (!is_array($this->extraMetadata)) { @@ -455,6 +461,7 @@ public function getIdTokenSignedResponseAlg(): ?string return $idTokenSignedResponseAlg; } + public function getAllowedResponseModes(): array { /** @psalm-suppress MixedAssignment */ @@ -471,6 +478,7 @@ public function getAllowedResponseModes(): array ]; } + public function getRequirePushedAuthorizationRequests(): bool { if (!is_array($this->extraMetadata)) { @@ -480,6 +488,7 @@ public function getRequirePushedAuthorizationRequests(): bool return (bool)($this->extraMetadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false); } + public function getRequireSignedRequestObject(): bool { if (!is_array($this->extraMetadata)) { @@ -489,6 +498,7 @@ public function getRequireSignedRequestObject(): bool return (bool)($this->extraMetadata[ClaimsEnum::RequireSignedRequestObject->value] ?? false); } + /** * Per-client Authentication Processing Filters, in the same format as the * global ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS option. These run, in @@ -509,6 +519,7 @@ public function getAuthProcFilters(): array return is_array($authProcFilters) ? $authProcFilters : []; } + /** * Whether all of the user's (scope-derived) claims should be released in the * ID Token issued to this client. By default (false) such claims are only @@ -528,6 +539,7 @@ public function getAddClaimsToIdToken(): bool ); } + /** * @return string[] */ @@ -554,6 +566,7 @@ public function getRequestUris(): array return $stringUris; } + /** * The OAuth 2.0 grant types the client is registered to use, or an empty array when none are registered. * @@ -579,6 +592,7 @@ public function getGrantTypes(): array return array_values(array_filter($grantTypes, 'is_string')); } + /** * The OAuth 2.0 response types the client is registered to use, or an empty array when none are registered. * @@ -600,6 +614,7 @@ public function getResponseTypes(): array return array_values(array_filter($responseTypes, 'is_string')); } + /** * The client authentication method the client is registered to use at the token endpoint, or null when none * is registered. @@ -621,6 +636,7 @@ public function getTokenEndpointAuthMethod(): ?string return null; } + /** * Default Maximum Authentication Age (seconds) applied when the authorization request omits max_age, or null * when not registered. @@ -639,6 +655,7 @@ public function getDefaultMaxAge(): ?int return null; } + /** * Whether the auth_time claim is required in the ID Token issued to this client. */ @@ -651,6 +668,7 @@ public function getRequireAuthTime(): bool return filter_var($value, FILTER_VALIDATE_BOOLEAN); } + /** * Default ACR values requested when the authorization request omits acr_values. * @@ -669,6 +687,7 @@ public function getDefaultAcrValues(): array return array_values(array_filter($values, 'is_string')); } + /** * URI a third party can use to initiate login for this client (informational; the OP does not act on it). */ @@ -677,6 +696,7 @@ public function getInitiateLoginUri(): ?string return $this->getStringExtraMetadata(ClaimsEnum::InitiateLoginUri->value); } + /** * RFC 7591 software_id (informational). */ @@ -685,6 +705,7 @@ public function getSoftwareId(): ?string return $this->getStringExtraMetadata(ClaimsEnum::SoftwareId->value); } + /** * RFC 7591 software_version (informational). */ @@ -693,6 +714,7 @@ public function getSoftwareVersion(): ?string return $this->getStringExtraMetadata(ClaimsEnum::SoftwareVersion->value); } + /** * logo_uri (informational; subject to impersonation protection on the DCR path). */ @@ -701,6 +723,7 @@ public function getLogoUri(): ?string return $this->getStringExtraMetadata(ClaimsEnum::LogoUri->value); } + /** * client_uri (informational). */ @@ -709,6 +732,7 @@ public function getClientUri(): ?string return $this->getStringExtraMetadata(ClaimsEnum::ClientUri->value); } + /** * policy_uri (informational; subject to impersonation protection on the DCR path). */ @@ -717,6 +741,7 @@ public function getPolicyUri(): ?string return $this->getStringExtraMetadata(ClaimsEnum::PolicyUri->value); } + /** * tos_uri (informational; subject to impersonation protection on the DCR path). */ @@ -725,6 +750,7 @@ public function getTosUri(): ?string return $this->getStringExtraMetadata(ClaimsEnum::TosUri->value); } + /** * application_type (web or native), or null when not registered. */ @@ -733,6 +759,7 @@ public function getApplicationType(): ?string return $this->getStringExtraMetadata(ClaimsEnum::ApplicationType->value); } + /** * contacts (e.g. administrator e-mail addresses). * @@ -751,6 +778,7 @@ public function getContacts(): array return array_values(array_filter($contacts, 'is_string')); } + private function getStringExtraMetadata(string $key): ?string { /** @var mixed $value */ diff --git a/src/Entities/Interfaces/AuthCodeEntityInterface.php b/src/Entities/Interfaces/AuthCodeEntityInterface.php index 00f66db2..093a4515 100644 --- a/src/Entities/Interfaces/AuthCodeEntityInterface.php +++ b/src/Entities/Interfaces/AuthCodeEntityInterface.php @@ -13,5 +13,6 @@ interface AuthCodeEntityInterface extends OAuth2AuthCodeEntityInterface, TokenRe */ public function getNonce(): ?string; + public function setNonce(string $nonce): void; } diff --git a/src/Entities/Interfaces/ClaimSetEntityInterface.php b/src/Entities/Interfaces/ClaimSetEntityInterface.php index 9e68ed6e..e46e3beb 100644 --- a/src/Entities/Interfaces/ClaimSetEntityInterface.php +++ b/src/Entities/Interfaces/ClaimSetEntityInterface.php @@ -8,7 +8,7 @@ * This file contains modified code from the 'steverhoades/oauth2-openid-connect-server' library * (https://github.com/steverhoades/oauth2-openid-connect-server), with original author, copyright notice and licence: * @author Steve Rhoades - * @copyright (c) 2018 Steve Rhoades + * @copyright (\SimpleSAML\Module\oidc\Entities\Interfaces\c) 2018 Steve Rhoades * @license http://opensource.org/licenses/MIT MIT */ interface ClaimSetEntityInterface extends ClaimSetInterface, ScopeInterface diff --git a/src/Entities/Interfaces/ClaimSetInterface.php b/src/Entities/Interfaces/ClaimSetInterface.php index 342f1f34..05ed3221 100644 --- a/src/Entities/Interfaces/ClaimSetInterface.php +++ b/src/Entities/Interfaces/ClaimSetInterface.php @@ -8,7 +8,7 @@ * This file contains modified code from the 'steverhoades/oauth2-openid-connect-server' library * (https://github.com/steverhoades/oauth2-openid-connect-server), with original author, copyright notice and licence: * @author Steve Rhoades - * @copyright (c) 2018 Steve Rhoades + * @copyright (\SimpleSAML\Module\oidc\Entities\Interfaces\c) 2018 Steve Rhoades * @license http://opensource.org/licenses/MIT MIT */ interface ClaimSetInterface diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index 8e8a6989..e44e894c 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -12,133 +12,196 @@ interface ClientEntityInterface extends OAuth2ClientEntityInterface, MementoInte { public function toArray(): array; + public function getSecret(): string; + public function restoreSecret(string $secret): self; + public function getDescription(): string; + public function getAuthSourceId(): ?string; + /** * @return string[] */ public function getScopes(): array; + public function isEnabled(): bool; + public function getOwner(): ?string; + /** * @return string[] */ public function getPostLogoutRedirectUri(): array; + /** * @param string[] $postLogoutRedirectUri */ public function setPostLogoutRedirectUri(array $postLogoutRedirectUri): void; + /** * @return string|null */ public function getBackChannelLogoutUri(): ?string; + /** * @param string|null $backChannelLogoutUri */ public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void; + public function getEntityIdentifier(): ?string; + /** * @return string[] */ public function getRedirectUris(): array; + /** * @return string[] */ public function getClientRegistrationTypes(): array; + /** * @return array[]|null */ public function getFederationJwks(): ?array; + /** * @return array[]|null */ public function getJwks(): ?array; + public function getJwksUri(): ?string; + + public function getSignedJwksUri(): ?string; + + public function getRegistrationType(): RegistrationTypeEnum; + + public function getUpdatedAt(): ?DateTimeImmutable; + + public function getCreatedAt(): ?DateTimeImmutable; + + public function getExpiresAt(): ?DateTimeImmutable; + + public function isExpired(): bool; + + public function isGeneric(): bool; + public function getExtraMetadata(): array; + + public function getRegistrationAccessTokenHash(): ?string; + + public function setRegistrationAccessTokenHash(?string $registrationAccessTokenHash): void; + + public function getIdTokenSignedResponseAlg(): ?string; + + public function getAllowedResponseModes(): array; + + public function getRequirePushedAuthorizationRequests(): bool; + + public function getRequireSignedRequestObject(): bool; + + /** * @return string[] */ public function getRequestUris(): array; + /** * @return string[] */ public function getGrantTypes(): array; + /** * @return string[] */ public function getResponseTypes(): array; + public function getTokenEndpointAuthMethod(): ?string; + public function getDefaultMaxAge(): ?int; + public function getRequireAuthTime(): bool; + /** * @return string[] */ public function getDefaultAcrValues(): array; + public function getInitiateLoginUri(): ?string; + public function getSoftwareId(): ?string; + public function getSoftwareVersion(): ?string; + public function getLogoUri(): ?string; + public function getClientUri(): ?string; + public function getPolicyUri(): ?string; + public function getTosUri(): ?string; + public function getApplicationType(): ?string; + /** * @return string[] */ public function getContacts(): array; + /** * @return array */ public function getAuthProcFilters(): array; + /** * Whether the user's (scope-derived) claims should be released in the ID Token issued to this client. */ diff --git a/src/Entities/Interfaces/ScopeInterface.php b/src/Entities/Interfaces/ScopeInterface.php index 2cc77dd7..b6cae6c0 100644 --- a/src/Entities/Interfaces/ScopeInterface.php +++ b/src/Entities/Interfaces/ScopeInterface.php @@ -8,7 +8,7 @@ * This file contains modified code from the 'steverhoades/oauth2-openid-connect-server' library * (https://github.com/steverhoades/oauth2-openid-connect-server), with original author, copyright notice and licence: * @author Steve Rhoades - * @copyright (c) 2018 Steve Rhoades + * @copyright (\SimpleSAML\Module\oidc\Entities\Interfaces\c) 2018 Steve Rhoades * @license http://opensource.org/licenses/MIT MIT */ interface ScopeInterface diff --git a/src/Entities/Interfaces/TokenAssociatableWithAuthCodeInterface.php b/src/Entities/Interfaces/TokenAssociatableWithAuthCodeInterface.php index 29306133..657bb18b 100644 --- a/src/Entities/Interfaces/TokenAssociatableWithAuthCodeInterface.php +++ b/src/Entities/Interfaces/TokenAssociatableWithAuthCodeInterface.php @@ -11,6 +11,7 @@ interface TokenAssociatableWithAuthCodeInterface */ public function setAuthCodeId(?string $authCodeId): void; + /** * @return string|null */ diff --git a/src/Entities/Interfaces/TokenRevokableInterface.php b/src/Entities/Interfaces/TokenRevokableInterface.php index 3ed65403..8cb5e78a 100644 --- a/src/Entities/Interfaces/TokenRevokableInterface.php +++ b/src/Entities/Interfaces/TokenRevokableInterface.php @@ -12,6 +12,7 @@ interface TokenRevokableInterface */ public function isRevoked(): bool; + /** * Revoke token */ diff --git a/src/Entities/IssuerStateEntity.php b/src/Entities/IssuerStateEntity.php index beb044c5..cbaa704b 100644 --- a/src/Entities/IssuerStateEntity.php +++ b/src/Entities/IssuerStateEntity.php @@ -20,6 +20,7 @@ public function __construct( ) { } + public function getState(): array { return [ @@ -30,26 +31,31 @@ public function getState(): array ]; } + public function getValue(): string { return $this->value; } + public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } + public function getExpirestAt(): DateTimeImmutable { return $this->expirestAt; } + public function isRevoked(): bool { return $this->isRevoked; } + public function revoke(): void { $this->isRevoked = true; diff --git a/src/Entities/PushedAuthorizationRequestEntity.php b/src/Entities/PushedAuthorizationRequestEntity.php index 832d3ca0..ea1bd359 100644 --- a/src/Entities/PushedAuthorizationRequestEntity.php +++ b/src/Entities/PushedAuthorizationRequestEntity.php @@ -19,41 +19,49 @@ public function __construct( ) { } + public function getRequestUri(): string { return $this->requestUri; } + public function getClientId(): string { return $this->clientId; } + public function getParameters(): array { return $this->parameters; } + public function getExpiresAt(): DateTimeImmutable { return $this->expiresAt; } + public function isConsumed(): bool { return $this->isConsumed; } + public function consume(): void { $this->isConsumed = true; } + public function isExpired(DateTimeImmutable $now): bool { return $this->expiresAt < $now; } + /** * @throws \JsonException */ diff --git a/src/Entities/RefreshTokenEntity.php b/src/Entities/RefreshTokenEntity.php index 9bb7676f..b6b957f6 100644 --- a/src/Entities/RefreshTokenEntity.php +++ b/src/Entities/RefreshTokenEntity.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; +use InvalidArgumentException; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; @@ -19,6 +20,7 @@ class RefreshTokenEntity implements RefreshTokenEntityInterface use RevokeTokenTrait; use AssociateWithAuthCodeTrait; + public function __construct( string $id, DateTimeImmutable $expiryDateTime, @@ -27,7 +29,7 @@ public function __construct( bool $isRevoked = false, ) { if ($id === '') { - throw new \InvalidArgumentException('Refresh token identifier cannot be empty.'); + throw new InvalidArgumentException('Refresh token identifier cannot be empty.'); } $this->setIdentifier($id); @@ -37,6 +39,7 @@ public function __construct( $this->isRevoked = $isRevoked; } + public function getState(): array { return [ diff --git a/src/Entities/ScopeEntity.php b/src/Entities/ScopeEntity.php index 4535879e..08f0486c 100644 --- a/src/Entities/ScopeEntity.php +++ b/src/Entities/ScopeEntity.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Entities; +use InvalidArgumentException; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; @@ -14,6 +15,7 @@ class ScopeEntity implements ScopeEntityInterface { use EntityTrait; + /** * @param string[] $claims */ @@ -24,22 +26,25 @@ public function __construct( protected array $claims = [], ) { if ($identifier === '') { - throw new \InvalidArgumentException('Scope identifier cannot be empty.'); + throw new InvalidArgumentException('Scope identifier cannot be empty.'); } $this->identifier = $identifier; } + public function getIcon(): ?string { return $this->icon; } + public function getDescription(): ?string { return $this->description; } + /** * @return array */ @@ -48,6 +53,7 @@ public function getClaims(): array return $this->claims; } + public function jsonSerialize(): string { return $this->getIdentifier(); diff --git a/src/Entities/Traits/AssociateWithAuthCodeTrait.php b/src/Entities/Traits/AssociateWithAuthCodeTrait.php index bb481822..a7128d90 100644 --- a/src/Entities/Traits/AssociateWithAuthCodeTrait.php +++ b/src/Entities/Traits/AssociateWithAuthCodeTrait.php @@ -8,11 +8,13 @@ trait AssociateWithAuthCodeTrait { protected ?string $authCodeId = null; + public function setAuthCodeId(?string $authCodeId): void { $this->authCodeId = $authCodeId; } + public function getAuthCodeId(): ?string { return $this->authCodeId; diff --git a/src/Entities/Traits/OidcAuthCodeTrait.php b/src/Entities/Traits/OidcAuthCodeTrait.php index 7aafeed9..15724a98 100644 --- a/src/Entities/Traits/OidcAuthCodeTrait.php +++ b/src/Entities/Traits/OidcAuthCodeTrait.php @@ -10,11 +10,13 @@ trait OidcAuthCodeTrait { use AuthCodeTrait; + /** * @var null|string */ protected ?string $nonce = null; + /** * @inheritDoc */ @@ -23,6 +25,7 @@ public function getNonce(): ?string return $this->nonce; } + public function setNonce(string $nonce): void { $this->nonce = $nonce; diff --git a/src/Entities/Traits/RevokeTokenTrait.php b/src/Entities/Traits/RevokeTokenTrait.php index bf04a80e..486a5199 100644 --- a/src/Entities/Traits/RevokeTokenTrait.php +++ b/src/Entities/Traits/RevokeTokenTrait.php @@ -8,11 +8,13 @@ trait RevokeTokenTrait { protected bool $isRevoked = false; + public function isRevoked(): bool { return $this->isRevoked; } + /** * Revoke token. */ diff --git a/src/Entities/UserEntity.php b/src/Entities/UserEntity.php index 6701593a..f8a6c072 100644 --- a/src/Entities/UserEntity.php +++ b/src/Entities/UserEntity.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; +use InvalidArgumentException; use League\OAuth2\Server\Entities\UserEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\ClaimSetInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\MementoInterface; @@ -17,6 +18,7 @@ class UserEntity implements UserEntityInterface, MementoInterface, ClaimSetInter /** @var non-empty-string */ private readonly string $identifier; + public function __construct( string $identifier, private readonly DateTimeImmutable $createdAt, @@ -24,12 +26,13 @@ public function __construct( private array $claims = [], ) { if ($identifier === '') { - throw new \InvalidArgumentException('User identifier cannot be empty.'); + throw new InvalidArgumentException('User identifier cannot be empty.'); } $this->identifier = $identifier; } + /** * {@inheritdoc} */ @@ -43,33 +46,39 @@ public function getState(): array ]; } + public function getIdentifier(): string { return $this->identifier; } + public function getClaims(): array { return $this->claims; } + public function setClaims(array $claims): self { $this->claims = $claims; return $this; } + public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } + public function setUpdatedAt(DateTimeImmutable $updatedAt): self { $this->updatedAt = $updatedAt; return $this; } + public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; diff --git a/src/Exceptions/OidcException.php b/src/Exceptions/OidcException.php index 08482de2..dad60c6e 100644 --- a/src/Exceptions/OidcException.php +++ b/src/Exceptions/OidcException.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Module\oidc\Exceptions; -class OidcException extends \Exception +use Exception; + +class OidcException extends Exception { } diff --git a/src/Factories/AuthSimpleFactory.php b/src/Factories/AuthSimpleFactory.php index 8545a47e..4ecab102 100644 --- a/src/Factories/AuthSimpleFactory.php +++ b/src/Factories/AuthSimpleFactory.php @@ -16,6 +16,7 @@ public function __construct( ) { } + /** * @codeCoverageIgnore * @throws \Exception @@ -27,8 +28,9 @@ public function build(OAuth2ClientEntityInterface $clientEntity): Simple return new Simple($authSourceId); } + /** - * @return Simple The default authsource + * @return \SimpleSAML\Auth\Simple The default authsource * @throws \Exception */ public function getDefaultAuthSource(): Simple @@ -36,6 +38,7 @@ public function getDefaultAuthSource(): Simple return new Simple($this->moduleConfig->getDefaultAuthSourceId()); } + /** * Get auth source defined on the client. If not set on the client, get the default auth source defined in config. * @@ -52,6 +55,7 @@ public function resolveAuthSourceId(OAuth2ClientEntityInterface $client): string return $defaultAuthSourceId; } + public function forAuthSourceId(string $authSourceId): Simple { return new Simple($authSourceId); diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index c4bd14d3..b7f6e483 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -36,6 +36,7 @@ public function __construct( ) { } + public function build(): AuthorizationServer { $authorizationServer = new AuthorizationServer( diff --git a/src/Factories/CacheFactory.php b/src/Factories/CacheFactory.php index 73c9258b..f2620270 100644 --- a/src/Factories/CacheFactory.php +++ b/src/Factories/CacheFactory.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Utils\ProtocolCache; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Psr16Cache; +use Throwable; class CacheFactory { @@ -22,6 +23,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ @@ -31,7 +33,7 @@ protected function buildAdapterInstance( ): AdapterInterface { try { $instance = $this->classInstanceBuilder->build($class, $args); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $message = "Error building cache adapter instance: " . $exception->getMessage(); $this->loggerService->error($message); throw new OidcException($message); @@ -46,6 +48,7 @@ protected function buildAdapterInstance( return $instance; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ @@ -65,6 +68,7 @@ public function forFederation(): ?FederationCache return new FederationCache(new Psr16Cache($adapter)); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ diff --git a/src/Factories/ClaimTranslatorExtractorFactory.php b/src/Factories/ClaimTranslatorExtractorFactory.php index 36cba669..2473a107 100644 --- a/src/Factories/ClaimTranslatorExtractorFactory.php +++ b/src/Factories/ClaimTranslatorExtractorFactory.php @@ -21,6 +21,7 @@ public function __construct( ) { } + /** * @throws \Exception */ @@ -66,6 +67,7 @@ public function build(): ClaimTranslatorExtractor ); } + /** * Apply a prefix to translator table keys (which serve as claim names). * @@ -91,6 +93,7 @@ protected function applyPrefixToTranslatorTableKeys(array $translatorTable, arra return $translatorTable; } + /** * @param array $claims Claim names for which to apply prefix * @param string $prefix Prefix to apply to claim names. @@ -105,6 +108,7 @@ protected function applyPrefixToClaimNames(array $claims, string $prefix): array return $claims; } + /** * Check if the scope has a claim name prefix set */ @@ -115,6 +119,7 @@ protected function isScopeClaimNamePrefixSet(array $scopeConfig): bool !empty($scopeConfig[self::CONFIG_KEY_CLAIM_NAME_PREFIX]); } + /** * Check if the scope allows claims to have multiple values. */ diff --git a/src/Factories/CoreFactory.php b/src/Factories/CoreFactory.php index ef454c8b..8f9bbd05 100644 --- a/src/Factories/CoreFactory.php +++ b/src/Factories/CoreFactory.php @@ -16,10 +16,11 @@ public function __construct( ) { } + /** * Builds a new Core instance. * - * @return Core + * @return \SimpleSAML\OpenID\Core */ public function build(): Core { diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index 6a9b2327..b552e4a6 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -27,6 +27,7 @@ use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\VerifiableCredentials; use SimpleSAML\OpenID\VerifiableCredentials\TxCode; +use Throwable; class CredentialOfferUriFactory { @@ -47,6 +48,7 @@ public function __construct( ) { } + /** * @param string[] $credentialConfigurationIds * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException @@ -62,7 +64,7 @@ public function buildForAuthorization( $issuerState = $this->issuerStateEntityFactory->buildNew(); $this->issuerStateRepository->persist($issuerState); break; - } catch (\Throwable $e) { + } catch (Throwable $e) { if ($issuerStateGenerationAttempts === 0) { $this->loggerService->error( 'All attempts to generate Issuer State failed: ' . $e->getMessage(), @@ -93,6 +95,7 @@ public function buildForAuthorization( return $this->buildUri($credentialOffer->jsonSerialize()); } + /** * @param string[] $credentialConfigurationIds * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException @@ -141,7 +144,7 @@ public function buildPreAuthorized( if ($userId === null) { throw new RuntimeException('User identifier attribute value is not available.'); } - } catch (\Throwable) { + } catch (Throwable) { $this->loggerService->warning('Could not extract user identifier from credential-offer attributes.'); } @@ -184,11 +187,11 @@ public function buildPreAuthorized( userIdentifier: $userId, redirectUri: 'openid-credential-offer://', flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, - txCode: $txCode instanceof VerifiableCredentials\TxCode ? $txCode->getCodeAsString() : null, + txCode: $txCode instanceof TxCode ? $txCode->getCodeAsString() : null, ); $this->authCodeRepository->persistNewAuthCode($authCode); break; - } catch (\Throwable $e) { + } catch (Throwable $e) { if ($authCodeIdGenerationAttempts === 0) { $this->loggerService->error( 'All attempts to generate Authorization Code failed: ' . $e->getMessage(), @@ -213,7 +216,7 @@ public function buildPreAuthorized( ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), ...(array_filter( [ - ClaimsEnum::TxCode->value => $txCode instanceof VerifiableCredentials\TxCode ? + ClaimsEnum::TxCode->value => $txCode instanceof TxCode ? $txCode->jsonSerialize() : null, ], @@ -223,13 +226,14 @@ public function buildPreAuthorized( ], ); - if ($txCode instanceof VerifiableCredentials\TxCode && $userEmail !== null) { + if ($txCode instanceof TxCode && $userEmail !== null) { $this->sendTxCodeByEmail($txCode, $userEmail); } return $this->buildUri($credentialOffer->jsonSerialize()); } + /** * Build the offer URI a wallet is sent to, carrying the offer either by value or by reference. * @@ -257,9 +261,10 @@ protected function buildUri(string|array $credentialOffer): string ); } + /** * @param mixed[] $userAttributes - * @throws RuntimeException + * @throws \RuntimeException */ public function getUserEmail(string $userEmailAttributeName, array $userAttributes): string { @@ -280,6 +285,7 @@ public function getUserEmail(string $userEmailAttributeName, array $userAttribut return $userEmail; } + public function buildTxCode( string $description, int|string $txCode = null, @@ -292,6 +298,7 @@ public function buildTxCode( ); } + public function sendTxCodeByEmail(TxCode $txCode, string $email, string $subject = null): void { $subject ??= 'Your one-time code'; diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index 176334fd..4ffea298 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -15,6 +15,7 @@ public function __construct( ) { } + /** * @throws \Exception */ @@ -32,6 +33,7 @@ public function buildPrivateKey(): CryptKey ); } + /** * @throws \Exception */ @@ -42,6 +44,7 @@ public function buildPublicKey(): CryptKey return new CryptKey($publicKeyFilename, null, false); } + /** * @return array{ * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, @@ -50,7 +53,7 @@ public function buildPublicKey(): CryptKey * private_key_password: ?non-empty-string, * key_id: ?non-empty-string * } - * @throws ConfigurationError + * @throws \SimpleSAML\Error\ConfigurationError * */ protected function getDefaultProtocolSignatureKeyPairConfig(): array diff --git a/src/Factories/DestinationPolicyFactory.php b/src/Factories/DestinationPolicyFactory.php index 3e510d09..f1389640 100644 --- a/src/Factories/DestinationPolicyFactory.php +++ b/src/Factories/DestinationPolicyFactory.php @@ -25,6 +25,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\OpenID\Exceptions\DestinationPolicyException On unusable configuration, which the diff --git a/src/Factories/Entities/AccessTokenEntityFactory.php b/src/Factories/Entities/AccessTokenEntityFactory.php index 266aee84..b03acd75 100644 --- a/src/Factories/Entities/AccessTokenEntityFactory.php +++ b/src/Factories/Entities/AccessTokenEntityFactory.php @@ -24,6 +24,7 @@ public function __construct( ) { } + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ @@ -61,6 +62,7 @@ public function fromData( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException diff --git a/src/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index 0304b804..639b2f15 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -20,6 +20,7 @@ public function __construct( ) { } + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ @@ -57,6 +58,7 @@ public function fromData( ); } + /** * @throws \Exception * @throws \JsonException diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 0261464a..b93946c8 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -44,6 +44,7 @@ class ClientEntityFactory ClaimsEnum::SoftwareVersion->value, ]; + public function __construct( private readonly SspBridge $sspBridge, private readonly Helpers $helpers, @@ -51,6 +52,7 @@ public function __construct( ) { } + /** * @param string[] $redirectUri * @param string[] $scopes @@ -115,6 +117,7 @@ public function fromData( ); } + /** * Resolve client data from registration metadata. * @@ -240,7 +243,7 @@ public function fromRegistrationData( $this->helpers->arr()->ensureStringValues($metadata[ClaimsEnum::ClientRegistrationTypes->value]) : $metadataFallbackClient?->getClientRegistrationTypes(); - $federationJwks = $federationJwks ?? $metadataFallbackClient?->getFederationJwks(); + $federationJwks ??= $metadataFallbackClient?->getFederationJwks(); /** @var ?array[] $jwks */ $jwks = isset($metadata[ClaimsEnum::Jwks->value]) && @@ -465,6 +468,7 @@ public function fromRegistrationData( ); } + protected function determineIsConfidential( array $metadata, ): bool { @@ -507,6 +511,7 @@ protected function determineIsConfidential( return true; } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -630,6 +635,7 @@ public function fromState(array $state): ClientEntityInterface ); } + public function getGenericForVci(): ClientEntityInterface { $clientId = 'vci_' . diff --git a/src/Factories/Entities/IssuerStateEntityFactory.php b/src/Factories/Entities/IssuerStateEntityFactory.php index b0e75b43..5b2c7215 100644 --- a/src/Factories/Entities/IssuerStateEntityFactory.php +++ b/src/Factories/Entities/IssuerStateEntityFactory.php @@ -18,6 +18,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -37,9 +38,10 @@ public function buildNew( return $this->fromData($value, $createdAt, $expiresAt, $isRevoked); } + /** * @param string $value Issuer State Entity value, max 64 characters. - * @throws OpenIdException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ public function fromData( string $value, @@ -54,10 +56,11 @@ public function fromData( return new IssuerStateEntity($value, $createdAt, $expiresAt, $isRevoked); } + /** * @param mixed[] $state - * @return IssuerStateEntity - * @throws OpenIdException + * @return \SimpleSAML\Module\oidc\Entities\IssuerStateEntity + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ public function fromState(array $state): IssuerStateEntity { diff --git a/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php index a1d428ce..81db35a7 100644 --- a/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php +++ b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php @@ -14,12 +14,14 @@ class PushedAuthorizationRequestEntityFactory { final public const string REQUEST_URI_PREFIX = 'urn:ietf:params:oauth:request_uri:'; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Helpers $helpers, ) { } + /** * @param mixed[] $parameters * @throws \Exception @@ -43,6 +45,7 @@ public function fromData( ); } + /** * @param mixed[] $state * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException diff --git a/src/Factories/Entities/RefreshTokenEntityFactory.php b/src/Factories/Entities/RefreshTokenEntityFactory.php index 5c5af49a..05d54e6f 100644 --- a/src/Factories/Entities/RefreshTokenEntityFactory.php +++ b/src/Factories/Entities/RefreshTokenEntityFactory.php @@ -17,6 +17,7 @@ public function __construct( ) { } + public function fromData( string $id, DateTimeImmutable $expiryDateTime, @@ -33,6 +34,7 @@ public function fromData( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/src/Factories/Entities/UserEntityFactory.php b/src/Factories/Entities/UserEntityFactory.php index 9b2671f5..58ea9107 100644 --- a/src/Factories/Entities/UserEntityFactory.php +++ b/src/Factories/Entities/UserEntityFactory.php @@ -15,6 +15,7 @@ public function __construct( ) { } + public function fromData(string $identifier, array $claims = []): UserEntity { $createdAt = $updatedAt = $this->helpers->dateTime()->getUtc(); @@ -27,6 +28,7 @@ public function fromData(string $identifier, array $claims = []): UserEntity ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index dc42d81d..6499a26b 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -25,6 +25,7 @@ public function __construct( ) { } + /** * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError diff --git a/src/Factories/FormFactory.php b/src/Factories/FormFactory.php index 4012ae8f..7e17b147 100644 --- a/src/Factories/FormFactory.php +++ b/src/Factories/FormFactory.php @@ -21,6 +21,7 @@ public function __construct( ) { } + /** * @param class-string $classname Form classname * diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index 1a686881..9d957aeb 100644 --- a/src/Factories/Grant/AuthCodeGrantFactory.php +++ b/src/Factories/Grant/AuthCodeGrantFactory.php @@ -34,6 +34,7 @@ public function __construct( ) { } + /** * @throws \Exception */ diff --git a/src/Factories/Grant/ImplicitGrantFactory.php b/src/Factories/Grant/ImplicitGrantFactory.php index 458d564a..81996633 100644 --- a/src/Factories/Grant/ImplicitGrantFactory.php +++ b/src/Factories/Grant/ImplicitGrantFactory.php @@ -26,6 +26,7 @@ public function __construct( ) { } + public function build(): ImplicitGrant { return new ImplicitGrant( diff --git a/src/Factories/Grant/PreAuthCodeGrantFactory.php b/src/Factories/Grant/PreAuthCodeGrantFactory.php index 8ae9beab..27c961a8 100644 --- a/src/Factories/Grant/PreAuthCodeGrantFactory.php +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -34,6 +34,7 @@ public function __construct( ) { } + /** * @throws \Exception */ diff --git a/src/Factories/Grant/RefreshTokenGrantFactory.php b/src/Factories/Grant/RefreshTokenGrantFactory.php index 78adf5ef..4df6e713 100644 --- a/src/Factories/Grant/RefreshTokenGrantFactory.php +++ b/src/Factories/Grant/RefreshTokenGrantFactory.php @@ -24,6 +24,7 @@ public function __construct( ) { } + public function build(): RefreshTokenGrant { $refreshTokenGrant = new RefreshTokenGrant( diff --git a/src/Factories/JwksFactory.php b/src/Factories/JwksFactory.php index 692d9ee2..50ca67c6 100644 --- a/src/Factories/JwksFactory.php +++ b/src/Factories/JwksFactory.php @@ -20,6 +20,7 @@ public function __construct( ) { } + /** * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError diff --git a/src/Factories/JwsFactory.php b/src/Factories/JwsFactory.php index 3d543322..2def97c3 100644 --- a/src/Factories/JwsFactory.php +++ b/src/Factories/JwsFactory.php @@ -16,6 +16,7 @@ public function __construct( ) { } + public function build(): Jws { return new Jws( diff --git a/src/Factories/RequestObjectFactory.php b/src/Factories/RequestObjectFactory.php index 15bd91d5..bfa6a7f4 100644 --- a/src/Factories/RequestObjectFactory.php +++ b/src/Factories/RequestObjectFactory.php @@ -18,10 +18,11 @@ public function __construct( ) { } + /** * Builds a new RequestObject instance. * - * @return RequestObject + * @return \SimpleSAML\OpenID\RequestObject */ public function build(): RequestObject { diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 86b00b49..5e586d37 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -78,24 +78,26 @@ public function __construct( private readonly Core $core, private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, - private readonly ?FederationCache $federationCache = null, - private readonly ?ProtocolCache $protocolCache = null, private readonly QueryResponseMode $queryResponseMode, private readonly FragmentResponseMode $fragmentResponseMode, private readonly FormPostResponseMode $formPostResponseMode, + private readonly ?FederationCache $federationCache = null, + private readonly ?ProtocolCache $protocolCache = null, ) { } + /** * @param \SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface[]|null $rules * @return \SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager */ public function build(?array $rules = null): RequestRulesManager { - $rules = $rules ?? $this->getDefaultRules(); + $rules ??= $this->getDefaultRules(); return new RequestRulesManager($rules, $this->logger); } + /** * @return \SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface[] */ diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 8d878b79..26e7ee32 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -17,10 +17,14 @@ class TemplateFactory { protected bool $showMenu = true; + protected bool $includeDefaultMenuItems = true; + protected bool $showModuleName = true; + protected bool $showSubPageTitle = true; + public function __construct( protected readonly Configuration $sspConfiguration, protected readonly ModuleConfig $moduleConfig, @@ -31,6 +35,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -93,6 +98,7 @@ public function build( return $template; } + protected function includeDefaultMenuItems(): void { $this->oidcMenu->addItem( @@ -173,18 +179,21 @@ protected function includeDefaultMenuItems(): void ); } + public function setShowMenu(bool $showMenu): TemplateFactory { $this->showMenu = $showMenu; return $this; } + public function setIncludeDefaultMenuItems(bool $includeDefaultMenuItems): TemplateFactory { $this->includeDefaultMenuItems = $includeDefaultMenuItems; return $this; } + public function setActiveHrefPath(?string $activeHrefPath): TemplateFactory { $this->oidcMenu->setActiveHrefPath( @@ -193,17 +202,20 @@ public function setActiveHrefPath(?string $activeHrefPath): TemplateFactory return $this; } + public function getActiveHrefPath(): ?string { return $this->oidcMenu->getActiveHrefPath(); } + public function setShowModuleName(bool $showModuleName): ?TemplateFactory { $this->showModuleName = $showModuleName; return $this; } + public function setShowSubPageTitle(bool $showSubPageTitle): TemplateFactory { $this->showSubPageTitle = $showSubPageTitle; diff --git a/src/Factories/TokenResponseFactory.php b/src/Factories/TokenResponseFactory.php index 839c8d61..5b3a20c8 100644 --- a/src/Factories/TokenResponseFactory.php +++ b/src/Factories/TokenResponseFactory.php @@ -22,6 +22,7 @@ public function __construct( ) { } + public function build(): TokenResponse { $tokenResponse = new TokenResponse( diff --git a/src/Factories/TokenStatusListFactory.php b/src/Factories/TokenStatusListFactory.php index ddc24a8b..ffc2bf76 100644 --- a/src/Factories/TokenStatusListFactory.php +++ b/src/Factories/TokenStatusListFactory.php @@ -18,6 +18,7 @@ public function __construct( ) { } + /** * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError diff --git a/src/Factories/VerifiableCredentialsFactory.php b/src/Factories/VerifiableCredentialsFactory.php index 869af3a3..27a46fe6 100644 --- a/src/Factories/VerifiableCredentialsFactory.php +++ b/src/Factories/VerifiableCredentialsFactory.php @@ -16,6 +16,7 @@ public function __construct( ) { } + /** * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index 8230e241..b3ff729d 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Forms; +use JsonException; use Nette\Forms\Form; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Bridges\SspBridge; @@ -67,6 +68,7 @@ public function __construct( $this->buildForm(); } + public function validateRedirectUri(Form $form): void { $values = $form->getValues(self::TYPE_ARRAY); @@ -79,6 +81,7 @@ public function validateRedirectUri(Form $form): void ); } + public function validateAllowedOrigin(Form $form): void { $values = $form->getValues(self::TYPE_ARRAY); @@ -91,6 +94,7 @@ public function validateAllowedOrigin(Form $form): void ); } + public function validatePostLogoutRedirectUri(Form $form): void { $values = $form->getValues(self::TYPE_ARRAY); @@ -103,6 +107,7 @@ public function validatePostLogoutRedirectUri(Form $form): void ); } + public function validateBackChannelLogoutUri(Form $form): void { /** @var ?string $bclUri */ @@ -116,6 +121,7 @@ public function validateBackChannelLogoutUri(Form $form): void } } + public function validateEntityIdentifier(Form $form): void { /** @var ?string $entityIdentifier */ @@ -129,6 +135,7 @@ public function validateEntityIdentifier(Form $form): void } } + public function validateClientRegistrationTypes(Form $form): void { /** @var ?string[] $clientRegistrationTypes */ @@ -142,16 +149,19 @@ public function validateClientRegistrationTypes(Form $form): void } } + public function validateFederationJwks(Form $form): void { $this->validateJwks($form->getValues()['federation_jwks'] ?? null); } + public function validateProtocolJwks(Form $form): void { $this->validateJwks($form->getValues()['jwks'] ?? null); } + public function validateJwksUri(Form $form): void { /** @var string[] $uris */ @@ -194,6 +204,7 @@ public function validateRequestUris(Form $form): void } } + /** * Validate the per-client Authentication Processing Filters. The value is * expected to be a JSON object/array in the same shape as the global @@ -239,6 +250,7 @@ public function validateAuthProcFilters(Form $form): void } } + /** * Cast integer-like string array keys to int, leaving all other keys (and * the values) untouched. Only the top level is processed, which is where @@ -260,6 +272,7 @@ protected function castNumericKeysToInt(array $array): array return $result; } + public function validateJwks(mixed $jwks): void { if (is_null($jwks)) { @@ -281,6 +294,7 @@ public function validateJwks(mixed $jwks): void } } + /** * @param string[] $values * @param non-empty-string $regex @@ -297,6 +311,7 @@ protected function validateByMatchingRegex( } } + public function getValues(string|object|bool|null $returnType = null, ?array $controls = null): array { $values = parent::getValues(self::TYPE_ARRAY); @@ -353,7 +368,7 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $values['federation_jwks'] = empty($federationJwks) ? null : json_decode($federationJwks, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { + } catch (JsonException $e) { $this->addError('Federation JSON error: ' . $e->getMessage()); $values['federation_jwks'] = null; } @@ -364,7 +379,7 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $values['jwks'] = empty($jwks) ? null : json_decode($jwks, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { + } catch (JsonException $e) { $this->addError('JWKS JSON error: ' . $e->getMessage()); $values['jwks'] = null; } @@ -484,7 +499,7 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = is_array($decodedAuthProcFilters) ? $this->castNumericKeysToInt($decodedAuthProcFilters) : $decodedAuthProcFilters; - } catch (\JsonException $e) { + } catch (JsonException $e) { $this->addError('Authentication Processing Filters JSON error: ' . $e->getMessage()); $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = []; } @@ -492,6 +507,7 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co return $values; } + /** * @throws \Exception */ @@ -624,6 +640,7 @@ public function setDefaults(object|array $values, bool $erase = false): static return $this; } + /** * @throws \Exception */ @@ -800,6 +817,7 @@ protected function buildForm(): void )->setHtmlAttribute('class', 'full-width'); } + /** * Validate provided response modes * @@ -820,6 +838,7 @@ public function validateResponseModes(Form $form): void } } + /** * ID Token signing algorithms the OP can actually sign with, i.e., those * for which a protocol signing key pair is configured (the same set @@ -835,6 +854,7 @@ protected function getSupportedIdTokenSigningAlgs(): array return $this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllAlgorithmNamesUnique(); } + /** * @return string[] map of value => label */ @@ -844,6 +864,7 @@ protected function getAllowedResponseModesValues(): array return array_combine($supported, $supported); } + /** * Grant types the client may be registered to use (value => label), matching the OP's * grant_types_supported. @@ -857,6 +878,7 @@ protected function getSupportedGrantTypes(): array return array_combine($supported, $supported); } + /** * Response types the client may be registered to use (value => label), matching the OP's * response_types_supported. @@ -870,6 +892,7 @@ protected function getSupportedResponseTypes(): array return array_combine($supported, $supported); } + /** * Token endpoint authentication methods the client may be registered to use (value => label). * @@ -882,6 +905,7 @@ protected function getSupportedTokenEndpointAuthMethods(): array return array_combine($supported, $supported); } + /** * The OP's supported ACR values (value => label), as configured via OPTION_AUTH_ACR_VALUES_SUPPORTED and * advertised in discovery as acr_values_supported. Empty when the OP advertises no ACRs. @@ -896,6 +920,7 @@ protected function getSupportedAcrValues(): array return array_combine($supported, $supported); } + /** * Whether the OP has any supported ACR values configured. Used by the template to hide the per-client * default_acr_values field when there is nothing to select. @@ -905,6 +930,7 @@ public function hasConfiguredAcrValues(): bool return $this->getSupportedAcrValues() !== []; } + /** * JSON map of response_type => required grant_types, restricted to the response types this OP offers. Consumed * by the admin-form JavaScript to live-select the corresponding grant types, sharing the single source of truth @@ -920,6 +946,7 @@ public function getResponseTypeGrantTypeMapJson(): string return (string)json_encode($map, JSON_UNESCAPED_SLASHES); } + /** * Application types the client may register (value => label). * @@ -935,6 +962,7 @@ protected function getSupportedApplicationTypes(): array return array_combine($supported, $supported); } + /** * @throws \Exception */ @@ -946,6 +974,7 @@ protected function getScopes(): array ); } + /** * @return string[] */ diff --git a/src/Forms/Controls/CsrfProtection.php b/src/Forms/Controls/CsrfProtection.php index 99e10dca..2f05b942 100644 --- a/src/Forms/Controls/CsrfProtection.php +++ b/src/Forms/Controls/CsrfProtection.php @@ -48,6 +48,7 @@ public function __construct(string|Stringable|null $errorMessage, protected Sess ->addRule(self::PROTECTION, $errorMessage); } + /** * @throws \Exception */ diff --git a/src/Forms/CredentialStatusForm.php b/src/Forms/CredentialStatusForm.php index b286380a..3a38a893 100644 --- a/src/Forms/CredentialStatusForm.php +++ b/src/Forms/CredentialStatusForm.php @@ -34,6 +34,7 @@ class CredentialStatusForm extends Form final public const string FIELD_STATUS = 'status'; + /** * @throws \Exception */ @@ -48,6 +49,7 @@ public function __construct( $this->buildForm(); } + /** * The statuses this form accepts, as submitted value to label. * @@ -64,6 +66,7 @@ public static function statusOptions(): array return $options; } + /** * Wording an administrator can act on, rather than the specification's own terms. * @@ -80,6 +83,7 @@ public static function labelFor(StatusTypeEnum $status): string }; } + /** * @throws \Exception */ diff --git a/src/Helpers.php b/src/Helpers.php index 5a55e766..d4eec17d 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -15,18 +15,26 @@ class Helpers { protected static ?Http $http = null; + protected static ?Client $client = null; + protected static ?DateTime $dateTIme = null; + protected static ?Str $str = null; + protected static ?Arr $arr = null; + protected static ?Random $random = null; + protected static ?Scope $scope = null; + public function http(): Http { return static::$http ??= new Http(); } + public function client(): Client { return static::$client ??= new Client( @@ -34,26 +42,31 @@ public function client(): Client ); } + public function dateTime(): DateTime { return static::$dateTIme ??= new DateTime(); } + public function str(): Str { return static::$str ??= new Str(); } + public function arr(): Arr { return static::$arr ??= new Arr(); } + public function random(): Random { return static::$random ??= new Random(); } + public function scope(): Scope { return static::$scope ??= new Scope(); diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php index 96cadff8..a05bdbb5 100644 --- a/src/Helpers/Arr.php +++ b/src/Helpers/Arr.php @@ -23,6 +23,7 @@ public function findByCallback(array $arr, callable $fn): mixed return null; } + /** * @param array $values * @return string[] @@ -32,12 +33,14 @@ public function ensureStringValues(array $values): array return array_map(fn(mixed $value): string => (string)$value, $values); } + public function isValueOneOf(mixed $value, array $set): bool { $value = is_array($value) ? $value : [$value]; return !empty(array_intersect($value, $set)); } + public function isValueSubsetOf(mixed $value, array $superset): bool { $value = is_array($value) ? $value : [$value]; @@ -45,6 +48,7 @@ public function isValueSubsetOf(mixed $value, array $superset): bool return empty(array_diff($value, $superset)); } + public function isValueSupersetOf(mixed $value, array $subset): bool { $value = is_array($value) ? $value : [$value]; diff --git a/src/Helpers/Client.php b/src/Helpers/Client.php index a2afddbc..09e8a02b 100644 --- a/src/Helpers/Client.php +++ b/src/Helpers/Client.php @@ -15,6 +15,7 @@ public function __construct(protected Http $http) { } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException diff --git a/src/Helpers/DateTime.php b/src/Helpers/DateTime.php index 2813b208..10dccc9c 100644 --- a/src/Helpers/DateTime.php +++ b/src/Helpers/DateTime.php @@ -14,11 +14,13 @@ public function getUtc(string $time = 'now'): DateTimeImmutable return new DateTimeImmutable($time, new DateTimeZone('UTC')); } + public function getFromTimestamp(int $timestamp): DateTimeImmutable { return $this->getUtc()->setTimestamp($timestamp); } + public function getSecondsToExpirationTime(int $expirationTime): int { return $expirationTime - $this->getUtc()->getTimestamp(); diff --git a/src/Helpers/Http.php b/src/Helpers/Http.php index 511ba963..42db3d65 100644 --- a/src/Helpers/Http.php +++ b/src/Helpers/Http.php @@ -17,6 +17,7 @@ public function getAllRequestParams(ServerRequestInterface $request): array ); } + /** * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods @@ -39,6 +40,7 @@ public function getAllRequestParamsBasedOnAllowedMethods( }; } + /** * Extract a Bearer token from an Authorization header value (RFC 6750, * Section 2.1), or null if no (non-empty) Bearer token is present. The diff --git a/src/Helpers/Str.php b/src/Helpers/Str.php index 5df6e4aa..aaf97f5b 100644 --- a/src/Helpers/Str.php +++ b/src/Helpers/Str.php @@ -17,6 +17,7 @@ public function convertScopesStringToArray(string $scopes, string $delimiter = ' return array_filter(explode($delimiter, trim($scopes)), fn($scope) => !empty($scope)); } + /** * @param non-empty-string $pattern * @return string[] diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index b5e0b43b..b6c0c488 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -45,12 +45,19 @@ class ModuleConfig { final public const string MODULE_NAME = 'oidc'; + protected const string KEY_DESCRIPTION = 'description'; + public const string KEY_ALGORITHM = 'algorithm'; + public const string KEY_PRIVATE_KEY_FILENAME = 'private_key_filename'; + public const string KEY_PUBLIC_KEY_FILENAME = 'public_key_filename'; + public const string KEY_PRIVATE_KEY_PASSWORD = 'private_key_password'; + public const string KEY_KEY_ID = 'key_id'; + final public const string DEFAULT_FILE_NAME = 'module_oidc.php'; /** @@ -68,82 +75,144 @@ class ModuleConfig final public const int MINIMUM_STATUS_LIST_RETIREMENT_GRACE_SECONDS = 3600; final public const string OPTION_PKI_PRIVATE_KEY_PASSPHRASE = 'pass_phrase'; + final public const string DEFAULT_PKI_PRIVATE_KEY_FILENAME = 'oidc_module.key'; + final public const string DEFAULT_PKI_CERTIFICATE_FILENAME = 'oidc_module.crt'; + final public const string OPTION_TOKEN_AUTHORIZATION_CODE_TTL = 'authCodeDuration'; + final public const string OPTION_TOKEN_REFRESH_TOKEN_TTL = 'refreshTokenDuration'; + final public const string OPTION_TOKEN_ACCESS_TOKEN_TTL = 'accessTokenDuration'; + final public const string OPTION_ENCRYPTION_KEY = 'encryption_key'; + final public const string OPTION_AUTH_SOURCE = 'auth'; + final public const string OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE = 'useridattr'; + final public const string OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE = 'translate'; + final public const string OPTION_AUTH_CUSTOM_SCOPES = 'scopes'; + final public const string OPTION_AUTH_ACR_VALUES_SUPPORTED = 'acrValuesSupported'; + final public const string OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP = 'authSourcesToAcrValuesMap'; + final public const string OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION = 'forcedAcrValueForCookieAuthentication'; + final public const string OPTION_AUTH_PROCESSING_FILTERS = 'authproc.oidc'; + final public const string OPTION_CRON_TAG = 'cron_tag'; + final public const string OPTION_ADMIN_UI_PERMISSIONS = 'permissions'; + final public const string OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE = 'items_per_page'; + final public const string DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME = 'oidc_module_federation.key'; + final public const string DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME = 'oidc_module_federation.crt'; + final public const string OPTION_ISSUER = 'issuer'; + final public const string OPTION_FEDERATION_ENTITY_STATEMENT_DURATION = 'federation_entity_statement_duration'; + final public const string OPTION_FEDERATION_AUTHORITY_HINTS = 'federation_authority_hints'; + final public const string OPTION_ORGANIZATION_NAME = 'organization_name'; + final public const string OPTION_DISPLAY_NAME = 'display_name'; + final public const string OPTION_DESCRIPTION = 'description'; + final public const string OPTION_KEYWORDS = 'keywords'; + final public const string OPTION_CONTACTS = 'contacts'; + final public const string OPTION_LOGO_URI = 'logo_uri'; + final public const string OPTION_POLICY_URI = 'policy_uri'; + final public const string OPTION_INFORMATION_URI = 'information_uri'; + final public const string OPTION_ORGANIZATION_URI = 'organization_uri'; + final public const string OPTION_FEDERATION_ENABLED = 'federation_enabled'; + final public const string OPTION_FEDERATION_CACHE_ADAPTER = 'federation_cache_adapter'; + final public const string OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS = 'federation_cache_adapter_arguments'; + final public const string OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED = 'federation_cache_max_duration_for_fetched'; + final public const string OPTION_FEDERATION_TRUST_ANCHORS = 'federation_trust_anchors'; + final public const string OPTION_FEDERATION_TRUST_MARK_TOKENS = 'federation_trust_mark_tokens'; + final public const string OPTION_FEDERATION_DYNAMIC_TRUST_MARKS = 'federation_dynamic_trust_mark_tokens'; + final public const string OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS = 'federation_participation_limit_by_trust_marks'; + final public const string OPTION_FEDERATION_TRUST_MARK_STATUS_ENDPOINT_USAGE_POLICY = 'federation_trust_mark_status_endpoint_usage_policy'; + final public const string OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED = 'federation_cache_duration_for_produced'; + final public const string OPTION_FEDERATION_HTTP_CLIENT_OPTIONS = 'federation_http_client_options'; + final public const string OPTION_FEDERATION_MAX_TRUST_CHAIN_DEPTH = 'federation_max_trust_chain_depth'; + final public const string OPTION_FEDERATION_MAX_AUTHORITY_HINTS = 'federation_max_authority_hints'; + final public const string OPTION_FEDERATION_MAX_TRUST_CHAIN_FETCHES = 'federation_max_trust_chain_fetches'; + final public const string OPTION_FEDERATION_TRUST_CHAIN_RESOLVE_TIMEOUT = 'federation_trust_chain_resolve_timeout'; + final public const string OPTION_FEDERATION_MAX_FETCH_SIZE_BYTES = 'federation_max_fetch_size_bytes'; + final public const string OPTION_PROTOCOL_CACHE_ADAPTER = 'protocol_cache_adapter'; + final public const string OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS = 'protocol_cache_adapter_arguments'; + final public const string OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION = 'protocol_user_entity_cache_duration'; + final public const string OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION = 'protocol_client_entity_cache_duration'; + final public const string OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED = 'protocol_discover_show_claims_supported'; + final public const string OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS = 'protocol_http_client_options'; + final public const string OPTION_BACKCHANNEL_LOGOUT_HTTP_CLIENT_OPTIONS = 'backchannel_logout_http_client_options'; final public const string OPTION_VCI_ENABLED = 'vci_enabled'; + final public const string OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED = 'vci_credential_configurations_supported'; + final public const string OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = 'vci_user_attribute_to_credential_claim_path_map'; + final public const string OPTION_API_ENABLED = 'api_enabled'; + final public const string OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED = 'api_vci_credential_offer_endpoint_enabled'; + final public const string OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED = 'api_vci_credential_status_endpoint_enabled'; + final public const string OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED = 'api_oauth2_token_introspection_endpoint_enabled'; + final public const string OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS = 'api_oauth2_token_introspection_resource_server_client_ids'; + final public const string OPTION_API_TOKENS = 'api_tokens'; /** Optional key naming an API token, so that an audit trail can say who made a change. */ @@ -160,46 +229,80 @@ class ModuleConfig final public const int MAX_API_TOKEN_NAME_LENGTH = 191; final public const string OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME = 'users_email_attribute_name'; + final public const string OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP = 'auth_sources_to_users_email_attribute_name_map'; + final public const string OPTION_VCI_ISSUER_STATE_TTL = 'vci_issuer_state_ttl'; + final public const string OPTION_VCI_NONCE_TTL = 'vci_nonce_ttl'; + final public const string OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS = 'vci_allow_non_registered_clients'; + final public const string OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS = 'vci_allowed_redirect_uri_prefixes_for_non_registered_clients'; + final public const string OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; + final public const string OPTION_FEDERATION_SIGNATURE_KEY_PAIRS = 'federation_signature_key_pairs'; + final public const string OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; + final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; + final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; + final public const string OPTION_VCI_STATUS_LIST_ENABLED = 'vci_status_list_enabled'; + final public const string OPTION_VCI_STATUS_LIST_KEY_PROFILE = 'vci_status_list_key_profile'; + final public const string OPTION_VCI_STATUS_LIST_POOLS = 'vci_status_list_pools'; + final public const string OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE = 'vci_status_list_requests_per_minute'; + final public const string OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE = 'vci_status_list_retirement_grace'; + final public const string OPTION_VCI_STATUS_LIST_AUDIT_RETENTION = 'vci_status_list_audit_retention'; + final public const string OPTION_VCI_CREDENTIAL_TTLS = 'vci_credential_ttls'; + final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; + final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; + final public const string OPTION_DCR_INITIAL_ACCESS_TOKENS = 'dcr_initial_access_tokens'; + final public const string OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED = 'dcr_impersonation_protection_enabled'; + final public const string OPTION_DCR_DEFAULT_SCOPES = 'dcr_default_scopes'; + final public const string OPTION_DCR_REGISTERED_CLIENTS_ENABLED = 'dcr_registered_clients_enabled'; + final public const string OPTION_PAR_REQUEST_URI_TTL = 'par_request_uri_ttl'; + final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'require_pushed_authorization_requests'; + final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'require_signed_request_object'; + final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'request_uri_parameter_supported'; + final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = 'federation_request_uri_allowed_prefixes'; + final public const string OPTION_REQUEST_URI_FETCH_TIMEOUT = 'request_uri_fetch_timeout'; + final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'request_uri_max_size_bytes'; final public const string OPTION_OUTBOUND_ALLOWED_SCHEMES = 'outbound_allowed_schemes'; + final public const string OPTION_OUTBOUND_ALLOWED_HOSTS = 'outbound_allowed_hosts'; + final public const string OPTION_OUTBOUND_ALLOWED_CIDRS = 'outbound_allowed_cidrs'; + final public const string OPTION_OUTBOUND_ADDRESS_PINNING_MODE = 'outbound_address_pinning_mode'; + protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ self::KEY_DESCRIPTION => 'openid', @@ -222,23 +325,31 @@ class ModuleConfig ]; /** - * @var Configuration Module configuration instance created form module config file. + * @var \SimpleSAML\Configuration Module configuration instance created form module config file. */ private readonly Configuration $moduleConfig; + /** - * @var Configuration SimpleSAMLphp configuration instance. + * @var \SimpleSAML\Configuration SimpleSAMLphp configuration instance. */ private readonly Configuration $sspConfig; + protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $protocolSignatureKeyPairConfigBag = null; + protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; + protected ?SignatureKeyPairBag $vciSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $vciSignatureKeyPairConfigBag = null; + protected ?StatusListPoolBag $vciStatusListPoolBag = null; /** @var ?array Credential configuration ID to how long its credentials live. */ protected ?array $vciCredentialTtls = null; + /** * @throws \Exception */ @@ -258,6 +369,7 @@ public function __construct( $this->validate(); } + /** * @return void * @throws \Exception @@ -331,11 +443,13 @@ function (array $scope, string $name): void { } } + public function moduleName(): string { return self::MODULE_NAME; } + /** * Get SimpleSAMLphp Configuration (config.php) instance. */ @@ -344,6 +458,7 @@ public function sspConfig(): Configuration return $this->sspConfig; } + /** * Get module config Configuration instance. */ @@ -371,6 +486,7 @@ public function getIssuer(): string return $issuer; } + /** * Whether the issuer is explicitly configured. If it is not, getIssuer() derives it from the * current HTTP host, which means it can differ depending on how the OP is reached. @@ -382,6 +498,7 @@ public function isIssuerConfigured(): bool return $issuer !== null && $issuer !== ''; } + public function getAuthCodeDuration(): DateInterval { return new DateInterval( @@ -389,6 +506,7 @@ public function getAuthCodeDuration(): DateInterval ); } + public function getAccessTokenDuration(): DateInterval { return new DateInterval( @@ -396,6 +514,7 @@ public function getAccessTokenDuration(): DateInterval ); } + public function getRefreshTokenDuration(): DateInterval { return new DateInterval( @@ -403,6 +522,7 @@ public function getRefreshTokenDuration(): DateInterval ); } + public function getParRequestUriTtl(): DateInterval { return new DateInterval( @@ -410,16 +530,19 @@ public function getParRequestUriTtl(): DateInterval ); } + public function getRequirePushedAuthorizationRequests(): bool { return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS, false); } + public function getRequireSignedRequestObject(): bool { return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT, false); } + /** * Whether the OP supports passing the Request Object by reference using the https request_uri parameter * (JWT-Secured Authorization Request by reference / OpenID Federation Authentication Request by reference). @@ -430,6 +553,7 @@ public function getRequestUriParameterSupported(): bool return $this->config()->getOptionalBoolean(self::OPTION_REQUEST_URI_PARAMETER_SUPPORTED, true); } + /** * Allowed https request_uri prefixes for OpenID Federation candidates (clients not registered in storage, * or registered through OpenID Federation). For such clients the OP must fetch the Request Object before @@ -469,11 +593,13 @@ public function getFederationRequestUriAllowedPrefixes(): ?array return array_values(array_filter($value, 'is_string')); } + public function getRequestUriFetchTimeout(): int { return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_FETCH_TIMEOUT, 5); } + public function getRequestUriMaxSizeBytes(): int { return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_MAX_SIZE_BYTES, 102400); @@ -499,6 +625,7 @@ public function getOutboundAllowedSchemes(): array return array_values(array_filter($schemes, 'is_string')); } + /** * Hosts this deployment declares legitimate whatever they resolve to. * @@ -512,6 +639,7 @@ public function getOutboundAllowedHosts(): array return array_values(array_filter($hosts, 'is_string')); } + /** * Address ranges permitted alongside the public ones, as CIDR. * @@ -525,6 +653,7 @@ public function getOutboundAllowedCidrs(): array return array_values(array_filter($cidrs, 'is_string')); } + /** * How strictly to insist on connecting to the address that was validated. * @@ -559,6 +688,7 @@ public function getOutboundAddressPinningMode(): AddressPinningModeEnum ); } + /** * @throws \Exception */ @@ -567,6 +697,7 @@ public function getDefaultAuthSourceId(): string return $this->config()->getString(self::OPTION_AUTH_SOURCE); } + /** * Get the ordered list of candidate user identifier attributes. * @@ -589,6 +720,7 @@ public function getUserIdentifierAttributes(): array return array_values(array_filter($value, 'is_string')); } + /** * Returns the primary (first) configured user ID candidate. * @throws \SimpleSAML\Error\ConfigurationError @@ -600,6 +732,7 @@ public function getUserIdentifierAttribute(): string ?? throw new ConfigurationError('No user identifier attribute configured.'); } + public function getSupportedAlgorithms(): SupportedAlgorithms { return new SupportedAlgorithms( @@ -618,6 +751,7 @@ public function getSupportedAlgorithms(): SupportedAlgorithms ); } + public function getSupportedSerializers(): SupportedSerializers { return new SupportedSerializers( @@ -627,6 +761,7 @@ public function getSupportedSerializers(): SupportedSerializers ); } + /** * @return string[] */ @@ -639,6 +774,7 @@ public function getSupportedResponseModes(): array ]; } + /** * Response types a client may be registered to use. * @@ -657,6 +793,7 @@ public function getSupportedResponseTypes(): array ]; } + /** * Grant types a client may be registered to use. * @@ -675,6 +812,7 @@ public function getSupportedGrantTypes(): array ]; } + /** * Token endpoint authentication methods a client may be registered to use. * @@ -690,8 +828,9 @@ public function getSupportedTokenEndpointAuthMethods(): array ]; } + /** - * @throws ConfigurationError + * @throws \SimpleSAML\Error\ConfigurationError * @return non-empty-array */ public function getProtocolSignatureKeyPairs(): array @@ -705,6 +844,7 @@ public function getProtocolSignatureKeyPairs(): array return $signatureKeyPairs; } + /** * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion @@ -720,6 +860,7 @@ public function getProtocolSignatureKeyPairConfigBag(): SignatureKeyPairConfigBa ); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion @@ -735,6 +876,7 @@ public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag ->fromConfig($this->getProtocolSignatureKeyPairConfigBag()); } + /** * Get supported Authentication Context Class References (ACRs). * @@ -746,6 +888,7 @@ public function getAcrValuesSupported(): array return array_values($this->config()->getOptionalArray(self::OPTION_AUTH_ACR_VALUES_SUPPORTED, [])); } + /** * Get a map of auth sources and their supported ACRs * @@ -757,6 +900,7 @@ public function getAuthSourcesToAcrValuesMap(): array return $this->config()->getOptionalArray(self::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP, []); } + /** * @return null|string * @throws \Exception @@ -774,6 +918,7 @@ public function getForcedAcrValueForCookieAuthentication(): ?string return (string)$value; } + /** * @throws \Exception */ @@ -787,6 +932,7 @@ public function getScopes(): array ); } + /** * @throws \Exception */ @@ -795,6 +941,7 @@ public function getPrivateScopes(): array return $this->config()->getOptionalArray(self::OPTION_AUTH_CUSTOM_SCOPES, []); } + /** * Get the encryption key used to encrypt / decrypt artifacts like * authorization codes and refresh tokens. @@ -834,6 +981,7 @@ public function getEncryptionKey(): Key|string } } + /** * Whether a dedicated encryption key is configured. When it is not, getEncryptionKey() falls * back to the SimpleSAMLphp secret salt, which is used as a password from which the actual key @@ -849,6 +997,7 @@ public function isEncryptionKeyConfigured(): bool return $encryptionKey !== null && $encryptionKey !== ''; } + /** * Get the configured SAML attribute to OIDC claim translation table. * @@ -867,6 +1016,7 @@ public function getSamlToOidcTranslateTable(): array return $this->config()->getOptionalArray(self::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, []); } + /** * Get autproc filters defined in the OIDC configuration. * @@ -878,16 +1028,19 @@ public function getAuthProcFilters(): array return $this->config()->getOptionalArray(self::OPTION_AUTH_PROCESSING_FILTERS, []); } + public function getProtocolCacheAdapterClass(): ?string { return $this->config()->getOptionalString(self::OPTION_PROTOCOL_CACHE_ADAPTER, null); } + public function getProtocolCacheAdapterArguments(): array { return $this->config()->getOptionalArray(self::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS, []); } + /** * Get cache duration for user entities (user data). If not set in configuration, it will fall back to SSP session * duration. @@ -904,6 +1057,7 @@ public function getProtocolUserEntityCacheDuration(): DateInterval ); } + /** * Get cache duration for client entities (user data), with the given default * @@ -919,6 +1073,7 @@ public function getProtocolClientEntityCacheDuration(): DateInterval ); } + public function getProtocolDiscoveryShowClaimsSupported(): bool { return $this->config()->getOptionalBoolean( @@ -927,6 +1082,7 @@ public function getProtocolDiscoveryShowClaimsSupported(): bool ); } + /** * Guzzle HTTP client options for the protocol-layer outbound fetches performed by the `openid` library * (e.g. fetching a client's `jwks_uri` or a `request_uri`). The array is passed through verbatim to the @@ -944,6 +1100,7 @@ public function getProtocolHttpClientOptions(): array return $this->getHttpClientOptions(self::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS); } + /** * Guzzle HTTP client options for the outbound Back-Channel Logout requests sent to the Relying Parties' * `backchannel_logout_uri` endpoints. The array is merged over the handler's own defaults (which set a @@ -963,6 +1120,7 @@ public function getBackChannelLogoutHttpClientOptions(): array return $this->getHttpClientOptions(self::OPTION_BACKCHANNEL_LOGOUT_HTTP_CLIENT_OPTIONS); } + /** * Read a Guzzle HTTP client options array from the given config option. * @@ -994,6 +1152,7 @@ public function getFederationEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_FEDERATION_ENABLED, false); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion @@ -1017,6 +1176,7 @@ public function getFederationSignatureKeyPairBag(): SignatureKeyPairBag ->fromConfig($signatureKeyPairConfigBag); } + /** * @throws \Exception */ @@ -1030,6 +1190,7 @@ public function getFederationEntityStatementDuration(): DateInterval ); } + /** * @throws \Exception */ @@ -1043,6 +1204,7 @@ public function getFederationEntityStatementCacheDurationForProduced(): DateInte ); } + public function getFederationAuthorityHints(): ?array { $authorityHints = $this->config()->getOptionalArray( @@ -1053,6 +1215,7 @@ public function getFederationAuthorityHints(): ?array return empty($authorityHints) ? null : $authorityHints; } + public function getFederationTrustMarkTokens(): ?array { $trustMarks = $this->config()->getOptionalArray( @@ -1063,6 +1226,7 @@ public function getFederationTrustMarkTokens(): ?array return empty($trustMarks) ? null : $trustMarks; } + public function getFederationDynamicTrustMarks(): ?array { $dynamicTrustMarks = $this->config()->getOptionalArray( @@ -1073,6 +1237,7 @@ public function getFederationDynamicTrustMarks(): ?array return empty($dynamicTrustMarks) ? null : $dynamicTrustMarks; } + public function getOrganizationName(): ?string { return $this->config()->getOptionalString( @@ -1081,6 +1246,7 @@ public function getOrganizationName(): ?string ); } + public function getDisplayName(): ?string { return $this->config()->getOptionalString( @@ -1089,6 +1255,7 @@ public function getDisplayName(): ?string ); } + public function getDescription(): ?string { return $this->config()->getOptionalString( @@ -1097,6 +1264,7 @@ public function getDescription(): ?string ); } + /** * JSON array with one or more strings representing search keywords, tags, categories, or labels that * apply to this Entity. @@ -1114,9 +1282,10 @@ public function getKeywords(): ?array return null; } - return array_filter($keywords, fn($keyword) => is_string($keyword)); + return array_filter($keywords, 'is_string'); } + public function getContacts(): ?array { return $this->config()->getOptionalArray( @@ -1125,6 +1294,7 @@ public function getContacts(): ?array ); } + public function getLogoUri(): ?string { return $this->config()->getOptionalString( @@ -1133,6 +1303,7 @@ public function getLogoUri(): ?string ); } + public function getPolicyUri(): ?string { return $this->config()->getOptionalString( @@ -1141,6 +1312,7 @@ public function getPolicyUri(): ?string ); } + public function getInformationUri(): ?string { return $this->config()->getOptionalString( @@ -1149,6 +1321,7 @@ public function getInformationUri(): ?string ); } + public function getOrganizationUri(): ?string { return $this->config()->getOptionalString( @@ -1157,16 +1330,19 @@ public function getOrganizationUri(): ?string ); } + public function getFederationCacheAdapterClass(): ?string { return $this->config()->getOptionalString(self::OPTION_FEDERATION_CACHE_ADAPTER, null); } + public function getFederationCacheAdapterArguments(): array { return $this->config()->getOptionalArray(self::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS, []); } + public function getFederationCacheMaxDurationForFetched(): DateInterval { return new DateInterval( @@ -1174,6 +1350,7 @@ public function getFederationCacheMaxDurationForFetched(): DateInterval ); } + /** * Guzzle HTTP client options for the federation-layer outbound fetches performed by the `openid` library * (entity statements, subordinate listings, Trust Mark status). Kept separate from the protocol-layer @@ -1193,6 +1370,7 @@ public function getFederationHttpClientOptions(): array return $this->getHttpClientOptions(self::OPTION_FEDERATION_HTTP_CLIENT_OPTIONS); } + /** * Maximum number of hops from the leaf entity up to a Trust Anchor. Mirrors the `openid` library default; * the library clamps it to 1..20. @@ -1204,6 +1382,7 @@ public function getFederationMaxTrustChainDepth(): int return $this->config()->getOptionalInteger(self::OPTION_FEDERATION_MAX_TRUST_CHAIN_DEPTH, 9); } + /** * Maximum number of `authority_hints` honoured per entity, which is the branching factor of the trust * chain traversal. Mirrors the `openid` library default; the library clamps it to 1..12. @@ -1215,6 +1394,7 @@ public function getFederationMaxAuthorityHints(): int return $this->config()->getOptionalInteger(self::OPTION_FEDERATION_MAX_AUTHORITY_HINTS, 6); } + /** * Maximum number of entity statement fetches allowed for a single trust chain resolution. This, together * with the resolve timeout, is what actually bounds the work an anonymous request can trigger: depth and @@ -1227,6 +1407,7 @@ public function getFederationMaxTrustChainFetches(): int return $this->config()->getOptionalInteger(self::OPTION_FEDERATION_MAX_TRUST_CHAIN_FETCHES, 100); } + /** * Wall-clock deadline, in seconds, for a single trust chain resolution. Mirrors the `openid` library * default; clamped by it to 1..300. @@ -1238,6 +1419,7 @@ public function getFederationTrustChainResolveTimeout(): int return $this->config()->getOptionalInteger(self::OPTION_FEDERATION_TRUST_CHAIN_RESOLVE_TIMEOUT, 30); } + /** * Maximum response body size, in bytes, read for a federation fetch. Mirrors the `openid` library default. * @@ -1251,6 +1433,7 @@ public function getFederationMaxFetchSizeBytes(): int ); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -1265,6 +1448,7 @@ public function getFederationTrustAnchors(): array return $trustAnchors; } + /** * @return non-empty-array * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType @@ -1275,6 +1459,7 @@ public function getFederationTrustAnchorIds(): array return array_map('strval', array_keys($this->getFederationTrustAnchors())); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -1296,6 +1481,7 @@ public function getTrustAnchorJwksJson(string $trustAnchorId): ?string ); } + public function getFederationParticipationLimitByTrustMarks(): array { return $this->config()->getOptionalArray( @@ -1304,6 +1490,7 @@ public function getFederationParticipationLimitByTrustMarks(): array ); } + public function getFederationTrustMarkStatusEndpointUsagePolicy(): TrustMarkStatusEndpointUsagePolicyEnum { /** @psalm-suppress MixedAssignment */ @@ -1319,6 +1506,7 @@ public function getFederationTrustMarkStatusEndpointUsagePolicy(): TrustMarkStat return TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly; } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -1332,6 +1520,7 @@ public function getTrustMarksNeededForFederationParticipationFor(string $trustAn return $participationLimit; } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -1350,6 +1539,7 @@ public function getVciEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_VCI_ENABLED, false); } + /** * Whether new credentials get a Token Status List entry allocated to them. * @@ -1388,6 +1578,7 @@ public function getVciStatusListEnabled(): bool return true; } + /** * Whether the host SimpleSAMLphp can perform reads which bypass secondaries. * @@ -1399,6 +1590,7 @@ public static function hasPrimaryDatabaseReadCapability(): bool return method_exists(Database::class, self::SSP_PRIMARY_READ_METHOD); } + /** * How many Status List requests one client may make per minute, or 0 for no limit. * @@ -1430,6 +1622,7 @@ public function getVciStatusListRequestsPerMinute(): int return $configured; } + /** * How long a Status List is left alone before it may be retired. * @@ -1481,6 +1674,7 @@ public function getVciStatusListRetirementGrace(): DateInterval return $grace; } + /** * How long rows in the status audit trail are kept, or null to keep them indefinitely. * @@ -1499,6 +1693,7 @@ public function getVciStatusListAuditRetention(): ?DateInterval ); } + /** * Reads an option which is a duration, is allowed to be absent, and has to be a length of time. * @@ -1555,6 +1750,7 @@ protected function resolveDurationOption(string $option, mixed $value): ?DateInt return $duration; } + /** * Key profile used for Status List Tokens which do not have one set on their own pool. * @@ -1593,6 +1789,7 @@ public function getVciStatusListKeyProfile(): StatusListKeyProfileEnum ); } + /** * The configured Status List pools. * @@ -1637,6 +1834,7 @@ public function getVciStatusListPoolBag(): StatusListPoolBag return $this->vciStatusListPoolBag = $poolBag; } + /** * The pool a credential configuration allocates Status List entries from, or null if it is not * configured to use them, in which case its credentials are issued without a `status` claim. @@ -1652,6 +1850,7 @@ public function getVciStatusListPoolFor(string $credentialConfigurationId): ?Sta return $this->getVciStatusListPoolBag()->getForCredentialConfigurationId($credentialConfigurationId); } + /** * How long credentials of each configuration remain valid. * @@ -1701,6 +1900,7 @@ public function getVciCredentialTtls(): array return $this->vciCredentialTtls = $ttls; } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -1750,6 +1950,7 @@ protected function resolveCredentialTtl(string $credentialConfigurationId, mixed return $ttl; } + /** * How long a credential of this configuration is valid for, or null if it does not expire. * @@ -1760,6 +1961,7 @@ public function getVciCredentialTtlFor(string $credentialConfigurationId): ?Date return $this->getVciCredentialTtls()[$credentialConfigurationId] ?? null; } + /** * Which Status List expiry lanes a pool would currently allocate into. * @@ -1817,6 +2019,7 @@ public function getDcrEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_DCR_ENABLED, false); } + /** * Access-control mode for the registration endpoint: open registration * (default) or gated behind an Initial Access Token. @@ -1831,6 +2034,7 @@ public function getDcrRegistrationAuth(): DcrRegistrationAuthEnum ); } + /** * Static allowlist of opaque Initial Access Tokens accepted by the * registration endpoint when the access mode is @@ -1854,6 +2058,7 @@ public function getDcrInitialAccessTokens(): array return $stringTokens; } + /** * Whether impersonation protection (OIDC Dynamic Client Registration 1.0, * Section 9.1) is enforced. When on (default), the host of `logo_uri`, @@ -1865,6 +2070,7 @@ public function getDcrImpersonationProtectionEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED, true); } + /** * Whether a client registered through Dynamic Client Registration (RFC 7591 / OIDC DCR) is created enabled and * therefore immediately usable. When `true` (default) a dynamically registered client can be used right away. @@ -1878,6 +2084,7 @@ public function getDcrRegisteredClientsEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_DCR_REGISTERED_CLIENTS_ENABLED, true); } + /** * Scopes assigned to a Dynamic Client Registration (DCR) client that registers without an explicit `scope`. * OpenID Connect Dynamic Client Registration 1.0 makes `scope` OPTIONAL and lets the OP assign a default set; @@ -1908,7 +2115,7 @@ public function getDcrDefaultScopes(): array /** - * @throws ConfigurationError + * @throws \SimpleSAML\Error\ConfigurationError * @return non-empty-array */ public function getVciSignatureKeyPairs(): array @@ -1939,6 +2146,7 @@ public function getVciSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag ); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment, ArgumentTypeCoercion @@ -1954,6 +2162,7 @@ public function getVciSignatureKeyPairBag(): SignatureKeyPairBag ->fromConfig($this->getVciSignatureKeyPairConfigBag()); } + /** * The Verifiable Credential Issuance key pair which is currently signing. * @@ -1980,11 +2189,13 @@ public function getActiveVciSignatureKeyPair(): SignatureKeyPair return $this->getVciSignatureKeyPairBag()->getFirstOrFail(); } + public function getVciCredentialConfigurationsSupported(): array { return $this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); } + /** * @param string $credentialConfigurationId * @return mixed[]|null @@ -2011,6 +2222,7 @@ public function getVciCredentialConfiguration(string $credentialConfigurationId) return $credentialConfiguration; } + /** * @return array */ @@ -2022,6 +2234,7 @@ public function getVciCredentialConfigurationIdsSupported(): array ); } + /** * Helper function to get the credential configuration IDs in a format suitable for creating ScopeEntity instances. * Returns an empty array if VCI is not enabled. @@ -2041,6 +2254,7 @@ public function getVciScopes(): array return $vciScopes; } + public function getVciCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string { foreach ( @@ -2067,6 +2281,7 @@ public function getVciCredentialConfigurationIdForCredentialDefinitionType(array return null; } + /** * Extract and parse the claims path definition from the credential configuration supported. * Returns an array of valid paths for the claims. @@ -2101,11 +2316,13 @@ public function getVciValidCredentialClaimPathsFor(string $credentialConfigurati return array_filter($validPaths); } + public function getVciUserAttributeToCredentialClaimPathMap(): array { return $this->config()->getOptionalArray(self::OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []); } + public function getVciUserAttributeToCredentialClaimPathMapFor(string $credentialConfigurationId): array { /** @psalm-suppress MixedAssignment */ @@ -2118,10 +2335,11 @@ public function getVciUserAttributeToCredentialClaimPathMapFor(string $credentia return []; } + /** * Get Issuer State Duration (TTL) if set. If not set, it will fall back to Authorization Code Duration. * - * @return DateInterval + * @return \DateInterval * @throws \Exception */ public function getVciIssuerStateDuration(): DateInterval @@ -2137,11 +2355,12 @@ public function getVciIssuerStateDuration(): DateInterval ); } + /** * Get Nonce TTL (validity duration) used for VCI proof-of-possession * nonces. If not set, it defaults to 5 minutes. * - * @return DateInterval + * @return \DateInterval * @throws \Exception */ public function getVciNonceTtl(): DateInterval @@ -2155,11 +2374,13 @@ public function getVciNonceTtl(): DateInterval return new DateInterval($nonceTtl); } + public function getVciAllowNonRegisteredClients(): bool { return $this->config()->getOptionalBoolean(self::OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS, false); } + public function getVciAllowedRedirectUriPrefixesForNonRegisteredClients(): array { return $this->config()->getOptionalArray( @@ -2180,6 +2401,7 @@ public function getVciCredentialJsonLdContext(): array return $this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT, []); } + /** * Get the JSON-LD context document (as a PHP array) configured for a * specific credential configuration ID. @@ -2209,11 +2431,13 @@ public function getApiEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_API_ENABLED, false); } + public function getApiVciCredentialOfferEndpointEnabled(): bool { return $this->config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED, false); } + /** * Whether the endpoint through which a credential's status can be changed is served. * @@ -2226,11 +2450,13 @@ public function getApiVciCredentialStatusEndpointEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED, false); } + public function getApiOAuth2TokenIntrospectionEndpointEnabled(): bool { return $this->config()->getOptionalBoolean(self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, false); } + /** * Clients allowed to introspect tokens issued to any client, and not only to themselves. * @@ -2254,6 +2480,7 @@ public function getApiOAuth2TokenIntrospectionResourceServerClientIds(): array ); } + /** * @return mixed[]|null */ @@ -2262,6 +2489,7 @@ public function getApiTokens(): ?array return $this->config()->getOptionalArray(self::OPTION_API_TOKENS, null); } + /** * @param string $token * @return mixed[] @@ -2304,6 +2532,7 @@ public function getApiTokenScopes(string $token): ?array return $positionalScopes === [] ? null : $positionalScopes; } + /** * The name an API token is configured under, or null when it has none. * @@ -2349,6 +2578,7 @@ public function getApiTokenName(string $token): ?string return $name; } + /** * Whether an API token entry is written as a settings array rather than as a bare list of scopes. * @@ -2364,11 +2594,13 @@ protected function isApiTokenSettingsShape(array $entry): bool is_string($entry[self::KEY_API_TOKEN_NAME] ?? null); } + public function getAuthSourcesToUsersEmailAttributeMap(): array { return $this->config()->getOptionalArray(self::OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP, []); } + public function getUsersEmailAttributeNameForAuthSourceId(string $authSource): string { /** @psalm-suppress MixedAssignment */ @@ -2381,11 +2613,13 @@ public function getUsersEmailAttributeNameForAuthSourceId(string $authSource): s return $this->getDefaultUsersEmailAttributeName(); } + public function getDefaultUsersEmailAttributeName(): string { return $this->config()->getOptionalString(self::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME, 'mail'); } + /** * @return array{ * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, @@ -2394,7 +2628,7 @@ public function getDefaultUsersEmailAttributeName(): string * private_key_password: ?non-empty-string, * key_id: ?non-empty-string * } - * @throws ConfigurationError * + * @throws \SimpleSAML\Error\ConfigurationError * */ public function getValidatedSignatureKeyPairArray(mixed $signatureKeyPair): array { @@ -2489,8 +2723,9 @@ public function getValidatedSignatureKeyPairArray(mixed $signatureKeyPair): arra ]; } + /** - * @throws ConfigurationError + * @throws \SimpleSAML\Error\ConfigurationError * @psalm-suppress MixedAssignment */ protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): SignatureKeyPairConfigBag @@ -2499,7 +2734,7 @@ protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): Signa foreach ($signatureKeyPairs as $signatureKeyPair) { /** - * @var SignatureAlgorithmEnum $algorithm + * @var \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum $algorithm * @var non-empty-string $privateKeyFilename * @var non-empty-string $publicKeyFilename * @var ?non-empty-string $privateKeyPassword @@ -2527,6 +2762,7 @@ protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): Signa return $signatureKeyPairConfigBag; } + public function getTimestampValidationLeeway(): DateInterval { return new DateInterval( diff --git a/src/Repositories/AbstractDatabaseRepository.php b/src/Repositories/AbstractDatabaseRepository.php index 2c77d135..1686e97b 100644 --- a/src/Repositories/AbstractDatabaseRepository.php +++ b/src/Repositories/AbstractDatabaseRepository.php @@ -25,6 +25,7 @@ abstract class AbstractDatabaseRepository */ protected const int MAX_BOUND_VARIABLES = 999; + /** * ClientRepository constructor. * @throws \Exception @@ -36,6 +37,7 @@ public function __construct( ) { } + public function getCacheKey(string $identifier): string { return is_string($tableName = $this->getTableName()) ? @@ -43,6 +45,7 @@ public function getCacheKey(string $identifier): string $identifier; } + /** * How many rows a batched statement can name before it has to be split. * @@ -61,5 +64,6 @@ protected function maxRowsPerStatement(int $perRow, int $fixed = 0): int return max(1, intdiv(self::MAX_BOUND_VARIABLES - $fixed, $perRow)); } + abstract public function getTableName(): ?string; } diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 213199f6..062f83bb 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -37,11 +37,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -75,6 +77,7 @@ public function getNewToken( ); } + /** * {@inheritdoc} * @throws \JsonException @@ -134,6 +137,7 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo ); } + /** * Find Access Token by id. * @throws \Exception @@ -175,6 +179,7 @@ public function findById(string $tokenId): ?AccessTokenEntity return $accessTokenEntity; } + /** * {@inheritdoc} * @throws \JsonException @@ -192,6 +197,7 @@ public function revokeAccessToken(string $tokenId): void $this->update($accessToken); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -208,6 +214,7 @@ public function revokeByAuthCodeId(string $authCodeId): void } } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -223,6 +230,7 @@ public function isAccessTokenRevoked(string $tokenId): bool return $accessToken->isRevoked(); } + /** * Removes expired access tokens. * @throws \Exception @@ -247,6 +255,7 @@ public function removeExpired(): void ); } + /** * @throws \JsonException */ @@ -275,6 +284,7 @@ private function update(AccessTokenEntity $accessTokenEntity): void ); } + protected function preparePdoState(array $state): array { $isRevoked = (bool)($state['is_revoked'] ?? true); diff --git a/src/Repositories/AllowedOriginRepository.php b/src/Repositories/AllowedOriginRepository.php index 9f76746d..d0e5d432 100644 --- a/src/Repositories/AllowedOriginRepository.php +++ b/src/Repositories/AllowedOriginRepository.php @@ -16,6 +16,7 @@ public function getTableName(): string return $this->database->applyPrefix(self::TABLE_NAME); } + /** * @param string[] $origins */ @@ -47,6 +48,7 @@ public function set(string $clientId, array $origins): void $this->database->write($stmt, $params); } + public function delete(string $clientId): void { $this->database->write( @@ -55,6 +57,7 @@ public function delete(string $clientId): void ); } + public function get(string $clientId): array { $stmt = $this->database->read( @@ -65,6 +68,7 @@ public function get(string $clientId): array return $stmt->fetchAll(PDO::FETCH_COLUMN, 0); } + public function has(string $origin): bool { // We only cache this method since it is used in authentication flow. @@ -90,6 +94,7 @@ public function has(string $origin): bool return $has; } + protected function clearCache(array $origins): void { /** @var string $origin */ diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index a527826c..1b940244 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -35,11 +35,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * @return \SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface */ @@ -48,6 +50,7 @@ public function getNewAuthCode(): AuthCodeEntityInterface throw new RuntimeException('Not implemented. Use AuthCodeEntityFactory instead.'); } + /** * {@inheritdoc} * @throws \JsonException @@ -110,6 +113,7 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity ); } + /** * Find Auth Code by id. * @throws \Exception @@ -150,6 +154,7 @@ public function findById(string $codeId): ?AuthCodeEntity return $authCodeEntity; } + /** * {@inheritdoc} * @throws \Exception @@ -167,6 +172,7 @@ public function revokeAuthCode(string $codeId): void $this->update($authCode); } + /** * Atomically consume a VCI pre-authorized code. * @@ -196,6 +202,7 @@ public function consumePreAuthorizedCode(string $codeId): bool return $affected === 1; } + /** * {@inheritdoc} * @throws \Exception @@ -211,6 +218,7 @@ public function isAuthCodeRevoked(string $codeId): bool return $authCode->isRevoked(); } + /** * Removes expired auth codes. * @throws \Exception @@ -225,6 +233,7 @@ public function removeExpired(): void ); } + /** * @throws \JsonException */ @@ -267,6 +276,7 @@ private function update(AuthCodeEntity $authCodeEntity): void ); } + protected function preparePdoState(array $state): array { $isRevoked = (bool)($state['is_revoked'] ?? true); diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index f407820f..4bc84483 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -28,11 +28,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * {@inheritdoc} * @throws \JsonException @@ -57,6 +59,7 @@ public function getClientEntity(string $clientIdentifier): ?OAuth2ClientEntityIn return $client; } + /** * @inheritDoc * @throws \JsonException @@ -77,6 +80,7 @@ public function validateClient(string $clientIdentifier, ?string $clientSecret, return true; } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -127,6 +131,7 @@ public function findById(string $clientIdentifier, ?string $owner = null): ?Clie return $clientEntity; } + public function findByEntityIdentifier(string $entityIdentifier, ?string $owner = null): ?ClientEntityInterface { /** @var ?array $cachedState */ @@ -177,6 +182,7 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner return $clientEntity; } + public function findFederatedByEntityIdentifier( string $entityIdentifier, ?string $owner = null, @@ -199,6 +205,7 @@ public function findFederatedByEntityIdentifier( return $clientEntity; } + private function addOwnerWhereClause(string $query, array $params, ?string $owner = null): array { if (isset($owner)) { @@ -212,6 +219,7 @@ private function addOwnerWhereClause(string $query, array $params, ?string $owne return [$query, $params]; } + /** * @return \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface[] * @throws \JsonException @@ -243,6 +251,7 @@ public function findAll(?string $owner = null): array return $clients; } + /** * @return \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface[] * @throws \JsonException @@ -282,6 +291,7 @@ public function findAllFederated(?string $owner = null): array return $clients; } + /** * @return array{ * numPages: int, @@ -313,7 +323,7 @@ public function findPaginated(int $page = 1, string $query = '', ?string $owner $params, ); - $clients = array_map(fn(array $state) => $this->clientEntityFactory->fromState($state), $stmt->fetchAll()); + $clients = array_map($this->clientEntityFactory->fromState(...), $stmt->fetchAll()); return [ 'numPages' => $numPages, @@ -322,6 +332,7 @@ public function findPaginated(int $page = 1, string $query = '', ?string $owner ]; } + public function add(ClientEntityInterface $client): void { $stmt = sprintf( @@ -403,6 +414,7 @@ public function add(ClientEntityInterface $client): void } } + public function delete(ClientEntityInterface $client, ?string $owner = null): void { /** @@ -424,6 +436,7 @@ public function delete(ClientEntityInterface $client, ?string $owner = null): vo } } + public function update(ClientEntityInterface $client, ?string $owner = null): void { $stmt = sprintf( @@ -487,6 +500,7 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo } } + private function count(string $query, ?string $owner): int { /** @@ -507,6 +521,7 @@ private function count(string $query, ?string $owner): int return (int) $stmt->fetchColumn(); } + /** * @throws \Exception */ @@ -516,6 +531,7 @@ private function getItemsPerPage(): int ->getOptionalIntegerRange(ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE, 1, 100, 20); } + private function calculateNumOfPages(int $total, int $limit): int { $numPages = (int)ceil($total / $limit); @@ -523,6 +539,7 @@ private function calculateNumOfPages(int $total, int $limit): int return max($numPages, 1); } + private function calculateCurrentPage(int $page, int $numPages): int { if ($page > $numPages) { @@ -536,11 +553,13 @@ private function calculateCurrentPage(int $page, int $numPages): int return $page; } + private function calculateOffset(int $page, int $limit): float|int { return ($page - 1) * $limit; } + protected function preparePdoState(array $state): array { $isEnabled = (bool)($state[ClientEntity::KEY_IS_ENABLED] ?? false); @@ -554,6 +573,7 @@ protected function preparePdoState(array $state): array return $state; } + public function getGenericForVci(): ClientEntityInterface { $client = $this->clientEntityFactory->getGenericForVci(); diff --git a/src/Repositories/CodeChallengeVerifiersRepository.php b/src/Repositories/CodeChallengeVerifiersRepository.php index ac0c700e..3f5016c1 100644 --- a/src/Repositories/CodeChallengeVerifiersRepository.php +++ b/src/Repositories/CodeChallengeVerifiersRepository.php @@ -18,6 +18,7 @@ class CodeChallengeVerifiersRepository */ protected array $codeChallengeVerifiers = []; + public function __construct() { if (in_array('sha256', hash_algos(), true)) { @@ -29,6 +30,7 @@ public function __construct() $this->codeChallengeVerifiers[$plainVerifier->getMethod()] = $plainVerifier; } + /** * @return \League\OAuth2\Server\CodeChallengeVerifiers\CodeChallengeVerifierInterface[] */ @@ -37,6 +39,7 @@ public function getAll(): array return $this->codeChallengeVerifiers; } + /** * @return \League\OAuth2\Server\CodeChallengeVerifiers\CodeChallengeVerifierInterface|null * Verifier for the method or null if not supported. @@ -46,6 +49,7 @@ public function get(string $method): ?CodeChallengeVerifierInterface return $this->codeChallengeVerifiers[$method] ?? null; } + public function has(string $method): bool { return isset($this->codeChallengeVerifiers[$method]); diff --git a/src/Repositories/Interfaces/AccessTokenRepositoryInterface.php b/src/Repositories/Interfaces/AccessTokenRepositoryInterface.php index c6ba6f16..adbd1731 100644 --- a/src/Repositories/Interfaces/AccessTokenRepositoryInterface.php +++ b/src/Repositories/Interfaces/AccessTokenRepositoryInterface.php @@ -15,6 +15,7 @@ interface AccessTokenRepositoryInterface extends OAuth2AccessTokenRepositoryInte */ public function revokeByAuthCodeId(string $authCodeId): void; + /** * Create a new access token * diff --git a/src/Repositories/Interfaces/IdentityProviderInterface.php b/src/Repositories/Interfaces/IdentityProviderInterface.php index 2ef813c8..26a64d64 100644 --- a/src/Repositories/Interfaces/IdentityProviderInterface.php +++ b/src/Repositories/Interfaces/IdentityProviderInterface.php @@ -11,7 +11,7 @@ * This file contains modified code from the 'steverhoades/oauth2-openid-connect-server' library * (https://github.com/steverhoades/oauth2-openid-connect-server), with original author, copyright notice and licence: * @author Steve Rhoades - * @copyright (c) 2018 Steve Rhoades + * @copyright (\SimpleSAML\Module\oidc\Repositories\Interfaces\c) 2018 Steve Rhoades * @license http://opensource.org/licenses/MIT MIT */ interface IdentityProviderInterface extends RepositoryInterface diff --git a/src/Repositories/Interfaces/RefreshTokenRepositoryInterface.php b/src/Repositories/Interfaces/RefreshTokenRepositoryInterface.php index cf30eb48..1f8193cc 100644 --- a/src/Repositories/Interfaces/RefreshTokenRepositoryInterface.php +++ b/src/Repositories/Interfaces/RefreshTokenRepositoryInterface.php @@ -14,6 +14,7 @@ interface RefreshTokenRepositoryInterface extends OAuth2RefreshTokenRepositoryIn */ public function revokeByAuthCodeId(string $authCodeId): void; + /** * Creates a new refresh token */ diff --git a/src/Repositories/IssuerStateRepository.php b/src/Repositories/IssuerStateRepository.php index df77f4fe..9d81cd0d 100644 --- a/src/Repositories/IssuerStateRepository.php +++ b/src/Repositories/IssuerStateRepository.php @@ -17,6 +17,7 @@ class IssuerStateRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_vci_issuer_state'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -27,11 +28,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + public function find(string $value): ?IssuerStateEntity { /** @var ?array $data */ @@ -66,6 +69,7 @@ public function find(string $value): ?IssuerStateEntity return $issuerState; } + public function findValid(string $value): ?IssuerStateEntity { $issuerState = $this->find($value); @@ -85,6 +89,7 @@ public function findValid(string $value): ?IssuerStateEntity return $issuerState; } + public function revoke(string $value): void { $issuerState = $this->find($value); @@ -97,6 +102,7 @@ public function revoke(string $value): void $this->update($issuerState); } + public function update(IssuerStateEntity $issuerState): void { $stmt = sprintf( @@ -127,6 +133,7 @@ public function update(IssuerStateEntity $issuerState): void ); } + public function persist(IssuerStateEntity $issuerState): void { $stmt = sprintf( @@ -154,6 +161,7 @@ public function persist(IssuerStateEntity $issuerState): void ); } + /** * Remove invalid issuer state entities (expired or revoked). * @return void @@ -179,6 +187,7 @@ public function removeInvalid(): void $this->database->write($stmt, $this->preparePdoState($data)); } + protected function preparePdoState(array $state): array { $isRevoked = (bool)($state['is_revoked'] ?? true); diff --git a/src/Repositories/PushedAuthorizationRequestRepository.php b/src/Repositories/PushedAuthorizationRequestRepository.php index c0d02be7..e73e01b1 100644 --- a/src/Repositories/PushedAuthorizationRequestRepository.php +++ b/src/Repositories/PushedAuthorizationRequestRepository.php @@ -17,6 +17,7 @@ class PushedAuthorizationRequestRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_par'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -27,11 +28,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * Persist the Pushed Authorization Request entity in the database. * @@ -54,6 +57,7 @@ public function persist(PushedAuthorizationRequestEntity $entity): void ); } + /** * Find Pushed Authorization Request entity by request_uri. * @@ -89,6 +93,7 @@ public function find(string $requestUri): ?PushedAuthorizationRequestEntity return $entity; } + /** * Find Pushed Authorization Request entity which is not consumed nor expired. * @@ -115,6 +120,7 @@ public function findValid(string $requestUri): ?PushedAuthorizationRequestEntity return $entity; } + /** * Mark the Pushed Authorization Request as consumed (one-time use). Atomic, * so it can be used as a replay guard: returns true only if this call was @@ -135,6 +141,7 @@ public function consume(string $requestUri): bool return is_int($affected) && $affected > 0; } + /** * Delete expired Pushed Authorization Request records. */ diff --git a/src/Repositories/RefreshTokenRepository.php b/src/Repositories/RefreshTokenRepository.php index d13dc182..5a46eb23 100644 --- a/src/Repositories/RefreshTokenRepository.php +++ b/src/Repositories/RefreshTokenRepository.php @@ -34,6 +34,7 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + /** * @return string */ @@ -42,6 +43,7 @@ public function getTableName(): string return $this->database->applyPrefix(self::TABLE_NAME); } + /** * {@inheritdoc} */ @@ -50,6 +52,7 @@ public function getNewRefreshToken(): ?RefreshTokenEntityInterface throw new RuntimeException('Not implemented. Use RefreshTokenEntityFactory instead.'); } + /** * {@inheritdoc} * @throws \League\OAuth2\Server\Exception\OAuthServerException @@ -80,6 +83,7 @@ public function persistNewRefreshToken(OAuth2RefreshTokenEntityInterface $refres ); } + /** * Find Refresh Token by id. * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -121,6 +125,7 @@ public function findById(string $tokenId): ?RefreshTokenEntityInterface return $refreshTokenEntity; } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -137,6 +142,7 @@ public function revokeRefreshToken(string $tokenId): void $this->update($refreshToken); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -152,6 +158,7 @@ public function revokeByAuthCodeId(string $authCodeId): void } } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -167,6 +174,7 @@ public function isRefreshTokenRevoked(string $tokenId): bool return $refreshToken->isRevoked(); } + /** * Removes expired refresh tokens. * @throws \Exception @@ -181,6 +189,7 @@ public function removeExpired(): void ); } + private function update(RefreshTokenEntityInterface $refreshTokenEntity): void { $stmt = sprintf( @@ -203,6 +212,7 @@ private function update(RefreshTokenEntityInterface $refreshTokenEntity): void ); } + protected function preparePdoState(array $state): array { $isRevoked = (bool)($state['is_revoked'] ?? true); diff --git a/src/Repositories/ScopeRepository.php b/src/Repositories/ScopeRepository.php index 4fc84588..b010521a 100644 --- a/src/Repositories/ScopeRepository.php +++ b/src/Repositories/ScopeRepository.php @@ -23,6 +23,7 @@ public function __construct( ) { } + /** * {@inheritdoc} * @throws \Exception @@ -52,6 +53,7 @@ public function getScopeEntityByIdentifier(string $identifier): ScopeEntity|Scop ); } + /** * {@inheritdoc} */ diff --git a/src/Repositories/StatusAuditRepository.php b/src/Repositories/StatusAuditRepository.php index c639c21a..db63b2e7 100644 --- a/src/Repositories/StatusAuditRepository.php +++ b/src/Repositories/StatusAuditRepository.php @@ -41,6 +41,7 @@ class StatusAuditRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_status_audit'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -50,11 +51,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * @param ?string $actorRef Who asked for the change: an API token principal's name, an * administrator's identifier, or null for an unattended one. Never the API token itself, which @@ -106,6 +109,7 @@ public function record( return $id; } + /** * Removes trail rows older than a cut-off, up to a bound. * @@ -187,6 +191,7 @@ public function removeOlderThan(DateTimeImmutable $createdBefore, int $limit): i return $removed; } + /** * Timestamps are stored without a zone and read back as UTC, so a moment is converted to UTC on the * way in rather than having its wall clock written as-is. A cut-off handed in as a local time would diff --git a/src/Repositories/StatusListEntryRepository.php b/src/Repositories/StatusListEntryRepository.php index 79f5ea91..00b70dcd 100644 --- a/src/Repositories/StatusListEntryRepository.php +++ b/src/Repositories/StatusListEntryRepository.php @@ -36,6 +36,7 @@ class StatusListEntryRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_status_list_entry'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -45,11 +46,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * The form a credential identifier is looked up by. * @@ -63,6 +66,7 @@ public function hashCredentialId(string $credentialId): string return hash('sha256', $credentialId); } + /** * Creates every index of a newly created list, unallocated and Valid. * @@ -105,6 +109,7 @@ public function seed(string $statusListId, int $capacity): void } } + /** * Claims one index for a credential, if it is still free and its list still accepts allocations of * this kind. @@ -189,6 +194,7 @@ public function allocate( return is_int($affected) && $affected > 0; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ @@ -202,6 +208,7 @@ public function findByCredentialIdHash(string $credentialIdHash): ?StatusListEnt ); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ @@ -218,6 +225,7 @@ public function findByListAndIdx(string $statusListId, int $idx): ?StatusListEnt ); } + /** * Moves an allocated entry from one status to another. * @@ -256,6 +264,7 @@ public function updateStatus( return is_int($affected) && $affected > 0; } + /** * Index to status for every entry which is not Valid, which is all a Status List needs in order to * be rebuilt: every index the query does not return is Valid, including the ones never allocated. @@ -297,6 +306,7 @@ public function findNonValidStatuses(string $statusListId): array return $statuses; } + public function countAllocated(string $statusListId): int { $rows = $this->readPrimary( @@ -314,6 +324,7 @@ public function countAllocated(string $statusListId): int return is_numeric($total) ? (int)$total : 0; } + /** * A page of issued credentials, newest first, for the administration screens. * @@ -399,6 +410,7 @@ public function findAllocatedPaginated( ]; } + /** * How many Status Lists hold a credential which never expires, and can therefore never be retired. * @@ -425,6 +437,7 @@ public function countNeverRetiringLists(): int return is_numeric($total) ? (int)$total : 0; } + /** * How many Status Lists hold an entry their expiry lane says they cannot. * @@ -474,6 +487,7 @@ public function countLaneMismatches(): int return is_numeric($total) ? (int)$total : 0; } + /** * Deletes the linkage of credentials which have expired, keeping the index and its status. * @@ -589,6 +603,7 @@ public function clearExpiredLinkage(DateTimeImmutable $expiredBefore, int $limit return $cleared; } + /** * Removes a bounded run of entries belonging to a list which has been retired. * @@ -645,6 +660,7 @@ public function deleteRetiredEntries(string $statusListId, int $limit): int return is_int($affected) ? $affected : 0; } + /** * @param array $params */ @@ -661,6 +677,7 @@ protected function countWhere(string $condition, array $params): int return is_numeric($total) ? (int)$total : 0; } + /** * @throws \Exception */ @@ -674,6 +691,7 @@ protected function getItemsPerPage(): int ); } + /** * @param array $rows * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -686,6 +704,7 @@ protected function buildRecord(array $rows): ?StatusListEntryRecord return is_array($row) ? StatusListEntryRecord::fromRow($row) : null; } + /** * @param array $params * @return array @@ -695,6 +714,7 @@ protected function readPrimary(string $statement, array $params = []): array return $this->database->readPrimary($statement, $params)->fetchAll(); } + /** * Timestamps are stored without a zone and read back as UTC, so a moment is converted to UTC on the * way in rather than having its wall clock written as-is. Without this, an expiry handed in as a diff --git a/src/Repositories/StatusListRepository.php b/src/Repositories/StatusListRepository.php index d5a36f09..48c00cde 100644 --- a/src/Repositories/StatusListRepository.php +++ b/src/Repositories/StatusListRepository.php @@ -33,6 +33,7 @@ class StatusListRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_status_list'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -42,11 +43,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * Reads a list for the purpose of serving it. * @@ -75,6 +78,7 @@ public function findById(string $id): ?StatusListRecord return $this->findByIdOnPrimary($id); } + /** * Reads a list for the purpose of deciding something about it. * @@ -90,6 +94,7 @@ public function findByIdOnPrimary(string $id): ?StatusListRecord ); } + /** * The lists a credential of this pool, policy and lane may currently be allocated into. * @@ -135,6 +140,7 @@ public function findActiveForPolicy( return $records; } + /** * Lists of this pool, policy and lane which exist but are not open for allocation yet, because * whichever request created them is still seeding their entries. @@ -203,6 +209,7 @@ public function findBeingPreparedForPolicy( return $records; } + /** * Removes a list this request created and then decided not to use. * @@ -254,6 +261,7 @@ public function deleteUnopened(string $id): bool return is_int($affected) && $affected > 0; } + /** * Highest generation used so far for this pool, policy and lane, or 0 when there is none yet. * @@ -285,6 +293,7 @@ public function getHighestGeneration( return is_numeric($highest) ? (int)$highest : 0; } + /** * Inserts a new list. * @@ -359,6 +368,7 @@ public function create( ); } + /** * Opens a freshly seeded list for allocation. * @@ -385,6 +395,7 @@ public function activate(string $id): bool return is_int($affected) && $affected > 0; } + /** * Stops a list accepting new allocations. * @@ -411,6 +422,7 @@ public function deactivate(string $id): bool return is_int($affected) && $affected > 0; } + /** * Bumps the advisory allocation counter. * @@ -431,6 +443,7 @@ public function incrementAllocatedCount(string $id): void ); } + /** * Marks the published token as no longer representing the list's content. * @@ -459,6 +472,7 @@ public function invalidatePublishedToken(string $id): void ); } + /** * Publishes a freshly signed token, provided the content it was signed over is still the content * which is published. @@ -534,6 +548,7 @@ public function publishToken( return is_int($affected) && $affected > 0; } + /** * Lists which have a published token, in batches, for the reconciler to check. * @@ -589,6 +604,7 @@ public function findPublished(int $limit, ?string $afterId = null): array return $candidates; } + /** * Invalidates a published token, but only while it is still the one which was examined. * @@ -628,6 +644,7 @@ public function invalidatePublishedTokenIfUnchanged( return is_int($affected) && $affected > 0; } + /** * Stops lists accepting allocations which they were never going to receive again anyway. * @@ -695,6 +712,7 @@ public function deactivateSuperseded(array $currentTargets): int return is_int($affected) ? $affected : 0; } + /** * Lists which stopped accepting allocations long enough ago to be worth examining for retirement. * @@ -765,6 +783,7 @@ public function findRetirementCandidates( ); } + /** * Stops a list being served, and gives back the token it was being served from. * @@ -826,6 +845,7 @@ public function retire(string $id, DateTimeImmutable $spentBefore): bool return is_int($affected) && $affected > 0; } + /** * Retired lists which still have entries behind them, and have been retired long enough for that to * be safe to act on. @@ -859,6 +879,7 @@ public function findRetiredWithEntries(int $limit, DateTimeImmutable $retiredBef ); } + /** * @param array $params * @return string[] @@ -880,6 +901,7 @@ protected function readIdentifiers(string $statement, array $params = []): array return $identifiers; } + /** * @param array $rows * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -892,6 +914,7 @@ protected function buildRecord(array $rows): ?StatusListRecord return is_array($row) ? StatusListRecord::fromRow($row) : null; } + /** * A read which is guaranteed not to come from a lagging secondary. * @@ -903,6 +926,7 @@ protected function readPrimary(string $statement, array $params = []): array return $this->database->readPrimary($statement, $params)->fetchAll(); } + /** * Timestamps are stored without a zone and read back as UTC, so a moment is converted to UTC on the * way in rather than having its wall clock written as-is. A value handed in as a local time would diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index 95de08b6..d2444d55 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -32,11 +32,13 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } + /** * @param string $identifier * @@ -82,6 +84,7 @@ public function getUserEntityByIdentifier(string $identifier): ?UserEntity return $userEntity; } + /** * {@inheritdoc} * @throws \Exception @@ -95,6 +98,7 @@ public function getUserEntityByUserCredentials( throw new Exception('Not supported'); } + public function add(UserEntity $userEntity): void { $stmt = sprintf( @@ -113,6 +117,7 @@ public function add(UserEntity $userEntity): void ); } + public function delete(UserEntity $userEntity): void { $this->database->write( @@ -125,6 +130,7 @@ public function delete(UserEntity $userEntity): void $this->protocolCache?->delete($this->getCacheKey($userEntity->getIdentifier())); } + public function update(UserEntity $userEntity, ?DateTimeImmutable $updatedAt = null): void { $userEntity->setUpdatedAt($updatedAt ?? $this->helpers->dateTime()->getUtc()); diff --git a/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php b/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php index c0274000..260c41d3 100644 --- a/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php +++ b/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php @@ -7,14 +7,29 @@ interface RelyingPartyAssociationInterface { public function getClientId(): string; + + public function setClientId(string $clientId): void; + + public function getUserId(): string; + + public function setUserId(string $userId): void; + + 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. * @@ -22,6 +37,7 @@ public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void; */ public function getClientIdTokenSignedResponseAlg(): ?string; + /** * Set id_token_signed_response_alg metadata parameter used by the client. * @param string|null $idTokenSignedResponseAlg diff --git a/src/Server/Associations/RelyingPartyAssociation.php b/src/Server/Associations/RelyingPartyAssociation.php index 5ce20b77..23d8a47c 100644 --- a/src/Server/Associations/RelyingPartyAssociation.php +++ b/src/Server/Associations/RelyingPartyAssociation.php @@ -20,51 +20,61 @@ public function __construct( ) { } + public function getClientId(): string { return $this->clientId; } + public function setClientId(string $clientId): void { $this->clientId = $clientId; } + public function getUserId(): string { return $this->userId; } + public function setUserId(string $userId): void { $this->userId = $userId; } + public function getSessionId(): ?string { return $this->sessionId; } + public function setSessionId(?string $sessionId): void { $this->sessionId = $sessionId; } + public function getBackChannelLogoutUri(): ?string { return $this->backChannelLogoutUri; } + 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/AuthorizationServer.php b/src/Server/AuthorizationServer.php index bbde4398..a26af746 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -48,6 +48,7 @@ class AuthorizationServer extends OAuth2AuthorizationServer */ protected CryptKeyInterface $publicKey; + /** * @inheritDoc */ @@ -78,6 +79,7 @@ public function __construct( $this->requestRulesManager = $requestRulesManager; } + /** * @inheritDoc * @throws \SimpleSAML\Error\BadRequest @@ -168,6 +170,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O throw OidcServerException::unsupportedResponseType($redirectUri, $state, $responseMode); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest diff --git a/src/Server/Exceptions/OidcServerException.php b/src/Server/Exceptions/OidcServerException.php index 5c3eac80..b2805e67 100644 --- a/src/Server/Exceptions/OidcServerException.php +++ b/src/Server/Exceptions/OidcServerException.php @@ -22,10 +22,11 @@ class OidcServerException extends OAuthServerException protected ?string $redirectUri = null; /** - * @var null|ResponseModeInterface + * @var null|\SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface */ protected ?ResponseModeInterface $responseMode = null; + private static function create( string $message, int $code, @@ -60,6 +61,7 @@ private static function create( return $exception; } + /** * Unsupported response type error. * @@ -89,6 +91,7 @@ public static function unsupportedResponseType( ); } + /** * Invalid scope error. * @@ -129,6 +132,7 @@ public static function invalidScope( return $e; } + /** * Invalid request error with redirect ability. * @@ -151,7 +155,7 @@ public static function invalidRequest( ): static { $errorMessage = 'The request is missing a required parameter, includes an invalid parameter value, ' . 'includes a parameter more than once, or is otherwise malformed.'; - $hint = ($hint === null) ? \sprintf('Check the `%s` parameter', $parameter) : $hint; + $hint ??= \sprintf('Check the `%s` parameter', $parameter); $e = self::create( $errorMessage, 9, @@ -167,6 +171,7 @@ public static function invalidRequest( return $e; } + /** * @param string|null $hint * @param string|null $redirectUri @@ -198,6 +203,7 @@ public static function accessDenied( return $e; } + /** * The authenticated client is not authorized to use this authorization grant type or response type * (RFC 6749 sections 4.1.2.1 / 5.2). @@ -228,6 +234,7 @@ public static function unauthorizedClient( ); } + /** * Prompt none requires that user should be authenticated. * @@ -263,6 +270,7 @@ public static function loginRequired( return $e; } + /** * Request object not supported. * @@ -298,6 +306,7 @@ public static function requestNotSupported( return $e; } + /** * Invalid refresh token. * @@ -312,6 +321,7 @@ public static function invalidRefreshToken(?string $hint = null, ?Throwable $pre return self::create('The refresh token is invalid.', 8, 'invalid_grant', 400, $hint, null, $previous); } + public static function invalidTrustChain( ?string $hint = null, ?string $redirectUri = null, @@ -336,6 +346,7 @@ public static function invalidTrustChain( return $e; } + /** * Forbidden request. * @@ -358,6 +369,7 @@ public static function forbidden(?string $hint = null, ?Throwable $previous = nu ); } + /** * Invalid client metadata error, as defined by the OAuth 2.0 Dynamic Client * Registration Protocol (RFC 7591, section 3.2.2) and OpenID Connect @@ -387,6 +399,7 @@ public static function invalidClientMetadata( ); } + /** * Invalid redirect URI error, as defined by the OAuth 2.0 Dynamic Client * Registration Protocol (RFC 7591, section 3.2.2) and OpenID Connect @@ -416,6 +429,7 @@ public static function invalidRedirectUri( ); } + /** * Returns the current payload. * @@ -426,6 +440,7 @@ public function getPayload(): array return parent::getPayload(); } + /** * Updates the current payload. * @@ -436,6 +451,7 @@ public function setPayload(array $payload): void parent::setPayload($payload); } + /** * @param string|null $redirectUri Set to string, or unset it with null */ @@ -444,6 +460,7 @@ public function setRedirectUri(?string $redirectUri = null): void $this->redirectUri = $redirectUri; } + /** * Check if the exception has an associated redirect URI. * @@ -459,6 +476,7 @@ public function hasRedirect(): bool return $this->redirectUri !== null; } + /** * Returns the Redirect URI used for redirecting. * @@ -469,6 +487,7 @@ public function getRedirectUri(): ?string return $this->redirectUri; } + /** * @param string|null $state Set to string, or unset it with null */ @@ -486,6 +505,7 @@ public function setState(?string $state = null): void $this->setPayload($payload); } + /** * Generate an HTTP response. * diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index a78f0c39..787583ab 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -97,17 +97,19 @@ class AuthCodeGrant extends OAuth2AuthCodeGrant implements { use IssueAccessTokenTrait; + protected DateInterval $authCodeTTL; /** @var \League\OAuth2\Server\CodeChallengeVerifiers\CodeChallengeVerifierInterface[] */ protected array $codeChallengeVerifiers = []; - /** @var HttpMethodsEnum[] */ + /** @var \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] */ protected array $allowedAuthorizationHttpMethods = [HttpMethodsEnum::GET, HttpMethodsEnum::POST]; - /** @var HttpMethodsEnum[] */ + /** @var \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] */ protected array $allowedTokenHttpMethods = [HttpMethodsEnum::POST]; + /** * @psalm-type AuthCodePayloadObject = object{ * scopes: null|string|array, @@ -154,6 +156,7 @@ public function __construct( $this->accessTokenEntityFactory = $accessTokenEntityFactory; } + /** * Reimplemented in order to support HTTP POST method. * @@ -175,6 +178,7 @@ public function canRespondToAuthorizationRequest(ServerRequestInterface $request && isset($requestParams['client_id'])); } + /** * Check if the authorization request is OIDC candidate (can respond with ID token). */ @@ -188,6 +192,7 @@ public function isOidcCandidate( ); } + /** * @inheritDoc * @throws \League\OAuth2\Server\Exception\OAuthServerException @@ -203,6 +208,7 @@ public function completeAuthorizationRequest( return parent::completeAuthorizationRequest($authorizationRequest); } + /** * This is reimplementation of OAuth2 completeAuthorizationRequest method with addition of nonce handling. * @@ -283,6 +289,7 @@ public function completeOidcAuthorizationRequest( return $response; } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException @@ -333,6 +340,7 @@ protected function issueOidcAuthCode( throw OAuthServerException::serverError('Could not issue OIDC Auth Code.'); } + /** * Get the client redirect URI if not set in the request. * @@ -352,6 +360,7 @@ protected function getAuthorizationRequestClientRedirectUri( return $redirectUri; } + /** * Reimplementation of respondToAccessTokenRequest because of features like nonce, private_key_jwt, acr... * @@ -395,10 +404,12 @@ public function respondToAccessTokenRequest( } try { + // phpcs:disable SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation /** * @noinspection PhpUndefinedClassInspection * @psalm-var AuthCodePayloadObject $authCodePayload */ + // phpcs:enable SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation $authCodePayload = json_decode($this->decrypt($encryptedAuthCode), null, 512, JSON_THROW_ON_ERROR); } catch (LogicException $e) { $this->loggerService->warning( @@ -647,9 +658,7 @@ public function respondToAccessTokenRequest( // } if (property_exists($authCodePayload, 'code_challenge_method')) { - $codeChallengeMethod = isset($authCodePayload->code_challenge_method) ? - $authCodePayload->code_challenge_method : - ''; + $codeChallengeMethod = $authCodePayload->code_challenge_method ?? ''; if (isset($this->codeChallengeVerifiers[$codeChallengeMethod])) { $codeChallengeVerifier = $this->codeChallengeVerifiers[$codeChallengeMethod]; @@ -766,6 +775,7 @@ public function respondToAccessTokenRequest( return $responseType; } + /** * Reimplementation because of private parent access * @@ -781,10 +791,12 @@ protected function validateAuthorizationCode( ServerRequestInterface $request, AuthCodeEntity $storedAuthCodeEntity, ): void { + // phpcs:disable SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation /** * @noinspection PhpUndefinedClassInspection * @psalm-var AuthCodePayloadObject $authCodePayload */ + // phpcs:enable SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation if (! is_a($this->accessTokenRepository, AccessTokenRepositoryInterface::class)) { throw OidcServerException::serverError('Unexpected access token repository entity type.'); @@ -853,6 +865,7 @@ protected function validateAuthorizationCode( } } + /** * @inheritDoc * @throws \Throwable @@ -1095,6 +1108,7 @@ public function validateAuthorizationRequestWithRequestRules( return $authorizationRequest; } + /** * @param \League\OAuth2\Server\Entities\AccessTokenEntityInterface $accessToken * @param string|null $authCodeId diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index 2d52c45a..be36b8b6 100644 --- a/src/Server/Grants/ImplicitGrant.php +++ b/src/Server/Grants/ImplicitGrant.php @@ -52,9 +52,11 @@ class ImplicitGrant extends OAuth2ImplicitGrant implements AuthorizationValidata { use IssueAccessTokenTrait; - /** @var HttpMethodsEnum[] */ + + /** @var \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] */ protected array $allowedAuthorizationHttpMethods = [HttpMethodsEnum::GET, HttpMethodsEnum::POST]; + public function __construct( protected IdTokenBuilder $idTokenBuilder, protected DateInterval $accessTokenTTL, @@ -70,6 +72,7 @@ public function __construct( $this->accessTokenEntityFactory = $accessTokenEntityFactory; } + /** * {@inheritdoc} * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -95,6 +98,7 @@ public function canRespondToAuthorizationRequest(ServerRequestInterface $request ! in_array('code', $responseType, true); // ...avoid triggering hybrid flow } + /** * {@inheritdoc} * @param \League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface $authorizationRequest @@ -117,6 +121,7 @@ public function completeAuthorizationRequest( throw new LogicException('Unexpected OAuth2AuthorizationRequest type.'); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable @@ -216,6 +221,7 @@ public function validateAuthorizationRequestWithRequestRules( return $authorizationRequest; } + /** * @throws \Exception * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -326,6 +332,7 @@ private function completeOidcAuthorizationRequest(AuthorizationRequest $authoriz return $response; } + private function getRedirectUrl(AuthorizationRequest $authorizationRequest): string { $redirectUri = $authorizationRequest->getRedirectUri(); diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index e6a2c98e..0667d849 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -39,6 +39,7 @@ public function getIdentifier(): string return GrantTypesEnum::PreAuthorizedCode->value; } + /** * Reimplemented to disable authz requests (code is pre-authorized). * @@ -50,6 +51,7 @@ public function canRespondToAuthorizationRequest(ServerRequestInterface $request return false; } + /** * Check if the authorization request is OIDC candidate (can respond with ID token). */ @@ -59,6 +61,7 @@ public function isOidcCandidate( return false; } + /** * @inheritDoc * @throws \League\OAuth2\Server\Exception\OAuthServerException @@ -70,6 +73,7 @@ public function completeAuthorizationRequest( throw OidcServerException::serverError('Not implemented'); } + /** * This is reimplementation of OAuth2 completeAuthorizationRequest method with addition of nonce handling. * @@ -83,6 +87,7 @@ public function completeOidcAuthorizationRequest( throw OidcServerException::serverError('Not implemented'); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException @@ -97,6 +102,7 @@ protected function issueOidcAuthCode( throw OidcServerException::serverError('Not implemented'); } + /** * Reimplementation for Pre-authorized Code. * @@ -222,6 +228,7 @@ public function respondToAccessTokenRequest( return $responseType; } + /** * Reimplementation because of private parent access * @@ -258,6 +265,7 @@ protected function validateAuthorizationCode( $this->loggerService->debug('PreAuthCodeGrant::validateAuthorizationCode passed.'); } + /** * @inheritDoc * @throws \Throwable @@ -269,6 +277,7 @@ public function validateAuthorizationRequestWithRequestRules( throw OidcServerException::serverError('Not implemented'); } + /** * @param \League\OAuth2\Server\Entities\AccessTokenEntityInterface $accessToken * @param string|null $authCodeId diff --git a/src/Server/Grants/RefreshTokenGrant.php b/src/Server/Grants/RefreshTokenGrant.php index 05a4ff3f..b6d3a841 100644 --- a/src/Server/Grants/RefreshTokenGrant.php +++ b/src/Server/Grants/RefreshTokenGrant.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Server\Grants; +use DateTimeImmutable; use Exception; use League\OAuth2\Server\Entities\AccessTokenEntityInterface as OAuth2AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; @@ -32,6 +33,7 @@ class RefreshTokenGrant extends OAuth2RefreshTokenGrant { use IssueAccessTokenTrait; + public function __construct( RefreshTokenRepositoryInterface $refreshTokenRepository, AccessTokenEntityFactory $accessTokenEntityFactory, @@ -43,6 +45,7 @@ public function __construct( $this->accessTokenEntityFactory = $accessTokenEntityFactory; } + /** * Authenticate the client at the refresh token endpoint without requiring a `client_id` request * parameter. The league default (AbstractGrant::validateClient) resolves the client from a @@ -73,6 +76,7 @@ protected function validateClient(ServerRequestInterface $request): ClientEntity return $resolvedClientAuthenticationMethod->getClient(); } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -157,7 +161,7 @@ protected function validateOldRefreshToken(ServerRequestInterface $request, stri // If the current time is still the same second as the original issuance, we sleep // for 1 second to guarantee the new ID Token gets a different, updated `iat`. if (isset($refreshTokenData['expire_time'])) { - $reference = new \DateTimeImmutable(); + $reference = new DateTimeImmutable(); $endTime = $reference->add($this->refreshTokenTTL); $ttlSeconds = $endTime->getTimestamp() - $reference->getTimestamp(); $oldIssueTime = ((int)$refreshTokenData['expire_time']) - $ttlSeconds; @@ -170,6 +174,7 @@ protected function validateOldRefreshToken(ServerRequestInterface $request, stri return $refreshTokenData; } + protected function issueRefreshToken( OAuth2AccessTokenEntityInterface $accessToken, ?string $authCodeId = null, diff --git a/src/Server/Grants/Traits/IssueAccessTokenTrait.php b/src/Server/Grants/Traits/IssueAccessTokenTrait.php index 3e7b3407..e60bb275 100644 --- a/src/Server/Grants/Traits/IssueAccessTokenTrait.php +++ b/src/Server/Grants/Traits/IssueAccessTokenTrait.php @@ -24,6 +24,7 @@ trait IssueAccessTokenTrait { protected AccessTokenEntityFactory $accessTokenEntityFactory; + /** * Issue an access token. * @@ -83,6 +84,7 @@ protected function issueAccessToken( throw OidcServerException::serverError('Unable to issue Access Token.'); } + /** * Generate a new unique identifier. * diff --git a/src/Server/LogoutHandlers/BackChannelLogoutHandler.php b/src/Server/LogoutHandlers/BackChannelLogoutHandler.php index fae6bd05..99f98c84 100644 --- a/src/Server/LogoutHandlers/BackChannelLogoutHandler.php +++ b/src/Server/LogoutHandlers/BackChannelLogoutHandler.php @@ -56,6 +56,7 @@ class BackChannelLogoutHandler 'multiplex', ]; + public function __construct( protected LogoutTokenBuilder $logoutTokenBuilder = new LogoutTokenBuilder(), protected LoggerService $loggerService = new LoggerService(), @@ -64,6 +65,7 @@ public function __construct( ) { } + /** * A logout URI is registered by the client, so it names a destination the deployment did not choose, * exactly like a `jwks_uri` does. This client is built here rather than by the openid library, so the @@ -81,6 +83,7 @@ protected function destinationPolicy(): DestinationPolicy ))->build(); } + /** * Attach the destination guard to the client, and return the client that carries it. * @@ -146,6 +149,7 @@ protected function guarded(Client $client, array $clientConfig, bool $hasSupplie return $client; } + /** * Whether Guzzle's own handler for this system is a cURL one, which is what decides whether an address * can be pinned. Asked of Guzzle rather than inferred from the extension being loaded, since the cURL @@ -169,6 +173,7 @@ protected function isCurlHandlerAvailable(array $clientConfig): bool } } + /** * @param \SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface[] * $relyingPartyAssociations @@ -226,6 +231,7 @@ public function handle(array $relyingPartyAssociations, ?HandlerStack $handlerSt } } + /** * @param \SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface[] * $relyingPartyAssociations diff --git a/src/Server/Registration/ClientMetadataValidator.php b/src/Server/Registration/ClientMetadataValidator.php index 66713e5c..c8b7980c 100644 --- a/src/Server/Registration/ClientMetadataValidator.php +++ b/src/Server/Registration/ClientMetadataValidator.php @@ -47,6 +47,7 @@ class ClientMetadataValidator // Front-channel logout metadata (not modelled in ClaimsEnum; this OP only supports back-channel logout). private const string CLAIM_FRONTCHANNEL_LOGOUT_URI = 'frontchannel_logout_uri'; + private const string CLAIM_FRONTCHANNEL_LOGOUT_SESSION_REQUIRED = 'frontchannel_logout_session_required'; /** @@ -97,12 +98,14 @@ class ClientMetadataValidator */ private const int MAX_REQUEST_URIS = 20; + public function __construct( private readonly ModuleConfig $moduleConfig, private readonly DestinationPolicy $destinationPolicy, ) { } + /** * Validate the incoming registration metadata. Returns the metadata unchanged on success. * @@ -136,6 +139,7 @@ public function validate(array $metadata, bool $isCallerAuthenticated = false): return $metadata; } + /** * redirect_uris is REQUIRED; it must be a non-empty array of valid absolute URIs. * @@ -169,6 +173,7 @@ private function validateRedirectUris(array $metadata): array return $validated; } + /** * logo_uri, client_uri, policy_uri and tos_uri must be valid absolute URIs when present. * @@ -189,6 +194,7 @@ private function validateInformationalUris(array $metadata): void } } + /** * request_uris, when present, must be an array of absolute https URIs. A fragment component is permitted: * OpenID Connect Core 1.0 Section 6.2 allows the request_uri to carry a base64url-encoded SHA-256 hash of the @@ -232,6 +238,7 @@ private function validateRequestUris(array $metadata): void } } + /** * Every URI this OP will later fetch from must name a destination the outbound policy permits. * @@ -317,6 +324,7 @@ private function validateFetchedUriDestinations(array $metadata, bool $isCallerA } } + /** * Whether a URI carries a userinfo component, which the destination policy refuses outright. * @@ -330,6 +338,7 @@ private function hasUriCredentials(string $uri): bool return is_array($parts) && (isset($parts['user']) || isset($parts['pass'])); } + /** * The part of a URI that decides where a request goes, used to charge one DNS lookup per destination * rather than one per URI. @@ -350,6 +359,7 @@ private function extractOrigin(string $uri): string (isset($parts['port']) ? ':' . $parts['port'] : ''); } + /** * contacts, when present, must be an array of non-empty strings. * @@ -375,6 +385,7 @@ private function validateContacts(array $metadata): void } } + /** * application_type, when present, must be one of the defined values (web or native). * @@ -396,6 +407,7 @@ private function validateApplicationType(array $metadata): void } } + /** * Reject registration of grant_types / response_types / token_endpoint_auth_method values that this OP does not * support (the same sets it advertises in discovery via ModuleConfig). Without this, a client could @@ -431,6 +443,7 @@ private function validateRegisterableProtocolValues(array $metadata): void } } + /** * Reject the registration when a list-valued metadata field contains a value outside the supported set. When * present, the field must be an array of strings, each of which must be supported. @@ -463,6 +476,7 @@ private function rejectUnsupportedArrayValues(array $metadata, string $claim, ar } } + /** * Verify that every registered redirect_uri conforms to the constraints implied by application_type, as * required by OpenID Connect Dynamic Client Registration 1.0 (Section 2, application_type): @@ -516,6 +530,7 @@ private function validateRedirectUrisForApplicationType(array $metadata, array $ } } + /** * Whether the host is a loopback address per OIDC DCR (localhost, 127.0.0.1 or the IPv6 literal [::1]). */ @@ -524,6 +539,7 @@ private function isLoopbackHost(?string $host): bool return in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true); } + /** * Whether the registration declares use of the implicit grant, via grant_types (`implicit`) or via a * response_type that requires it (`id_token`, `id_token token`, and the hybrid combinations). Reuses the shared @@ -553,6 +569,7 @@ private function clientUsesImplicitGrant(array $metadata): bool ); } + /** * subject_type, when present, must be 'public': this OP only issues public subject identifiers (no pairwise). * @@ -578,6 +595,7 @@ private function validateSubjectType(array $metadata): void } } + /** * Validate additional supported metadata: the behavioral "default when omitted" fields (default_max_age, * require_auth_time, default_acr_values) and the informational fields (initiate_login_uri, software_id, @@ -658,6 +676,7 @@ private function validateAdditionalMetadata(array $metadata): void } } + /** * Reject metadata requesting features this OP does not support (see UNSUPPORTED_FEATURE_CLAIMS and front-channel * logout), rather than silently ignoring it. This keeps the registration response an honest contract: the OP @@ -685,6 +704,7 @@ private function rejectUnsupportedFeatures(array $metadata): void } } + /** * Impersonation protection (OIDC Dynamic Client Registration 1.0, Section 9.1): each protected informational * URI must share a host with one of the registered redirect_uris, to mitigate a rogue client supplying the @@ -720,11 +740,13 @@ private function enforceImpersonationProtection(array $metadata, array $redirect } } + private function isValidAbsoluteUri(string $uri): bool { return filter_var($uri, FILTER_VALIDATE_URL) !== false && $this->extractHost($uri) !== null; } + /** * Whether the URI has a (non-empty) scheme component, i.e. is an absolute URI. */ @@ -735,6 +757,7 @@ private function hasScheme(string $uri): bool return is_string($scheme) && $scheme !== ''; } + /** * Whether the URI has a fragment component at all. OpenID Connect Core 3.1.2.1 requires redirect_uris to * contain no fragment component, which includes an empty fragment: a trailing '#' (e.g. ".../cb#") is a @@ -746,6 +769,7 @@ private function hasFragment(string $uri): bool return str_contains($uri, '#'); } + /** * Extract the lower-cased host component of a URI, or null if absent. */ diff --git a/src/Server/RequestRules/Interfaces/RequestRuleInterface.php b/src/Server/RequestRules/Interfaces/RequestRuleInterface.php index 646356d7..eeb4dae9 100644 --- a/src/Server/RequestRules/Interfaces/RequestRuleInterface.php +++ b/src/Server/RequestRules/Interfaces/RequestRuleInterface.php @@ -26,14 +26,17 @@ interface RequestRuleInterface */ public function getKey(): string; + /** * Check specific rule. * * @param \SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface $currentResultBag * ResultBag with all results of the checks performed to current check * @param array $data Data which will be available during check. - * @param ResponseModeInterface $responseMode Response mode to use for error responses - * @param HttpMethodsEnum[] $allowedServerRequestMethods Indicate allowed HTTP methods used for request + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode Response mode to + * use for error responses + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods Indicate allowed HTTP + * methods used for request * * @return \SimpleSAML\Module\oidc\Server\RequestRules\Result|null Result of the specific check * (the concrete value type T is bound per rule and surfaced via the ResultBag accessors) diff --git a/src/Server/RequestRules/Interfaces/ResultBagInterface.php b/src/Server/RequestRules/Interfaces/ResultBagInterface.php index ec577aba..38c66ce4 100644 --- a/src/Server/RequestRules/Interfaces/ResultBagInterface.php +++ b/src/Server/RequestRules/Interfaces/ResultBagInterface.php @@ -15,29 +15,32 @@ interface ResultBagInterface */ public function add(Result $result): void; + /** * Get specific result or null if it doesn't exist. * * The value type is inferred from the rule class-string passed as the key. * * @template T - * @param class-string> $key + * @param class-string<\SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface> $key * @return \SimpleSAML\Module\oidc\Server\RequestRules\Result|null */ public function get(string $key): ?Result; + /** * Get specific result or fail if it doesn't exist. * * The value type is inferred from the rule class-string passed as the key. * * @template T - * @param class-string> $key + * @param class-string<\SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface> $key * @return \SimpleSAML\Module\oidc\Server\RequestRules\Result * @throws \Throwable If result with specific key is not present. */ public function getOrFail(string $key): Result; + /** * Get the value of a specific result or fail if the result doesn't exist. * @@ -45,23 +48,26 @@ public function getOrFail(string $key): Result; * from the rule class-string passed as the key. * * @template T - * @param class-string> $key + * @param class-string<\SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface> $key * @return T * @throws \Throwable If result with specific key is not present. */ public function getValueOrFail(string $key): mixed; + /** * Get all results. * @return array> */ public function getAll(): array; + /** * Remove result from the result bag. */ public function remove(string $key): void; + /** * Check if specific result exists in result bag. */ diff --git a/src/Server/RequestRules/RequestRulesManager.php b/src/Server/RequestRules/RequestRulesManager.php index e8f8e85b..f4489718 100644 --- a/src/Server/RequestRules/RequestRulesManager.php +++ b/src/Server/RequestRules/RequestRulesManager.php @@ -26,6 +26,7 @@ class RequestRulesManager /** @var array $data Which will be available during each check */ protected array $data = []; + /** * RequestRulesManager constructor. * @param \SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface[] $rules @@ -39,17 +40,20 @@ public function __construct(array $rules = [], protected LoggerService $loggerSe $this->resultBag = new ResultBag(); } + public function add(RequestRuleInterface $rule): void { $this->rules[$rule->getKey()] = $rule; } + /** * @param class-string[] $ruleKeysToExecute - * @param ResponseModeInterface $responseMode Response mode which will be - * used in rules execution, as some rules might need to adjust their - * behaviour based on response mode used in request. - * @param HttpMethodsEnum[] $allowedServerRequestMethods Indicate allowed HTTP methods used for request + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode Response mode which + * will be used in rules execution, as some rules might need to adjust their behaviour based on response mode used + * in request. + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods Indicate allowed HTTP + * methods used for request * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function check( @@ -80,6 +84,7 @@ public function check( return $this->resultBag; } + /** * Predefine (add) the existing result, so it can be used by other checkers during check. */ @@ -88,6 +93,7 @@ public function predefineResult(Result $result): void $this->resultBag->add($result); } + /** * Predefine existing ResultBag so that it can be used by other checkers during check. */ @@ -96,6 +102,7 @@ public function predefineResultBag(ResultBagInterface $resultBag): void $this->resultBag = $resultBag; } + /** * Set data which will be available in each check, using key value pair */ diff --git a/src/Server/RequestRules/Result.php b/src/Server/RequestRules/Result.php index 784581b1..1236462b 100644 --- a/src/Server/RequestRules/Result.php +++ b/src/Server/RequestRules/Result.php @@ -22,11 +22,13 @@ public function __construct(protected string $key, protected mixed $value = null { } + public function getKey(): string { return $this->key; } + /** * @return T */ diff --git a/src/Server/RequestRules/ResultBag.php b/src/Server/RequestRules/ResultBag.php index a45e15a9..214ec476 100644 --- a/src/Server/RequestRules/ResultBag.php +++ b/src/Server/RequestRules/ResultBag.php @@ -16,11 +16,13 @@ class ResultBag implements ResultBagInterface */ protected array $results = []; + public function add(Result $result): void { $this->results[$result->getKey()] = $result; } + /** * @template T * @param class-string<\SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface> $key @@ -28,10 +30,12 @@ public function add(Result $result): void */ public function get(string $key): ?Result { + // phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation /** @var \SimpleSAML\Module\oidc\Server\RequestRules\Result|null */ return $this->results[$key] ?? null; } + /** * @template T * @param class-string<\SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface> $key @@ -50,6 +54,7 @@ public function getOrFail(string $key): Result return $result; } + /** * @template T * @param class-string<\SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface> $key @@ -60,6 +65,7 @@ public function getValueOrFail(string $key): mixed return $this->getOrFail($key)->getValue(); } + /** * @return array> */ @@ -68,11 +74,13 @@ public function getAll(): array return $this->results; } + public function remove(string $key): void { unset($this->results[$key]); } + public function has(string $key): bool { return array_key_exists($key, $this->results); diff --git a/src/Server/RequestRules/Rules/AbstractRule.php b/src/Server/RequestRules/Rules/AbstractRule.php index fe4eb48f..32de883a 100644 --- a/src/Server/RequestRules/Rules/AbstractRule.php +++ b/src/Server/RequestRules/Rules/AbstractRule.php @@ -14,7 +14,7 @@ /** * @template T - * @implements RequestRuleInterface + * @implements \SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface */ abstract class AbstractRule implements RequestRuleInterface { @@ -24,6 +24,7 @@ public function __construct( ) { } + /** * @inheritDoc */ @@ -32,6 +33,7 @@ public function getKey(): string return static::class; } + /** * Check if the authorization request is an OpenID Connect request * (designated by the openid scope), as opposed to a plain OAuth 2.0 diff --git a/src/Server/RequestRules/Rules/AcrValuesRule.php b/src/Server/RequestRules/Rules/AcrValuesRule.php index 8cdef34f..dd573456 100644 --- a/src/Server/RequestRules/Rules/AcrValuesRule.php +++ b/src/Server/RequestRules/Rules/AcrValuesRule.php @@ -15,15 +15,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class AcrValuesRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/AddClaimsToIdTokenRule.php b/src/Server/RequestRules/Rules/AddClaimsToIdTokenRule.php index eb22cc98..0ae22382 100644 --- a/src/Server/RequestRules/Rules/AddClaimsToIdTokenRule.php +++ b/src/Server/RequestRules/Rules/AddClaimsToIdTokenRule.php @@ -22,7 +22,7 @@ * - the client is configured with the administrator-only `add_claims_to_id_token` option (the client wants its * claims in the ID Token regardless, e.g. because it never calls the UserInfo endpoint). * - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class AddClaimsToIdTokenRule extends AbstractRule { @@ -31,7 +31,7 @@ class AddClaimsToIdTokenRule extends AbstractRule * * @throws \Throwable * - * @param ResponseModeInterface $responseMode + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php index 9603bba0..aa825504 100644 --- a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php +++ b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; +use JsonException; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; @@ -18,7 +19,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class AuthorizationDetailsRule extends AbstractRule { @@ -30,11 +31,12 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -63,7 +65,7 @@ public function checkRule( try { $authorizationDetails = json_decode($authorizationDetailsParam, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { + } catch (JsonException) { $loggerService->error( 'AuthorizationDetailsRule: Could not JSON decode authorization_details parameter value.', ); diff --git a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php index 67519ec6..dc67a87a 100644 --- a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php +++ b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php @@ -19,7 +19,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule<\SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod> + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule<\SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod> */ class ClientAuthenticationRule extends AbstractRule { @@ -31,12 +31,13 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/ClientIdRule.php b/src/Server/RequestRules/Rules/ClientIdRule.php index 98d10bc1..9a4547bd 100644 --- a/src/Server/RequestRules/Rules/ClientIdRule.php +++ b/src/Server/RequestRules/Rules/ClientIdRule.php @@ -17,7 +17,7 @@ /** * Resolve a client instance based on a client_id or request object. * - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class ClientIdRule extends AbstractRule { @@ -37,8 +37,8 @@ class ClientIdRule extends AbstractRule * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php index d2cae61b..495b9b47 100644 --- a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -18,9 +18,10 @@ use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use Throwable; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class ClientRedirectUriRule extends AbstractRule { @@ -32,13 +33,14 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @inheritDoc * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -81,7 +83,7 @@ public function checkRule( ) { throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); } - } catch (\Throwable $exception) { + } catch (Throwable $exception) { if ( $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && $this->moduleConfig->getVciEnabled() && diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 1a9089cd..f5418e0b 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -35,12 +35,13 @@ /** * Resolve a client instance based on a client_id or request object. * - * @extends AbstractRule<\SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface> + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule<\SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface> */ class ClientRule extends AbstractRule { protected const string KEY_REQUEST_OBJECT_JTI = 'request_object_jti'; + public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, @@ -56,6 +57,7 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @inheritDoc * @@ -72,8 +74,8 @@ public function __construct( * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -154,6 +156,7 @@ public function checkRule( throw OidcServerException::invalidClient($request); } + /** * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods */ diff --git a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php index be738fd1..9f664281 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php @@ -18,7 +18,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class CodeChallengeMethodRule extends AbstractRule { @@ -30,12 +30,13 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/CodeChallengeRule.php b/src/Server/RequestRules/Rules/CodeChallengeRule.php index 7730f5e0..cbefce66 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeRule.php @@ -15,7 +15,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class CodeChallengeRule extends AbstractRule { @@ -24,8 +24,8 @@ class CodeChallengeRule extends AbstractRule * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/CodeVerifierRule.php b/src/Server/RequestRules/Rules/CodeVerifierRule.php index b6c539f2..acf444ec 100644 --- a/src/Server/RequestRules/Rules/CodeVerifierRule.php +++ b/src/Server/RequestRules/Rules/CodeVerifierRule.php @@ -15,15 +15,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class CodeVerifierRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/IdTokenHintRule.php b/src/Server/RequestRules/Rules/IdTokenHintRule.php index f2181977..621d32db 100644 --- a/src/Server/RequestRules/Rules/IdTokenHintRule.php +++ b/src/Server/RequestRules/Rules/IdTokenHintRule.php @@ -18,9 +18,10 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Jwks; +use Throwable; /** - * @extends AbstractRule<\SimpleSAML\OpenID\Core\IdTokenHint|null> + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule<\SimpleSAML\OpenID\Core\IdTokenHint|null> */ class IdTokenHintRule extends AbstractRule { @@ -34,13 +35,14 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @inheritDoc * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -93,7 +95,7 @@ public function checkRule( // expired); the `nbf` and `iat` timestamps are still validated. try { $idTokenHint = $this->core->idTokenHintFactory()->fromToken($idTokenHintParam); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $loggerService->notice( 'Request rejected: `id_token_hint` could not be parsed or validated.', ['exception' => $exception->getMessage()], @@ -125,7 +127,7 @@ public function checkRule( try { $idTokenHint->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $loggerService->notice( 'Request rejected: `id_token_hint` signature verification failed.', ['exception' => $exception->getMessage()], diff --git a/src/Server/RequestRules/Rules/IssuerStateRule.php b/src/Server/RequestRules/Rules/IssuerStateRule.php index 0a1174ce..2384275c 100644 --- a/src/Server/RequestRules/Rules/IssuerStateRule.php +++ b/src/Server/RequestRules/Rules/IssuerStateRule.php @@ -14,15 +14,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class IssuerStateRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/LoginHintRule.php b/src/Server/RequestRules/Rules/LoginHintRule.php index f984fb60..b5465858 100644 --- a/src/Server/RequestRules/Rules/LoginHintRule.php +++ b/src/Server/RequestRules/Rules/LoginHintRule.php @@ -14,15 +14,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class LoginHintRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index 4eebce56..d49c0991 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -21,7 +21,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class MaxAgeRule extends AbstractRule { @@ -35,6 +35,7 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -43,8 +44,8 @@ public function __construct( * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php b/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php index 30ff22cc..6d23eb19 100644 --- a/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php @@ -18,7 +18,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class PostLogoutRedirectUriRule extends AbstractRule { @@ -30,13 +30,14 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @inheritDoc * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/PromptRule.php b/src/Server/RequestRules/Rules/PromptRule.php index 5b2ed80a..c4c471d0 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -24,7 +24,7 @@ * This rule never yields a value into the result bag (it only performs validation / side effects), * so its value type is `never`. * - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class PromptRule extends AbstractRule { @@ -38,6 +38,7 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \SimpleSAML\Error\AuthSource @@ -47,8 +48,8 @@ public function __construct( * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index 1125361e..a27aef88 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -20,9 +20,10 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core\RequestObject as ConnectRequestObject; use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; +use Throwable; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class RequestObjectRule extends AbstractRule { @@ -35,12 +36,13 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -191,6 +193,7 @@ public function checkRule( return new Result($this->getKey(), $requestObject->getPayload()); } + /** * Check whether the request carries a Request Object, either by value (request param) or by reference * (https request_uri param). Note that a Pushed Authorization Request URI (urn form) is not a Request @@ -221,6 +224,7 @@ protected function hasRequestObjectSource( return is_string($requestUri) && str_starts_with(strtolower($requestUri), 'https://'); } + /** * Validate the Request Object audience (aud) claim. * @@ -257,6 +261,7 @@ protected function verifyAudience( } } + /** * Validate the Request Object issuer (iss) claim. * @@ -294,6 +299,7 @@ protected function verifyIssuer( } } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -314,7 +320,7 @@ protected function verifySignature( try { $requestObject->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { throw OidcServerException::accessDenied( 'request object validation failed: ' . $exception->getMessage(), $redirectUri, diff --git a/src/Server/RequestRules/Rules/RequestUriRule.php b/src/Server/RequestRules/Rules/RequestUriRule.php index a99ecc83..1c85777a 100644 --- a/src/Server/RequestRules/Rules/RequestUriRule.php +++ b/src/Server/RequestRules/Rules/RequestUriRule.php @@ -37,7 +37,7 @@ * @see \SimpleSAML\Module\oidc\Utils\RequestParamsResolver * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule * - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class RequestUriRule extends AbstractRule { @@ -50,12 +50,13 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -142,6 +143,7 @@ public function checkRule( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable @@ -207,8 +209,9 @@ protected function checkPushedAuthorizationRequestUri( return new Result($this->getKey(), $requestUri); } + /** - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Throwable */ diff --git a/src/Server/RequestRules/Rules/RequestedClaimsRule.php b/src/Server/RequestRules/Rules/RequestedClaimsRule.php index 39a2d8f9..ae4a49fa 100644 --- a/src/Server/RequestRules/Rules/RequestedClaimsRule.php +++ b/src/Server/RequestRules/Rules/RequestedClaimsRule.php @@ -17,7 +17,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class RequestedClaimsRule extends AbstractRule { @@ -33,8 +33,8 @@ public function __construct( /** * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -82,6 +82,7 @@ public function checkRule( return new Result($this->getKey(), $claims); } + private function filterUnauthorizedClaims(array &$requestClaims, string $key, array $authorized): void { if (!array_key_exists($key, $requestClaims)) { diff --git a/src/Server/RequestRules/Rules/RequiredNonceRule.php b/src/Server/RequestRules/Rules/RequiredNonceRule.php index 6dc846cb..d8f937f9 100644 --- a/src/Server/RequestRules/Rules/RequiredNonceRule.php +++ b/src/Server/RequestRules/Rules/RequiredNonceRule.php @@ -15,7 +15,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class RequiredNonceRule extends AbstractRule { @@ -24,8 +24,8 @@ class RequiredNonceRule extends AbstractRule * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php index ea003ba6..63c49a31 100644 --- a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php +++ b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php @@ -12,9 +12,10 @@ use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use Throwable; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class RequiredOpenIdScopeRule extends AbstractRule { @@ -23,8 +24,8 @@ class RequiredOpenIdScopeRule extends AbstractRule * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, @@ -61,7 +62,7 @@ public function checkRule( $responseMode, ); } - } catch (\Throwable $e) { + } catch (Throwable $e) { if ($this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods)) { $loggerService->info('RequiredOpenIdScopeRule: Skippping openid scope check for VCI request.'); } else { diff --git a/src/Server/RequestRules/Rules/ResponseModeRule.php b/src/Server/RequestRules/Rules/ResponseModeRule.php index c6d830e2..2e25bcb1 100644 --- a/src/Server/RequestRules/Rules/ResponseModeRule.php +++ b/src/Server/RequestRules/Rules/ResponseModeRule.php @@ -21,7 +21,7 @@ use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; /** - * @extends AbstractRule<\SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface> + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule<\SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface> */ class ResponseModeRule extends AbstractRule { @@ -40,8 +40,8 @@ public function __construct( /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/ResponseTypeRule.php b/src/Server/RequestRules/Rules/ResponseTypeRule.php index c1c79a34..df6751f3 100644 --- a/src/Server/RequestRules/Rules/ResponseTypeRule.php +++ b/src/Server/RequestRules/Rules/ResponseTypeRule.php @@ -16,15 +16,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class ResponseTypeRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php index 12c697a2..005a1f60 100644 --- a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php +++ b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php @@ -14,7 +14,7 @@ use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class ScopeOfflineAccessRule extends AbstractRule { @@ -23,7 +23,7 @@ class ScopeOfflineAccessRule extends AbstractRule * * @throws \Throwable * - * @param ResponseModeInterface $responseMode + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index 7dc16f2f..6ccb8d74 100644 --- a/src/Server/RequestRules/Rules/ScopeRule.php +++ b/src/Server/RequestRules/Rules/ScopeRule.php @@ -19,7 +19,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule<\League\OAuth2\Server\Entities\ScopeEntityInterface[]> + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule<\League\OAuth2\Server\Entities\ScopeEntityInterface[]> */ class ScopeRule extends AbstractRule { @@ -31,13 +31,14 @@ public function __construct( parent::__construct($requestParamsResolver, $helpers); } + /** * @inheritDoc * * @throws \Throwable * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/StateRule.php b/src/Server/RequestRules/Rules/StateRule.php index b7475620..943275c4 100644 --- a/src/Server/RequestRules/Rules/StateRule.php +++ b/src/Server/RequestRules/Rules/StateRule.php @@ -14,15 +14,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class StateRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/UiLocalesRule.php b/src/Server/RequestRules/Rules/UiLocalesRule.php index 04d7261d..81827e43 100644 --- a/src/Server/RequestRules/Rules/UiLocalesRule.php +++ b/src/Server/RequestRules/Rules/UiLocalesRule.php @@ -14,15 +14,15 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule */ class UiLocalesRule extends AbstractRule { /** * @inheritDoc * - * @param ResponseModeInterface $responseMode - * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface $responseMode + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods */ public function checkRule( ServerRequestInterface $request, diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index 034c99af..177ca0c7 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -93,6 +93,7 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest private ?ResponseModeInterface $responseMode = null; + public static function fromOAuth2AuthorizationRequest( OAuth2AuthorizationRequest $oAuth2authorizationRequest, ): AuthorizationRequest { @@ -120,6 +121,7 @@ public static function fromOAuth2AuthorizationRequest( return $authorizationRequest; } + /** * @return string|null */ @@ -128,11 +130,13 @@ public function getNonce(): ?string return $this->nonce; } + public function setNonce(string $nonce): void { $this->nonce = $nonce; } + /** * @return int|null */ @@ -141,6 +145,7 @@ public function getAuthTime(): ?int return $this->authTime; } + /** * @param int|null $authTime */ @@ -149,6 +154,7 @@ public function setAuthTime(?int $authTime): void $this->authTime = $authTime; } + /** * @return array|null */ @@ -157,6 +163,7 @@ public function getClaims(): ?array return $this->claims; } + /** * @param array|null $claims */ @@ -174,16 +181,19 @@ public function getAddClaimsToIdToken(): bool return $this->addClaimsToIdToken; } + public function setAddClaimsToIdToken(bool $addClaimsToIdToken): void { $this->addClaimsToIdToken = $addClaimsToIdToken; } + public function setResponseType(string $responseType): void { $this->responseType = $responseType; } + /** * @return string|null */ @@ -192,16 +202,19 @@ public function getResponseType(): ?string return $this->responseType; } + public function setResponseMode(ResponseModeInterface $responseMode): void { $this->responseMode = $responseMode; } + public function getResponseMode(): ?ResponseModeInterface { return $this->responseMode; } + /** * Check if access token should be issued in authorization response (implicit flow, hybrid flow...). * @return bool @@ -215,76 +228,91 @@ public function shouldReturnAccessTokenInAuthorizationResponse(): bool return false; } + public function setIsCookieBasedAuthn(?bool $isCookieBasedAuthn): void { $this->isCookieBasedAuthn = $isCookieBasedAuthn; } + public function getIsCookieBasedAuthn(): ?bool { return $this->isCookieBasedAuthn; } + public function setAuthSourceId(?string $authSourceId): void { $this->authSourceId = $authSourceId; } + public function getAuthSourceId(): ?string { return $this->authSourceId; } + public function getRequestedAcrValues(): ?array { return $this->requestedAcrValues; } + public function setRequestedAcrValues(?array $requestedAcrValues): void { $this->requestedAcrValues = $requestedAcrValues; } + public function getUiLocales(): ?string { return $this->uiLocales; } + public function setUiLocales(?string $uiLocales): void { $this->uiLocales = $uiLocales; } + public function getLoginHint(): ?string { return $this->loginHint; } + public function setLoginHint(?string $loginHint): void { $this->loginHint = $loginHint; } + public function getIdTokenHintSubject(): ?string { return $this->idTokenHintSubject; } + public function setIdTokenHintSubject(?string $idTokenHintSubject): void { $this->idTokenHintSubject = $idTokenHintSubject; } + public function getAcr(): ?string { return $this->acr; } + public function setAcr(?string $acr): void { $this->acr = $acr; } + /** * @return string|null */ @@ -293,6 +321,7 @@ public function getSessionId(): ?string return $this->sessionId; } + /** * @param string|null $sessionId */ @@ -301,61 +330,73 @@ public function setSessionId(?string $sessionId): void $this->sessionId = $sessionId; } + public function isVciRequest(): bool { return $this->isVciRequest; } + public function setIsVciRequest(bool $isVciRequest): void { $this->isVciRequest = $isVciRequest; } + public function getIssuerState(): ?string { return $this->issuerState; } + public function setIssuerState(?string $issuerState): void { $this->issuerState = $issuerState; } + public function getFlowType(): ?FlowTypeEnum { return $this->flowType; } + public function setFlowType(?FlowTypeEnum $flowType): void { $this->flowType = $flowType; } + public function getAuthorizationDetails(): ?array { return $this->authorizationDetails; } + public function setAuthorizationDetails(?array $authorizationDetails): void { $this->authorizationDetails = $authorizationDetails; } + public function getBoundClientId(): ?string { return $this->boundClientId; } + public function setBoundClientId(?string $boundClientId): void { $this->boundClientId = $boundClientId; } + public function getBoundRedirectUri(): ?string { return $this->boundRedirectUri; } + public function setBoundRedirectUri(?string $boundRedirectUri): void { $this->boundRedirectUri = $boundRedirectUri; diff --git a/src/Server/RequestTypes/LogoutRequest.php b/src/Server/RequestTypes/LogoutRequest.php index 140a5861..dde3a710 100644 --- a/src/Server/RequestTypes/LogoutRequest.php +++ b/src/Server/RequestTypes/LogoutRequest.php @@ -35,44 +35,52 @@ public function __construct( ) { } + public function getIdTokenHint(): ?IdToken { return $this->idTokenHint; } + public function setIdTokenHint(?IdToken $idTokenHint): LogoutRequest { $this->idTokenHint = $idTokenHint; return $this; } + public function getPostLogoutRedirectUri(): ?string { return $this->postLogoutRedirectUri; } + public function setPostLogoutRedirectUri(?string $postLogoutRedirectUri): LogoutRequest { $this->postLogoutRedirectUri = $postLogoutRedirectUri; return $this; } + public function getState(): ?string { return $this->state; } + public function setState(?string $state): LogoutRequest { $this->state = $state; return $this; } + public function getUiLocales(): ?string { return $this->uiLocales; } + public function setUiLocales(?string $uiLocales): LogoutRequest { $this->uiLocales = $uiLocales; diff --git a/src/Server/ResourceServer.php b/src/Server/ResourceServer.php index e1b18d44..6b02a212 100644 --- a/src/Server/ResourceServer.php +++ b/src/Server/ResourceServer.php @@ -14,6 +14,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/src/Server/ResponseModes/FormPostResponseMode.php b/src/Server/ResponseModes/FormPostResponseMode.php index 70064cd1..cdace96c 100644 --- a/src/Server/ResponseModes/FormPostResponseMode.php +++ b/src/Server/ResponseModes/FormPostResponseMode.php @@ -10,14 +10,11 @@ class FormPostResponseMode implements ResponseModeInterface { - private TemplateFactory $templateFactory; - - public function __construct( - TemplateFactory $templateFactory, - ) { - $this->templateFactory = $templateFactory; + public function __construct(private readonly TemplateFactory $templateFactory) + { } + public function buildResponse(string $redirectUri, array $params): AbstractResponseType { $template = $this->templateFactory->build( diff --git a/src/Server/ResponseTypes/HtmlResponse.php b/src/Server/ResponseTypes/HtmlResponse.php index 52c3204e..c2b8d2ba 100644 --- a/src/Server/ResponseTypes/HtmlResponse.php +++ b/src/Server/ResponseTypes/HtmlResponse.php @@ -11,11 +11,13 @@ class HtmlResponse extends AbstractResponseType { private string $html = ''; + public function setHtml(string $html): void { $this->html = $html; } + public function generateHttpResponse(ResponseInterface $response): ResponseInterface { $response->getBody()->write($this->html); diff --git a/src/Server/ResponseTypes/Interfaces/AcrResponseTypeInterface.php b/src/Server/ResponseTypes/Interfaces/AcrResponseTypeInterface.php index 98cddc65..072a8e75 100644 --- a/src/Server/ResponseTypes/Interfaces/AcrResponseTypeInterface.php +++ b/src/Server/ResponseTypes/Interfaces/AcrResponseTypeInterface.php @@ -8,5 +8,6 @@ interface AcrResponseTypeInterface { public function setAcr(?string $acr): void; + public function getAcr(): ?string; } diff --git a/src/Server/ResponseTypes/Interfaces/AuthTimeResponseTypeInterface.php b/src/Server/ResponseTypes/Interfaces/AuthTimeResponseTypeInterface.php index bc639ea2..7edeac22 100644 --- a/src/Server/ResponseTypes/Interfaces/AuthTimeResponseTypeInterface.php +++ b/src/Server/ResponseTypes/Interfaces/AuthTimeResponseTypeInterface.php @@ -11,6 +11,7 @@ interface AuthTimeResponseTypeInterface */ public function setAuthTime(?int $authTime): void; + /** * @return int|null */ diff --git a/src/Server/ResponseTypes/Interfaces/NonceResponseTypeInterface.php b/src/Server/ResponseTypes/Interfaces/NonceResponseTypeInterface.php index 05cf6d29..6b2a5f1b 100644 --- a/src/Server/ResponseTypes/Interfaces/NonceResponseTypeInterface.php +++ b/src/Server/ResponseTypes/Interfaces/NonceResponseTypeInterface.php @@ -11,6 +11,7 @@ interface NonceResponseTypeInterface */ public function setNonce(?string $nonce): void; + /** * @return string|null */ diff --git a/src/Server/ResponseTypes/Interfaces/SessionIdResponseTypeInterface.php b/src/Server/ResponseTypes/Interfaces/SessionIdResponseTypeInterface.php index c128b1b0..21dce1b0 100644 --- a/src/Server/ResponseTypes/Interfaces/SessionIdResponseTypeInterface.php +++ b/src/Server/ResponseTypes/Interfaces/SessionIdResponseTypeInterface.php @@ -8,5 +8,6 @@ interface SessionIdResponseTypeInterface { public function getSessionId(): ?string; + public function setSessionId(?string $sessionId): void; } diff --git a/src/Server/ResponseTypes/TokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php index b4600305..802d84c9 100644 --- a/src/Server/ResponseTypes/TokenResponse.php +++ b/src/Server/ResponseTypes/TokenResponse.php @@ -47,6 +47,7 @@ class TokenResponse extends BearerTokenResponse implements protected ?string $sessionId = null; + public function __construct( private readonly IdentityProviderInterface $identityProvider, protected IdTokenBuilder $idTokenBuilder, @@ -56,6 +57,7 @@ public function __construct( $this->privateKey = $privateKey; } + /** * @param \League\OAuth2\Server\Entities\AccessTokenEntityInterface $accessToken * @return array @@ -91,6 +93,7 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra return array_filter($extraParams); } + protected function prepareIdTokenExtraParam(AccessTokenEntity $accessToken): array { $userIdentifier = $accessToken->getUserIdentifier(); @@ -128,6 +131,7 @@ protected function prepareIdTokenExtraParam(AccessTokenEntity $accessToken): arr ]; } + protected function prepareVciAuthorizationDetailsExtraParam(AccessTokenEntity $accessToken): array { $normalizedAuthorizationDetails = []; @@ -164,6 +168,7 @@ protected function prepareVciAuthorizationDetailsExtraParam(AccessTokenEntity $a return ['authorization_details' => $normalizedAuthorizationDetails]; } + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * @@ -181,6 +186,7 @@ private function isOpenIDRequest(array $scopes): bool return false; } + /** * @param string|null $nonce */ @@ -189,6 +195,7 @@ public function setNonce(?string $nonce): void $this->nonce = $nonce; } + /** * @return string|null */ @@ -197,6 +204,7 @@ public function getNonce(): ?string return $this->nonce; } + /** * @param int|null $authTime */ @@ -205,6 +213,7 @@ public function setAuthTime(?int $authTime): void $this->authTime = $authTime; } + /** * @return int|null */ @@ -213,21 +222,25 @@ public function getAuthTime(): ?int return $this->authTime; } + public function setAcr(?string $acr): void { $this->acr = $acr; } + public function getAcr(): ?string { return $this->acr; } + public function getSessionId(): ?string { return $this->sessionId; } + public function setSessionId(?string $sessionId): void { $this->sessionId = $sessionId; diff --git a/src/Server/TokenIssuers/RefreshTokenIssuer.php b/src/Server/TokenIssuers/RefreshTokenIssuer.php index f136dded..0c2d72f0 100644 --- a/src/Server/TokenIssuers/RefreshTokenIssuer.php +++ b/src/Server/TokenIssuers/RefreshTokenIssuer.php @@ -27,6 +27,7 @@ public function __construct( parent::__construct($helpers); } + /** * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 852cd02a..c2400fb5 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -13,6 +13,8 @@ use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\Jws\ParsedJws; +use Throwable; use function apache_request_headers; use function count; @@ -31,6 +33,7 @@ public function __construct( ) { } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -77,7 +80,7 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe try { $token = $this->ensureValidAccessToken($jwt); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { throw OidcServerException::accessDenied($exception->getMessage(), null, $exception); } @@ -93,12 +96,13 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe ->withAttribute('oauth_scopes', $token->getPayloadClaim('scopes')); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function ensureValidAccessToken(string $accessTokenJwt): Jws\ParsedJws + public function ensureValidAccessToken(string $accessTokenJwt): ParsedJws { // Attempt to parse the JWT $token = $this->jws->parsedJwsFactory()->fromToken($accessTokenJwt); @@ -131,11 +135,13 @@ public function ensureValidAccessToken(string $accessTokenJwt): Jws\ParsedJws return $token; } + protected function getTokenFromAuthorizationBearer(string $authorizationHeader): string { return trim((string) preg_replace('/^\s*Bearer\s/', '', $authorizationHeader)); } + /** * Convert single record arrays into strings to ensure backwards compatibility between v4 and v3.x of lcobucci/jwt * diff --git a/src/Services/Api/ApiTokenPrincipalResolver.php b/src/Services/Api/ApiTokenPrincipalResolver.php index 5b905c22..e63c67ac 100644 --- a/src/Services/Api/ApiTokenPrincipalResolver.php +++ b/src/Services/Api/ApiTokenPrincipalResolver.php @@ -49,14 +49,17 @@ class ApiTokenPrincipalResolver /** Marks a fingerprint as such, so it is never mistaken for a name someone chose. */ protected const string FINGERPRINT_PREFIX = 'token:'; + protected ?string $derivedKey = null; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly LoggerService $loggerService, ) { } + /** * @return string Never the token, and never empty. * @throws \SimpleSAML\Error\ConfigurationError @@ -89,6 +92,7 @@ public function resolve(string $token): string return $name ?? $this->fingerprint($token); } + /** * Whether a name has any configured API token buried in it. * @@ -106,6 +110,7 @@ protected function carriesAToken(string $name): bool return false; } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -118,6 +123,7 @@ protected function fingerprint(string $token): string ); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index c32619a9..65c45e34 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -22,6 +22,7 @@ class Authorization public const string KEY_AUTHORIZATION = 'Authorization'; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly SspBridge $sspBridge, @@ -40,7 +41,7 @@ public function requireSimpleSAMLphpAdmin(bool $forceAdminAuthentication = false if ($forceAdminAuthentication) { try { $this->sspBridge->utils()->auth()->requireAdmin(); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { throw new AuthorizationException( Translate::noop('Unable to initiate admin authentication.'), previous: $exception, @@ -53,6 +54,7 @@ public function requireSimpleSAMLphpAdmin(bool $forceAdminAuthentication = false } } + /** * @param \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] $requiredScopes * @@ -82,6 +84,7 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope } } + /** * Authorize a state changing request, and say who it is being made by. * @@ -141,6 +144,7 @@ public function requireBearerTokenForAnyOfScope(Request $request, array $require return $this->apiTokenPrincipalResolver->resolve($token); } + protected function findToken(Request $request): ?string { $bearerToken = $this->helpers->http()->getBearerToken($request->headers->get(self::KEY_AUTHORIZATION)); diff --git a/src/Services/AuthContextService.php b/src/Services/AuthContextService.php index ce3b0056..9edac0a6 100644 --- a/src/Services/AuthContextService.php +++ b/src/Services/AuthContextService.php @@ -32,6 +32,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\Exception * @throws \Exception @@ -52,6 +53,7 @@ public function getAuthUserId(): string return $userId; } + /** * Checks if the user has the correct entitlements for the given permission. Throws an exception if user does not. * @param string $neededPermission The permissions needed @@ -84,6 +86,7 @@ public function requirePermission(string $neededPermission): void throw new RuntimeException('Missing entitlement for ' . $neededPermission); } + /** * @throws \Exception */ @@ -94,6 +97,7 @@ public function authenticate(): Simple return $simple; } + public function logout(): void { $simple = $this->authSimpleFactory->getDefaultAuthSource(); diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php index e61b3864..496339cd 100644 --- a/src/Services/AuthenticationService.php +++ b/src/Services/AuthenticationService.php @@ -10,7 +10,6 @@ use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Auth\Simple; use SimpleSAML\Auth\State; -use SimpleSAML\Error; use SimpleSAML\Error\Exception; use SimpleSAML\Error\NoState; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; @@ -39,6 +38,7 @@ class AuthenticationService */ public const string LOGIN_PARAM_USERNAME = 'core:username'; + /** * ID of auth source used during authn. */ @@ -48,7 +48,8 @@ class AuthenticationService * Ordered list of candidate user identifier attributes. * @var string[] */ - private array $userIdAttrs; + private readonly array $userIdAttrs; + /** * @throws \Exception @@ -71,15 +72,16 @@ public function __construct( $this->userIdAttrs = $this->moduleConfig->getUserIdentifierAttributes(); } + /** - * @param ServerRequestInterface $request - * @param OAuth2AuthorizationRequestInterface $authorizationRequest + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface $authorizationRequest * * @return array - * @throws Error\AuthSource - * @throws Exception + * @throws \SimpleSAML\Error\AuthSource + * @throws \SimpleSAML\Error\Exception * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws Error\UnserializableException + * @throws \SimpleSAML\Error\UnserializableException * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ @@ -116,9 +118,9 @@ public function processRequest( /** * @param array|null $state * - * @return UserEntity - * @throws Error\NotFound - * @throws Exception + * @return \SimpleSAML\Module\oidc\Entities\UserEntity + * @throws \SimpleSAML\Error\NotFound + * @throws \SimpleSAML\Error\Exception * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException @@ -127,7 +129,7 @@ public function getAuthenticateUser( ?array $state, ): UserEntity { if (!isset($state['Attributes']) || !is_array($state['Attributes'])) { - throw new Error\Exception('State array does not contain any attributes.'); + throw new Exception('State array does not contain any attributes.'); } $claims = $state['Attributes']; @@ -135,7 +137,7 @@ public function getAuthenticateUser( $userId = $this->userIdentifierResolver->resolve($this->userIdAttrs, $claims); if ($userId === null) { - throw new Error\Exception( + throw new Exception( sprintf( 'None of the configured user identifier attributes (%s) exist in the user attribute state.' . ' Available attributes are: %s.', @@ -156,7 +158,7 @@ public function getAuthenticateUser( } if (empty($state['Oidc']['RelyingPartyMetadata']['id'])) { - throw new Error\Exception('OIDC RelyingPartyMetadata ID does not exist in state.'); + throw new Exception('OIDC RelyingPartyMetadata ID does not exist in state.'); } $client = $this->clientRepository->findById((string)$state['Oidc']['RelyingPartyMetadata']['id']); @@ -172,8 +174,8 @@ public function getAuthenticateUser( /** * @param array|null $state * - * @return OAuth2AuthorizationRequestInterface - * @throws Exception + * @return \League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface + * @throws \SimpleSAML\Error\Exception */ public function getAuthorizationRequestFromState(array|null $state): OAuth2AuthorizationRequestInterface @@ -192,13 +194,13 @@ public function getAuthorizationRequestFromState(array|null $state): OAuth2Autho } /** - * @param Simple $authSimple - * @param OAuth2ClientEntityInterface $client - * @param ServerRequestInterface $request - * @param OAuth2AuthorizationRequestInterface $authorizationRequest + * @param \SimpleSAML\Auth\Simple $authSimple + * @param \League\OAuth2\Server\Entities\ClientEntityInterface $client + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface $authorizationRequest * * @return array - * @throws Error\AuthSource + * @throws \SimpleSAML\Error\AuthSource */ public function prepareStateArray( @@ -241,6 +243,7 @@ function (/** @param array-key $key */ $key) { return $state; } + /** * @return bool */ @@ -249,6 +252,7 @@ public function isCookieBasedAuthn(): bool return (bool) $this->sessionService->getIsCookieBasedAuthn(); } + /** * @return string|null */ @@ -257,6 +261,7 @@ public function getAuthSourceId(): ?string return $this->authSourceId; } + /** * @return string|null */ @@ -265,6 +270,7 @@ public function getSessionId(): ?string return $this->sessionService->getCurrentSession()->getSessionId(); } + /** * Resolve additional login parameters to pass to the authentication source, based on the authorization request. * @@ -288,9 +294,10 @@ protected function resolveLoginParams(OAuth2AuthorizationRequestInterface $autho return [self::LOGIN_PARAM_USERNAME => $loginHint]; } + /** - * @throws Error\BadRequest - * @throws Error\NotFound + * @throws \SimpleSAML\Error\BadRequest + * @throws \SimpleSAML\Error\NotFound * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -305,10 +312,11 @@ public function authenticate( $authSimple->login($loginParams); } + /** - * @throws Error\BadRequest + * @throws \SimpleSAML\Error\BadRequest * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws Error\NotFound + * @throws \SimpleSAML\Error\NotFound * @throws \JsonException */ public function authenticateForClient( @@ -318,6 +326,7 @@ public function authenticateForClient( $this->authenticate($this->authSimpleFactory->build($clientEntity), $loginParams); } + /** * Determine whether the given subject identifier corresponds to the End-User described by the released * (post-authproc) attributes. This is used to verify an `id_token_hint` subject against the authenticated @@ -349,6 +358,7 @@ public function subjectMatchesAttributes(string $subject, array $attributes): bo return hash_equals($canonicalSubject, $subject); } + /** * Store Relying on Party Association to the current session. * @throws \Exception @@ -369,6 +379,7 @@ protected function addRelyingPartyAssociation(ClientEntityInterface $oidcClient, ); } + /** * This is a wrapper around Auth/State::loadState that facilitates testing by * hiding the static method @@ -376,7 +387,7 @@ protected function addRelyingPartyAssociation(ClientEntityInterface $oidcClient, * @param array $queryParameters * * @return array|null - * @throws NoState + * @throws \SimpleSAML\Error\NoState */ public function manageState(array $queryParameters): ?array { @@ -395,6 +406,7 @@ public function manageState(array $queryParameters): ?array return $state; } + /** * Run authproc filters with the processing chain. * @@ -411,8 +423,8 @@ public function manageState(array $queryParameters): ?array * @param array $state * * @return void - * @throws Exception - * @throws Error\UnserializableException + * @throws \SimpleSAML\Error\Exception + * @throws \SimpleSAML\Error\UnserializableException * @throws \Exception */ protected function runAuthProcs(array &$state): void @@ -434,6 +446,7 @@ protected function runAuthProcs(array &$state): void $this->processingChainFactory->build($state)->processState($state); } + /** * Resolve per-client authproc filters from the OIDC relying party metadata * present in the authentication state (exposed there by prepareStateArray()). diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 1bfb48c8..6e18c3a2 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -25,21 +25,27 @@ class DatabaseMigration { /** Driver name reported for MySQL and MariaDB, the one driver needing its own DDL below. */ private const string DRIVER_MYSQL = 'mysql'; + private const string DRIVER_SQLITE = 'sqlite'; + private const string DRIVER_PGSQL = 'pgsql'; + private readonly Database $database; + public function __construct(?Database $database = null) { $this->database = $database ?? Database::getInstance(); } + public function isMigrated(): bool { return empty($this->getNotImplementedVersions()); } + public function getNotImplementedVersions(): array { $implementedVersions = $this->versions(); @@ -52,6 +58,7 @@ public function getNotImplementedVersions(): array }); } + public function versions(): array { $versionsTablename = $this->versionsTableName(); @@ -64,6 +71,7 @@ public function versions(): array ->fetchAll(PDO::FETCH_COLUMN, 0); } + public function migrate(): void { $versionsTablename = $this->versionsTableName(); @@ -260,11 +268,13 @@ public function migrate(): void } } + private function versionsTableName(): string { return $this->database->applyPrefix('oidc_migration_versions'); } + /** * @return void */ @@ -349,6 +359,7 @@ private function version20180305180300(): void ,); } + /** * @return void */ @@ -362,6 +373,7 @@ private function version20180425203400(): void ,); } + private function version20200517071100(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -372,6 +384,7 @@ private function version20200517071100(): void ,); } + private function version20200901163000(): void { $clientTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -382,6 +395,7 @@ private function version20200901163000(): void ,); } + private function version20210902113500(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -392,6 +406,7 @@ private function version20210902113500(): void ,); } + /** * Add auth_code_id column to access token and refresh token tables */ @@ -412,6 +427,7 @@ protected function version20210714113000(): void ,); } + /** * Add requested claims to authorization token */ @@ -425,6 +441,7 @@ protected function version20210823141300(): void ,); } + /** * Add table for allowed origins. */ @@ -447,6 +464,7 @@ protected function version20210827111300(): void ,); } + /** * Add post_logout_redirect_uri to client. */ @@ -460,6 +478,7 @@ protected function version20210908143500(): void ,); } + /** * Add backchannel_logout_uri to client */ @@ -473,6 +492,7 @@ protected function version20210916153400(): void ,); } + /** * Add logout_ticket table */ @@ -488,6 +508,7 @@ protected function version20210916173400(): void ,); } + /** * Add Entity Identifier column */ @@ -517,6 +538,7 @@ protected function version20240603141400(): void ,); } + /** * Add Client Registration Types column */ @@ -530,6 +552,7 @@ protected function version20240605145700(): void ,); } + private function version20240820132400(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -540,6 +563,7 @@ private function version20240820132400(): void ,); } + private function version20240828153300(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -550,6 +574,7 @@ private function version20240828153300(): void ,); } + private function version20240830153300(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -560,6 +585,7 @@ private function version20240830153300(): void ,); } + private function version20240902120000(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -570,6 +596,7 @@ private function version20240902120000(): void ,); } + private function version20240905120000(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -616,6 +643,7 @@ private function version20240905120000(): void } } + private function version20240906120000(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -626,6 +654,7 @@ private function version20240906120000(): void ,); } + private function version20250818163000(): void { $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -641,6 +670,7 @@ private function version20250818163000(): void ,); } + private function version20250908163000(): void { $issuerStateTableName = $this->database->applyPrefix(IssuerStateRepository::TABLE_NAME); @@ -655,6 +685,7 @@ private function version20250908163000(): void ,); } + private function version20250912163000(): void { $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -670,6 +701,7 @@ private function version20250912163000(): void ,); } + private function version20250913163000(): void { $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -681,6 +713,7 @@ private function version20250913163000(): void ,); } + private function version20250915163000(): void { $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -700,6 +733,7 @@ private function version20250915163000(): void ,); } + private function version20250916163000(): void { $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -711,6 +745,7 @@ private function version20250916163000(): void ,); } + private function version20250917163000(): void { $accessTokenTableName = $this->database->applyPrefix(AccessTokenRepository::TABLE_NAME); @@ -737,6 +772,7 @@ private function version20250917163000(): void ,); } + private function version20251021000001(): void { $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); @@ -747,6 +783,7 @@ private function version20251021000001(): void ,); } + private function version20251021000002(): void { $accessTokenTableName = $this->database->applyPrefix(AccessTokenRepository::TABLE_NAME); @@ -757,6 +794,7 @@ private function version20251021000002(): void ,); } + private function version20260109000001(): void { $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); @@ -804,6 +842,7 @@ private function version20260608130000(): void $this->database->write("CREATE INDEX $idxParExpiresAt ON $parTableName (expires_at)"); } + /** * Add storage for the OpenID Connect Dynamic Client Registration Access Token (a hash of it), used to * authenticate read requests at the Client Configuration Endpoint. @@ -912,6 +951,7 @@ private function version20260801000001(): void ); } + /** * Create the Status List entry table. * @@ -991,6 +1031,7 @@ private function version20260801000002(): void ); } + /** * Create the Status List audit table. * @@ -1026,6 +1067,7 @@ private function version20260801000003(): void $this->createIndex($idxCredentialIdHash, $auditTableName, 'credential_id_hash'); } + /** * Count of how many times a Status List's published token has been invalidated. * @@ -1058,6 +1100,7 @@ private function version20260801000004(): void ,); } + /** * Indexes the administration screens read Status List entries by. * @@ -1088,6 +1131,7 @@ private function version20260801000005(): void ); } + /** * Whether a table already has a column. * @@ -1132,6 +1176,7 @@ private function hasColumn(string $tableName, string $columnName): bool return $existing !== []; } + /** * Column type for a value which can reach a few hundred kilobytes. * @@ -1143,6 +1188,7 @@ private function largeTextColumnType(): string return $this->database->getDriver() === self::DRIVER_MYSQL ? 'MEDIUMTEXT' : 'TEXT'; } + /** * Column type for a point in time. * @@ -1154,6 +1200,7 @@ private function dateTimeColumnType(): string return $this->database->getDriver() === self::DRIVER_MYSQL ? 'DATETIME' : 'TIMESTAMP'; } + /** * Create an index unless it is already there. * @@ -1197,6 +1244,7 @@ private function createIndex( $this->database->write("CREATE {$unique}INDEX $indexName ON $tableName ($columns)"); } + /** * @param string[] $columnNames */ diff --git a/src/Services/ErrorResponder.php b/src/Services/ErrorResponder.php index 00267261..47662453 100644 --- a/src/Services/ErrorResponder.php +++ b/src/Services/ErrorResponder.php @@ -19,6 +19,7 @@ public function __construct( ) { } + /** * @throws \Throwable * @throws \SimpleSAML\Error\Error @@ -51,6 +52,7 @@ public function forException(Throwable $exception): Response ); } + /** * Create a JSON error response (as specified for the token endpoint), * regardless of any redirect URI contained in the exception. This is @@ -77,6 +79,7 @@ public function forExceptionJson(OAuthServerException $exception): JsonResponse ); } + /** * Log an OAuth error that is about to be returned to the client. This is the single place every endpoint's * error response passes through, so it ensures the descriptive error type, description and hint (which the @@ -111,6 +114,7 @@ private function logOAuthServerException(OAuthServerException $exception): void } } + private function logUnexpectedException(Throwable $exception): void { $context = ['exception' => $exception::class]; diff --git a/src/Services/ExpiredEntriesCleaner.php b/src/Services/ExpiredEntriesCleaner.php index 9343c045..1c2b4579 100644 --- a/src/Services/ExpiredEntriesCleaner.php +++ b/src/Services/ExpiredEntriesCleaner.php @@ -28,6 +28,7 @@ public function __construct( ) { } + public function clean(): void { $this->accessTokenRepository->removeExpired(); diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index 446f0e8e..cc4c449b 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -27,6 +27,7 @@ public function __construct( ) { } + /** * @psalm-suppress MixedAssignment */ @@ -131,6 +132,7 @@ public function buildFor( ); } + /** * @param string $jwsAlgorithm JWS Algorithm designation (like RS256, * RS384...). diff --git a/src/Services/LoggerService.php b/src/Services/LoggerService.php index 58bffa9c..570c68f6 100644 --- a/src/Services/LoggerService.php +++ b/src/Services/LoggerService.php @@ -17,41 +17,49 @@ public function emergency(string|Stringable $message, array $context = []): void Logger::emergency((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function alert(string|Stringable $message, array $context = []): void { Logger::alert((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function critical(string|Stringable $message, array $context = []): void { Logger::critical((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function error(string|Stringable $message, array $context = []): void { Logger::error((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function warning(string|Stringable $message, array $context = []): void { Logger::warning((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function notice(string|Stringable $message, array $context = []): void { Logger::notice((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function info(string|Stringable $message, array $context = []): void { Logger::info((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function debug(string|Stringable $message, array $context = []): void { Logger::debug((string)$message . ($context ? " " . var_export($context, true) : "")); } + public function log(mixed $level, string|Stringable $message, array $context = []): void { match ($level) { @@ -67,6 +75,7 @@ public function log(mixed $level, string|Stringable $message, array $context = [ }; } + public static function getInstance(): self { return new self(); diff --git a/src/Services/LogoutTokenBuilder.php b/src/Services/LogoutTokenBuilder.php index 230ad882..6b0a6d7e 100644 --- a/src/Services/LogoutTokenBuilder.php +++ b/src/Services/LogoutTokenBuilder.php @@ -17,6 +17,7 @@ class LogoutTokenBuilder { protected Core $core; + public function __construct( protected ModuleConfig $moduleConfig = new ModuleConfig(), protected LoggerService $loggerService = new LoggerService(), @@ -28,6 +29,7 @@ public function __construct( ))->build(); } + /** * @throws \Exception * @throws \League\OAuth2\Server\Exception\OAuthServerException diff --git a/src/Services/NonceService.php b/src/Services/NonceService.php index 2e7aecc2..edb3839d 100644 --- a/src/Services/NonceService.php +++ b/src/Services/NonceService.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Services; +use Exception; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; @@ -21,6 +22,7 @@ public function __construct( ) { } + /** * @throws \Exception */ @@ -52,6 +54,7 @@ public function generateNonce(): string )->getToken(); } + public function validateNonce(string $nonce): bool { try { @@ -78,12 +81,13 @@ public function validateNonce(string $nonce): bool $this->loggerService->debug('Nonce validation succeeded.'); return true; - } catch (\Exception $e) { + } catch (Exception $e) { $this->loggerService->warning('Nonce validation failed: ' . $e->getMessage()); return false; } } + /** * The key a nonce says it was signed with. * diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index e3e102af..18cfabc3 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -22,6 +22,7 @@ class OpMetadataService { private array $metadata; + /** * @throws \Exception */ @@ -34,6 +35,7 @@ public function __construct( $this->initMetadata(); } + /** * Initialize metadata array. * @throws \Exception @@ -122,6 +124,7 @@ private function initMetadata(): void // pre-authorized_grant_anonymous_access_supported // TODO mivanci Make configurable } + /** * Get OIDC Provider (OP) metadata array. * diff --git a/src/Services/SessionMessagesService.php b/src/Services/SessionMessagesService.php index d91ad23b..c47dd866 100644 --- a/src/Services/SessionMessagesService.php +++ b/src/Services/SessionMessagesService.php @@ -12,6 +12,7 @@ public function __construct(private readonly Session $session) { } + /** * @throws \Exception */ @@ -20,6 +21,7 @@ public function addMessage(string $value): void $this->session->setData('message', uniqid(), $value); } + /** * @return array */ diff --git a/src/Services/SessionService.php b/src/Services/SessionService.php index 2349ae84..ac569ab4 100644 --- a/src/Services/SessionService.php +++ b/src/Services/SessionService.php @@ -26,16 +26,19 @@ public function __construct(protected Session $session) { } + public function getCurrentSession(): Session { return $this->session; } + public function getSessionById(string $id): ?Session { return Session::getSession($id); } + /** * @throws \Exception */ @@ -49,6 +52,7 @@ public function setIsCookieBasedAuthn(bool $isCookieBasedAuthn): void ); } + public function getIsCookieBasedAuthn(): ?bool { /** @var ?bool $isCookieBasedAuthn */ @@ -64,6 +68,7 @@ public function getIsCookieBasedAuthn(): ?bool return null; } + /** * @throws \Exception */ @@ -89,11 +94,13 @@ public function addRelyingPartyAssociation(RelyingPartyAssociationInterface $ass ); } + public function getRelyingPartyAssociations(): array { return self::getRelyingPartyAssociationsForSession($this->session); } + /** * @return \SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface[] */ @@ -112,6 +119,7 @@ public static function getRelyingPartyAssociationsForSession(Session $session): ); } + /** * @throws \Exception */ @@ -120,6 +128,7 @@ public function clearRelyingPartyAssociations(): void self::clearRelyingPartyAssociationsForSession($this->session); } + /** * @throws \Exception */ @@ -133,6 +142,7 @@ public static function clearRelyingPartyAssociationsForSession(Session $session) ); } + /** * @throws \Exception */ @@ -146,6 +156,7 @@ public function setIsAuthnPerformedInPreviousRequest(bool $isAuthnPerformedInPre ); } + public function getIsAuthnPerformedInPreviousRequest(): bool { return (bool) $this->session->getData( @@ -154,6 +165,7 @@ public function getIsAuthnPerformedInPreviousRequest(): bool ); } + /** * @throws \Exception */ @@ -162,6 +174,7 @@ public function registerLogoutHandler(string $authSourceId, string $className, s $this->session->registerLogoutHandler($authSourceId, $className, $functionName); } + /** * Set indication if logout was initiated using OIDC protocol. * @throws \Exception @@ -176,6 +189,7 @@ public function setIsOidcInitiatedLogout(bool $isOidcInitiatedLogout): void ); } + /** * Helper method to get indication if logout was initiated using OIDC protocol for given session. */ diff --git a/src/Services/StateService.php b/src/Services/StateService.php index 11454393..e21ba421 100644 --- a/src/Services/StateService.php +++ b/src/Services/StateService.php @@ -12,9 +12,10 @@ class StateService { /** - * @var State + * @var \SimpleSAML\Auth\State */ - private State $authState; + private readonly State $authState; + /** * @@ -24,14 +25,16 @@ public function __construct() $this->authState = new State(); } + /** - * @return State + * @return \SimpleSAML\Auth\State */ public function getAuthState(): State { return $this->authState; } + /** * @param string $id * @param string $stage diff --git a/src/StatusList/Contracts/StatusIndexAllocatorInterface.php b/src/StatusList/Contracts/StatusIndexAllocatorInterface.php index d8b71ca0..5cb66594 100644 --- a/src/StatusList/Contracts/StatusIndexAllocatorInterface.php +++ b/src/StatusList/Contracts/StatusIndexAllocatorInterface.php @@ -28,7 +28,7 @@ interface StatusIndexAllocatorInterface * @param string $credentialConfigurationId Recorded on the entry, since a pool serves several * configurations and the pool alone does not say which one this was. * @param ?string $subjectRef Keyed hash of the user identifier, never the identifier itself. - * @param ?DateTimeImmutable $expiresAt When the credential expires, or null if it never does. A + * @param ?\DateTimeImmutable $expiresAt When the credential expires, or null if it never does. A * list holding a non-expiring entry can never be retired. * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException When no index could be claimed in * any list. Running out of probes in one list is not this: that rotates to a new list and retries. diff --git a/src/StatusList/Contracts/StatusListTokenProviderInterface.php b/src/StatusList/Contracts/StatusListTokenProviderInterface.php index 263e206c..0abca10b 100644 --- a/src/StatusList/Contracts/StatusListTokenProviderInterface.php +++ b/src/StatusList/Contracts/StatusListTokenProviderInterface.php @@ -19,7 +19,8 @@ interface StatusListTokenProviderInterface { /** - * @return ?StatusListTokenResult Null when there is no such list, or it has been retired. + * @return ?\SimpleSAML\Module\oidc\StatusList\Values\StatusListTokenResult Null when there is no such list, or + * it has been retired. * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException When a token is needed but can not * be produced -- the signing key is gone, signing failed, or concurrent changes kept superseding it. */ diff --git a/src/StatusList/Contracts/StatusUpdaterInterface.php b/src/StatusList/Contracts/StatusUpdaterInterface.php index 5ce01b9d..ed9d9ef7 100644 --- a/src/StatusList/Contracts/StatusUpdaterInterface.php +++ b/src/StatusList/Contracts/StatusUpdaterInterface.php @@ -28,6 +28,7 @@ interface StatusUpdaterInterface */ public function setStatus(string $statusListId, int $idx, StatusTypeEnum $status): bool; + /** * Check that a list could hold this status, without changing anything. * @@ -43,6 +44,7 @@ public function setStatus(string $statusListId, int $idx, StatusTypeEnum $status */ public function enforceCanRepresent(string $statusListId, StatusTypeEnum $status): void; + /** * The status currently recorded, or null when the entry does not exist or was never allocated. * diff --git a/src/StatusList/CredentialStatusIssuer.php b/src/StatusList/CredentialStatusIssuer.php index 3ab13f95..75d9299a 100644 --- a/src/StatusList/CredentialStatusIssuer.php +++ b/src/StatusList/CredentialStatusIssuer.php @@ -38,14 +38,16 @@ public function __construct( ) { } + /** * @param string $credentialConfigurationId Which configuration is being issued, deciding both * whether there is a pool at all and which one. * @param string $credentialId The `jti` the credential will carry, which has to be minted before * this is called, since claiming the index and recording what it was claimed for are one operation. * @param string $userIdentifier Hashed here and never stored as given. - * @param ?DateTimeImmutable $expiresAt When the credential expires, or null if it never does. - * @return ?StatusClaim Null when this configuration does not allocate Status List entries. + * @param ?\DateTimeImmutable $expiresAt When the credential expires, or null if it never does. + * @return ?\SimpleSAML\OpenID\TokenStatusList\StatusClaim Null when this configuration does not allocate + * Status List entries. * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException diff --git a/src/StatusList/CredentialStatusService.php b/src/StatusList/CredentialStatusService.php index de00a0c9..5b8336c1 100644 --- a/src/StatusList/CredentialStatusService.php +++ b/src/StatusList/CredentialStatusService.php @@ -40,12 +40,13 @@ public function __construct( ) { } + /** * @param string $credentialId The identifier the credential carries as its `jti`. * @param ?string $actorRef Who asked, as a name rather than a secret. Null for an unattended * change, which is what a scheduled task is. - * @return ?CredentialStatusChange Null when no credential of that identifier can be acted on, - * which covers one that was never issued here and one which has expired alike. + * @return ?\SimpleSAML\Module\oidc\StatusList\Values\CredentialStatusChange Null when no credential of that + * identifier can be acted on, which covers one that was never issued here and one which has expired alike. * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException When the list this * credential sits in can not represent the requested status. Permanent, not worth retrying. * @throws \SimpleSAML\Module\oidc\Exceptions\StatusConflictException When concurrent changes kept @@ -152,6 +153,7 @@ public function setStatus( ); } + /** * The status a credential currently holds, or null when there is none to report. * @@ -167,6 +169,7 @@ public function getStatusValue(string $credentialId): ?int return $this->isActionable($entry) ? $entry?->getStatus() : null; } + /** * Whether a status change against this entry would mean anything. * diff --git a/src/StatusList/DbStatusIndexAllocator.php b/src/StatusList/DbStatusIndexAllocator.php index 171e53a6..c0499864 100644 --- a/src/StatusList/DbStatusIndexAllocator.php +++ b/src/StatusList/DbStatusIndexAllocator.php @@ -82,6 +82,7 @@ class DbStatusIndexAllocator implements StatusIndexAllocatorInterface */ protected const string PREPARING_LIST_STALE_AFTER = 'PT2M'; + public function __construct( protected readonly StatusListRepository $statusListRepository, protected readonly StatusListEntryRepository $statusListEntryRepository, @@ -93,6 +94,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException @@ -187,6 +189,7 @@ public function allocateFor( ); } + /** * A list of this pool and lane which is accepting allocations, creating one if there is none. * @@ -229,6 +232,7 @@ protected function selectList( ); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ @@ -269,6 +273,7 @@ protected function findOpenListWithRoom( return null; } + /** * Waits, for a bounded time, on a list another request is still seeding. * @@ -328,6 +333,7 @@ protected function awaitListBeingPrepared( return null; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException * @throws \Exception @@ -345,6 +351,7 @@ protected function isListBeingPrepared( ) !== []; } + /** * Whether the list this request has just created turned out to be redundant. * @@ -388,6 +395,7 @@ protected function isSupersededAfterCreating( ) !== []; } + /** * The point past which a list which is still not open counts as abandoned rather than in progress. * @@ -399,6 +407,7 @@ protected function staleBefore(): DateTimeImmutable ->sub(new DateInterval(self::PREPARING_LIST_STALE_AFTER)); } + /** * Whether a list is below the point at which a successor should be started. * @@ -413,6 +422,7 @@ protected function hasRoom(StatusListRecord $statusList): bool ); } + /** * Creates a list, seeds every index, and opens it for allocation. * @@ -549,6 +559,7 @@ protected function createList( return $statusList; } + /** * Recovers from an insert which did not succeed. * @@ -609,6 +620,7 @@ protected function adoptListCreatedByAnotherRequest( ); } + /** * Tries random indices in one list until one is free or the budget runs out. * diff --git a/src/StatusList/DbStatusListTokenProvider.php b/src/StatusList/DbStatusListTokenProvider.php index 02eab3f6..546c5d94 100644 --- a/src/StatusList/DbStatusListTokenProvider.php +++ b/src/StatusList/DbStatusListTokenProvider.php @@ -51,6 +51,7 @@ class DbStatusListTokenProvider implements StatusListTokenProviderInterface */ protected const int MAX_PUBLISH_ATTEMPTS = 3; + public function __construct( protected readonly StatusListRepository $statusListRepository, protected readonly StatusListEntryRepository $statusListEntryRepository, @@ -64,6 +65,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ @@ -90,10 +92,12 @@ public function getToken(string $statusListId): ?StatusListTokenResult return $this->publish($statusList->getId()); } + /** * Builds, signs and publishes a token, or adopts one another request published in the meantime. * - * @return ?StatusListTokenResult Null when the list turned out to be gone or retired. + * @return ?\SimpleSAML\Module\oidc\StatusList\Values\StatusListTokenResult Null when the list turned out to be + * gone or retired. * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ protected function publish(string $statusListId): ?StatusListTokenResult @@ -204,6 +208,7 @@ protected function publish(string $statusListId): ?StatusListTokenResult ); } + /** * The published token, if there is one and it is still worth serving. * @@ -239,6 +244,7 @@ protected function publishedResult(StatusListRecord $statusList, DateTimeImmutab return new StatusListTokenResult($token, $statusList->getTtlSeconds(), $issuedAt, $expiresAt); } + /** * Builds the list from its entries and signs it. * @@ -293,6 +299,7 @@ protected function sign( } } + /** * How the token says who signed it and with which key. * @@ -333,6 +340,7 @@ protected function identityFor(StatusListKeyProfileEnum $keyProfile, KeyPair $ke ]; } + /** * How close to expiry a published token is replaced rather than served. * diff --git a/src/StatusList/DbStatusUpdater.php b/src/StatusList/DbStatusUpdater.php index 487e07ef..b2b2ece1 100644 --- a/src/StatusList/DbStatusUpdater.php +++ b/src/StatusList/DbStatusUpdater.php @@ -40,6 +40,7 @@ class DbStatusUpdater implements StatusUpdaterInterface */ protected const int MAX_UPDATE_ATTEMPTS = 3; + public function __construct( protected readonly StatusListRepository $statusListRepository, protected readonly StatusListEntryRepository $statusListEntryRepository, @@ -47,6 +48,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException @@ -126,6 +128,7 @@ public function setStatus(string $statusListId, int $idx, StatusTypeEnum $status ); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -136,6 +139,7 @@ public function enforceCanRepresent(string $statusListId, StatusTypeEnum $status $this->enforceStatusFits($this->requireList($statusListId), $status); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException * @throws \Exception @@ -151,6 +155,7 @@ protected function requireList(string $statusListId): StatusListRecord return $statusList; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ @@ -165,6 +170,7 @@ public function getStatusValue(string $statusListId, int $idx): ?int return $entry->getStatus(); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ @@ -194,6 +200,7 @@ protected function requireAllocatedEntry(string $statusListId, int $idx): Status return $entry; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException */ diff --git a/src/StatusList/StatusListContentHasher.php b/src/StatusList/StatusListContentHasher.php index c0ee4542..2b71646f 100644 --- a/src/StatusList/StatusListContentHasher.php +++ b/src/StatusList/StatusListContentHasher.php @@ -34,6 +34,7 @@ class StatusListContentHasher */ final public const string VERSION = 'v1'; + /** * @param array $nonValidStatuses Index to status for every entry which is not Valid. Every * index absent from this map is Valid, including the ones never allocated, which is the same diff --git a/src/StatusList/StatusListKeyResolver.php b/src/StatusList/StatusListKeyResolver.php index 05fac661..063eacf6 100644 --- a/src/StatusList/StatusListKeyResolver.php +++ b/src/StatusList/StatusListKeyResolver.php @@ -7,6 +7,7 @@ use SimpleSAML\Module\oidc\Exceptions\StatusListException; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use Throwable; /** * Which key a Status List Token is signed with. @@ -30,6 +31,7 @@ public function __construct( ) { } + /** * The key newly created lists are bound to. * @@ -44,7 +46,7 @@ public function getCurrent(): SignatureKeyPair { try { return $this->moduleConfig->getActiveVciSignatureKeyPair(); - } catch (\Throwable $throwable) { + } catch (Throwable $throwable) { throw new StatusListException( 'No Verifiable Credential Issuance signature key pair is configured, so Status Lists ' . 'can not be signed: ' . $throwable->getMessage(), @@ -54,6 +56,7 @@ public function getCurrent(): SignatureKeyPair } } + /** * The identifier stored against a list, being either the configured key ID or, when none was * configured, the thumbprint derived from the key itself. It has to be exactly what the key pair @@ -66,6 +69,7 @@ public function getCurrentKeyId(): string return $this->getCurrent()->getKeyPair()->getKeyId(); } + /** * The key a list was created with. * diff --git a/src/StatusList/StatusListLifecycle.php b/src/StatusList/StatusListLifecycle.php index 4f928ccb..3c4e282d 100644 --- a/src/StatusList/StatusListLifecycle.php +++ b/src/StatusList/StatusListLifecycle.php @@ -92,6 +92,7 @@ class StatusListLifecycle /** Ceiling on how many batches of audit rows one run will remove. */ protected const int MAX_AUDIT_BATCHES = 200; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly StatusListRepository $statusListRepository, @@ -103,6 +104,7 @@ public function __construct( ) { } + /** * Runs every step, and lets each of them fail on its own. * @@ -161,6 +163,7 @@ public function run(): StatusListLifecycleReport ); } + /** * Runs one step, and turns anything it throws into something the caller can carry on past. * @@ -180,6 +183,7 @@ protected function attempt(string $description, callable $step, array &$failures } } + /** * Forgets which credential held which index, once that credential has expired. * @@ -216,6 +220,7 @@ public function clearExpiredCredentialLinkage(): int return $cleared; } + /** * Stops lists being allocation targets when the configuration they were created under is no longer * the current one. @@ -288,6 +293,7 @@ public function deactivateSupersededStatusLists(): int return $deactivated; } + /** * Says so when a pool has stopped using one of the two expiry lanes. * @@ -332,6 +338,7 @@ protected function warnIfLanesAreBeingSuperseded(array $currentTargets): void } } + /** * Retires the lists which nothing can still be holding. * @@ -417,6 +424,7 @@ public function retireSpentStatusLists(): int return $retired; } + /** * Removes the entry rows of lists which have been retired. * @@ -473,6 +481,7 @@ public function purgeRetiredStatusListEntries(): int return $purged; } + /** * Prunes the status audit trail to the configured retention. * diff --git a/src/StatusList/StatusListRateLimiter.php b/src/StatusList/StatusListRateLimiter.php index 31596b45..eff6a2f6 100644 --- a/src/StatusList/StatusListRateLimiter.php +++ b/src/StatusList/StatusListRateLimiter.php @@ -31,6 +31,7 @@ class StatusListRateLimiter /** Public so that a caller refusing a request can tell the client when the window turns over. */ final public const int WINDOW_SECONDS = 60; + public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly ?ProtocolCache $protocolCache, @@ -39,6 +40,7 @@ public function __construct( ) { } + /** * @param ?string $clientIdentifier Whatever the request appears to come from, or null when that * could not be established -- in which case there is nothing to count against and the request goes diff --git a/src/StatusList/StatusListReconciler.php b/src/StatusList/StatusListReconciler.php index e8449d8e..8ece8940 100644 --- a/src/StatusList/StatusListReconciler.php +++ b/src/StatusList/StatusListReconciler.php @@ -47,6 +47,7 @@ class StatusListReconciler */ protected const int MAX_BATCHES = 1000; + public function __construct( protected readonly StatusListRepository $statusListRepository, protected readonly StatusListEntryRepository $statusListEntryRepository, @@ -55,6 +56,7 @@ public function __construct( ) { } + /** * @return int How many published tokens were found not to describe their list, and invalidated. * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -137,6 +139,7 @@ public function reconcile(): int return $invalidated; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ diff --git a/src/StatusList/SubjectRefHasher.php b/src/StatusList/SubjectRefHasher.php index 65746671..54c02fe5 100644 --- a/src/StatusList/SubjectRefHasher.php +++ b/src/StatusList/SubjectRefHasher.php @@ -41,14 +41,17 @@ class SubjectRefHasher /** Length of the derived key, matching the output size of the hash it keys. */ protected const int DERIVED_KEY_BYTES = 32; + /** Derived once per request, since every allocation in a batch issuance needs it. */ protected ?string $derivedKey = null; + public function __construct( protected readonly ModuleConfig $moduleConfig, ) { } + /** * @return string 64 lowercase hex characters, sized for the CHAR(64) column it is stored in. * @throws \SimpleSAML\Error\ConfigurationError @@ -58,6 +61,7 @@ public function hash(string $userIdentifier): string return hash_hmac(self::HASH_ALGORITHM, $userIdentifier, $this->deriveKey()); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ diff --git a/src/StatusList/Values/AllocationAttempt.php b/src/StatusList/Values/AllocationAttempt.php index 134eeb3a..8e9b194c 100644 --- a/src/StatusList/Values/AllocationAttempt.php +++ b/src/StatusList/Values/AllocationAttempt.php @@ -24,6 +24,7 @@ class AllocationAttempt { protected bool $hasWaitedInVain = false; + /** * Whether this request has already waited on another request's list without one appearing. */ @@ -32,6 +33,7 @@ public function hasWaitedInVain(): bool return $this->hasWaitedInVain; } + public function recordWaitedInVain(): void { $this->hasWaitedInVain = true; diff --git a/src/StatusList/Values/CredentialStatusChange.php b/src/StatusList/Values/CredentialStatusChange.php index 3674c193..60ae16f2 100644 --- a/src/StatusList/Values/CredentialStatusChange.php +++ b/src/StatusList/Values/CredentialStatusChange.php @@ -25,16 +25,19 @@ public function __construct( ) { } + public function getStatusListId(): string { return $this->statusListId; } + public function getIdx(): int { return $this->idx; } + /** * The status observed immediately before the change, as a raw value. * @@ -46,11 +49,13 @@ public function getPreviousStatus(): int return $this->previousStatus; } + public function getStatus(): StatusTypeEnum { return $this->status; } + /** * Whether this call is what put the credential into that status, as opposed to finding it there. */ diff --git a/src/StatusList/Values/DatabaseRowValuesTrait.php b/src/StatusList/Values/DatabaseRowValuesTrait.php index 4a723b61..b8829ac7 100644 --- a/src/StatusList/Values/DatabaseRowValuesTrait.php +++ b/src/StatusList/Values/DatabaseRowValuesTrait.php @@ -29,6 +29,7 @@ protected static function asString(array $row, string $key): string ); } + /** * @param array $row */ @@ -44,6 +45,7 @@ protected static function asNullableString(array $row, string $key): ?string return is_scalar($value) ? (string)$value : null; } + /** * @param array $row * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -62,6 +64,7 @@ protected static function asInt(array $row, string $key): int return (int)$value; } + /** * @param array $row */ @@ -77,6 +80,7 @@ protected static function asNullableInt(array $row, string $key): ?int return is_string($value) && preg_match('/^-?\d+$/', $value) === 1 ? (int)$value : null; } + /** * @param array $row */ @@ -95,6 +99,7 @@ protected static function asBool(array $row, string $key): bool return (bool)$value; } + /** * @param array $row */ diff --git a/src/StatusList/Values/StatusAllocation.php b/src/StatusList/Values/StatusAllocation.php index 5acab76b..ab04b665 100644 --- a/src/StatusList/Values/StatusAllocation.php +++ b/src/StatusList/Values/StatusAllocation.php @@ -23,11 +23,13 @@ public function __construct( ) { } + public function getStatusListId(): string { return $this->statusListId; } + /** * The reference as the credential carries it, for the `status` claim. */ @@ -36,11 +38,13 @@ public function getStatusReference(): StatusReference return $this->statusReference; } + public function getUri(): string { return $this->statusReference->getUri(); } + public function getIdx(): int { return $this->statusReference->getIdx(); diff --git a/src/StatusList/Values/StatusListAllocationTarget.php b/src/StatusList/Values/StatusListAllocationTarget.php index 7c51716b..0430f57c 100644 --- a/src/StatusList/Values/StatusListAllocationTarget.php +++ b/src/StatusList/Values/StatusListAllocationTarget.php @@ -30,16 +30,19 @@ public function __construct( ) { } + public function getPoolId(): string { return $this->poolId; } + public function getPolicyFingerprint(): string { return $this->policyFingerprint; } + public function getExpiryLane(): StatusListExpiryLaneEnum { return $this->expiryLane; diff --git a/src/StatusList/Values/StatusListEntryRecord.php b/src/StatusList/Values/StatusListEntryRecord.php index 7059e306..c8dc3ef5 100644 --- a/src/StatusList/Values/StatusListEntryRecord.php +++ b/src/StatusList/Values/StatusListEntryRecord.php @@ -22,9 +22,10 @@ class StatusListEntryRecord { use DatabaseRowValuesTrait; + /** * @param int $status Raw status value, which may be one this library does not name. - * @param ?DateTimeImmutable $expiresAt When the credential occupying this index expires, or null + * @param ?\DateTimeImmutable $expiresAt When the credential occupying this index expires, or null * if it never does. A list holding a non-expiring entry can never be retired. * @param ?string $subjectRef Keyed hash of the user identifier, never the identifier itself. */ @@ -43,26 +44,31 @@ public function __construct( ) { } + public function getStatusListId(): string { return $this->statusListId; } + public function getIdx(): int { return $this->idx; } + public function isAllocated(): bool { return $this->allocated; } + public function getStatus(): int { return $this->status; } + /** * The Status Type for this entry, or null when the stored value is application specific or not yet * registered. Callers deciding only whether the credential is usable should compare getStatus() @@ -73,46 +79,55 @@ public function getStatusType(): ?StatusTypeEnum return StatusTypeEnum::tryFrom($this->status); } + public function getExpiresAt(): ?DateTimeImmutable { return $this->expiresAt; } + public function isNonExpiring(): bool { return !$this->expiresAt instanceof DateTimeImmutable; } + public function getCredentialId(): ?string { return $this->credentialId; } + public function getCredentialIdHash(): ?string { return $this->credentialIdHash; } + public function getCredentialConfigurationId(): ?string { return $this->credentialConfigurationId; } + public function getSubjectRef(): ?string { return $this->subjectRef; } + public function getIssuedAt(): ?DateTimeImmutable { return $this->issuedAt; } + public function getUpdatedAt(): ?DateTimeImmutable { return $this->updatedAt; } + /** * @param array $row * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException diff --git a/src/StatusList/Values/StatusListLifecycleReport.php b/src/StatusList/Values/StatusListLifecycleReport.php index 45c51ee1..ae43093b 100644 --- a/src/StatusList/Values/StatusListLifecycleReport.php +++ b/src/StatusList/Values/StatusListLifecycleReport.php @@ -33,31 +33,37 @@ public function __construct( ) { } + public function getClearedLinkages(): int { return $this->clearedLinkages; } + public function getDeactivatedStatusLists(): int { return $this->deactivatedStatusLists; } + public function getRetiredStatusLists(): int { return $this->retiredStatusLists; } + public function getPurgedEntries(): int { return $this->purgedEntries; } + public function getPrunedAuditRows(): int { return $this->prunedAuditRows; } + /** * @return string[] */ @@ -66,6 +72,7 @@ public function getFailures(): array return $this->failures; } + /** * Whether the run changed anything at all, so that a cron which has nothing to do stays quiet. */ diff --git a/src/StatusList/Values/StatusListPool.php b/src/StatusList/Values/StatusListPool.php index 092891f4..d8da7d6d 100644 --- a/src/StatusList/Values/StatusListPool.php +++ b/src/StatusList/Values/StatusListPool.php @@ -82,6 +82,7 @@ class StatusListPool */ protected const int CAPACITY_MULTIPLE = 8; + /** * @param string $id Pool identifier, being the key it is configured under. * @param string[] $credentialConfigurationIds Credential configurations which allocate from this pool. @@ -103,6 +104,7 @@ public function __construct( $this->validate(); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -215,6 +217,7 @@ protected function validate(): void } } + /** * How many seconds a duration is worth, measured from a fixed point rather than from now. * @@ -231,11 +234,13 @@ protected static function toSeconds(DateInterval $interval): int return (new DateTimeImmutable('@0'))->add($interval)->getTimestamp(); } + public function getId(): string { return $this->id; } + /** * @return string[] */ @@ -244,21 +249,25 @@ public function getCredentialConfigurationIds(): array return $this->credentialConfigurationIds; } + public function hasCredentialConfigurationId(string $credentialConfigurationId): bool { return in_array($credentialConfigurationId, $this->credentialConfigurationIds, true); } + public function getBits(): int { return $this->bits; } + public function getCapacity(): int { return $this->capacity; } + /** * @return \SimpleSAML\OpenID\Codebooks\StatusTypeEnum[] */ @@ -267,11 +276,13 @@ public function getAllowedStatuses(): array return $this->allowedStatuses; } + public function isStatusAllowed(StatusTypeEnum $status): bool { return in_array($status, $this->allowedStatuses, true); } + /** * The allowed statuses in the form persisted on the Status List row, being their values in * ascending order and comma separated. @@ -288,41 +299,49 @@ public function getAllowedStatusesAsString(): string return implode(',', $values); } + public function getTtl(): DateInterval { return $this->ttl; } + public function getTtlInSeconds(): int { return self::toSeconds($this->ttl); } + public function getTokenValidity(): DateInterval { return $this->tokenValidity; } + public function getTokenValidityInSeconds(): int { return self::toSeconds($this->tokenValidity); } + public function getRefreshInterval(): DateInterval { return $this->refreshInterval; } + public function getRefreshIntervalInSeconds(): int { return self::toSeconds($this->refreshInterval); } + public function getKeyProfile(): StatusListKeyProfileEnum { return $this->keyProfile; } + /** * Hash of the immutable part of this pool's policy, which allocation filters candidate lists on. * @@ -357,6 +376,7 @@ public function getPolicyFingerprint(string $signingKeyId): string ); } + /** * Builds a pool from its configured settings, applying the defaults for everything left out. * @@ -381,6 +401,7 @@ public static function fromConfig( ); } + /** * @param array $config * @return string[] @@ -418,6 +439,7 @@ protected static function resolveCredentialConfigurationIds(string $id, array $c return array_values(array_unique($ids)); } + /** * @param array $config * @throws \SimpleSAML\Error\ConfigurationError @@ -445,6 +467,7 @@ protected static function resolveInt(string $id, array $config, string $key, int return $value; } + /** * @param array $config * @throws \SimpleSAML\Error\ConfigurationError @@ -483,6 +506,7 @@ protected static function resolveInterval( } } + /** * @param array $config * @return \SimpleSAML\OpenID\Codebooks\StatusTypeEnum[] @@ -535,6 +559,7 @@ protected static function resolveAllowedStatuses(string $id, array $config): arr return $statuses; } + /** * @param array $config * @throws \SimpleSAML\Error\ConfigurationError diff --git a/src/StatusList/Values/StatusListPoolBag.php b/src/StatusList/Values/StatusListPoolBag.php index 1088752b..b81b8dd5 100644 --- a/src/StatusList/Values/StatusListPoolBag.php +++ b/src/StatusList/Values/StatusListPoolBag.php @@ -24,6 +24,7 @@ class StatusListPoolBag /** @var array Credential configuration ID to the ID of the pool it allocates from. */ protected array $poolIdsByCredentialConfigurationId = []; + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -34,6 +35,7 @@ public function __construct(StatusListPool ...$pools) } } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -67,6 +69,7 @@ protected function add(StatusListPool $pool): void $this->pools[$pool->getId()] = $pool; } + /** * @return array */ @@ -75,16 +78,19 @@ public function getAll(): array return $this->pools; } + public function getById(string $poolId): ?StatusListPool { return $this->pools[$poolId] ?? null; } + public function isEmpty(): bool { return $this->pools === []; } + /** * The pool a credential configuration allocates from, or null if it is not configured to use * Status Lists at all. Credentials of such a configuration are issued without a `status` claim. @@ -96,6 +102,7 @@ public function getForCredentialConfigurationId(string $credentialConfigurationI return $poolId === null ? null : $this->getById($poolId); } + /** * @return string[] */ @@ -104,6 +111,7 @@ public function getAllCredentialConfigurationIds(): array return array_keys($this->poolIdsByCredentialConfigurationId); } + /** * @param array $config Pool identifier to that pool's settings. * @throws \SimpleSAML\Error\ConfigurationError diff --git a/src/StatusList/Values/StatusListReconciliationCandidate.php b/src/StatusList/Values/StatusListReconciliationCandidate.php index 34cc68ab..555eb6c4 100644 --- a/src/StatusList/Values/StatusListReconciliationCandidate.php +++ b/src/StatusList/Values/StatusListReconciliationCandidate.php @@ -18,6 +18,7 @@ class StatusListReconciliationCandidate { use DatabaseRowValuesTrait; + public function __construct( protected readonly string $id, protected readonly int $bits, @@ -27,31 +28,37 @@ public function __construct( ) { } + public function getId(): string { return $this->id; } + public function getBits(): int { return $this->bits; } + public function getCapacity(): int { return $this->capacity; } + public function getSignedTokenContentHash(): string { return $this->signedTokenContentHash; } + public function getInvalidationCounter(): int { return $this->invalidationCounter; } + /** * @param array $row * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException diff --git a/src/StatusList/Values/StatusListRecord.php b/src/StatusList/Values/StatusListRecord.php index 602fde05..8fe5dd5c 100644 --- a/src/StatusList/Values/StatusListRecord.php +++ b/src/StatusList/Values/StatusListRecord.php @@ -23,6 +23,7 @@ class StatusListRecord { use DatabaseRowValuesTrait; + /** * @param string $id Opaque public identifier, being the last path segment of the list's URI. * @param string $uri The authoritative URI. Referenced Tokens carry this string and Status List @@ -68,31 +69,37 @@ public function __construct( ) { } + public function getId(): string { return $this->id; } + public function getUri(): string { return $this->uri; } + public function getPoolId(): string { return $this->poolId; } + public function getPolicyFingerprint(): string { return $this->policyFingerprint; } + public function getExpiryLane(): StatusListExpiryLaneEnum { return $this->expiryLane; } + /** * Generation within this list's pool, policy and lane, which is the scope its uniqueness is * declared over and the only scope anything compares it in. Generations of one lane are therefore @@ -103,16 +110,19 @@ public function getGeneration(): int return $this->generation; } + public function getBits(): int { return $this->bits; } + public function getCapacity(): int { return $this->capacity; } + /** * @return int[] */ @@ -125,6 +135,7 @@ public function getAllowedStatusValues(): array return array_map('intval', explode(',', $this->allowedStatuses)); } + /** * Whether this list may carry the given status. * @@ -136,36 +147,43 @@ public function isStatusValueAllowed(int $status): bool return in_array($status, $this->getAllowedStatusValues(), true); } + public function getAllowedStatusesAsString(): string { return $this->allowedStatuses; } + public function getTtlSeconds(): int { return $this->ttlSeconds; } + public function getTokenValiditySeconds(): int { return $this->tokenValiditySeconds; } + public function getRefreshIntervalSeconds(): int { return $this->refreshIntervalSeconds; } + public function getSigningKeyId(): string { return $this->signingKeyId; } + public function getKeyProfile(): StatusListKeyProfileEnum { return $this->keyProfile; } + /** * Advisory count of allocated entries. Incrementing it is a separate statement from the allocation * itself, so it can undercount; it drives the decision to rotate, never a correctness decision. @@ -175,51 +193,61 @@ public function getAllocatedCount(): int return $this->allocatedCount; } + public function isActive(): bool { return $this->isActive; } + public function getDeactivatedAt(): ?DateTimeImmutable { return $this->deactivatedAt; } + public function getRetiredAt(): ?DateTimeImmutable { return $this->retiredAt; } + public function isRetired(): bool { return $this->retiredAt instanceof DateTimeImmutable; } + public function getSignedToken(): ?string { return $this->signedToken; } + public function getSignedTokenContentHash(): string { return $this->signedTokenContentHash; } + public function getSignedTokenIssuedAt(): ?DateTimeImmutable { return $this->signedTokenIssuedAt; } + public function getSignedTokenExpiresAt(): ?DateTimeImmutable { return $this->signedTokenExpiresAt; } + public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; } + /** * The value a signer must still find on the row for its token to be publishable. */ @@ -228,6 +256,7 @@ public function getInvalidationCounter(): int return $this->invalidationCounter; } + /** * Whether a published token exists which can be served as-is. */ @@ -238,6 +267,7 @@ public function hasPublishedToken(): bool $this->signedToken !== ''; } + /** * @param array $row * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -277,6 +307,7 @@ public static function fromRow(array $row): self ); } + /** * @param array $row * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException @@ -290,6 +321,7 @@ protected static function asKeyProfile(array $row, string $key): StatusListKeyPr ); } + /** * Raised rather than defaulted, unlike the two columns above which tolerate a missing value. * diff --git a/src/StatusList/Values/StatusListTokenResult.php b/src/StatusList/Values/StatusListTokenResult.php index 31771dd7..557e15ec 100644 --- a/src/StatusList/Values/StatusListTokenResult.php +++ b/src/StatusList/Values/StatusListTokenResult.php @@ -25,26 +25,31 @@ public function __construct( ) { } + public function getToken(): string { return $this->token; } + public function getTtlSeconds(): int { return $this->ttlSeconds; } + public function getIssuedAt(): DateTimeImmutable { return $this->issuedAt; } + public function getExpiresAt(): DateTimeImmutable { return $this->expiresAt; } + /** * A strong validator over the exact bytes served. * @@ -66,6 +71,7 @@ public function getEntityTag(?string $contentCoding = null): string ); } + /** * How long a cache may hold this response. * diff --git a/src/Stores/Session/LogoutTicketStoreBuilder.php b/src/Stores/Session/LogoutTicketStoreBuilder.php index 116af868..9361eafb 100644 --- a/src/Stores/Session/LogoutTicketStoreBuilder.php +++ b/src/Stores/Session/LogoutTicketStoreBuilder.php @@ -8,21 +8,25 @@ class LogoutTicketStoreBuilder { protected static ?LogoutTicketStoreInterface $sessionLogoutTicketStore; + public function __construct(?LogoutTicketStoreInterface $sessionLogoutTicketStore = null) { self::$sessionLogoutTicketStore = $sessionLogoutTicketStore ?? self::getDefaultSessionLogoutTicketStore(); } + public function getInstance(): LogoutTicketStoreInterface { return self::getStaticInstance(); } + public static function getStaticInstance(): LogoutTicketStoreInterface { return self::$sessionLogoutTicketStore ?? self::getDefaultSessionLogoutTicketStore(); } + public static function getDefaultSessionLogoutTicketStore(): LogoutTicketStoreInterface { // For now, we only have DB version implemented... diff --git a/src/Stores/Session/LogoutTicketStoreDb.php b/src/Stores/Session/LogoutTicketStoreDb.php index 8ef68597..bb980925 100644 --- a/src/Stores/Session/LogoutTicketStoreDb.php +++ b/src/Stores/Session/LogoutTicketStoreDb.php @@ -22,6 +22,7 @@ class LogoutTicketStoreDb implements LogoutTicketStoreInterface */ protected int $ttl; + public function __construct( ?Database $database = null, int $ttl = 60, @@ -31,6 +32,7 @@ public function __construct( $this->ttl = max($ttl, 0); } + public function add(string $sid): void { $stmt = sprintf( @@ -44,6 +46,7 @@ public function add(string $sid): void ); } + /** * @throws \Exception */ @@ -57,6 +60,7 @@ public function delete(string $sid): void ); } + /** * @inheritDoc * @throws \Exception @@ -85,6 +89,7 @@ public function deleteMultiple(array $sids): void $this->database->write($stmt, $params); } + /** * @inheritDoc * @throws \Exception @@ -96,6 +101,7 @@ public function getAll(): array return $this->database->read("SELECT * FROM {$this->getTableName()}")->fetchAll(PDO::FETCH_ASSOC); } + /** * @throws \Exception */ @@ -110,6 +116,8 @@ protected function deleteExpired(): void ], ); } + + /** * @return string */ diff --git a/src/Stores/Session/LogoutTicketStoreInterface.php b/src/Stores/Session/LogoutTicketStoreInterface.php index 5b440404..51bc8d08 100644 --- a/src/Stores/Session/LogoutTicketStoreInterface.php +++ b/src/Stores/Session/LogoutTicketStoreInterface.php @@ -8,11 +8,13 @@ interface LogoutTicketStoreInterface { public function add(string $sid): void; + /** * @return list */ public function getAll(): array; + /** * @param string[] $sids * @return void diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php index 26373fe5..b4071e6a 100644 --- a/src/Utils/AuthenticatedOAuth2ClientResolver.php +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -19,11 +19,13 @@ use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use Symfony\Component\HttpFoundation\Request; +use Throwable; class AuthenticatedOAuth2ClientResolver { protected const string KEY_CLIENT_ASSERTION_JTI = 'client_assertion_jti'; + public function __construct( protected readonly ClientRepository $clientRepository, protected readonly RequestParamsResolver $requestParamsResolver, @@ -37,6 +39,7 @@ public function __construct( ) { } + public function forAnySupportedMethod( Request|ServerRequestInterface $request, ?ClientEntityInterface $preFetchedClient = null, @@ -53,7 +56,7 @@ public function forAnySupportedMethod( } return $resolved; - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->loggerService->error( 'Error while trying to resolve authenticated client: ' . $exception->getMessage(), @@ -62,13 +65,14 @@ public function forAnySupportedMethod( } } + /** * If the client has explicitly registered a token_endpoint_auth_method, the method it actually authenticated * with must match it. Enforced only when explicitly registered, preserving behavior for manually-managed * clients that do not have it configured. Throwing here results in client authentication failing (the caller * treats a null resolution as invalid_client). * - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ protected function enforceRegisteredTokenEndpointAuthMethod( ResolvedClientAuthenticationMethod $resolved, @@ -91,8 +95,9 @@ protected function enforceRegisteredTokenEndpointAuthMethod( } } + /** - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function forPublicClient( ServerRequestInterface|Request $request, @@ -134,8 +139,9 @@ public function forPublicClient( ); } + /** - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function forClientSecretBasic( Request|ServerRequestInterface $request, @@ -220,10 +226,11 @@ public function forClientSecretBasic( ); } + /** * For client_secret_post authentication method. * - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function forClientSecretPost( Request|ServerRequestInterface $request, @@ -285,6 +292,7 @@ public function forClientSecretPost( ); } + /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\ClientAssertionException @@ -342,7 +350,7 @@ public function forPrivateKeyJwt( try { $clientAssertion->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { throw new AuthorizationException( 'Client Assertion validation failed: ' . $exception->getMessage(), ); @@ -392,6 +400,7 @@ public function forPrivateKeyJwt( ); } + public function findActiveClient(string $clientId): ?ClientEntityInterface { $client = $this->clientRepository->findById($clientId); @@ -415,8 +424,9 @@ public function findActiveClient(string $clientId): ?ClientEntityInterface return $client; } + /** - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ protected function resolveClientOrFail( string $clientId, @@ -434,8 +444,9 @@ protected function resolveClientOrFail( return $client; } + /** - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function findActiveClientOrFail(string $clientId): ClientEntityInterface { @@ -444,8 +455,9 @@ public function findActiveClientOrFail(string $clientId): ClientEntityInterface ); } + /** - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function validateClientSecret(ClientEntityInterface $client, string $clientSecret): void { diff --git a/src/Utils/ClaimTranslatorExtractor.php b/src/Utils/ClaimTranslatorExtractor.php index dc29f956..21466e29 100644 --- a/src/Utils/ClaimTranslatorExtractor.php +++ b/src/Utils/ClaimTranslatorExtractor.php @@ -4,7 +4,7 @@ * This file contains modified code from the 'steverhoades/oauth2-openid-connect-server' library * (https://github.com/steverhoades/oauth2-openid-connect-server), with original author, copyright notice and licence: * @author Steve Rhoades - * @copyright (c) 2018 Steve Rhoades + * @copyright (\c) 2018 Steve Rhoades * @license http://opensource.org/licenses/MIT MIT */ @@ -69,7 +69,7 @@ class ClaimTranslatorExtractor ]; - /** @var array */ + /** @var array */ protected array $claimSets = []; /** @var string[] */ @@ -221,6 +221,7 @@ public function __construct( } } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -239,6 +240,7 @@ public function addClaimSet(ClaimSetEntityInterface $claimSet): self return $this; } + public function getClaimSet(string $scope): ?ClaimSetEntityInterface { if (!$this->hasClaimSet($scope)) { @@ -248,11 +250,13 @@ public function getClaimSet(string $scope): ?ClaimSetEntityInterface return $this->claimSets[$scope]; } + public function hasClaimSet(string $scope): bool { return array_key_exists($scope, $this->claimSets); } + /** * Get the effective SAML attribute to OIDC claim translation table, that is, the default table * with the configured one merged over it, the user identifier attributes prepended to the 'sub' @@ -266,6 +270,7 @@ public function getTranslationTable(): array return $this->translationTable; } + private function translateSamlAttributesToClaims(array $translationTable, array $samlAttributes): array { $claims = []; @@ -304,6 +309,7 @@ private function translateSamlAttributesToClaims(array $translationTable, array return $claims; } + private function convertType(string $type, mixed $attributes): mixed { if (is_array($attributes)) { @@ -336,6 +342,7 @@ private function convertType(string $type, mixed $attributes): mixed return $attributes; } + /** * @param array $scopes */ @@ -374,6 +381,7 @@ public function extract(array $scopes, array $claims): array return $claimData; } + public function extractAdditionalIdTokenClaims(?array $claimsRequest, array $claims): array { /** @var array $idTokenClaims */ @@ -381,6 +389,7 @@ public function extractAdditionalIdTokenClaims(?array $claimsRequest, array $cla return $this->extractAdditionalClaims($idTokenClaims, $claims); } + public function extractAdditionalUserInfoClaims(?array $claimsRequest, array $claims): array { /** @var array $userInfoClaims */ @@ -388,6 +397,7 @@ public function extractAdditionalUserInfoClaims(?array $claimsRequest, array $cl return $this->extractAdditionalClaims($userInfoClaims, $claims); } + /** * Add any individually requested claims * @link https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests @@ -411,6 +421,7 @@ private function extractAdditionalClaims(array $requestedClaims, array $claims): return $additionalClaims; } + private function validateSubjectClaim(array $claims): void { if ( @@ -421,6 +432,7 @@ private function validateSubjectClaim(array $claims): void } } + /** * Get supported claims for this OP. This will return all the claims for which the "SAML attribute to OIDC claim * translation" has been defined in module config, meaning it is expected for OP to release those claims. diff --git a/src/Utils/DateIntervalFormatter.php b/src/Utils/DateIntervalFormatter.php index 4c1274cd..da862ccb 100644 --- a/src/Utils/DateIntervalFormatter.php +++ b/src/Utils/DateIntervalFormatter.php @@ -42,6 +42,7 @@ public function toHumanReadable(DateInterval $dateInterval): string return $parts === [] ? '0 seconds' : implode(' ', $parts); } + /** * Render the interval back to its ISO 8601 duration spec, that is, the form used in the module * configuration file, for example, 'PT10M'. Handy on overview screens, since it is the value an diff --git a/src/Utils/Debug/ArrayLogger.php b/src/Utils/Debug/ArrayLogger.php index 337ec8fc..de06f0b8 100644 --- a/src/Utils/Debug/ArrayLogger.php +++ b/src/Utils/Debug/ArrayLogger.php @@ -9,16 +9,24 @@ use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use SimpleSAML\Module\oidc\Helpers; +use Stringable; class ArrayLogger implements LoggerInterface { public const int WEIGHT_EMERGENCY = 8; + public const int WEIGH_ALERT = 7; + public const int WEIGHT_CRITICAL = 6; + public const int WEIGHT_ERROR = 5; + public const int WEIGHT_WARNING = 4; + public const int WEIGHT_NOTICE = 3; + public const int WEIGHT_INFO = 2; + public const int WEIGHT_DEBUG = 1; @@ -35,24 +43,27 @@ public function __construct( $this->setWeight($weight); } + public function setWeight(int $weight): void { $this->weight = max(self::WEIGHT_DEBUG, min($weight, self::WEIGHT_EMERGENCY)); } + /** * @inheritDoc */ - public function emergency(\Stringable|string $message, array $context = []): void + public function emergency(Stringable|string $message, array $context = []): void { // Always log emergency. $this->entries[] = $this->prepareEntry(LogLevel::EMERGENCY, $message, $context); } + /** * @inheritDoc */ - public function alert(\Stringable|string $message, array $context = []): void + public function alert(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGH_ALERT) { return; @@ -60,10 +71,11 @@ public function alert(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::ALERT, $message, $context); } + /** * @inheritDoc */ - public function critical(\Stringable|string $message, array $context = []): void + public function critical(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGHT_CRITICAL) { return; @@ -71,10 +83,11 @@ public function critical(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::CRITICAL, $message, $context); } + /** * @inheritDoc */ - public function error(\Stringable|string $message, array $context = []): void + public function error(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGHT_ERROR) { return; @@ -82,10 +95,11 @@ public function error(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::ERROR, $message, $context); } + /** * @inheritDoc */ - public function warning(\Stringable|string $message, array $context = []): void + public function warning(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGHT_WARNING) { return; @@ -93,10 +107,11 @@ public function warning(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::WARNING, $message, $context); } + /** * @inheritDoc */ - public function notice(\Stringable|string $message, array $context = []): void + public function notice(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGHT_NOTICE) { return; @@ -104,10 +119,11 @@ public function notice(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::NOTICE, $message, $context); } + /** * @inheritDoc */ - public function info(\Stringable|string $message, array $context = []): void + public function info(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGHT_INFO) { return; @@ -115,10 +131,11 @@ public function info(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::INFO, $message, $context); } + /** * @inheritDoc */ - public function debug(\Stringable|string $message, array $context = []): void + public function debug(Stringable|string $message, array $context = []): void { if ($this->weight > self::WEIGHT_DEBUG) { return; @@ -126,10 +143,11 @@ public function debug(\Stringable|string $message, array $context = []): void $this->entries[] = $this->prepareEntry(LogLevel::DEBUG, $message, $context); } + /** * @inheritDoc */ - public function log($level, \Stringable|string $message, array $context = []): void + public function log($level, Stringable|string $message, array $context = []): void { match ($level) { LogLevel::EMERGENCY => $this->emergency($message, $context), @@ -144,12 +162,14 @@ public function log($level, \Stringable|string $message, array $context = []): v }; } + public function getEntries(): array { return $this->entries; } - protected function prepareEntry(string $logLevel, \Stringable|string $message, array $context = []): string + + protected function prepareEntry(string $logLevel, Stringable|string $message, array $context = []): string { return sprintf( '%s %s %s %s', diff --git a/src/Utils/FederationParticipationValidator.php b/src/Utils/FederationParticipationValidator.php index 5ce45fe1..fe92a391 100644 --- a/src/Utils/FederationParticipationValidator.php +++ b/src/Utils/FederationParticipationValidator.php @@ -11,6 +11,7 @@ use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Federation\TrustChain; +use Throwable; class FederationParticipationValidator { @@ -21,6 +22,7 @@ public function __construct( ) { } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException @@ -81,6 +83,7 @@ public function byTrustMarksFor(TrustChain $trustChain): void } } + /** * @param non-empty-string[] $limitedTrustMarkTypes * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException @@ -123,7 +126,7 @@ public function validateForOneOfLimit( ), ); return; - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->loggerService->debug( sprintf( 'Trust Mark Type %s validation failed with error: %s. Trying next if available.', @@ -145,6 +148,7 @@ public function validateForOneOfLimit( throw new TrustMarkException($error); } + /** * @param non-empty-string[] $limitedTrustMarkTypes * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException @@ -184,7 +188,7 @@ public function validateForAllOfLimit( $limitedTrustMarkType, ), ); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $error = sprintf( 'Trust Mark Type %s validation failed with error: %s. AllOf limit rule failed.', $limitedTrustMarkType, diff --git a/src/Utils/FingerprintGenerator.php b/src/Utils/FingerprintGenerator.php index 0733adac..7cd88377 100644 --- a/src/Utils/FingerprintGenerator.php +++ b/src/Utils/FingerprintGenerator.php @@ -15,7 +15,7 @@ class FingerprintGenerator * @param string $algo One of the supported algorithms (see hash_algos() function) * @return string * - * @throws InvalidArgumentException + * @throws \InvalidArgumentException */ public static function forFile(string $path, string $algo = 'md5'): string { @@ -30,6 +30,7 @@ public static function forFile(string $path, string $algo = 'md5'): string return $fingerprint; } + /** * Generate a fingerprint (hash) for a provided string. * diff --git a/src/Utils/HttpContentNegotiator.php b/src/Utils/HttpContentNegotiator.php index ac35c26d..cae638f7 100644 --- a/src/Utils/HttpContentNegotiator.php +++ b/src/Utils/HttpContentNegotiator.php @@ -68,6 +68,7 @@ public function acceptsMediaType(?string $accept, string $mediaType): bool return $bestWeight > 0.0; } + /** * The content coding to encode the body with, in the client's order of preference. * @@ -118,6 +119,7 @@ public function preferredContentCoding(?string $acceptEncoding, string ...$suppo return $best; } + /** * Splits one comma separated element into its value and its weight. * @@ -152,6 +154,7 @@ protected function parseWeighted(string $specification): ?array return ['value' => $value, 'weight' => $weight]; } + /** * How specifically a media range names the given media type: 3 exact, 2 by type, 1 by wildcard, and * -1 for no match at all. @@ -177,6 +180,7 @@ protected function precedenceOf(string $range, array $wanted): int return $parsed['type'] === $wanted['type'] && $parsed['subtype'] === $wanted['subtype'] ? 3 : -1; } + /** * @return ?array{type: string, subtype: string} */ diff --git a/src/Utils/JwksResolver.php b/src/Utils/JwksResolver.php index 4f7c305f..68d5979c 100644 --- a/src/Utils/JwksResolver.php +++ b/src/Utils/JwksResolver.php @@ -13,6 +13,7 @@ public function __construct(protected Jwks $jwks) { } + /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index ae744179..2256a7cf 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -17,10 +17,12 @@ use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\ClientAssertion; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\RequestObject; use SimpleSAML\OpenID\RequestObject\RequestObjectBag; use Symfony\Component\HttpFoundation\Request; +use Throwable; /** * Resolve authorization params from an HTTP request (based or not based on @@ -54,6 +56,7 @@ class RequestParamsResolver */ protected array $pushedAuthorizationRequestParams = []; + public function __construct( protected readonly Helpers $helpers, protected readonly Core $core, @@ -67,6 +70,7 @@ public function __construct( ) { } + /** * Get all HTTP request params (not from Request Object). * @@ -81,6 +85,7 @@ public function getAllFromRequest(Request|ServerRequestInterface $request): arra return $this->helpers->http()->getAllRequestParams($request); } + /** * Get all HTTP request params based on allowed methods (not from * Request Object). @@ -102,6 +107,7 @@ public function getAllFromRequestBasedOnAllowedMethods( ) ?? []; } + /** * Get all request params, including those from Request Object if present. * @@ -139,6 +145,7 @@ public function getAllBasedOnAllowedMethods( ); } + /** * Get param value from an HTTP request or Request Object if present. * @@ -149,6 +156,7 @@ public function get(string $paramKey, Request|ServerRequestInterface $request): return $this->getAll($request)[$paramKey] ?? null; } + /** * Get param value from an HTTP request or Request Object if present, * based on allowed methods. @@ -165,6 +173,7 @@ public function getBasedOnAllowedMethods( return $allParams[$paramKey] ?? null; } + /** * Get param value as null or string from an HTTP request or Request Object * if present, based on allowed methods. This is a convenience method, @@ -186,6 +195,7 @@ public function getAsStringBasedOnAllowedMethods( (string)$value; } + /** * Get param value from an HTTP request (not from Request Object), based * on allowed methods. @@ -202,6 +212,7 @@ public function getFromRequestBasedOnAllowedMethods( return isset($allParams[$paramKey]) ? (string)$allParams[$paramKey] : null; } + /** * Check if Request Object is present as a request param (passed by value) * and parse it to use its claims as params. @@ -224,6 +235,7 @@ protected function resolveRequestObjectParams(array $requestParams): array return $this->parseRequestObjectBagByToken($token)?->get(Core\RequestObject::class)?->getPayload() ?? []; } + /** * Check if Request URI is present as a request param and resolve its claims * to use them as params. For Pushed Authorization Request URIs (urn form), @@ -264,6 +276,7 @@ protected function resolveRequestUriParams(array $requestParams): array ?->get(Core\RequestObject::class)?->getPayload() ?? []; } + /** * @return mixed[] */ @@ -276,7 +289,7 @@ protected function resolvePushedAuthorizationRequestParams(string $requestUri): try { return $this->pushedAuthorizationRequestParams[$requestUri] = $this->pushedAuthorizationRequestRepository->findValid($requestUri)?->getParameters() ?? []; - } catch (\Throwable $throwable) { + } catch (Throwable $throwable) { $this->loggerService->warning( 'RequestParamsResolver: error resolving pushed authorization request: ' . $throwable->getMessage(), compact('requestUri'), @@ -285,6 +298,7 @@ protected function resolvePushedAuthorizationRequestParams(string $requestUri): } } + /** * Resolve the Request Object Bag for the current request, regardless of * whether the Request Object was passed by value (request param) or by @@ -322,6 +336,7 @@ public function getRequestObjectBag( return null; } + /** * Parse (memoized) the Request Object token using all available Request * Object flavors (OpenID Connect Core, JAR, OpenID Federation). The @@ -338,7 +353,7 @@ protected function parseRequestObjectBagByToken(string $token): ?RequestObjectBa try { $this->requestObjectBagsByToken[$token] = $this->requestObject->requestObjectParser() ->fromToken($token); - } catch (\Throwable $throwable) { + } catch (Throwable $throwable) { $this->loggerService->warning( 'RequestParamsResolver: error parsing request object: ' . $throwable->getMessage(), ); @@ -349,6 +364,7 @@ protected function parseRequestObjectBagByToken(string $token): ?RequestObjectBa return $this->requestObjectBagsByToken[$token]; } + /** * Fetch and parse (memoized) the Request Object from the given https * Request URI, if allowed by policy. @@ -370,7 +386,7 @@ protected function fetchRequestObjectBagByUri(string $requestUri, array $request $this->moduleConfig->getRequestUriFetchTimeout(), $this->moduleConfig->getRequestUriMaxSizeBytes(), ); - } catch (\Throwable $throwable) { + } catch (Throwable $throwable) { $this->loggerService->warning( 'RequestParamsResolver: error fetching request object from request_uri: ' . $throwable->getMessage(), compact('requestUri'), @@ -379,6 +395,7 @@ protected function fetchRequestObjectBagByUri(string $requestUri, array $request } } + /** * Decide whether a https Request URI (Request Object by reference) is * allowed to be fetched. This is the single authorization point for @@ -420,6 +437,7 @@ protected function isHttpsRequestUriFetchAllowed(string $requestUri, array $requ $this->isFederationRequestUriAllowed($requestUri); } + /** * Check the federation request_uri against the configured prefix allowlist * (SSRF / DoS mitigation for the outbound fetch of a not-yet-trusted @@ -443,6 +461,7 @@ protected function isFederationRequestUriAllowed(string $requestUri): bool return false; } + /** * Parse the Request Object token according to OpenID Core specification. * Note that this won't do signature validation of it. @@ -456,6 +475,7 @@ public function parseRequestObjectToken(string $token): Core\RequestObject return $this->core->requestObjectFactory()->fromToken($token); } + /** * Parse the Request Object token according to OpenID Federation * specification. Note that this won't do signature validation of it. @@ -468,17 +488,19 @@ public function parseFederationRequestObjectToken(string $token): Federation\Req return $this->federation->requestObjectFactory()->fromToken($token); } + /** * Parse the Client Assertion token according to OpenID Core specification. * Note that this won't do signature validation of it. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function parseClientAssertionToken(string $clientAssertionParam): Core\ClientAssertion + public function parseClientAssertionToken(string $clientAssertionParam): ClientAssertion { return $this->core->clientAssertionFactory()->fromToken($clientAssertionParam); } + /** * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException diff --git a/src/Utils/ResponseTypeGrantTypeCorrespondence.php b/src/Utils/ResponseTypeGrantTypeCorrespondence.php index a5a02277..7ad5bb79 100644 --- a/src/Utils/ResponseTypeGrantTypeCorrespondence.php +++ b/src/Utils/ResponseTypeGrantTypeCorrespondence.php @@ -36,6 +36,7 @@ public static function map(): array ]; } + /** * The unique set of grant types required by the given response types, in stable order. * @@ -56,6 +57,7 @@ public static function requiredGrantTypes(array $responseTypes): array return array_values($required); } + /** * Merge the grant types required by the given response types into the given grant types, preserving the * existing order and appending any missing required ones. diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 84f3e728..8d295d58 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -20,6 +20,7 @@ public function __construct( ) { } + public function getModuleUrl(string $resource = '', array $parameters = []): string { $resource = $this->moduleConfig->moduleName() . '/' . $resource; @@ -44,6 +45,7 @@ public function newRedirectResponseToModuleUrl( ); } + public function newResponse( ?string $content = '', int $status = 200, @@ -52,6 +54,7 @@ public function newResponse( return new Response($content, $status, $headers); } + public function newJsonResponse( array|null $data = null, int $status = 200, @@ -61,6 +64,7 @@ public function newJsonResponse( return new JsonResponse($data, $status, $headers, $json); } + public function newJsonErrorResponse( string $error, string $description, @@ -83,21 +87,25 @@ public function urlAdminConfigGeneral(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminConfigGeneral->value, $parameters); } + public function urlAdminConfigProtocol(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminConfigProtocol->value, $parameters); } + public function urlAdminConfigFederation(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminConfigFederation->value, $parameters); } + public function urlAdminMigrations(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminMigrations->value, $parameters); } + public function urlAdminMigrationsRun(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminMigrationsRun->value, $parameters); @@ -110,29 +118,34 @@ public function urlAdminClients(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminClients->value, $parameters); } + public function urlAdminClientsShow(string $clientId, array $parameters = []): string { $parameters[ParametersEnum::ClientId->value] = $clientId; return $this->getModuleUrl(RoutesEnum::AdminClientsShow->value, $parameters); } + public function urlAdminClientsEdit(string $clientId, array $parameters = []): string { $parameters[ParametersEnum::ClientId->value] = $clientId; return $this->getModuleUrl(RoutesEnum::AdminClientsEdit->value, $parameters); } + public function urlAdminClientsAdd(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminClientsAdd->value, $parameters); } + public function urlAdminClientsResetSecret(string $clientId, array $parameters = []): string { $parameters[ParametersEnum::ClientId->value] = $clientId; return $this->getModuleUrl(RoutesEnum::AdminClientsResetSecret->value, $parameters); } + public function urlAdminClientsDelete(string $clientId, array $parameters = []): string { $parameters[ParametersEnum::ClientId->value] = $clientId; @@ -146,6 +159,7 @@ public function urlAdminCredentialStatus(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminCredentialStatus->value, $parameters); } + public function urlAdminCredentialStatusChange(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminCredentialStatusChange->value, $parameters); @@ -158,16 +172,19 @@ public function urlAdminTestTrustChainResolution(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminTestTrustChainResolution->value, $parameters); } + public function urlAdminTestTrustMarkValidation(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->value, $parameters); } + public function urlAdminTestFederationDiscovery(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminTestFederationDiscovery->value, $parameters); } + public function urlAdminTestVerifiableCredentialIssuance(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value, $parameters); @@ -191,31 +208,37 @@ public function urlConfiguration(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::Configuration->value, $parameters); } + public function urlAuthorization(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::Authorization->value, $parameters); } + public function urlToken(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::Token->value, $parameters); } + public function urlUserInfo(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::UserInfo->value, $parameters); } + public function urlJwks(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::Jwks->value, $parameters); } + public function urlEndSession(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::EndSession->value, $parameters); } + /** * Dynamic Client Registration endpoint. Only served (and only advertised in OP metadata) when * Dynamic Client Registration is enabled. @@ -234,6 +257,7 @@ public function urlFederationConfiguration(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::FederationConfiguration->value, $parameters); } + public function urlPushedAuthorizationRequest(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value, $parameters); @@ -248,16 +272,19 @@ public function urlCredentialIssuerConfiguration(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::CredentialIssuerConfiguration->value, $parameters); } + public function urlCredentialIssuerCredential(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::CredentialIssuerCredential->value, $parameters); } + public function urlCredentialIssuerNonce(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::CredentialIssuerNonce->value, $parameters); } + public function urlCredentialJsonLdContext(string $credentialConfigurationId, array $parameters = []): string { $path = str_replace( @@ -311,11 +338,13 @@ public function urlApiVciCredentialOffer(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); } + public function urlApiVciCredentialStatus(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::ApiVciCredentialStatus->value, $parameters); } + public function urlApiOAuth2TokenIntrospection(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::ApiOAuth2TokenIntrospection->value, $parameters); diff --git a/src/Utils/UiLocalesResolver.php b/src/Utils/UiLocalesResolver.php index 301fa428..2511f1b3 100644 --- a/src/Utils/UiLocalesResolver.php +++ b/src/Utils/UiLocalesResolver.php @@ -20,6 +20,7 @@ public function __construct( ) { } + /** * Get the most preferred requested language which is available in * SimpleSAMLphp (per the language.available config option), or null if @@ -56,6 +57,7 @@ public function resolve(?string $uiLocales): ?string return null; } + /** * Get languages available in SimpleSAMLphp, represented as BCP47 language tags (SSP uses underscore as * region separator in some codes, like pt_BR, while BCP47 uses hyphen). Can be used to advertise @@ -71,6 +73,7 @@ public function getSupportedUiLocales(): array ); } + /** * Get languages available in SimpleSAMLphp (configured in language.available and usable for UI * rendering), as computed by SimpleSAMLphp itself @@ -85,6 +88,7 @@ protected function getAvailableLanguages(): array return $this->sspBridge->locale()->language()->getAvailableLanguages($this->sspConfiguration); } + protected function normalize(string $languageTag): string { return strtolower(str_replace('-', '_', $languageTag)); diff --git a/src/Utils/VciContextResolver.php b/src/Utils/VciContextResolver.php index 108903af..a5e41a15 100644 --- a/src/Utils/VciContextResolver.php +++ b/src/Utils/VciContextResolver.php @@ -12,8 +12,8 @@ class VciContextResolver { /** * VciContextResolver constructor. - * @param ModuleConfig $moduleConfig - * @param Routes $routes + * @param \SimpleSAML\Module\oidc\ModuleConfig $moduleConfig + * @param \SimpleSAML\Module\oidc\Utils\Routes $routes */ public function __construct( protected readonly ModuleConfig $moduleConfig, @@ -21,6 +21,7 @@ public function __construct( ) { } + /** * Resolve the @context array for a given credential configuration. * diff --git a/src/ValueAbstracts/IntrospectionAuthorization.php b/src/ValueAbstracts/IntrospectionAuthorization.php index 72da35c9..521d516f 100644 --- a/src/ValueAbstracts/IntrospectionAuthorization.php +++ b/src/ValueAbstracts/IntrospectionAuthorization.php @@ -22,6 +22,7 @@ protected function __construct( ) { } + /** * A caller trusted with every token this OP has issued: a logged in administrator, an API token * holding an introspection scope, or a client the deployment has named as a resource server. @@ -31,6 +32,7 @@ public static function forAnyToken(): self return new self(null); } + /** * A client which authenticated as itself, and may therefore only see what was issued to it. That * tells it nothing it did not already hold, whereas another client's token would answer with that @@ -41,6 +43,7 @@ public static function forTokensOfClient(string $clientId): self return new self($clientId); } + /** * @param ?string $clientId The client a token was issued to, or null when that could not be established. */ @@ -55,6 +58,7 @@ public function mayIntrospectTokenOf(?string $clientId): bool return $clientId !== null && $clientId === $this->clientId; } + /** * The client this caller is limited to, or null when it is limited to none. */ diff --git a/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php index 9e0f3590..9efbeae1 100644 --- a/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php +++ b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php @@ -15,11 +15,13 @@ public function __construct( ) { } + public function getClient(): ClientEntityInterface { return $this->client; } + public function getClientAuthenticationMethod(): ClientAuthenticationMethodsEnum { return $this->clientAuthenticationMethod; diff --git a/tests/config/config.php b/tests/config/config.php index 73a11e40..43deed75 100644 --- a/tests/config/config.php +++ b/tests/config/config.php @@ -6,7 +6,10 @@ declare(strict_types=1); -$httpUtils = new \SimpleSAML\Utils\HTTP(); +use SimpleSAML\Logger; +use SimpleSAML\Utils\HTTP; + +$httpUtils = new HTTP(); $config = [ @@ -368,7 +371,7 @@ * must exist and be writable for SimpleSAMLphp. If set to something else, set * loggingdir above to 'null'. */ - 'logging.level' => SimpleSAML\Logger::NOTICE, + 'logging.level' => Logger::NOTICE, 'logging.handler' => 'syslog', /* diff --git a/tests/config/module_oidc.php b/tests/config/module_oidc.php index 6cf3444c..06847518 100644 --- a/tests/config/module_oidc.php +++ b/tests/config/module_oidc.php @@ -2,14 +2,17 @@ declare(strict_types=1); +use SimpleSAML\Module\oidc\Codebooks\LimitsEnum; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use Symfony\Component\Cache\Adapter\ArrayAdapter; $config = [ ModuleConfig::OPTION_ISSUER => 'http://test.issuer', ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ [ - ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', ], @@ -39,7 +42,7 @@ ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS => [ ], - ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\ArrayAdapter::class, + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => ArrayAdapter::class, ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [], ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', @@ -57,7 +60,7 @@ ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ [ - ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', ], @@ -81,19 +84,19 @@ // 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 => [ + LimitsEnum::OneOf->value => [ 'trust-mark-type', 'trust-mark-type-2', ], // Entities must have all Trust Marks from the list below. - \SimpleSAML\Module\oidc\Codebooks\LimitsEnum::AllOf->value => [ + LimitsEnum::AllOf->value => [ 'trust-mark-type-3', 'trust-mark-type-4', ], ], ], - ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\ArrayAdapter::class, + ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => ArrayAdapter::class, ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [], ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION => 'P1D', ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED => 'PT2M', diff --git a/tests/integration/src/DatabaseContainers.php b/tests/integration/src/DatabaseContainers.php index 9bed4cbc..2ac5c493 100644 --- a/tests/integration/src/DatabaseContainers.php +++ b/tests/integration/src/DatabaseContainers.php @@ -41,10 +41,12 @@ final class DatabaseContainers private static bool $isEnvironmentResolved = false; + private function __construct() { } + /** * @return array * @throws \Exception @@ -56,6 +58,7 @@ public static function postgres(): array return self::$postgresConfig ??= self::startPostgres(); } + /** * @return array * @throws \Exception @@ -67,6 +70,7 @@ public static function mysql(): array return self::$mysqlConfig ??= self::startMysql(); } + /** * @return array */ @@ -82,6 +86,7 @@ public static function sqlite(): array ]; } + /** * @return array */ @@ -94,6 +99,7 @@ public static function all(): array ]; } + private static function resolveEnvironment(): void { if (self::$isEnvironmentResolved) { @@ -115,6 +121,7 @@ private static function resolveEnvironment(): void self::$isEnvironmentResolved = true; } + /** * @return array * @throws \Exception @@ -144,6 +151,7 @@ private static function startPostgres(): array ]; } + /** * @return array * @throws \Exception @@ -178,6 +186,7 @@ private static function startMysql(): array ]; } + /** * A free port, found by opening a listening socket and closing it again. * diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 644ea645..29151c7f 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Test\Module\oidc\integration\Repositories; +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -30,36 +32,55 @@ use SimpleSAML\Test\Module\oidc\integration\DatabaseContainers; #[CoversClass(AccessTokenRepository::class)] +#[AllowMockObjectsWithoutExpectations] class AccessTokenRepositoryTest extends TestCase { - protected array $state; - protected array $scopes; - protected string $expiresAt; + final public const bool IS_REVOKED = false; - final public const IS_REVOKED = false; - final public const AUTH_CODE_ID = 'authCode123'; - final public const REQUESTED_CLAIMS = ['key' => 'value']; - final public const CLIENT_ID = 'access_token_client_id'; - final public const USER_ID = 'access_token_user_id'; - final public const ACCESS_TOKEN_ID = 'access_token_id'; + final public const string AUTH_CODE_ID = 'authCode123'; + + final public const array REQUESTED_CLAIMS = ['key' => 'value']; + + final public const string CLIENT_ID = 'access_token_client_id'; + + final public const string USER_ID = 'access_token_user_id'; + + final public const string ACCESS_TOKEN_ID = 'access_token_id'; - protected AccessTokenRepository $accessTokenRepository; public static array $pgConfig; + public static array $mysqlConfig; + public static array $sqliteConfig; + protected array $state; + + protected array $scopes; + + protected string $expiresAt; + + protected AccessTokenRepository $accessTokenRepository; + protected AbstractDatabaseRepository $mock; + protected ScopeEntity $scopeEntityOpenId; + protected ScopeEntity $scopeEntityProfile; protected MockObject $accessTokenEntityFactoryMock; + protected MockObject $accessTokenEntityMock; + protected array $accessTokenState; + protected AccessTokenEntityFactory $accessTokenEntityFactory; + protected MockObject $jwsMock; + protected MockObject $moduleConfigMock; + /** * @throws \Exception */ @@ -71,6 +92,7 @@ public static function setUpBeforeClass(): void self::$sqliteConfig = DatabaseContainers::sqlite(); } + /** * @return void * @throws \PHPUnit\Framework\MockObject\Exception @@ -83,7 +105,7 @@ public function setUp(): void $this->scopeEntityProfile = $this->createStub(ScopeEntity::class); $this->scopeEntityProfile->method('getIdentifier')->willReturn('profile'); $this->scopeEntityProfile->method('jsonSerialize')->willReturn('profile'); - $this->scopes = [$this->scopeEntityOpenId, $this->scopeEntityProfile,]; + $this->scopes = [$this->scopeEntityOpenId, $this->scopeEntityProfile]; $this->accessTokenState = [ 'id' => self::ACCESS_TOKEN_ID, @@ -115,6 +137,7 @@ public function setUp(): void ); } + public function useDatabase($config): void { $configuration = Configuration::loadFromArray($config, '', 'simplesaml'); @@ -130,6 +153,7 @@ public function getTableName(): ?string return $this->database->applyPrefix('oidc_access_token'); } + public function getDatabase(): Database { return $this->database; @@ -163,7 +187,7 @@ public function getDatabase(): Database $this->mock->getDatabase()->write('DELETE from ' . $clientRepositoryMock->getTableName()); $clientRepositoryMock->add($client); - $createUpdatedAt = new \DateTimeImmutable(); + $createUpdatedAt = new DateTimeImmutable(); $helpers = new Helpers(); $user = new UserEntity(self::USER_ID, $createUpdatedAt, $createUpdatedAt, []); $userRepositoryMock = new UserRepository( @@ -177,18 +201,19 @@ public function getDatabase(): Database $userRepositoryMock->add($user); } + public static function databaseToTest(): array { return DatabaseContainers::all(); } + /** * @throws \JsonException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException * @throws \SimpleSAML\Error\Error * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ - #[DataProvider('databaseToTest')] public function testRevokeByAuthCodeId(string $database): void { @@ -208,13 +233,14 @@ public function testRevokeByAuthCodeId(string $database): void $this->assertTrue($isRevoked); } + /** * @param string $id * @param bool $enabled * @param bool $confidential * @param string|null $owner * - * @return ClientEntityInterface + * @return \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface */ public static function clientRepositoryGetClient( string $id, diff --git a/tests/integration/src/StatusList/StatusListStorageTest.php b/tests/integration/src/StatusList/StatusListStorageTest.php index b44a9c8b..9b5c52fd 100644 --- a/tests/integration/src/StatusList/StatusListStorageTest.php +++ b/tests/integration/src/StatusList/StatusListStorageTest.php @@ -4,8 +4,10 @@ namespace SimpleSAML\Test\Module\oidc\integration\StatusList; +use DateInterval; use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -40,6 +42,7 @@ #[CoversClass(StatusListRepository::class)] #[CoversClass(StatusListEntryRepository::class)] #[CoversClass(StatusAuditRepository::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListStorageTest extends TestCase { protected const string LIST_ID = 'integration-status-list-0000000000000000000000000000000000000000'; @@ -49,14 +52,20 @@ class StatusListStorageTest extends TestCase protected const int CAPACITY = 64; + public static array $pgConfig; + public static array $mysqlConfig; + public static array $sqliteConfig; protected Database $database; + protected StatusListRepository $statusListRepository; + protected StatusListEntryRepository $statusListEntryRepository; + /** * @throws \Exception */ @@ -68,6 +77,7 @@ public static function setUpBeforeClass(): void self::$sqliteConfig = DatabaseContainers::sqlite(); } + /** * @param array $config * @throws \Exception @@ -357,7 +367,7 @@ public function testFindsAListBeingPreparedOnlyWhileItIsRecent(string $database) 'integration-pool', 'integration-fingerprint', StatusListExpiryLaneEnum::Expiring, - $helpers->dateTime()->getUtc()->sub(new \DateInterval('PT2M')), + $helpers->dateTime()->getUtc()->sub(new DateInterval('PT2M')), ), ); @@ -368,7 +378,7 @@ public function testFindsAListBeingPreparedOnlyWhileItIsRecent(string $database) 'integration-pool', 'integration-fingerprint', StatusListExpiryLaneEnum::Expiring, - $helpers->dateTime()->getUtc()->add(new \DateInterval('PT2M')), + $helpers->dateTime()->getUtc()->add(new DateInterval('PT2M')), ), ); @@ -380,7 +390,7 @@ public function testFindsAListBeingPreparedOnlyWhileItIsRecent(string $database) 'integration-pool', 'integration-fingerprint', StatusListExpiryLaneEnum::Expiring, - $helpers->dateTime()->getUtc()->sub(new \DateInterval('PT2M')), + $helpers->dateTime()->getUtc()->sub(new DateInterval('PT2M')), ), ); } @@ -460,7 +470,7 @@ public function testPublishingIsSettledByTheObservedContentHash(string $database $this->givenSeededList(); $issuedAt = (new Helpers())->dateTime()->getUtc(); - $expiresAt = $issuedAt->add(new \DateInterval('P7D')); + $expiresAt = $issuedAt->add(new DateInterval('P7D')); $firstHash = str_repeat('a', 64); // The first publication observes the empty hash a newly created list carries. @@ -511,7 +521,7 @@ public function testRefreshingUnchangedContentRequiresANewerIssuanceTime(string $hash = str_repeat('c', 64); $issuedAt = (new Helpers())->dateTime()->getUtc(); - $expiresAt = $issuedAt->add(new \DateInterval('P7D')); + $expiresAt = $issuedAt->add(new DateInterval('P7D')); $this->assertTrue( $this->statusListRepository->publishToken( @@ -533,7 +543,7 @@ public function testRefreshingUnchangedContentRequiresANewerIssuanceTime(string 0, $hash, 'stale.refresh.token', - $issuedAt->sub(new \DateInterval('PT1H')), + $issuedAt->sub(new DateInterval('PT1H')), $expiresAt, ), ); @@ -546,8 +556,8 @@ public function testRefreshingUnchangedContentRequiresANewerIssuanceTime(string 0, $hash, 'refreshed.token', - $issuedAt->add(new \DateInterval('PT1H')), - $expiresAt->add(new \DateInterval('PT1H')), + $issuedAt->add(new DateInterval('PT1H')), + $expiresAt->add(new DateInterval('PT1H')), ), ); @@ -572,7 +582,7 @@ public function testAnInvalidationDuringSigningBlocksPublicationEvenWhileTheHash $this->givenSeededList(); $issuedAt = (new Helpers())->dateTime()->getUtc(); - $expiresAt = $issuedAt->add(new \DateInterval('P7D')); + $expiresAt = $issuedAt->add(new DateInterval('P7D')); // What a signer reads before it starts: nothing published, and the counter as it stands. $observed = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); @@ -629,7 +639,7 @@ public function testFindsOnlyListsWhichHaveAPublishedToken(string $database): vo str_repeat('d', 64), 'a.published.token', $issuedAt, - $issuedAt->add(new \DateInterval('P7D')), + $issuedAt->add(new DateInterval('P7D')), ); $published = $this->statusListRepository->findPublished(10); @@ -664,7 +674,7 @@ public function testGuardedInvalidationOnlyClearsTheTokenItExamined(string $data $examinedHash, 'the.examined.token', $issuedAt, - $issuedAt->add(new \DateInterval('P7D')), + $issuedAt->add(new DateInterval('P7D')), ); // A hash which is not the one on the row: this token is not the one that was examined. @@ -692,6 +702,7 @@ public function testGuardedInvalidationOnlyClearsTheTokenItExamined(string $data $this->assertSame(1, $statusList?->getInvalidationCounter()); } + /** * @throws \Exception */ @@ -717,6 +728,7 @@ protected function givenAllocatedEntry( ); } + /** * A second list, in the non-expiring lane, for the cases which need an entry of each kind. They can * no longer share one list, which is the point of the lane. @@ -746,6 +758,7 @@ protected function givenSeededNonExpiringList(): void $this->statusListRepository->activate(self::OTHER_LIST_ID); } + /** * @return array */ @@ -1231,6 +1244,7 @@ public function testAnInterruptedColumnMigrationCanBeRerun(string $database): vo $this->assertSame([], $migration->getNotImplementedVersions()); } + /** * @return array */ diff --git a/tests/unit/src/Admin/AuthorizationTest.php b/tests/unit/src/Admin/AuthorizationTest.php index ee64df2a..d50cf0e1 100644 --- a/tests/unit/src/Admin/AuthorizationTest.php +++ b/tests/unit/src/Admin/AuthorizationTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -17,14 +18,20 @@ use SimpleSAML\Utils\Auth; #[CoversClass(Authorization::class)] +#[AllowMockObjectsWithoutExpectations] class AuthorizationTest extends TestCase { protected MockObject $sspBridgeMock; + protected MockObject $sspBridgeUtilsMock; + protected MockObject $sspBridgeUtilsAuthMock; + protected MockObject $authContextServiceMock; + protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->sspBridgeMock = $this->createMock(SspBridge::class); @@ -37,6 +44,7 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut( ?SspBridge $sspBridge = null, ?AuthContextService $authContextService = null, @@ -49,11 +57,13 @@ protected function sut( return new Authorization($sspBridge, $authContextService, $loggerService); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Authorization::class, $this->sut()); } + public function testCanCheckIsAdmin(): void { $this->assertFalse($this->sut()->isAdmin()); @@ -61,6 +71,7 @@ public function testCanCheckIsAdmin(): void $this->assertTrue($this->sut()->isAdmin()); } + public function testCanRequireAdmin(): void { $this->expectException(AuthorizationException::class); @@ -71,6 +82,7 @@ public function testCanRequireAdmin(): void $this->sut()->requireAdmin(); } + public function testCanForceRequireAdmin(): void { $this->sspBridgeUtilsAuthMock->expects($this->once())->method('requireAdmin'); @@ -79,6 +91,7 @@ public function testCanForceRequireAdmin(): void $this->sut()->requireAdmin(true); } + public function testThrowsOnForceRequireAdminError(): void { $this->sspBridgeUtilsAuthMock->expects($this->once())->method('requireAdmin') @@ -90,6 +103,7 @@ public function testThrowsOnForceRequireAdminError(): void $this->sut()->requireAdmin(true); } + public function testRequireAdminOrUserWithPermissionReturnsIfAdmin(): void { $this->sspBridgeUtilsAuthMock->expects($this->once())->method('isAdmin')->willReturn(true); @@ -98,6 +112,7 @@ public function testRequireAdminOrUserWithPermissionReturnsIfAdmin(): void $this->sut()->requireAdminOrUserWithPermission('permission'); } + public function testRequireAdminOrUserWithPermissionReturnsIfUser(): void { $this->sspBridgeUtilsAuthMock->expects($this->atLeastOnce())->method('isAdmin') @@ -111,6 +126,7 @@ public function testRequireAdminOrUserWithPermissionReturnsIfUser(): void $this->sut()->requireAdminOrUserWithPermission('permission'); } + public function testRequireUserWithPermissionThrowsIfUserNotAuthorized(): void { $this->expectException(AuthorizationException::class); @@ -123,6 +139,7 @@ public function testRequireUserWithPermissionThrowsIfUserNotAuthorized(): void $this->sut()->requireAdminOrUserWithPermission('permission'); } + public function testCanGetUserId(): void { $this->authContextServiceMock->expects($this->once())->method('getAuthUserId')->willReturn('id'); diff --git a/tests/unit/src/Admin/ConfigOverview/ConfigOptionCoverageTest.php b/tests/unit/src/Admin/ConfigOverview/ConfigOptionCoverageTest.php index 4c9f19fc..3bb38947 100644 --- a/tests/unit/src/Admin/ConfigOverview/ConfigOptionCoverageTest.php +++ b/tests/unit/src/Admin/ConfigOverview/ConfigOptionCoverageTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -23,6 +24,7 @@ * with a short reason. */ #[CoversNothing] +#[AllowMockObjectsWithoutExpectations] class ConfigOptionCoverageTest extends TestCase { use OverviewTestTrait; @@ -31,6 +33,7 @@ class ConfigOptionCoverageTest extends TestCase use FederationOverviewTestTrait; use VciOverviewTestTrait; + /** * The overview screens, and how to build each one's sections. */ @@ -44,6 +47,7 @@ class ConfigOptionCoverageTest extends TestCase 'Legacy option which is no longer read anywhere in the module.', ]; + /** * @return array Constant name to constant value. */ @@ -63,6 +67,7 @@ protected function moduleConfigOptions(): array return $options; } + /** * Config option values displayed by a single builder, in the order the rows appear. * @@ -92,6 +97,7 @@ protected function displayedBy(string $screen): array return $displayed; } + /** * Config option values displayed anywhere in the admin UI. * @@ -109,17 +115,20 @@ protected function displayedAnywhere(): array return $displayed; } + protected function isExcluded(string $constantName): bool { return array_key_exists($constantName, self::NOT_DISPLAYED); } + public function testFoundModuleConfigOptions(): void { // Sanity check, so that a broken reflection lookup can not make the coverage test pass. $this->assertGreaterThan(50, count($this->moduleConfigOptions())); } + /** * @throws \Exception */ @@ -145,6 +154,7 @@ public function testEveryConfigOptionIsDisplayedOrExplicitlyExcluded(): void ); } + /** * @throws \Exception */ @@ -168,6 +178,7 @@ public function testExclusionListHasNoStaleEntries(): void } } + /** * @throws \Exception */ @@ -184,6 +195,7 @@ public function testScreensDisplayOnlyKnownConfigOptions(): void } } + /** * An option may legitimately appear on more than one screen (the issuer is both a protocol and * a federation concern), but showing it twice on the same screen is a mistake. diff --git a/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php index fdfd8a3d..aa1e6c77 100644 --- a/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php @@ -4,8 +4,10 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use SimpleSAML\Module\oidc\Admin\ConfigOverview\AbstractOverviewBuilder; use SimpleSAML\Module\oidc\Admin\ConfigOverview\FederationOverviewBuilder; use SimpleSAML\Module\oidc\Admin\ConfigOverview\Section; @@ -19,16 +21,19 @@ #[CoversClass(FederationOverviewBuilder::class)] #[CoversClass(AbstractOverviewBuilder::class)] +#[AllowMockObjectsWithoutExpectations] class FederationOverviewBuilderTest extends TestCase { use OverviewTestTrait; use FederationOverviewTestTrait; + public function testCanCreateInstance(): void { $this->assertInstanceOf(FederationOverviewBuilder::class, $this->buildFederationOverviewBuilder()); } + public function testCanBuildSections(): void { $sections = $this->buildFederationOverviewBuilder()->build(); @@ -43,6 +48,7 @@ public function testCanBuildSections(): void } } + public function testSectionAnchorsAreUnique(): void { $anchors = array_map( @@ -53,6 +59,7 @@ public function testSectionAnchorsAreUnique(): void $this->assertSame($anchors, array_unique($anchors)); } + public function testEveryRowHasALabel(): void { foreach ($this->flattenRows($this->buildFederationOverviewBuilder()->build()) as $row) { @@ -60,6 +67,7 @@ public function testEveryRowHasALabel(): void } } + public function testNotesWhenFederationIsDisabled(): void { $disabledRow = $this->findRowForOption( @@ -79,6 +87,7 @@ public function testNotesWhenFederationIsDisabled(): void $this->assertNull($enabledRow->getNote()); } + /** * Same guard as on the protocol screen: isIssuerConfigured() reads the option through * getOptionalString(), which throws for a non-string value. @@ -96,6 +105,7 @@ public function testSurvivesNonStringIssuerValue(): void $this->assertNull($row->getNote()); } + public function testShowsTrustAnchorsWithAndWithoutJwks(): void { $row = $this->findRowForOption( @@ -120,6 +130,7 @@ public function testShowsTrustAnchorsWithAndWithoutJwks(): void $this->assertNull($row->getWarning()); } + /** * A JWKS which is neither a string nor null makes ModuleConfig::getTrustAnchorJwksJson() throw * at runtime, so it must not be shown as though the JWKS were simply omitted. @@ -143,6 +154,7 @@ public function testFlagsInvalidTrustAnchorJwks(): void $this->assertStringContainsString('neither a JSON string nor null', (string)$row->getWarning()); } + /** * getFederationTrustAnchors() throws when federation is enabled without any Trust Anchor. That * is exactly the misconfiguration an administrator opens this screen to diagnose, so it must be @@ -164,6 +176,7 @@ public function testReportsTrustAnchorConfigurationErrorInPlace(): void $this->assertStringContainsString('written to the SimpleSAMLphp log', (string)$row->getWarning()); } + /** * The exception message from a broken option must never reach the screen, since config * validation and the openid key loading path both quote configured values in their messages. @@ -191,6 +204,7 @@ public function testLogsRatherThanRendersConfigurationErrorDetail(): void $this->assertStringNotContainsString('No Trust Anchors have been configured', (string)$row->getWarning()); } + public function testShowsAuthorityHints(): void { $row = $this->findRowForOption( @@ -204,6 +218,7 @@ public function testShowsAuthorityHints(): void $this->assertSame(['https://intermediate.example.org/'], $row->getValue()); } + public function testNeverExposesTrustMarkTokens(): void { $sections = $this->buildFederationOverviewBuilder([ @@ -220,6 +235,7 @@ public function testNeverExposesTrustMarkTokens(): void $this->assertSame('1', $row->getValue()); } + public function testShowsResolvedTrustMarks(): void { $trustMark = $this->createMock(TrustMark::class); @@ -244,6 +260,7 @@ public function testShowsResolvedTrustMarks(): void ); } + /** * A Trust Mark which cannot be read must not take the screen down, but it must not vanish * without a trace either. @@ -251,7 +268,7 @@ public function testShowsResolvedTrustMarks(): void public function testWarnsAboutTrustMarksWhichCanNotBeRead(): void { $brokenTrustMark = $this->createMock(TrustMark::class); - $brokenTrustMark->method('getTrustMarkType')->willThrowException(new \RuntimeException('broken')); + $brokenTrustMark->method('getTrustMarkType')->willThrowException(new RuntimeException('broken')); $loggerMock = $this->createMock(LoggerService::class); $loggerMock->expects($this->once()) @@ -272,6 +289,7 @@ public function testWarnsAboutTrustMarksWhichCanNotBeRead(): void $this->assertStringContainsString('could not be read', (string)$row->getWarning()); } + public function testDoesNotWarnWhenAllTrustMarksAreReadable(): void { $trustMark = $this->createMock(TrustMark::class); @@ -287,6 +305,7 @@ public function testDoesNotWarnWhenAllTrustMarksAreReadable(): void $this->assertNull($row->getWarning()); } + public function testShowsDynamicTrustMarks(): void { $row = $this->findRowForOption( @@ -302,6 +321,7 @@ public function testShowsDynamicTrustMarks(): void $this->assertSame(['trust-mark-type' => ['https://tmi.example.org/']], $row->getValue()); } + public function testDescribesTrustMarkStatusPolicy(): void { $row = $this->findRowForOption( @@ -318,6 +338,7 @@ public function testDescribesTrustMarkStatusPolicy(): void $this->assertSame(ConfigOverviewValueTypeEnum::Text, $row->getValueType()); } + public function testNormalizesParticipationLimits(): void { $row = $this->findRowForOption( @@ -345,6 +366,7 @@ public function testNormalizesParticipationLimits(): void $this->assertNull($row->getWarning()); } + /** * Warnings must be whole sentences from the catalog. Interpolating identifiers into them would * produce a string gettext can never match, leaving the warning English everywhere. @@ -367,6 +389,7 @@ public function testParticipationLimitWarningsAreWholeCatalogSentences(): void $this->assertStringNotContainsString('https://ta.example.org/', $warning); } + /** * FederationParticipationValidator calls LimitsEnum::from() on the raw map, so an unrecognized * limit identifier fails at runtime. Dropping it here would report "no limit" for a Trust Anchor @@ -394,6 +417,7 @@ public function testSurfacesUnknownParticipationLimitIds(): void $this->assertStringContainsString('Unrecognized limit identifiers', (string)$row->getWarning()); } + /** * The runtime rejects these shapes, so presenting them as an empty (harmless) rule would hide a * broken configuration. @@ -418,6 +442,7 @@ public function testWarnsAboutMalformedParticipationLimitShapes(): void $this->assertStringContainsString('unexpected shape', (string)$row->getWarning()); } + public function testWarnsAboutNonArrayParticipationLimitEntry(): void { $row = $this->findRowForOption( @@ -434,6 +459,7 @@ public function testWarnsAboutNonArrayParticipationLimitEntry(): void $this->assertStringContainsString('unexpected shape', (string)$row->getWarning()); } + public function testShowsTrustChainResolutionLimits(): void { $sections = $this->buildFederationOverviewBuilder()->build(); @@ -454,6 +480,7 @@ public function testShowsTrustChainResolutionLimits(): void } } + public function testRedactsCredentialsInFederationHttpClientOptions(): void { $sections = $this->buildFederationOverviewBuilder([ @@ -474,6 +501,7 @@ public function testRedactsCredentialsInFederationHttpClientOptions(): void $this->assertSame('(not shown)', $value['proxy']); } + public function testWarnsWhenFederationTlsVerificationIsDisabled(): void { $row = $this->findRowForOption( @@ -487,6 +515,7 @@ public function testWarnsWhenFederationTlsVerificationIsDisabled(): void $this->assertStringContainsString('man-in-the-middle', (string)$row->getWarning()); } + public function testNeverExposesCacheAdapterArguments(): void { $sections = $this->buildFederationOverviewBuilder([ @@ -498,6 +527,7 @@ public function testNeverExposesCacheAdapterArguments(): void $this->assertStringNotContainsString('super-secret-password', $this->renderableContent($sections)); } + public function testNotesWhenNoFederationCacheAdapterIsConfigured(): void { $row = $this->findRowForOption( @@ -511,6 +541,7 @@ public function testNotesWhenNoFederationCacheAdapterIsConfigured(): void $this->assertStringContainsString('recommended in production', (string)$row->getNote()); } + public function testRendersDurationsIncludingYears(): void { $row = $this->findRowForOption( @@ -524,6 +555,7 @@ public function testRendersDurationsIncludingYears(): void $this->assertSame('1 year (P1Y)', $row->getValue()); } + public function testShowsOptionalEntityMetadata(): void { $sections = $this->buildFederationOverviewBuilder([ diff --git a/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php index f5bde2b3..991c9934 100644 --- a/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Admin\ConfigOverview\AbstractOverviewBuilder; @@ -14,11 +15,13 @@ #[CoversClass(GeneralOverviewBuilder::class)] #[CoversClass(AbstractOverviewBuilder::class)] +#[AllowMockObjectsWithoutExpectations] class GeneralOverviewBuilderTest extends TestCase { use OverviewTestTrait; use GeneralOverviewTestTrait; + /** * The prepared permissions value of the given sections. * @@ -36,6 +39,7 @@ protected function permissionsValue(array $sections): array return $value; } + /** * A single prepared permission entry, by name. */ @@ -53,6 +57,7 @@ protected function permissionEntry(array $sections, string $name): array $this->fail("No permission entry named '$name' was prepared."); } + /** * @throws \Exception */ @@ -61,6 +66,7 @@ public function testCanCreateInstance(): void $this->assertInstanceOf(GeneralOverviewBuilder::class, $this->buildGeneralOverviewBuilder()); } + /** * @throws \Exception */ @@ -78,6 +84,7 @@ public function testCanBuildSections(): void } } + /** * @throws \Exception */ @@ -91,6 +98,7 @@ public function testSectionAnchorsAreUnique(): void $this->assertSame($anchors, array_unique($anchors)); } + /** * @throws \Exception */ @@ -101,6 +109,7 @@ public function testEveryRowHasALabel(): void } } + /** * @throws \Exception */ @@ -118,6 +127,7 @@ public function testShowsConfiguredCronTag(): void $this->assertStringContainsString('cron module runs this tag', (string)$row->getNote()); } + /** * Without a tag the cron hook returns before cleaning anything, so the tables grow unbounded. * @@ -136,6 +146,7 @@ public function testWarnsWhenCronTagIsNotSet(): void $this->assertStringContainsString('never', (string)$row->getWarning()); } + /** * The hook compares the configured value with the tag being run, so a value which is not a * usable tag never matches. That must be reported as cleanup not running, and not as the option @@ -157,6 +168,7 @@ public function testWarnsWhenCronTagCanNotMatchATag(): void } } + /** * A tag only ever reaches the module through the cron module's hook dispatch. * @@ -177,6 +189,7 @@ public function testWarnsWhenCronModuleIsNotEnabled(): void $this->assertStringContainsString('cron module is not enabled', (string)$row->getWarning()); } + /** * Cron::runTag() refuses a tag which is not allowed, so the hook is never reached. Nothing in * this module's own configuration hints at that, which is exactly why the screen must say so. @@ -201,6 +214,7 @@ public function testWarnsWhenCronModuleMayNotRunTheTag(): void ); } + /** * Cron::isValidTag() reads the allowed tags as a required option, so an absent one is not read * as 'anything goes': it makes the cron module throw for every tag, this module's included. @@ -226,6 +240,7 @@ public function testWarnsWhenCronHasNoUsableTagList(): void } } + /** * @throws \Exception */ @@ -243,6 +258,7 @@ public function testShowsItemsPerPage(): void $this->assertStringNotContainsString('Not set', (string)$row->getNote()); } + /** * @throws \Exception */ @@ -260,6 +276,7 @@ public function testShowsDefaultItemsPerPageWhenNotConfigured(): void $this->assertStringContainsString('Not set', (string)$row->getNote()); } + /** * ClientRepository resolves the value the same way, so an out of range value breaks the client * registry. The row must report that rather than show a number which is never used. @@ -280,6 +297,7 @@ public function testWarnsWhenItemsPerPageIsOutOfRange(): void $this->assertStringContainsString('could not be resolved', (string)$row->getWarning()); } + /** * @throws \Exception */ @@ -302,6 +320,7 @@ public function testShowsPermissions(): void $this->assertNull($client['ineffectiveReason']); } + /** * The entitlements are read through getArrayizeString(), which accepts a lone string too. * @@ -322,6 +341,7 @@ public function testAcceptsASingleEntitlementString(): void $this->assertNull($client['ineffectiveReason']); } + /** * Without an attribute to inspect, requirePermission() reports permissions as not enabled and * every check falls back to administrator authentication. @@ -352,6 +372,7 @@ public function testReportsPermissionsAsNotEnabledWithoutAnAttribute(): void $this->assertStringContainsString('permissions are off', (string)$row->getNote()); } + /** * @throws \Exception */ @@ -365,6 +386,7 @@ public function testReportsPermissionsAsNotEnabledWhenNotConfigured(): void $this->assertSame([], $value['permissions']); } + /** * getString() rejects a non-string attribute, which leaves the check failing for everyone. * @@ -385,6 +407,7 @@ public function testMarksNonStringAttributeAsInvalid(): void $this->assertTrue($value['isAttributeInvalid']); } + /** * Only the 'client' permission is ever requested, so any other key grants nothing. * @@ -404,6 +427,7 @@ public function testMarksUnrecognizedPermissionAsNotChecked(): void $this->assertNull($this->permissionEntry($sections, 'client')['ineffectiveReason']); } + /** * A permission nobody can present an entitlement for can only ever fall back to administrator * authentication, so listing it without a marker would overstate what it grants. @@ -429,6 +453,7 @@ public function testMarksPermissionWithoutUsableEntitlements(): void } } + /** * The attribute names the option to read, so it must not be listed as a permission of its own. * @@ -449,6 +474,7 @@ public function testDoesNotListTheAttributeKeyAsAPermission(): void $this->assertSame(['client'], array_column($permissions, 'name')); } + /** * A malformed option must fail on its own row rather than take the screen down. * diff --git a/tests/unit/src/Admin/ConfigOverview/GeneralOverviewTestTrait.php b/tests/unit/src/Admin/ConfigOverview/GeneralOverviewTestTrait.php index e554caad..87c65d25 100644 --- a/tests/unit/src/Admin/ConfigOverview/GeneralOverviewTestTrait.php +++ b/tests/unit/src/Admin/ConfigOverview/GeneralOverviewTestTrait.php @@ -7,6 +7,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Admin\ConfigOverview\GeneralOverviewBuilder; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Module; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; use SimpleSAML\Module\oidc\Utils\Routes; @@ -28,7 +29,7 @@ protected function buildGeneralOverviewBuilder( bool $isCronModuleEnabled = true, mixed $allowedCronTags = ['daily', 'hourly', 'frequent'], ): GeneralOverviewBuilder { - $sspBridgeModuleMock = $this->createMock(SspBridge\Module::class); + $sspBridgeModuleMock = $this->createMock(Module::class); $sspBridgeModuleMock->method('isModuleEnabled')->willReturn($isCronModuleEnabled); $sspBridgeModuleMock->method('getOptionalConfig')->willReturn( Configuration::loadFromArray(['allowed_tags' => $allowedCronTags]), diff --git a/tests/unit/src/Admin/ConfigOverview/OverviewTemplateRenderTest.php b/tests/unit/src/Admin/ConfigOverview/OverviewTemplateRenderTest.php index b6c3510b..44e36d47 100644 --- a/tests/unit/src/Admin/ConfigOverview/OverviewTemplateRenderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/OverviewTemplateRenderTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\ModuleConfig; @@ -21,6 +22,7 @@ * Strict variables are enabled so that unknown properties are errors rather than empty output. */ #[CoversNothing] +#[AllowMockObjectsWithoutExpectations] class OverviewTemplateRenderTest extends TestCase { use OverviewTestTrait; @@ -29,6 +31,7 @@ class OverviewTemplateRenderTest extends TestCase use FederationOverviewTestTrait; use VciOverviewTestTrait; + protected function twig(): Environment { $loader = new FilesystemLoader(); @@ -40,6 +43,7 @@ protected function twig(): Environment return $twig; } + /** * @throws \Exception */ @@ -48,6 +52,7 @@ protected function render(array $overrides = []): string return $this->renderSections($this->buildProtocolOverviewBuilder($overrides)->build()); } + /** * @throws \Exception */ @@ -58,6 +63,7 @@ protected function renderFederation(array $overrides = [], array $trustMarks = [ ); } + /** * @throws \Exception */ @@ -66,6 +72,7 @@ protected function renderVci(array $overrides = []): string return $this->renderSections($this->buildVciOverviewBuilder($overrides)->build()); } + /** * @throws \Exception */ @@ -74,6 +81,7 @@ protected function renderGeneral(array $overrides = []): string return $this->renderSections($this->buildGeneralOverviewBuilder($overrides)->build()); } + /** * @param \SimpleSAML\Module\oidc\Admin\ConfigOverview\Section[] $sections * @throws \Exception @@ -86,6 +94,7 @@ protected function renderSections(array $sections): string ); } + /** * @throws \Exception */ @@ -101,6 +110,7 @@ public function testCanRenderAllSections(): void } } + /** * @throws \Exception */ @@ -112,6 +122,7 @@ public function testRendersTextAndDurationValues(): void $this->assertStringContainsString('10 minutes (PT10M)', $html); } + /** * @throws \Exception */ @@ -120,6 +131,7 @@ public function testRendersUrlValuesAsLinks(): void $this->assertStringContainsString('rel="noopener noreferrer"', $this->render()); } + /** * @throws \Exception */ @@ -130,6 +142,7 @@ public function testRendersStringListValues(): void $this->assertStringContainsString('
  • eduPersonPrincipalName
  • ', $html); } + /** * @throws \Exception */ @@ -143,6 +156,7 @@ public function testRendersStringMapValues(): void $this->assertStringContainsString('example-userpass', $html); } + /** * @throws \Exception */ @@ -167,6 +181,7 @@ public function testRendersScopeValues(): void $this->assertStringContainsString('openid', $html); } + /** * @throws \Exception */ @@ -180,6 +195,7 @@ public function testRendersJsonValues(): void $this->assertStringContainsString('"timeout"', $html); } + /** * @throws \Exception */ @@ -193,6 +209,7 @@ public function testRendersWarnings(): void $this->assertStringContainsString('man-in-the-middle', $html); } + /** * @throws \Exception */ @@ -201,6 +218,7 @@ public function testRendersNotes(): void $this->assertStringContainsString('config-note', $this->render()); } + /** * @throws \Exception */ @@ -220,6 +238,7 @@ public function testRedactsCredentialsInHttpClientOptions(): void $this->assertStringContainsString('"timeout"', $html); } + /** * @throws \Exception */ @@ -235,6 +254,7 @@ public function testCanRenderAllFederationSections(): void } } + /** * @throws \Exception */ @@ -252,6 +272,7 @@ public function testRendersTrustAnchorValues(): void $this->assertStringContainsString('code-box-content', $html); } + /** * @throws \Exception */ @@ -267,6 +288,7 @@ public function testRendersTrustMarkValues(): void $this->assertStringContainsString('trust_mark_type', $html); } + /** * The key pair bag is null when it could not be resolved, and the template must cope. * @@ -282,6 +304,7 @@ public function testRendersSignatureKeyPairsWhenTheyCanNotBeResolved(): void $this->assertStringContainsString('config-warning', $html); } + /** * @throws \Exception */ @@ -297,6 +320,7 @@ public function testCanRenderAllVciSections(): void } } + /** * @throws \Exception */ @@ -327,6 +351,7 @@ public function testRendersCredentialConfigurationValues(): void $this->assertStringContainsString('credentialSubject.mail', $html); } + /** * The two ineffective mapping markers are matched on a string the builder produces, so a renamed * reason constant would silently stop rendering. Both branches are pinned here. @@ -366,6 +391,7 @@ public function testRendersBothIneffectiveMappingReasons(): void ); } + /** * Only the pair issuance actually reads is listed, so without this marker the entry would look * complete while the row warning claims something is wrong with it. @@ -402,6 +428,7 @@ public function testRendersIgnoredMappingPairMarker(): void $this->assertStringNotContainsString('eduPersonPrincipalName →', $html); } + /** * @throws \Exception */ @@ -417,6 +444,7 @@ public function testCanRenderAllGeneralSections(): void } } + /** * @throws \Exception */ @@ -433,6 +461,7 @@ public function testRendersAdminUiPermissionValues(): void $this->assertStringContainsString('urn:example:oidc:manage:client', $html); } + /** * The two ineffective permission markers are matched on a string the builder produces, so a * renamed reason constant would silently stop rendering. Both branches are pinned here. @@ -455,6 +484,7 @@ public function testRendersBothIneffectivePermissionReasons(): void $this->assertStringContainsString('not a permission this module checks, so it grants nothing', $html); } + /** * Permissions are off without an attribute to inspect, but what is configured must stay visible. * @@ -472,6 +502,7 @@ public function testRendersPermissionsWhichAreNotEnabled(): void $this->assertStringContainsString('urn:example:oidc:manage:client', $html); } + /** * @throws \Exception */ @@ -486,6 +517,7 @@ public function testRendersInvalidPermissionsAttribute(): void $this->assertStringContainsString('The attribute to inspect is not a string', $html); } + /** * @throws \Exception */ @@ -506,6 +538,7 @@ public function testDoesNotRenderFederationSecrets(): void $this->assertStringNotContainsString('super-secret-basic-password', $html); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php b/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php index e9de44f9..23a8ffd0 100644 --- a/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php +++ b/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php @@ -8,6 +8,7 @@ use SimpleSAML\Module\oidc\Admin\ConfigOverview\Row; use SimpleSAML\Module\oidc\Admin\ConfigOverview\Section; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\ValueAbstracts; use SimpleSAML\Utils\Config; @@ -40,7 +41,7 @@ protected function buildOverviewModuleConfig( $sspBridgeUtilsHttpMock = $this->createMock(HTTP::class); $sspBridgeUtilsHttpMock->method('getSelfURLHost')->willReturn($derivedHost); - $sspBridgeUtilsMock = $this->createMock(SspBridge\Utils::class); + $sspBridgeUtilsMock = $this->createMock(Utils::class); $sspBridgeUtilsMock->method('config')->willReturn($sspBridgeUtilsConfigMock); $sspBridgeUtilsMock->method('http')->willReturn($sspBridgeUtilsHttpMock); @@ -56,6 +57,7 @@ protected function buildOverviewModuleConfig( ); } + /** * Flatten all rows of the given sections. * @@ -76,6 +78,7 @@ protected function flattenRows(array $sections): array return $rows; } + /** * Find the row which displays the given ModuleConfig::OPTION_* value. * @@ -92,6 +95,7 @@ protected function findRowForOption(array $sections, string $configOption): ?Row return null; } + /** * Find a row by its label. * @@ -108,6 +112,7 @@ protected function findRowByLabel(array $sections, string $label): ?Row return null; } + /** * All displayable (scalar or array) row content, as one searchable string. Used to assert that * secrets never reach the screen. diff --git a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php index e83e612b..273ae820 100644 --- a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Admin\ConfigOverview\ProtocolOverviewBuilder; @@ -20,16 +21,19 @@ #[CoversClass(ProtocolOverviewBuilder::class)] #[CoversClass(Row::class)] #[CoversClass(Section::class)] +#[AllowMockObjectsWithoutExpectations] class ProtocolOverviewBuilderTest extends TestCase { use OverviewTestTrait; use ProtocolOverviewTestTrait; + public function testCanCreateInstance(): void { $this->assertInstanceOf(ProtocolOverviewBuilder::class, $this->buildProtocolOverviewBuilder()); } + public function testCanBuildSections(): void { $sections = $this->buildProtocolOverviewBuilder()->build(); @@ -44,6 +48,7 @@ public function testCanBuildSections(): void } } + public function testSectionAnchorsAreUnique(): void { $anchors = array_map( @@ -54,6 +59,7 @@ public function testSectionAnchorsAreUnique(): void $this->assertSame($anchors, array_unique($anchors)); } + public function testEveryRowHasALabel(): void { foreach ($this->flattenRows($this->buildProtocolOverviewBuilder()->build()) as $row) { @@ -61,6 +67,7 @@ public function testEveryRowHasALabel(): void } } + public function testEachConfigOptionIsShownOnlyOnce(): void { $configOptions = []; @@ -76,6 +83,7 @@ public function testEachConfigOptionIsShownOnlyOnce(): void $this->assertSame($configOptions, array_unique($configOptions)); } + public function testRendersDurationsIncludingYears(): void { $sections = $this->buildProtocolOverviewBuilder( @@ -88,6 +96,7 @@ public function testRendersDurationsIncludingYears(): void $this->assertSame('1 year (P1Y)', $row->getValue()); } + public function testNotesWhenIssuerIsNotExplicitlyConfigured(): void { $configuredRow = $this->findRowForOption( @@ -105,6 +114,7 @@ public function testNotesWhenIssuerIsNotExplicitlyConfigured(): void $this->assertStringContainsString('derived', (string)$derivedRow->getNote()); } + /** * A broken option must be reported in place rather than take the screen down, since this is the * screen an administrator opens to diagnose it. Mirrors the federation screen's behaviour. @@ -122,6 +132,7 @@ public function testReportsSignatureKeyPairErrorInPlace(): void $this->assertStringContainsString('written to the SimpleSAMLphp log', (string)$row->getWarning()); } + public function testReportsIssuerErrorInPlace(): void { // With no configured issuer and a host which resolves to an empty string, getIssuer() throws. @@ -137,6 +148,7 @@ public function testReportsIssuerErrorInPlace(): void $this->assertNotNull($row->getWarning()); } + /** * The destination policy options are what an administrator comes to this screen to check when * outbound fetches start failing, so a malformed one has to fail on its own row. Resolving them @@ -161,6 +173,7 @@ public function testReportsAMalformedDestinationPolicyOptionInPlace(): void ); } + /** * Who may introspect another client's tokens is exactly the sort of thing an administrator opens * this screen to check, so a mistyped list has to fail on its own row rather than take the screen @@ -189,6 +202,7 @@ public function testReportsMalformedIntrospectionResourceServersInPlace(): void $this->assertNotNull($this->findRowForOption($sections, ModuleConfig::OPTION_API_TOKENS)); } + /** * A non-string issuer makes getOptionalString() throw, which both getIssuer() and * isIssuerConfigured() go through. Resolving the configured state outside the guard would @@ -208,6 +222,7 @@ public function testSurvivesNonStringIssuerValue(): void $this->assertNull($row->getNote()); } + /** * The scope list pulls in Verifiable Credential scopes, so a malformed VCI configuration can * break protocol rows. When that happens the descriptive notes must be suppressed, otherwise the @@ -234,6 +249,7 @@ public function testSuppressesNotesWhenScopeResolutionFails(): void $this->assertNull($defaultScopesRow->getNote()); } + public function testDoesNotExposeConfigurationErrorDetail(): void { $loggerMock = $this->createMock(LoggerService::class); @@ -258,6 +274,7 @@ public function testDoesNotExposeConfigurationErrorDetail(): void $this->assertStringNotContainsString('At least one', (string)$row->getWarning()); } + public function testNeverExposesEncryptionKey(): void { $sections = $this->buildProtocolOverviewBuilder( @@ -274,6 +291,7 @@ public function testNeverExposesEncryptionKey(): void $this->assertStringContainsString('Dedicated', (string)$row->getValue()); } + public function testReportsEncryptionKeyFallbackToSecretSalt(): void { $row = $this->findRowForOption( @@ -285,6 +303,7 @@ public function testReportsEncryptionKeyFallbackToSecretSalt(): void $this->assertStringContainsString('secret salt', (string)$row->getValue()); } + public function testNeverExposesInitialAccessTokens(): void { $sections = $this->buildProtocolOverviewBuilder([ @@ -302,6 +321,7 @@ public function testNeverExposesInitialAccessTokens(): void $this->assertSame('1', $row->getValue()); } + public function testNeverExposesApiTokens(): void { $sections = $this->buildProtocolOverviewBuilder([ @@ -315,6 +335,7 @@ public function testNeverExposesApiTokens(): void ); } + public function testNeverExposesCacheAdapterArguments(): void { $sections = $this->buildProtocolOverviewBuilder([ @@ -329,6 +350,7 @@ public function testNeverExposesCacheAdapterArguments(): void ); } + public function testRedactsCredentialsInHttpClientOptions(): void { $sections = $this->buildProtocolOverviewBuilder([ @@ -362,6 +384,7 @@ public function testRedactsCredentialsInHttpClientOptions(): void $this->assertSame('(not shown)', $value['cert']); } + public function testStillDetectsDisabledTlsVerificationBehindRedaction(): void { $row = $this->findRowForOption( @@ -383,6 +406,7 @@ public function testStillDetectsDisabledTlsVerificationBehindRedaction(): void $this->assertSame('(not shown)', $value['auth']); } + public function testConfiguredValuesAreNotTranslatable(): void { $sections = $this->buildProtocolOverviewBuilder()->build(); @@ -408,6 +432,7 @@ public function testConfiguredValuesAreNotTranslatable(): void } } + public function testWarnsWhenTlsVerificationIsDisabled(): void { $sections = $this->buildProtocolOverviewBuilder([ @@ -420,6 +445,7 @@ public function testWarnsWhenTlsVerificationIsDisabled(): void $this->assertStringContainsString('man-in-the-middle', (string)$row->getWarning()); } + public function testDoesNotWarnAboutTlsVerificationByDefault(): void { $row = $this->findRowForOption( @@ -431,6 +457,7 @@ public function testDoesNotWarnAboutTlsVerificationByDefault(): void $this->assertNull($row->getWarning()); } + public function testShowsFederationRequestUriAllowlist(): void { $deniedRow = $this->findRowForOption( @@ -452,6 +479,7 @@ public function testShowsFederationRequestUriAllowlist(): void $this->assertNull($allowlistedRow->getWarning()); } + public function testWarnsWhenAnyFederationRequestUriIsAllowed(): void { $row = $this->findRowForOption( @@ -466,6 +494,7 @@ public function testWarnsWhenAnyFederationRequestUriIsAllowed(): void $this->assertStringContainsString('server-side request forgery', (string)$row->getWarning()); } + public function testDoesNotWarnAboutRequestUriAllowlistWhenFetchingIsDisabled(): void { $row = $this->findRowForOption( @@ -481,6 +510,7 @@ public function testDoesNotWarnAboutRequestUriAllowlistWhenFetchingIsDisabled(): $this->assertNull($row->getWarning()); } + /** * RequestParamsResolver only takes the federation by-reference path when federation is enabled, * so warning about it while federation is off would be a false alarm. @@ -501,6 +531,7 @@ public function testDoesNotWarnAboutRequestUriAllowlistWhenFederationIsDisabled( $this->assertStringContainsString('Federation is disabled', (string)$row->getNote()); } + public function testWarnsWhenDynamicClientRegistrationIsOpen(): void { $row = $this->findRowForOption( @@ -512,6 +543,7 @@ public function testWarnsWhenDynamicClientRegistrationIsOpen(): void $this->assertStringContainsString('open', (string)$row->getWarning()); } + public function testDoesNotWarnAboutOpenRegistrationWhenDcrIsDisabled(): void { $row = $this->findRowForOption( @@ -523,6 +555,7 @@ public function testDoesNotWarnAboutOpenRegistrationWhenDcrIsDisabled(): void $this->assertNull($row->getWarning()); } + public function testWarnsWhenInitialAccessTokenModeHasNoTokens(): void { $row = $this->findRowForOption( @@ -538,6 +571,7 @@ public function testWarnsWhenInitialAccessTokenModeHasNoTokens(): void $this->assertStringContainsString('rejected', (string)$row->getWarning()); } + public function testWarnsWhenImpersonationProtectionIsDisabled(): void { $row = $this->findRowForOption( @@ -552,6 +586,7 @@ public function testWarnsWhenImpersonationProtectionIsDisabled(): void $this->assertNotNull($row->getWarning()); } + public function testNotesWhenDcrDefaultScopesFallBackToAllSupported(): void { $fallbackRow = $this->findRowForOption( @@ -572,6 +607,7 @@ public function testNotesWhenDcrDefaultScopesFallBackToAllSupported(): void $this->assertSame(['openid'], $configuredRow->getValue()); } + public function testMarksScopeOrigin(): void { $row = $this->findRowForOption( @@ -603,6 +639,7 @@ public function testMarksScopeOrigin(): void $this->assertSame(['national_document_id'], $scopesByName['private']['claims']); } + public function testRendersAuthProcFiltersInBothConfigForms(): void { $row = $this->findRowForOption( @@ -627,6 +664,7 @@ public function testRendersAuthProcFiltersInBothConfigForms(): void ); } + /** * SimpleSAMLphp's ProcessingChain runs filters by priority, not by order of declaration, so the * overview must show the effective execution order. @@ -655,6 +693,7 @@ public function testSortsAuthProcFiltersByPriority(): void ); } + public function testShowsRegistrationEndpointOnlyWhenDcrIsEnabled(): void { $labels = fn(array $sections): array => array_map( diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php index ad55de94..da6d988f 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -15,11 +16,13 @@ #[CoversClass(VciOverviewBuilder::class)] #[CoversClass(AbstractOverviewBuilder::class)] +#[AllowMockObjectsWithoutExpectations] class VciOverviewBuilderTest extends TestCase { use OverviewTestTrait; use VciOverviewTestTrait; + /** * A minimal but realistic credential configuration, shaped like the one in the config template. */ @@ -42,11 +45,13 @@ protected static function credentialConfiguration(): array ]; } + public function testCanCreateInstance(): void { $this->assertInstanceOf(VciOverviewBuilder::class, $this->buildVciOverviewBuilder()); } + public function testCanBuildSections(): void { $sections = $this->buildVciOverviewBuilder()->build(); @@ -61,6 +66,7 @@ public function testCanBuildSections(): void } } + public function testSectionAnchorsAreUnique(): void { $anchors = array_map( @@ -71,6 +77,7 @@ public function testSectionAnchorsAreUnique(): void $this->assertSame($anchors, array_unique($anchors)); } + public function testEveryRowHasALabel(): void { foreach ($this->flattenRows($this->buildVciOverviewBuilder()->build()) as $row) { @@ -78,6 +85,7 @@ public function testEveryRowHasALabel(): void } } + public function testNotesWhenVciIsDisabled(): void { $disabledRow = $this->findRowForOption( @@ -99,6 +107,7 @@ public function testNotesWhenVciIsDisabled(): void $this->assertNull($enabledRow->getNote()); } + /** * The experimental status is stated in the documentation and in the distributed configuration, but * neither is necessarily where the person who switched this on is looking. Once credentials are @@ -120,6 +129,7 @@ public function testWarnsThatIssuanceIsExperimentalOnceItIsEnabled(): void $this->assertStringNotContainsString('draft', strtolower($warning)); } + public function testBuildsCredentialConfigurationDetail(): void { $row = $this->findRowForOption( @@ -176,6 +186,7 @@ public function testBuildsCredentialConfigurationDetail(): void $this->assertNull($configuration['jsonLdContextUrl']); } + /** * CredentialIssuerCredentialController skips a mapping whose path is not among the declared * claim paths, so the screen must not present it as effective. @@ -210,6 +221,7 @@ public function testMarksMappingsIssuanceWillSkip(): void $this->assertStringContainsString('never reaches the credential', (string)$row->getWarning()); } + public function testDoesNotWarnWhenEveryMappingIsEffective(): void { $row = $this->findRowForOption( @@ -229,6 +241,7 @@ public function testDoesNotWarnWhenEveryMappingIsEffective(): void $this->assertNull($row->getWarning()); } + /** * Issuance reads only key()/current() of a map entry, so any further pair is ignored. Listing * them all would claim attributes are issued when they never are. @@ -265,6 +278,7 @@ public function testShowsOnlyTheMappingPairIssuanceUses(): void $this->assertStringContainsString('only the first pair', (string)$row->getWarning()); } + public function testSkipsMappingEntriesWithNonStringAttributeName(): void { $row = $this->findRowForOption( @@ -287,6 +301,7 @@ public function testSkipsMappingEntriesWithNonStringAttributeName(): void $this->assertSame([], $configurations[0]['attributeMappings']); } + /** * getVciCredentialJsonLdContext() throws for a non-array value, and it sits outside the * credential configuration guard. @@ -306,6 +321,7 @@ public function testReportsMalformedJsonLdContextInPlace(): void $this->assertNull($row->getNote()); } + /** * CredentialIssuerCredentialController can only issue jwt_vc_json, dc+sd-jwt and vc+sd-jwt, and * rejects a configuration whose format is missing, so an unsupported one must not look valid. @@ -333,6 +349,7 @@ public function testFlagsUnsupportedCredentialFormat(): void $this->assertStringContainsString('unsupported format', (string)$row->getWarning()); } + /** * Issuance filters a mapping path down to its string segments and writes at what remains, so the * screen must name the path the credential ends up with, not the one that was configured. @@ -374,6 +391,7 @@ public function testRendersMappingPathWithoutNonStringSegments(): void $this->assertTrue($mapping['isEffective']); } + /** * array_filter() preserves keys, so a non-string segment before the end leaves a gap in them and * the in_array() comparison against the declared paths fails. Issuance rejects such a mapping, so @@ -412,6 +430,7 @@ public function testMarksMappingWithInterruptedPathAsIneffective(): void $this->assertSame('notDeclared', $mapping['ineffectiveReason']); } + /** * A declared path is handed to issuance unchanged, so it is shown as configured even when a * segment could never form part of a usable path. @@ -441,6 +460,7 @@ public function testRendersDeclaredClaimPathAsConfigured(): void $this->assertSame(['credentialSubject.0.mail'], $configurations[0]['claimPaths']); } + /** * For jwt_vc_json the controller writes at the configured path but serializes only the * 'credentialSubject' branch, so a path rooted elsewhere is written and immediately dropped. @@ -487,6 +507,7 @@ public function testMarksJwtVcJsonMappingOutsideCredentialSubjectAsIneffective() $this->assertSame('droppedForFormat', $mappingsByAttribute['secondaryMail']['ineffectiveReason']); } + /** * The SD-JWT formats do not have that restriction: dc+sd-jwt uses the path as-is. */ @@ -520,6 +541,7 @@ public function testAcceptsSdJwtMappingOutsideCredentialSubject(): void $this->assertNull($row->getWarning()); } + /** * vc+sd-jwt does root a disclosure under 'credentialSubject' when the parent path does not * already mention it, so the effective path differs from the configured one. @@ -567,6 +589,7 @@ public function testRootsVcSdJwtDisclosurePathUnderCredentialSubject(): void $this->assertTrue($mappingsByAttribute['givenName']['isEffective']); } + public function testLinksJsonLdContextWhenConfigured(): void { $row = $this->findRowForOption( @@ -589,6 +612,7 @@ public function testLinksJsonLdContextWhenConfigured(): void $this->assertNotNull($configurations[0]['jsonLdContextUrl']); } + /** * A non-array credential configuration makes getVciCredentialConfiguration() throw, which must * be reported in place rather than take the screen down. @@ -609,6 +633,7 @@ public function testReportsMalformedCredentialConfigurationInPlace(): void $this->assertNull($row->getNote()); } + public function testDoesNotExposeMalformedCredentialConfigurationDetail(): void { $sections = $this->buildVciOverviewBuilder([ @@ -619,6 +644,7 @@ public function testDoesNotExposeMalformedCredentialConfigurationDetail(): void $this->assertStringNotContainsString('super-secret-looking-value', $this->renderableContent($sections)); } + public function testWarnsWhenNonRegisteredClientsHaveNoAllowedPrefixes(): void { $row = $this->findRowForOption( @@ -634,6 +660,7 @@ public function testWarnsWhenNonRegisteredClientsHaveNoAllowedPrefixes(): void $this->assertStringContainsString('will be rejected', (string)$row->getWarning()); } + /** * ClientRedirectUriRule casts each configured prefix with (string), so a null becomes an empty * prefix and str_starts_with() then matches every redirect URI. Filtering non-strings out would @@ -659,6 +686,7 @@ public function testMirrorsRuntimeNormalizationOfRedirectPrefixes(): void $this->assertStringContainsString('redirected anywhere', (string)$row->getWarning()); } + /** * The runtime does not skip a nested array: casting it yields the literal 'Array', so a redirect * URI starting with that text would be accepted. Dropping it here would hide that. @@ -681,6 +709,7 @@ public function testShowsNestedArrayPrefixAsTheRuntimeCastsIt(): void $this->assertStringContainsString('not a string', (string)$row->getWarning()); } + public function testDoesNotWarnAboutPrefixesWhenNonRegisteredClientsAreDisallowed(): void { $row = $this->findRowForOption( @@ -695,6 +724,7 @@ public function testDoesNotWarnAboutPrefixesWhenNonRegisteredClientsAreDisallowe $this->assertNull($row->getWarning()); } + public function testNotesIssuerStateTtlFallback(): void { $fallbackRow = $this->findRowForOption( @@ -714,6 +744,7 @@ public function testNotesIssuerStateTtlFallback(): void $this->assertSame('30 minutes (PT30M)', $configuredRow->getValue()); } + public function testNotesNonceTtlFallback(): void { $row = $this->findRowForOption( @@ -726,6 +757,7 @@ public function testNotesNonceTtlFallback(): void $this->assertStringContainsString('falls back to 5 minutes', (string)$row->getNote()); } + /** * The credential offer endpoint is gated by the module API master switch, which lives on the * protocol screen, so an inconsistent pair must be called out here. @@ -744,6 +776,7 @@ public function testWarnsWhenCredentialOfferEndpointIsEnabledWithoutTheApi(): vo $this->assertStringContainsString('module API itself is disabled', (string)$row->getWarning()); } + /** * VciCredentialOfferApiController rejects every request unless VCI is enabled, so the endpoint * must not be listed as served on the strength of the API switches alone. @@ -774,6 +807,7 @@ public function testDoesNotListOfferEndpointWhenVciIsDisabled(): void ); } + /** * Both CredentialIssuerCredentialController and NonceController throw a forbidden response from * their constructor while VCI is off, so neither endpoint may be presented as usable. @@ -803,6 +837,7 @@ public function testMarksCredentialAndNonceEndpointsUnservedWhenVciIsDisabled(): } } + /** * A malformed api_enabled belongs to the protocol screen; it must not be reported as a failure * of the credential offer endpoint option, whose own value is still readable. @@ -823,6 +858,7 @@ public function testDoesNotBlameOfferEndpointForMalformedApiSwitch(): void $this->assertStringNotContainsString('written to the SimpleSAMLphp log', (string)$row->getWarning()); } + public function testDoesNotWarnWhenApiAndOfferEndpointAgree(): void { $row = $this->findRowForOption( @@ -837,6 +873,7 @@ public function testDoesNotWarnWhenApiAndOfferEndpointAgree(): void $this->assertNull($row->getWarning()); } + public function testShowsEmailAttributeConfiguration(): void { $sections = $this->buildVciOverviewBuilder([ @@ -858,6 +895,7 @@ public function testShowsEmailAttributeConfiguration(): void $this->assertSame(['example-userpass' => ['emailAddress']], $mapRow->getValue()); } + /** * A malformed JSON-LD or attribute-map option must be reported on its own row, without emptying * the otherwise valid credential configurations and blaming the wrong setting. @@ -883,6 +921,7 @@ public function testIsolatesJsonLdFailureFromCredentialConfigurations(): void $this->assertNotNull($jsonLdRow->getWarning()); } + public function testIsolatesAttributeMapFailureFromCredentialConfigurations(): void { $sections = $this->buildVciOverviewBuilder([ @@ -906,6 +945,7 @@ public function testIsolatesAttributeMapFailureFromCredentialConfigurations(): v $this->assertStringContainsString('written to the SimpleSAMLphp log', (string)$mapRow->getWarning()); } + /** * getUsersEmailAttributeNameForAuthSourceId() only honours a string and otherwise falls back to * the default, so a non-string entry is not an override and must not be shown as one. @@ -946,6 +986,7 @@ public function testSurvivesMalformedOption(array $overrides, string $configOpti $this->assertNull($row->getNote()); } + public static function malformedOptionProvider(): array { return [ @@ -980,6 +1021,7 @@ public static function malformedOptionProvider(): array ]; } + /** * A non-array entry is treated as absent by the runtime, so counting it as a configured document * would advertise one whose endpoint returns 404. @@ -1001,6 +1043,7 @@ public function testCountsOnlyUsableJsonLdContexts(): void $this->assertSame('1', $row->getValue()); } + public function testReportsSignatureKeyPairErrorInPlace(): void { $row = $this->findRowForOption( diff --git a/tests/unit/src/Admin/Menu/ItemTest.php b/tests/unit/src/Admin/Menu/ItemTest.php index 941cae4f..63b51ca1 100644 --- a/tests/unit/src/Admin/Menu/ItemTest.php +++ b/tests/unit/src/Admin/Menu/ItemTest.php @@ -4,17 +4,22 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\Menu; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Admin\Menu\Item; #[CoversClass(Item::class)] +#[AllowMockObjectsWithoutExpectations] class ItemTest extends TestCase { protected string $hrefPath; + protected string $label; + protected string $iconAssetPath; + protected function setUp(): void { $this->hrefPath = 'path'; @@ -22,6 +27,7 @@ protected function setUp(): void $this->iconAssetPath = 'icon-path'; } + protected function sut( ?string $hrefPath = null, ?string $label = null, @@ -34,6 +40,7 @@ protected function sut( return new Item($hrefPath, $label, $iconAssetPath); } + public function testCanCreateInstance(): void { $sut = $this->sut(); diff --git a/tests/unit/src/Admin/MenuTest.php b/tests/unit/src/Admin/MenuTest.php index be99a132..6d13b3cc 100644 --- a/tests/unit/src/Admin/MenuTest.php +++ b/tests/unit/src/Admin/MenuTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -13,27 +14,32 @@ #[CoversClass(Menu::class)] #[UsesClass(Item::class)] +#[AllowMockObjectsWithoutExpectations] class MenuTest extends TestCase { protected MockObject $itemMock; + protected function setUp(): void { $this->itemMock = $this->createMock(Item::class); } + protected function sut( ?Item ...$items, ): Menu { return new Menu(...$items); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Menu::class, $this->sut()); $this->assertInstanceOf(Menu::class, $this->sut($this->itemMock)); } + public function testCanAddGetItem(): void { $sut = $this->sut(); @@ -42,6 +48,7 @@ public function testCanAddGetItem(): void $this->assertCount(1, $sut->getItems()); } + public function testCanSetGetActiveHrefPath(): void { $sut = $this->sut(); @@ -50,6 +57,7 @@ public function testCanSetGetActiveHrefPath(): void $this->assertSame('oidc', $sut->getActiveHrefPath()); } + public function testCanBuildItem(): void { $this->assertInstanceOf(Item::class, $this->sut()->buildItem('oidc', 'OIDC')); diff --git a/tests/unit/src/Bridges/OAuth2BridgeTest.php b/tests/unit/src/Bridges/OAuth2BridgeTest.php index 5963247e..2bf7a5fe 100644 --- a/tests/unit/src/Bridges/OAuth2BridgeTest.php +++ b/tests/unit/src/Bridges/OAuth2BridgeTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges; use Defuse\Crypto\Key; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -15,9 +16,11 @@ #[CoversClass(OAuth2Bridge::class)] #[UsesClass(OidcException::class)] +#[AllowMockObjectsWithoutExpectations] class OAuth2BridgeTest extends TestCase { private ModuleConfig&MockObject $moduleConfig; + private OAuth2Bridge $bridge; @@ -42,6 +45,7 @@ public function testEncryptDecryptWithPasswordFromConfig(): void $this->assertEquals($unencrypted, $decrypted); } + public function testEncryptDecryptWithExplicitKey(): void { $key = Key::createNewRandomKey(); @@ -55,6 +59,7 @@ public function testEncryptDecryptWithExplicitKey(): void $this->assertEquals($unencrypted, $decrypted); } + public function testEncryptDecryptWithExplicitPassword(): void { $password = 'secret-password-explicit'; diff --git a/tests/unit/src/Bridges/PsrHttpBridgeTest.php b/tests/unit/src/Bridges/PsrHttpBridgeTest.php index c27cc750..35bf6cd3 100644 --- a/tests/unit/src/Bridges/PsrHttpBridgeTest.php +++ b/tests/unit/src/Bridges/PsrHttpBridgeTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -16,14 +17,20 @@ use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; #[CoversClass(PsrHttpBridge::class)] +#[AllowMockObjectsWithoutExpectations] class PsrHttpBridgeTest extends TestCase { protected MockObject $httpFoundationFactoryMock; + protected MockObject $serverRequestFactoryMock; + protected MockObject $responseFactoryMock; + protected MockObject $streamFactoryMock; + protected MockObject $uploadedFileFactoryMock; + protected function setUp(): void { $this->httpFoundationFactoryMock = $this->createMock(HttpFoundationFactory::class); @@ -33,6 +40,7 @@ protected function setUp(): void $this->uploadedFileFactoryMock = $this->createMock(UploadedFileFactoryInterface::class); } + protected function sut( ?HttpFoundationFactory $httpFoundationFactory = null, ?ServerRequestFactoryInterface $serverRequestFactory = null, @@ -55,11 +63,13 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(PsrHttpBridge::class, $this->sut()); } + public function testCanGetProperties(): void { $sut = $this->sut(); diff --git a/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php b/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php index 63c0989d..68c40d88 100644 --- a/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php +++ b/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge\Auth; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth\Source; #[CoversClass(Source::class)] +#[AllowMockObjectsWithoutExpectations] class SourceTest extends TestCase { protected function sut(): Source @@ -16,6 +18,7 @@ protected function sut(): Source return new Source(); } + public function testCanGetSources(): void { $this->assertTrue(in_array('admin', $this->sut()->getSources())); diff --git a/tests/unit/src/Bridges/SspBridge/AuthTest.php b/tests/unit/src/Bridges/SspBridge/AuthTest.php index d573f64b..25942aa4 100644 --- a/tests/unit/src/Bridges/SspBridge/AuthTest.php +++ b/tests/unit/src/Bridges/SspBridge/AuthTest.php @@ -4,11 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth\Source; #[CoversClass(Auth::class)] +#[AllowMockObjectsWithoutExpectations] class AuthTest extends TestCase { protected function sut(): Auth @@ -16,13 +19,15 @@ protected function sut(): Auth return new Auth(); } + public function testCanConstruct(): void { $this->assertInstanceOf(Auth::class, $this->sut()); } + public function testCanBuildSourceInstance(): void { - $this->assertInstanceOf(Auth\Source::class, $this->sut()->source()); + $this->assertInstanceOf(Source::class, $this->sut()->source()); } } diff --git a/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php b/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php index 9dd04998..bf084024 100644 --- a/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php +++ b/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php @@ -4,12 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge\Locale; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale\Language; #[CoversClass(Language::class)] +#[AllowMockObjectsWithoutExpectations] class LanguageTest extends TestCase { protected function sut(): Language @@ -17,11 +19,13 @@ protected function sut(): Language return new Language(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Language::class, $this->sut()); } + public function testCanGetAvailableLanguages(): void { $configuration = Configuration::loadFromArray([ diff --git a/tests/unit/src/Bridges/SspBridge/LocaleTest.php b/tests/unit/src/Bridges/SspBridge/LocaleTest.php index 8637123e..06cc5d1d 100644 --- a/tests/unit/src/Bridges/SspBridge/LocaleTest.php +++ b/tests/unit/src/Bridges/SspBridge/LocaleTest.php @@ -4,11 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale\Language; #[CoversClass(Locale::class)] +#[AllowMockObjectsWithoutExpectations] class LocaleTest extends TestCase { protected function sut(): Locale @@ -16,13 +19,15 @@ protected function sut(): Locale return new Locale(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Locale::class, $this->sut()); } + public function testCanBuildLanguageInstance(): void { - $this->assertInstanceOf(Locale\Language::class, $this->sut()->language()); + $this->assertInstanceOf(Language::class, $this->sut()->language()); } } diff --git a/tests/unit/src/Bridges/SspBridge/Module/AdminTest.php b/tests/unit/src/Bridges/SspBridge/Module/AdminTest.php index 8e8d3aac..d4a1ab61 100644 --- a/tests/unit/src/Bridges/SspBridge/Module/AdminTest.php +++ b/tests/unit/src/Bridges/SspBridge/Module/AdminTest.php @@ -4,12 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge\Module; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\admin\Controller\Menu; use SimpleSAML\Module\oidc\Bridges\SspBridge\Module\Admin; #[CoversClass(Admin::class)] +#[AllowMockObjectsWithoutExpectations] class AdminTest extends TestCase { protected function sut(): Admin @@ -17,11 +19,13 @@ protected function sut(): Admin return new Admin(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Admin::class, $this->sut()); } + public function testCanBuildSspAdminMenu(): void { $this->assertInstanceOf(Menu::class, $this->sut()->buildSspAdminMenu()); diff --git a/tests/unit/src/Bridges/SspBridge/ModuleTest.php b/tests/unit/src/Bridges/SspBridge/ModuleTest.php index 08c831e4..7590fcaa 100644 --- a/tests/unit/src/Bridges/SspBridge/ModuleTest.php +++ b/tests/unit/src/Bridges/SspBridge/ModuleTest.php @@ -4,11 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge\Module; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Module\Admin; #[CoversClass(Module::class)] +#[AllowMockObjectsWithoutExpectations] class ModuleTest extends TestCase { protected function sut(): Module @@ -16,16 +19,19 @@ protected function sut(): Module return new Module(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Module::class, $this->sut()); } + public function testCanBuildAdminInstance(): void { - $this->assertInstanceOf(Module\Admin::class, $this->sut()->admin()); + $this->assertInstanceOf(Admin::class, $this->sut()->admin()); } + public function testCanGetModuleUrl(): void { $this->assertStringContainsString( @@ -34,6 +40,7 @@ public function testCanGetModuleUrl(): void ); } + public function testCanCheckIsModuleEnabled(): void { $this->assertFalse($this->sut()->isModuleEnabled('invalid')); diff --git a/tests/unit/src/Bridges/SspBridge/UtilsTest.php b/tests/unit/src/Bridges/SspBridge/UtilsTest.php index e9cb8be9..d67bf414 100644 --- a/tests/unit/src/Bridges/SspBridge/UtilsTest.php +++ b/tests/unit/src/Bridges/SspBridge/UtilsTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges\SspBridge; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; @@ -14,6 +15,7 @@ use SimpleSAML\Utils\Random; #[CoversClass(Utils::class)] +#[AllowMockObjectsWithoutExpectations] class UtilsTest extends TestCase { protected function sut(): Utils @@ -21,31 +23,37 @@ protected function sut(): Utils return new Utils(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(Utils::class, $this->sut()); } + public function testCanBuildConfigInstance(): void { $this->assertInstanceOf(Config::class, $this->sut()->config()); } + public function testCanBuildHttpInstance(): void { $this->assertInstanceOf(HTTP::class, $this->sut()->http()); } + public function testCanBuildRandomInstance(): void { $this->assertInstanceOf(Random::class, $this->sut()->random()); } + public function testCanBuildAuthInstance(): void { $this->assertInstanceOf(Auth::class, $this->sut()->auth()); } + public function testCanBuileAttributesInstance(): void { $this->assertInstanceOf(Attributes::class, $this->sut()->attributes()); diff --git a/tests/unit/src/Bridges/SspBridgeTest.php b/tests/unit/src/Bridges/SspBridgeTest.php index ae220ab4..2d547884 100644 --- a/tests/unit/src/Bridges/SspBridgeTest.php +++ b/tests/unit/src/Bridges/SspBridgeTest.php @@ -4,11 +4,17 @@ namespace SimpleSAML\Test\Module\oidc\unit\Bridges; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Module; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; #[CoversClass(SspBridge::class)] +#[AllowMockObjectsWithoutExpectations] class SspBridgeTest extends TestCase { protected function sut(): SspBridge @@ -16,28 +22,33 @@ protected function sut(): SspBridge return new SspBridge(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(SspBridge::class, $this->sut()); } + public function testCanBuildUtilsInstance(): void { - $this->assertInstanceOf(SspBridge\Utils::class, $this->sut()->utils()); + $this->assertInstanceOf(Utils::class, $this->sut()->utils()); } + public function testCanBuildModuleInstance(): void { - $this->assertInstanceOf(SspBridge\Module::class, $this->sut()->module()); + $this->assertInstanceOf(Module::class, $this->sut()->module()); } + public function testCanBuildAuthInstance(): void { - $this->assertInstanceOf(SspBridge\Auth::class, $this->sut()->auth()); + $this->assertInstanceOf(Auth::class, $this->sut()->auth()); } + public function testCanBuildLocaleInstance(): void { - $this->assertInstanceOf(SspBridge\Locale::class, $this->sut()->locale()); + $this->assertInstanceOf(Locale::class, $this->sut()->locale()); } } diff --git a/tests/unit/src/Codebooks/RegistrationTypeEnumTest.php b/tests/unit/src/Codebooks/RegistrationTypeEnumTest.php index 3a9a444e..b38717fc 100644 --- a/tests/unit/src/Codebooks/RegistrationTypeEnumTest.php +++ b/tests/unit/src/Codebooks/RegistrationTypeEnumTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Codebooks; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; #[CoversClass(RegistrationTypeEnum::class)] +#[AllowMockObjectsWithoutExpectations] class RegistrationTypeEnumTest extends TestCase { public function testCanGetDescription(): void diff --git a/tests/unit/src/Codebooks/StatusListExpiryLaneEnumTest.php b/tests/unit/src/Codebooks/StatusListExpiryLaneEnumTest.php index 3001b91d..e2092db2 100644 --- a/tests/unit/src/Codebooks/StatusListExpiryLaneEnumTest.php +++ b/tests/unit/src/Codebooks/StatusListExpiryLaneEnumTest.php @@ -5,11 +5,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Codebooks; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; #[CoversClass(StatusListExpiryLaneEnum::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListExpiryLaneEnumTest extends TestCase { public function testACredentialWithNoExpiryBelongsInTheNonExpiringLane(): void @@ -17,6 +19,7 @@ public function testACredentialWithNoExpiryBelongsInTheNonExpiringLane(): void $this->assertSame(StatusListExpiryLaneEnum::NonExpiring, StatusListExpiryLaneEnum::forExpiry(null)); } + public function testACredentialWithAnExpiryBelongsInTheExpiringLane(): void { $this->assertSame( @@ -25,6 +28,7 @@ public function testACredentialWithAnExpiryBelongsInTheExpiringLane(): void ); } + /** * Only whether there is an expiry decides the lane, not whether it has already passed. A credential * issued with an expiry in the past is an odd thing, but its list can still be retired once the @@ -38,6 +42,7 @@ public function testAnExpiryAlreadyInThePastStillBelongsInTheExpiringLane(): voi ); } + /** * The values are persisted on every Status List row, so changing one would orphan every list already * created under the old spelling -- it would match no allocation and never be selected again. diff --git a/tests/unit/src/ConformanceConfigTest.php b/tests/unit/src/ConformanceConfigTest.php index b07ed891..12d6bcc0 100644 --- a/tests/unit/src/ConformanceConfigTest.php +++ b/tests/unit/src/ConformanceConfigTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\ModuleConfig; @@ -20,6 +21,7 @@ * one full Docker stack later. That is what happened when the back-channel logout host was left out. */ #[CoversNothing] +#[AllowMockObjectsWithoutExpectations] class ConformanceConfigTest extends TestCase { /** @@ -33,11 +35,13 @@ class ConformanceConfigTest extends TestCase 'jwks', ]; + /** @var array */ protected array $conformanceConfig; protected string $seedData; + protected function setUp(): void { $repositoryRoot = dirname(__DIR__, 3); @@ -51,6 +55,7 @@ protected function setUp(): void $this->seedData = (string)file_get_contents($repositoryRoot . '/docker/conformance.sql'); } + public function testSeededOutboundDestinationsAreAllowed(): void { $allowedHosts = $this->allowedHosts(); @@ -82,6 +87,7 @@ public function testSeededOutboundDestinationsAreAllowed(): void } } + /** * @return list */ @@ -93,6 +99,7 @@ protected function allowedHosts(): array return array_map($this->normalizeHost(...), $allowedHosts); } + /** * The hosts of the URIs seeded clients cause the OP to make an outbound request to. * @@ -127,6 +134,7 @@ protected function seededOutboundHosts(): array return array_values(array_unique($hosts)); } + protected function isOutboundUri(string $uri): bool { $path = parse_url($uri, PHP_URL_PATH); @@ -144,6 +152,7 @@ protected function isOutboundUri(string $uri): bool return false; } + /** * Matches how the destination policy compares a host: case and a trailing root label carry no meaning. */ diff --git a/tests/unit/src/Controllers/AccessTokenControllerTest.php b/tests/unit/src/Controllers/AccessTokenControllerTest.php index bd302c7d..edfd7f5c 100644 --- a/tests/unit/src/Controllers/AccessTokenControllerTest.php +++ b/tests/unit/src/Controllers/AccessTokenControllerTest.php @@ -6,6 +6,7 @@ use Nyholm\Psr7\Response; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; @@ -23,15 +24,23 @@ /** * @covers \SimpleSAML\Module\oidc\Controllers\AccessTokenController */ +#[AllowMockObjectsWithoutExpectations] class AccessTokenControllerTest extends TestCase { protected MockObject $authorizationServerMock; + protected MockObject $allowedOriginRepository; + protected MockObject $serverRequestMock; + protected MockObject $responseMock; + protected MockObject $psrHttpBridgeMock; + protected MockObject $errorResponderMock; + protected MockObject $requestFactoryMock; + protected MockObject $responseFactoryMock; protected MockObject $symfonyRequestMock; @@ -69,6 +78,7 @@ protected function setUp(): void $this->psrHttpBridgeMock->method('getHttpFoundationFactory')->willReturn($this->httpFoundationFactoryMock); } + protected function mock(): AccessTokenController { return new AccessTokenController( @@ -79,6 +89,7 @@ protected function mock(): AccessTokenController ); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -87,6 +98,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException */ @@ -104,6 +116,7 @@ public function testItRespondsToAccessTokenRequest(): void ); } + public function testItHandlesCorsRequest(): void { $this->serverRequestMock->expects($this->once())->method('getMethod')->willReturn('OPTIONS'); @@ -120,6 +133,7 @@ public function testItHandlesCorsRequest(): void $this->mock()->__invoke($this->serverRequestMock); } + public function testItAlwaysReturnsAccessControlAllowOrigin(): void { $this->authorizationServerMock @@ -134,6 +148,7 @@ public function testItAlwaysReturnsAccessControlAllowOrigin(): void $this->mock()->token($this->symfonyRequestMock); } + public function testItUsesRequestTrait(): void { $this->assertContains(RequestTrait::class, class_uses(AccessTokenController::class)); diff --git a/tests/unit/src/Controllers/Admin/ClientControllerTest.php b/tests/unit/src/Controllers/Admin/ClientControllerTest.php index 7f853d8d..7b0d80cb 100644 --- a/tests/unit/src/Controllers/Admin/ClientControllerTest.php +++ b/tests/unit/src/Controllers/Admin/ClientControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Admin; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -27,20 +28,33 @@ use Symfony\Component\HttpFoundation\Request; #[CoversClass(ClientController::class)] +#[AllowMockObjectsWithoutExpectations] class ClientControllerTest extends TestCase { protected MockObject $templateFactoryMock; + protected MockObject $authorizationMock; + protected MockObject $clientRepositoryMock; + protected MockObject $clientEntityFactoryMock; + protected MockObject $allowedOriginRepositoryMock; + protected MockObject $formFactoryMock; + protected MockObject $sspBridgeMock; + protected MockObject $sessionMessagesServiceMock; + protected MockObject $routesMock; + protected MockObject $helpersMock; + protected MockObject $loggerMock; + protected MockObject $clientEntityMock; + protected MockObject $clientFormMock; protected array $sampleFormData = [ @@ -86,6 +100,7 @@ class ClientControllerTest extends TestCase ClientEntity::KEY_ALLOWED_RESPONSE_MODES => ['query', 'fragment', 'form_post'], ]; + protected function setUp(): void { $this->templateFactoryMock = $this->createMock(TemplateFactory::class); @@ -106,6 +121,7 @@ protected function setUp(): void $this->formFactoryMock->method('build')->willReturn($this->clientFormMock); } + protected function sut( ?TemplateFactory $templateFactory = null, ?Authorization $authorization = null, @@ -146,12 +162,14 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->authorizationMock->expects($this->once())->method('requireAdminOrUserWithPermission'); $this->assertInstanceOf(ClientController::class, $this->sut()); } + public function testIndex(): void { $request = Request::create( @@ -173,6 +191,7 @@ public function testIndex(): void $this->sut()->index($request); } + public function testShow(): void { $request = Request::create( @@ -190,6 +209,7 @@ public function testShow(): void $this->sut()->show($request); } + public function testShowThrowsIfClientIdNotProvided(): void { $request = Request::create( @@ -204,6 +224,7 @@ public function testShowThrowsIfClientIdNotProvided(): void $this->sut()->show($request); } + public function testCanResetSecret(): void { $request = Request::create( @@ -224,6 +245,7 @@ public function testCanResetSecret(): void $this->sut()->resetSecret($request); } + public function testResetSecretThrowsIfCurrentSecretNotValid(): void { $request = Request::create( @@ -242,6 +264,7 @@ public function testResetSecretThrowsIfCurrentSecretNotValid(): void $this->sut()->resetSecret($request); } + public function testCanDelete(): void { $request = Request::create( @@ -261,6 +284,7 @@ public function testCanDelete(): void $this->sut()->delete($request); } + public function testDeleteThrowsIfCurrentSecretNotValid(): void { $request = Request::create( @@ -279,6 +303,7 @@ public function testDeleteThrowsIfCurrentSecretNotValid(): void $this->sut()->delete($request); } + public function testCanAdd(): void { $this->clientFormMock->expects($this->once())->method('isSuccess')->willReturn(true); @@ -299,6 +324,7 @@ public function testCanAdd(): void $this->sut()->add(); } + public function testCanShowAddForm(): void { $this->clientFormMock->expects($this->once())->method('isSuccess')->willReturn(false); @@ -309,6 +335,7 @@ public function testCanShowAddForm(): void $this->sut()->add(); } + public function testWontAddIfClientIdentifierExists(): void { $this->clientFormMock->expects($this->once())->method('isSuccess')->willReturn(true); @@ -328,6 +355,7 @@ public function testWontAddIfClientIdentifierExists(): void $this->sut()->add(); } + public function testWontAddIfClientEntityIdentifierExists(): void { $this->clientFormMock->expects($this->once())->method('isSuccess')->willReturn(true); @@ -349,6 +377,7 @@ public function testWontAddIfClientEntityIdentifierExists(): void $this->sut()->add(); } + public function testThrowsForInvalidClientData(): void { $data = $this->sampleFormData; @@ -362,6 +391,7 @@ public function testThrowsForInvalidClientData(): void $this->sut()->add(); } + public function testCanEdit(): void { $request = Request::create( @@ -397,6 +427,7 @@ public function testCanEdit(): void $this->sut()->edit($request); } + public function testWontEditIfClientEntityIdentifierExists(): void { $request = Request::create( @@ -437,6 +468,7 @@ public function testWontEditIfClientEntityIdentifierExists(): void $this->sut()->edit($request); } + public function testCanShowEditForm(): void { $request = Request::create( diff --git a/tests/unit/src/Controllers/Admin/ConfigControllerTest.php b/tests/unit/src/Controllers/Admin/ConfigControllerTest.php index 450ae5df..11c86d63 100644 --- a/tests/unit/src/Controllers/Admin/ConfigControllerTest.php +++ b/tests/unit/src/Controllers/Admin/ConfigControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Admin; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -20,26 +21,43 @@ use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\EntityStatementFetcher; use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; +use SimpleSAML\OpenID\Federation\TrustMarkFetcher; #[CoversClass(ConfigController::class)] +#[AllowMockObjectsWithoutExpectations] class ConfigControllerTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $templateFactoryMock; + protected MockObject $authorizationMock; + protected MockObject $databaseMigrationMock; + protected MockObject $sessionMessagesServiceMock; + protected MockObject $federationMock; + protected MockObject $routesMock; + protected MockObject $generalOverviewBuilderMock; + protected MockObject $protocolOverviewBuilderMock; + protected MockObject $federationOverviewBuilderMock; + protected MockObject $vciOverviewBuilderMock; + protected MockObject $trustMarkFactoryMock; + protected MockObject $entityStatementFetcherMock; + protected MockObject $trustMarkFetcherMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -57,13 +75,14 @@ protected function setUp(): void $this->trustMarkFactoryMock = $this->createMock(TrustMarkFactory::class); $this->federationMock->method('trustMarkFactory')->willReturn($this->trustMarkFactoryMock); - $this->entityStatementFetcherMock = $this->createMock(Federation\EntityStatementFetcher::class); + $this->entityStatementFetcherMock = $this->createMock(EntityStatementFetcher::class); $this->federationMock->method('entityStatementFetcher')->willReturn($this->entityStatementFetcherMock); - $this->trustMarkFetcherMock = $this->createMock(Federation\TrustMarkFetcher::class); + $this->trustMarkFetcherMock = $this->createMock(TrustMarkFetcher::class); $this->federationMock->method('trustMarkFetcher')->willReturn($this->trustMarkFetcherMock); } + public function sut( ?ModuleConfig $moduleConfig = null, ?TemplateFactory $templateFactory = null, @@ -110,12 +129,14 @@ public function sut( ); } + public function testCanCreateInstance(): void { $this->authorizationMock->expects($this->once())->method('requireAdmin'); $this->assertInstanceOf(ConfigController::class, $this->sut()); } + public function testCanShowMigrationsScreen(): void { $this->templateFactoryMock->expects($this->once())->method('build') @@ -124,6 +145,7 @@ public function testCanShowMigrationsScreen(): void $this->sut()->migrations(); } + public function testCanRunMigrations(): void { $this->databaseMigrationMock->expects($this->once())->method('migrate'); @@ -133,6 +155,7 @@ public function testCanRunMigrations(): void $this->sut()->runMigrations(); } + public function testWontRunMigrationsIfAlreadyMigrated(): void { $this->databaseMigrationMock->expects($this->once())->method('isMigrated')->willReturn(true); @@ -141,6 +164,7 @@ public function testWontRunMigrationsIfAlreadyMigrated(): void $this->sut()->runMigrations(); } + public function testCanShowGeneralSettingsScreen(): void { $this->generalOverviewBuilderMock->expects($this->once())->method('build')->willReturn([]); @@ -151,6 +175,7 @@ public function testCanShowGeneralSettingsScreen(): void $this->sut()->generalSettings(); } + public function testCanShowProtocolSettingsScreen(): void { $this->protocolOverviewBuilderMock->expects($this->once())->method('build')->willReturn([]); @@ -161,6 +186,7 @@ public function testCanShowProtocolSettingsScreen(): void $this->sut()->protocolSettings(); } + public function testCanShowFederationSettingsScreen(): void { $this->templateFactoryMock->expects($this->once())->method('build') @@ -169,6 +195,7 @@ public function testCanShowFederationSettingsScreen(): void $this->sut()->federationSettings(); } + public function testCanIncludeTrustMarksInFederationSettings(): void { $this->moduleConfigMock->method('getFederationTrustMarkTokens')->willReturn(['token']); @@ -181,6 +208,7 @@ public function testCanIncludeTrustMarksInFederationSettings(): void $this->sut()->federationSettings(); } + public function testCanIncludeDynamicTrustMarksInFederationSettings(): void { $this->moduleConfigMock->method('getIssuer')->willReturn('issuer-id'); diff --git a/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php b/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php index 69b39171..09892257 100644 --- a/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php +++ b/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php @@ -4,9 +4,11 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Admin; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RuntimeException; use SimpleSAML\Auth\Simple; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Codebooks\StatusChangeSourceEnum; @@ -36,6 +38,7 @@ use Symfony\Component\HttpFoundation\Request; #[CoversClass(CredentialStatusController::class)] +#[AllowMockObjectsWithoutExpectations] class CredentialStatusControllerTest extends TestCase { protected const string CREDENTIAL_ID = 'https://op.example.org/vc/abc'; @@ -46,19 +49,33 @@ class CredentialStatusControllerTest extends TestCase protected const string LIST_ID = 'a-status-list-id'; + protected MockObject $moduleConfigMock; + protected MockObject $templateFactoryMock; + protected MockObject $authorizationMock; + protected MockObject $statusListEntryRepositoryMock; + protected MockObject $statusListRepositoryMock; + protected MockObject $credentialStatusServiceMock; + protected MockObject $subjectRefHasherMock; + protected MockObject $formFactoryMock; + protected MockObject $formMock; + protected MockObject $sessionMessagesServiceMock; + protected MockObject $authSimpleFactoryMock; + protected MockObject $authSimpleMock; + protected MockObject $routesMock; + protected MockObject $loggerMock; /** @var array Data the controller handed to the template. */ @@ -67,6 +84,7 @@ class CredentialStatusControllerTest extends TestCase /** @var string[] Messages the controller left for the administrator. */ protected array $messages = []; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -131,6 +149,7 @@ function (string $message): void { $this->loggerMock = $this->createMock(LoggerService::class); } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ @@ -153,11 +172,13 @@ protected function sut(): CredentialStatusController ); } + protected function userIdentifierResolver(): UserIdentifierResolver { return new UserIdentifierResolver(); } + protected function entry(string $statusListId = self::LIST_ID, int $status = 0): StatusListEntryRecord { return new StatusListEntryRecord( @@ -175,6 +196,7 @@ protected function entry(string $statusListId = self::LIST_ID, int $status = 0): ); } + protected function statusListRecord(int ...$allowedStatuses): MockObject { $statusList = $this->createMock(StatusListRecord::class); @@ -185,6 +207,7 @@ protected function statusListRecord(int ...$allowedStatuses): MockObject return $statusList; } + /** * Enforced where a method added later is covered by existing rather than by being remembered. * @@ -197,6 +220,7 @@ public function testRequiresAdminBeforeAnythingElse(): void $this->assertInstanceOf(CredentialStatusController::class, $this->sut()); } + /** * @throws \Throwable */ @@ -222,6 +246,7 @@ public function testListsEntries(): void $this->assertSame('', $this->templateData['query']); } + /** * An administrator has either a credential identifier or the identifier of the person it was * issued to, and cannot be expected to tell the interface which of the two they typed. @@ -242,6 +267,7 @@ public function testSearchesForBothStoredFormsOfWhatWasTyped(): void $this->assertSame('someone@example.org', $this->templateData['query']); } + /** * @throws \Throwable */ @@ -258,6 +284,7 @@ public function testDoesNotSearchWithoutATerm(): void $this->sut()->index(new Request()); } + /** * How many bits an entry occupies is fixed when its list is created, so offering a status the list * can never carry would put a button on the page whose only possible outcome is an error. @@ -282,6 +309,7 @@ public function testOffersOnlyStatusesTheListCanCarry(): void ); } + /** * Between showing an administrator no way to withdraw a credential and showing one which reports * why it did not work, the second is the one which can be acted on. @@ -304,6 +332,7 @@ public function testOffersEveryStatusWhenTheListCannotBeRead(): void ); } + /** * @throws \Throwable */ @@ -325,6 +354,7 @@ public function testLooksUpEachListOnlyOnce(): void $this->sut()->index(new Request()); } + /** * @throws \Throwable */ @@ -339,6 +369,7 @@ public function testAppliesTheRequestedStatus(): void $this->assertSame(['The credential status has been changed.'], $this->messages); } + /** * Repeating a request is how somebody who never saw an answer recovers, so it is reported as * having already been done rather than as having been done again. @@ -365,6 +396,7 @@ public function testReportsAStatusWhichWasAlreadyHeld(): void ); } + /** * @throws \Throwable */ @@ -377,6 +409,7 @@ public function testReportsNothingWhichCanBeActedOn(): void $this->assertStringContainsString('No credential', $this->messages[0]); } + /** * @throws \Throwable */ @@ -390,6 +423,7 @@ public function testReportsAStatusTheListCannotCarry(): void $this->assertStringContainsString('without room for that status', $this->messages[0]); } + /** * @throws \Throwable */ @@ -403,6 +437,7 @@ public function testReportsAChangeLostToAConcurrentOne(): void $this->assertStringContainsString('changed by something else at the same time', $this->messages[0]); } + /** * @throws \Throwable */ @@ -417,6 +452,7 @@ public function testReportsAFailureWithoutRepeatingItsDetail(): void $this->assertStringNotContainsString('on fire', $this->messages[0]); } + /** * A stale or missing CSRF token lands here, and nothing is asked of the service. * @@ -437,6 +473,7 @@ public function testChangesNothingWhenTheFormIsNotAccepted(): void $this->assertSame(['The credential status change was not accepted. Please try again.'], $this->messages); } + /** * @throws \Throwable */ @@ -458,6 +495,7 @@ public function testChangesNothingWithoutACredentialIdentifier(): void $this->assertSame(['The credential status change was not accepted. Please try again.'], $this->messages); } + /** * @throws \Throwable */ @@ -479,6 +517,7 @@ public function testChangesNothingForAStatusWhichIsNotOne(): void $this->assertSame(['The credential status change was not accepted. Please try again.'], $this->messages); } + /** * SimpleSAMLphp's administrator authentication is a shared password in most deployments, which * names nobody. Where it has been pointed at a real authentication source, the identifier it @@ -502,6 +541,7 @@ public function testRecordsTheAdministratorWhenTheLoginKnowsWhoTheyAre(): void $this->sut()->change(new Request()); } + /** * A credential must not stay in a wallet because the administrator behind the request could not * be named. @@ -512,7 +552,7 @@ public function testStillChangesTheStatusWhenTheAdministratorCannotBeIdentified( { $this->authSimpleFactoryMock = $this->createMock(AuthSimpleFactory::class); $this->authSimpleFactoryMock->method('forAuthSourceId') - ->willThrowException(new \RuntimeException('No such authentication source.')); + ->willThrowException(new RuntimeException('No such authentication source.')); $this->credentialStatusServiceMock->expects($this->once()) ->method('setStatus') @@ -522,6 +562,7 @@ public function testStillChangesTheStatusWhenTheAdministratorCannotBeIdentified( $this->sut()->change(new Request()); } + /** * @throws \Throwable */ @@ -539,6 +580,7 @@ public function testReturnsToThePageTheChangeWasMadeFrom(): void $this->sut()->change(new Request([], ['q' => 'someone@example.org', 'page' => '3'])); } + /** * @throws \Throwable */ diff --git a/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php b/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php index 4546dfdd..bb1a6b32 100644 --- a/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php +++ b/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Api; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -29,21 +30,28 @@ use Symfony\Component\HttpFoundation\Response; #[CoversClass(VciCredentialStatusApiController::class)] +#[AllowMockObjectsWithoutExpectations] class VciCredentialStatusApiControllerTest extends TestCase { protected const string CREDENTIAL_ID = 'https://issuer.example.org/vc/abc'; protected const string ACTOR = 'HR system'; + protected MockObject $moduleConfigMock; + protected MockObject $authorizationMock; + protected MockObject $credentialStatusServiceMock; + protected MockObject $routesMock; + protected MockObject $loggerServiceMock; /** @var array Body of the JSON response the controller produced. */ protected array $responseData = []; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -80,6 +88,7 @@ function (array $data): JsonResponse { ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -94,6 +103,7 @@ protected function sut(): VciCredentialStatusApiController ); } + /** * @param array $body */ @@ -111,6 +121,7 @@ protected function request(array $body = []): Request return $request; } + protected function change(bool $isChanged = true, StatusTypeEnum $status = StatusTypeEnum::Invalid): void { $this->credentialStatusServiceMock->method('setStatus')->willReturn( @@ -118,6 +129,7 @@ protected function change(bool $isChanged = true, StatusTypeEnum $status = Statu ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -132,6 +144,7 @@ public function testChangesTheStatus(): void $this->assertTrue($this->responseData['changed'] ?? null); } + /** * A caller retrying a request it never saw the answer to needs to be told the credential is * revoked, not that it just revoked it a second time. @@ -148,6 +161,7 @@ public function testARepeatedRequestSucceedsAndSaysNothingChanged(): void $this->assertFalse($this->responseData['changed'] ?? null); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -166,6 +180,7 @@ public function testPassesTheAuthorizedPrincipalThroughToTheAuditTrail(): void $this->sut()->credentialStatus($this->request()); } + /** * The endpoint's own authorization path, which unlike the rest of this API accepts nothing but a * bearer token in the Authorization header. @@ -188,6 +203,7 @@ public function testRequiresABearerTokenCarryingAStatusScope(): void $this->sut()->credentialStatus($this->request()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -206,6 +222,7 @@ public function testRefusesAnUnauthorizedRequestWithoutTouchingAnyStatus(): void ); } + /** * One never issued here, one issued without a status claim and one which has expired are all * answered the same way, so that a caller can not learn which identifiers exist. @@ -222,6 +239,7 @@ public function testRespondsNotFoundWhenThereIsNothingToActOn(): void ); } + /** * The number of bits per entry is fixed when a list is created, so this can never succeed and * saying so is more use than a 500. @@ -241,6 +259,7 @@ public function testRespondsUnprocessableWhenTheListCanNotCarryTheStatus(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -255,6 +274,7 @@ public function testRespondsConflictWhenTheChangeLostToAnother(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -270,6 +290,7 @@ public function testRespondsServerErrorForAnythingElse(): void $this->assertStringNotContainsString('database', (string)$response->getContent()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -283,6 +304,7 @@ public function testRefusesARequestWithoutACredentialIdentifier(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -298,6 +320,7 @@ public function testRefusesAStatusItDoesNotRecognise(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -331,6 +354,7 @@ function (string $credentialId, StatusTypeEnum $status) use (&$requested): Crede } } + /** * A good token which does not cover this action. Answering 401 would tell the caller its token is * bad and invite it to rotate one which is working perfectly well; the fix is a scope only an @@ -356,6 +380,7 @@ public function testRespondsForbiddenWhenTheTokenLacksTheScope(): void ); } + /** * Without the challenge a client is left to guess that this endpoint wants a bearer token. * @@ -377,6 +402,7 @@ public function testChallengesForABearerTokenWhenNoneWasUsable(): void ); } + /** * RFC 6750 keeps `invalid_token` for a token which actually arrived. A client told its token was * rejected when it never sent one may go and rotate a token which was working. @@ -399,6 +425,7 @@ public function testChallengesWithoutAnErrorCodeWhenNoTokenWasSent(): void ); } + public function testIsNotServedWhileTheEndpointIsDisabled(): void { $moduleConfig = $this->createMock(ModuleConfig::class); @@ -412,6 +439,7 @@ public function testIsNotServedWhileTheEndpointIsDisabled(): void $this->sut()->credentialStatus($this->request()); } + public function testIsNotServedWhileTheApiIsDisabled(): void { $moduleConfig = $this->createMock(ModuleConfig::class); @@ -424,6 +452,7 @@ public function testIsNotServedWhileTheApiIsDisabled(): void $this->sut(); } + /** * Turning issuance off must stop new credentials being issued, not strand the ones already in * wallets as impossible to withdraw. Switching issuance off in a hurry is what an operator does @@ -446,6 +475,7 @@ public function testStillRevokesWhileCredentialIssuanceIsDisabled(): void ); } + /** * The request is what is broken, not the server, and a 500 says the opposite. * diff --git a/tests/unit/src/Controllers/AuthorizationControllerTest.php b/tests/unit/src/Controllers/AuthorizationControllerTest.php index 7bd59fd9..46714603 100644 --- a/tests/unit/src/Controllers/AuthorizationControllerTest.php +++ b/tests/unit/src/Controllers/AuthorizationControllerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -13,7 +14,10 @@ use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale\Language; use SimpleSAML\Module\oidc\Controllers\AuthorizationController; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\AuthorizationServer; @@ -25,39 +29,66 @@ use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; /** * @covers \SimpleSAML\Module\oidc\Controllers\AuthorizationController */ +#[AllowMockObjectsWithoutExpectations] class AuthorizationControllerTest extends TestCase { - final public const AUTH_SOURCE = 'auth_source'; - final public const USER_ID_ATTR = 'uid'; - final public const USERNAME = 'username'; - final public const OIDC_OP_METADATA = ['issuer' => 'https://idp.example.org']; - final public const USER_ENTITY_ATTRIBUTES = [ + final public const string AUTH_SOURCE = 'auth_source'; + + final public const string USER_ID_ATTR = 'uid'; + + final public const string USERNAME = 'username'; + + final public const array OIDC_OP_METADATA = ['issuer' => 'https://idp.example.org']; + + final public const array USER_ENTITY_ATTRIBUTES = [ self::USER_ID_ATTR => [self::USERNAME], 'eduPersonTargetedId' => [self::USERNAME], ]; - final public const AUTH_DATA = ['Attributes' => self::USER_ENTITY_ATTRIBUTES]; - final public const CLIENT_ENTITY = ['id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; - final public const AUTHZ_REQUEST_PARAMS = ['client_id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; - protected Stub $authenticationServiceStub; + final public const array AUTH_DATA = ['Attributes' => self::USER_ENTITY_ATTRIBUTES]; + + final public const array CLIENT_ENTITY = ['id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; + + final public const array AUTHZ_REQUEST_PARAMS = [ + 'client_id' => 'clientid', + 'redirect_uri' => 'https://rp.example.org', + ]; + + + protected MockObject $authenticationServiceStub; + protected Stub $authorizationServerStub; + protected Stub $moduleConfigStub; + protected MockObject $loggerServiceMock; + protected MockObject $authorizationRequestMock; + protected Stub $userEntityStub; + protected Stub $serverRequestStub; + protected Stub $responseStub; + protected MockObject $psrHttpBridgeMock; + protected MockObject $errorResponderMock; + protected Stub $uiLocalesResolverStub; + protected MockObject $sspBridgeMock; + protected MockObject $sspBridgeLocaleMock; + protected MockObject $sspBridgeLocaleLanguageMock; + protected array $state; protected static string $sampleAuthSourceId = 'authSource123'; @@ -67,16 +98,20 @@ class AuthorizationControllerTest extends TestCase protected static array $sampleRequestedAcrs = ['values' => ['1', '0'], 'essential' => false]; protected MockObject $symfonyRequestMock; + protected MockObject $symfonyResponseMock; + protected MockObject $responseHeaderBagMock; + protected MockObject $httpFoundationFactoryMock; + /** * @throws \Exception */ public function setUp(): void { - $this->authenticationServiceStub = $this->createStub(AuthenticationService::class); + $this->authenticationServiceStub = $this->createMock(AuthenticationService::class); $this->authorizationServerStub = $this->createStub(AuthorizationServer::class); $this->moduleConfigStub = $this->createStub(ModuleConfig::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); @@ -91,8 +126,8 @@ public function setUp(): void $this->uiLocalesResolverStub = $this->createStub(UiLocalesResolver::class); $this->sspBridgeMock = $this->createMock(SspBridge::class); - $this->sspBridgeLocaleMock = $this->createMock(SspBridge\Locale::class); - $this->sspBridgeLocaleLanguageMock = $this->createMock(SspBridge\Locale\Language::class); + $this->sspBridgeLocaleMock = $this->createMock(Locale::class); + $this->sspBridgeLocaleLanguageMock = $this->createMock(Language::class); $this->sspBridgeMock->method('locale')->willReturn($this->sspBridgeLocaleMock); $this->sspBridgeLocaleMock->method('language')->willReturn($this->sspBridgeLocaleLanguageMock); @@ -107,7 +142,7 @@ public function setUp(): void ]; $this->symfonyRequestMock = $this->createMock(Request::class); - $this->symfonyResponseMock = $this->createMock(\Symfony\Component\HttpFoundation\Response::class); + $this->symfonyResponseMock = $this->createMock(Response::class); $this->responseHeaderBagMock = $this->createMock(ResponseHeaderBag::class); $this->symfonyResponseMock->headers = $this->responseHeaderBagMock; @@ -116,6 +151,7 @@ public function setUp(): void $this->psrHttpBridgeMock->method('getHttpFoundationFactory')->willReturn($this->httpFoundationFactoryMock); } + public static function queryParameterValues(): array { return [ @@ -128,6 +164,7 @@ public static function queryParameterValues(): array ]; } + protected function mock( ?AuthenticationService $authenticationService = null, ?AuthorizationServer $authorizationServer = null, @@ -203,6 +240,7 @@ public function testReturnsResponseWhenInvoked(array $queryParameters): void $this->assertInstanceOf(ResponseInterface::class, $controller($this->serverRequestStub)); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -236,6 +274,7 @@ public function testValidateAcrThrowsIfAuthSourceIdNotSetInAuthorizationRequest( ($this->mock())($this->serverRequestStub); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -271,6 +310,7 @@ public function testValidateAcrThrowsIfCookieBasedAuthnNotSetInAuthorizationRequ ($this->mock())($this->serverRequestStub); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -315,6 +355,7 @@ public function testValidateAcrSetsForcedAcrForCookieAuthentication(): void ($this->mock())($this->serverRequestStub); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -359,6 +400,7 @@ public function testValidateAcrThrowsIfNoMatchedAcrForEssentialAcrs(): void ($this->mock())($this->serverRequestStub); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -403,6 +445,7 @@ public function testValidateAcrSetsFirstMatchedAcr(): void ($this->mock())($this->serverRequestStub); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -447,6 +490,7 @@ public function testValidateAcrSetsCurrentSessionAcrIfNoMatchedAcr(): void ($this->mock())($this->serverRequestStub); } + /** * @throws \SimpleSAML\Error\AuthSource * @throws \SimpleSAML\Error\BadRequest @@ -492,6 +536,7 @@ public function testValidateAcrLogsWarningIfNoAcrsConfigured(): void ($this->mock())($this->serverRequestStub); } + public function testItAlwaysReturnsAccessControlAllowOrigin(): void { $this->authorizationServerStub @@ -505,6 +550,7 @@ public function testItAlwaysReturnsAccessControlAllowOrigin(): void $this->mock()->authorization($this->symfonyRequestMock); } + /** * @throws \Throwable */ @@ -535,6 +581,7 @@ public function testSetsUiLanguageBasedOnUiLocalesOnInitialRequest(): void ($this->mock())($this->serverRequestStub); } + /** * @throws \Throwable */ @@ -564,6 +611,7 @@ public function testDoesNotSetUiLanguageWhenNoRequestedLanguageIsAvailable(): vo ($this->mock())($this->serverRequestStub); } + /** * @throws \Throwable */ @@ -595,6 +643,7 @@ public function testDoesNotOverrideExistingLanguageCookieWithUiLocales(): void ($this->mock())($this->serverRequestStub); } + /** * When an id_token_hint is present and the authenticated End-User's subject matches it, the request proceeds. * @@ -624,6 +673,7 @@ public function testValidateIdTokenHintPassesOnSubjectMatch(): void $this->assertInstanceOf(ResponseInterface::class, ($this->mock())($this->serverRequestStub)); } + /** * When an id_token_hint is present but the authenticated End-User's subject differs from it, the request is * rejected with login_required rather than issued for a different user. @@ -632,7 +682,7 @@ public function testValidateIdTokenHintPassesOnSubjectMatch(): void */ public function testValidateIdTokenHintThrowsLoginRequiredOnSubjectMismatch(): void { - $clientStub = $this->createStub(\SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface::class); + $clientStub = $this->createStub(ClientEntityInterface::class); $clientStub->method('getIdentifier')->willReturn('clientid'); $this->authorizationRequestMock->method('getIdTokenHintSubject')->willReturn('subject-a'); diff --git a/tests/unit/src/Controllers/ConfigurationDiscoveryControllerTest.php b/tests/unit/src/Controllers/ConfigurationDiscoveryControllerTest.php index 7b2c3f0a..19739ecb 100644 --- a/tests/unit/src/Controllers/ConfigurationDiscoveryControllerTest.php +++ b/tests/unit/src/Controllers/ConfigurationDiscoveryControllerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; @@ -13,9 +14,10 @@ /** * @covers \SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController */ +#[AllowMockObjectsWithoutExpectations] class ConfigurationDiscoveryControllerTest extends TestCase { - final public const OIDC_OP_METADATA = [ + final public const array OIDC_OP_METADATA = [ 'issuer' => 'http://localhost', 'authorization_endpoint' => 'http://localhost/authorization', 'token_endpoint' => 'http://localhost/token', @@ -30,9 +32,12 @@ class ConfigurationDiscoveryControllerTest extends TestCase 'end_session_endpoint' => 'http://localhost/end-session', ]; + protected MockObject $opMetadataServiceMock; + protected MockObject $serverRequestMock; + /** * @throws \Exception */ @@ -44,6 +49,7 @@ protected function setUp(): void $this->serverRequestMock = $this->createMock(ServerRequest::class); } + protected function mock( ?OpMetadataService $opMetadataService = null, ): ConfigurationDiscoveryController { @@ -52,6 +58,7 @@ protected function mock( return new ConfigurationDiscoveryController($opMetadataService); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -60,6 +67,7 @@ public function testItIsInitializable(): void ); } + public function testItReturnsOpenIdConnectConfiguration(): void { $this->assertSame( @@ -68,6 +76,7 @@ public function testItReturnsOpenIdConnectConfiguration(): void ); } + public function testItAlwaysReturnsAccessControlAllowOrigin(): void { $this->assertTrue($this->mock()->__invoke()->headers->has('Access-Control-Allow-Origin'),); diff --git a/tests/unit/src/Controllers/EndSessionControllerTest.php b/tests/unit/src/Controllers/EndSessionControllerTest.php index 8954380f..cc008739 100644 --- a/tests/unit/src/Controllers/EndSessionControllerTest.php +++ b/tests/unit/src/Controllers/EndSessionControllerTest.php @@ -6,6 +6,7 @@ use Exception; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -21,7 +22,6 @@ use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; -use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Core\IdToken; use SimpleSAML\Session; use SimpleSAML\XHTML\Template; @@ -31,27 +31,46 @@ /** * @covers \SimpleSAML\Module\oidc\Controllers\EndSessionController */ +#[AllowMockObjectsWithoutExpectations] class EndSessionControllerTest extends TestCase { protected Stub $authorizationServerStub; + protected Stub $authenticationServiceStub; + protected Stub $sessionServiceStub; + protected Stub $sessionLogoutTicketStoreBuilderStub; + protected Stub $serverRequestStub; + protected Stub $idTokenHintStub; + protected Stub $logoutRequestStub; + protected Stub $dataSetStub; + protected MockObject $currentSessionMock; + protected MockObject $sessionMock; + protected array $dataSet = ['sid' => '123']; + protected Stub $loggerServiceStub; + protected Stub $sessionLogoutTicketStoreDbStub; + protected MockObject $loggerServiceMock; + protected Stub $templateFactoryStub; + protected MockObject $psrHttpBridgeMock; + protected MockObject $errorResponderMock; + protected Stub $uiLocalesResolverStub; + /** * @throws \PHPUnit\Framework\MockObject\Exception */ @@ -75,6 +94,7 @@ public function setUp(): void $this->uiLocalesResolverStub = $this->createStub(UiLocalesResolver::class); } + protected function mock(?TemplateFactory $templateFactory = null): EndSessionController { return new EndSessionController( @@ -89,6 +109,7 @@ protected function mock(?TemplateFactory $templateFactory = null): EndSessionCon ); } + public function testConstruct(): void { $this->assertInstanceOf( @@ -97,6 +118,7 @@ public function testConstruct(): void ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -111,6 +133,7 @@ public function testInvokeThrowsForInvalidLogoutRequest(): void $this->mock()->__invoke($this->serverRequestStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -125,7 +148,6 @@ public function testCallLogoutForSessionIdInIdTokenHint(): void $this->sessionServiceStub->method('getSessionById')->willReturn($this->sessionMock); $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); @@ -139,6 +161,7 @@ public function testCallLogoutForSessionIdInIdTokenHint(): void $this->mock()->__invoke($this->serverRequestStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -153,7 +176,6 @@ public function testLogsIfSessionFromIdTokenHintNotFound(): void $this->sessionServiceStub->method('getSessionById')->willThrowException(new Exception()); $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); @@ -166,6 +188,7 @@ public function testLogsIfSessionFromIdTokenHintNotFound(): void $this->mock()->__invoke($this->serverRequestStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -184,6 +207,7 @@ public function testLogoutCalledOnCurrentSession(): void $this->mock()->__invoke($this->serverRequestStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -200,6 +224,7 @@ public function testReturnsRedirectResponseIfPostLogoutRedirectUriIsSet(): void $this->assertInstanceOf(RedirectResponse::class, $this->mock()->__invoke($this->serverRequestStub)); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -213,11 +238,13 @@ public function testReturnsResponse(): void $this->assertInstanceOf(Response::class, $this->mock()->__invoke($this->serverRequestStub)); } + public function testLogoutHandler(): never { $this->markTestIncomplete(); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -234,6 +261,7 @@ public function testRendersLogoutPageInResolvedUiLanguage(): void $this->assertSame('hr', $this->captureRenderedTemplateLanguage()); } + /** * @throws \Throwable * @throws \SimpleSAML\Error\BadRequest @@ -250,6 +278,7 @@ public function testRendersLogoutPageWithoutLanguageWhenNoneResolved(): void $this->assertNull($this->captureRenderedTemplateLanguage()); } + /** * Invoke the controller with a TemplateFactory mock and return the language passed to the rendered * logout template. diff --git a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php index aaba8f9a..b2dd9472 100644 --- a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php +++ b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Federation; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,18 +20,28 @@ use SimpleSAML\OpenID\Jwks; #[CoversClass(EntityStatementController::class)] +#[AllowMockObjectsWithoutExpectations] class EntityStatementControllerTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $jwksMock; + protected MockObject $opMetadataServiceMock; + protected MockObject $helpersMock; + protected MockObject $routesMock; + protected MockObject $federationMock; + protected MockObject $jwkMock; + protected MockObject $loggerServiceMock; + protected MockObject $federationCacheMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -43,6 +54,7 @@ protected function setUp(): void $this->federationCacheMock = $this->createMock(FederationCache::class); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?Jwks $jwks = null, @@ -74,12 +86,14 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->moduleConfigMock->expects($this->once())->method('getFederationEnabled')->willReturn(true); $this->assertInstanceOf(EntityStatementController::class, $this->sut()); } + public function testThrowsIfFederationNotEnabled(): void { $this->moduleConfigMock->expects($this->once())->method('getFederationEnabled')->willReturn(false); @@ -89,6 +103,7 @@ public function testThrowsIfFederationNotEnabled(): void $this->sut(); } + public function testCanGetConfigurationStatement(): void { $this->moduleConfigMock->expects($this->once())->method('getFederationEnabled')->willReturn(true); diff --git a/tests/unit/src/Controllers/JwksControllerTest.php b/tests/unit/src/Controllers/JwksControllerTest.php index 36c0d08c..a8b490e9 100644 --- a/tests/unit/src/Controllers/JwksControllerTest.php +++ b/tests/unit/src/Controllers/JwksControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Controllers\JwksController; @@ -17,14 +18,20 @@ /** * @covers \SimpleSAML\Module\oidc\Controllers\JwksController */ +#[AllowMockObjectsWithoutExpectations] class JwksControllerTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $jwks; + protected MockObject $routesMock; + protected MockObject $jwksDecoratorFactoryMock; + protected MockObject $jwksDecoratorMock; + /** * @throws \Exception */ @@ -49,6 +56,7 @@ protected function setUp(): void $this->jwks->method('jwksDecoratorFactory')->willReturn($this->jwksDecoratorFactoryMock); } + protected function mock( ?ModuleConfig $moduleConfig = null, ?Jwks $jwks = null, @@ -65,6 +73,7 @@ protected function mock( ); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -73,6 +82,7 @@ public function testItIsInitializable(): void ); } + public function testItReturnsJsonKeys(): void { $keys = [ @@ -94,6 +104,7 @@ public function testItReturnsJsonKeys(): void ); } + public function testItAlwaysReturnsAccessControlAllowOrigin(): void { $response = $this->mock()->jwks(); diff --git a/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php index b0ea6ef0..096a43dc 100644 --- a/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php +++ b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\OAuth2; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; @@ -20,18 +21,23 @@ /** * @covers \SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController */ +#[AllowMockObjectsWithoutExpectations] class OAuth2ServerConfigurationControllerTest extends TestCase { - final public const OIDC_OP_METADATA = [ + final public const array OIDC_OP_METADATA = [ 'issuer' => 'http://localhost', 'authorization_endpoint' => 'http://localhost/authorization', 'token_endpoint' => 'http://localhost/token', ]; + protected MockObject $opMetadataServiceMock; + protected MockObject $routesMock; + protected MockObject $moduleConfigMock; + protected function setUp(): void { $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); @@ -41,6 +47,7 @@ protected function setUp(): void $this->opMetadataServiceMock->method('getMetadata')->willReturn(self::OIDC_OP_METADATA); } + protected function mock( ?OpMetadataService $opMetadataService = null, ?Routes $routes = null, @@ -53,6 +60,7 @@ protected function mock( ); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -61,6 +69,7 @@ public function testItIsInitializable(): void ); } + public function testItReturnsConfigurationWithoutIntrospectionIfApiDisabled(): void { $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); @@ -75,6 +84,7 @@ public function testItReturnsConfigurationWithoutIntrospectionIfApiDisabled(): v $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); } + public function testItReturnsConfigurationWithoutIntrospectionIfIntrospectionDisabled(): void { $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); @@ -89,6 +99,7 @@ public function testItReturnsConfigurationWithoutIntrospectionIfIntrospectionDis $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); } + public function testItReturnsConfigurationWithIntrospectionEndpointEnabled(): void { $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); diff --git a/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php index 0974df37..6e4c999a 100644 --- a/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php +++ b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\OAuth2; +use Exception; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -28,18 +30,28 @@ use Symfony\Component\HttpFoundation\Request; #[CoversClass(TokenIntrospectionController::class)] +#[AllowMockObjectsWithoutExpectations] class TokenIntrospectionControllerTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $authenticatedOAuth2ClientResolverMock; + protected MockObject $routesMock; + protected MockObject $loggerServiceMock; + protected MockObject $apiAuthorizationMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $bearerTokenValidatorMock; + protected MockObject $oAuth2BridgeMock; + protected MockObject $refreshTokenRepositoryMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -56,6 +68,7 @@ protected function setUp(): void $this->refreshTokenRepositoryMock = $this->createMock(RefreshTokenRepository::class); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver = null, @@ -80,11 +93,13 @@ protected function sut( ); } + public function testItIsInitializable(): void { $this->assertInstanceOf(TokenIntrospectionController::class, $this->sut()); } + public function testConstructThrowsForbiddenIfApiNotEnabled(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -99,6 +114,7 @@ public function testConstructThrowsForbiddenIfApiNotEnabled(): void } } + public function testConstructThrowsForbiddenIfIntrospectionNotEnabled(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -114,6 +130,7 @@ public function testConstructThrowsForbiddenIfIntrospectionNotEnabled(): void } } + /** * @param string $clientId Identifier the client authenticated as. A caller is only told about tokens * issued to it, so this is what the tokens in these tests have to belong to. @@ -130,6 +147,7 @@ private function createValidResolvedClientAuthenticationMethodMock( return $mock; } + public function testInvokeReturnsUnauthorizedOnAuthorizationException(): void { $requestMock = $this->createMock(Request::class); @@ -153,6 +171,7 @@ public function testInvokeReturnsUnauthorizedOnAuthorizationException(): void $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeReturnsBadRequestIfMissingToken(): void { $requestMock = $this->createMock(Request::class); @@ -173,6 +192,7 @@ public function testInvokeReturnsBadRequestIfMissingToken(): void $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeReturnsActiveFalseIfTokenInvalid(): void { $requestMock = $this->createMock(Request::class); @@ -189,12 +209,12 @@ public function testInvokeReturnsActiveFalseIfTokenInvalid(): void $this->bearerTokenValidatorMock->expects($this->once()) ->method('ensureValidAccessToken') ->with('invalid-token') - ->willThrowException(new \Exception('bad token')); + ->willThrowException(new Exception('bad token')); $this->oAuth2BridgeMock->expects($this->once()) ->method('decrypt') ->with('invalid-token') - ->willThrowException(new \Exception('bad refresh token')); + ->willThrowException(new Exception('bad refresh token')); $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) @@ -205,6 +225,7 @@ public function testInvokeReturnsActiveFalseIfTokenInvalid(): void $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeCallsAccessTokenFirstRefreshSecondIfNoHint(): void { $requestMock = $this->createMock(Request::class); @@ -221,7 +242,7 @@ public function testInvokeCallsAccessTokenFirstRefreshSecondIfNoHint(): void $this->bearerTokenValidatorMock->expects($this->once()) ->method('ensureValidAccessToken') ->with('invalid-access-token') - ->willThrowException(new \Exception('bad token')); + ->willThrowException(new Exception('bad token')); $this->oAuth2BridgeMock->expects($this->once()) ->method('decrypt') @@ -240,14 +261,13 @@ public function testInvokeCallsAccessTokenFirstRefreshSecondIfNoHint(): void $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true && $data['client_id'] === 'client1'; - })) + ->with($this->callback(fn(array $data) => $data['active'] === true && $data['client_id'] === 'client1')) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeWithTokenTypeHintAccessToken(): void { $requestMock = $this->createMock(Request::class); @@ -276,14 +296,13 @@ public function testInvokeWithTokenTypeHintAccessToken(): void $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true && $data['client_id'] === 'client2'; - })) + ->with($this->callback(fn(array $data) => $data['active'] === true && $data['client_id'] === 'client2')) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeWithTokenTypeHintRefreshToken(): void { $requestMock = $this->createMock(Request::class); @@ -316,14 +335,13 @@ public function testInvokeWithTokenTypeHintRefreshToken(): void $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true && $data['client_id'] === 'client3'; - })) + ->with($this->callback(fn(array $data) => $data['active'] === true && $data['client_id'] === 'client3')) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeReturnsExpectedAccessTokenPayload(): void { $requestMock = $this->createMock(Request::class); @@ -373,6 +391,7 @@ public function testInvokeReturnsExpectedAccessTokenPayload(): void $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeReturnsExpectedRefreshTokenPayload(): void { $requestMock = $this->createMock(Request::class); @@ -404,20 +423,19 @@ public function testInvokeReturnsExpectedRefreshTokenPayload(): void $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true - && $data['scope'] === 'scope1 scope2' - && $data['client_id'] === 'client1' - && $data['exp'] > time() - && $data['sub'] === 'sub1' - && $data['aud'] === 'client1' - && $data['jti'] === 'jti1'; - })) + ->with($this->callback(fn(array $data) => $data['active'] === true + && $data['scope'] === 'scope1 scope2' + && $data['client_id'] === 'client1' + && $data['exp'] > time() + && $data['sub'] === 'sub1' + && $data['aud'] === 'client1' + && $data['jti'] === 'jti1')) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeDoesNotTellClientAboutAnotherClientsAccessToken(): void { $requestMock = $this->createMock(Request::class); @@ -453,6 +471,7 @@ public function testInvokeDoesNotTellClientAboutAnotherClientsAccessToken(): voi $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeDoesNotTellClientAboutAnotherClientsRefreshToken(): void { $requestMock = $this->createMock(Request::class); @@ -490,6 +509,7 @@ public function testInvokeDoesNotTellClientAboutAnotherClientsRefreshToken(): vo $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + /** * A token whose owner the payload does not state can not be matched against the caller, so the caller * is told nothing rather than being given the benefit of the doubt. @@ -526,6 +546,7 @@ public function testInvokeDoesNotTellClientAboutTokenWithoutEstablishedOwner(): $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + /** * An identifier PHP considers falsy is still an identifier - a client entity rejects only an empty * one - so the client it names is still to be told about its own tokens. The introspection response @@ -558,14 +579,17 @@ public function testInvokeLetsClientWithFalsyIdentifierIntrospectItsOwnToken(): $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true && $data['sub'] === 'own-subject-identifier'; - })) + ->with( + $this->callback( + fn(array $data) => $data['active'] === true && $data['sub'] === 'own-subject-identifier', + ), + ) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeLetsConfiguredResourceServerIntrospectAnotherClientsToken(): void { $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionResourceServerClientIds') @@ -595,14 +619,17 @@ public function testInvokeLetsConfiguredResourceServerIntrospectAnotherClientsTo $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true && $data['client_id'] === 'other-client'; - })) + ->with( + $this->callback( + fn(array $data) => $data['active'] === true && $data['client_id'] === 'other-client', + ), + ) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); } + public function testInvokeLetsApiTokenCallerIntrospectAnyClientsToken(): void { $requestMock = $this->createMock(Request::class); @@ -633,9 +660,7 @@ public function testInvokeLetsApiTokenCallerIntrospectAnyClientsToken(): void $responseMock = $this->createMock(JsonResponse::class); $this->routesMock->expects($this->once()) ->method('newJsonResponse') - ->with($this->callback(function (array $data) { - return $data['active'] === true && $data['client_id'] === 'some-client'; - })) + ->with($this->callback(fn(array $data) => $data['active'] === true && $data['client_id'] === 'some-client')) ->willReturn($responseMock); $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); diff --git a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php index b2f6b39c..e3db5d0e 100644 --- a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php +++ b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use DateTimeZone; use League\OAuth2\Server\Exception\OAuthServerException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -15,6 +16,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; +use RuntimeException; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; @@ -40,25 +42,40 @@ #[UsesClass(Result::class)] #[UsesClass(ResultBag::class)] #[UsesClass(ResolvedClientAuthenticationMethod::class)] +#[AllowMockObjectsWithoutExpectations] class PushedAuthorizationControllerTest extends TestCase { protected MockObject $authenticatedOAuth2ClientResolverMock; + protected MockObject $pushedAuthorizationRequestRepositoryMock; + protected MockObject $pushedAuthorizationRequestEntityFactoryMock; + protected MockObject $requestRulesManagerMock; + protected MockObject $psrHttpBridgeMock; + protected MockObject $errorResponderMock; + protected Helpers $helpers; + protected MockObject $loggerMock; protected MockObject $serverRequestMock; + protected MockObject $responseMock; + protected MockObject $responseFactoryMock; + protected MockObject $streamMock; + protected MockObject $clientMock; + protected MockObject $parEntityMock; + protected MockObject $resultBagMock; + protected function setUp(): void { $this->authenticatedOAuth2ClientResolverMock = $this->createMock(AuthenticatedOAuth2ClientResolver::class); @@ -98,6 +115,7 @@ protected function setUp(): void $this->requestRulesManagerMock->method('check')->willReturn($this->resultBagMock); } + protected function sut(): PushedAuthorizationController { return new PushedAuthorizationController( @@ -112,6 +130,7 @@ protected function sut(): PushedAuthorizationController ); } + protected function prepareAuthenticatedClient( ClientAuthenticationMethodsEnum $method = ClientAuthenticationMethodsEnum::ClientSecretPost, ): void { @@ -119,11 +138,13 @@ protected function prepareAuthenticatedClient( $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn($resolvedAuth); } + public function testItIsInitializable(): void { $this->assertInstanceOf(PushedAuthorizationController::class, $this->sut()); } + public function testMethodMustBePost(): void { $this->serverRequestMock->method('getMethod')->willReturn('GET'); @@ -137,6 +158,7 @@ public function testMethodMustBePost(): void $this->assertSame($this->responseMock, $response); } + public function testClientAuthenticationFailureThrows(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -146,6 +168,7 @@ public function testClientAuthenticationFailureThrows(): void $this->sut()->__invoke($this->serverRequestMock); } + public function testConfidentialClientMustAuthenticate(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -156,6 +179,7 @@ public function testConfidentialClientMustAuthenticate(): void $this->sut()->__invoke($this->serverRequestMock); } + public function testRejectsRequestUriInBody(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -169,6 +193,7 @@ public function testRejectsRequestUriInBody(): void $this->sut()->__invoke($this->serverRequestMock); } + public function testRejectsClientIdParamWhichDoesNotMatchAuthenticatedClient(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -182,6 +207,7 @@ public function testRejectsClientIdParamWhichDoesNotMatchAuthenticatedClient(): $this->sut()->__invoke($this->serverRequestMock); } + public function testHandlesValidParRequest(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -223,6 +249,7 @@ public function testHandlesValidParRequest(): void $this->assertSame($this->responseMock, $response); } + public function testPersistsRequestObjectPayloadOnlyWhenJarIsUsed(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -255,6 +282,7 @@ public function testPersistsRequestObjectPayloadOnlyWhenJarIsUsed(): void $this->sut()->__invoke($this->serverRequestMock); } + public function testRejectsRequestObjectClientIdClaimWhichDoesNotMatchAuthenticatedClient(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); @@ -270,6 +298,7 @@ public function testRejectsRequestObjectClientIdClaimWhichDoesNotMatchAuthentica $this->sut()->__invoke($this->serverRequestMock); } + public function testParReturnsJsonErrorResponseForOAuthServerException(): void { $requestMock = $this->createMock(Request::class); @@ -290,6 +319,7 @@ public function testParReturnsJsonErrorResponseForOAuthServerException(): void $this->assertSame($jsonResponse, $this->sut()->par($requestMock)); } + public function testParReturnsGenericJsonErrorResponseForUnexpectedThrowable(): void { $requestMock = $this->createMock(Request::class); @@ -299,7 +329,7 @@ public function testParReturnsGenericJsonErrorResponseForUnexpectedThrowable(): $this->serverRequestMock->method('getMethod')->willReturn('POST'); $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') - ->willThrowException(new \RuntimeException('some internal error')); + ->willThrowException(new RuntimeException('some internal error')); $jsonResponse = new JsonResponse(); $this->errorResponderMock->expects($this->once()) diff --git a/tests/unit/src/Controllers/RegistrationControllerTest.php b/tests/unit/src/Controllers/RegistrationControllerTest.php index 6e7e3666..4c73db23 100644 --- a/tests/unit/src/Controllers/RegistrationControllerTest.php +++ b/tests/unit/src/Controllers/RegistrationControllerTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -34,18 +35,28 @@ #[UsesClass(OidcServerException::class)] #[UsesClass(ErrorResponder::class)] #[UsesClass(Helpers::class)] +#[AllowMockObjectsWithoutExpectations] class RegistrationControllerTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $clientEntityFactoryMock; + protected MockObject $clientRepositoryMock; + protected MockObject $routesMock; + protected MockObject $loggerMock; + protected MockObject $clientMock; + protected ClientMetadataValidator $clientMetadataValidator; + protected ErrorResponder $errorResponder; + protected Helpers $helpers; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -93,6 +104,7 @@ protected function setUp(): void $this->clientMock->method('getExtraMetadata')->willReturn([]); } + protected function sut(): RegistrationController { return new RegistrationController( @@ -107,6 +119,7 @@ protected function sut(): RegistrationController ); } + protected function postRequest(string $json): Request { return Request::create( @@ -120,6 +133,7 @@ protected function postRequest(string $json): Request ); } + /** * @return array */ @@ -131,6 +145,7 @@ protected function decode(Response $response): array return $decoded; } + public function testCreateReturns201WithClientIdAndRegistrationAccessToken(): void { $this->clientEntityFactoryMock->method('fromRegistrationData')->willReturn($this->clientMock); @@ -150,6 +165,7 @@ public function testCreateReturns201WithClientIdAndRegistrationAccessToken(): vo $this->assertSame(0, $body['client_secret_expires_at']); } + public function testDisabledFeatureReturns404(): void { $moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -163,6 +179,7 @@ public function testDisabledFeatureReturns404(): void $this->assertSame(404, $response->getStatusCode()); } + public function testMissingRedirectUrisReturns400InvalidRedirectUri(): void { $response = $this->sut()->registration($this->postRequest('{"client_name":"Example"}')); @@ -171,6 +188,7 @@ public function testMissingRedirectUrisReturns400InvalidRedirectUri(): void $this->assertSame('invalid_redirect_uri', $this->decode($response)['error']); } + public function testInvalidJsonReturns400InvalidClientMetadata(): void { $response = $this->sut()->registration($this->postRequest('not-json')); @@ -179,6 +197,7 @@ public function testInvalidJsonReturns400InvalidClientMetadata(): void $this->assertSame('invalid_client_metadata', $this->decode($response)['error']); } + public function testWrongContentTypeReturns400InvalidRequest(): void { $request = Request::create( @@ -197,6 +216,7 @@ public function testWrongContentTypeReturns400InvalidRequest(): void $this->assertSame('invalid_request', $this->decode($response)['error']); } + public function testJsonContentTypeWithCharsetParameterIsAccepted(): void { $this->clientEntityFactoryMock->method('fromRegistrationData')->willReturn($this->clientMock); @@ -216,6 +236,7 @@ public function testJsonContentTypeWithCharsetParameterIsAccepted(): void $this->assertSame(201, $response->getStatusCode()); } + public function testInitialAccessTokenModeRejectsMissingToken(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -231,6 +252,7 @@ public function testInitialAccessTokenModeRejectsMissingToken(): void $this->assertSame(401, $response->getStatusCode()); } + public function testReadReturns200ForValidToken(): void { $token = 'rat-plaintext'; @@ -254,6 +276,7 @@ public function testReadReturns200ForValidToken(): void $this->assertArrayHasKey('registration_client_uri', $body); } + public function testReadReturns401ForInvalidToken(): void { $this->clientMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Dynamic); @@ -268,6 +291,7 @@ public function testReadReturns401ForInvalidToken(): void $this->assertSame(401, $response->getStatusCode()); } + public function testReadReturns401ForUnknownClient(): void { $this->clientRepositoryMock->method('findById')->willReturn(null); diff --git a/tests/unit/src/Controllers/StatusListControllerTest.php b/tests/unit/src/Controllers/StatusListControllerTest.php index 78981e27..a53b8281 100644 --- a/tests/unit/src/Controllers/StatusListControllerTest.php +++ b/tests/unit/src/Controllers/StatusListControllerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; use DateInterval; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -21,18 +22,25 @@ use Symfony\Component\HttpFoundation\Response; #[CoversClass(StatusListController::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListControllerTest extends TestCase { protected const string LIST_ID = 'a-status-list-id'; protected const string TOKEN = 'header.payload.signature'; + protected MockObject $statusListTokenProviderMock; + protected MockObject $statusListRateLimiterMock; + protected MockObject $routesMock; + protected MockObject $loggerServiceMock; + protected Helpers $helpers; + /** * @throws \Exception */ @@ -53,6 +61,7 @@ protected function setUp(): void $this->statusListTokenProviderMock->method('getToken')->willReturn($this->tokenResult()); } + /** * @throws \Exception */ @@ -68,6 +77,7 @@ protected function tokenResult(): StatusListTokenResult ); } + protected function sut(): StatusListController { return new StatusListController( @@ -80,6 +90,7 @@ protected function sut(): StatusListController ); } + /** * @param array $headers * @param array $query @@ -95,6 +106,7 @@ protected function request(array $headers = [], array $query = []): Request return new Request($query, [], [], [], [], $server); } + public function testServesTheToken(): void { $response = $this->sut()->statusList($this->request(), self::LIST_ID); @@ -104,6 +116,7 @@ public function testServesTheToken(): void $this->assertSame(StatusListController::MEDIA_TYPE, $response->headers->get('Content-Type')); } + /** * The specification recommends cross origin reads, and a browser based Relying Party which can not * read the response can not distinguish a revoked credential from a network failure. @@ -116,6 +129,7 @@ public function testAlwaysAllowsCrossOriginReads(): void ); } + public function testAnnouncesHowLongTheResponseMayBeCached(): void { $response = $this->sut()->statusList($this->request(), self::LIST_ID); @@ -131,6 +145,7 @@ public function testAnnouncesHowLongTheResponseMayBeCached(): void $this->assertMatchesRegularExpression('/^"[0-9a-f]{64}"$/', (string)$response->headers->get('ETag')); } + /** * A list which is not served and one which never existed are the same answer. */ @@ -146,6 +161,7 @@ public function testRespondsNotFoundForAnUnknownList(): void ); } + /** * Failing closed is the whole point: a token which no longer describes its list reports revoked * credentials as valid, so nothing at all is served when a fresh one can not be produced. @@ -163,6 +179,7 @@ public function testRespondsServiceUnavailableWhenATokenCanNotBeProduced(): void $this->assertEmpty($response->getContent()); } + /** * Answering a historical query with the current status would be worse than refusing it. */ @@ -174,6 +191,7 @@ public function testRespondsNotImplementedForAHistoricalQuery(): void ); } + public function testRespondsNotAcceptableWhenTheMediaTypeIsRefused(): void { $this->assertSame( @@ -182,6 +200,7 @@ public function testRespondsNotAcceptableWhenTheMediaTypeIsRefused(): void ); } + public function testServesTheTokenWhenTheMediaTypeIsAccepted(): void { foreach (['*/*', 'application/*', StatusListController::MEDIA_TYPE] as $accept) { @@ -192,6 +211,7 @@ public function testServesTheTokenWhenTheMediaTypeIsAccepted(): void } } + public function testRespondsTooManyRequestsWhenTheLimitIsReached(): void { $rateLimiter = $this->createMock(StatusListRateLimiter::class); @@ -204,6 +224,7 @@ public function testRespondsTooManyRequestsWhenTheLimitIsReached(): void $this->assertSame('60', $response->headers->get('Retry-After')); } + public function testCompressesTheBodyWhenTheClientAsksForIt(): void { $response = $this->sut()->statusList($this->request(['Accept-Encoding' => 'gzip']), self::LIST_ID); @@ -212,6 +233,7 @@ public function testCompressesTheBodyWhenTheClientAsksForIt(): void $this->assertSame(self::TOKEN, gzdecode((string)$response->getContent())); } + public function testSendsTheBodyUnencodedWhenNoCodingIsAcceptable(): void { $response = $this->sut()->statusList($this->request(['Accept-Encoding' => 'br']), self::LIST_ID); @@ -220,6 +242,7 @@ public function testSendsTheBodyUnencodedWhenNoCodingIsAcceptable(): void $this->assertSame(self::TOKEN, $response->getContent()); } + /** * The compressed and uncompressed responses are different representations of the same token, so a * cache which holds both must not confuse them. @@ -232,6 +255,7 @@ public function testTheEntityTagDistinguishesTheEncodedResponse(): void $this->assertNotSame($plain->headers->get('ETag'), $compressed->headers->get('ETag')); } + public function testRespondsNotModifiedWhenTheClientAlreadyHasTheToken(): void { $entityTag = (string)$this->sut()->statusList($this->request(), self::LIST_ID)->headers->get('ETag'); @@ -243,6 +267,7 @@ public function testRespondsNotModifiedWhenTheClientAlreadyHasTheToken(): void $this->assertSame(43200, $response->getMaxAge()); } + /** * If-None-Match compares weakly, so a tag the client stored as weak still matches. */ @@ -259,6 +284,7 @@ public function testRespondsNotModifiedForAWeakenedEntityTag(): void ); } + public function testRespondsNotModifiedForAWildcardValidator(): void { $this->assertSame( @@ -267,6 +293,7 @@ public function testRespondsNotModifiedForAWildcardValidator(): void ); } + public function testServesTheTokenWhenTheClientHoldsADifferentOne(): void { $this->assertSame( @@ -278,6 +305,7 @@ public function testServesTheTokenWhenTheClientHoldsADifferentOne(): void ); } + /** * A client which stored the uncompressed copy and now asks for a compressed one is not holding the * representation which would be served. diff --git a/tests/unit/src/Controllers/Traits/RequestTraitTest.php b/tests/unit/src/Controllers/Traits/RequestTraitTest.php index bb6dec5e..b6b56a03 100644 --- a/tests/unit/src/Controllers/Traits/RequestTraitTest.php +++ b/tests/unit/src/Controllers/Traits/RequestTraitTest.php @@ -7,6 +7,7 @@ use Exception; use Nyholm\Psr7\Response; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; @@ -19,14 +20,21 @@ /** * @covers \SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait */ +#[AllowMockObjectsWithoutExpectations] class RequestTraitTest extends TestCase { protected $mock; + protected MockObject $serverRequestMock; + protected MockObject $allowedOriginRepositoryMock; + protected ReflectionMethod $handleCors; + protected MockObject $psrHttpBridgeMock; + protected MockObject $responseMock; + protected MockObject $responseFactoryMock; @@ -46,12 +54,14 @@ public function setUp(): void ) { use RequestTrait; + public function __construct( public AllowedOriginRepository $allowedOriginRepository, public PsrHttpBridge $psrHttpBridge, ) { } + public function handleCorsWrapper(ServerRequest $request): Response { return $this->handleCors($request); @@ -61,6 +71,7 @@ public function handleCorsWrapper(ServerRequest $request): Response $this->serverRequestMock = $this->createMock(ServerRequest::class); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -72,6 +83,7 @@ public function testItThrowsIfOriginHeaderNotAvailable(): void $this->mock->handleCorsWrapper($this->serverRequestMock); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -86,6 +98,7 @@ public function testItThrowsIfOriginHeaderNotAllowed(): void $this->mock->handleCorsWrapper($this->serverRequestMock); } + public function testItHandlesCorsRequest(): void { $origin = 'https://example.org'; diff --git a/tests/unit/src/Controllers/UserInfoControllerTest.php b/tests/unit/src/Controllers/UserInfoControllerTest.php index 8bc137ed..2f9de48c 100644 --- a/tests/unit/src/Controllers/UserInfoControllerTest.php +++ b/tests/unit/src/Controllers/UserInfoControllerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -25,31 +26,51 @@ use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; /** * @covers \SimpleSAML\Module\oidc\Controllers\UserInfoController */ +#[AllowMockObjectsWithoutExpectations] class UserInfoControllerTest extends TestCase { protected MockObject $resourceServerMock; + protected MockObject $accessTokenRepositoryMock; + protected MockObject $userRepositoryMock; + protected MockObject $allowedOriginRepositoryMock; + protected MockObject $claimTranslatorExtractorMock; + protected MockObject $serverRequestMock; + protected MockObject $authorizationServerRequestMock; + protected MockObject $accessTokenEntityMock; + protected MockObject $userEntityMock; + protected MockObject $psrHttpBridgeMock; + protected MockObject $errorResponderMock; + protected MockObject $routesMock; + protected MockObject $symfonyRequestMock; + protected MockObject $symfonyResponseMock; + protected MockObject $responseHeaderBagMock; + protected MockObject $httpFoundationFactoryMock; + protected MockObject $psrHttpFactoryMock; + protected function setUp(): void { $this->resourceServerMock = $this->createMock(ResourceServer::class); @@ -76,8 +97,8 @@ protected function setUp(): void ) => new JsonResponse($data, $status, $headers, $json), ); - $this->symfonyRequestMock = $this->createMock(\Symfony\Component\HttpFoundation\Request::class); - $this->symfonyResponseMock = $this->createMock(\Symfony\Component\HttpFoundation\Response::class); + $this->symfonyRequestMock = $this->createMock(Request::class); + $this->symfonyResponseMock = $this->createMock(Response::class); $this->responseHeaderBagMock = $this->createMock(ResponseHeaderBag::class); $this->symfonyResponseMock->headers = $this->responseHeaderBagMock; @@ -90,6 +111,7 @@ protected function setUp(): void $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($this->psrHttpFactoryMock); } + protected function mock(): UserInfoController { return new UserInfoController( @@ -104,6 +126,7 @@ protected function mock(): UserInfoController ); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -112,6 +135,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \League\OAuth2\Server\Exception\OAuthServerException @@ -179,6 +203,7 @@ public function testItReturnsExtractedClaims(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \League\OAuth2\Server\Exception\OAuthServerException @@ -214,6 +239,7 @@ public function testItThrowsIfAccessTokenNotFound(): void $this->mock()->__invoke($this->serverRequestMock); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \League\OAuth2\Server\Exception\OAuthServerException @@ -258,6 +284,7 @@ public function testItThrowsIfUserNotFound(): void $this->mock()->__invoke($this->serverRequestMock); } + public function testItHandlesCorsRequest(): void { $this->serverRequestMock->expects($this->once())->method('getMethod')->willReturn('OPTIONS'); @@ -286,11 +313,13 @@ public function testItHandlesCorsRequest(): void $this->assertSame($this->symfonyResponseMock, $response); } + public function testItUsesRequestTrait(): void { $this->assertContains(RequestTrait::class, class_uses(UserInfoController::class)); } + public function testItAlwaysReturnsAccessControlAllowOrigin(): void { $this->authorizationServerRequestMock diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php index f1c9cb4f..798c5421 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\VerifiableCredentials; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,6 +23,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; #[CoversClass(CredentialIssuerConfigurationController::class)] +#[AllowMockObjectsWithoutExpectations] class CredentialIssuerConfigurationControllerTest extends TestCase { protected const string CONFIGURATION_ID = 'UniversityDegreeCredential'; @@ -54,12 +56,18 @@ class CredentialIssuerConfigurationControllerTest extends TestCase 'getVciStatusListRequestsPerMinute', ]; + protected MockObject $moduleConfigMock; + protected MockObject $routesMock; + protected MockObject $loggerServiceMock; + protected MockObject $vciContextResolverMock; + protected SignatureKeyPairBag $vciSignatureKeyPairBag; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -94,6 +102,7 @@ protected function setUp(): void ); } + protected function buildSignatureKeyPair(string $keyId, SignatureAlgorithmEnum $algorithm): SignatureKeyPair { $keyPairMock = $this->createMock(KeyPair::class); @@ -106,6 +115,7 @@ protected function buildSignatureKeyPair(string $keyId, SignatureAlgorithmEnum $ return $signatureKeyPairMock; } + /** * @return array> */ @@ -119,6 +129,7 @@ protected function credentialConfigurations(): array ]; } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -132,6 +143,7 @@ protected function sut(): CredentialIssuerConfigurationController ); } + /** * @return array * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -149,6 +161,7 @@ protected function publishedMetadata(): array return $decoded; } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -162,6 +175,7 @@ public function testPublishesTheIssuerAndItsEndpoints(): void $this->assertSame(self::NONCE_ENDPOINT, $metadata[ClaimsEnum::NonceEndpoint->value]); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -187,6 +201,7 @@ public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): vo $this->assertSame('UniversityDegree', $configuration[ClaimsEnum::Scope->value]); } + /** * A wallet is told which algorithm a credential will come back signed with, and that has to be the * algorithm of the key which will actually sign it. The two are separate calls made by separate @@ -218,6 +233,7 @@ public function testAdvertisesTheAlgorithmOfTheActiveSigningKeyOnly(): void } } + /** * The document goes to wallets, so nothing about how this deployment runs its Status Lists may be * in it. @@ -260,6 +276,7 @@ public function testPublishesNoStatusListControls(): void $this->assertStringNotContainsString('status_list', $encoded); } + /** * The constructor is the gate: with Verifiable Credentials switched off there is no metadata to * publish, and nothing further in this controller should be reachable. diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php index bdebcae0..7db27515 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php @@ -7,6 +7,7 @@ use DateInterval; use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -54,6 +55,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +#[AllowMockObjectsWithoutExpectations] class CredentialIssuerCredentialControllerTest extends TestCase { protected const string CONFIGURATION_ID = 'test_id'; @@ -62,20 +64,35 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected const string STATUS_LIST_URI = 'https://issuer.com/module.php/oidc/statuslist/list-1'; + protected MockObject $resourceServerMock; + protected MockObject $accessTokenRepositoryMock; + protected MockObject $moduleConfigMock; + protected MockObject $routesMock; + protected MockObject $psrHttpBridgeMock; + protected MockObject $verifiableCredentialsMock; + protected MockObject $loggerServiceMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $userRepositoryMock; + protected MockObject $didMock; + protected MockObject $issuerStateRepositoryMock; + protected MockObject $nonceServiceMock; + protected MockObject $vciContextResolverMock; + protected MockObject $credentialStatusIssuerMock; + protected Helpers $helpers; /** @var array> Payloads handed to whichever credential factory was used. */ @@ -85,8 +102,10 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected array $signedWith = []; protected MockObject $vciSignatureKeyPairMock; + protected MockObject $vciPrivateKeyMock; + public function setUp(): void { $this->resourceServerMock = $this->createMock(ResourceServer::class); @@ -119,6 +138,7 @@ public function setUp(): void $this->prepareCredentialFactories(); } + protected function prepareRequestPipeline(): void { $psrRequestMock = $this->createMock(ServerRequestInterface::class); @@ -139,6 +159,7 @@ protected function prepareRequestPipeline(): void $this->accessTokenRepositoryMock->method('findById')->with('token_id')->willReturn($accessToken); } + protected function prepareUser(): void { $userEntity = $this->createMock(UserEntity::class); @@ -146,6 +167,7 @@ protected function prepareUser(): void $this->userRepositoryMock->method('getUserEntityByIdentifier')->willReturn($userEntity); } + protected function prepareSigningKey(): void { $this->vciPrivateKeyMock = $this->createMock(JwkDecorator::class); @@ -172,6 +194,7 @@ protected function prepareSigningKey(): void $vcHelpersMock->method('arr')->willReturn($this->createMock(VcArr::class)); } + /** * Every credential factory records the payload it was asked to sign, which is where the claims * under test end up. @@ -218,6 +241,7 @@ function (mixed $key, mixed $algorithm, array $payload) use ($vcSdJwtMock): VcSd $this->verifiableCredentialsMock->method('vcSdJwtFactory')->willReturn($vcSdJwtFactoryMock); } + /** * @param string[] $proofJwts */ @@ -254,6 +278,7 @@ protected function issue( $this->sut()->credential($request); } + protected function sut(): CredentialIssuerCredentialController { return new CredentialIssuerCredentialController( @@ -275,6 +300,7 @@ protected function sut(): CredentialIssuerCredentialController ); } + /** * @throws \SimpleSAML\OpenID\Exceptions\StatusListException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException @@ -284,6 +310,7 @@ protected function statusClaim(int $idx = 42): StatusClaim return new StatusClaim(new StatusReference(self::STATUS_LIST_URI, $idx)); } + /** * The other half of what issuer metadata promises. The credential configuration advertises the * active signing key's algorithm, so issuance has to reach for that same key rather than for @@ -303,6 +330,7 @@ public function testSignsEveryCredentialWithTheActiveSigningKey(): void } } + public function testCredentialWithMultipleProofs(): void { $this->routesMock->expects($this->once()) @@ -315,6 +343,7 @@ public function testCredentialWithMultipleProofs(): void $this->issue(proofJwts: ['jwt1', 'jwt2']); } + /** * The identifier is the key revocation is later requested by, so anyone able to guess one could ask * for a credential they were never issued to be withdrawn. @@ -337,6 +366,7 @@ public function testTheCredentialIdentifierIsUnpredictable(): void } } + /** * A request carrying several proofs is issued several credentials, and each one has to be * revocable on its own. @@ -370,6 +400,7 @@ function ( ); } + public function testMergesTheStatusClaimIntoTheCredential(): void { $this->credentialStatusIssuerMock->method('issueFor')->willReturn($this->statusClaim(7)); @@ -387,6 +418,7 @@ public function testMergesTheStatusClaimIntoTheCredential(): void ); } + /** * The Status List specification places the claim at the top level of a JOSE Referenced Token, not * inside the credential body of the W3C formats. @@ -404,6 +436,7 @@ public function testTheStatusClaimIsNotPlacedInsideTheCredentialBody(): void ); } + public function testCarriesTheStatusClaimInEverySupportedFormat(): void { foreach ( @@ -426,6 +459,7 @@ public function testCarriesTheStatusClaimInEverySupportedFormat(): void } } + /** * A configuration which belongs to no pool was never meant to be revocable, and its credentials are * issued exactly as before. @@ -442,6 +476,7 @@ public function testIssuesWithoutAStatusClaimWhenTheConfigurationHasNoPool(): vo $this->assertArrayNotHasKey(ClaimsEnum::Status->value, $this->signedPayloads[0]); } + /** * Issuing anyway would hand out a credential which can never be withdrawn, with nothing on it to * say so, which is worse than refusing the request. @@ -462,6 +497,7 @@ public function testRefusesToIssueWhenAStatusListEntryCanNotBeAllocated(): void $this->assertSame([], $this->signedPayloads); } + /** * No lifetime is configured by default, and adding one changes what already issued credentials * mean, so nothing expires unless an operator asks for it. @@ -479,6 +515,7 @@ public function testCredentialsDoNotExpireByDefault(): void ); } + public function testAppliesTheConfiguredCredentialLifetime(): void { $this->moduleConfigMock->method('getVciCredentialTtlFor')->willReturn(new DateInterval('P30D')); @@ -508,6 +545,7 @@ public function testAppliesTheConfiguredCredentialLifetime(): void ); } + /** * The Verifiable Credentials Data Model 2.0 names the end of validity `validUntil`, alongside the * `validFrom` this format already emits. @@ -524,6 +562,7 @@ public function testTheDataModelTwoFormatAlsoStatesTheLifetimeAsValidUntil(): vo $this->assertArrayHasKey(ClaimsEnum::Exp->value, $payload); } + public function testTheStatusListEntryIsAllocatedWithTheCredentialLifetime(): void { $this->moduleConfigMock->method('getVciCredentialTtlFor')->willReturn(new DateInterval('P30D')); diff --git a/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php index a88f08e5..2ddc5667 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\VerifiableCredentials; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,13 +16,18 @@ use Symfony\Component\HttpFoundation\JsonResponse; #[CoversClass(NonceController::class)] +#[AllowMockObjectsWithoutExpectations] class NonceControllerTest extends TestCase { protected MockObject $nonceServiceMock; + protected MockObject $routesMock; + protected MockObject $loggerServiceMock; + protected MockObject $moduleConfigMock; + public function setUp(): void { $this->nonceServiceMock = $this->createMock(NonceService::class); @@ -30,6 +36,7 @@ public function setUp(): void $this->moduleConfigMock = $this->createMock(ModuleConfig::class); } + /** * @throws \Exception */ diff --git a/tests/unit/src/DistributedConfigTest.php b/tests/unit/src/DistributedConfigTest.php index 4343a5f1..288a48ee 100644 --- a/tests/unit/src/DistributedConfigTest.php +++ b/tests/unit/src/DistributedConfigTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -19,6 +20,7 @@ * ES256 entry pointing at EC key files that the guide creates only in an optional section. */ #[CoversNothing] +#[AllowMockObjectsWithoutExpectations] class DistributedConfigTest extends TestCase { /** @var array */ @@ -26,6 +28,7 @@ class DistributedConfigTest extends TestCase protected string $installationGuide; + protected function setUp(): void { $repositoryRoot = dirname(__DIR__, 3); @@ -41,6 +44,7 @@ protected function setUp(): void ); } + /** * The protocol key pairs are resolved before any JWS can be signed, and all of them are, so an * entry naming a key file that a fresh installation does not have stops the module from signing @@ -104,6 +108,7 @@ public function testActiveKeyFilenamesAreCreatedByTheInstallationGuide(string $o } } + /** * @return array */ diff --git a/tests/unit/src/Entities/AccessTokenEntityTest.php b/tests/unit/src/Entities/AccessTokenEntityTest.php index 76e0c284..76817a3e 100644 --- a/tests/unit/src/Entities/AccessTokenEntityTest.php +++ b/tests/unit/src/Entities/AccessTokenEntityTest.php @@ -4,8 +4,10 @@ namespace SimpleSAML\Test\Module\oidc\unit\Entities; +use DateInterval; use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; @@ -20,17 +22,25 @@ /** * @covers \SimpleSAML\Module\oidc\Entities\AccessTokenEntity */ +#[AllowMockObjectsWithoutExpectations] class AccessTokenEntityTest extends TestCase { protected array $state; protected string $id = '123'; + protected array $scopes; + protected string $expiresAt; + protected string $userId = 'user123'; + protected bool $isRevoked = false; + protected string $authCodeId = 'authCode123'; + protected array $requestedClaims = ['key' => 'value']; + protected string $clientId = 'client123'; protected ClientEntity $clientEntityStub; @@ -38,14 +48,20 @@ class AccessTokenEntityTest extends TestCase protected ScopeEntity $scopeEntityOpenId; protected ScopeEntity $scopeEntityProfile; + protected MockObject $unencryptedTokenMock; + protected DateTimeImmutable $expiryDateTime; protected MockObject $moduleConfigMock; + protected MockObject $jwsMock; + protected MockObject $signatureKeyPairMock; + protected MockObject $signatureKeyPairBagMock; + /** * @throws \Exception */ @@ -67,7 +83,7 @@ protected function setUp(): void ]; $this->expiryDateTime = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) - ->add(new \DateInterval('PT1M')); + ->add(new DateInterval('PT1M')); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->jwsMock = $this->createMock(Jws::class); @@ -84,6 +100,7 @@ protected function setUp(): void ->willReturn($this->signatureKeyPairBagMock); } + public function mock(): AccessTokenEntity { return new AccessTokenEntity( @@ -100,6 +117,7 @@ public function mock(): AccessTokenEntity ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf( @@ -108,6 +126,7 @@ public function testCanCreateInstance(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -122,6 +141,7 @@ public function testHasProperState(): void $this->assertSame($this->requestedClaims, $this->mock()->getRequestedClaims()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index 6b0be802..88df5d7a 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -16,20 +17,32 @@ /** * @covers \SimpleSAML\Module\oidc\Entities\AuthCodeEntity */ +#[AllowMockObjectsWithoutExpectations] class AuthCodeEntityTest extends TestCase { protected MockObject $clientEntityMock; + protected array $state; + protected string $id; + protected Stub $scopeEntityOpenIdStub; + protected array $scopes; + protected string $userIdentifier; + protected bool $isRevoked; + protected string $redirectUri; + protected string $nonce; + protected DateTimeImmutable $expiryDateTime; + protected ?array $authorizationDetails; + /** * @throws \Exception */ @@ -53,6 +66,7 @@ protected function setUp(): void $this->authorizationDetails = null; } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -72,6 +86,7 @@ protected function mock(): AuthCodeEntity ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -84,6 +99,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -111,6 +127,7 @@ public function testCanGetState(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -123,6 +140,7 @@ public function testCanSetNonce(): void $this->assertSame('new_nonce', $authCodeEntity->getNonce()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException diff --git a/tests/unit/src/Entities/ClaimSetEntityTest.php b/tests/unit/src/Entities/ClaimSetEntityTest.php index ea7ee3cf..bddeda62 100644 --- a/tests/unit/src/Entities/ClaimSetEntityTest.php +++ b/tests/unit/src/Entities/ClaimSetEntityTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Entities; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\ClaimSetEntity; #[CoversClass(ClaimSetEntity::class)] +#[AllowMockObjectsWithoutExpectations] class ClaimSetEntityTest extends TestCase { public function testCanCreateInstance(): void diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index dd4134d1..899a05b4 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Entities; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\ClientEntity; @@ -12,33 +13,58 @@ /** * @covers \SimpleSAML\Module\oidc\Entities\ClientEntity */ +#[AllowMockObjectsWithoutExpectations] class ClientEntityTest extends TestCase { protected array $state = []; + protected string $id = 'id'; + protected string $secret = 'secret'; + protected string $name = 'name'; + protected string $description = 'description'; + protected array $redirectUri = ['https://localhost/redirect']; + protected array $scopes = []; + protected bool $isEnabled = true; + protected bool $isConfidential = false; + protected ?string $authSource = 'auth_source'; + protected string $owner = 'user@test.com'; + protected array $postLogoutRedirectUri = []; + protected ?string $backChannelLogoutUri = null; + protected ?string $entityIdentifier = null; + protected ?array $clientRegistrationTypes = null; + protected ?array $federationJwks = null; + protected ?array $jwks = null; + protected ?string $jwksUri = null; + protected ?string $signedJwksUri = null; + protected RegistrationTypeEnum $registrationType = RegistrationTypeEnum::Manual; + protected ?DateTimeImmutable $updatedAt = null; + protected ?DateTimeImmutable $createdAt = null; + protected ?DateTimeImmutable $expiresAt = null; + protected bool $isGeneric = false; + protected function setUp(): void { $this->state = [ @@ -62,6 +88,7 @@ protected function setUp(): void ]; } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -95,6 +122,7 @@ public function mock(): ClientEntity ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -112,6 +140,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -139,6 +168,7 @@ public function testCanGetProperties(): void $this->assertSame('https://localhost/back', $clientEntity->getBackChannelLogoutUri()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -151,6 +181,7 @@ public function testCanChangeSecret(): void $this->assertSame($clientEntity->getSecret(), 'new_secret'); } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -189,6 +220,7 @@ public function testCanGetState(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -252,6 +284,7 @@ public function testCanExportAsArray(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -296,6 +329,7 @@ public function testCanGetAuthProcFilters(): void $this->assertSame($authProcFilters, $clientEntity->toArray()[ClientEntity::KEY_AUTH_PROC_FILTERS]); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -336,6 +370,7 @@ public function testCanGetAddClaimsToIdToken(): void $this->assertTrue($clientEntity->toArray()[ClientEntity::KEY_ADD_CLAIMS_TO_ID_TOKEN]); } + public function testEnforcementGettersReturnRawRegisteredValues(): void { // v7 transition: when not registered, these getters return the raw "unset" value (empty / null) rather diff --git a/tests/unit/src/Entities/RefreshTokenEntityTest.php b/tests/unit/src/Entities/RefreshTokenEntityTest.php index 31c32bc8..cf2fc71a 100644 --- a/tests/unit/src/Entities/RefreshTokenEntityTest.php +++ b/tests/unit/src/Entities/RefreshTokenEntityTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; @@ -15,14 +16,20 @@ /** * @covers \SimpleSAML\Module\oidc\Entities\RefreshTokenEntity */ +#[AllowMockObjectsWithoutExpectations] class RefreshTokenEntityTest extends TestCase { protected string $id; + protected DateTimeImmutable $expiryDateTime; + protected MockObject $accessTokenEntityMock; + protected false $isRevoked; + protected string $authCodeId; + /** * @throws \Exception */ @@ -36,6 +43,7 @@ protected function setUp(): void $this->authCodeId = 'auth_code_id'; } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -50,6 +58,7 @@ protected function mock(): RefreshTokenEntityInterface ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -61,6 +70,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/tests/unit/src/Entities/ScopeEntityTest.php b/tests/unit/src/Entities/ScopeEntityTest.php index 957ee544..e9021f96 100644 --- a/tests/unit/src/Entities/ScopeEntityTest.php +++ b/tests/unit/src/Entities/ScopeEntityTest.php @@ -4,9 +4,11 @@ namespace SimpleSAML\Test\Module\oidc\unit\Entities; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\ScopeEntity; +#[AllowMockObjectsWithoutExpectations] class ScopeEntityTest extends TestCase { protected function mock( @@ -18,6 +20,7 @@ protected function mock( return new ScopeEntity($id, $description, $icon, $attributes); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -26,6 +29,7 @@ public function testItIsInitializable(): void ); } + public function testCanGetProperties(): void { $scopeEntity = $this->mock(); diff --git a/tests/unit/src/Entities/UserEntityTest.php b/tests/unit/src/Entities/UserEntityTest.php index 1d6e5e79..86ee4e07 100644 --- a/tests/unit/src/Entities/UserEntityTest.php +++ b/tests/unit/src/Entities/UserEntityTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Entities; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\UserEntity; @@ -12,6 +13,7 @@ /** * @covers \SimpleSAML\Module\oidc\Entities\UserEntity */ +#[AllowMockObjectsWithoutExpectations] class UserEntityTest extends TestCase { protected array $state; @@ -21,8 +23,10 @@ class UserEntityTest extends TestCase protected array $claims = []; protected Stub $createdAt; + protected Stub $updatedAt; + protected function setUp(): void { $this->state = [ @@ -36,6 +40,7 @@ protected function setUp(): void $this->updatedAt = $this->createStub(DateTimeImmutable::class); } + protected function mock( ?string $identifier = null, ?array $claims = null, @@ -55,6 +60,7 @@ protected function mock( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -67,6 +73,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -78,6 +85,7 @@ public function testCanGetProperties(): void $this->assertSame($userEntity->getClaims(), []); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/tests/unit/src/Factories/AuthSimpleFactoryTest.php b/tests/unit/src/Factories/AuthSimpleFactoryTest.php index 6e4da6c9..9f4f4f2c 100644 --- a/tests/unit/src/Factories/AuthSimpleFactoryTest.php +++ b/tests/unit/src/Factories/AuthSimpleFactoryTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; /** * @covers \SimpleSAML\Module\oidc\Factories\AuthSimpleFactory */ +#[AllowMockObjectsWithoutExpectations] class AuthSimpleFactoryTest extends TestCase { public function testIncomplete(): never diff --git a/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php b/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php index bd81fef5..fafe3ddd 100644 --- a/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php +++ b/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -17,11 +18,14 @@ /** * @covers \SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory */ +#[AllowMockObjectsWithoutExpectations] class ClaimTranslatorExtractorFactoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $claimSetEntityFactory; + /** * @throws \Exception */ @@ -70,6 +74,7 @@ protected function setUp(): void $this->claimSetEntityFactory = $this->createMock(ClaimSetEntityFactory::class); } + protected function mock(): ClaimTranslatorExtractorFactory { return new ClaimTranslatorExtractorFactory( @@ -78,6 +83,7 @@ protected function mock(): ClaimTranslatorExtractorFactory ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf( @@ -86,6 +92,7 @@ public function testCanCreateInstance(): void ); } + /** * @throws \Exception */ @@ -97,6 +104,7 @@ public function testCanBuildClaimTranslatorExtractor(): void ); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php b/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php index 4405f2ab..d640a2df 100644 --- a/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php +++ b/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php @@ -6,6 +6,7 @@ use DateInterval; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -36,11 +37,13 @@ #[CoversClass(CredentialOfferUriFactory::class)] #[UsesClass(AuthCodeEntity::class)] +#[AllowMockObjectsWithoutExpectations] class CredentialOfferUriFactoryTest extends TestCase { /** @var array */ private array $logRecords = []; + public function testFallbackUserIdentifierDoesNotLogAttributesOrExceptionDetails(): void { $sensitiveAttributeValue = 'sensitive-user-attribute-value'; @@ -99,6 +102,7 @@ public function testFallbackUserIdentifierDoesNotLogAttributesOrExceptionDetails $this->assertStringNotContainsString($sensitiveExceptionValue, $logs); } + public function testByValueOfferSurvivesQueryParsing(): void { // Appended raw, the '&' would split the offer into a second query parameter and the '#' would @@ -118,6 +122,7 @@ public function testByValueOfferSurvivesQueryParsing(): void $this->assertSame($issuer, $offer['credential_issuer']); } + public function testByReferenceOfferSurvivesQueryParsing(): void { // An offer passed by reference is a URL which may carry a query string of its own. @@ -136,6 +141,7 @@ public function testByReferenceOfferSurvivesQueryParsing(): void $this->assertSame($offerUri, $parameters['credential_offer_uri']); } + public function testBuildTxCodeGeneratesFourDigitNumericCode(): void { $txCode = $this->factory( @@ -146,6 +152,7 @@ public function testBuildTxCodeGeneratesFourDigitNumericCode(): void $this->assertMatchesRegularExpression('/^[0-9]{4}$/', $txCode->getCodeAsString()); } + private function factory( LoggerService $logger, UserIdentifierResolver $userIdentifierResolver, @@ -188,6 +195,7 @@ private function factory( ); } + /** * Parse the query of an offer URI back into parameters. parse_url() rejects the * openid-credential-offer:// scheme outright, so the prefix is stripped by hand. @@ -205,6 +213,7 @@ private function parseOfferUriQuery(string $credentialOfferUri): array return $parameters; } + private function captureLogs(LoggerService&MockObject $logger, string $level): void { $logger->method($level)->willReturnCallback( diff --git a/tests/unit/src/Factories/DestinationPolicyFactoryTest.php b/tests/unit/src/Factories/DestinationPolicyFactoryTest.php index 2211e9fb..09434d0f 100644 --- a/tests/unit/src/Factories/DestinationPolicyFactoryTest.php +++ b/tests/unit/src/Factories/DestinationPolicyFactoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -20,12 +21,14 @@ * that reaches a resolver is one that fails on a train. */ #[CoversClass(DestinationPolicyFactory::class)] +#[AllowMockObjectsWithoutExpectations] class DestinationPolicyFactoryTest extends TestCase { protected MockObject $moduleConfigMock; protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -34,6 +37,7 @@ protected function setUp(): void $this->configure(); } + /** * @param list $allowedSchemes * @param list $allowedHosts @@ -52,16 +56,19 @@ protected function configure( $this->moduleConfigMock->method('getOutboundAddressPinningMode')->willReturn($pinningMode); } + protected function sut(): DestinationPolicyFactory { return new DestinationPolicyFactory($this->moduleConfigMock, $this->loggerServiceMock); } + public function testBuildsAPolicy(): void { $this->assertInstanceOf(DestinationPolicy::class, $this->sut()->build()); } + public function testPassesThroughTheConfiguredPinningMode(): void { $this->configure(pinningMode: AddressPinningModeEnum::Required); @@ -69,6 +76,7 @@ public function testPassesThroughTheConfiguredPinningMode(): void $this->assertSame(AddressPinningModeEnum::Required, $this->sut()->build()->getAddressPinningMode()); } + public function testPassesThroughAllowedHosts(): void { $this->configure(allowedHosts: ['rp.internal.example']); @@ -78,6 +86,7 @@ public function testPassesThroughAllowedHosts(): void $this->assertTrue($policy->isUriAllowed('https://rp.internal.example/jwks')); } + /** * The narrow range is the point of the option: permitting one internal endpoint must not permit its * neighbours. @@ -92,6 +101,7 @@ public function testPassesThroughAllowedRangesWithoutWideningThem(): void $this->assertFalse($policy->isAddressAllowed('10.1.2.4')); } + public function testRefusesPlainHttpUnlessConfiguredToAllowIt(): void { $this->configure(allowedHosts: ['rp.internal.example']); @@ -103,6 +113,7 @@ public function testRefusesPlainHttpUnlessConfiguredToAllowIt(): void $this->assertTrue($this->sut()->build()->isUriAllowed('http://rp.internal.example/jwks')); } + /** * A range that can never match is a configuration mistake that would otherwise look like a working * exemption until the day someone relies on it. diff --git a/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php b/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php index 5ed1936b..64cfd492 100644 --- a/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php +++ b/tests/unit/src/Factories/Entities/ClientEntityFactoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories\Entities; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -23,15 +24,19 @@ #[CoversClass(ClientEntityFactory::class)] #[UsesClass(ClientEntity::class)] +#[AllowMockObjectsWithoutExpectations] class ClientEntityFactoryTest extends TestCase { protected MockObject $sspBridgeMock; + protected MockObject $moduleConfigMock; /** Backing value for ModuleConfig::getDcrRegisteredClientsEnabled() in tests (real default is true). */ protected bool $dcrRegisteredClientsEnabled = true; + protected Helpers $helpers; + /** * @throws \Exception */ @@ -62,6 +67,7 @@ protected function setUp(): void $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag')->willReturn($signatureKeyPairBagMock); } + protected function sut(): ClientEntityFactory { return new ClientEntityFactory( @@ -71,11 +77,13 @@ protected function sut(): ClientEntityFactory ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(ClientEntityFactory::class, $this->sut()); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -93,6 +101,7 @@ public function testFromRegistrationDataAcceptsSupportedIdTokenSignedResponseAlg $this->assertSame('ES256', $client->getIdTokenSignedResponseAlg()); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -115,6 +124,7 @@ public function testFromRegistrationDataRejectsUnsupportedIdTokenSignedResponseA } } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -129,6 +139,7 @@ public function testFromRegistrationDataWithoutIdTokenSignedResponseAlg(): void $this->assertNull($client->getIdTokenSignedResponseAlg()); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -140,6 +151,7 @@ public function testFromRegistrationDataThrowsWhenRedirectUrisMissing(): void $this->sut()->fromRegistrationData([], RegistrationTypeEnum::FederatedAutomatic); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -156,6 +168,7 @@ public function testFromRegistrationDataSetsDynamicRegistrationType(): void $this->assertNull($client->getRegistrationAccessTokenHash()); } + /** * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -178,6 +191,7 @@ public function testFromRegistrationDataStoresAndEchoesInformationalMetadata(): $this->assertSame('web', $extraMetadata[ClaimsEnum::ApplicationType->value]); } + /** * Admin-only client properties (e.g. authproc filters) must NEVER be honored * when supplied through client registration metadata, since an authproc @@ -203,6 +217,7 @@ public function testFromRegistrationDataIgnoresAdminOnlyAuthProcFilters(): void $this->assertSame([], $client->getAuthProcFilters()); } + /** * An administrator-set authproc filter on an existing client must be * preserved across re-registration, and must not be overridable by the @@ -237,6 +252,7 @@ public function testFromRegistrationDataPreservesAdminSetAuthProcFiltersAndIgnor $this->assertSame($adminSetFilters, $client->getAuthProcFilters()); } + /** * The administrator-only "release user claims in ID Token" property must NEVER be honored when supplied * through client registration metadata; an untrusted client must not be able to force its own claims into @@ -259,6 +275,7 @@ public function testFromRegistrationDataIgnoresAdminOnlyAddClaimsToIdToken(): vo $this->assertFalse($client->getAddClaimsToIdToken()); } + /** * An administrator-set "release user claims in ID Token" value on an existing client must be preserved * across re-registration, and must not be overridable by the (untrusted) registration metadata. @@ -286,6 +303,7 @@ public function testFromRegistrationDataPreservesAdminSetAddClaimsToIdTokenAndIg $this->assertTrue($client->getAddClaimsToIdToken()); } + /** * The behavioral default metadata (default_max_age, require_auth_time, default_acr_values) and informational * metadata (initiate_login_uri, software_id, software_version) are persisted from a registration request. @@ -316,6 +334,7 @@ public function testFromRegistrationDataPersistsAdditionalMetadata(): void $this->assertSame('2.0', $client->getSoftwareVersion()); } + /** * request_uris from a registration request are persisted (into extra metadata) so they can be * exact-matched when a Request Object is later passed by reference (request_uri). The fragment, which OIDC @@ -337,6 +356,7 @@ public function testFromRegistrationDataPersistsRequestUris(): void $this->assertSame(['https://example.org/request-object#aHash'], $client->getRequestUris()); } + /** * A Dynamic registration that omits grant_types / response_types / token_endpoint_auth_method gets the * OIDC DCR 1.0 defaults persisted, so they can be returned in the registration response and enforced. @@ -356,6 +376,7 @@ public function testFromRegistrationDataAppliesDefaultGrantResponseAndAuthMethod $this->assertSame('client_secret_basic', $client->getTokenEndpointAuthMethod()); } + /** * Explicit grant_types / response_types / token_endpoint_auth_method on a Dynamic registration are persisted * as-is. @@ -380,6 +401,7 @@ public function testFromRegistrationDataPersistsExplicitGrantResponseAndAuthMeth $this->assertSame('private_key_jwt', $client->getTokenEndpointAuthMethod()); } + /** * The OIDC DCR response_type <-> grant_type correspondence is normalized: grant types required by the * registered response_types are added to grant_types, even when the client omitted grant_types (so it falls @@ -404,6 +426,7 @@ public function testFromRegistrationDataNormalizesGrantTypesToResponseTypeCorres $this->assertSame(['code', 'id_token'], $client->getResponseTypes()); } + /** * The client type (confidential/public) follows token_endpoint_auth_method: `none` yields a public client. * @@ -424,6 +447,7 @@ public function testFromRegistrationDataDerivesPublicTypeFromNoneAuthMethod(): v $this->assertSame('none', $client->getTokenEndpointAuthMethod()); } + /** * application_type `native` (with no auth method provided) yields a public client. * @@ -444,6 +468,7 @@ public function testFromRegistrationDataDerivesPublicTypeFromNativeApplicationTy $this->assertSame('none', $client->getTokenEndpointAuthMethod()); } + /** * The client type is re-derived on an RFC 7592 update too: changing token_endpoint_auth_method from `none` to a * real authentication method flips the client from public to confidential (previously it was carried over from @@ -476,6 +501,7 @@ public function testFromRegistrationDataReDerivesClientTypeOnUpdate(): void $this->assertSame('client_secret_basic', $updatedClient->getTokenEndpointAuthMethod()); } + /** * A new Dynamic client is created enabled by default (auto-enable). * @@ -494,6 +520,7 @@ public function testFromRegistrationDataEnablesNewDynamicClientByDefault(): void $this->assertTrue($client->isEnabled()); } + /** * When configured for review, a new Dynamic client is created disabled. * @@ -512,6 +539,7 @@ public function testFromRegistrationDataCreatesNewDynamicClientDisabledWhenConfi $this->assertFalse($client->isEnabled()); } + /** * The review setting applies to Dynamic registrations only: OpenID Federation automatic registrations are * always created enabled (they are vouched for by their trust chain). @@ -531,6 +559,7 @@ public function testFromRegistrationDataAlwaysEnablesFederatedClientRegardlessOf $this->assertTrue($client->isEnabled()); } + /** * The review gate only applies to the initial registration: an update preserves the existing enabled state * (so re-registering an already-approved client does not silently disable it again). @@ -558,6 +587,7 @@ public function testFromRegistrationDataUpdatePreservesEnabledStateUnderReviewSe $this->assertTrue($updatedClient->isEnabled()); } + /** * RFC 7592 update is a full replace: client-settable metadata omitted from the update request is reset to its * default (or removed), not retained from the previous registration. @@ -603,6 +633,7 @@ public function testFromRegistrationDataUpdateReplacesOmittedClientMetadata(): v $this->assertSame($original->getSecret(), $updated->getSecret()); } + /** * Admin-only metadata (e.g. authproc, which a registering client can never set) survives an RFC 7592 update, * even though the update otherwise replaces client-settable metadata. @@ -636,6 +667,7 @@ public function testFromRegistrationDataUpdateRetainsAdminOnlyMetadata(): void $this->assertSame($authProcFilters, $updated->getAuthProcFilters()); } + /** * Federation automatic registrations are not forced to the Dynamic defaults: nothing is persisted for these * three fields unless the federation metadata provides them. @@ -656,6 +688,7 @@ public function testFromRegistrationDataDoesNotForceGrantTypeDefaultsForFederate $this->assertArrayNotHasKey(ClaimsEnum::TokenEndpointAuthMethod->value, $extraMetadata); } + /** * A Dynamic registration that omits `scope` is assigned the configured DCR default scope set. * @@ -678,6 +711,7 @@ public function testFromRegistrationDataAssignsDefaultScopesForScopelessDynamicR ); } + /** * The DCR default scope set must NOT be applied to OpenID Federation automatic registrations; a federated * client that omits `scope` keeps the conservative `openid`-only default. @@ -697,6 +731,7 @@ public function testFromRegistrationDataDoesNotApplyDcrDefaultScopesForFederated $this->assertSame(['openid'], array_values($client->getScopes())); } + /** * An explicit but unsupported `scope` is NOT treated as "not specified": the unsupported values are dropped and * the client ends up with `openid` only - it does not receive the DCR default scope set. @@ -719,6 +754,7 @@ public function testFromRegistrationDataWithUnsupportedScopeDoesNotApplyDcrDefau $this->assertSame(['openid'], array_values($client->getScopes())); } + /** * An explicit, supported `scope` on a Dynamic registration is honored as-is and is not overridden by the DCR * default scope set. diff --git a/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php index ad55707d..ac8e7541 100644 --- a/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php +++ b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php @@ -6,6 +6,7 @@ use DateInterval; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -18,11 +19,14 @@ #[CoversClass(PushedAuthorizationRequestEntityFactory::class)] #[UsesClass(PushedAuthorizationRequestEntity::class)] +#[AllowMockObjectsWithoutExpectations] class PushedAuthorizationRequestEntityFactoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected Helpers $helpers; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -30,6 +34,7 @@ protected function setUp(): void $this->helpers = new Helpers(); } + protected function sut(): PushedAuthorizationRequestEntityFactory { return new PushedAuthorizationRequestEntityFactory( @@ -38,11 +43,13 @@ protected function sut(): PushedAuthorizationRequestEntityFactory ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(PushedAuthorizationRequestEntityFactory::class, $this->sut()); } + public function testCanBuildNew(): void { $parameters = ['client_id' => 'client123', 'response_type' => 'code']; @@ -70,6 +77,7 @@ public function testCanBuildNew(): void ); } + public function testBuildNewGeneratesUniqueRequestUris(): void { $sut = $this->sut(); @@ -80,6 +88,7 @@ public function testBuildNewGeneratesUniqueRequestUris(): void ); } + public function testCanBuildFromState(): void { $entity = $this->sut()->fromState([ @@ -105,6 +114,7 @@ public function testCanBuildFromState(): void ); } + public function testFromStateThrowsForInvalidState(): void { $this->expectException(OpenIdException::class); diff --git a/tests/unit/src/Factories/FederationFactoryTest.php b/tests/unit/src/Factories/FederationFactoryTest.php index 3040363e..59b05787 100644 --- a/tests/unit/src/Factories/FederationFactoryTest.php +++ b/tests/unit/src/Factories/FederationFactoryTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; use DateInterval; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -18,11 +19,14 @@ use SimpleSAML\OpenID\SupportedAlgorithms; #[CoversClass(FederationFactory::class)] +#[AllowMockObjectsWithoutExpectations] class FederationFactoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -42,6 +46,7 @@ protected function setUp(): void ->willReturn(102400); } + protected function sut(): FederationFactory { $destinationPolicyFactory = $this->createMock(DestinationPolicyFactory::class); @@ -54,6 +59,7 @@ protected function sut(): FederationFactory ); } + /** * The destination policy must not be built until a Federation is. * @@ -74,6 +80,7 @@ public function testDoesNotBuildTheDestinationPolicyUntilItBuilds(): void ); } + public function testCanBuild(): void { $this->moduleConfigMock->method('getFederationMaxTrustChainDepth')->willReturn(9); @@ -84,6 +91,7 @@ public function testCanBuild(): void $this->assertInstanceOf(Federation::class, $this->sut()->build()); } + /** * Values distinct from the library's own defaults, so that a limit which is not actually wired through * shows up as a failure instead of silently falling back. diff --git a/tests/unit/src/Factories/FormFactoryTest.php b/tests/unit/src/Factories/FormFactoryTest.php index dbeb5623..387cd8c5 100644 --- a/tests/unit/src/Factories/FormFactoryTest.php +++ b/tests/unit/src/Factories/FormFactoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -17,13 +18,18 @@ #[CoversClass(FormFactory::class)] #[UsesClass(ClientForm::class)] +#[AllowMockObjectsWithoutExpectations] class FormFactoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $csrfProtectionMock; + protected MockObject $sspBridgeMock; + protected MockObject $helpersMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -32,6 +38,7 @@ protected function setUp(): void $this->helpersMock = $this->createMock(Helpers::class); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?CsrfProtection $csrfProtection = null, @@ -51,11 +58,13 @@ protected function sut( ); } + public function testCanConstruct(): void { $this->assertInstanceOf(FormFactory::class, $this->sut()); } + public function testCanBuildClientForm(): void { $this->assertInstanceOf( diff --git a/tests/unit/src/Factories/ProcessingChainFactoryTest.php b/tests/unit/src/Factories/ProcessingChainFactoryTest.php index a998fa41..f863e7c2 100644 --- a/tests/unit/src/Factories/ProcessingChainFactoryTest.php +++ b/tests/unit/src/Factories/ProcessingChainFactoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; @@ -11,26 +12,38 @@ /** * @covers \SimpleSAML\Module\oidc\Factories\ProcessingChainFactory */ +#[AllowMockObjectsWithoutExpectations] class ProcessingChainFactoryTest extends TestCase { - final public const URI = 'https://some-server/authorize.php?abc=efg'; - final public const AUTH_SOURCE = 'auth_source'; - final public const USER_ID_ATTR = 'uid'; - final public const USERNAME = 'username'; - final public const OIDC_OP_METADATA = ['issuer' => 'https://idp.example.org']; - final public const USER_ENTITY_ATTRIBUTES = [ + final public const string URI = 'https://some-server/authorize.php?abc=efg'; + + final public const string AUTH_SOURCE = 'auth_source'; + + final public const string USER_ID_ATTR = 'uid'; + + final public const string USERNAME = 'username'; + + final public const array OIDC_OP_METADATA = ['issuer' => 'https://idp.example.org']; + + final public const array USER_ENTITY_ATTRIBUTES = [ self::USER_ID_ATTR => [self::USERNAME], 'eduPersonTargetedId' => [self::USERNAME], ]; - final public const AUTH_DATA = ['Attributes' => self::USER_ENTITY_ATTRIBUTES]; - final public const CLIENT_ENTITY = ['id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; - final public const AUTHZ_REQUEST_PARAMS = ['client_id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; + + final public const array AUTH_DATA = ['Attributes' => self::USER_ENTITY_ATTRIBUTES]; + + final public const array CLIENT_ENTITY = ['id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; + + final public const array AUTHZ_REQUEST_PARAMS = [ + 'client_id' => 'clientid', + 'redirect_uri' => 'https://rp.example.org', + ]; /** * The factory consumes the IdP / SP metadata (entityid + authproc) that * AuthenticationService::runAuthProcs() has already prepared in the state. */ - final public const STATE = [ + final public const array STATE = [ 'Attributes' => self::AUTH_DATA['Attributes'], 'Oidc' => [ 'OpenIdProviderMetadata' => self::OIDC_OP_METADATA, @@ -41,14 +54,16 @@ class ProcessingChainFactoryTest extends TestCase 'Destination' => ['entityid' => 'clientid', 'authproc' => []], ]; + /** - * @return ProcessingChainFactory + * @return \SimpleSAML\Module\oidc\Factories\ProcessingChainFactory */ protected function prepareMockedInstance(): ProcessingChainFactory { return new ProcessingChainFactory(); } + /** * @return void */ @@ -60,6 +75,7 @@ public function testCanCreateInstance(): void ); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Factories/TemplateFactoryTest.php b/tests/unit/src/Factories/TemplateFactoryTest.php index c2a40c74..8f83495c 100644 --- a/tests/unit/src/Factories/TemplateFactoryTest.php +++ b/tests/unit/src/Factories/TemplateFactoryTest.php @@ -4,13 +4,15 @@ namespace SimpleSAML\Test\Module\oidc\unit\Factories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Admin\Menu; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Module; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Module\Admin; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\SessionMessagesService; @@ -18,19 +20,26 @@ use SimpleSAML\XHTML\Template; #[CoversClass(TemplateFactory::class)] -#[UsesClass(Template::class)] -#[UsesClass(Configuration::class)] +#[AllowMockObjectsWithoutExpectations] class TemplateFactoryTest extends TestCase { protected Configuration $sspConfiguration; + protected MockObject $moduleConfigMock; + protected MockObject $menuMock; + protected MockObject $sspBridgeMock; + protected MockObject $sessionMessagesServiceMock; + protected MockObject $routes; + protected MockObject $sspBridgeModuleMock; + protected MockObject $sspBridgeModuleAdminMock; + protected function setUp(): void { // Template instantiation uses a bunch of configuration options from SSP config file, so let's use test @@ -43,12 +52,13 @@ protected function setUp(): void $this->sessionMessagesServiceMock = $this->createMock(SessionMessagesService::class); $this->routes = $this->createMock(Routes::class); - $this->sspBridgeModuleMock = $this->createMock(SspBridge\Module::class); + $this->sspBridgeModuleMock = $this->createMock(Module::class); $this->sspBridgeMock->method('module')->willReturn($this->sspBridgeModuleMock); - $this->sspBridgeModuleAdminMock = $this->createMock(SspBridge\Module\Admin::class); + $this->sspBridgeModuleAdminMock = $this->createMock(Admin::class); $this->sspBridgeModuleMock->method('admin')->willReturn($this->sspBridgeModuleAdminMock); } + protected function sut( ?Configuration $configuration = null, ?ModuleConfig $moduleConfig = null, @@ -74,11 +84,13 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(TemplateFactory::class, $this->sut()); } + public function testCanBuildTemplate(): void { $template = $this->sut()->build('oidc:clients.twig', [], 'path'); @@ -86,6 +98,7 @@ public function testCanBuildTemplate(): void $this->assertInstanceOf(Template::class, $template); } + public function testCanAddTemplatesFromAdminModule(): void { $this->sspBridgeModuleMock->expects($this->once())->method('isModuleEnabled') @@ -96,6 +109,7 @@ public function testCanAddTemplatesFromAdminModule(): void $this->sut()->build('oidc:clients.twig'); } + public function testCanSetActiveHrefPath(): void { $this->menuMock->expects($this->once())->method('setActiveHrefPath'); @@ -106,6 +120,7 @@ public function testCanSetActiveHrefPath(): void $sut->getActiveHrefPath(); } + public function testCanSetTemplateFactoryProperties(): void { $sut = $this->sut(); diff --git a/tests/unit/src/Forms/ClientFormTest.php b/tests/unit/src/Forms/ClientFormTest.php index 9ae2aa87..2b39e17d 100644 --- a/tests/unit/src/Forms/ClientFormTest.php +++ b/tests/unit/src/Forms/ClientFormTest.php @@ -5,7 +5,9 @@ namespace SimpleSAML\Test\Module\oidc\unit\Forms; use DateTimeImmutable; +use Nette\InvalidArgumentException; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -13,6 +15,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth\Source; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Forms\ClientForm; @@ -24,6 +28,7 @@ #[CoversClass(ClientForm::class)] #[UsesClass(Helpers::class)] +#[AllowMockObjectsWithoutExpectations] class ClientFormTest extends TestCase { protected MockObject $csrfProtectionMock; @@ -31,13 +36,18 @@ class ClientFormTest extends TestCase protected MockObject $moduleConfigMock; protected MockObject $serverRequestMock; + protected MockObject $sspBridgeMock; + protected MockObject $sspBridgeAuthMock; + protected MockObject $sspBridgeAuthSourceMock; + protected Helpers $helpers; protected array $clientDataSample; + /** * @throws \Exception */ @@ -63,10 +73,10 @@ public function setUp(): void $this->sspBridgeMock = $this->createMock(SspBridge::class); $this->helpers = new Helpers(); - $this->sspBridgeAuthMock = $this->createMock(SspBridge\Auth::class); + $this->sspBridgeAuthMock = $this->createMock(Auth::class); $this->sspBridgeMock->method('auth')->willReturn($this->sspBridgeAuthMock); - $this->sspBridgeAuthSourceMock = $this->createMock(SspBridge\Auth\Source::class); + $this->sspBridgeAuthSourceMock = $this->createMock(Source::class); $this->sspBridgeAuthMock->method('source')->willReturn($this->sspBridgeAuthSourceMock); $this->clientDataSample = [ @@ -101,6 +111,7 @@ public function setUp(): void ]; } + protected function sut( ?ModuleConfig $moduleConfig = null, ?CsrfProtection $csrfProtection = null, @@ -120,6 +131,7 @@ protected function sut( ); } + public static function validateOriginProvider(): array { return [ @@ -172,6 +184,7 @@ public function testValidateOrigin(string $url, bool $isValid): void $this->assertEquals(!$isValid, $clientForm->hasErrors(), $url); } + public function testSetDefaultsLeavesValidAuthSourceValue(): void { $this->sspBridgeAuthSourceMock->method('getSources')->willReturn(['default-sp']); @@ -181,6 +194,7 @@ public function testSetDefaultsLeavesValidAuthSourceValue(): void $this->assertSame('default-sp', $sut->getValues()['auth_source']); } + public function testSetDefaultsUnsetsAuthSourceIfNotValid(): void { $sut = $this->sut()->setDefaults($this->clientDataSample); @@ -188,6 +202,7 @@ public function testSetDefaultsUnsetsAuthSourceIfNotValid(): void $this->assertNull($sut->getValues()['auth_source']); } + public static function redirectUriProvider(): array { return [ @@ -229,6 +244,7 @@ public static function redirectUriProvider(): array ]; } + #[DataProvider('redirectUriProvider')] public function testCanValidateRedirectUri(string $url, bool $isValid): void { @@ -239,6 +255,7 @@ public function testCanValidateRedirectUri(string $url, bool $isValid): void $this->assertEquals(!$isValid, $sut->hasErrors(), $url); } + public function testIdTokenSignedResponseAlgSelectIsLimitedToSupportedAlgs(): void { $sut = $this->sut(); @@ -248,10 +265,11 @@ public function testIdTokenSignedResponseAlgSelectIsLimitedToSupportedAlgs(): vo $this->assertSame('ES256', $sut->getValues()[ClaimsEnum::IdTokenSignedResponseAlg->value]); // An unsupported algorithm is rejected by the select (out of allowed set). - $this->expectException(\Nette\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->sut()->setValues([ClaimsEnum::IdTokenSignedResponseAlg->value => 'HS256']); } + public function testGrantTypesResponseTypesAndAuthMethodRoundTrip(): void { $sut = $this->sut(); @@ -267,6 +285,7 @@ public function testGrantTypesResponseTypesAndAuthMethodRoundTrip(): void $this->assertSame('private_key_jwt', $values[ClaimsEnum::TokenEndpointAuthMethod->value]); } + public function testEmptyTokenEndpointAuthMethodNormalizesToNull(): void { $values = $this->sut()->getValues(); @@ -276,6 +295,7 @@ public function testEmptyTokenEndpointAuthMethodNormalizesToNull(): void $this->assertSame([], $values[ClaimsEnum::ResponseTypes->value]); } + public function testDefaultAcrValuesAreConstrainedToSupported(): void { // The field is a multi-select bound to the OP's supported ACRs. setDefaults (the edit path) drops values @@ -294,6 +314,7 @@ public function testDefaultAcrValuesAreConstrainedToSupported(): void $this->assertTrue($sut->hasConfiguredAcrValues()); } + public function testGrantTypesAreNormalizedToResponseTypeCorrespondence(): void { // Selecting an implicit response type must pull in the implicit grant type on save, even if the admin @@ -310,6 +331,7 @@ public function testGrantTypesAreNormalizedToResponseTypeCorrespondence(): void $this->assertSame(['code', 'id_token'], $values[ClaimsEnum::ResponseTypes->value]); } + public function testClientTypeFollowsTokenEndpointAuthMethod(): void { // `none` => public, regardless of the submitted radio value. @@ -327,6 +349,7 @@ public function testClientTypeFollowsTokenEndpointAuthMethod(): void $this->assertTrue($values['is_confidential']); } + public function testNativeApplicationTypeMakesClientPublicWhenNoAuthMethod(): void { // native + no auth method => public, overriding the submitted radio (mirrors DCR). @@ -346,6 +369,7 @@ public function testNativeApplicationTypeMakesClientPublicWhenNoAuthMethod(): vo $this->assertTrue($values['is_confidential']); } + public function testClientTypeStandsWhenAuthMethodUnset(): void { // When no auth method is selected, the explicit confidential/public choice is preserved. @@ -356,6 +380,7 @@ public function testClientTypeStandsWhenAuthMethodUnset(): void $this->assertTrue($values['is_confidential']); } + public function testInformationalMetadataRoundTrip(): void { $sut = $this->sut(); @@ -377,6 +402,7 @@ public function testInformationalMetadataRoundTrip(): void $this->assertSame(['admin@example.org', 'ops@example.org'], $values[ClaimsEnum::Contacts->value]); } + public function testEmptyInformationalMetadataNormalizesToNullOrEmpty(): void { $values = $this->sut()->getValues(); @@ -386,6 +412,7 @@ public function testEmptyInformationalMetadataNormalizesToNullOrEmpty(): void $this->assertSame([], $values[ClaimsEnum::Contacts->value]); } + public function testAcceptsValidAuthProcFilters(): void { $clientForm = $this->sut(); @@ -402,6 +429,7 @@ public function testAcceptsValidAuthProcFilters(): void ); } + public function testCastsNumericAuthProcFilterPrioritiesToInt(): void { $clientForm = $this->sut(); @@ -417,6 +445,7 @@ public function testCastsNumericAuthProcFilterPrioritiesToInt(): void $this->assertIsInt(array_key_first($filters)); } + public function testRejectsAuthProcFiltersWithInvalidJson(): void { $clientForm = $this->sut(); @@ -427,6 +456,7 @@ public function testRejectsAuthProcFiltersWithInvalidJson(): void $this->assertTrue($clientForm->hasErrors()); } + public function testRejectsAuthProcFilterWithoutClass(): void { $clientForm = $this->sut(); @@ -436,6 +466,7 @@ public function testRejectsAuthProcFilterWithoutClass(): void $this->assertTrue($clientForm->hasErrors()); } + public function testSetDefaultsAndGetValuesRoundTripAuthProcFilters(): void { $this->sspBridgeAuthSourceMock->method('getSources')->willReturn(['default-sp']); @@ -454,11 +485,13 @@ public function testSetDefaultsAndGetValuesRoundTripAuthProcFilters(): void ); } + public function testAddClaimsToIdTokenDefaultsToFalse(): void { $this->assertFalse($this->sut()->getValues()[ClientEntity::KEY_ADD_CLAIMS_TO_ID_TOKEN]); } + public function testSetDefaultsAndGetValuesRoundTripAddClaimsToIdToken(): void { $this->sspBridgeAuthSourceMock->method('getSources')->willReturn(['default-sp']); diff --git a/tests/unit/src/Forms/CredentialStatusFormTest.php b/tests/unit/src/Forms/CredentialStatusFormTest.php index 4e90c920..e6d6cade 100644 --- a/tests/unit/src/Forms/CredentialStatusFormTest.php +++ b/tests/unit/src/Forms/CredentialStatusFormTest.php @@ -6,6 +6,7 @@ use Nette\Forms\Controls\SelectBox; use Nette\Forms\Form; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -17,13 +18,18 @@ use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; #[CoversClass(CredentialStatusForm::class)] +#[AllowMockObjectsWithoutExpectations] class CredentialStatusFormTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $csrfProtectionMock; + protected MockObject $sspBridgeMock; + protected Helpers $helpers; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -32,6 +38,7 @@ protected function setUp(): void $this->helpers = new Helpers(); } + /** * @throws \Exception */ @@ -45,6 +52,7 @@ protected function sut(): CredentialStatusForm ); } + /** * @throws \Exception */ @@ -56,6 +64,7 @@ public function testCarriesTheFieldsTheListingSubmits(): void $this->assertNotNull($form->getComponent(CredentialStatusForm::FIELD_STATUS)); } + /** * CSRF protection is why this class exists at all: the markup is written out in the template, and * validating what comes back is what is left. @@ -67,6 +76,7 @@ public function testAttachesTheCsrfProtector(): void $this->assertSame($this->csrfProtectionMock, $this->sut()->getComponent(Form::ProtectorId)); } + /** * @throws \Exception */ @@ -75,6 +85,7 @@ public function testIsSubmittedByPost(): void $this->assertSame(Form::Post, $this->sut()->getMethod()); } + /** * The listing shows fewer options per row, since a list can be unable to carry a status. This * decides only whether what came back is a status at all. @@ -92,6 +103,7 @@ public function testAcceptsEveryStatus(): void ); } + /** * The submitted value has to come back as the Status Type's own backing value, since that is what * the controller turns back into a Status Type. @@ -109,6 +121,7 @@ public function testKeepsStatusValuesAsIntegers(): void $this->assertSame(StatusTypeEnum::Suspended->value, $status->getValue()); } + /** * "Invalid" is what the specification calls a status which every other document in this space * calls revoked, and an administrator should not have to know that to withdraw a credential. @@ -120,6 +133,7 @@ public function testLabelsTheInvalidStatusAsRevoked(): void $this->assertSame('Suspended', CredentialStatusForm::labelFor(StatusTypeEnum::Suspended)); } + public function testOffersOneOptionPerStatus(): void { $this->assertCount(count(StatusTypeEnum::cases()), CredentialStatusForm::statusOptions()); diff --git a/tests/unit/src/Helpers/ArrTest.php b/tests/unit/src/Helpers/ArrTest.php index cac1613a..45c7366a 100644 --- a/tests/unit/src/Helpers/ArrTest.php +++ b/tests/unit/src/Helpers/ArrTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Helpers\Arr; #[CoversClass(Arr::class)] +#[AllowMockObjectsWithoutExpectations] class ArrTest extends TestCase { protected function sut(): Arr @@ -16,6 +18,7 @@ protected function sut(): Arr return new Arr(); } + public function testCanFindByCallback(): void { $this->assertSame( @@ -32,6 +35,7 @@ public function testCanFindByCallback(): void )); } + public function testEnsureStringValues(): void { $this->assertSame( @@ -40,6 +44,7 @@ public function testEnsureStringValues(): void ); } + public function testIsValueOneOf(): void { $this->assertTrue($this->sut()->isValueOneOf('a', ['a'])); @@ -50,6 +55,7 @@ public function testIsValueOneOf(): void $this->assertFalse($this->sut()->isValueOneOf(['a'], ['b'])); } + public function testIsValueSubsetOf(): void { $this->assertTrue($this->sut()->isValueSubsetOf('a', ['a', 'b', 'c'])); @@ -61,6 +67,7 @@ public function testIsValueSubsetOf(): void $this->assertFalse($this->sut()->isValueSubsetOf(['a', 'c'], ['b'])); } + public function testIsValueSupersetOf(): void { $this->assertTrue($this->sut()->isValueSupersetOf('a', ['a'])); diff --git a/tests/unit/src/Helpers/ClientTest.php b/tests/unit/src/Helpers/ClientTest.php index b79b052a..c854039f 100644 --- a/tests/unit/src/Helpers/ClientTest.php +++ b/tests/unit/src/Helpers/ClientTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,13 +16,18 @@ use SimpleSAML\Module\oidc\Repositories\ClientRepository; #[CoversClass(Client::class)] +#[AllowMockObjectsWithoutExpectations] class ClientTest extends TestCase { protected MockObject $httpMock; + protected MockObject $requestMock; + protected MockObject $clientRepositoryMock; + protected MockObject $clientEntityMock; + protected function sut( ?Http $http = null, ): Client { @@ -30,6 +36,7 @@ protected function sut( return new Client($http); } + protected function setUp(): void { $this->httpMock = $this->createMock(Http::class); @@ -38,6 +45,7 @@ protected function setUp(): void $this->clientEntityMock = $this->createMock(ClientEntity::class); } + public function testCanGetFromRequest(): void { $this->httpMock->expects($this->once())->method('getAllRequestParams') @@ -53,6 +61,7 @@ public function testCanGetFromRequest(): void ); } + public function testGetFromRequestThrowsIfNoClientId(): void { $this->expectException(OidcException::class); @@ -61,6 +70,7 @@ public function testGetFromRequestThrowsIfNoClientId(): void $this->sut()->getFromRequest($this->requestMock, $this->clientRepositoryMock); } + public function testGetFromRequestThrowsIfClientNotFound(): void { $this->expectException(OidcException::class); diff --git a/tests/unit/src/Helpers/DateTimeTest.php b/tests/unit/src/Helpers/DateTimeTest.php index f5d673f6..405b6b75 100644 --- a/tests/unit/src/Helpers/DateTimeTest.php +++ b/tests/unit/src/Helpers/DateTimeTest.php @@ -5,11 +5,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Helpers\DateTime; #[CoversClass(DateTime::class)] +#[AllowMockObjectsWithoutExpectations] class DateTimeTest extends TestCase { protected function sut(): DateTime @@ -17,15 +19,17 @@ protected function sut(): DateTime return new DateTime(); } + public function testCanGetUtc(): void { - $this->assertInstanceOf(\DateTimeImmutable::class, $this->sut()->getUtc()); + $this->assertInstanceOf(DateTimeImmutable::class, $this->sut()->getUtc()); $this->assertSame( 'UTC', $this->sut()->getUtc()->getTimezone()->getName(), ); } + public function testCanGetFromTimestamp(): void { $timestamp = (new DateTimeImmutable())->getTimestamp(); @@ -36,6 +40,7 @@ public function testCanGetFromTimestamp(): void ); } + public function testCanGetSecondsToExpirationTime(): void { $expirationTime = (new DateTimeImmutable())->getTimestamp() + 60; diff --git a/tests/unit/src/Helpers/HttpTest.php b/tests/unit/src/Helpers/HttpTest.php index 83169b3e..c340a77a 100644 --- a/tests/unit/src/Helpers/HttpTest.php +++ b/tests/unit/src/Helpers/HttpTest.php @@ -4,26 +4,31 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Helpers\Http; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +#[AllowMockObjectsWithoutExpectations] class HttpTest extends TestCase { protected MockObject $serverRequestMock; + protected function setUp(): void { $this->serverRequestMock = $this->createMock(ServerRequestInterface::class); } + protected function sut(): Http { return new Http(); } + public function testCanGetAllRequestParams(): void { $this->serverRequestMock->expects($this->once())->method('getQueryParams') @@ -38,6 +43,7 @@ public function testCanGetAllRequestParams(): void ); } + public function testCanGetAllRequestParamsBasedOnAllowedMethodsForGet(): void { $this->serverRequestMock->expects($this->once())->method('getMethod') @@ -55,6 +61,7 @@ public function testCanGetAllRequestParamsBasedOnAllowedMethodsForGet(): void ); } + public function testCanGetAllRequestParamsBasedOnAllowedMethodsForPost(): void { $this->serverRequestMock->expects($this->once())->method('getMethod') @@ -72,6 +79,7 @@ public function testCanGetAllRequestParamsBasedOnAllowedMethodsForPost(): void ); } + public function testGerAllRequestParamsBasedOnAllowedMethodsReturnsNullForNonAllowedMethod(): void { $this->serverRequestMock->expects($this->once())->method('getMethod') @@ -85,22 +93,26 @@ public function testGerAllRequestParamsBasedOnAllowedMethodsReturnsNullForNonAll ); } + public function testCanGetBearerToken(): void { $this->assertSame('abc123', $this->sut()->getBearerToken('Bearer abc123')); } + public function testGetBearerTokenIsCaseInsensitiveAndTrimsToken(): void { $this->assertSame('abc123', $this->sut()->getBearerToken('bearer abc123 ')); } + public function testGetBearerTokenReturnsNullWhenMissingOrNotBearer(): void { $this->assertNull($this->sut()->getBearerToken('Basic dXNlcjpwYXNz')); $this->assertNull($this->sut()->getBearerToken(null)); } + public function testGetBearerTokenReturnsNullForEmptyToken(): void { $this->assertNull($this->sut()->getBearerToken('Bearer ')); diff --git a/tests/unit/src/Helpers/RandomTest.php b/tests/unit/src/Helpers/RandomTest.php index 0960bc5c..84fb6152 100644 --- a/tests/unit/src/Helpers/RandomTest.php +++ b/tests/unit/src/Helpers/RandomTest.php @@ -4,18 +4,22 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Helpers\Random; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; #[CoversClass(Random::class)] +#[AllowMockObjectsWithoutExpectations] class RandomTest extends TestCase { protected function sut(): Random { return new Random(); } + + public function testCanGetIdentifier(): void { $this->assertNotEmpty( @@ -23,6 +27,7 @@ public function testCanGetIdentifier(): void ); } + public function testGetIdentifierThrowsOnInvalidLength(): void { $this->expectException(OidcServerException::class); diff --git a/tests/unit/src/Helpers/ScopeTest.php b/tests/unit/src/Helpers/ScopeTest.php index 814d9792..c3f35499 100644 --- a/tests/unit/src/Helpers/ScopeTest.php +++ b/tests/unit/src/Helpers/ScopeTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; use League\OAuth2\Server\Entities\ScopeEntityInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -12,12 +13,16 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; #[CoversClass(Scope::class)] +#[AllowMockObjectsWithoutExpectations] class ScopeTest extends TestCase { protected Stub $scopeEntityOpenIdStub; + protected Stub $scopeEntityProfileStub; + protected array $scopeEntitiesArray; + /** * @throws \Exception */ @@ -33,11 +38,13 @@ protected function setUp(): void ]; } + protected function sut(): Scope { return new Scope(); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -48,6 +55,7 @@ public function testCanCheckScopeExistence(): void $this->assertFalse($this->sut()->exists($this->scopeEntitiesArray, 'invalid')); } + public function testThrowsForInvalidScopeEntity(): void { $this->expectException(OidcServerException::class); diff --git a/tests/unit/src/Helpers/StrTest.php b/tests/unit/src/Helpers/StrTest.php index f3397a9d..b0c2de9d 100644 --- a/tests/unit/src/Helpers/StrTest.php +++ b/tests/unit/src/Helpers/StrTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Helpers; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Helpers\Str; #[CoversClass(Str::class)] +#[AllowMockObjectsWithoutExpectations] class StrTest extends TestCase { protected function sut(): Str @@ -16,6 +18,7 @@ protected function sut(): Str return new Str(); } + public function testCanConvertScopesStringToArray(): void { $this->assertSame( @@ -24,6 +27,7 @@ public function testCanConvertScopesStringToArray(): void ); } + public function testCanConvertTextToArray(): void { $this->assertSame( diff --git a/tests/unit/src/HelpersTest.php b/tests/unit/src/HelpersTest.php index 9fb4271d..313cabe3 100644 --- a/tests/unit/src/HelpersTest.php +++ b/tests/unit/src/HelpersTest.php @@ -4,19 +4,28 @@ namespace SimpleSAML\Test\Module\oidc\unit; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\Arr; +use SimpleSAML\Module\oidc\Helpers\Client; +use SimpleSAML\Module\oidc\Helpers\DateTime; +use SimpleSAML\Module\oidc\Helpers\Http; +use SimpleSAML\Module\oidc\Helpers\Random; +use SimpleSAML\Module\oidc\Helpers\Scope; +use SimpleSAML\Module\oidc\Helpers\Str; #[CoversClass(Helpers::class)] -#[UsesClass(Helpers\Http::class)] -#[UsesClass(Helpers\Client::class)] -#[UsesClass(Helpers\DateTime::class)] -#[UsesClass(Helpers\Str::class)] -#[UsesClass(Helpers\Arr::class)] -#[UsesClass(Helpers\Random::class)] -#[UsesClass(Helpers\Scope::class)] +#[UsesClass(Http::class)] +#[UsesClass(Client::class)] +#[UsesClass(DateTime::class)] +#[UsesClass(Str::class)] +#[UsesClass(Arr::class)] +#[UsesClass(Random::class)] +#[UsesClass(Scope::class)] +#[AllowMockObjectsWithoutExpectations] class HelpersTest extends TestCase { protected function sut(): Helpers @@ -24,14 +33,15 @@ protected function sut(): Helpers return new Helpers(); } + public function testCanBuildHelpers(): void { - $this->assertInstanceOf(Helpers\Http::class, $this->sut()->http()); - $this->assertInstanceOf(Helpers\Client::class, $this->sut()->client()); - $this->assertInstanceOf(Helpers\DateTime::class, $this->sut()->dateTime()); - $this->assertInstanceOf(Helpers\Str::class, $this->sut()->str()); - $this->assertInstanceOf(Helpers\Arr::class, $this->sut()->arr()); - $this->assertInstanceOf(Helpers\Random::class, $this->sut()->random()); - $this->assertInstanceOf(Helpers\Scope::class, $this->sut()->scope()); + $this->assertInstanceOf(Http::class, $this->sut()->http()); + $this->assertInstanceOf(Client::class, $this->sut()->client()); + $this->assertInstanceOf(DateTime::class, $this->sut()->dateTime()); + $this->assertInstanceOf(Str::class, $this->sut()->str()); + $this->assertInstanceOf(Arr::class, $this->sut()->arr()); + $this->assertInstanceOf(Random::class, $this->sut()->random()); + $this->assertInstanceOf(Scope::class, $this->sut()->scope()); } } diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 244a8379..6bfb5b39 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -6,12 +6,15 @@ use DateInterval; use Defuse\Crypto\Key; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; +use SimpleSAML\Database; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; @@ -31,12 +34,16 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; use SimpleSAML\Utils\Config; use SimpleSAML\Utils\HTTP; +use Symfony\Component\Cache\Adapter\ArrayAdapter; #[CoversClass(ModuleConfig::class)] +#[AllowMockObjectsWithoutExpectations] class ModuleConfigTest extends TestCase { protected string $fileName; + protected array $overrides; + protected MockObject $sspConfigMock; protected array $moduleConfig = [ @@ -44,7 +51,7 @@ class ModuleConfigTest extends TestCase ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ [ - ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_rsa_01.key', ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_rsa_01.pub', ], @@ -77,17 +84,23 @@ class ModuleConfigTest extends TestCase 'abc123', ], - ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\ArrayAdapter::class, + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => ArrayAdapter::class, ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [], ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => null, ]; + private MockObject $sspBridgeMock; + private MockObject $sspBridgeUtilsMock; + private MockObject $sspBridgeUtilsHttpMock; + private MockObject $sspBridgeUtilsConfigMock; + private MockObject $valueAbstractMock; + protected function setUp(): void { $this->fileName = ModuleConfig::DEFAULT_FILE_NAME; @@ -96,7 +109,7 @@ protected function setUp(): void $this->sspBridgeMock = $this->createMock(SspBridge::class); - $this->sspBridgeUtilsMock = $this->createMock(SspBridge\Utils::class); + $this->sspBridgeUtilsMock = $this->createMock(Utils::class); $this->sspBridgeUtilsConfigMock = $this->createMock(Config::class); $this->sspBridgeUtilsConfigMock->method('getCertPath') @@ -113,6 +126,7 @@ protected function setUp(): void $this->valueAbstractMock = $this->createMock(ValueAbstracts::class); } + protected function sut( ?string $fileName = null, ?array $overrides = null, @@ -135,6 +149,7 @@ protected function sut( ); } + public function testCanGetCommonOptions(): void { $this->assertSame(ModuleConfig::MODULE_NAME, $this->sut()->moduleName()); @@ -152,11 +167,13 @@ public function testCanGetCommonOptions(): void ); } + public function testCanGetProtocolSignatureKeyPairs(): void { $this->assertNotEmpty($this->sut()->getProtocolSignatureKeyPairs()); } + public function testGetProtocolSignatureKeyPairsThrowsOnInvalidConfigValue(): void { $this->expectException(ConfigurationError::class); @@ -167,6 +184,7 @@ public function testGetProtocolSignatureKeyPairsThrowsOnInvalidConfigValue(): vo )->getProtocolSignatureKeyPairs(); } + public function testCanGetProtocolSignatureKeyPairConfigBag(): void { $sut = $this->sut(); @@ -181,6 +199,7 @@ public function testCanGetProtocolSignatureKeyPairConfigBag(): void ); } + public function testCanGetProtocolSignatureKeyPairgBag(): void { $sut = $this->sut(); @@ -195,26 +214,31 @@ public function testCanGetProtocolSignatureKeyPairgBag(): void ); } + public function testCanGetSspConfig(): void { $this->assertInstanceOf(Configuration::class, $this->sut()->sspConfig()); } + public function testCanGetOpenIdScopes(): void { $this->assertNotEmpty($this->sut()->getScopes()); } + public function testCanGetAuthProcFilters(): void { $this->assertIsArray($this->sut()->getAuthProcFilters()); } + public function testCanGetIssuer(): void { $this->assertNotEmpty($this->sut()->getIssuer()); } + public function testGetsCurrentHostIfIssuerNotSetInConfig(): void { $this->sspBridgeUtilsHttpMock->expects($this->once())->method('getSelfURLHost') @@ -223,6 +247,7 @@ public function testGetsCurrentHostIfIssuerNotSetInConfig(): void $this->sut()->getIssuer(); } + public function testThrowsOnEmptyIssuer(): void { $this->overrides[ModuleConfig::OPTION_ISSUER] = ''; @@ -231,6 +256,7 @@ public function testThrowsOnEmptyIssuer(): void $this->sut()->getIssuer(); } + public function testCanGetForcedAcrValueForCookieAuthentication(): void { $this->overrides[ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION] = '1a'; @@ -238,18 +264,21 @@ public function testCanGetForcedAcrValueForCookieAuthentication(): void $this->assertEquals('1a', $this->sut()->getForcedAcrValueForCookieAuthentication()); } + public function testCanGetUserIdentifierAttribute(): void { $this->overrides[ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE] = 'sample'; $this->assertEquals('sample', $this->sut()->getUserIdentifierAttribute()); } + public function testCanGetUserIdentifierAttributesFromString(): void { $this->overrides[ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE] = 'sample'; $this->assertEquals(['sample'], $this->sut()->getUserIdentifierAttributes()); } + public function testCanGetUserIdentifierAttributesFromArray(): void { $this->overrides[ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE] = ['ePPN', 'uid']; @@ -258,6 +287,7 @@ public function testCanGetUserIdentifierAttributesFromArray(): void $this->assertEquals('ePPN', $this->sut()->getUserIdentifierAttribute()); } + public function testCanGetCommonFederationOptions(): void { $this->assertFalse($this->sut()->getFederationEnabled()); @@ -283,6 +313,7 @@ public function testCanGetCommonFederationOptions(): void $this->assertInstanceOf(DateInterval::class, $this->sut()->getTimestampValidationLeeway()); } + /** * The defaults deliberately mirror the `openid` library's own, so that the module does not silently * diverge from the limits upstream calibrated. @@ -299,6 +330,7 @@ public function testFederationTraversalLimitsDefaultToLibraryValues(): void $this->assertSame([], $sut->getFederationHttpClientOptions()); } + public function testCanOverrideFederationTraversalLimits(): void { $sut = $this->sut( @@ -318,6 +350,7 @@ public function testCanOverrideFederationTraversalLimits(): void $this->assertSame(4096, $sut->getFederationMaxFetchSizeBytes()); } + /** * Federation HTTP client options are read independently of the protocol-layer ones, so that disabling TLS * verification for one can never leak into the other. @@ -334,6 +367,7 @@ public function testFederationHttpClientOptionsAreSeparateFromProtocolOnes(): vo $this->assertSame([], $sut->getProtocolHttpClientOptions()); } + public function testCanGetFederationSignatureKeyPairBag(): void { $sut = $this->sut(); @@ -341,6 +375,7 @@ public function testCanGetFederationSignatureKeyPairBag(): void $this->assertInstanceOf(SignatureKeyPairBag::class, $sut->getFederationSignatureKeyPairBag()); } + public function testGetFederationSignatureKeyPairBagThrowsOnInvalidConfigValue(): void { $this->expectException(ConfigurationError::class); @@ -351,6 +386,7 @@ public function testGetFederationSignatureKeyPairBagThrowsOnInvalidConfigValue() )->getFederationSignatureKeyPairBag(); } + /** * The whole of the Verifiable Credential Issuance rollover model: additional pairs are published * so they can verify, and the one listed first is the one that signs. Everything which signs asks @@ -371,6 +407,7 @@ public function testActiveVciSignatureKeyPairIsTheFirstConfiguredOne(): void $this->assertSame(SignatureAlgorithmEnum::ES256, $activeSignatureKeyPair->getSignatureAlgorithm()); } + public function testGetActiveVciSignatureKeyPairThrowsWhenNoKeyPairCouldBeBuilt(): void { $sut = $this->sutWithVciSignatureKeyPairBag(new SignatureKeyPairBag()); @@ -380,6 +417,7 @@ public function testGetActiveVciSignatureKeyPairThrowsWhenNoKeyPairCouldBeBuilt( $sut->getActiveVciSignatureKeyPair(); } + /** * A ModuleConfig whose VCI option resolves to the given bag, so that a test can decide what the * bag holds without needing a key pair per algorithm on disk. @@ -406,6 +444,7 @@ protected function sutWithVciSignatureKeyPairBag(SignatureKeyPairBag $signatureK ); } + protected function buildSignatureKeyPair(string $keyId, SignatureAlgorithmEnum $algorithm): SignatureKeyPair { $keyPairMock = $this->createMock(KeyPair::class); @@ -418,6 +457,7 @@ protected function buildSignatureKeyPair(string $keyId, SignatureAlgorithmEnum $ return $signatureKeyPairMock; } + public function testKeywordsCanBeNull(): void { $this->assertNull( @@ -429,6 +469,7 @@ public function testKeywordsCanBeNull(): void ); } + public function testGetFederationTrustAnchorsThrowsOnEmptyIfFederationEnabled(): void { $this->expectException(ConfigurationError::class); @@ -443,13 +484,13 @@ public function testGetFederationTrustAnchorsThrowsOnEmptyIfFederationEnabled(): } - public function testCanGetTrustAnchorJwksJson(): void { $this->assertNotEmpty($this->sut()->getTrustAnchorJwksJson('https://ta.example.org/')); $this->assertEmpty($this->sut()->getTrustAnchorJwksJson('invalid')); } + public function testGetTrustAnchorJwksJsonThrowsOnInvalidData(): void { $this->expectException(ConfigurationError::class); @@ -462,6 +503,7 @@ public function testGetTrustAnchorJwksJsonThrowsOnInvalidData(): void )->getTrustAnchorJwksJson('ta'); } + public function testThrowsIfTryingToOverrideProtectedScopes(): void { $this->overrides[ModuleConfig::OPTION_AUTH_CUSTOM_SCOPES] = [ @@ -474,6 +516,7 @@ public function testThrowsIfTryingToOverrideProtectedScopes(): void $this->sut(); } + public function testThrowsIfCustomScopeDoesNotHaveDescription(): void { $this->overrides[ModuleConfig::OPTION_AUTH_CUSTOM_SCOPES] = [ @@ -484,6 +527,7 @@ public function testThrowsIfCustomScopeDoesNotHaveDescription(): void $this->sut(); } + public function testThrowsIfAcrIsNotString(): void { $this->overrides[ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED] = [123]; @@ -492,6 +536,7 @@ public function testThrowsIfAcrIsNotString(): void $this->sut(); } + public function testThrowsIfAuthSourceNotString(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = [123 => []]; @@ -499,6 +544,7 @@ public function testThrowsIfAuthSourceNotString(): void $this->sut(); } + public function testThrowsIfAuthSourceToAcrMapAcrNotArray(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = ['abc' => 123]; @@ -506,6 +552,7 @@ public function testThrowsIfAuthSourceToAcrMapAcrNotArray(): void $this->sut(); } + public function testThrowsIfAuthSourceToAcrMapAcrNotString(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = ['abc' => [123]]; @@ -513,6 +560,7 @@ public function testThrowsIfAuthSourceToAcrMapAcrNotString(): void $this->sut(); } + public function testThrowsIfAuthSourceToAcrMapAcrNotAllowed(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = ['abc' => ['acr']]; @@ -520,6 +568,7 @@ public function testThrowsIfAuthSourceToAcrMapAcrNotAllowed(): void $this->sut(); } + public function testThrowsIForcedAcrValueForCookieAuthenticationNotAllowed(): void { $this->overrides[ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED] = ['abc']; @@ -528,6 +577,7 @@ public function testThrowsIForcedAcrValueForCookieAuthenticationNotAllowed(): vo $this->sut(); } + public function testCanGetEncryptionKey(): void { $this->sspBridgeUtilsConfigMock->expects($this->once())->method('getSecretSalt') @@ -536,6 +586,7 @@ public function testCanGetEncryptionKey(): void $this->assertSame('secretSalt', $this->sut()->getEncryptionKey()); } + public function testCanGetEncryptionKeyAsDefuseKey(): void { $this->sspBridgeUtilsConfigMock->expects($this->never())->method('getSecretSalt'); @@ -549,6 +600,7 @@ public function testCanGetEncryptionKeyAsDefuseKey(): void $this->assertSame($key->saveToAsciiSafeString(), $encryptionKey->saveToAsciiSafeString()); } + public function testGetEncryptionKeyThrowsForInvalidDefuseKey(): void { $this->overrides[ModuleConfig::OPTION_ENCRYPTION_KEY] = 'not-a-valid-ascii-safe-key'; @@ -558,6 +610,7 @@ public function testGetEncryptionKeyThrowsForInvalidDefuseKey(): void $this->sut()->getEncryptionKey(); } + public function testCanGetProtocolCacheConfiguration(): void { $this->assertNotEmpty($this->sut()->getProtocolCacheAdapterClass()); @@ -567,6 +620,7 @@ public function testCanGetProtocolCacheConfiguration(): void $this->assertInstanceOf(DateInterval::class, $this->sut()->getProtocolClientEntityCacheDuration()); } + public function testCanGetRequestUriParameterSupported(): void { // Default. @@ -579,12 +633,14 @@ public function testCanGetRequestUriParameterSupported(): void ); } + public function testGetFederationRequestUriAllowedPrefixesDeniesByDefault(): void { // Option absent -> deny all federation-candidate fetches (empty allowlist). $this->assertSame([], $this->sut()->getFederationRequestUriAllowedPrefixes()); } + public function testGetFederationRequestUriAllowedPrefixesCanAllowAny(): void { // Explicit null -> allow any. @@ -595,6 +651,7 @@ public function testGetFederationRequestUriAllowedPrefixesCanAllowAny(): void ); } + public function testGetFederationRequestUriAllowedPrefixesReturnsConfiguredPrefixes(): void { $sut = $this->sut( @@ -609,6 +666,7 @@ public function testGetFederationRequestUriAllowedPrefixesReturnsConfiguredPrefi $this->assertSame(['https://rp.example.org/'], $sut->getFederationRequestUriAllowedPrefixes()); } + public function testCanGetProtocolDiscoveryShowClaimsSupported(): void { $this->assertFalse($this->sut()->getProtocolDiscoveryShowClaimsSupported()); @@ -619,6 +677,7 @@ public function testCanGetProtocolDiscoveryShowClaimsSupported(): void ); } + public function testCanGetFederationDynamicTrustMarks(): void { $this->assertNull($this->sut()->getFederationDynamicTrustMarks()); @@ -637,6 +696,7 @@ public function testCanGetFederationDynamicTrustMarks(): void ); } + public function testCanGetFederationParticipationLimitByTrustMarks(): void { $this->assertArrayHasKey( @@ -645,6 +705,7 @@ public function testCanGetFederationParticipationLimitByTrustMarks(): void ); } + public function testCanGetTrustMarksNeededForFederationParticipationFor(): void { $neededTrustMarks = $this->sut()->getTrustMarksNeededForFederationParticipationFor('https://ta.example.org/'); @@ -653,6 +714,7 @@ public function testCanGetTrustMarksNeededForFederationParticipationFor(): void $this->assertTrue(in_array('trust-mark-type', $neededTrustMarks['one_of'])); } + public function testGetTrustMarksNeededForFederationParticipationForThrowsOnInvalidConfigValue(): void { $sut = $this->sut( @@ -668,6 +730,7 @@ public function testGetTrustMarksNeededForFederationParticipationForThrowsOnInva $sut->getTrustMarksNeededForFederationParticipationFor('https://ta.example.org/'); } + public function testCanGetIsFederationParticipationLimitedByTrustMarksFor(): void { $this->assertTrue( @@ -675,6 +738,7 @@ public function testCanGetIsFederationParticipationLimitedByTrustMarksFor(): voi ); } + public function testCanGetFederationTrustMarkStatusEndpointUsagePolicy(): void { // Assert default policy. @@ -696,6 +760,7 @@ public function testCanGetFederationTrustMarkStatusEndpointUsagePolicy(): void ); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidValue(): void { $this->expectException(ConfigurationError::class); @@ -704,6 +769,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidValue(): voi $this->sut()->getValidatedSignatureKeyPairArray('invalid'); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidSignature(): void { $value = [ @@ -716,6 +782,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidSignature(): $this->sut()->getValidatedSignatureKeyPairArray($value); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPrivateKey(): void { $value = [ @@ -729,6 +796,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPrivateKey() $this->sut()->getValidatedSignatureKeyPairArray($value); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPrivateKey(): void { $value = [ @@ -742,6 +810,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPrivateK $this->sut()->getValidatedSignatureKeyPairArray($value); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPublicKey(): void { $value = [ @@ -756,6 +825,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPublicKey(): $this->sut()->getValidatedSignatureKeyPairArray($value); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPublicKey(): void { $value = [ @@ -770,6 +840,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPublicKe $this->sut()->getValidatedSignatureKeyPairArray($value); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyPasswordString(): void { $value = [ @@ -785,6 +856,7 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyPasswordString $this->sut()->getValidatedSignatureKeyPairArray($value); } + public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyKeyIdString(): void { $value = [ @@ -815,6 +887,7 @@ public function testStatusListsAreDisabledByDefault(): void $this->assertFalse($this->sut()->getVciStatusListEnabled()); } + /** * @throws \Exception */ @@ -826,6 +899,7 @@ public function testStatusListsCanBeEnabled(): void ); } + /** * SimpleSAMLphp is a development dependency here, so nothing stops this module being installed into * a host which lacks the primary read. Deciding whether a credential has been revoked off a lagging @@ -835,7 +909,7 @@ public function testStatusListsCanBeEnabled(): void public function testPrimaryDatabaseReadCapabilityIsDetectedRatherThanAssumed(): void { $this->assertSame( - method_exists(\SimpleSAML\Database::class, ModuleConfig::SSP_PRIMARY_READ_METHOD), + method_exists(Database::class, ModuleConfig::SSP_PRIMARY_READ_METHOD), ModuleConfig::hasPrimaryDatabaseReadCapability(), ); @@ -843,6 +917,7 @@ public function testPrimaryDatabaseReadCapabilityIsDetectedRatherThanAssumed(): $this->assertTrue(ModuleConfig::hasPrimaryDatabaseReadCapability()); } + /** * @throws \Exception */ @@ -854,6 +929,7 @@ public function testStatusListKeyProfileDefaultsToDidJwk(): void ); } + /** * @throws \Exception */ @@ -873,6 +949,7 @@ public function testStatusListKeyProfileAcceptsAnEnumCaseOrItsValue(): void ); } + /** * @throws \Exception */ @@ -884,6 +961,7 @@ public function testStatusListKeyProfileRejectsAnUnknownValue(): void ->getVciStatusListKeyProfile(); } + /** * A typo here would otherwise be silent: the pool would never be allocated from, and the * credentials which were meant to be revocable would be issued without a status claim. @@ -904,6 +982,7 @@ public function testStatusListPoolsRejectAnUnknownCredentialConfiguration(): voi ])->getVciStatusListPoolBag(); } + /** * @throws \Exception */ @@ -918,6 +997,7 @@ public function testResolvesTheStatusListPoolForACredentialConfiguration(): void $this->assertNull($sut->getVciStatusListPoolFor('SomethingElse')); } + /** * With the capability off, nothing allocates, so no credential configuration resolves to a pool * even when one is configured. @@ -935,6 +1015,7 @@ public function testResolvesNoStatusListPoolWhileTheCapabilityIsDisabled(): void $this->assertSame('default', $sut->getVciStatusListPoolBag()->getById('default')?->getId()); } + /** * @return array */ @@ -956,6 +1037,7 @@ protected function withStatusListPool(bool $isEnabled): array ); } + /** * A pool whose credential configurations all lack a lifetime allocates only into the non-expiring * lane, so any list it has in the other one is no longer an allocation target and has to be @@ -976,6 +1058,7 @@ public function testResolvesOnlyTheNonExpiringLaneForAPoolWithoutLifetimes(): vo ); } + /** * @throws \Exception */ @@ -991,6 +1074,7 @@ public function testResolvesOnlyTheExpiringLaneWhenEveryConfigurationHasALifetim $this->assertSame([StatusListExpiryLaneEnum::Expiring], $sut->getVciStatusListCurrentLanesFor($pool)); } + /** * A mixed pool keeps a list in each lane, and both are current. Reporting only one would have the * lifecycle deactivate the other on every run, and the allocator recreate it on the next allocation. @@ -1029,6 +1113,7 @@ public function testResolvesBothLanesForAPoolWhoseConfigurationsDiffer(): void $this->assertContains(StatusListExpiryLaneEnum::NonExpiring, $lanes); } + /** * Not expiring is what this module has always done, and an expiry changes what already issued * credentials mean, so it stays something an operator asks for. @@ -1043,6 +1128,7 @@ public function testCredentialsHaveNoLifetimeUnlessOneIsConfigured(): void $this->assertNull($sut->getVciCredentialTtlFor('TestCredential')); } + /** * @throws \Exception */ @@ -1055,6 +1141,7 @@ public function testResolvesTheConfiguredCredentialLifetime(): void $this->assertNull($sut->getVciCredentialTtlFor('SomethingElse')); } + /** * As with the pools, a typo would otherwise be silent: credentials which were meant to expire * would go on being issued without an expiry and nothing would say so. @@ -1075,6 +1162,7 @@ public function testCredentialLifetimesRejectAnUnknownCredentialConfiguration(): ))->getVciCredentialTtls(); } + /** * @throws \Exception */ @@ -1085,6 +1173,7 @@ public function testCredentialLifetimesRejectAnUnparseableDuration(): void $this->sut(overrides: $this->withCredentialTtl('thirty days'))->getVciCredentialTtls(); } + /** * A zero lifetime would issue credentials which have already expired, which is never what was * meant. Leaving the entry out is how a configuration says its credentials do not expire. @@ -1098,6 +1187,7 @@ public function testCredentialLifetimesRejectADurationOfNoTime(): void $this->sut(overrides: $this->withCredentialTtl('PT0S'))->getVciCredentialTtls(); } + /** * The shape this option has always had, which has to go on working. * @@ -1114,6 +1204,7 @@ public function testReadsApiTokenScopesGivenAsABareList(): void $this->assertNull($sut->getApiTokenName('a-token')); } + /** * A bare list is read by value rather than by key, so one which happens to carry an entry under * a key of 'scopes' or 'name' authorized before this option grew a second shape. Quietly ceasing @@ -1144,6 +1235,7 @@ public function testStillReadsABareListWhoseKeysLookLikeSettings(): void ); } + /** * @throws \Exception */ @@ -1165,6 +1257,7 @@ public function testReadsApiTokenScopesAndNameGivenAsSettings(): void $this->assertSame('HR system', $sut->getApiTokenName('a-token')); } + /** * A token someone had already annotated with a name, listing its scopes positionally, authorized * before this option grew a second shape. Ceasing to would take its access away on upgrade, and @@ -1191,6 +1284,7 @@ public function testReadsApiTokenScopesListedAlongsideAName(): void $this->assertSame('legacy label', $sut->getApiTokenName('a-token')); } + /** * A name on its own authorizes nothing. Reading the settings shape as though the whole array were * a list of scopes would hand the token a scope named after its own name. @@ -1212,6 +1306,7 @@ public function testReadsNoApiTokenScopesFromSettingsWhichDeclareNone(): void $this->assertSame('HR system', $sut->getApiTokenName('a-token')); } + /** * The name goes into a fixed width column in the audit trail. Left unchecked, an over-long one * would make every status change that token asks for fail at the point of recording it, on the @@ -1241,6 +1336,7 @@ public function testRejectsAnApiTokenNameTooLongToRecord(): void $sut->getApiTokenName('a-token'); } + /** * @throws \Exception */ @@ -1260,6 +1356,7 @@ public function testAcceptsAnApiTokenNameOfTheGreatestRecordableLength(): void $this->assertSame($name, $sut->getApiTokenName('a-token')); } + /** * @throws \Exception */ @@ -1274,6 +1371,7 @@ public function testReadsNothingForAnUnknownApiToken(): void $this->assertNull($sut->getApiTokenName('some-other-token')); } + /** * Nobody is a resource server until a deployment says so, since being one means being told about * other clients' tokens. @@ -1285,6 +1383,7 @@ public function testReadsNoIntrospectionResourceServersByDefault(): void $this->assertSame([], $this->sut()->getApiOAuth2TokenIntrospectionResourceServerClientIds()); } + /** * @throws \Exception */ @@ -1306,6 +1405,7 @@ public function testReadsConfiguredIntrospectionResourceServers(): void ); } + /** * An entry which is not a client identifier can not name a client, and an empty one would sit in * the list looking like it named something. Neither is allowed to authorize anything. @@ -1330,6 +1430,7 @@ public function testDropsIntrospectionResourceServerEntriesWhichCanNotNameAClien $this->assertSame(['resource-server'], $sut->getApiOAuth2TokenIntrospectionResourceServerClientIds()); } + /** * @return array */ @@ -1344,6 +1445,7 @@ protected function withCredentialTtl(mixed $ttl): array ); } + /** * @return array */ @@ -1352,6 +1454,7 @@ protected function withOption(string $option, mixed $value): array return array_merge($this->overrides, [$option => $value]); } + /** * @throws \Exception */ @@ -1360,6 +1463,7 @@ public function testRetirementGraceDefaultsToAMonth(): void $this->assertSame(30, $this->sut()->getVciStatusListRetirementGrace()->d); } + /** * @throws \Exception */ @@ -1373,6 +1477,7 @@ public function testReadsTheConfiguredRetirementGrace(): void $this->assertSame(90, $sut->getVciStatusListRetirementGrace()->d); } + /** * The first of the two waits has to outlast an issuance which was already under way when the list * stopped accepting allocations, and nothing available can serialise the two instead. A wait shorter @@ -1390,6 +1495,7 @@ public function testRejectsARetirementGraceOfNoTime(): void ))->getVciStatusListRetirementGrace(); } + /** * @throws \Exception */ @@ -1404,6 +1510,7 @@ public function testRejectsARetirementGraceShorterThanAnHour(): void ))->getVciStatusListRetirementGrace(); } + /** * @throws \Exception */ @@ -1417,6 +1524,7 @@ public function testAcceptsTheShortestRetirementGraceThereIs(): void $this->assertSame(1, $sut->getVciStatusListRetirementGrace()->h); } + /** * @throws \Exception */ @@ -1430,6 +1538,7 @@ public function testRejectsAnUnparseableRetirementGrace(): void ))->getVciStatusListRetirementGrace(); } + /** * The configuration file is PHP, so a duration can arrive as an object rather than a string. That * has to be checked like any other value: an interval can be inverted, which no duration string can @@ -1451,6 +1560,7 @@ public function testRejectsAnInvertedRetirementGraceGivenAsAnInterval(): void ))->getVciStatusListRetirementGrace(); } + /** * @throws \Exception */ @@ -1464,6 +1574,7 @@ public function testAcceptsARetirementGraceGivenAsAnInterval(): void $this->assertSame(14, $sut->getVciStatusListRetirementGrace()->d); } + /** * How long a record of who revoked what needs keeping follows from the deployment's own * obligations, so nothing is discarded unless an operator says how long is long enough. @@ -1475,6 +1586,7 @@ public function testTheAuditTrailIsKeptIndefinitelyUnlessARetentionIsSet(): void $this->assertNull($this->sut()->getVciStatusListAuditRetention()); } + /** * @throws \Exception */ @@ -1488,6 +1600,7 @@ public function testReadsTheConfiguredAuditRetention(): void $this->assertSame(1, $sut->getVciStatusListAuditRetention()?->y); } + /** * A retention of no time would delete every row the moment it was written, which is a way of asking * for no trail at all rather than a retention policy. Leaving the option out is how that is said. @@ -1504,6 +1617,7 @@ public function testRejectsAnAuditRetentionOfNoTime(): void ))->getVciStatusListAuditRetention(); } + /** * @throws \Exception */ @@ -1517,6 +1631,7 @@ public function testRejectsAnAuditRetentionWhichIsNotADuration(): void ))->getVciStatusListAuditRetention(); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php b/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php index 47c4a69c..4248a220 100644 --- a/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php +++ b/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -13,12 +14,16 @@ use SimpleSAML\Module\oidc\Utils\ProtocolCache; #[CoversClass(AbstractDatabaseRepository::class)] +#[AllowMockObjectsWithoutExpectations] class AbstractDatabaseRepositoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $databaseMock; + protected MockObject $protocolCacheMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -26,6 +31,7 @@ protected function setUp(): void $this->protocolCacheMock = $this->createMock(ProtocolCache::class); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?Database $database = null, @@ -44,11 +50,13 @@ public function getTableName(): ?string }; } + public function testCanGetCacheKey(): void { $this->assertSame('sut_something', $this->sut()->getCacheKey('something')); } + /** * The ceiling is SQLite's pre 3.32 default of 999, which is the lowest of the three drivers and so * the one every statement has to be built to. @@ -60,6 +68,7 @@ public function testWorksOutHowManyRowsOneStatementCanName(): void $this->assertSame(333, $this->rowsPerStatement(3)); } + public function testCountsWhatTheStatementBindsBesidesItsRows(): void { // A statement carrying a timestamp of its own has one fewer variable to spend on rows, and at @@ -68,6 +77,7 @@ public function testCountsWhatTheStatementBindsBesidesItsRows(): void $this->assertSame(498, $this->rowsPerStatement(2, 3)); } + /** * A statement can not name a fraction of a row, and answering zero would leave a caller chunking by * nothing, which never advances. @@ -78,6 +88,7 @@ public function testNamesAtLeastOneRowHoweverWideTheRowIs(): void $this->assertSame(1, $this->rowsPerStatement(2, 999)); } + protected function rowsPerStatement(int $perRow, int $fixed = 0): int { return (new class ( @@ -90,6 +101,7 @@ public function getTableName(): ?string return 'sut'; } + public function rows(int $perRow, int $fixed): int { return $this->maxRowsPerStatement($perRow, $fixed); diff --git a/tests/unit/src/Repositories/AccessTokenRepositoryTest.php b/tests/unit/src/Repositories/AccessTokenRepositoryTest.php index d1be8c40..3bb3fb89 100644 --- a/tests/unit/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/unit/src/Repositories/AccessTokenRepositoryTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Exception; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,6 +20,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\DateTime; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; @@ -27,27 +29,43 @@ use SimpleSAML\Module\oidc\Utils\ProtocolCache; #[CoversClass(AccessTokenRepository::class)] +#[AllowMockObjectsWithoutExpectations] class AccessTokenRepositoryTest extends TestCase { - final public const CLIENT_ID = 'access_token_client_id'; - final public const USER_ID = 'access_token_user_id'; - final public const ACCESS_TOKEN_ID = 'access_token_id'; - final public const AUTH_CODE_ID = 'auth_code_id'; + final public const string CLIENT_ID = 'access_token_client_id'; + + final public const string USER_ID = 'access_token_user_id'; + + final public const string ACCESS_TOKEN_ID = 'access_token_id'; + + final public const string AUTH_CODE_ID = 'auth_code_id'; + protected MockObject $moduleConfigMock; + protected MockObject $clientRepositoryMock; + protected MockObject $clientEntityFactoryMock; + protected MockObject $accessTokenEntityFactoryMock; + protected MockObject $accessTokenEntityMock; + protected MockObject $helpersMock; + protected MockObject $dateTimeHelperMock; protected static bool $dbSeeded = false; + protected MockObject $clientEntityMock; + protected array $accessTokenState; + protected Database $database; + protected MockObject $protocolCacheMock; + /** * @throws \Exception */ @@ -66,6 +84,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -93,13 +112,14 @@ protected function setUp(): void ]; $this->helpersMock = $this->createMock(Helpers::class); - $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); + $this->dateTimeHelperMock = $this->createMock(DateTime::class); $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); $this->database = Database::getInstance(); $this->protocolCacheMock = $this->createMock(ProtocolCache::class); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?Database $database = null, @@ -125,11 +145,13 @@ protected function sut( ); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_access_token', $this->sut()->getTableName()); } + /** * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException * @throws \SimpleSAML\Error\Error @@ -151,6 +173,7 @@ public function testAddAndFound(): void $this->assertEquals($this->accessTokenEntityMock, $foundAccessToken); } + public function testPersistNewAccessTokenThrowsIfNotAccessTokenEntity(): void { $oAuthAccessTokenEntity = $this->createMock(\League\OAuth2\Server\Entities\AccessTokenEntityInterface::class); @@ -161,6 +184,7 @@ public function testPersistNewAccessTokenThrowsIfNotAccessTokenEntity(): void $this->sut()->persistNewAccessToken($oAuthAccessTokenEntity); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -171,6 +195,7 @@ public function testAddAndNotFound(): void $this->assertNull($notFoundAccessToken); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -193,6 +218,7 @@ public function testRevokeToken(): void $this->assertTrue($isRevoked); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -204,6 +230,7 @@ public function testErrorRevokeInvalidToken(): void $this->sut()->revokeAccessToken('notoken'); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -214,6 +241,7 @@ public function testErrorCheckIsRevokedInvalidToken(): void $this->sut()->isAccessTokenRevoked('notoken'); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -233,6 +261,7 @@ public function testRemoveExpired(): void $this->assertNull($notFoundAccessToken); } + public function testCanGetNewToken() { $this->accessTokenEntityFactoryMock->expects($this->once())->method('fromData') @@ -252,6 +281,7 @@ public function testCanGetNewToken() ); } + public function testCanGetNewTokenForEmptyUserId(): void { $this->accessTokenEntityFactoryMock->expects($this->once())->method('fromData') @@ -271,6 +301,7 @@ public function testCanGetNewTokenForEmptyUserId(): void ); } + public function testCanGetNewTokenThrowsForEmptyId(): void { $this->expectException(OidcServerException::class); @@ -287,6 +318,7 @@ public function testCanGetNewTokenThrowsForEmptyId(): void ); } + public function testCanRevokeByAuthCodeId(): void { $this->accessTokenEntityMock->method('getState')->willReturn($this->accessTokenState); diff --git a/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php b/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php index 47b3cc8c..c51fe8f6 100644 --- a/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php +++ b/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; @@ -16,21 +17,24 @@ /** * @covers \SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository */ +#[AllowMockObjectsWithoutExpectations] class AllowedOriginRepositoryTest extends TestCase { - final public const CLIENT_ID = 'some_client_id'; + final public const string CLIENT_ID = 'some_client_id'; - protected MockObject $moduleConfigMock; - protected MockObject $protocolCacheMock; - - - final public const ORIGINS = [ + final public const array ORIGINS = [ 'https://example.org', 'https://sample.com', ]; + + protected MockObject $moduleConfigMock; + + protected MockObject $protocolCacheMock; + private AllowedOriginRepository $repository; + /** * @throws \Exception */ @@ -49,6 +53,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -63,16 +68,19 @@ protected function setUp(): void ); } + public function tearDown(): void { $this->repository->delete(self::CLIENT_ID); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_allowed_origin', $this->repository->getTableName()); } + public function testSetGetHasDelete(): void { $this->repository->set(self::CLIENT_ID, []); @@ -89,6 +97,7 @@ public function testSetGetHasDelete(): void $this->assertFalse($this->repository->has(self::ORIGINS[1])); } + public function testHasCanReturnFromCache(): void { $this->protocolCacheMock->expects($this->once())->method('get') diff --git a/tests/unit/src/Repositories/AuthCodeRepositoryTest.php b/tests/unit/src/Repositories/AuthCodeRepositoryTest.php index 2913fc66..69ad6a22 100644 --- a/tests/unit/src/Repositories/AuthCodeRepositoryTest.php +++ b/tests/unit/src/Repositories/AuthCodeRepositoryTest.php @@ -8,8 +8,10 @@ use DateTimeZone; use Exception; use League\OAuth2\Server\Entities\AuthCodeEntityInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RuntimeException; use SimpleSAML\Configuration; use SimpleSAML\Database; use SimpleSAML\Error\Error; @@ -20,6 +22,7 @@ use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\DateTime; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; @@ -29,24 +32,38 @@ /** * @covers \SimpleSAML\Module\oidc\Repositories\AuthCodeRepository */ +#[AllowMockObjectsWithoutExpectations] class AuthCodeRepositoryTest extends TestCase { - final public const CLIENT_ID = 'auth_code_client_id'; - final public const USER_ID = 'auth_code_user_id'; - final public const AUTH_CODE_ID = 'auth_code_id'; - final public const REDIRECT_URI = 'http://localhost/redirect'; + final public const string CLIENT_ID = 'auth_code_client_id'; + + final public const string USER_ID = 'auth_code_user_id'; + + final public const string AUTH_CODE_ID = 'auth_code_id'; + + final public const string REDIRECT_URI = 'http://localhost/redirect'; + protected AuthCodeRepository $repository; + protected MockObject $clientEntityMock; + protected MockObject $clientRepositoryMock; + protected MockObject $authCodeEntityFactoryMock; + protected MockObject $helpersMock; + protected MockObject $moduleConfigMock; + protected MockObject $protocolCacheMock; + protected MockObject $dateTimeHelperMock; + /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] */ protected array $scopes; + /** * @throws \Exception */ @@ -65,6 +82,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -80,7 +98,7 @@ protected function setUp(): void $this->authCodeEntityFactoryMock = $this->createMock(AuthCodeEntityFactory::class); $this->helpersMock = $this->createMock(Helpers::class); - $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); + $this->dateTimeHelperMock = $this->createMock(DateTime::class); $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); $database = Database::getInstance(); @@ -95,11 +113,13 @@ protected function setUp(): void ); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_auth_code', $this->repository->getTableName()); } + /** * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException * @throws \SimpleSAML\Error\Error @@ -132,6 +152,7 @@ public function testAddAndFound(): void $this->assertEquals($authCode, $foundAuthCode); } + /** * @throws \Exception */ @@ -142,6 +163,7 @@ public function testAddAndNotFound(): void $this->assertNull($notFoundAuthCode); } + /** * @throws \JsonException * @throws \Exception @@ -182,6 +204,7 @@ function (array $state) use (&$callNumber): bool { $this->assertTrue($isRevoked); } + /** * @throws \JsonException */ @@ -192,6 +215,7 @@ public function testErrorRevokeInvalidAuthCode(): void $this->repository->revokeAuthCode('nocode'); } + public function testErrorCheckIsRevokedInvalidAuthCode(): void { $this->expectException(Exception::class); @@ -199,6 +223,7 @@ public function testErrorCheckIsRevokedInvalidAuthCode(): void $this->repository->isAuthCodeRevoked('nocode'); } + /** * @throws \JsonException * @throws \SimpleSAML\Error\Error @@ -229,6 +254,7 @@ public function testConsumePreAuthorizedCodeReturnsTrueOnlyOnce(): void $this->assertFalse($this->repository->consumePreAuthorizedCode($codeId)); } + /** * @throws \JsonException * @throws \SimpleSAML\Error\Error @@ -252,6 +278,7 @@ public function testConsumePreAuthorizedCodeDoesNotConsumeStandardAuthorizationC $this->assertFalse($this->repository->consumePreAuthorizedCode($codeId)); } + /** * @throws \JsonException * @throws \SimpleSAML\Error\Error @@ -276,6 +303,7 @@ public function testConsumePreAuthorizedCodeRejectsCodeExpiredAtConsumptionTime( $this->assertFalse($this->repository->consumePreAuthorizedCode($codeId)); } + /** * @throws \Exception */ @@ -292,13 +320,15 @@ public function testRemoveExpired(): void $this->assertNull($notFoundAuthCode); } + public function testGetNewAuthCodeThrows(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->repository->getNewAuthCode(); } + public function testPersistNewAuthCodeThrowsIfNotAuthCodeEntity(): void { $this->expectException(Error::class); diff --git a/tests/unit/src/Repositories/ClientRepositoryTest.php b/tests/unit/src/Repositories/ClientRepositoryTest.php index 7b17ec41..e314d0e8 100644 --- a/tests/unit/src/Repositories/ClientRepositoryTest.php +++ b/tests/unit/src/Repositories/ClientRepositoryTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; @@ -19,12 +20,16 @@ /** * @covers \SimpleSAML\Module\oidc\Repositories\ClientRepository */ +#[AllowMockObjectsWithoutExpectations] class ClientRepositoryTest extends TestCase { protected ClientRepository $repository; + protected MockObject $clientEntityMock; + protected MockObject $clientEntityFactoryMock; + /** * @throws \Exception */ @@ -43,6 +48,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->clientEntityMock = $this->createMock(ClientEntityInterface::class); @@ -58,32 +64,20 @@ protected function setUp(): void ); } - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException - */ + public function tearDown(): void { - $this->clientEntityFactoryMock->method('fromState')->willReturnCallback( - function (array $state) { - $client = $this->createStub(ClientEntityInterface::class); - $client->method('getIdentifier')->willReturn($state['id']); - return $client; - }, - ); - - $clients = $this->repository->findAll(); - - foreach ($clients as $client) { - $this->repository->delete($client); - } + $database = Database::getInstance(); + $database->write('DELETE FROM ' . $this->repository->getTableName()); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_client', $this->repository->getTableName()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -97,6 +91,7 @@ public function testAddAndFound(): void $this->assertEquals($client, $foundClient); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \JsonException @@ -110,6 +105,7 @@ public function testGetClientEntity(): void $this->assertNotNull($client); } + public function testGetClientEntityReturnsNullForExpiredClient(): void { $this->clientEntityMock->expects($this->once())->method('isExpired')->willReturn(true); @@ -124,6 +120,7 @@ public function testGetClientEntityReturnsNullForExpiredClient(): void $this->assertNull($this->repository->getClientEntity('clientid')); } + /** * @throws \JsonException */ @@ -135,6 +132,7 @@ public function testGetDisabledClientEntity(): void $this->assertNull($this->repository->getClientEntity('clientid')); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \JsonException @@ -146,6 +144,7 @@ public function testNotFoundClient(): void $this->assertNull($client); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \JsonException @@ -161,6 +160,7 @@ public function testValidateConfidentialClient(): void $this->assertTrue($validate); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \JsonException @@ -176,6 +176,7 @@ public function testValidatePublicClient(): void $this->assertTrue($validate); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \JsonException @@ -189,6 +190,7 @@ public function testNotValidateConfidentialClientWithWrongSecret() $this->assertFalse($validate); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \JsonException @@ -199,6 +201,7 @@ public function testNotValidateWhenClientDoesNotExists() $this->assertFalse($validate); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -213,6 +216,7 @@ public function testFindAll(): void $this->assertInstanceOf(ClientEntity::class, current($clients)); } + /** * @throws \Exception */ @@ -234,6 +238,7 @@ public function testFindPaginated(): void self::assertEquals(2, $clientPageTwo['currentPage']); } + /** * @throws \Exception */ @@ -249,6 +254,7 @@ public function testFindPageInRange(): void self::assertEquals(2, $clientPageOne['currentPage']); } + /** * @throws \Exception */ @@ -260,6 +266,7 @@ public function testFindPaginationWithEmptyList() self::assertCount(0, $clientPageOne['items']); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -289,6 +296,7 @@ public function testUpdate(): void $this->assertEquals($client, $foundClient); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException @@ -306,6 +314,7 @@ public function testDelete(): void $this->assertNull($foundClient); } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -358,6 +367,7 @@ public function testCrudWithOwner(): void $this->assertNotNull($foundClient); } + public function testCanFindByIdFromCache(): void { $protocolCacheMock = $this->createMock(ProtocolCache::class); @@ -378,6 +388,7 @@ public function testCanFindByIdFromCache(): void $this->assertInstanceOf(ClientEntityInterface::class, $sut->findById('clientid')); } + public function testCanFindByEntityIdentifier(): void { $client = self::getClient(id: 'clientId', entityId: 'entityId'); @@ -393,6 +404,7 @@ public function testCanFindByEntityIdentifier(): void $this->assertNull($this->repository->findByEntityIdentifier('nonExistingEntityId')); } + public function testCanFindFederatedByEntityIdentifier(): void { $client = self::getClient(id: 'clientId', entityId: 'entityId', federationJwks: []); @@ -408,6 +420,7 @@ public function testCanFindFederatedByEntityIdentifier(): void $this->assertNull($this->repository->findFederatedByEntityIdentifier('nonExistingEntityId')); } + public function testCanNotFindFederatedByEntityIdentifierIfMissingFederationAttributes(): void { $client = self::getClient(id: 'clientId', entityId: 'entityId'); @@ -423,6 +436,7 @@ public function testCanNotFindFederatedByEntityIdentifierIfMissingFederationAttr $this->assertNull($this->repository->findFederatedByEntityIdentifier('entityId')); } + public function testCanFindAllFederated(): void { $client = self::getClient(id: 'clientId', entityId: 'entityId', federationJwks: []); @@ -433,6 +447,7 @@ public function testCanFindAllFederated(): void $this->assertCount(1, $this->repository->findAllFederated()); } + public function testCanFindByEntityIdFromCache(): void { $protocolCacheMock = $this->createMock(ProtocolCache::class); @@ -452,6 +467,7 @@ public function testCanFindByEntityIdFromCache(): void $this->assertInstanceOf(ClientEntityInterface::class, $sut->findByEntityIdentifier('entityId')); } + public static function getClient( string $id, bool $enabled = true, diff --git a/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php b/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php index 06b34126..e4d0226c 100644 --- a/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php +++ b/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php @@ -5,11 +5,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; use League\OAuth2\Server\CodeChallengeVerifiers\CodeChallengeVerifierInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; #[CoversClass(CodeChallengeVerifiersRepository::class)] +#[AllowMockObjectsWithoutExpectations] class CodeChallengeVerifiersRepositoryTest extends TestCase { protected function sut(): CodeChallengeVerifiersRepository @@ -17,11 +19,13 @@ protected function sut(): CodeChallengeVerifiersRepository return new CodeChallengeVerifiersRepository(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(CodeChallengeVerifiersRepository::class, $this->sut()); } + public function testCanGetCodeChallengeVerifier(): void { $this->assertInstanceOf( @@ -39,6 +43,7 @@ public function testCanGetCodeChallengeVerifier(): void $this->assertNotEmpty($this->sut()->getAll()); } + public function testReturnsNullForUnsuportedVerifier(): void { $this->assertNull($this->sut()->get('unsuported')); diff --git a/tests/unit/src/Repositories/IssuerStateRepositoryTest.php b/tests/unit/src/Repositories/IssuerStateRepositoryTest.php index 6867d952..57d769ae 100644 --- a/tests/unit/src/Repositories/IssuerStateRepositoryTest.php +++ b/tests/unit/src/Repositories/IssuerStateRepositoryTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; use DateInterval; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -21,13 +22,18 @@ #[CoversClass(IssuerStateRepository::class)] #[UsesClass(IssuerStateEntity::class)] #[UsesClass(IssuerStateEntityFactory::class)] +#[AllowMockObjectsWithoutExpectations] class IssuerStateRepositoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected Helpers $helpers; + protected IssuerStateEntityFactory $entityFactory; + protected IssuerStateRepository $repository; + /** * @throws \Exception */ @@ -46,6 +52,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -65,11 +72,13 @@ protected function setUp(): void ); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_vci_issuer_state', $this->repository->getTableName()); } + public function testGetCacheKeyIsTablePrefixed(): void { $this->assertSame( @@ -78,6 +87,7 @@ public function testGetCacheKeyIsTablePrefixed(): void ); } + public function testCanPersistAndFind(): void { $entity = $this->entityFactory->buildNew(); @@ -95,11 +105,13 @@ public function testCanPersistAndFind(): void $this->assertFalse($foundEntity->isRevoked()); } + public function testFindReturnsNullForUnknownValue(): void { $this->assertNull($this->repository->find('unknown-issuer-state-value')); } + public function testFindValidReturnsEntityForValidValue(): void { $entity = $this->entityFactory->buildNew(); @@ -111,6 +123,7 @@ public function testFindValidReturnsEntityForValidValue(): void ); } + public function testFindValidReturnsNullForExpiredValue(): void { $createdAt = $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT10M')); @@ -125,6 +138,7 @@ public function testFindValidReturnsNullForExpiredValue(): void $this->assertNull($this->repository->findValid($entity->getValue())); } + public function testCanRevoke(): void { $entity = $this->entityFactory->buildNew(); @@ -138,6 +152,7 @@ public function testCanRevoke(): void $this->assertNull($this->repository->findValid($entity->getValue())); } + public function testCanRemoveInvalid(): void { $validEntity = $this->entityFactory->buildNew(); diff --git a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php index 9191a7be..1dc80686 100644 --- a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php +++ b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; use DateInterval; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -22,13 +23,18 @@ #[CoversClass(PushedAuthorizationRequestRepository::class)] #[UsesClass(PushedAuthorizationRequestEntity::class)] #[UsesClass(PushedAuthorizationRequestEntityFactory::class)] +#[AllowMockObjectsWithoutExpectations] class PushedAuthorizationRequestRepositoryTest extends TestCase { protected MockObject $moduleConfigMock; + protected Helpers $helpers; + protected PushedAuthorizationRequestEntityFactory $entityFactory; + protected PushedAuthorizationRequestRepository $repository; + /** * @throws \Exception */ @@ -47,6 +53,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -66,11 +73,13 @@ protected function setUp(): void ); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_par', $this->repository->getTableName()); } + public function testCanPersistAndFind(): void { $parameters = ['client_id' => 'client123', 'response_type' => 'code']; @@ -91,6 +100,7 @@ public function testCanPersistAndFind(): void $this->assertFalse($foundEntity->isConsumed()); } + public function testFindReturnsNullForUnknownRequestUri(): void { $this->assertNull( @@ -98,6 +108,7 @@ public function testFindReturnsNullForUnknownRequestUri(): void ); } + public function testFindValidReturnsEntityForValidRequestUri(): void { $entity = $this->entityFactory->fromData('client123', []); @@ -109,6 +120,7 @@ public function testFindValidReturnsEntityForValidRequestUri(): void ); } + public function testFindValidReturnsNullForExpiredRequestUri(): void { $entity = $this->entityFactory->fromData( @@ -121,6 +133,7 @@ public function testFindValidReturnsNullForExpiredRequestUri(): void $this->assertNull($this->repository->findValid($entity->getRequestUri())); } + public function testFindValidReturnsNullForConsumedRequestUri(): void { $entity = $this->entityFactory->fromData('client123', []); @@ -131,6 +144,7 @@ public function testFindValidReturnsNullForConsumedRequestUri(): void $this->assertNull($this->repository->findValid($entity->getRequestUri())); } + public function testConsumeReturnsTrueOnlyOnce(): void { $entity = $this->entityFactory->fromData('client123', []); @@ -141,6 +155,7 @@ public function testConsumeReturnsTrueOnlyOnce(): void $this->assertFalse($this->repository->consume($entity->getRequestUri())); } + public function testConsumeReturnsFalseForUnknownRequestUri(): void { $this->assertFalse( @@ -148,6 +163,7 @@ public function testConsumeReturnsFalseForUnknownRequestUri(): void ); } + public function testCanRemoveExpired(): void { $expiredEntity = $this->entityFactory->fromData( @@ -168,6 +184,7 @@ public function testCanRemoveExpired(): void ); } + protected function repositoryWithCache(MockObject $protocolCacheMock): PushedAuthorizationRequestRepository { return new PushedAuthorizationRequestRepository( @@ -179,6 +196,7 @@ protected function repositoryWithCache(MockObject $protocolCacheMock): PushedAut ); } + public function testPersistStoresEntityInCache(): void { $entity = $this->entityFactory->fromData('client123', []); @@ -194,6 +212,7 @@ public function testPersistStoresEntityInCache(): void $this->repositoryWithCache($protocolCacheMock)->persist($entity); } + public function testFindCanReturnEntityFromCache(): void { // Note: this entity is intentionally not persisted to database, so a successful find proves the @@ -210,6 +229,7 @@ public function testFindCanReturnEntityFromCache(): void $this->assertSame(['response_type' => 'code'], $foundEntity->getParameters()); } + public function testFindCachesEntityResolvedFromDatabase(): void { $entity = $this->entityFactory->fromData('client123', []); @@ -230,6 +250,7 @@ public function testFindCachesEntityResolvedFromDatabase(): void ); } + public function testConsumeInvalidatesCache(): void { $entity = $this->entityFactory->fromData('client123', []); diff --git a/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php b/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php index 3aa7ee5d..49133859 100644 --- a/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php +++ b/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php @@ -8,6 +8,7 @@ use DateTimeZone; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -25,20 +26,31 @@ /** * @covers \SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository */ +#[AllowMockObjectsWithoutExpectations] class RefreshTokenRepositoryTest extends TestCase { - final public const CLIENT_ID = 'refresh_token_client_id'; - final public const USER_ID = 'refresh_token_user_id'; - final public const ACCESS_TOKEN_ID = 'refresh_token_access_token_id'; - final public const REFRESH_TOKEN_ID = 'refresh_token_id'; - final public const AUTH_CODE_ID = 'auth_code_id'; + final public const string CLIENT_ID = 'refresh_token_client_id'; + + final public const string USER_ID = 'refresh_token_user_id'; + + final public const string ACCESS_TOKEN_ID = 'refresh_token_access_token_id'; + + final public const string REFRESH_TOKEN_ID = 'refresh_token_id'; + + final public const string AUTH_CODE_ID = 'auth_code_id'; + protected RefreshTokenRepository $repository; + protected MockObject $accessTokenMock; + protected MockObject $accessTokenRepositoryMock; + protected MockObject $refreshTokenEntityFactoryMock; + protected MockObject $refreshTokenEntityMock; + /** * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException * @throws \SimpleSAML\Error\Error @@ -60,6 +72,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); @@ -81,11 +94,13 @@ protected function setUp(): void ); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_refresh_token', $this->repository->getTableName()); } + /** * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -103,9 +118,11 @@ public function testAddAndFound(): void $this->refreshTokenEntityFactoryMock->expects($this->once()) ->method('fromState') - ->with($this->callback(function (array $state): bool { - return $state['id'] === self::REFRESH_TOKEN_ID; - }))->willReturn($refreshToken); + ->with( + $this->callback( + fn(array $state): bool => $state['id'] === self::REFRESH_TOKEN_ID, + ), + )->willReturn($refreshToken); $this->accessTokenRepositoryMock->method('findById')->willReturn($this->accessTokenMock); $foundRefreshToken = $this->repository->findById(self::REFRESH_TOKEN_ID); @@ -113,6 +130,7 @@ public function testAddAndFound(): void $this->assertEquals($refreshToken, $foundRefreshToken); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -123,6 +141,7 @@ public function testAddAndNotFound(): void $this->assertNull($notFoundRefreshToken); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -134,9 +153,7 @@ public function testRevokeToken(): void $this->refreshTokenEntityMock->expects($this->once())->method('revoke'); $this->refreshTokenEntityFactoryMock->expects($this->atLeastOnce()) ->method('fromState') - ->with($this->callback(function (array $state): bool { - return $state['id'] === self::REFRESH_TOKEN_ID; - })) + ->with($this->callback(fn(array $state): bool => $state['id'] === self::REFRESH_TOKEN_ID)) ->willReturnOnConsecutiveCalls($this->refreshTokenEntityMock, $revokedRefreshTokenMock); $this->repository->revokeRefreshToken(self::REFRESH_TOKEN_ID); @@ -145,6 +162,7 @@ public function testRevokeToken(): void $this->assertTrue($isRevoked); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -155,6 +173,7 @@ public function testErrorRevokeInvalidToken(): void $this->repository->revokeRefreshToken('notoken'); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -165,6 +184,7 @@ public function testErrorCheckIsRevokedInvalidToken(): void $this->repository->isRefreshTokenRevoked('notoken'); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -177,6 +197,7 @@ public function testRemoveExpired(): void $this->assertNull($notFoundRefreshToken); } + public function testGetNewRefreshTokenThrows(): void { $this->expectException(RuntimeException::class); @@ -185,6 +206,7 @@ public function testGetNewRefreshTokenThrows(): void $this->repository->getNewRefreshToken(); } + public function testPersistNewRefreshTokenThrowsIfNotRefreshTokenEntity(): void { $this->expectException(OAuthServerException::class); @@ -194,6 +216,7 @@ public function testPersistNewRefreshTokenThrowsIfNotRefreshTokenEntity(): void $this->repository->persistNewRefreshToken($oAuthRefreshTokenEntity); } + public function testCanRevokeByAuthCodeId(): void { $refreshToken = new RefreshTokenEntity( @@ -207,9 +230,9 @@ public function testCanRevokeByAuthCodeId(): void $this->refreshTokenEntityFactoryMock->expects($this->once()) ->method('fromState') - ->with($this->callback(function (array $state): bool { - return $state['id'] === self::REFRESH_TOKEN_ID; - }))->willReturn($this->refreshTokenEntityMock); + ->with( + $this->callback(fn(array $state): bool => $state['id'] === self::REFRESH_TOKEN_ID), + )->willReturn($this->refreshTokenEntityMock); $this->accessTokenRepositoryMock->method('findById')->willReturn($this->accessTokenMock); diff --git a/tests/unit/src/Repositories/ScopeRepositoryTest.php b/tests/unit/src/Repositories/ScopeRepositoryTest.php index 8372cd43..2170624f 100644 --- a/tests/unit/src/Repositories/ScopeRepositoryTest.php +++ b/tests/unit/src/Repositories/ScopeRepositoryTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; use League\OAuth2\Server\Entities\ClientEntityInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Entities\ScopeEntity; @@ -16,6 +17,7 @@ /** * @covers \SimpleSAML\Module\oidc\Repositories\ScopeRepository */ +#[AllowMockObjectsWithoutExpectations] class ScopeRepositoryTest extends TestCase { public static function setUpBeforeClass(): void @@ -34,6 +36,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + /** * @throws \Exception */ @@ -51,6 +54,7 @@ public function testGetScopeEntityByIdentifier(): void $this->assertEquals($expected, $scope); } + /** * @throws \Exception */ @@ -61,6 +65,7 @@ public function testGetUnknownScope(): void $this->assertNull($scopeRepository->getScopeEntityByIdentifier('none')); } + /** * @throws \Exception */ @@ -81,6 +86,7 @@ public function testFinalizeScopes(): void $this->assertEquals($expectedScopes, $finalizedScopes); } + public function testFinalizeScopesReturnsEmptyIfNotClientEntity(): void { $scopeRepository = new ScopeRepository(new ModuleConfig(), new ScopeEntityFactory()); diff --git a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php index 386d8f65..01e98289 100644 --- a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use DateTimeZone; use PDOStatement; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -20,6 +21,7 @@ use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; #[CoversClass(StatusAuditRepository::class)] +#[AllowMockObjectsWithoutExpectations] class StatusAuditRepositoryTest extends TestCase { protected const string CREDENTIAL_ID_HASH = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; @@ -34,10 +36,14 @@ class StatusAuditRepositoryTest extends TestCase */ protected const int MAX_BOUND_VARIABLES = 999; + protected MockObject $moduleConfigMock; + protected Helpers $helpers; + protected StatusAuditRepository $repository; + /** * @throws \Exception */ @@ -59,6 +65,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -74,11 +81,13 @@ protected function setUp(): void Database::getInstance()->write(sprintf('DELETE FROM %s', $this->repository->getTableName())); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_status_audit', $this->repository->getTableName()); } + /** * @return array> */ @@ -89,6 +98,7 @@ protected function readRows(): array ->fetchAll(); } + /** * @throws \Exception */ @@ -116,6 +126,7 @@ public function testRecordsATransition(): void $this->assertSame(StatusChangeSourceEnum::Api->value, $rows[0]['source']); } + /** * A trail whose rows overwrite each other is not a trail. Every change against the same credential * has to survive alongside the ones before it. @@ -149,6 +160,7 @@ public function testKeepsEveryTransitionForTheSameCredential(): void $this->assertNotSame($rows[0]['id'], $rows[1]['id']); } + /** * A scheduled task has no human or API principal behind it, and inventing one would be worse than * recording that there was none. @@ -169,6 +181,7 @@ public function testRecordsAnUnattendedChangeWithNoActor(): void $this->assertNull($this->readRows()[0]['actor_ref']); } + /** * @throws \Exception */ @@ -193,6 +206,7 @@ public function testStoresTheMomentInUtc(): void ); } + /** * Every row needs its own identifier, and they are generated rather than handed out by the * database, so a collision would silently replace an earlier record of a change. @@ -219,6 +233,7 @@ public function testGivesEachRowADistinctIdentifier(): void $this->assertSame($identifiers, array_unique($identifiers)); } + /** * @throws \Exception */ @@ -236,6 +251,7 @@ protected function recordAt(string $createdAt, int $idx = 0): void ); } + /** * @throws \Exception */ @@ -257,6 +273,7 @@ public function testRemovesRowsOlderThanTheCutOff(): void $this->assertSame(2, (int)$rows[0]['idx']); } + /** * @throws \Exception */ @@ -274,6 +291,7 @@ public function testRemovesNoMoreThanTheGivenNumberOfRows(): void $this->assertSame(0, $this->repository->removeOlderThan($cutOff, 2)); } + /** * @throws \Exception */ @@ -290,6 +308,7 @@ public function testRemovesNothingWhenEveryRowIsWithinRetention(): void $this->assertCount(1, $this->readRows()); } + /** * The cut-off is compared against a column which carries no timezone and is written in UTC, so one * handed in on another scale would prune either too much or too little by the size of the offset. @@ -310,6 +329,7 @@ public function testComparesTheCutOffInUtc(): void $this->assertCount(1, $this->readRows()); } + /** * The tests above run against a real SQLite, which has allowed 32766 bound variables since 3.32, so * a delete naming more identifiers than an older build accepts passes there regardless. Counting diff --git a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php index 77c12bc5..59f89a64 100644 --- a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use PDOStatement; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,6 +23,7 @@ use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; #[CoversClass(StatusListEntryRepository::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListEntryRepositoryTest extends TestCase { protected const string LIST_ID = 'a-status-list-id'; @@ -40,12 +42,18 @@ class StatusListEntryRepositoryTest extends TestCase */ protected const int MAX_BOUND_VARIABLES = 999; + protected MockObject $moduleConfigMock; + protected Helpers $helpers; + protected StatusListEntryRepository $repository; + protected StatusListRepository $statusListRepository; + protected int $itemsPerPage = 20; + /** * @throws \Exception */ @@ -67,6 +75,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + /** * @throws \Exception */ @@ -101,11 +110,13 @@ protected function setUp(): void Database::getInstance()->write(sprintf('DELETE FROM %s', $this->statusListRepository->getTableName())); } + protected function setItemsPerPage(int $itemsPerPage): void { $this->itemsPerPage = $itemsPerPage; } + /** * The lane defaults to the non-expiring one because allocate() below defaults to no expiry, and * allocation is refused when the two disagree. A test which gives its credentials an expiry has to @@ -140,6 +151,7 @@ protected function createList( $this->statusListRepository->activate($id); } + /** * @throws \Exception */ @@ -163,6 +175,7 @@ protected function allocate( ); } + /** * @param \SimpleSAML\Module\oidc\StatusList\Values\StatusListEntryRecord[] $entries * @return string[] @@ -175,6 +188,7 @@ protected function credentialIdsOf(array $entries): array ); } + /** * Every index exists as a row from the moment a list is created, so a listing which did not filter * on allocation would show tens of thousands of things nobody was ever issued. @@ -192,6 +206,7 @@ public function testListsOnlyAllocatedEntries(): void $this->assertSame(['urn:vc:one'], $this->credentialIdsOf($page['items'])); } + /** * @throws \Exception */ @@ -207,6 +222,7 @@ public function testListsNewestFirst(): void ); } + /** * A batch issuance stamps the same moment on every credential in it. An order which left those * rows free to come back in any sequence would show one of them twice and another not at all as an @@ -236,6 +252,7 @@ public function testPagesThroughEntriesIssuedAtTheSameMomentWithoutRepeatingOrLo $this->assertSame(['urn:vc:0', 'urn:vc:1', 'urn:vc:2', 'urn:vc:3', 'urn:vc:4'], $seen); } + /** * @throws \Exception */ @@ -256,6 +273,7 @@ public function testReportsThePageCount(): void $this->assertCount(2, $page['items']); } + /** * A page number out of range is a bookmark or a typo, not something to answer with an empty table. * @@ -271,6 +289,7 @@ public function testClampsThePageToWhatExists(): void $this->assertSame(1, $this->repository->findAllocatedPaginated(-5)['currentPage']); } + /** * @throws \Exception */ @@ -290,6 +309,7 @@ public function testFindsByCredentialIdentifier(): void $this->assertSame(['urn:vc:two'], $this->credentialIdsOf($page['items'])); } + /** * Every credential issued to one person, which is what a lost device or a leaver comes down to. * @@ -311,6 +331,7 @@ public function testFindsEveryCredentialOfOneSubject(): void $this->assertSame(['urn:vc:one', 'urn:vc:two'], $found); } + /** * One box, both stored forms of what was typed, either matching being a hit. * @@ -340,6 +361,7 @@ public function testMatchesEitherStoredFormOfTheSearchTerm(): void ); } + /** * @throws \Exception */ @@ -358,6 +380,7 @@ public function testFindsNothingWhenNeitherFormMatches(): void $this->assertSame([], $page['items']); } + /** * An unallocated row has no expiry either, and counting those would report every list a deployment * has as permanent from the moment it was created. @@ -382,6 +405,7 @@ public function testCountsOnlyListsHoldingAnAllocatedCredentialWhichNeverExpires $this->assertSame(1, $this->repository->countNeverRetiringLists()); } + /** * @throws \Exception */ @@ -397,6 +421,7 @@ public function testCountsEachListOnceHoweverManyPermanentCredentialsItHolds(): $this->assertSame(2, $this->repository->countNeverRetiringLists()); } + /** * @return array */ @@ -418,6 +443,7 @@ protected function readEntry(int $idx, string $statusListId = self::LIST_ID): ar return $rows[0]; } + /** * The four linkage columns are one fact, so they go together. Keeping any of them would leave a row * which still says somebody was issued a credential while claiming not to know which one. @@ -445,6 +471,7 @@ public function testClearsTheWholeLinkageOfAnExpiredCredential(): void $this->assertNull($entry['subject_ref']); } + /** * What is kept is what the published token is built from, and what stops the index being handed out * to a second credential. @@ -474,6 +501,7 @@ public function testKeepsTheIndexItsStatusAndItsExpiryWhenClearingLinkage(): voi $this->assertNotEmpty($entry['allocated']); } + /** * @throws \Exception */ @@ -492,6 +520,7 @@ public function testLeavesCredentialsWhichHaveNotExpiredAlone(): void $this->assertSame('urn:vc:live', $this->readEntry(0)['credential_id']); } + /** * The guard which makes the expiry lane an invariant rather than a convention. The lane it compares * against is derived inside allocate() from the expiry being written, so a caller cannot arrange for @@ -521,6 +550,7 @@ public function testRefusesToAllocateACredentialWithNoExpiryIntoAnExpiringList() $this->assertNull($this->readEntry(0)['credential_id']); } + /** * The other direction, which harms nothing on its own -- a non-expiring list is never retired * whatever it holds -- but is the same defect seen from the other side, and is what the mismatch @@ -547,6 +577,7 @@ public function testRefusesToAllocateAnExpiringCredentialIntoANonExpiringList(): $this->assertEmpty($this->readEntry(0)['allocated']); } + /** * @throws \Exception */ @@ -561,6 +592,7 @@ public function testCountsNoLaneMismatchesWhenEveryListHoldsWhatItsLaneSays(): v $this->assertSame(0, $this->repository->countLaneMismatches()); } + /** * Written past the guard on purpose, since nothing in the module can produce this state any more. * The monitor exists for the case where something did anyway -- a hand-edited row, a restore from @@ -587,6 +619,7 @@ public function testCountsALaneMismatchInEitherDirection(): void $this->assertSame(2, $this->repository->countLaneMismatches()); } + /** * Sets an entry's expiry directly, bypassing the lane guard in allocate(), so that a state the * module refuses to create can be put in front of the monitor which looks for it. @@ -608,6 +641,7 @@ protected function forceExpiry(string $statusListId, int $idx, ?string $expiresA ); } + /** * A credential without an expiry is one which can be presented at any point in the future, so the * linkage which makes it revocable has to outlive every cut-off. @@ -623,6 +657,7 @@ public function testNeverClearsTheLinkageOfACredentialWithoutAnExpiry(): void $this->assertSame('urn:vc:permanent', $this->readEntry(0)['credential_id']); } + /** * @throws \Exception */ @@ -648,6 +683,7 @@ public function testClearsNoMoreThanTheGivenNumberOfLinkages(): void $this->assertSame(0, $this->repository->clearExpiredLinkage($now, 2)); } + /** * An allocated row whose credential has expired names no credential any more, so there is nothing an * administrator could ask about it or do to it. @@ -674,6 +710,7 @@ public function testStopsListingEntriesWhoseLinkageHasBeenCleared(): void $this->assertSame(['urn:vc:live'], $this->credentialIdsOf($page['items'])); } + protected function countEntriesOf(string $statusListId): int { $rows = Database::getInstance()->readPrimary( @@ -687,6 +724,7 @@ protected function countEntriesOf(string $statusListId): int return (int)$rows[0]['entry_total']; } + /** * @throws \Exception */ @@ -703,6 +741,7 @@ public function testRemovesRetiredEntriesInBoundedRuns(): void $this->assertSame(0, $this->countEntriesOf(self::LIST_ID)); } + /** * @throws \Exception */ @@ -717,6 +756,7 @@ public function testRemovesEntriesOfOneListOnly(): void $this->assertSame(self::CAPACITY, $this->countEntriesOf(self::OTHER_LIST_ID)); } + /** * A repository whose statements are collected instead of run. * @@ -761,6 +801,7 @@ function (string $statement, array $params = []) use (&$bindings): int { return new StatusListEntryRepository($this->moduleConfigMock, $databaseMock, null, $this->helpers); } + /** * @throws \Exception */ @@ -790,6 +831,7 @@ public function testSeedsInStatementsEveryDriverAccepts(): void $this->assertSame(range(0, 1199), $seeded); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Repositories/StatusListRepositoryTest.php b/tests/unit/src/Repositories/StatusListRepositoryTest.php index 69ff797e..a5219cda 100644 --- a/tests/unit/src/Repositories/StatusListRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusListRepositoryTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -25,6 +26,7 @@ * being allocated into it any more. */ #[CoversClass(StatusListRepository::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListRepositoryTest extends TestCase { protected const string LIST_ID = 'a-status-list-id'; @@ -37,11 +39,16 @@ class StatusListRepositoryTest extends TestCase protected const int CAPACITY = 8; + protected MockObject $moduleConfigMock; + protected Helpers $helpers; + protected StatusListRepository $repository; + protected StatusListEntryRepository $entryRepository; + /** * @throws \Exception */ @@ -63,6 +70,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -86,6 +94,7 @@ protected function setUp(): void Database::getInstance()->write(sprintf('DELETE FROM %s', $this->repository->getTableName())); } + /** * @throws \Exception */ @@ -115,6 +124,7 @@ protected function createList( $this->repository->activate($id); } + /** * Deactivation is stamped with the moment it happened, which is now, and the retirement candidate * query looks for lists deactivated before a cut-off. Backdating the column is how a test says a @@ -133,6 +143,7 @@ protected function backdateDeactivation(string $id, string $deactivatedAt): void ); } + /** * A moment far enough ahead that any expiry a test sets is behind it, for the cases which are not * about the expiry guard itself. @@ -142,6 +153,7 @@ protected function spentBefore(): DateTimeImmutable return new DateTimeImmutable('2099-01-01 00:00:00'); } + /** * One combination the current configuration would allocate into. */ @@ -153,6 +165,7 @@ protected function target( return new StatusListAllocationTarget($poolId, $policyFingerprint, $expiryLane); } + /** * A pool which has stopped using one of the two lanes leaves its list in the other one active and * reachable by nothing: the policy fingerprint does not change when a credential configuration gains @@ -179,6 +192,7 @@ public function testDeactivatesListsInALaneThePoolNoLongerAllocatesInto(): void $this->assertInstanceOf(DateTimeImmutable::class, $statusList?->getDeactivatedAt()); } + /** * The same transition the other way round, which is what giving a pool's last configuration a * lifetime looks like. @@ -199,6 +213,7 @@ public function testDeactivatesANonExpiringListOnceThePoolOnlyIssuesExpiringCred $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isActive()); } + /** * The whole point, end to end: two lists of one pool, one per lane, each holding the kind of * credential its lane is for. Both have stopped accepting allocations and both have waited out the @@ -254,6 +269,7 @@ public function testRetiresTheExpiringListAndKeepsTheNonExpiringOne(): void $this->assertFalse($this->repository->findByIdOnPrimary(self::OTHER_LIST_ID)?->isRetired()); } + /** * The other half of the same rule, and the one which would turn a mixed pool into a rotation loop if * it were got wrong: while a pool allocates into both lanes, both of its lists are current and @@ -279,6 +295,7 @@ public function testLeavesBothLanesOfAMixedPoolActive(): void $this->assertTrue($this->repository->findByIdOnPrimary(self::OTHER_LIST_ID)?->isActive()); } + /** * Both lanes of one pool and policy can hold the same generation, since that is the scope the * uniqueness is declared over. Two lists sharing a generation across lanes is normal, not a clash. @@ -308,6 +325,7 @@ public function testAllowsTheSameGenerationInEachLane(): void ); } + /** * The counter is read over the same scope the unique constraint covers, so a list under a different * policy -- during a signing key rotation, say -- does not raise the generation a request in this @@ -330,6 +348,7 @@ public function testCountsGenerationsSeparatelyForEachPolicy(): void ); } + /** * @throws \Exception */ @@ -349,6 +368,7 @@ public function testOffersOnlyListsOfTheRequestedLaneForAllocation(): void $this->assertSame(StatusListExpiryLaneEnum::Expiring, $expiring[0]->getExpiryLane()); } + /** * A list being seeded in the other lane is not something a request in this one may stand down for: * it could never allocate into it, so it would give up its own list and find nothing to adopt. @@ -385,6 +405,7 @@ public function testDoesNotOfferListsBeingPreparedInAnotherLane(): void ); } + /** * Retirement candidates are chosen by what a list holds, not by its lane, and this is the case which * makes that matter: a non-expiring list which was created and never allocated into names no @@ -406,6 +427,7 @@ public function testOffersADeactivatedNonExpiringListWhichHoldsNothing(): void ); } + /** * A list is only ever selected for allocation while its pool and policy fingerprint match the * current configuration, so one created under a policy which has since changed is unreachable. It @@ -426,6 +448,7 @@ public function testDeactivatesListsCreatedUnderASupersededPolicy(): void $this->assertInstanceOf(DateTimeImmutable::class, $statusList?->getDeactivatedAt()); } + /** * @throws \Exception */ @@ -437,6 +460,7 @@ public function testLeavesListsOfTheCurrentPolicyActive(): void $this->assertTrue($this->repository->findByIdOnPrimary(self::LIST_ID)?->isActive()); } + /** * @throws \Exception */ @@ -448,6 +472,7 @@ public function testDeactivatesListsOfAPoolWhichIsNoLongerConfigured(): void $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isActive()); } + /** * @throws \Exception */ @@ -459,6 +484,7 @@ public function testDeactivatesEverythingWhenNoPoolIsConfigured(): void $this->assertSame(2, $this->repository->deactivateSuperseded([])); } + /** * A list is created inactive and stays that way while its entries are seeded. Stamping one of those * as deactivated would move it out of the path which deletes an abandoned seed and into the one @@ -489,6 +515,7 @@ public function testLeavesListsWhichAreStillBeingSeededAlone(): void $this->assertNull($this->repository->findByIdOnPrimary(self::LIST_ID)?->getDeactivatedAt()); } + /** * @throws \Exception */ @@ -504,6 +531,7 @@ public function testFindsListsDeactivatedBeforeTheCutOff(): void ); } + /** * @throws \Exception */ @@ -518,6 +546,7 @@ public function testDoesNotOfferListsDeactivatedTooRecently(): void ); } + /** * @throws \Exception */ @@ -531,6 +560,7 @@ public function testDoesNotOfferListsWhichAreStillActive(): void ); } + /** * Inactive with no deactivation stamp is a list whose entries are still being seeded, or one whose * seeding was abandoned. Neither is retired; an abandoned one is deleted outright. @@ -562,6 +592,7 @@ public function testDoesNotOfferListsWhichWereNeverOpened(): void ); } + /** * A list holding a credential without an expiry is not waiting for anything -- it can never be * retired. Leaving it among the candidates would let a deployment with enough of them fill every @@ -592,6 +623,7 @@ public function testDoesNotOfferListsWhichCanNeverBeRetired(): void ); } + /** * Unallocated entries have no expiry either, and there is one for every index of the list from the * moment it is created, so an exclusion which did not filter on allocation would rule out every @@ -621,6 +653,7 @@ public function testStillOffersAListWhoseUnusedIndicesHaveNoExpiry(): void ); } + /** * @throws \Exception */ @@ -637,6 +670,7 @@ public function testDoesNotOfferListsWhichAreAlreadyRetired(): void ); } + /** * Retiring a list takes it out of the set being paged through, so an offset would step over exactly * as many unexamined lists as were retired. @@ -660,6 +694,7 @@ public function testPagesRetirementCandidatesByCursor(): void $this->assertSame(['list-c'], $this->repository->findRetirementCandidates($cutOff, 2, 'list-b')); } + /** * @throws \Exception */ @@ -686,6 +721,7 @@ public function testRetirementStampsTheListAndGivesBackItsToken(): void $this->assertSame('', $statusList?->getSignedTokenContentHash()); } + /** * The counter is what keeps a signer which is mid-flight from publishing a token onto a list which * has just been retired out from under it. @@ -707,6 +743,7 @@ public function testRetirementMovesTheInvalidationCounter(): void ); } + /** * @throws \Exception */ @@ -718,6 +755,7 @@ public function testRefusesToRetireAListWhichIsStillActive(): void $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isRetired()); } + /** * Of several workers deciding at the same moment, only one should report having retired it. * @@ -732,6 +770,7 @@ public function testOnlyTheFirstCallRetiresAList(): void $this->assertFalse($this->repository->retire(self::LIST_ID, $this->spentBefore())); } + /** * @throws \Exception */ @@ -749,6 +788,7 @@ protected function allocateEntry(int $idx, string $credentialId, ?DateTimeImmuta ); } + /** * The whole point of testing the expiry inside the retiring statement: a caller which read the * entries, decided they had all expired, and retired the list in a second statement would leave a @@ -768,6 +808,7 @@ public function testRefusesToRetireAListWhichStillHoldsALiveCredential(): void $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isRetired()); } + /** * @throws \Exception */ @@ -782,6 +823,7 @@ public function testRetiresAListWhoseCredentialsHaveAllExpired(): void ); } + /** * @throws \Exception */ @@ -796,6 +838,7 @@ public function testNeverRetiresAListHoldingACredentialWithoutAnExpiry(): void ); } + /** * Every index exists as a row from the moment the list is created and an unallocated one has no * expiry, so a guard which did not filter on allocation would refuse to retire any list at all. @@ -813,6 +856,7 @@ public function testTheUnusedIndicesOfAListDoNotKeepItFromRetiring(): void ); } + /** * @throws \Exception */ @@ -826,6 +870,7 @@ public function testFindsRetiredListsWhichStillHaveEntries(): void $this->assertSame([self::LIST_ID], $this->repository->findRetiredWithEntries(10, $this->spentBefore())); } + /** * Removing the entries is bounded, so the same list is found again by run after run. Once it has * none left it has to drop out, otherwise every list a deployment ever retired would be re-examined @@ -844,6 +889,7 @@ public function testStopsOfferingARetiredListOnceItsEntriesAreGone(): void $this->assertSame([], $this->repository->findRetiredWithEntries(10, $this->spentBefore())); } + /** * Retirement can not be serialised against an issuance which was already in flight, so a credential * can in principle be written into a list just after it was retired. Retirement alone leaves that @@ -865,6 +911,7 @@ public function testDoesNotOfferAListRetiredTooRecentlyToPurge(): void ); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Repositories/UserRepositoryTest.php b/tests/unit/src/Repositories/UserRepositoryTest.php index 6e071b2a..dd0fd817 100644 --- a/tests/unit/src/Repositories/UserRepositoryTest.php +++ b/tests/unit/src/Repositories/UserRepositoryTest.php @@ -4,8 +4,11 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; +use DateInterval; use DateTimeImmutable; +use Exception; use PDOStatement; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -23,17 +26,27 @@ /** * @covers \SimpleSAML\Module\oidc\Repositories\UserRepository */ +#[AllowMockObjectsWithoutExpectations] class UserRepositoryTest extends TestCase { protected static UserRepository $repository; + protected Stub $helpersStub; + protected MockObject $userEntityFactoryMock; + protected MockObject $userEntityMock; + protected MockObject $moduleConfigMock; + protected ?MockObject $protocolCacheMock; + protected MockObject $databaseMock; + protected MockObject $pdoStatementMock; + protected Database $database; + protected array $userEntityState = [ 'id' => 'uniqueid', 'claims' => '[]', @@ -41,6 +54,7 @@ class UserRepositoryTest extends TestCase 'created_at' => '2024-11-04 11:07:26', ]; + /** * @throws \Exception */ @@ -68,6 +82,7 @@ protected function setUp(): void $this->protocolCacheMock = $this->createMock(ProtocolCache::class); } + protected function mock( ?ModuleConfig $moduleConfig = null, ?Database $database = null, @@ -90,11 +105,13 @@ protected function mock( ); } + public function testGetTableName(): void { $this->assertSame('phpunit_oidc_user', $this->mock()->getTableName()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -110,9 +127,7 @@ public function testCanAddFindDelete(): void $this->userEntityFactoryMock->expects($this->once()) ->method('fromState') - ->with($this->callback(function (array $state) { - return $state['id'] === 'uniqueid'; - })) + ->with($this->callback(fn(array $state) => $state['id'] === 'uniqueid')) ->willReturn($userEntity); $user = $repository->getUserEntityByIdentifier('uniqueid'); @@ -121,6 +136,7 @@ public function testCanAddFindDelete(): void $this->assertSame($user->getIdentifier(), 'uniqueid'); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -131,6 +147,7 @@ public function testNotFound(): void $this->assertNull($user); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -147,6 +164,7 @@ public function testUpdate(): void $this->assertNotSame($user, $user2); } + public function testCanDelete(): void { $repository = $this->mock(); @@ -157,6 +175,7 @@ public function testCanDelete(): void $this->assertNull($repository->getUserEntityByIdentifier('uniqueid')); } + public function testCanGetWhenUserEntityIsCached(): void { $this->protocolCacheMock->expects($this->once()) @@ -167,9 +186,7 @@ public function testCanGetWhenUserEntityIsCached(): void $this->userEntityFactoryMock->expects($this->once()) ->method('fromState') - ->with($this->callback(function (array $state) { - return $state['id'] === 'uniqueid'; - })) + ->with($this->callback(fn(array $state) => $state['id'] === 'uniqueid')) ->willReturn($this->userEntityMock); $repository = $this->mock( @@ -183,6 +200,7 @@ public function testCanGetWhenUserEntityIsCached(): void ); } + public function testCanGetWhenUserEntityIsNotCached(): void { $this->protocolCacheMock->expects($this->once()) @@ -205,9 +223,7 @@ public function testCanGetWhenUserEntityIsNotCached(): void $this->userEntityFactoryMock->expects($this->once()) ->method('fromState') - ->with($this->callback(function (array $state) { - return $state['id'] === 'uniqueid'; - })) + ->with($this->callback(fn(array $state) => $state['id'] === 'uniqueid')) ->willReturn($this->userEntityMock); $repository = $this->mock( @@ -221,10 +237,11 @@ public function testCanGetWhenUserEntityIsNotCached(): void ); } + public function testWillAddToDatabaseAndCache(): void { $this->moduleConfigMock->method('getProtocolUserEntityCacheDuration') - ->willReturn(new \DateInterval('PT1H')); + ->willReturn(new DateInterval('PT1H')); $this->userEntityMock->expects($this->exactly(2)) ->method('getState') @@ -237,7 +254,7 @@ public function testWillAddToDatabaseAndCache(): void $this->databaseMock->expects($this->once()) ->method('write') ->with( - $this->isType('string'), + $this->isString(), $this->userEntityState, ); @@ -247,10 +264,11 @@ public function testWillAddToDatabaseAndCache(): void )->add($this->userEntityMock); } + public function testWillUpdateDatabaseAndCache(): void { $this->moduleConfigMock->method('getProtocolUserEntityCacheDuration') - ->willReturn(new \DateInterval('PT1H')); + ->willReturn(new DateInterval('PT1H')); $this->userEntityMock->expects($this->exactly(2)) ->method('getState') @@ -263,7 +281,7 @@ public function testWillUpdateDatabaseAndCache(): void $this->databaseMock->expects($this->once()) ->method('write') ->with( - $this->isType('string'), + $this->isString(), $this->userEntityState, ); @@ -273,6 +291,7 @@ public function testWillUpdateDatabaseAndCache(): void )->update($this->userEntityMock); } + public function testWillDeleteFromDatabaseAndCache(): void { $this->userEntityMock->expects($this->exactly(2)) @@ -287,9 +306,7 @@ public function testWillDeleteFromDatabaseAndCache(): void ->method('write') ->with( $this->stringContains('DELETE'), - $this->callback(function (array $params) { - return $params['id'] === 'uniqueid'; - }), + $this->callback(fn(array $params) => $params['id'] === 'uniqueid'), ); $this->mock( @@ -298,9 +315,10 @@ public function testWillDeleteFromDatabaseAndCache(): void )->delete($this->userEntityMock); } + public function testGetUserEntityByUserCredentialsThrows(): void { - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('Not supported'); $this->mock()->getUserEntityByUserCredentials( diff --git a/tests/unit/src/Server/Associations/RelyingPartyAssociationTest.php b/tests/unit/src/Server/Associations/RelyingPartyAssociationTest.php index 937a7a46..261101a9 100644 --- a/tests/unit/src/Server/Associations/RelyingPartyAssociationTest.php +++ b/tests/unit/src/Server/Associations/RelyingPartyAssociationTest.php @@ -4,19 +4,25 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\Associations; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Server\Associations\RelyingPartyAssociation; /** * @covers \SimpleSAML\Module\oidc\Server\Associations\RelyingPartyAssociation */ +#[AllowMockObjectsWithoutExpectations] class RelyingPartyAssociationTest extends TestCase { protected string $clientId = 'client123'; + protected string $userId = 'user123'; + protected string $sessionId = 'session123'; + protected string $backChannelLogoutUri = 'https//example.org/logout'; + public function testConstruct(): void { $rpAssociation = new RelyingPartyAssociation( diff --git a/tests/unit/src/Server/AuthorizationServerTest.php b/tests/unit/src/Server/AuthorizationServerTest.php index cd8294d6..e6feb068 100644 --- a/tests/unit/src/Server/AuthorizationServerTest.php +++ b/tests/unit/src/Server/AuthorizationServerTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; /** * @covers \SimpleSAML\Module\oidc\Server\AuthorizationServer */ +#[AllowMockObjectsWithoutExpectations] class AuthorizationServerTest extends TestCase { public function testValidateLogoutRequest(): never diff --git a/tests/unit/src/Server/Exceptions/OidcServerExceptionTest.php b/tests/unit/src/Server/Exceptions/OidcServerExceptionTest.php index 925ec0d3..d54f38be 100644 --- a/tests/unit/src/Server/Exceptions/OidcServerExceptionTest.php +++ b/tests/unit/src/Server/Exceptions/OidcServerExceptionTest.php @@ -6,6 +6,7 @@ use Exception; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; @@ -26,10 +27,11 @@ #[CoversClass(OidcServerException::class)] #[UsesClass(QueryResponseMode::class)] #[UsesClass(FragmentResponseMode::class)] +#[AllowMockObjectsWithoutExpectations] class OidcServerExceptionTest extends TestCase { /** - * @param callable():OidcServerException $factory + * @param callable():\SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException $factory */ #[DataProvider('errorProvider')] public function testProducesTheSpecifiedErrorCodeAndStatus( @@ -45,8 +47,9 @@ public function testProducesTheSpecifiedErrorCodeAndStatus( $this->assertNotSame('', $exception->getPayload()['error_description']); } + /** - * @return array + * @return array */ public static function errorProvider(): array { @@ -116,6 +119,7 @@ public static function errorProvider(): array ]; } + public function testNamesTheOffendingParameterInAnInvalidRequest(): void { $description = OidcServerException::invalidRequest('redirect_uri')->getPayload()['error_description']; @@ -123,6 +127,7 @@ public function testNamesTheOffendingParameterInAnInvalidRequest(): void $this->assertStringContainsString('redirect_uri', $description); } + public function testHintsDifferentlyDependingOnWhetherAScopeWasNamed(): void { // "check the scope you sent" is unhelpful when none was sent, so the empty case points at the @@ -134,6 +139,7 @@ public function testHintsDifferentlyDependingOnWhetherAScopeWasNamed(): void $this->assertStringContainsString('default scope', $missing); } + public function testAppendsTheHintToTheErrorDescription(): void { // The hint is what tells an integrator which of several ways the request was wrong. @@ -145,6 +151,7 @@ public function testAppendsTheHintToTheErrorDescription(): void $this->assertStringContainsString('RFC-7636', $description); } + public function testCarriesTheStateBackToTheClientWhenOneWasGiven(): void { // Without the state echoed back, a client cannot match the error to the request it sent. @@ -154,6 +161,7 @@ public function testCarriesTheStateBackToTheClientWhenOneWasGiven(): void $this->assertArrayNotHasKey('state', OidcServerException::accessDenied()->getPayload()); } + public function testStateCanBeSetAndClearedAfterTheFact(): void { $exception = OidcServerException::accessDenied(); @@ -165,6 +173,7 @@ public function testStateCanBeSetAndClearedAfterTheFact(): void $this->assertArrayNotHasKey('state', $exception->getPayload()); } + public function testReportsWhetherItHasARedirectUri(): void { $this->assertFalse(OidcServerException::accessDenied()->hasRedirect()); @@ -178,6 +187,7 @@ public function testReportsWhetherItHasARedirectUri(): void $this->assertFalse($withRedirect->hasRedirect()); } + public function testKeepsTheOriginalExceptionAsThePrevious(): void { $cause = new Exception('the underlying failure'); @@ -185,6 +195,7 @@ public function testKeepsTheOriginalExceptionAsThePrevious(): void $this->assertSame($cause, OidcServerException::forbidden(null, $cause)->getPrevious()); } + public function testRendersAnErrorWithNoRedirectUriAsAJsonBody(): void { $response = OidcServerException::invalidRequest('client_id') @@ -198,6 +209,7 @@ public function testRendersAnErrorWithNoRedirectUriAsAJsonBody(): void $this->assertSame('invalid_request', $body['error']); } + public function testRendersAnErrorWithARedirectUriAsARedirectCarryingTheErrorInTheQuery(): void { $response = OidcServerException::accessDenied(null, 'https://rp.example.org/callback', null, 'the-state') @@ -213,6 +225,7 @@ public function testRendersAnErrorWithARedirectUriAsARedirectCarryingTheErrorInT $this->assertSame('the-state', $query['state']); } + public function testPutsTheErrorInTheFragmentWhenTheCallerAsksForIt(): void { // The implicit and hybrid flows return the response in the fragment, so their errors go there too, @@ -229,6 +242,7 @@ public function testPutsTheErrorInTheFragmentWhenTheCallerAsksForIt(): void $this->assertSame('access_denied', $fragment['error']); } + public function testAnExplicitResponseModeWinsOverTheFragmentFlag(): void { $response = OidcServerException::accessDenied( diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index 1834e0ae..6a7b4e3e 100644 --- a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php @@ -19,6 +19,7 @@ use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use LogicException; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -98,26 +99,44 @@ #[UsesClass(UserEntity::class)] #[UsesClass(Arr::class)] #[UsesClass(QueryResponseMode::class)] +#[AllowMockObjectsWithoutExpectations] class AuthCodeGrantTest extends TestCase { private const string AUTH_CODE_ID = 'auth-code-id'; + private const string CLIENT_ID = 'client-id'; + private const string USER_ID = 'user-id'; + private const string REDIRECT_URI = 'https://rp.example.org/callback'; + private const string STATE = 'opaque-state-value'; + private const string CODE_VERIFIER = 'ZG9uLXQtdXNlLXRoaXMtdmVyaWZpZXItaW4tcHJvZHVjdGlvbg'; + private AuthCodeRepository&MockObject $authCodeRepositoryMock; + private AccessTokenRepositoryInterface&MockObject $accessTokenRepositoryMock; + private RefreshTokenRepositoryInterface&MockObject $refreshTokenRepositoryMock; + private RequestRulesManager&MockObject $requestRulesManagerMock; + private RequestParamsResolver&MockObject $requestParamsResolverMock; + private AccessTokenEntityFactory&MockObject $accessTokenEntityFactoryMock; + private AuthCodeEntityFactory&MockObject $authCodeEntityFactoryMock; + private RefreshTokenIssuer&MockObject $refreshTokenIssuerMock; + private Helpers&MockObject $helpersMock; + private Scope&MockObject $scopeHelperMock; + private LoggerService&MockObject $loggerServiceMock; + private ScopeRepositoryInterface&MockObject $scopeRepositoryMock; private Key $encryptionKey; @@ -131,6 +150,7 @@ class AuthCodeGrantTest extends TestCase /** What the access token factory was last called with, for assertions on values with no other outlet. */ private array $accessTokenFactoryArguments = []; + protected function setUp(): void { $this->authCodeRepositoryMock = $this->createMock(AuthCodeRepository::class); @@ -180,6 +200,7 @@ public function testRejectsTokenRequestWithoutAuthorizationCode(): void ); } + public function testRejectsAuthorizationCodeItCannotDecrypt(): void { // A code encrypted under a different key stands in for any tampered or forged code. The grant must @@ -192,6 +213,7 @@ public function testRejectsAuthorizationCodeItCannotDecrypt(): void ); } + public function testRejectsAuthorizationCodePayloadWithoutIdentifier(): void { $this->authCodeRepositoryMock->expects($this->never())->method('findById'); @@ -202,6 +224,7 @@ public function testRejectsAuthorizationCodePayloadWithoutIdentifier(): void ); } + public function testRejectsAuthorizationCodeThatIsNotInStorage(): void { $this->authCodeRepositoryMock->method('findById')->willReturn(null); @@ -209,6 +232,7 @@ public function testRejectsAuthorizationCodeThatIsNotInStorage(): void $this->assertRejects('invalid_grant', $this->request()); } + public function testRejectsUnexpectedAuthCodeRepositoryType(): void { // The grant is constructed against the league interface but reaches for this module's repository, so @@ -232,6 +256,7 @@ public function testRequiresClientIdFromGenericClient(): void $this->assertRejects('invalid_request', $this->request()); } + public function testRejectsClientIdThatDoesNotMatchTheBoundOne(): void { $this->storedAuthCode(isGeneric: true); @@ -240,6 +265,7 @@ public function testRejectsClientIdThatDoesNotMatchTheBoundOne(): void $this->assertRejects('invalid_grant', $this->request()); } + public function testRequiresRedirectUriFromGenericClient(): void { $this->storedAuthCode(isGeneric: true); @@ -248,6 +274,7 @@ public function testRequiresRedirectUriFromGenericClient(): void $this->assertRejects('invalid_request', $this->request()); } + public function testRejectsRedirectUriThatDoesNotMatchTheBoundOne(): void { $this->storedAuthCode(isGeneric: true); @@ -265,6 +292,7 @@ public function testRejectsClientNotRegisteredForTheAuthorizationCodeGrant(): vo $this->assertRejects('unauthorized_client', $this->request()); } + public function testAcceptsClientThatRegisteredNoGrantTypesAtAll(): void { // An empty list means nothing was registered, not "nothing is allowed" - manually managed and pre-DCR @@ -275,6 +303,7 @@ public function testAcceptsClientThatRegisteredNoGrantTypesAtAll(): void $this->sut()->respondToAccessTokenRequest($this->request(), $this->responseType(), new DateInterval('PT5M')); } + public function testRejectsTokenRequestWithNeitherClientAuthenticationNorPkce(): void { // Nothing proves the caller is the client the code was issued to, so the code must not be redeemable. @@ -296,6 +325,7 @@ public function testRejectsCodeVerifierWhenAuthorizationRequestHadNoCodeChalleng $this->assertRejects('invalid_request', $this->request()); } + public function testRequiresCodeVerifierWhenAuthorizationRequestUsedCodeChallenge(): void { $this->storedAuthCode(); @@ -307,6 +337,7 @@ public function testRequiresCodeVerifierWhenAuthorizationRequestUsedCodeChalleng ); } + public function testRejectsCodeVerifierThatFailsVerification(): void { $this->storedAuthCode(); @@ -318,6 +349,7 @@ public function testRejectsCodeVerifierThatFailsVerification(): void ); } + public function testAcceptsCodeVerifierThatVerifiesAgainstTheStoredChallenge(): void { $this->storedAuthCode(); @@ -333,6 +365,7 @@ public function testAcceptsCodeVerifierThatVerifiesAgainstTheStoredChallenge(): $this->assertSecretsWereNotLogged(self::CODE_VERIFIER); } + public function testRejectsUnsupportedCodeChallengeMethod(): void { $this->storedAuthCode(); @@ -356,6 +389,7 @@ public function testRejectsExpiredAuthorizationCode(): void ); } + public function testRevokesRelatedTokensWhenAuthorizationCodeIsReplayed(): void { // RFC 6749 section 4.1.2: a reused code means the code may be in an attacker's hands, so everything @@ -372,6 +406,7 @@ public function testRevokesRelatedTokensWhenAuthorizationCodeIsReplayed(): void $this->assertRejects('invalid_grant', $this->request()); } + public function testRejectsAuthorizationCodeIssuedToAnotherClient(): void { $this->storedAuthCode(); @@ -382,6 +417,7 @@ public function testRejectsAuthorizationCodeIssuedToAnotherClient(): void ); } + public function testRequiresRedirectUriWhenTheAuthorizationRequestHadOne(): void { $this->storedAuthCode(); @@ -393,6 +429,7 @@ public function testRequiresRedirectUriWhenTheAuthorizationRequestHadOne(): void ); } + public function testRejectsRedirectUriThatDiffersFromTheAuthorizationRequest(): void { $this->storedAuthCode(); @@ -430,6 +467,7 @@ public function testIssuesAccessTokenAndRevokesTheAuthorizationCode(): void $this->assertSame($responseType, $result); } + public function testTakesTheClientFromTheStoredCodeRatherThanFromTheRequest(): void { // The client is authoritatively known from the stored code, so it is predefined as the ClientRule @@ -455,6 +493,7 @@ public function testTakesTheClientFromTheStoredCodeRatherThanFromTheRequest(): v ); } + public function testRedeemsCodeForGenericClientBoundToItsClientIdAndRedirectUri(): void { // A generic (non-registered) client has no credential to authenticate with, so PKCE is what @@ -473,6 +512,7 @@ public function testRedeemsCodeForGenericClientBoundToItsClientIdAndRedirectUri( ); } + public function testCarriesAuthenticationContextFromTheAuthorizationCodeIntoTheResponse(): void { $this->storedAuthCode(); @@ -498,6 +538,7 @@ public function testCarriesAuthenticationContextFromTheAuthorizationCodeIntoTheR ); } + public function testIssuesRefreshTokenOnlyWhenOfflineAccessWasGranted(): void { $this->storedAuthCode(); @@ -517,6 +558,7 @@ public function testIssuesRefreshTokenOnlyWhenOfflineAccessWasGranted(): void $this->sut()->respondToAccessTokenRequest($this->request(), $responseType, new DateInterval('PT5M')); } + public function testDoesNotIssueRefreshTokenWithoutOfflineAccess(): void { $this->storedAuthCode(); @@ -530,6 +572,7 @@ public function testDoesNotIssueRefreshTokenWithoutOfflineAccess(): void $this->sut()->respondToAccessTokenRequest($this->request(), $responseType, new DateInterval('PT5M')); } + public function testDoesNotLogAnyCredentialFromTheTokenRequest(): void { // Every one of these is a credential: the code and the verifier redeem an authorization, the secret @@ -584,6 +627,7 @@ public function testRespondsOnlyToAuthorizationRequestsAskingForACode(): void $this->assertFalse($sut->canRespondToAuthorizationRequest($this->request([]))); } + public function testTreatsARequestAsOidcOnlyWhenItAsksForTheOpenidScope(): void { $sut = $this->sut(); @@ -605,11 +649,13 @@ public function testReturnsAPlainOAuth2RequestWhenItIsNeitherOidcNorVerifiableCr $this->assertNotInstanceOf(AuthorizationRequest::class, $request); } + public function testReturnsAnOidcRequestWhenTheOpenidScopeIsRequested(): void { $this->assertInstanceOf(AuthorizationRequest::class, $this->validatedAuthorizationRequest()); } + public function testReturnsAnOidcRequestForACredentialRequestWithoutTheOpenidScope(): void { // A wallet asking for a credential does not send openid, but still needs the OIDC request type. @@ -623,6 +669,7 @@ public function testReturnsAnOidcRequestForACredentialRequestWithoutTheOpenidSco $this->assertSame(FlowTypeEnum::VciAuthorizationCode, $request->getFlowType()); } + public function testCarriesTheCodeChallengeOntoTheAuthorizationRequestOnlyWhenOneWasSent(): void { $withPkce = $this->validatedAuthorizationRequest( @@ -637,6 +684,7 @@ public function testCarriesTheCodeChallengeOntoTheAuthorizationRequestOnlyWhenOn $this->assertNull($this->validatedAuthorizationRequest()->getCodeChallenge()); } + public function testCarriesTheAuthenticationContextParametersOntoTheAuthorizationRequest(): void { $idTokenHint = $this->createMock(IdTokenHint::class); @@ -665,6 +713,7 @@ public function testCarriesTheAuthenticationContextParametersOntoTheAuthorizatio $this->assertSame('issuer-state-value', $request->getIssuerState()); } + public function testDoesNotLogTheLoginHintValue(): void { // login_hint is routinely an email address or a username, so only its presence may be recorded. @@ -675,6 +724,7 @@ public function testDoesNotLogTheLoginHintValue(): void $this->assertSecretsWereNotLogged($loginHint); } + public function testBindsTheUsedClientIdAndRedirectUriWhenTheClientIsGeneric(): void { // A generic client stands in for many wallets, so the identifiers actually used have to be recorded @@ -688,6 +738,7 @@ public function testBindsTheUsedClientIdAndRedirectUriWhenTheClientIsGeneric(): $this->assertSame(self::REDIRECT_URI, $request->getBoundRedirectUri()); } + public function testDoesNotBindClientIdentifiersForARegisteredClient(): void { $request = $this->validatedAuthorizationRequest(); @@ -696,6 +747,7 @@ public function testDoesNotBindClientIdentifiersForARegisteredClient(): void $this->assertNull($request->getBoundRedirectUri()); } + public function testAddsCredentialConfigurationIdsFromAuthorizationDetailsToTheScopes(): void { $authorizationDetails = [ @@ -732,6 +784,7 @@ public function testRefusesToCompleteAnAuthorizationRequestWithoutThisModulesUse $this->sut()->completeOidcAuthorizationRequest($authorizationRequest); } + public function testRedirectsWithAccessDeniedWhenTheUserDeclinedTheRequest(): void { $authorizationRequest = $this->approvedAuthorizationRequest(); @@ -748,6 +801,7 @@ public function testRedirectsWithAccessDeniedWhenTheUserDeclinedTheRequest(): vo } } + public function testFallsBackToTheClientsRegisteredRedirectUriWhenTheRequestCarriesNone(): void { // The registered URI is the only one that was ever validated, so it is the only safe fallback. @@ -764,6 +818,7 @@ public function testFallsBackToTheClientsRegisteredRedirectUriWhenTheRequestCarr $this->assertStringStartsWith(self::REDIRECT_URI . '?', $this->redirectUriOf($response)); } + public function testIssuesAnAuthorizationCodeAndRedirectsBackWithItAndTheState(): void { $authorizationRequest = $this->approvedAuthorizationRequest(); @@ -778,6 +833,7 @@ public function testIssuesAnAuthorizationCodeAndRedirectsBackWithItAndTheState() $this->assertSame(self::STATE, $query['state'], 'The state must be echoed back untouched.'); } + public function testStampsTheIssuedCodeWithTheFlowItBelongsTo(): void { $verifiableCredentialRequest = $this->approvedAuthorizationRequest(); @@ -796,6 +852,7 @@ public function testStampsTheIssuedCodeWithTheFlowItBelongsTo(): void ); } + public function testRejectsAnUnexpectedAuthCodeRepositoryWhenIssuingACode(): void { $foreignRepository = $this->createMock(OAuth2AuthCodeRepositoryInterface::class); @@ -805,6 +862,7 @@ public function testRejectsAnUnexpectedAuthCodeRepositoryWhenIssuingACode(): voi $this->sut($foreignRepository)->completeOidcAuthorizationRequest($this->approvedAuthorizationRequest()); } + public function testDoesNotLogTheAuthorizationCodeItIssues(): void { $authorizationRequest = $this->approvedAuthorizationRequest(); @@ -815,6 +873,7 @@ public function testDoesNotLogTheAuthorizationCodeItIssues(): void $this->assertSecretsWereNotLogged($query['code']); } + public function testRoutesAnOidcAuthorizationRequestToTheOidcCompletionPath(): void { $authorizationRequest = $this->approvedAuthorizationRequest(); @@ -827,6 +886,7 @@ public function testRoutesAnOidcAuthorizationRequestToTheOidcCompletionPath(): v $this->assertSame(self::STATE, $query['state']); } + public function testUsesTheRegisteredRedirectUriWhenTheClientHasExactlyOne(): void { // A client may register its redirect URI as a bare string rather than a list. @@ -844,6 +904,7 @@ public function testUsesTheRegisteredRedirectUriWhenTheClientHasExactlyOne(): vo ); } + public function testRetriesWithAFreshIdentifierWhenTheGeneratedOneCollides(): void { // Identifiers are random, so a collision is rare but survivable: the grant must try again rather @@ -881,6 +942,7 @@ public function testRetriesWithAFreshIdentifierWhenTheGeneratedOneCollides(): vo ); } + /** * The two halves of the grant have to agree on the shape of the encrypted payload. * @@ -963,6 +1025,7 @@ private function sut(?OAuth2AuthCodeRepositoryInterface $authCodeRepository = nu return $grant; } + /** * Assert that redeeming the code fails with a given OAuth error type. * @@ -991,6 +1054,7 @@ private function assertRejects( $this->fail(sprintf('Expected the token request to be rejected with "%s".', $expectedErrorType)); } + /** * @param array $parsedBody */ @@ -1010,6 +1074,7 @@ private function request(?array $parsedBody = null, bool $withRedirectUri = true return $request; } + /** * A complete token request for the given authorization code payload. * @@ -1031,6 +1096,7 @@ private function requestFor(array $payload, array $extraBody = []): ServerReques )); } + /** * The decrypted contents of an authorization code, as the authorization request half writes them. * @@ -1052,6 +1118,7 @@ private function payload(array $overrides = []): array ); } + /** * @return array */ @@ -1068,6 +1135,7 @@ private function payloadWithChallenge(string $method = 'S256'): array ]); } + /** * @param array $payload */ @@ -1087,6 +1155,7 @@ private function encryptPayload(array $payload, ?Key $key = null): string return $encrypt(json_encode($payload, JSON_THROW_ON_ERROR)); } + /** * Put an authorization code in storage and make the rules answer for the client it was issued to. * @@ -1125,6 +1194,7 @@ private function storedAuthCode( return $authCode; } + /** * What the generic-client branch reads straight off the request rather than through the rules. */ @@ -1144,6 +1214,7 @@ private function resolveRequestParams( ); } + /** * Make the resolver hand back the request body, the way the real one does. * @@ -1159,6 +1230,7 @@ private function resolverReturnsTheRequestBody(RequestParamsResolver&MockObject ); } + private function rulesReturn( ?string $codeVerifier = null, ClientAuthenticationMethodsEnum $authenticationMethod = ClientAuthenticationMethodsEnum::ClientSecretBasic, @@ -1179,6 +1251,7 @@ private function rulesReturn( $this->requestRulesManagerMock->method('check')->willReturn($resultBag); } + /** * Drive validateAuthorizationRequestWithRequestRules() with a full set of rule results. * @@ -1245,6 +1318,7 @@ private function validatedAuthorizationRequest( return $this->sut()->validateAuthorizationRequestWithRequestRules($this->request([]), $incoming); } + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ @@ -1256,6 +1330,7 @@ private function oAuth2AuthorizationRequest(array $scopes): OAuth2AuthorizationR return $request; } + /** * An authorization request in the state the authorization screen leaves it in: a user is attached and * the user approved it. Individual tests take it back apart to cover the paths that do not get here. @@ -1273,6 +1348,7 @@ private function approvedAuthorizationRequest(?ClientEntity $client = null): Aut return $request; } + private function clientMock(bool $isGeneric = false, ?array $grantTypes = null): ClientEntity&MockObject { $client = $this->createMock(ClientEntity::class); @@ -1283,6 +1359,7 @@ private function clientMock(bool $isGeneric = false, ?array $grantTypes = null): return $client; } + private function authCodeEntity(?ClientEntity $client = null, bool $isRevoked = false): AuthCodeEntity { return new AuthCodeEntity( @@ -1298,6 +1375,7 @@ private function authCodeEntity(?ClientEntity $client = null, bool $isRevoked = ); } + /** * Complete the request and report what the auth code factory was called with. * @@ -1324,6 +1402,7 @@ function (...$arguments) use (&$captured): AuthCodeEntity { return $captured; } + private function expectAuthCodeToBeIssued(?ClientEntity $client = null): AuthCodeEntity { $authCode = $this->authCodeEntity($client); @@ -1336,6 +1415,7 @@ private function expectAuthCodeToBeIssued(?ClientEntity $client = null): AuthCod return $authCode; } + /** * The base64url encoded SHA-256 of the shared verifier, per RFC 7636 section 4.2. */ @@ -1344,11 +1424,13 @@ private function codeChallenge(): string return strtr(rtrim(base64_encode(hash('sha256', self::CODE_VERIFIER, true)), '='), '+/', '-_'); } + private function redirectUriOf(AbstractResponseType $response): string { return $response->generateHttpResponse(new Response())->getHeaderLine('location'); } + /** * The query parameters the client is redirected back with. * @@ -1362,6 +1444,7 @@ private function redirectQueryOf(AbstractResponseType $response): array return $query; } + private function expectAccessTokenToBeIssued(): AccessTokenEntity&MockObject { $accessToken = $this->createMock(AccessTokenEntity::class); @@ -1379,8 +1462,9 @@ private function expectAccessTokenToBeIssued(): AccessTokenEntity&MockObject return $accessToken; } + /** - * @return ResponseTypeInterface&MockObject + * @return \League\OAuth2\Server\ResponseTypes\ResponseTypeInterface&\PHPUnit\Framework\MockObject\MockObject */ private function responseType(): MockObject { @@ -1393,6 +1477,7 @@ private function responseType(): MockObject ]); } + private function captureLogs(string $level): void { $this->loggerServiceMock->method($level)->willReturnCallback( @@ -1402,6 +1487,7 @@ function (string|Stringable $message, array $context = []): void { ); } + private function assertSecretsWereNotLogged(string ...$secrets): void { $logs = json_encode($this->logRecords, JSON_THROW_ON_ERROR); diff --git a/tests/unit/src/Server/Grants/ImplicitGrantTest.php b/tests/unit/src/Server/Grants/ImplicitGrantTest.php index 2142c5c8..7c1c4dba 100644 --- a/tests/unit/src/Server/Grants/ImplicitGrantTest.php +++ b/tests/unit/src/Server/Grants/ImplicitGrantTest.php @@ -4,9 +4,12 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\Grants; +use DateInterval; +use Exception; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\ResponseTypes\RedirectResponse; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -26,27 +29,42 @@ use SimpleSAML\OpenID\Core\IdToken; #[CoversClass(ImplicitGrant::class)] +#[AllowMockObjectsWithoutExpectations] class ImplicitGrantTest extends TestCase { protected MockObject $idTokenBuilderMock; - protected \DateInterval $accessTokenTtl1h; + + protected DateInterval $accessTokenTtl1h; + protected MockObject $accessTokenRepositoryMock; + protected MockObject $requestRulesManagerMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $accessTokenEntityFactoryMock; + protected MockObject $scopeRepositoryMock; + protected MockObject $serverRequestMock; + protected MockObject $authorizationRequestMock; + protected MockObject $userEntityMock; + protected MockObject $scopeEntityMock; + protected MockObject $clientEntityMock; + protected MockObject $resultBagMock; + protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->idTokenBuilderMock = $this->createMock(IdTokenBuilder::class); - $this->accessTokenTtl1h = new \DateInterval('PT1H'); + $this->accessTokenTtl1h = new DateInterval('PT1H'); $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class); $this->requestRulesManagerMock = $this->createMock(RequestRulesManager::class); $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -62,9 +80,10 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut( ?IdTokenBuilder $idTokenBuilder = null, - ?\DateInterval $accessTokenTtl = null, + ?DateInterval $accessTokenTtl = null, ?AccessTokenRepositoryInterface $accessTokenRepository = null, ?RequestRulesManager $requestRulesManager = null, ?RequestParamsResolver $requestParamsResolver = null, @@ -97,11 +116,13 @@ protected function sut( return $implicitGrant; } + public function testCanConstruct(): void { $this->assertInstanceOf(ImplicitGrant::class, $this->sut()); } + public function testCanRespondToAuthorizationRequestForIdTokenTokenResponseType(): void { $this->requestParamsResolverMock->expects($this->once()) @@ -111,6 +132,7 @@ public function testCanRespondToAuthorizationRequestForIdTokenTokenResponseType( $this->assertTrue($this->sut()->canRespondToAuthorizationRequest($this->serverRequestMock)); } + public function testCanRespondToAuthorizationRequestForIdTokenResponseType(): void { $this->requestParamsResolverMock->expects($this->once()) @@ -120,6 +142,7 @@ public function testCanRespondToAuthorizationRequestForIdTokenResponseType(): vo $this->assertTrue($this->sut()->canRespondToAuthorizationRequest($this->serverRequestMock)); } + public function testCanRespondToAuthorizationRequestReturnsFalseIfNoClientId(): void { $this->requestParamsResolverMock->expects($this->once()) @@ -129,6 +152,7 @@ public function testCanRespondToAuthorizationRequestReturnsFalseIfNoClientId(): $this->assertFalse($this->sut()->canRespondToAuthorizationRequest($this->serverRequestMock)); } + public function testCanRespondToAuthorizationRequestReturnsFalseForHybridFlow(): void { $this->requestParamsResolverMock->expects($this->once()) @@ -138,9 +162,10 @@ public function testCanRespondToAuthorizationRequestReturnsFalseForHybridFlow(): $this->assertFalse($this->sut()->canRespondToAuthorizationRequest($this->serverRequestMock)); } + public function testCompleteAuthorizationRequestThrowsForNonOidcRequests(): void { - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('Unexpected'); $this->sut()->completeAuthorizationRequest($this->createMock( @@ -148,6 +173,7 @@ public function testCompleteAuthorizationRequestThrowsForNonOidcRequests(): void )); } + public function testCanCompleteAuthorizationRequest(): void { $this->authorizationRequestMock->expects($this->once())->method('getUser') @@ -169,6 +195,7 @@ public function testCanCompleteAuthorizationRequest(): void ); } + /** * The grant forwards the "add claims to ID Token" decision (made by AddClaimsToIdTokenRule and carried on the * authorization request) to the ID Token builder. When it is true, the user's claims are released in the ID @@ -203,6 +230,7 @@ public function testReleasesUserClaimsInIdTokenWhenRequested(): void $this->sut()->completeAuthorizationRequest($this->authorizationRequestMock); } + /** * When the decision is false, the user's claims are not released in the ID Token (they remain available at * the UserInfo endpoint via the issued access token). @@ -236,6 +264,7 @@ public function testDoesNotReleaseUserClaimsInIdTokenWhenNotRequested(): void $this->sut()->completeAuthorizationRequest($this->authorizationRequestMock); } + public function testCanValidateAuthorizationRequestWithRequestRules(): void { $this->markTestIncomplete('RequestRulesManager needs to be refactored so it can be strongly typed.'); diff --git a/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php b/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php index 6eaf7d0b..1b9a4181 100644 --- a/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php @@ -7,6 +7,7 @@ use DateInterval; use DateTimeImmutable; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -36,27 +37,42 @@ #[CoversClass(PreAuthCodeGrant::class)] #[UsesClass(AuthCodeEntity::class)] #[UsesClass(ResultBag::class)] +#[AllowMockObjectsWithoutExpectations] class PreAuthCodeGrantTest extends TestCase { private const string PRE_AUTHORIZED_CODE = 'pre-authorized-code-secret'; + private const string TRANSACTION_CODE = '1234'; + private const string CLIENT_ID = 'wallet-client'; + private AuthCodeRepository&MockObject $authCodeRepositoryMock; + private AccessTokenRepositoryInterface&MockObject $accessTokenRepositoryMock; + private RefreshTokenRepositoryInterface&MockObject $refreshTokenRepositoryMock; + private RequestRulesManager&MockObject $requestRulesManagerMock; + private RequestParamsResolver&MockObject $requestParamsResolverMock; + private AccessTokenEntityFactory&MockObject $accessTokenEntityFactoryMock; + private AuthCodeEntityFactory&MockObject $authCodeEntityFactoryMock; + private RefreshTokenIssuer&MockObject $refreshTokenIssuerMock; + private Helpers&MockObject $helpersMock; + private LoggerService&MockObject $loggerServiceMock; + private ServerRequestInterface&MockObject $requestMock; /** @var array */ private array $logRecords = []; + protected function setUp(): void { $this->authCodeRepositoryMock = $this->createMock(AuthCodeRepository::class); @@ -78,6 +94,7 @@ protected function setUp(): void $this->captureLogs('error'); } + public function testRedeemsPreAuthorizedCodeOnlyAfterAtomicConsumption(): void { $this->configureRequestParameters(self::TRANSACTION_CODE); @@ -120,6 +137,7 @@ public function testRedeemsPreAuthorizedCodeOnlyAfterAtomicConsumption(): void $this->assertSecretsWereNotLogged(self::PRE_AUTHORIZED_CODE, self::TRANSACTION_CODE); } + public function testRejectsReplayBeforeIssuingAnotherAccessToken(): void { $this->configureRequestParameters(null); @@ -147,6 +165,7 @@ public function testRejectsReplayBeforeIssuingAnotherAccessToken(): void $this->assertSecretsWereNotLogged(self::PRE_AUTHORIZED_CODE); } + public function testRejectsInvalidTransactionCodeWithoutConsumingPreAuthorizedCode(): void { $submittedTransactionCode = '9999'; @@ -175,6 +194,7 @@ public function testRejectsInvalidTransactionCodeWithoutConsumingPreAuthorizedCo ); } + public function testTokenPersistenceFailureLeavesPreAuthorizedCodeConsumed(): void { $this->configureRequestParameters(null); @@ -222,6 +242,7 @@ public function testTokenPersistenceFailureLeavesPreAuthorizedCodeConsumed(): vo $this->assertSecretsWereNotLogged(self::PRE_AUTHORIZED_CODE); } + private function sut(): PreAuthCodeGrant { return new PreAuthCodeGrant( @@ -239,6 +260,7 @@ private function sut(): PreAuthCodeGrant ); } + private function preAuthorizedCode(?string $transactionCode = null): AuthCodeEntity { $client = $this->createMock(ClientEntity::class); @@ -256,6 +278,7 @@ private function preAuthorizedCode(?string $transactionCode = null): AuthCodeEnt ); } + private function configureRequestParameters(?string $transactionCode): void { $this->requestParamsResolverMock->expects($this->never())->method('getAllFromRequest'); @@ -270,6 +293,7 @@ private function configureRequestParameters(?string $transactionCode): void ); } + private function captureLogs(string $level): void { $this->loggerServiceMock->method($level)->willReturnCallback( @@ -279,6 +303,7 @@ function (string|Stringable $message, array $context = []): void { ); } + private function assertSecretsWereNotLogged(string ...$secrets): void { $logs = json_encode($this->logRecords, JSON_THROW_ON_ERROR); diff --git a/tests/unit/src/Server/Grants/RefreshTokenGrantTest.php b/tests/unit/src/Server/Grants/RefreshTokenGrantTest.php index 9f8c57ea..66fdf3f6 100644 --- a/tests/unit/src/Server/Grants/RefreshTokenGrantTest.php +++ b/tests/unit/src/Server/Grants/RefreshTokenGrantTest.php @@ -6,10 +6,12 @@ use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use ReflectionMethod; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; @@ -20,15 +22,22 @@ use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; #[CoversClass(RefreshTokenGrant::class)] +#[AllowMockObjectsWithoutExpectations] class RefreshTokenGrantTest extends TestCase { protected MockObject $refreshTokenRepositoryMock; + protected MockObject $accessTokenEntityFactoryMock; + protected MockObject $refreshTokenIssuerMock; + protected MockObject $clientResolverMock; + protected MockObject $serverRequestMock; + protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->refreshTokenRepositoryMock = $this->createMock(RefreshTokenRepositoryInterface::class); @@ -39,6 +48,7 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut(): RefreshTokenGrant { return new RefreshTokenGrant( @@ -50,19 +60,21 @@ protected function sut(): RefreshTokenGrant ); } + /** * @throws \ReflectionException */ protected function callValidateClient(RefreshTokenGrant $grant): ClientEntity { - $method = new \ReflectionMethod(RefreshTokenGrant::class, 'validateClient'); + $method = new ReflectionMethod(RefreshTokenGrant::class, 'validateClient'); - /** @var ClientEntity $client */ + /** @var \SimpleSAML\Module\oidc\Entities\ClientEntity $client */ $client = $method->invoke($grant, $this->serverRequestMock); return $client; } + /** * The refresh grant must authenticate the client via the resolver (which supports private_key_jwt, * client_secret_basic/post and public clients) rather than the league default that requires a client_id @@ -84,6 +96,7 @@ public function testValidateClientResolvesClientWithoutRequiringClientIdParamete $this->assertSame($clientMock, $this->callValidateClient($this->sut())); } + /** * When the client cannot be authenticated the grant must reject the request with an invalid_client error, * not fall back to the league default (which would demand a client_id parameter). diff --git a/tests/unit/src/Server/LogoutHandlers/BackChannelLogoutHandlerTest.php b/tests/unit/src/Server/LogoutHandlers/BackChannelLogoutHandlerTest.php index eee41feb..90681302 100644 --- a/tests/unit/src/Server/LogoutHandlers/BackChannelLogoutHandlerTest.php +++ b/tests/unit/src/Server/LogoutHandlers/BackChannelLogoutHandlerTest.php @@ -8,6 +8,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use GuzzleHttp\RequestOptions; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; @@ -22,16 +23,19 @@ /** * @covers \SimpleSAML\Module\oidc\Server\LogoutHandlers\BackChannelLogoutHandler */ +#[AllowMockObjectsWithoutExpectations] class BackChannelLogoutHandlerTest extends TestCase { /** * @var mixed */ private MockObject $logoutTokenBuilderMock; + /** * @var mixed */ private MockObject $loggerServiceMock; + /** * @var mixed */ @@ -46,6 +50,7 @@ class BackChannelLogoutHandlerTest extends TestCase private array $sampleRelyingPartyAssociation = []; + /** * @throws \Exception */ @@ -61,6 +66,7 @@ public function setUp(): void $this->sampleRelyingPartyAssociation[] = $this->getSampleRelyingPartyAssociation(); } + protected function mocked(): BackChannelLogoutHandler { return new BackChannelLogoutHandler( @@ -71,6 +77,7 @@ protected function mocked(): BackChannelLogoutHandler ); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException */ @@ -84,6 +91,7 @@ public function testLogsErrorForInvalidUri(): void $this->mocked()->handle($this->sampleRelyingPartyAssociation); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException */ @@ -102,6 +110,7 @@ public function testLogsNoticeForSuccessfulResponse(): void $this->mocked()->handle($this->sampleRelyingPartyAssociation, $handlerStack); } + /** * TLS verification must be on unless a deployment explicitly opts out, since the Logout Token carries the * 'sub' / 'sid' claims. Earlier versions disabled it unconditionally. @@ -117,6 +126,7 @@ public function testVerifiesTlsAndAppliesTimeoutsByDefault(): void $this->assertSame(3, $options[RequestOptions::TIMEOUT]); } + /** * @throws \League\OAuth2\Server\Exception\OAuthServerException */ @@ -136,6 +146,7 @@ public function testConfiguredHttpClientOptionsOverrideDefaults(): void $this->assertSame(3, $options[RequestOptions::CONNECT_TIMEOUT]); } + /** * A logout URI is registered by the client, so this client fetches a destination the deployment did not * choose and has to be guarded like any other. It is built here rather than by the openid library, so @@ -170,6 +181,7 @@ public function testRefusesToSendLogoutToADestinationThePolicyForbids(): void $this->mocked()->handle([$association], HandlerStack::create($mockHandler)); } + /** * A deployment can configure its own handler through the client options. Attaching the guard must not * cost it that handler, which an earlier version did by assigning over it. @@ -197,6 +209,7 @@ function () use (&$reached): Response { $this->assertTrue($reached, 'The configured handler was replaced rather than guarded.'); } + /** * A configured stack outlives the call that used it, so the guard has to be replaced rather than added * to. Otherwise the nth logout runs n policy checks, each with its own DNS lookup, on a stack that @@ -233,6 +246,7 @@ public function testDoesNotAccumulateGuardsOnAStackReusedAcrossCalls(): void $this->assertSame($afterFirst, $countGuards(), 'The stack gained a guard per call.'); } + /** * Run a single Back-Channel Logout request through a mock handler and return the effective Guzzle options, * which are the client config merged into the per-request options. @@ -256,6 +270,7 @@ function (RequestInterface $request, array $options) use (&$captured): Response return $captured; } + protected function getSampleRelyingPartyAssociation( ?string $clientId = null, ?string $userId = null, diff --git a/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php b/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php index f56a9d29..2ce4c149 100644 --- a/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php +++ b/tests/unit/src/Server/Registration/ClientMetadataValidatorTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\Registration; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -14,6 +15,7 @@ use SimpleSAML\OpenID\Network\DestinationPolicy; #[CoversClass(ClientMetadataValidator::class)] +#[AllowMockObjectsWithoutExpectations] class ClientMetadataValidatorTest extends TestCase { protected MockObject $moduleConfigMock; @@ -25,6 +27,7 @@ class ClientMetadataValidatorTest extends TestCase */ protected MockObject $destinationPolicyMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -45,11 +48,13 @@ protected function setUp(): void $this->destinationPolicyMock->method('isUriAllowed')->willReturn(true); } + protected function sut(): ClientMetadataValidator { return new ClientMetadataValidator($this->moduleConfigMock, $this->destinationPolicyMock); } + /** * Assert that validating the given metadata is rejected with the expected OAuth error code and a hint * containing the given substring. @@ -69,6 +74,7 @@ protected function assertRejected( } } + public function testValidMetadataPasses(): void { $metadata = [ @@ -85,6 +91,7 @@ public function testValidMetadataPasses(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testNativeRedirectUriIsAllowed(): void { $metadata = ['redirect_uris' => ['com.example.app:/callback']]; @@ -92,21 +99,25 @@ public function testNativeRedirectUriIsAllowed(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testMissingRedirectUrisIsRejected(): void { $this->assertRejected(['client_name' => 'Example'], 'invalid_redirect_uri', 'redirect_uris is required'); } + public function testEmptyRedirectUrisIsRejected(): void { $this->assertRejected(['redirect_uris' => []], 'invalid_redirect_uri', 'redirect_uris is required'); } + public function testRedirectUriWithoutSchemeIsRejected(): void { $this->assertRejected(['redirect_uris' => ['not-a-uri']], 'invalid_redirect_uri', 'invalid'); } + public function testInvalidLogoUriIsRejected(): void { $this->assertRejected( @@ -116,6 +127,7 @@ public function testInvalidLogoUriIsRejected(): void ); } + public function testContactsMustBeArray(): void { $this->assertRejected( @@ -125,6 +137,7 @@ public function testContactsMustBeArray(): void ); } + public function testInvalidApplicationTypeIsRejected(): void { $this->assertRejected( @@ -134,6 +147,7 @@ public function testInvalidApplicationTypeIsRejected(): void ); } + public function testValidRequestUrisPass(): void { // https URIs, including one with a fragment (OIDC Core allows a content-hash fragment on request_uri). @@ -148,6 +162,7 @@ public function testValidRequestUrisPass(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testRequestUrisMustBeArray(): void { $this->assertRejected( @@ -157,6 +172,7 @@ public function testRequestUrisMustBeArray(): void ); } + public function testNonHttpsRequestUriIsRejected(): void { $this->assertRejected( @@ -166,6 +182,7 @@ public function testNonHttpsRequestUriIsRejected(): void ); } + public function testSubjectTypePublicIsAccepted(): void { $metadata = ['redirect_uris' => ['https://client.example.org/cb'], 'subject_type' => 'public']; @@ -173,6 +190,7 @@ public function testSubjectTypePublicIsAccepted(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testPairwiseSubjectTypeIsRejected(): void { $this->assertRejected( @@ -182,9 +200,8 @@ public function testPairwiseSubjectTypeIsRejected(): void ); } - /** - * @dataProvider unsupportedFeatureMetadataProvider - */ + + #[DataProvider('unsupportedFeatureMetadataProvider')] public function testUnsupportedFeatureMetadataIsRejected(string $field, mixed $value): void { $this->assertRejected( @@ -194,6 +211,7 @@ public function testUnsupportedFeatureMetadataIsRejected(string $field, mixed $v ); } + public static function unsupportedFeatureMetadataProvider(): array { return [ @@ -207,6 +225,7 @@ public static function unsupportedFeatureMetadataProvider(): array ]; } + public function testValidAdditionalMetadataPasses(): void { $metadata = [ @@ -222,6 +241,7 @@ public function testValidAdditionalMetadataPasses(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testNegativeDefaultMaxAgeIsRejected(): void { $this->assertRejected( @@ -231,6 +251,7 @@ public function testNegativeDefaultMaxAgeIsRejected(): void ); } + public function testNonBooleanRequireAuthTimeIsRejected(): void { $this->assertRejected( @@ -240,6 +261,7 @@ public function testNonBooleanRequireAuthTimeIsRejected(): void ); } + public function testNonArrayDefaultAcrValuesIsRejected(): void { $this->assertRejected( @@ -249,6 +271,7 @@ public function testNonArrayDefaultAcrValuesIsRejected(): void ); } + public function testUnsupportedGrantTypeIsRejected(): void { $this->assertRejected( @@ -261,6 +284,7 @@ public function testUnsupportedGrantTypeIsRejected(): void ); } + public function testUnsupportedResponseTypeIsRejected(): void { $this->assertRejected( @@ -273,6 +297,7 @@ public function testUnsupportedResponseTypeIsRejected(): void ); } + public function testUnsupportedTokenEndpointAuthMethodIsRejected(): void { $this->assertRejected( @@ -285,6 +310,7 @@ public function testUnsupportedTokenEndpointAuthMethodIsRejected(): void ); } + public function testSupportedGrantResponseAndAuthMethodArePassedThrough(): void { // 'none' (public client) and the implicit response/grant types are supported and must be accepted. @@ -298,6 +324,7 @@ public function testSupportedGrantResponseAndAuthMethodArePassedThrough(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testUnsupportedDefaultAcrValueIsRejected(): void { $this->assertRejected( @@ -310,6 +337,7 @@ public function testUnsupportedDefaultAcrValueIsRejected(): void ); } + public function testRedirectUriWithEmptyFragmentIsRejected(): void { // A trailing '#' is an (empty) fragment component, which OIDC Core 3.1.2.1 forbids. @@ -320,6 +348,7 @@ public function testRedirectUriWithEmptyFragmentIsRejected(): void ); } + public function testRedirectUriWithEncodedHashIsAllowed(): void { // A percent-encoded '%23' in the path is a literal '#', not a fragment delimiter. @@ -328,6 +357,7 @@ public function testRedirectUriWithEncodedHashIsAllowed(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testNativeClientRejectsRemoteHttpRedirectUri(): void { $this->assertRejected( @@ -340,6 +370,7 @@ public function testNativeClientRejectsRemoteHttpRedirectUri(): void ); } + public function testNativeClientAllowsCustomSchemeAndLoopbackRedirectUris(): void { $metadata = [ @@ -355,6 +386,7 @@ public function testNativeClientAllowsCustomSchemeAndLoopbackRedirectUris(): voi $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testWebImplicitClientRejectsNonHttpsRedirectUri(): void { $this->assertRejected( @@ -367,6 +399,7 @@ public function testWebImplicitClientRejectsNonHttpsRedirectUri(): void ); } + public function testWebImplicitClientRejectsLocalhostRedirectUri(): void { $this->assertRejected( @@ -379,6 +412,7 @@ public function testWebImplicitClientRejectsLocalhostRedirectUri(): void ); } + public function testWebCodeClientIsNotConstrainedByImplicitRule(): void { // Default (web) client not using implicit: an http://localhost redirect stays allowed. @@ -390,6 +424,7 @@ public function testWebCodeClientIsNotConstrainedByImplicitRule(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testNonHttpsInitiateLoginUriIsRejected(): void { $this->assertRejected( @@ -402,6 +437,7 @@ public function testNonHttpsInitiateLoginUriIsRejected(): void ); } + public function testImpersonationProtectionRejectsMismatchedHost(): void { $this->assertRejected( @@ -411,6 +447,7 @@ public function testImpersonationProtectionRejectsMismatchedHost(): void ); } + public function testImpersonationProtectionAllowsClientUriOnDifferentHost(): void { // client_uri is intentionally excluded from the host check. @@ -422,6 +459,7 @@ public function testImpersonationProtectionAllowsClientUriOnDifferentHost(): voi $this->assertSame($metadata, $this->sut()->validate($metadata)); } + public function testImpersonationProtectionCanBeDisabled(): void { $moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -444,6 +482,7 @@ public function testImpersonationProtectionCanBeDisabled(): void ); } + public static function refusedDestinationProvider(): array { return [ @@ -453,6 +492,7 @@ public static function refusedDestinationProvider(): array ]; } + #[DataProvider('refusedDestinationProvider')] public function testRefusesAUriNamingADestinationThePolicyForbids(string $claim, string $uri): void { @@ -471,6 +511,7 @@ public function testRefusesAUriNamingADestinationThePolicyForbids(string $claim, ); } + /** * request_uris is a list, so a single bad entry among good ones has to be caught rather than only the * first value being looked at. @@ -498,6 +539,7 @@ public function testRefusesARequestUriNamingADestinationThePolicyForbids(): void ); } + /** * The destination checks resolve names, and a resolver is bounded by nothing here, so they are not * work an unauthenticated caller may order. An open registration is still protected: the refusal @@ -517,6 +559,7 @@ public function testDoesNotResolveDestinationsForAnUnauthenticatedCaller(): void $this->assertSame($metadata, $this->sut()->validate($metadata)); } + /** * Each distinct destination costs a synchronous DNS lookup, so an unbounded request_uris list is work * an unauthenticated caller can order for itself when registration is open. The list has to be refused @@ -542,6 +585,7 @@ public function testRefusesAnOverlongRequestUrisListWithoutResolvingIt(): void ); } + /** * A list repeating one destination is one destination, and must not be charged as many. */ @@ -567,6 +611,7 @@ public function testChecksEachDistinctDestinationOnlyOnce(): void $this->assertSame($metadata, $this->sut()->validate($metadata, isCallerAuthenticated: true)); } + /** * The policy refuses a URI carrying credentials on the URI itself, not on where it points, so such a * URI must never be deduplicated against a clean one sharing its host - it would otherwise be accepted @@ -593,6 +638,7 @@ public function testChecksACredentialBearingUriEvenBehindACleanOneOnTheSameHost( ); } + /** * The origin is what identifies a destination, so a different scheme or port is a different one even * on the same host. Folding those together would let an http URI ride in on an https one. @@ -614,6 +660,7 @@ public function testTreatsADifferentSchemeOrPortAsADifferentDestination(): void $this->assertSame($metadata, $this->sut()->validate($metadata, isCallerAuthenticated: true)); } + /** * The policy decides destinations, not the shape of the metadata, so a claim the OP never fetches from * must not be run past it. logo_uri is shown to a human; refusing it here would be a different rule. diff --git a/tests/unit/src/Server/RequestRules/RequestRulesManagerTest.php b/tests/unit/src/Server/RequestRules/RequestRulesManagerTest.php index a1fbdbca..e8f0722e 100644 --- a/tests/unit/src/Server/RequestRules/RequestRulesManagerTest.php +++ b/tests/unit/src/Server/RequestRules/RequestRulesManagerTest.php @@ -5,6 +5,8 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -18,12 +20,17 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager */ +#[AllowMockObjectsWithoutExpectations] class RequestRulesManagerTest extends TestCase { protected string $key = 'some-key'; + protected string $value = 'some-value'; + protected Stub $resultStub; + protected Stub $ruleStub; + protected Stub $request; @@ -44,6 +51,7 @@ public function setUp(): void $this->request = $this->createStub(ServerRequestInterface::class); } + public function testConstructWithoutRules(): RequestRulesManager { $requestRulesManager = new RequestRulesManager(); @@ -52,6 +60,7 @@ public function testConstructWithoutRules(): RequestRulesManager return $requestRulesManager; } + /** * @throws \Exception */ @@ -64,11 +73,11 @@ public function testConstructWithRules(): void ); } + /** - * @depends testConstructWithoutRules - * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ + #[Depends('testConstructWithoutRules')] public function testAddAndCheck(RequestRulesManager $requestRulesManager): void { $requestRulesManager->add($this->ruleStub); @@ -79,22 +88,22 @@ public function testAddAndCheck(RequestRulesManager $requestRulesManager): void $this->assertArrayHasKey($this->key, $resultBag->getAll()); } + /** - * @depends testConstructWithoutRules - * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ + #[Depends('testConstructWithoutRules')] public function testCheckWithNonExistingRuleKeyThrows(RequestRulesManager $requestRulesManager): void { $this->expectException(LogicException::class); $requestRulesManager->check($this->request, ['wrong-key']); } + /** - * @depends testConstructWithoutRules - * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ + #[Depends('testConstructWithoutRules')] public function testPredefineResult(RequestRulesManager $requestRulesManager): void { $requestRulesManager->predefineResult($this->resultStub); @@ -104,12 +113,12 @@ public function testPredefineResult(RequestRulesManager $requestRulesManager): v $this->assertArrayHasKey($this->key, $resultBag->getAll()); } + /** - * @depends testConstructWithoutRules - * * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception */ + #[Depends('testConstructWithoutRules')] public function testSetData(RequestRulesManager $requestRulesManager): void { $requestRulesManager->setData($this->key, $this->value); diff --git a/tests/unit/src/Server/RequestRules/ResultBagTest.php b/tests/unit/src/Server/RequestRules/ResultBagTest.php index 171c6bfa..dca9cbe3 100644 --- a/tests/unit/src/Server/RequestRules/ResultBagTest.php +++ b/tests/unit/src/Server/RequestRules/ResultBagTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; @@ -12,21 +13,25 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\ResultBag */ +#[AllowMockObjectsWithoutExpectations] class ResultBagTest extends TestCase { protected string $key = 'some-key'; + protected string $value = 'some-value'; protected Result $result; protected ResultBag $resultBag; + protected function setUp(): void { $this->result = new Result($this->key, $this->value); $this->resultBag = new ResultBag(); } + public function testGetAll(): void { $this->assertEmpty($this->resultBag->getAll()); @@ -35,6 +40,7 @@ public function testGetAll(): void $this->assertCount(2, $this->resultBag->getAll()); } + public function testAdd(): void { $this->assertNull($this->resultBag->get($this->key)); @@ -42,6 +48,7 @@ public function testAdd(): void $this->assertInstanceOf(Result::class, $this->resultBag->get($this->key)); } + public function testGetOrFail(): void { $this->resultBag->add($this->result); @@ -50,6 +57,7 @@ public function testGetOrFail(): void $this->resultBag->getOrFail('non-existent'); } + public function testGetValueOrFail(): void { $this->resultBag->add($this->result); @@ -58,6 +66,7 @@ public function testGetValueOrFail(): void $this->resultBag->getValueOrFail('non-existent'); } + public function testGet(): void { $this->assertNull($this->resultBag->get($this->key)); @@ -65,6 +74,7 @@ public function testGet(): void $this->assertSame($this->result, $this->resultBag->get($this->key)); } + public function testRemove(): void { $this->assertNull($this->resultBag->get($this->key)); diff --git a/tests/unit/src/Server/RequestRules/ResultTest.php b/tests/unit/src/Server/RequestRules/ResultTest.php index 0609e06b..fc516bdf 100644 --- a/tests/unit/src/Server/RequestRules/ResultTest.php +++ b/tests/unit/src/Server/RequestRules/ResultTest.php @@ -4,17 +4,22 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Server\RequestRules\Result; /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Result */ +#[AllowMockObjectsWithoutExpectations] class ResultTest extends TestCase { protected string $key = 'some-key'; + protected string $value = 'some-value'; + public function testConstruct(): Result { $result = new Result($this->key, $this->value); @@ -22,23 +27,21 @@ public function testConstruct(): Result return $result; } + public function testConstructWithoutValue(): void { $this->assertInstanceOf(Result::class, new Result($this->key)); } - /** - * @depends testConstruct - */ + + #[Depends('testConstruct')] public function testGetKey(Result $result): void { $this->assertSame($this->key, $result->getKey()); } - /** - * @depends testConstruct - * - */ + + #[Depends('testConstruct')] public function testGetValue(Result $result): void { $this->assertSame($this->value, $result->getValue()); diff --git a/tests/unit/src/Server/RequestRules/Rules/AcrValuesRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/AcrValuesRuleTest.php index 87fa84d3..12f03822 100644 --- a/tests/unit/src/Server/RequestRules/Rules/AcrValuesRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/AcrValuesRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -20,16 +21,24 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule */ +#[AllowMockObjectsWithoutExpectations] class AcrValuesRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $resultBagStub; + protected Stub $resultStub; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Stub $responseModeStub; + protected Helpers $helpers; + /** * @throws \Exception */ @@ -44,6 +53,7 @@ protected function setUp(): void $this->helpers = new Helpers(); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -57,6 +67,7 @@ protected function sut( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -72,6 +83,7 @@ public function testNoAcrIsSetIfAcrValuesNotRequested(): void $this->assertNull($result->getValue()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -93,6 +105,7 @@ public function testPopulatesAcrValuesFromClaimsParameter(): void $this->assertTrue($result->getValue()['essential']); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -112,6 +125,7 @@ public function testPopulatesAcrValuesFromAcrValuesRequestParameter(): void $this->assertFalse($result->getValue()['essential']); } + /** * When no acr is requested (claims param or acr_values), the client's registered default_acr_values are used. * diff --git a/tests/unit/src/Server/RequestRules/Rules/AddClaimsToIdTokenRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/AddClaimsToIdTokenRuleTest.php index 0103bf94..935ac00f 100644 --- a/tests/unit/src/Server/RequestRules/Rules/AddClaimsToIdTokenRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/AddClaimsToIdTokenRuleTest.php @@ -5,6 +5,8 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -22,10 +24,13 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule */ +#[AllowMockObjectsWithoutExpectations] class AddClaimsToIdTokenRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; protected array $requestParams = [ @@ -51,8 +56,10 @@ class AddClaimsToIdTokenRuleTest extends TestCase private ResultBag $resultBag; private Stub $loggerServiceStub; + private Stub $responseModeStub; + /** * @throws \Exception */ @@ -67,6 +74,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -80,10 +88,11 @@ protected function sut( ); } + /** - * @dataProvider validResponseTypeProvider * @throws \Throwable */ + #[DataProvider('validResponseTypeProvider')] public function testAddClaimsToIdTokenRuleTest($responseType) { $this->resultBag->add(new Result(ResponseTypeRule::class, $responseType)); @@ -99,6 +108,7 @@ public function testAddClaimsToIdTokenRuleTest($responseType) $this->assertTrue($result->getValue()); } + public static function validResponseTypeProvider(): array { return [ @@ -106,10 +116,11 @@ public static function validResponseTypeProvider(): array ]; } + /** - * @dataProvider invalidResponseTypeProvider * @throws \Throwable */ + #[DataProvider('invalidResponseTypeProvider')] public function testDoNotAddClaimsToIdTokenRuleTest($responseType) { $this->resultBag->add(new Result(ResponseTypeRule::class, $responseType)); @@ -126,6 +137,7 @@ public function testDoNotAddClaimsToIdTokenRuleTest($responseType) $this->assertFalse($result->getValue()); } + public static function invalidResponseTypeProvider(): array { return [ @@ -138,6 +150,7 @@ public static function invalidResponseTypeProvider(): array ]; } + /** * A client configured with the administrator-only `add_claims_to_id_token` option gets the claims released * in the ID Token even for a response type that would not otherwise trigger it (e.g. `id_token token`). @@ -164,6 +177,7 @@ public function testAddsClaimsWhenClientConfiguredEvenForNonIdTokenResponseType( $this->assertTrue($result->getValue()); } + /** * When neither the response type nor the client requests it, claims are not released in the ID Token. * @@ -189,6 +203,7 @@ public function testDoesNotAddClaimsWhenNeitherResponseTypeNorClientRequestIt(): $this->assertFalse($result->getValue()); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientAuthenticationRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientAuthenticationRuleTest.php index 5cfe96d5..cd6169af 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientAuthenticationRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientAuthenticationRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -25,17 +26,26 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule */ +#[AllowMockObjectsWithoutExpectations] class ClientAuthenticationRuleTest extends TestCase { protected ResultBag $resultBag; + protected Stub $clientStub; + protected Stub $requestStub; + protected Stub $loggerServiceStub; + protected MockObject $requestParamsResolverMock; + protected Helpers $helpers; + protected MockObject $authenticatedOAuth2ClientResolverMock; + protected Stub $responseModeStub; + protected function setUp(): void { $this->resultBag = new ResultBag(); @@ -48,6 +58,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -64,6 +75,7 @@ protected function sut( ); } + /** * A client already resolved by an upstream rule (ClientRule) is used as the pre-fetched client, without * touching the client_id request parameter. @@ -102,6 +114,7 @@ public function testUsesPreFetchedClientFromResultBag(): void $this->assertSame($resolved, $result->getValue()); } + /** * When no upstream client is available but a client_id param is present, it is used to pre-fetch the client. * @@ -139,6 +152,7 @@ public function testFallsBackToClientIdParameterWhenPresent(): void $this->assertSame($resolved, $result->getValue()); } + /** * The core of the fix: with no upstream client and no client_id parameter (e.g. private_key_jwt, where the * identity is conveyed by the assertion), the rule must still authenticate by letting the resolver derive the @@ -178,6 +192,7 @@ public function testDoesNotRequireClientIdParameter(): void $this->assertSame($resolved, $result->getValue()); } + /** * If the resolver can not authenticate the client by any supported method, the request is denied. * @@ -202,6 +217,7 @@ public function testThrowsWhenNoAuthenticationMethodResolved(): void ); } + /** * A confidential client must not authenticate using the 'none' method. * diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php index 4f203ba0..87c66704 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -29,13 +30,18 @@ #[CoversClass(ClientIdRule::class)] #[UsesClass(Result::class)] #[UsesClass(ResultBag::class)] +#[AllowMockObjectsWithoutExpectations] class ClientIdRuleTest extends TestCase { private RequestParamsResolver&MockObject $requestParamsResolverMock; + private ServerRequestInterface&MockObject $requestMock; + private LoggerService&MockObject $loggerServiceMock; + private ResponseModeInterface&MockObject $responseModeMock; + protected function setUp(): void { $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -46,6 +52,7 @@ protected function setUp(): void $this->requestMock->method('getServerParams')->willReturn([]); } + public function testResolvesTheClientIdFromTheRequestParameters(): void { $this->resolverReturns('client-from-parameter'); @@ -53,6 +60,7 @@ public function testResolvesTheClientIdFromTheRequestParameters(): void $this->assertSame('client-from-parameter', $this->check()?->getValue()); } + public function testFallsBackToTheHttpBasicAuthenticationUser(): void { // With client_secret_basic the client identifies itself in the Authorization header rather than in @@ -65,6 +73,7 @@ public function testFallsBackToTheHttpBasicAuthenticationUser(): void $this->assertSame('client-from-basic-auth', $this->check($request)?->getValue()); } + public function testPrefersTheRequestParameterOverTheBasicAuthenticationUser(): void { $this->resolverReturns('client-from-parameter'); @@ -75,6 +84,7 @@ public function testPrefersTheRequestParameterOverTheBasicAuthenticationUser(): $this->assertSame('client-from-parameter', $this->check($request)?->getValue()); } + public function testRejectsARequestThatNamesNoClientAtAll(): void { $this->resolverReturns(null); @@ -87,6 +97,7 @@ public function testRejectsARequestThatNamesNoClientAtAll(): void } } + private function resolverReturns(?string $clientId): void { $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods') @@ -97,6 +108,7 @@ private function resolverReturns(?string $clientId): void ); } + private function check(?ServerRequestInterface $request = null): ?Result { $rule = new ClientIdRule($this->requestParamsResolverMock, new Helpers()); diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientRedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientRedirectUriRuleTest.php index a5fc8d6d..036c09e3 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientRedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientRedirectUriRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -33,17 +34,25 @@ #[CoversClass(ClientRedirectUriRule::class)] #[UsesClass(Result::class)] #[UsesClass(ResultBag::class)] +#[AllowMockObjectsWithoutExpectations] class ClientRedirectUriRuleTest extends TestCase { private const string REGISTERED_URI = 'https://rp.example.org/callback'; + private const string OTHER_URI = 'https://attacker.example.org/callback'; + private RequestParamsResolver&MockObject $requestParamsResolverMock; + private ModuleConfig&MockObject $moduleConfigMock; + private ServerRequestInterface&MockObject $requestMock; + private LoggerService&MockObject $loggerServiceMock; + private ResponseModeInterface&MockObject $responseModeMock; + protected function setUp(): void { $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -58,6 +67,7 @@ protected function setUp(): void $this->moduleConfigMock->method('getVciAllowNonRegisteredClients')->willReturn(false); } + public function testRequiresTheClientToHaveBeenResolvedFirst(): void { $this->expectException(LogicException::class); @@ -65,6 +75,7 @@ public function testRequiresTheClientToHaveBeenResolvedFirst(): void $this->check(new ResultBag()); } + public function testRefusesToCheckAgainstSomethingThatIsNotAClient(): void { // The bag is untyped, so a rule that put the wrong thing under ClientRule would otherwise have this @@ -77,6 +88,7 @@ public function testRefusesToCheckAgainstSomethingThatIsNotAClient(): void $this->check($resultBag); } + public function testRejectsARequestWithoutARedirectUri(): void { // OAuth 2.0 allows omitting it when only one is registered; OpenID Connect requires it, and this @@ -91,6 +103,7 @@ public function testRejectsARequestWithoutARedirectUri(): void } } + public function testAcceptsTheRedirectUriRegisteredAsAString(): void { $this->resolverReturns(self::REGISTERED_URI); @@ -101,6 +114,7 @@ public function testAcceptsTheRedirectUriRegisteredAsAString(): void ); } + public function testRejectsARedirectUriThatDiffersFromTheOneRegisteredAsAString(): void { $this->resolverReturns(self::OTHER_URI); @@ -110,6 +124,7 @@ public function testRejectsARedirectUriThatDiffersFromTheOneRegisteredAsAString( $this->check($this->resultBagFor($this->client(self::REGISTERED_URI))); } + public function testAcceptsARedirectUriPresentInTheRegisteredList(): void { $this->resolverReturns(self::REGISTERED_URI); @@ -119,6 +134,7 @@ public function testAcceptsARedirectUriPresentInTheRegisteredList(): void $this->assertSame(self::REGISTERED_URI, $this->check($this->resultBagFor($client))?->getValue()); } + public function testRejectsARedirectUriAbsentFromTheRegisteredList(): void { $this->resolverReturns(self::OTHER_URI); @@ -131,6 +147,7 @@ public function testRejectsARedirectUriAbsentFromTheRegisteredList(): void } } + public function testMatchesTheRegisteredListExactlyRatherThanByPrefix(): void { // A registered https://rp.example.org/callback must not admit .../callback/../elsewhere or a @@ -155,6 +172,7 @@ public function testAdmitsAnUnregisteredWalletWhoseRedirectUriMatchesAnAllowedPr ); } + public function testRefusesAnUnregisteredWalletWhoseRedirectUriMatchesNoAllowedPrefix(): void { // The prefix list is the whole of the permission: a URI outside it is refused even though every @@ -166,6 +184,7 @@ public function testRefusesAnUnregisteredWalletWhoseRedirectUriMatchesNoAllowedP $this->check($this->resultBagFor($this->client([self::REGISTERED_URI]))); } + public function testDoesNotOfferThePrefixEscapeWhenTheRequestIsNotACredentialRequest(): void { // Both switches on, but an ordinary authorization request must still be held to the registered URI. @@ -192,6 +211,7 @@ public function testDoesNotOfferThePrefixEscapeWhenTheRequestIsNotACredentialReq ); } + public function testDoesNotOfferThePrefixEscapeWhenUnregisteredClientsAreNotAllowed(): void { $requestParamsResolver = $this->createMock(RequestParamsResolver::class); @@ -215,11 +235,13 @@ public function testDoesNotOfferThePrefixEscapeWhenUnregisteredClientsAreNotAllo ); } + private function resolverReturns(?string $redirectUri): void { $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn($redirectUri); } + /** * Replaces the resolver and config wholesale, so the redirect URI has to be restated here: a mock * keeps the first matcher registered for a method, so re-stubbing the old one would have no effect. @@ -239,6 +261,7 @@ private function enableUnregisteredWallets(array $allowedPrefixes, string $redir ->willReturn($allowedPrefixes); } + private function client(array|string $registeredRedirectUri): ClientEntityInterface&MockObject { $client = $this->createMock(ClientEntityInterface::class); @@ -248,6 +271,7 @@ private function client(array|string $registeredRedirectUri): ClientEntityInterf return $client; } + private function resultBagFor(ClientEntityInterface $client): ResultBag { $resultBag = new ResultBag(); @@ -256,6 +280,7 @@ private function resultBagFor(ClientEntityInterface $client): ResultBag return $resultBag; } + private function check(ResultBag $resultBag): ?Result { $rule = new ClientRedirectUriRule( diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php index 91a2d178..60231785 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -27,23 +28,38 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule */ +#[AllowMockObjectsWithoutExpectations] class ClientRuleTest extends TestCase { protected Stub $clientEntityStub; + protected Stub $clientRepositoryStub; + protected Stub $requestStub; + protected Stub $resultBagStub; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Stub $moduleConfigStub; + protected Stub $federationStub; + protected Stub $federationCacheStub; + protected Stub $clientEntityFactoryStub; + protected Stub $helpersStub; + protected Stub $jwksResolverStub; + protected Stub $federationParticipationValidatorStub; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -65,6 +81,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut(): ClientRule { return new ClientRule( @@ -81,11 +98,13 @@ protected function sut(): ClientRule ); } + public function testConstruct(): void { $this->assertInstanceOf(ClientRule::class, $this->sut()); } + public function testCheckRuleEmptyClientIdThrows(): void { $this->requestParamsResolverStub->method('getBasedOnAllowedMethods')->willReturn(null); @@ -99,6 +118,7 @@ public function testCheckRuleEmptyClientIdThrows(): void ); } + public function testCheckRuleInvalidClientThrows(): void { $this->requestParamsResolverStub->method('getBasedOnAllowedMethods')->willReturn('123'); @@ -113,6 +133,7 @@ public function testCheckRuleInvalidClientThrows(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php index b9443452..6cf8d535 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -25,18 +26,30 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule */ +#[AllowMockObjectsWithoutExpectations] class CodeChallengeMethodRuleTest extends TestCase { protected CodeChallengeMethodRule $rule; + protected Stub $requestStub; + protected Stub $resultBagStub; + protected Result $redirectUriResult; + protected Result $stateResult; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected MockObject $codeChallengeVerifiersRepositoryMock; + protected Helpers $helpers; + protected Stub $responseModeStub; + + /** * @throws \Exception */ @@ -53,6 +66,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -69,6 +83,7 @@ protected function sut( ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -80,6 +95,7 @@ public function testCheckRuleRedirectUriDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -92,6 +108,7 @@ public function testCheckRuleStateDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable */ @@ -105,6 +122,7 @@ public function testCheckRuleWithInvalidCodeChallengeMethodThrows(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -127,6 +145,7 @@ public function testCheckRuleForValidCodeChallengeMethod(): void $this->assertSame('plain', $result->getValue()); } + protected function prepareValidResultBag(): ResultBag { $resultBag = new ResultBag(); diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php index cc55a113..5689270b 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -25,22 +26,34 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule */ +#[AllowMockObjectsWithoutExpectations] class CodeChallengeRuleTest extends TestCase { protected CodeChallengeRule $rule; + protected Stub $requestStub; + protected Stub $resultBagStub; + protected Result $redirectUriResult; + protected Result $stateResult; protected string $codeChallenge = '123123123123123123123123123123123123123123123123123123123123'; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Stub $clientStub; + protected Result $clientIdResult; + protected Helpers $helpers; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -58,6 +71,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -71,6 +85,7 @@ protected function sut( ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -82,6 +97,7 @@ public function testCheckRuleRedirectUriDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -94,6 +110,7 @@ public function testCheckRuleStateDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable */ @@ -113,6 +130,7 @@ public function testCheckRuleNoCodeReturnsNullForConfidentialClients(): void $this->assertNull($result->getValue()); } + /** * @throws \Throwable */ @@ -124,6 +142,7 @@ public function testCheckRuleInvalidCodeChallengeThrows(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -145,6 +164,7 @@ public function testCheckRuleForValidCodeChallenge(): void $this->assertSame($this->codeChallenge, $result->getValue()); } + protected function prepareValidResultBag(): ResultBag { $resultBag = new ResultBag(); diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeVerifierRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeVerifierRuleTest.php index 45518ffe..0af67236 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeVerifierRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeVerifierRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; @@ -33,19 +34,26 @@ #[CoversClass(CodeVerifierRule::class)] #[UsesClass(Result::class)] #[UsesClass(ResultBag::class)] +#[AllowMockObjectsWithoutExpectations] class CodeVerifierRuleTest extends TestCase { private const string VALID_VERIFIER = 'M25iVXpKU3puUjFaYWg3T1NDTDQtcW1ROUY5YXlwalNoc0hhakxpZlRJUQ'; + private const string CLIENT_ID = 'client-id'; + private RequestParamsResolver&MockObject $requestParamsResolverMock; + private ServerRequestInterface&MockObject $requestMock; + private LoggerService&MockObject $loggerServiceMock; + private ResponseModeInterface&MockObject $responseModeMock; /** @var array */ private array $logRecords = []; + protected function setUp(): void { $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -62,6 +70,7 @@ function (string|Stringable $message, array $context = []): void { } } + public function testRequiresTheClientToHaveBeenResolvedFirst(): void { // Whether a verifier may be omitted depends on the client, so running this rule without one is a @@ -71,6 +80,7 @@ public function testRequiresTheClientToHaveBeenResolvedFirst(): void $this->check(new ResultBag()); } + public function testRejectsAPublicClientThatSendsNoCodeVerifier(): void { // A public client has no secret, so without PKCE nothing binds the code to whoever requested it. @@ -84,6 +94,7 @@ public function testRejectsAPublicClientThatSendsNoCodeVerifier(): void } } + public function testAllowsAConfidentialClientToOmitTheCodeVerifier(): void { // A confidential client authenticates with its credentials, so PKCE is optional for it. @@ -95,6 +106,7 @@ public function testAllowsAConfidentialClientToOmitTheCodeVerifier(): void $this->assertNull($result->getValue()); } + #[DataProvider('malformedVerifierProvider')] public function testRejectsACodeVerifierThatDoesNotFollowRfc7636(string $verifier, string $why): void { @@ -108,6 +120,7 @@ public function testRejectsACodeVerifierThatDoesNotFollowRfc7636(string $verifie } } + /** * @return array */ @@ -125,6 +138,7 @@ public static function malformedVerifierProvider(): array ]; } + #[DataProvider('validVerifierProvider')] public function testAcceptsACodeVerifierWithinTheAllowedBounds(string $verifier): void { @@ -133,6 +147,7 @@ public function testAcceptsACodeVerifierWithinTheAllowedBounds(string $verifier) $this->assertSame($verifier, $this->check($this->resultBagFor($this->client()))?->getValue()); } + /** * @return array */ @@ -146,6 +161,7 @@ public static function validVerifierProvider(): array ]; } + public function testDoesNotLogTheCodeVerifierItWasGiven(): void { // The verifier is a credential: it is what proves the caller started the authorization. @@ -163,12 +179,14 @@ public function testDoesNotLogTheCodeVerifierItWasGiven(): void ); } + private function resolverReturns(?string $codeVerifier): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') ->willReturn($codeVerifier); } + private function client(bool $isConfidential = true): ClientEntityInterface&MockObject { $client = $this->createMock(ClientEntityInterface::class); @@ -178,6 +196,7 @@ private function client(bool $isConfidential = true): ClientEntityInterface&Mock return $client; } + private function resultBagFor(ClientEntityInterface $client): ResultBag { $resultBag = new ResultBag(); @@ -186,6 +205,7 @@ private function resultBagFor(ClientEntityInterface $client): ResultBag return $resultBag; } + private function check(ResultBag $resultBag): ?Result { $rule = new CodeVerifierRule($this->requestParamsResolverMock, new Helpers()); diff --git a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php index 0bf2d9ed..3bc5d9fb 100644 --- a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php @@ -4,7 +4,9 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use Exception; use League\OAuth2\Server\CryptKey; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -29,29 +31,44 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule */ +#[AllowMockObjectsWithoutExpectations] class IdTokenHintRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $resultBagStub; + protected Stub $moduleConfigStub; protected static string $certFolder; + protected static string $privateKeyPath; + protected static string $publicKeyPath; + protected static CryptKey $privateKey; + protected static CryptKey $publicKey; protected static string $issuer = 'https://example.org'; protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected MockObject $jwksMock; + protected MockObject $coreMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $idTokenMock; + protected Stub $responseModeStub; + /** * @throws \ReflectionException * @throws \Exception @@ -78,6 +95,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -101,11 +119,13 @@ protected function sut( ); } + public function testConstruct(): void { $this->assertInstanceOf(IdTokenHintRule::class, $this->sut()); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -123,6 +143,7 @@ public function testCheckRuleIsNullWhenParamNotSet(): void $this->assertNull($result->getValue()); } + /** * A hint that can not be parsed/validated (malformed JWS, missing/invalid required claims, or an expired * token) must be translated into a protocol-level invalid_request error, not surface as a raw exception. @@ -134,7 +155,7 @@ public function testCheckRuleThrowsInvalidRequestForUnparsableIdToken(): void $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('malformed'); $this->idTokenFactoryMock->method('fromToken') ->with('malformed') - ->willThrowException(new \Exception('parse-failure')); + ->willThrowException(new Exception('parse-failure')); $this->expectException(OidcServerException::class); $this->sut()->checkRule( @@ -146,6 +167,7 @@ public function testCheckRuleThrowsInvalidRequestForUnparsableIdToken(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -155,7 +177,7 @@ public function testCheckRuleThrowsForIdTokenWithInvalidSignature(): void ->willReturn('invalid-it-token'); $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); $this->idTokenMock->method('verifyWithKeySet') - ->willThrowException(new \Exception('invalid-signature')); + ->willThrowException(new Exception('invalid-signature')); $this->idTokenFactoryMock->method('fromToken') ->with('invalid-it-token') ->willReturn($this->idTokenMock); @@ -169,6 +191,7 @@ public function testCheckRuleThrowsForIdTokenWithInvalidSignature(): void ); } + /** * @throws \ReflectionException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -193,6 +216,7 @@ public function testCheckRuleThrowsForIdTokenWithInvalidIssuer(): void ); } + /** * @throws \ReflectionException * @throws \Throwable @@ -217,6 +241,7 @@ public function testCheckRulePassesForValidIdToken(): void $this->assertInstanceOf(IdTokenHint::class, $result->getValue()); } + /** * In the authorization flow (ClientRule present), a hint whose audience does not include the requesting client * is rejected, binding the hint to the requesting client. diff --git a/tests/unit/src/Server/RequestRules/Rules/LoginHintRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/LoginHintRuleTest.php index 62e7bd7f..15ad15c4 100644 --- a/tests/unit/src/Server/RequestRules/Rules/LoginHintRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/LoginHintRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -18,15 +19,22 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule */ +#[AllowMockObjectsWithoutExpectations] class LoginHintRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $resultBagStub; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -42,6 +50,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -55,6 +64,7 @@ protected function sut( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -74,6 +84,7 @@ public function testCheckRuleReturnsResultWhenParamSet() $this->assertEquals('user@example.org', $result->getValue()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/tests/unit/src/Server/RequestRules/Rules/MaxAgeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/MaxAgeRuleTest.php index 1927a6fa..ba35c223 100644 --- a/tests/unit/src/Server/RequestRules/Rules/MaxAgeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/MaxAgeRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -28,19 +29,30 @@ use SimpleSAML\Utils\HTTP as SspHttp; #[CoversClass(MaxAgeRule::class)] +#[AllowMockObjectsWithoutExpectations] class MaxAgeRuleTest extends TestCase { protected MockObject $requestParamsResolverMock; + protected MockObject $authSimpleFactoryMock; + protected MockObject $authenticationServiceMock; + protected MockObject $sspBridgeMock; + protected MockObject $authSimpleMock; + protected MockObject $clientMock; + protected MockObject $loggerServiceMock; + protected MockObject $requestMock; + protected MockObject $responseModeMock; + protected ResultBag $resultBag; + protected function setUp(): void { $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -59,6 +71,7 @@ protected function setUp(): void $this->resultBag->add(new Result(ClientRule::class, $this->clientMock)); } + protected function sut(): MaxAgeRule { return new MaxAgeRule( @@ -70,6 +83,7 @@ protected function sut(): MaxAgeRule ); } + protected function checkRule(): ?Result { return $this->sut()->checkRule( @@ -81,6 +95,7 @@ protected function checkRule(): ?Result ); } + public function testReturnsNullWhenNoMaxAgeNoDefaultAndNoRequireAuthTime(): void { $this->requestParamsResolverMock->method('getAllBasedOnAllowedMethods')->willReturn([]); @@ -90,6 +105,7 @@ public function testReturnsNullWhenNoMaxAgeNoDefaultAndNoRequireAuthTime(): void $this->assertNull($this->checkRule()); } + public function testRequireAuthTimeReturnsAuthInstantWithoutMaxAge(): void { $this->requestParamsResolverMock->method('getAllBasedOnAllowedMethods')->willReturn([]); @@ -105,6 +121,7 @@ public function testRequireAuthTimeReturnsAuthInstantWithoutMaxAge(): void $this->assertSame(1000, $result?->getValue()); } + public function testDefaultMaxAgeNotExpiredReturnsAuthInstant(): void { $this->requestParamsResolverMock->method('getAllBasedOnAllowedMethods')->willReturn([]); @@ -117,6 +134,7 @@ public function testDefaultMaxAgeNotExpiredReturnsAuthInstant(): void $this->assertNotNull($this->checkRule()); } + public function testExpiredMaxAgeReAuthenticatesAndPropagatesLoginHint(): void { $this->resultBag->add(new Result(ClientRedirectUriRule::class, 'https://rp.example.org/cb')); diff --git a/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php index 3eba8228..0898458e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -25,22 +26,32 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule */ +#[AllowMockObjectsWithoutExpectations] class PostLogoutRedirectUriRuleTest extends TestCase { protected Stub $clientRepositoryStub; + protected Stub $requestStub; + protected Stub $resultBagStub; + protected Stub $clientStub; protected static string $postLogoutRedirectUri = 'https://redirect.org/uri'; + protected static string $issuer = 'https://example.org'; protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected MockObject $idTokenMock; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -61,6 +72,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -77,6 +89,7 @@ protected function sut( ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -95,6 +108,7 @@ public function testCheckRuleReturnsNullIfNoParamSet(): void $this->assertNull($result->getValue()); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -124,6 +138,7 @@ public function testCheckRuleSkipsRedirectionWhenIdTokenHintNotAvailable(): void $this->assertNull($result->getValue()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -151,6 +166,7 @@ public function testCheckRuleThrowsWhenAudClaimNotValid(): void (new Result(PostLogoutRedirectUriRule::class)); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -180,6 +196,7 @@ public function testCheckRuleThrowsWhenClientNotFound(): void (new Result(PostLogoutRedirectUriRule::class)); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -214,6 +231,7 @@ public function testCheckRuleThrowsWhenPostLogoutRegisteredUriNotRegistered(): v (new Result(PostLogoutRedirectUriRule::class)); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException diff --git a/tests/unit/src/Server/RequestRules/Rules/PromptRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/PromptRuleTest.php index 8955dd3e..b01530ac 100644 --- a/tests/unit/src/Server/RequestRules/Rules/PromptRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/PromptRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -31,19 +32,30 @@ use SimpleSAML\Utils\HTTP as SspHttp; #[CoversClass(PromptRule::class)] +#[AllowMockObjectsWithoutExpectations] class PromptRuleTest extends TestCase { protected MockObject $requestParamsResolverMock; + protected MockObject $authSimpleFactoryMock; + protected MockObject $authenticationServiceMock; + protected MockObject $sspBridgeMock; + protected MockObject $authSimpleMock; + protected MockObject $clientMock; + protected MockObject $loggerServiceMock; + protected MockObject $requestMock; + protected MockObject $responseModeMock; + protected ResultBag $resultBag; + protected function setUp(): void { $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -64,6 +76,7 @@ protected function setUp(): void $this->resultBag->add(new Result(StateRule::class, 'state123')); } + protected function sut(): PromptRule { return new PromptRule( @@ -75,6 +88,7 @@ protected function sut(): PromptRule ); } + protected function checkRule(): ?Result { return $this->sut()->checkRule( @@ -86,6 +100,7 @@ protected function checkRule(): ?Result ); } + public function testReturnsNullWhenNoPromptParam(): void { $this->requestParamsResolverMock->method('getAllBasedOnAllowedMethods')->willReturn([]); @@ -94,6 +109,7 @@ public function testReturnsNullWhenNoPromptParam(): void $this->assertNull($this->checkRule()); } + public function testPromptLoginReAuthenticatesAndPropagatesLoginHint(): void { $this->resultBag->add(new Result(LoginHintRule::class, 'user@example.org')); @@ -120,6 +136,7 @@ public function testPromptLoginReAuthenticatesAndPropagatesLoginHint(): void $this->assertNull($this->checkRule()); } + public function testPromptNoneThrowsLoginRequiredWhenNotAuthenticated(): void { $this->requestParamsResolverMock->method('getAllBasedOnAllowedMethods') @@ -131,6 +148,7 @@ public function testPromptNoneThrowsLoginRequiredWhenNotAuthenticated(): void $this->checkRule(); } + public function testPromptNoneWithMatchingIdTokenHintProceeds(): void { $idTokenHintMock = $this->createMock(IdTokenHint::class); @@ -146,6 +164,7 @@ public function testPromptNoneWithMatchingIdTokenHintProceeds(): void $this->assertNull($this->checkRule()); } + public function testPromptNoneWithoutIdTokenHintProceeds(): void { $this->resultBag->add(new Result(IdTokenHintRule::class, null)); @@ -158,6 +177,7 @@ public function testPromptNoneWithoutIdTokenHintProceeds(): void $this->assertNull($this->checkRule()); } + public function testPromptNoneWithMismatchedIdTokenHintThrowsLoginRequired(): void { $idTokenHintMock = $this->createMock(IdTokenHint::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php index 2abf5f3b..51edc738 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -23,17 +24,27 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule */ +#[AllowMockObjectsWithoutExpectations] class RedirectUriRuleTest extends TestCase { protected ClientRedirectUriRule $rule; + protected ResultBag $resultBag; + protected Stub $clientStub; + protected Stub $requestStub; + protected string $redirectUri = 'https://some-redirect-uri.org'; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected Stub $moduleConfigStub; + protected Stub $responseModeStub; @@ -52,6 +63,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -68,6 +80,7 @@ protected function sut( ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -84,6 +97,7 @@ public function testCheckRuleClientIdDependency(): void ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -101,6 +115,7 @@ public function testCheckRuleWithInvalidClientDependancy(): void ); } + /** * @throws \Throwable */ @@ -112,6 +127,7 @@ public function testCheckRuleRedirectUriNotSetThrows(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable */ @@ -124,6 +140,7 @@ public function testCheckRuleDifferentClientRedirectUriThrows(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable */ @@ -144,6 +161,7 @@ public function testCheckRuleDifferentClientRedirectUriArrayThrows(): void ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -166,6 +184,7 @@ public function testCheckRuleWithValidRedirectUri(): void $this->assertSame($this->redirectUri, $result->getValue()); } + protected function prepareValidResultBag(): ResultBag { $this->clientStub->method('getRedirectUri')->willReturn($this->redirectUri); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 3c16ede0..626538a2 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -27,21 +28,34 @@ use SimpleSAML\OpenID\RequestObject\RequestObjectBag; #[CoversClass(RequestObjectRule::class)] +#[AllowMockObjectsWithoutExpectations] class RequestObjectRuleTest extends TestCase { protected MockObject $clientStub; + protected Stub $resultBagStub; + protected MockObject $requestParamsResolverMock; + protected MockObject $requestObjectMock; + protected MockObject $jarRequestObjectMock; + protected MockObject $requestObjectBagMock; + protected Stub $requestStub; + protected Stub $loggerServiceStub; + protected MockObject $jwksResolverMock; + protected Helpers $helpers; + protected Stub $responseModeStub; + protected Stub $moduleConfigStub; + protected function setUp(): void { $this->clientStub = $this->createMock(ClientEntityInterface::class); @@ -65,6 +79,7 @@ protected function setUp(): void $this->moduleConfigStub = $this->createStub(ModuleConfig::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -84,6 +99,7 @@ protected function sut( ); } + protected function prepareOidcRequest(): void { // A `request` param signals a Request Object is present (by value). @@ -98,6 +114,7 @@ protected function prepareOidcRequest(): void ->willReturn($this->requestObjectBagMock); } + protected function prepareOAuth2Request(?JarRequestObject $jarRequestObject = null): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); @@ -112,11 +129,13 @@ protected function prepareOAuth2Request(?JarRequestObject $jarRequestObject = nu ->willReturn($this->requestObjectBagMock); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestObjectRule::class, $this->sut()); } + public function testRequestParamCanBeAbsent(): void { $result = $this->sut()->checkRule( @@ -129,6 +148,7 @@ public function testRequestParamCanBeAbsent(): void $this->assertNull($result); } + public function testThrowsWhenRequestObjectSourceIsPresentButBagCannotBeResolved(): void { // `request` param present (source present), but the resolver could not parse/fetch it (null bag). @@ -145,6 +165,7 @@ public function testThrowsWhenRequestObjectSourceIsPresentButBagCannotBeResolved ); } + public function testUnprotectedRequestParamCanBeUsedForOidcRequest(): void { $this->prepareOidcRequest(); @@ -162,6 +183,7 @@ public function testUnprotectedRequestParamCanBeUsedForOidcRequest(): void $this->assertNotEmpty($result->getValue()); } + public function testMissingClientJwksThrows(): void { $this->prepareOidcRequest(); @@ -179,6 +201,7 @@ public function testMissingClientJwksThrows(): void ); } + public function testThrowsForInvalidRequestObject(): void { $this->prepareOidcRequest(); @@ -199,6 +222,7 @@ public function testThrowsForInvalidRequestObject(): void ); } + public function testReturnsValidRequestObject(): void { $this->prepareOidcRequest(); @@ -223,6 +247,7 @@ public function testReturnsValidRequestObject(): void $this->assertNotEmpty($result->getValue()); } + public function testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled(): void { $this->prepareOidcRequest(); @@ -241,6 +266,7 @@ public function testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled(): void ); } + public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void { $this->prepareOidcRequest(); @@ -260,6 +286,7 @@ public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void ); } + public function testAcceptsOidcRequestWhenAudienceIncludesIssuer(): void { $this->prepareOidcRequest(); @@ -278,6 +305,7 @@ public function testAcceptsOidcRequestWhenAudienceIncludesIssuer(): void $this->assertInstanceOf(Result::class, $result); } + public function testThrowsForOidcRequestWhenAudienceDoesNotIncludeIssuer(): void { $this->prepareOidcRequest(); @@ -296,6 +324,7 @@ public function testThrowsForOidcRequestWhenAudienceDoesNotIncludeIssuer(): void ); } + public function testThrowsForOAuth2RequestWhenAudienceDoesNotIncludeIssuer(): void { $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); @@ -317,6 +346,7 @@ public function testThrowsForOAuth2RequestWhenAudienceDoesNotIncludeIssuer(): vo ); } + public function testAcceptsOidcRequestWhenIssuerMatchesClient(): void { $this->prepareOidcRequest(); @@ -334,6 +364,7 @@ public function testAcceptsOidcRequestWhenIssuerMatchesClient(): void $this->assertInstanceOf(Result::class, $result); } + public function testThrowsForOidcRequestWhenIssuerDoesNotMatchClient(): void { $this->prepareOidcRequest(); @@ -351,6 +382,7 @@ public function testThrowsForOidcRequestWhenIssuerDoesNotMatchClient(): void ); } + public function testThrowsForOAuth2RequestWhenIssuerDoesNotMatchClient(): void { $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); @@ -371,6 +403,7 @@ public function testThrowsForOAuth2RequestWhenIssuerDoesNotMatchClient(): void ); } + public function testThrowsForOAuth2RequestWithNonJarRequestObject(): void { // For example, an unsigned Request Object is not a valid JAR Request Object. @@ -387,6 +420,7 @@ public function testThrowsForOAuth2RequestWithNonJarRequestObject(): void ); } + public function testThrowsForOAuth2RequestWithMismatchedClientIdClaim(): void { $this->jarRequestObjectMock->method('getClientId')->willReturn('otherClient'); @@ -403,6 +437,7 @@ public function testThrowsForOAuth2RequestWithMismatchedClientIdClaim(): void ); } + public function testReturnsValidJarRequestObjectForOAuth2Request(): void { $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php index 106664b8..27f25401 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -28,22 +29,35 @@ #[CoversClass(RequestUriRule::class)] #[UsesClass(Result::class)] +#[AllowMockObjectsWithoutExpectations] class RequestUriRuleTest extends TestCase { - protected const PAR_REQUEST_URI = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; - protected const HTTPS_REQUEST_URI = 'https://client.example.org/request-object.jwt'; + protected const string PAR_REQUEST_URI = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + + protected const string HTTPS_REQUEST_URI = 'https://client.example.org/request-object.jwt'; + protected MockObject $clientMock; + protected MockObject $resultBagMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $pushedAuthorizationRequestRepositoryMock; + protected MockObject $moduleConfigMock; + protected MockObject $parEntityMock; + protected Stub $requestStub; + protected MockObject $loggerServiceMock; + protected Helpers $helpers; + protected Stub $responseModeStub; + protected function setUp(): void { $this->clientMock = $this->createMock(ClientEntityInterface::class); @@ -65,6 +79,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut(): RequestUriRule { return new RequestUriRule( @@ -75,6 +90,7 @@ protected function sut(): RequestUriRule ); } + /** * Set raw request params which will be resolved from the request itself (not the merged view). * @@ -86,6 +102,7 @@ protected function prepareRawParams(array $params): void ->willReturnCallback(fn(string $paramKey): ?string => $params[$paramKey] ?? null); } + protected function checkRule(): mixed { return $this->sut()->checkRule( @@ -97,11 +114,13 @@ protected function checkRule(): mixed ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestUriRule::class, $this->sut()); } + public function testRequestUriParamCanBeAbsent(): void { $this->prepareRawParams([]); @@ -109,6 +128,7 @@ public function testRequestUriParamCanBeAbsent(): void $this->assertNull($this->checkRule()); } + public function testThrowsIfParIsRequiredGloballyButNotUsed(): void { $this->prepareRawParams([]); @@ -118,6 +138,7 @@ public function testThrowsIfParIsRequiredGloballyButNotUsed(): void $this->checkRule(); } + public function testThrowsIfParIsRequiredForClientButNotUsed(): void { $this->prepareRawParams([]); @@ -128,6 +149,7 @@ public function testThrowsIfParIsRequiredForClientButNotUsed(): void $this->checkRule(); } + public function testThrowsIfRequestAndRequestUriAreBothPresent(): void { $this->prepareRawParams([ @@ -140,6 +162,7 @@ public function testThrowsIfRequestAndRequestUriAreBothPresent(): void $this->checkRule(); } + public function testThrowsIfClientIdParamIsMissing(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI]); @@ -148,6 +171,7 @@ public function testThrowsIfClientIdParamIsMissing(): void $this->checkRule(); } + public function testThrowsForInvalidRequestUriScheme(): void { $this->prepareRawParams(['request_uri' => 'urn:other:thing', 'client_id' => 'client123']); @@ -156,6 +180,7 @@ public function testThrowsForInvalidRequestUriScheme(): void $this->checkRule(); } + public function testThrowsIfPushedAuthorizationRequestIsNotFound(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); @@ -165,6 +190,7 @@ public function testThrowsIfPushedAuthorizationRequestIsNotFound(): void $this->checkRule(); } + public function testThrowsIfPushedAuthorizationRequestIsExpired(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); @@ -175,6 +201,7 @@ public function testThrowsIfPushedAuthorizationRequestIsExpired(): void $this->checkRule(); } + public function testThrowsIfPushedAuthorizationRequestIsConsumed(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); @@ -186,6 +213,7 @@ public function testThrowsIfPushedAuthorizationRequestIsConsumed(): void $this->checkRule(); } + public function testThrowsIfPushedAuthorizationRequestIsBoundToDifferentClient(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); @@ -198,6 +226,7 @@ public function testThrowsIfPushedAuthorizationRequestIsBoundToDifferentClient() $this->checkRule(); } + public function testThrowsIfPushedAuthorizationRequestConsumptionFails(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); @@ -211,6 +240,7 @@ public function testThrowsIfPushedAuthorizationRequestConsumptionFails(): void $this->checkRule(); } + public function testCanUseValidPushedAuthorizationRequestUri(): void { $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); @@ -230,6 +260,7 @@ public function testCanUseValidPushedAuthorizationRequestUri(): void $this->assertSame(self::PAR_REQUEST_URI, $result->getValue()); } + public function testThrowsForHttpsRequestUriIfParIsRequired(): void { $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); @@ -239,6 +270,7 @@ public function testThrowsForHttpsRequestUriIfParIsRequired(): void $this->checkRule(); } + public function testThrowsForHttpsRequestUriIfNotSupported(): void { // Override the default (true) set in setUp via a fresh module config mock. @@ -264,6 +296,7 @@ public function testThrowsForHttpsRequestUriIfNotSupported(): void ); } + public function testThrowsForUnresolvableHttpsRequestUri(): void { $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); @@ -274,6 +307,7 @@ public function testThrowsForUnresolvableHttpsRequestUri(): void $this->checkRule(); } + public function testCanUseResolvableHttpsRequestUri(): void { $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php index 5f7561ec..f533e1be 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -23,18 +24,28 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule */ +#[AllowMockObjectsWithoutExpectations] class RequestedClaimsRuleTest extends TestCase { protected ResultBag $resultBag; + protected Stub $clientStub; + protected Stub $requestStub; + protected string $redirectUri = 'https://some-redirect-uri.org'; + protected Stub $loggerServiceStub; + /** @var string[] */ protected static array $userIdAttrs = ['uid']; + protected Stub $requestParamsResolverStub; + protected Stub $claimSetEntityFactoryStub; + protected Helpers $helpers; + protected Stub $responseModeStub; @@ -63,6 +74,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -82,6 +94,7 @@ protected function sut( ); } + /** * @throws \Throwable */ @@ -97,6 +110,7 @@ public function testNoRequestedClaims(): void $this->assertNull($result); } + /** * @throws \Throwable */ diff --git a/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php index cb15a571..ffd70df0 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -22,13 +23,17 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule */ +#[AllowMockObjectsWithoutExpectations] class RequiredNonceRuleTest extends TestCase { protected ResultBag $resultBag; + protected Result $redirectUriResult; + protected Result $stateResult; protected Stub $requestStub; + protected Helpers $helpers; protected array $requestQueryParams = [ @@ -37,9 +42,12 @@ class RequiredNonceRuleTest extends TestCase ]; protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -59,6 +67,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -72,6 +81,7 @@ protected function sut( ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -83,6 +93,7 @@ public function testCheckRuleRedirectUriDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -95,6 +106,7 @@ public function testCheckRuleStateDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -116,6 +128,7 @@ public function testCheckRulePassesWhenNonceIsPresent() $this->assertEquals($this->requestQueryParams['nonce'], $result->getValue()); } + /** * @throws \Throwable */ diff --git a/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php index 84de98eb..32742c47 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -24,21 +25,28 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule */ +#[AllowMockObjectsWithoutExpectations] class RequiredOpenIdScopeRuleTest extends TestCase { protected array $scopeEntities = []; protected Result $redirectUriResult; + protected Result $stateResult; + protected Result $scopeResult; protected Stub $requestStub; protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -58,6 +66,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -71,6 +80,7 @@ protected function sut( ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -82,6 +92,7 @@ public function testCheckRuleRedirectUriDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -94,6 +105,7 @@ public function testCheckRuleStateDependency(): void $this->sut()->checkRule($this->requestStub, $resultBag, $this->loggerServiceStub, [], $this->responseModeStub); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -117,6 +129,7 @@ public function testCheckRulePassesWhenOpenIdScopeIsPresent() $this->assertTrue($result->getValue()); } + /** * @throws \Throwable */ diff --git a/tests/unit/src/Server/RequestRules/Rules/ResponseModeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ResponseModeRuleTest.php index 78ab747a..bebae49b 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ResponseModeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ResponseModeRuleTest.php @@ -5,6 +5,8 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -28,18 +30,29 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule */ +#[AllowMockObjectsWithoutExpectations] class ResponseModeRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected ResultBag $resultBag; + protected Stub $loggerServiceStub; + protected Stub $responseModeStub; + protected Stub $clientStub; + protected Stub $queryResponseModeStub; + protected Stub $fragmentResponseModeStub; + protected Stub $formPostResponseModeStub; + protected Stub $moduleConfigStub; protected array $requestParams = [ @@ -48,6 +61,7 @@ class ResponseModeRuleTest extends TestCase 'response_mode' => 'query', ]; + protected function setUp(): void { $this->requestStub = $this->createStub(ServerRequestInterface::class); @@ -72,6 +86,7 @@ protected function setUp(): void $this->resultBag->add(new Result(StateRule::class, 'state123')); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -90,6 +105,7 @@ protected function sut( ); } + public function testThrowsWhenClientIdMissing(): void { $params = $this->requestParams; @@ -106,6 +122,7 @@ public function testThrowsWhenClientIdMissing(): void ); } + public function testReturnsQueryResponseModeWhenExplicitlyRequested(): void { $this->requestParamsResolverStub->method('getAllBasedOnAllowedMethods')->willReturn($this->requestParams); @@ -122,6 +139,7 @@ public function testReturnsQueryResponseModeWhenExplicitlyRequested(): void $this->assertSame($this->queryResponseModeStub, $result->getValue()); } + public function testReturnsFragmentResponseModeWhenExplicitlyRequested(): void { $params = $this->requestParams; @@ -140,6 +158,7 @@ public function testReturnsFragmentResponseModeWhenExplicitlyRequested(): void $this->assertSame($this->fragmentResponseModeStub, $result->getValue()); } + public function testReturnsFormPostResponseModeWhenExplicitlyRequested(): void { $params = $this->requestParams; @@ -158,6 +177,7 @@ public function testReturnsFormPostResponseModeWhenExplicitlyRequested(): void $this->assertSame($this->formPostResponseModeStub, $result->getValue()); } + public function testDefaultsToQueryWhenResponseModeNotSetAndResponseTypeIsCode(): void { $params = $this->requestParams; @@ -177,9 +197,8 @@ public function testDefaultsToQueryWhenResponseModeNotSetAndResponseTypeIsCode() $this->assertSame($this->queryResponseModeStub, $result->getValue()); } - /** - * @dataProvider tokenResponseTypeProvider - */ + + #[DataProvider('tokenResponseTypeProvider')] public function testDefaultsToFragmentWhenResponseModeNotSetAndResponseTypeContainsToken( string $responseType, ): void { @@ -200,6 +219,7 @@ public function testDefaultsToFragmentWhenResponseModeNotSetAndResponseTypeConta $this->assertSame($this->fragmentResponseModeStub, $result->getValue()); } + public static function tokenResponseTypeProvider(): array { return [ @@ -210,6 +230,7 @@ public static function tokenResponseTypeProvider(): array ]; } + public function testDefaultsToQueryWhenResponseModeAndResponseTypeNotSet(): void { $params = ['client_id' => 'client123']; @@ -227,6 +248,7 @@ public function testDefaultsToQueryWhenResponseModeAndResponseTypeNotSet(): void $this->assertSame($this->queryResponseModeStub, $result->getValue()); } + public function testThrowsOnInvalidResponseMode(): void { $params = $this->requestParams; @@ -243,6 +265,7 @@ public function testThrowsOnInvalidResponseMode(): void ); } + public function testThrowsWhenResponseModeNotAllowedByClient(): void { $this->clientStub = $this->createStub(ClientEntityInterface::class); @@ -267,6 +290,7 @@ public function testThrowsWhenResponseModeNotAllowedByClient(): void ); } + public function testThrowsWhenClientRuleResultMissing(): void { $resultBag = new ResultBag(); @@ -284,6 +308,7 @@ public function testThrowsWhenClientRuleResultMissing(): void ); } + public function testThrowsWhenRedirectUriResultMissing(): void { $resultBag = new ResultBag(); @@ -302,6 +327,7 @@ public function testThrowsWhenRedirectUriResultMissing(): void ); } + public function testThrowsWhenStateResultMissing(): void { $resultBag = new ResultBag(); @@ -321,6 +347,7 @@ public function testThrowsWhenStateResultMissing(): void ); } + public function testResultKeyMatchesRuleClass(): void { $this->requestParamsResolverStub->method('getAllBasedOnAllowedMethods')->willReturn($this->requestParams); diff --git a/tests/unit/src/Server/RequestRules/Rules/ResponseTypeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ResponseTypeRuleTest.php index aa1437a8..46748de2 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ResponseTypeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ResponseTypeRuleTest.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -23,10 +25,13 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseTypeRule */ +#[AllowMockObjectsWithoutExpectations] class ResponseTypeRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; protected array $requestParams = [ @@ -54,8 +59,10 @@ class ResponseTypeRuleTest extends TestCase private ResultBag $resultBag; protected Stub $loggerServiceStub; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -75,6 +82,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -88,10 +96,11 @@ protected function sut( ); } + /** - * @dataProvider validResponseTypeProvider * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ + #[DataProvider('validResponseTypeProvider')] public function testResponseTypeRuleTest($responseType) { $this->requestParams['response_type'] = $responseType; @@ -107,6 +116,7 @@ public function testResponseTypeRuleTest($responseType) $this->assertSame($responseType, $result->getValue()); } + public static function validResponseTypeProvider(): array { return [ @@ -115,6 +125,7 @@ public static function validResponseTypeProvider(): array ]; } + public function testRejectsResponseTypeNotRegisteredForClient(): void { $client = $this->createStub(ClientEntityInterface::class); @@ -138,6 +149,7 @@ public function testRejectsResponseTypeNotRegisteredForClient(): void ); } + public function testEmptyRegisteredResponseTypesIsNotEnforced(): void { // A present-but-empty response_types list means "not configured / unconstrained", not "allow nothing". @@ -162,6 +174,7 @@ public function testEmptyRegisteredResponseTypesIsNotEnforced(): void $this->assertSame('id_token', $result?->getValue()); } + public function testResponseTypeRuleThrowsWithNoResponseTypeParamTest() { $params = $this->requestParams; diff --git a/tests/unit/src/Server/RequestRules/Rules/ScopeOfflineAccessRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ScopeOfflineAccessRuleTest.php index eec02c23..1a90d69e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ScopeOfflineAccessRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ScopeOfflineAccessRuleTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; use League\OAuth2\Server\Entities\ScopeEntityInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -24,24 +25,40 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeOfflineAccessRule */ +#[AllowMockObjectsWithoutExpectations] class ScopeOfflineAccessRuleTest extends TestCase { protected Stub $serverRequestStub; + protected MockObject $resultBagMock; + protected MockObject $loggerServiceMock; + protected Stub $clientStub; + protected Stub $scopeEntityOpenid; + protected Stub $scopeEntityOfflineAccess; + protected Stub $redirectUriResultStub; + protected Stub $stateResultStub; + protected Stub $clientResultStub; + protected Stub $validScopesResultStub; + protected Stub $moduleConfigStub; + protected Stub $openIdConfigurationStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -74,6 +91,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -87,6 +105,7 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf( @@ -95,6 +114,7 @@ public function testCanCreateInstance(): void ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -130,6 +150,7 @@ public function testReturnsFalseWhenOfflineAccessScopeNotPresent(): void $this->assertFalse($result->getValue()); } + /** * @throws \Throwable */ @@ -164,6 +185,7 @@ public function testThrowsWhenClientDoesntHaveOfflineAccessScopeRegistered(): vo ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException diff --git a/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php index c5b8e6eb..131e007c 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php @@ -6,12 +6,14 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use LogicException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Builder\InvocationStubber; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\Str; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; @@ -26,29 +28,39 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule */ +#[AllowMockObjectsWithoutExpectations] class ScopeRuleTest extends TestCase { protected Stub $scopeRepositoryStub; + protected Stub $resultBagStub; + protected array $data = [ 'default_scope' => '', 'scope_delimiter_string' => ' ', ]; + protected string $scopes = 'openid profile'; protected array $scopeEntities = []; protected Result $redirectUriResult; + protected Result $stateResult; protected Stub $requestStub; protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Stub $helpersStub; + protected Stub $strHelperMock; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -66,11 +78,12 @@ protected function setUp(): void $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpersStub = $this->createStub(Helpers::class); - $this->strHelperMock = $this->createMock(Helpers\Str::class); + $this->strHelperMock = $this->createMock(Str::class); $this->helpersStub->method('str')->willReturn($this->strHelperMock); $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -87,11 +100,13 @@ protected function sut( ); } + public function testConstruct(): void { $this->assertInstanceOf(ScopeRule::class, $this->sut()); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -109,6 +124,7 @@ public function testCheckRuleRedirectUriDependency(): void ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -127,6 +143,7 @@ public function testCheckRuleStateDependency(): void ); } + /** * @throws \Throwable * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -159,6 +176,7 @@ public function testValidScopes(): void $this->assertSame($this->scopeEntities['profile'], $result->getValue()[1]); } + /** * @throws \Throwable */ @@ -184,6 +202,7 @@ public function testInvalidScopeThrows(): void ); } + protected function prepareValidResultBag(): ResultBag { $resultBag = new ResultBag(); @@ -192,6 +211,7 @@ protected function prepareValidResultBag(): ResultBag return $resultBag; } + protected function prepareValidScopeRepositoryStub(): InvocationStubber { return $this->scopeRepositoryStub diff --git a/tests/unit/src/Server/RequestRules/Rules/StateRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/StateRuleTest.php index faeb0ec6..96b56944 100644 --- a/tests/unit/src/Server/RequestRules/Rules/StateRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/StateRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -19,13 +20,18 @@ * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\AbstractRule * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule */ +#[AllowMockObjectsWithoutExpectations] class StateRuleTest extends TestCase { protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -37,6 +43,7 @@ public function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -50,11 +57,13 @@ protected function sut( ); } + public function testGetKey(): void { $this->assertSame(StateRule::class, $this->sut()->getKey()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -80,6 +89,7 @@ public function testCheckRuleHasValue(): void $this->assertSame($value, $result->getValue()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception diff --git a/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php index 5ad94f12..7e42ddb5 100644 --- a/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -18,15 +19,22 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule */ +#[AllowMockObjectsWithoutExpectations] class UiLocalesRuleTest extends TestCase { protected Stub $requestStub; + protected Stub $resultBagStub; + protected Stub $loggerServiceStub; + protected Stub $requestParamsResolverStub; + protected Helpers $helpers; + protected Stub $responseModeStub; + /** * @throws \Exception */ @@ -42,6 +50,7 @@ protected function setUp(): void $this->responseModeStub = $this->createStub(ResponseModeInterface::class); } + protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, @@ -55,6 +64,7 @@ protected function sut( ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -74,6 +84,7 @@ public function testCheckRuleReturnsResultWhenParamSet() $this->assertEquals('en', $result->getValue()); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/tests/unit/src/Server/RequestTypes/AuthorizationRequestTest.php b/tests/unit/src/Server/RequestTypes/AuthorizationRequestTest.php index 656e66eb..1c49a1f1 100644 --- a/tests/unit/src/Server/RequestTypes/AuthorizationRequestTest.php +++ b/tests/unit/src/Server/RequestTypes/AuthorizationRequestTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestTypes; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; /** * @covers \SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest */ +#[AllowMockObjectsWithoutExpectations] class AuthorizationRequestTest extends TestCase { public function testIncomplete(): never diff --git a/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php b/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php index 9f5edd34..9e96c3aa 100644 --- a/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php +++ b/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestTypes; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; @@ -12,14 +13,18 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest */ +#[AllowMockObjectsWithoutExpectations] class LogoutRequestTest extends TestCase { protected Stub $idTokenHintStub; protected static string $postLogoutRedirectUri = 'https://redirect.org/uri'; + protected static string $state = 'state123'; + protected static string $uiLocales = 'en'; + /** * @throws \Exception */ @@ -28,6 +33,7 @@ protected function setUp(): void $this->idTokenHintStub = $this->createStub(IdToken::class); } + public function testConstructWithoutParams(): void { $logoutRequest = new LogoutRequest(); @@ -39,6 +45,7 @@ public function testConstructWithoutParams(): void $this->assertNull($logoutRequest->getUiLocales()); } + public function testConstructWithParams(): void { $logoutRequest = new LogoutRequest( @@ -56,6 +63,7 @@ public function testConstructWithParams(): void $this->assertEquals(self::$uiLocales, $logoutRequest->getUiLocales()); } + public function testFluentPropertySetters(): void { $logoutRequest = (new LogoutRequest()) diff --git a/tests/unit/src/Server/ResponseModes/FormPostResponseModeTest.php b/tests/unit/src/Server/ResponseModes/FormPostResponseModeTest.php index f75c1d7b..60c613c3 100644 --- a/tests/unit/src/Server/ResponseModes/FormPostResponseModeTest.php +++ b/tests/unit/src/Server/ResponseModes/FormPostResponseModeTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\ResponseModes; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Factories\TemplateFactory; @@ -15,10 +16,12 @@ /** * @covers \SimpleSAML\Module\oidc\Server\ResponseModes\FormPostResponseMode */ +#[AllowMockObjectsWithoutExpectations] class FormPostResponseModeTest extends TestCase { protected FormPostResponseMode $sut; + protected function setUp(): void { $config = Configuration::loadFromArray([ @@ -51,6 +54,7 @@ function ( $this->sut = new FormPostResponseMode($templateFactory); } + public function testBuildResponseReturnsHtmlWithFormPost(): void { $result = $this->sut->buildResponse( diff --git a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index c8d281d9..485a300c 100644 --- a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -8,6 +8,7 @@ use Exception; use League\OAuth2\Server\CryptKey; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -33,33 +34,59 @@ /** * @covers \SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse */ +#[AllowMockObjectsWithoutExpectations] class TokenResponseTest extends TestCase { - final public const TOKEN_ID = 'tokenId'; - final public const ISSUER = 'someIssuer'; - final public const CLIENT_ID = 'clientId'; - final public const SUBJECT = 'userId'; - final public const KEY_ID = 'bafd184e90a88107054f4bc05f5e7a76'; - final public const USER_ID_ATTR = 'uid'; + final public const string TOKEN_ID = 'tokenId'; + + final public const string ISSUER = 'someIssuer'; + + final public const string CLIENT_ID = 'clientId'; + + final public const string SUBJECT = 'userId'; + + final public const string KEY_ID = 'bafd184e90a88107054f4bc05f5e7a76'; + + final public const string USER_ID_ATTR = 'uid'; + + protected string $certFolder; + protected UserEntity $userEntity; + protected array $scopes; + protected DateTimeImmutable $expiration; + protected MockObject $clientEntityMock; + protected MockObject $accessTokenEntityMock; + protected MockObject $identityProviderMock; + protected MockObject $moduleConfigMock; + protected MockObject $sspConfigurationMock; + protected CryptKey $privateKey; + 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 * @throws \ReflectionException @@ -133,6 +160,7 @@ protected function setUp(): void $this->idTokenMock = $this->createMock(IdToken::class); } + protected function prepareMockedInstance(?IdTokenBuilder $idTokenBuilder = null): TokenResponse { $idTokenBuilder ??= $this->idTokenBuilder; @@ -152,6 +180,7 @@ protected function prepareMockedInstance(?IdTokenBuilder $idTokenBuilder = null) return $tokenResponse; } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -160,6 +189,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \Exception */ @@ -181,6 +211,7 @@ public function testItCanGenerateResponse(): void $this->assertTrue($this->shouldHaveValidIdToken($body)); } + /** * @throws \Exception */ @@ -219,6 +250,7 @@ public function testItCanGenerateResponseWithIndividualRequestedClaims(): void $this->assertTrue($this->shouldHaveValidIdToken($body, ['name' => 'Homer Simpson'])); } + /** * When the client is configured to release user claims in the ID Token (admin-only * `add_claims_to_id_token`), the ID Token is built with $addClaimsFromScopes = true, so the scope-derived @@ -253,6 +285,7 @@ public function testReleasesUserClaimsInIdTokenWhenClientConfiguredTo(): void $idTokenResponse->generateHttpResponse(new Response()); } + /** * By default (client not configured to release claims in the ID Token), the ID Token is built with * $addClaimsFromScopes = false, so scope-derived user claims remain available only at the UserInfo endpoint. @@ -286,6 +319,7 @@ public function testDoesNotReleaseUserClaimsInIdTokenByDefault(): void $idTokenResponse->generateHttpResponse(new Response()); } + public function testNoExtraParamsForNonOidcRequest(): void { $this->accessTokenEntityMock->method('getRequestedClaims')->willReturn([]); @@ -302,6 +336,7 @@ public function testNoExtraParamsForNonOidcRequest(): void $this->shouldHaveValidIdToken($body); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php index 9df6db39..920564bf 100644 --- a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php +++ b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php @@ -6,6 +6,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -26,23 +27,38 @@ /** * @covers \SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator */ +#[AllowMockObjectsWithoutExpectations] class BearerTokenValidatorTest extends TestCase { protected MockObject $accessTokenRepositoryMock; + 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 */ @@ -85,6 +101,7 @@ public function setUp(): void $this->parsedJwsMock->method('getIssuer')->willReturn('issuer123'); } + protected function sut( ?AccessTokenRepository $accessTokenRepository = null, ?ModuleConfig $moduleConfig = null, @@ -107,6 +124,7 @@ protected function sut( ); } + public function testValidatorThrowsForNonExistentAccessToken() { $this->expectException(OidcServerException::class); @@ -114,6 +132,7 @@ public function testValidatorThrowsForNonExistentAccessToken() $this->sut()->validateAuthorization($this->serverRequest); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -133,6 +152,7 @@ public function testValidatesForAuthorizationHeader() ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -159,6 +179,7 @@ public function testValidatesForPostBodyParam() ); } + public function testThrowsForUnparsableAccessToken() { $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . 'invalid'); @@ -172,6 +193,7 @@ public function testThrowsForUnparsableAccessToken() $this->sut()->validateAuthorization($serverRequest); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception @@ -191,6 +213,7 @@ public function testThrowsForRevokedAccessToken() $this->sut()->validateAuthorization($serverRequest); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \JsonException diff --git a/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php b/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php index 8c932d41..da57bf40 100644 --- a/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php +++ b/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services\Api; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -13,15 +14,19 @@ use SimpleSAML\Module\oidc\Services\LoggerService; #[CoversClass(ApiTokenPrincipalResolver::class)] +#[AllowMockObjectsWithoutExpectations] class ApiTokenPrincipalResolverTest extends TestCase { protected const string TOKEN = 'a-strong-random-token'; protected const string OTHER_TOKEN = 'another-strong-random-token'; + protected MockObject $moduleConfigMock; + protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -30,6 +35,7 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut(?ModuleConfig $moduleConfig = null): ApiTokenPrincipalResolver { return new ApiTokenPrincipalResolver( @@ -38,6 +44,7 @@ protected function sut(?ModuleConfig $moduleConfig = null): ApiTokenPrincipalRes ); } + /** * Nothing stops an operator writing the token as its own display name, and the resulting audit * row would hold the bearer secret this whole class exists to keep out of it. @@ -55,6 +62,7 @@ public function testIgnoresANameWhichIsTheTokenItself(): void $this->assertMatchesRegularExpression('/^token:[0-9a-f]{16}$/', $principal); } + /** * A name the token is buried in is no safer than one which is the token, and reads far more like * something an operator would write on purpose. @@ -70,6 +78,7 @@ public function testIgnoresANameWhichMerelyCarriesTheToken(): void $this->assertStringNotContainsString(self::TOKEN, $this->sut()->resolve(self::TOKEN)); } + /** * A name is just as dangerous when the secret buried in it belongs to a different token, and that * token would authenticate perfectly well while leaking somebody else's. @@ -85,6 +94,7 @@ public function testIgnoresANameCarryingSomeOtherConfiguredToken(): void $this->assertStringNotContainsString(self::OTHER_TOKEN, $this->sut()->resolve(self::TOKEN)); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -95,6 +105,7 @@ public function testUsesTheConfiguredName(): void $this->assertSame('HR system', $this->sut()->resolve(self::TOKEN)); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -105,6 +116,7 @@ public function testFallsBackToAFingerprintWhenThereIsNoName(): void $this->assertMatchesRegularExpression('/^token:[0-9a-f]{16}$/', $this->sut()->resolve(self::TOKEN)); } + /** * An audit trail whose actor is the same for every caller records nothing worth having. * @@ -120,6 +132,7 @@ public function testFingerprintsDistinguishTokens(): void ); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -130,6 +143,7 @@ public function testFingerprintsAreStable(): void $this->assertSame($this->sut()->resolve(self::TOKEN), $this->sut()->resolve(self::TOKEN)); } + /** * The fingerprint goes into a database table. An unkeyed hash of a token an operator chose badly * could be confirmed by guessing it, which would make the audit trail an oracle for the very @@ -156,6 +170,7 @@ public function testTheFingerprintIsKeyedRatherThanAPlainHash(): void ); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -166,6 +181,7 @@ public function testNeverReturnsTheTokenItself(): void $this->assertStringNotContainsString(self::TOKEN, $this->sut()->resolve(self::TOKEN)); } + /** * Naming the token is the way out of this, and the message says so. */ diff --git a/tests/unit/src/Services/Api/AuthorizationTest.php b/tests/unit/src/Services/Api/AuthorizationTest.php index f0a46e3c..ac7d6647 100644 --- a/tests/unit/src/Services/Api/AuthorizationTest.php +++ b/tests/unit/src/Services/Api/AuthorizationTest.php @@ -4,10 +4,12 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services\Api; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Exceptions\InsufficientScopeException; @@ -21,16 +23,23 @@ use Symfony\Component\HttpFoundation\Request; #[CoversClass(Authorization::class)] +#[AllowMockObjectsWithoutExpectations] class AuthorizationTest extends TestCase { protected const string TOKEN = 'a-strong-random-token'; + protected MockObject $moduleConfigMock; + protected MockObject $sspBridgeMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $apiTokenPrincipalResolverMock; + protected Helpers $helpers; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -41,6 +50,7 @@ protected function setUp(): void $this->helpers = new Helpers(); } + protected function sut(): Authorization { return new Authorization( @@ -52,6 +62,7 @@ protected function sut(): Authorization ); } + /** * @param array $headers * @param array $query @@ -67,6 +78,7 @@ protected function request(array $headers = [], array $query = []): Request return new Request($query, [], [], [], [], $server); } + /** * @return \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] */ @@ -75,6 +87,7 @@ protected function requiredScopes(): array return [ApiScopesEnum::VciCredentialStatus, ApiScopesEnum::VciAll, ApiScopesEnum::All]; } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException * @throws \SimpleSAML\Error\ConfigurationError @@ -92,6 +105,7 @@ public function testAuthorizesABearerTokenHoldingARequiredScope(): void ); } + /** * It returns who the caller is, never the secret they proved it with, so that nothing downstream * can put a bearer token into a log line or an audit row. @@ -122,6 +136,7 @@ public function testReturnsAPrincipalRatherThanTheToken(): void ); } + /** * A request with no token at all is a different answer from one whose token was refused, since * the challenge sent back differs. @@ -133,6 +148,7 @@ public function testDistinguishesAMissingTokenFromARefusedOne(): void $this->sut()->requireBearerTokenForAnyOfScope($this->request(), $this->requiredScopes()); } + /** * The reason this method exists at all. requireTokenForAnyOfScope() authorizes an administrator's * session before it examines any token, which means a request carrying an administrator's cookies @@ -143,7 +159,7 @@ public function testDoesNotAcceptAnAdministratorSessionInPlaceOfAToken(): void { $auth = $this->createMock(SspAuth::class); $auth->method('isAdmin')->willReturn(true); - $utils = $this->createMock(SspBridge\Utils::class); + $utils = $this->createMock(Utils::class); $utils->method('auth')->willReturn($auth); $this->sspBridgeMock->method('utils')->willReturn($utils); @@ -152,6 +168,7 @@ public function testDoesNotAcceptAnAdministratorSessionInPlaceOfAToken(): void $this->sut()->requireBearerTokenForAnyOfScope($this->request(), $this->requiredScopes()); } + /** * A token in the query string ends up in access logs, in browser history and in the Referer of * whatever the response links to. @@ -170,6 +187,7 @@ public function testDoesNotAcceptTheTokenAsARequestParameter(): void ); } + public function testRefusesARequestWithNoAuthorizationHeader(): void { $this->expectException(AuthorizationException::class); @@ -177,6 +195,7 @@ public function testRefusesARequestWithNoAuthorizationHeader(): void $this->sut()->requireBearerTokenForAnyOfScope($this->request(), $this->requiredScopes()); } + public function testRefusesATokenWithNoConfiguredScopes(): void { $this->moduleConfigMock->method('getApiTokenScopes')->willReturn(null); @@ -189,6 +208,7 @@ public function testRefusesATokenWithNoConfiguredScopes(): void ); } + /** * A known token which does not cover this action is a different answer from an unusable one: the * caller is authenticated, and rotating its token would not help. @@ -206,6 +226,7 @@ public function testRefusesATokenWhoseScopesDoNotCoverTheAction(): void ); } + /** * A token which is not configured and one configured without scopes are answered the same way, so * that a caller can not use the difference to test whether a token exists. diff --git a/tests/unit/src/Services/AuthContextServiceTest.php b/tests/unit/src/Services/AuthContextServiceTest.php index 8cc7f36b..9c5eb54a 100644 --- a/tests/unit/src/Services/AuthContextServiceTest.php +++ b/tests/unit/src/Services/AuthContextServiceTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -17,18 +18,26 @@ /** * @covers \SimpleSAML\Module\oidc\Services\AuthContextService */ +#[AllowMockObjectsWithoutExpectations] class AuthContextServiceTest extends TestCase { - final public const AUTHORIZED_USER = [ + final public const array AUTHORIZED_USER = [ 'idAttribute' => ['myUsername'], 'someEntitlement' => ['val1', 'val2', 'val3'], ]; + + protected Configuration $permissions; + protected MockObject $oidcConfigurationMock; + protected MockObject $moduleConfigMock; + protected MockObject $authSimpleService; + protected MockObject $authSimpleFactory; + /** * @throws \PHPUnit\Framework\MockObject\Exception */ @@ -54,6 +63,7 @@ protected function setUp(): void $this->authSimpleFactory->method('getDefaultAuthSource')->willReturn($this->authSimpleService); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?AuthSimpleFactory $authSimpleFactory = null, @@ -70,6 +80,7 @@ protected function sut( ); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -78,6 +89,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \Exception */ @@ -92,6 +104,7 @@ public function testItReturnsUsername(): void ); } + public function testItRespectsCandidatePriority(): void { $this->moduleConfigMock->method('getUserIdentifierAttributes') @@ -104,6 +117,7 @@ public function testItRespectsCandidatePriority(): void ); } + public function testItThrowsWhenNoUsername(): void { $this->moduleConfigMock->method('getUserIdentifierAttributes')->willReturn(['attributeNotSet']); @@ -113,6 +127,7 @@ public function testItThrowsWhenNoUsername(): void $this->sut()->getAuthUserId(); } + /** * @throws \Exception */ @@ -124,9 +139,10 @@ public function testPermissionsOk(): void $this->authSimpleService->method('getAttributes')->willReturn(self::AUTHORIZED_USER); $this->sut()->requirePermission('client'); - $this->expectNotToPerformAssertions(); + $this->assertTrue(true); } + /** * @throws \Exception */ @@ -139,6 +155,7 @@ public function testItThrowsIfNotAuthorizedForPermission(): void $this->sut()->requirePermission('no-match'); } + /** * @throws \Exception */ @@ -159,6 +176,7 @@ public function testItThrowsForWrongEntitlements(): void $this->sut()->requirePermission('client'); } + /** * @throws \Exception */ @@ -178,6 +196,7 @@ public function testItThrowsForNotHavingEntitlementAttribute(): void $this->sut()->requirePermission('client'); } + /** * @throws \Exception */ diff --git a/tests/unit/src/Services/AuthenticationServiceTest.php b/tests/unit/src/Services/AuthenticationServiceTest.php index e7ff08b2..4072a992 100644 --- a/tests/unit/src/Services/AuthenticationServiceTest.php +++ b/tests/unit/src/Services/AuthenticationServiceTest.php @@ -7,6 +7,7 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest as OAuth2AuthorizationRequest; use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\Uri; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; @@ -24,6 +25,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\Client; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; @@ -41,21 +43,34 @@ /** * @covers \SimpleSAML\Module\oidc\Services\AuthenticationService */ +#[AllowMockObjectsWithoutExpectations] class AuthenticationServiceTest extends TestCase { - final public const URI = 'https://some-server/authorize.php?abc=efg'; - final public const AUTH_SOURCE = 'auth_source'; - final public const USER_ID_ATTR = 'uid'; - final public const USERNAME = 'username'; - final public const OIDC_OP_METADATA = ['issuer' => 'https://idp.example.org']; - final public const USER_ENTITY_ATTRIBUTES = [ + final public const string URI = 'https://some-server/authorize.php?abc=efg'; + + final public const string AUTH_SOURCE = 'auth_source'; + + final public const string USER_ID_ATTR = 'uid'; + + final public const string USERNAME = 'username'; + + final public const array OIDC_OP_METADATA = ['issuer' => 'https://idp.example.org']; + + final public const array USER_ENTITY_ATTRIBUTES = [ self::USER_ID_ATTR => [self::USERNAME], 'eduPersonTargetedId' => [self::USERNAME], ]; - final public const AUTH_DATA = ['Attributes' => self::USER_ENTITY_ATTRIBUTES]; - final public const CLIENT_ENTITY = ['id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; - final public const AUTHZ_REQUEST_PARAMS = ['client_id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; - final public const STATE = [ + + final public const array AUTH_DATA = ['Attributes' => self::USER_ENTITY_ATTRIBUTES]; + + final public const array CLIENT_ENTITY = ['id' => 'clientid', 'redirect_uri' => 'https://rp.example.org']; + + final public const array AUTHZ_REQUEST_PARAMS = [ + 'client_id' => 'clientid', + 'redirect_uri' => 'https://rp.example.org', + ]; + + final public const array STATE = [ 'Attributes' => self::AUTH_DATA['Attributes'], 'Oidc' => [ 'OpenIdProviderMetadata' => self::OIDC_OP_METADATA, @@ -64,29 +79,52 @@ class AuthenticationServiceTest extends TestCase ], ]; + protected MockObject $authSimpleFactoryMock; + protected MockObject $authSimpleMock; + protected MockObject $authSourceMock; + protected MockObject $authorizationRequestMock; + protected MockObject $claimTranslatorExtractorMock; + protected MockObject $clientEntityMock; + protected MockObject $clientRepositoryMock; + protected MockObject $moduleConfigMock; + protected MockObject $opMetadataService; + protected MockObject $processingChainFactoryMock; + protected MockObject $processingChainMock; + protected MockObject $serverRequestMock; + protected MockObject $sessionMock; + protected MockObject $sessionServiceMock; + protected MockObject $stateServiceMock; + protected MockObject $userEntityMock; + protected MockObject $userRepositoryMock; + protected MockObject $helpersMock; + protected MockObject $clientHelperMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $userEntityFactoryMock; + protected MockObject $routesMock; + /** * @throws \PHPUnit\Framework\MockObject\Exception */ @@ -123,7 +161,7 @@ protected function setUp(): void $this->sessionServiceMock->method('getCurrentSession')->willReturn($this->sessionMock); $this->helpersMock = $this->createMock(Helpers::class); - $this->clientHelperMock = $this->createMock(Helpers\Client::class); + $this->clientHelperMock = $this->createMock(Client::class); $this->helpersMock->method('client')->willReturn($this->clientHelperMock); $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); @@ -134,8 +172,9 @@ protected function setUp(): void $this->routesMock = $this->createMock(Routes::class); } + /** - * @return AuthenticationService + * @return \SimpleSAML\Module\oidc\Services\AuthenticationService */ public function mock(): AuthenticationService { @@ -161,6 +200,7 @@ public function mock(): AuthenticationService ->getMock(); } + /** * @return void */ @@ -172,9 +212,10 @@ public function testItIsInitializable(): void ); } + /** * @return void - * @throws Exception + * @throws \SimpleSAML\Error\Exception * @throws \JsonException * @throws \SimpleSAML\Error\BadRequest * @throws \SimpleSAML\Error\NotFound @@ -205,6 +246,7 @@ public function testItCreatesNewUser(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \SimpleSAML\Error\NotFound @@ -229,7 +271,7 @@ public function testItReturnsAnUser(): void $this->userRepositoryMock->expects($this->once())->method('update')->with($this->userEntityMock); $this->claimTranslatorExtractorMock->expects($this->once())->method('extract') - ->with(['openid'], $this->isType('array')) + ->with(['openid'], $this->isArray()) ->willReturn([]); $this->assertSame( @@ -238,6 +280,7 @@ public function testItReturnsAnUser(): void ); } + /** * @return array */ @@ -307,9 +350,10 @@ public function testGetAuthenticateUserItThrowsWhenState( $this->mock()->getAuthenticateUser($state); } + /** * @return void - * @throws Exception + * @throws \SimpleSAML\Error\Exception * @throws \JsonException * @throws \SimpleSAML\Error\BadRequest * @throws \SimpleSAML\Error\NotFound @@ -326,6 +370,7 @@ public function testGetAuthenticateUserItThrowsIfClaimsNotExist(): void $this->mock()->getAuthenticateUser($invalidState); } + /** * @return void * @throws \JsonException @@ -340,6 +385,7 @@ public function testItAuthenticates(): void $this->mock()->authenticateForClient($this->clientEntityMock); } + /** * @return void * @throws \SimpleSAML\Error\AuthSource @@ -368,6 +414,7 @@ public function testItConstructsStateArray(): void ); } + /** * @return array */ @@ -433,6 +480,7 @@ public function testItProcessesRequest(bool $isAuthnPer): void ); } + /** * When the user is not yet authenticated and a login_hint is present in the authorization request, it must be * propagated to the authentication source as the pre-filled username (the 'core:username' login parameter). @@ -481,6 +529,7 @@ public function testItPropagatesLoginHintToAuthentication(): void ); } + /** * The subject matches when it equals the resolved user identifier (the default `sub` produced by * IdTokenBuilder when no `sub` mapping is in effect). @@ -496,6 +545,7 @@ public function testSubjectMatchesAttributesMatchesUserIdentifier(): void ); } + /** * The subject also matches a mapped `sub` claim, so a hint issued when the `sub` mapping was applied is * accepted for the same user. @@ -511,6 +561,7 @@ public function testSubjectMatchesAttributesMatchesMappedSubClaim(): void ); } + /** * A subject that matches neither the user identifier nor a mapped `sub` claim identifies a different * End-User and must not match. @@ -526,6 +577,7 @@ public function testSubjectMatchesAttributesRejectsDifferentSubject(): void ); } + /** * When no user identifier can be resolved from the attributes, no subject can match. */ @@ -536,8 +588,9 @@ public function testSubjectMatchesAttributesReturnsFalseWhenNoIdentifier(): void ); } + /** - * @throws NoState + * @throws \SimpleSAML\Error\NoState */ public function testItThrowsOnMissingQueryParameterAuthparam(): void { @@ -545,8 +598,9 @@ public function testItThrowsOnMissingQueryParameterAuthparam(): void $this->mock()->manageState([]); } + /** - * @throws NoState + * @throws \SimpleSAML\Error\NoState */ public function testLoadStateFromProcessingChainRedirect(): void { @@ -575,6 +629,7 @@ public function testLoadStateFromProcessingChainRedirect(): void $this->assertEquals('456', $mock->getAuthSourceId()); } + /** * @return void */ @@ -622,6 +677,7 @@ public function runAuthProcsPublic(array &$state): void $this->assertArrayHasKey('Attributes', $state); } + /** * Per-client authproc filters (from the relying party metadata) must be * applied on the SP (Destination) side, while the global filters remain on @@ -697,11 +753,12 @@ public static function authorizationRequestInstanceOf(): array /** * @param array $state - * @param AuthorizationRequest|OAuth2AuthorizationRequest $authorizationRequest + * @param \SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest|\League\OAuth2\Server\RequestTypes\AuthorizationRequest + * $authorizationRequest * @param string $instanceOf * * @return void - * @throws Exception + * @throws \SimpleSAML\Error\Exception */ #[DataProvider('authorizationRequestInstanceOf')] public function testItGetsAuthorizationRequestFromState( @@ -720,6 +777,7 @@ public function testItGetsAuthorizationRequestFromState( ); } + /** * @return array */ @@ -747,7 +805,7 @@ public static function authorizationRequestValues(): array * @param string $exceptionMessage * * @return void - * @throws Exception + * @throws \SimpleSAML\Error\Exception */ #[DataProvider('authorizationRequestValues')] public function testGetsAuthorizationRequestFromStateThrowsOnInvalid(array $state, string $exceptionMessage): void diff --git a/tests/unit/src/Services/ErrorResponderTest.php b/tests/unit/src/Services/ErrorResponderTest.php index 506374ae..ceccca13 100644 --- a/tests/unit/src/Services/ErrorResponderTest.php +++ b/tests/unit/src/Services/ErrorResponderTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; use League\OAuth2\Server\Exception\OAuthServerException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,22 +16,27 @@ use SimpleSAML\Module\oidc\Services\LoggerService; #[CoversClass(ErrorResponder::class)] +#[AllowMockObjectsWithoutExpectations] class ErrorResponderTest extends TestCase { protected MockObject $psrHttpBridgeMock; + protected MockObject $loggerServiceMock; + protected function setUp(): void { $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut(): ErrorResponder { return new ErrorResponder($this->psrHttpBridgeMock, $this->loggerServiceMock); } + public function testForExceptionJsonLogsClientErrorAsNotice(): void { $this->loggerServiceMock->expects($this->once())->method('notice'); @@ -42,6 +48,7 @@ public function testForExceptionJsonLogsClientErrorAsNotice(): void $this->assertSame(400, $response->getStatusCode()); } + public function testForExceptionJsonLogsAccessDeniedAsWarning(): void { $this->loggerServiceMock->expects($this->once())->method('warning'); @@ -52,6 +59,7 @@ public function testForExceptionJsonLogsAccessDeniedAsWarning(): void $this->assertSame(401, $response->getStatusCode()); } + public function testForExceptionJsonLogsServerErrorAsError(): void { $this->loggerServiceMock->expects($this->once())->method('error'); @@ -62,6 +70,7 @@ public function testForExceptionJsonLogsServerErrorAsError(): void $this->assertSame(500, $response->getStatusCode()); } + public function testForExceptionLogsUnexpectedThrowableAsError(): void { $this->loggerServiceMock->expects($this->once())->method('error'); diff --git a/tests/unit/src/Services/ExpiredEntriesCleanerTest.php b/tests/unit/src/Services/ExpiredEntriesCleanerTest.php index 60096674..67773724 100644 --- a/tests/unit/src/Services/ExpiredEntriesCleanerTest.php +++ b/tests/unit/src/Services/ExpiredEntriesCleanerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,14 +16,20 @@ use SimpleSAML\Module\oidc\Services\ExpiredEntriesCleaner; #[CoversClass(ExpiredEntriesCleaner::class)] +#[AllowMockObjectsWithoutExpectations] class ExpiredEntriesCleanerTest extends TestCase { private AccessTokenRepository&MockObject $accessTokenRepositoryMock; + private AuthCodeRepository&MockObject $authCodeRepositoryMock; + private RefreshTokenRepository&MockObject $refreshTokenRepositoryMock; + private IssuerStateRepository&MockObject $issuerStateRepositoryMock; + private PushedAuthorizationRequestRepository&MockObject $pushedAuthorizationRequestRepositoryMock; + protected function setUp(): void { $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class); @@ -34,6 +41,7 @@ protected function setUp(): void ); } + public function testItIsInitializable(): void { $cleaner = new ExpiredEntriesCleaner( @@ -47,6 +55,7 @@ public function testItIsInitializable(): void $this->assertInstanceOf(ExpiredEntriesCleaner::class, $cleaner); } + public function testCleanRemovesExpiredAndInvalidEntries(): void { $this->accessTokenRepositoryMock->expects($this->once()) diff --git a/tests/unit/src/Services/IdTokenBuilderTest.php b/tests/unit/src/Services/IdTokenBuilderTest.php index 573c6165..6cba77ab 100644 --- a/tests/unit/src/Services/IdTokenBuilderTest.php +++ b/tests/unit/src/Services/IdTokenBuilderTest.php @@ -6,7 +6,9 @@ use DateTimeImmutable; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\UserEntityInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -32,20 +34,32 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; #[CoversClass(IdTokenBuilder::class)] +#[AllowMockObjectsWithoutExpectations] class IdTokenBuilderTest extends TestCase { 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); @@ -81,6 +95,7 @@ protected function setUp(): void $this->accessTokenEntityMock->method('getScopes')->willReturn([$this->scopeEntityMock]); } + protected function sut( ?ClaimTranslatorExtractor $claimTranslatorExtractor = null, ?Core $core = null, @@ -97,11 +112,13 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(IdTokenBuilder::class, $this->sut()); } + public function testCanBuild(): void { $this->moduleConfigMock->expects($this->once())->method('getIssuer') @@ -138,6 +155,7 @@ public function testCanBuild(): void ); } + /** * The issued `sub` must be the canonical subject (the mapped `sub` claim when one is configured), regardless of * whether the client releases the user's claims in the ID Token. A stable `sub` is relied upon elsewhere, e.g. @@ -192,6 +210,7 @@ public function testSubIsCanonicalRegardlessOfClaimRelease(): void ); } + /** * The subject is REQUIRED, so it must be kept even when its value would be considered falsy (e.g. "0"). */ @@ -237,6 +256,7 @@ public function testSubIsKeptForFalsyValue(): void ); } + public function testWillNegotiateIdTokenSignatureAlgorithm(): void { $this->clientEntityMock->method('getIdTokenSignedResponseAlg') @@ -266,6 +286,7 @@ public function testWillNegotiateIdTokenSignatureAlgorithm(): void ); } + public function testThrowsForInvalidUserEntity(): void { $userEntityInterfaceMock = $this->createMock(UserEntityInterface::class); @@ -284,11 +305,12 @@ public function testThrowsForInvalidUserEntity(): void ); } + public function testThrowsForInvalidClientEntity(): void { $accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); $accessTokenEntityMock->method('getClient')->willReturn( - $this->createMock(\League\OAuth2\Server\Entities\ClientEntityInterface::class), + $this->createMock(ClientEntityInterface::class), ); $this->expectException(RuntimeException::class); @@ -306,6 +328,7 @@ public function testThrowsForInvalidClientEntity(): void ); } + public function testGenerateAccessTokenHash(): void { $accessTokenMock = $this->createMock(AccessTokenEntity::class); @@ -326,6 +349,7 @@ public function testGenerateAccessTokenHash(): void ); } + public function testGenerateAccessTokenHashWithEdDsa(): void { $accessTokenMock = $this->createMock(AccessTokenEntity::class); @@ -346,6 +370,7 @@ public function testGenerateAccessTokenHashWithEdDsa(): void ); } + public function testGenerateAccessTokenHashThrowsForUnsupportedAlgorithm(): void { $accessTokenMock = $this->createMock(AccessTokenEntity::class); @@ -356,6 +381,7 @@ public function testGenerateAccessTokenHashThrowsForUnsupportedAlgorithm(): void $this->sut()->generateAccessTokenHash($accessTokenMock, 'UNSUPPORTED'); } + public function testGenerateAccessTokenHashThrowsWhenNotEntityStringRepresentationInterface(): void { $accessTokenMock = $this->createMock(AccessTokenEntityInterface::class); diff --git a/tests/unit/src/Services/LogoutTokenBuilderTest.php b/tests/unit/src/Services/LogoutTokenBuilderTest.php index dedabf01..08d03a9f 100644 --- a/tests/unit/src/Services/LogoutTokenBuilderTest.php +++ b/tests/unit/src/Services/LogoutTokenBuilderTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Factories\CoreFactory; @@ -22,12 +23,17 @@ /** * @covers \SimpleSAML\Module\oidc\Services\LogoutTokenBuilder */ +#[AllowMockObjectsWithoutExpectations] class LogoutTokenBuilderTest extends TestCase { private static string $clientId = 'client123'; + private static string $userId = 'user123'; + private static string $sessionId = 'session123'; + private static string $backChannelLogoutUri = 'https//some-host.org/logout'; + private static string $logoutTokenType = 'logout+jwt'; /** @@ -39,11 +45,17 @@ class LogoutTokenBuilderTest extends TestCase * @var mixed */ private MockObject $relyingPartyAssociationMock; + private MockObject $loggerServiceMock; + private MockObject $coreFactoryMock; + private MockObject $protocolSignatureKeyPairBagMock; + private MockObject $signatureKeyPairMock; + private MockObject $coreMock; + private MockObject $logoutTokenFactoryMock; @@ -82,6 +94,7 @@ public function setUp(): void $this->coreMock->method('logoutTokenFactory')->willReturn($this->logoutTokenFactoryMock); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?LoggerService $loggerService = null, @@ -98,11 +111,13 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(LogoutTokenBuilder::class, $this->sut()); } + /** * @throws \ReflectionException * @throws \Exception @@ -133,6 +148,7 @@ public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void $this->sut()->forRelyingPartyAssociation($this->relyingPartyAssociationMock); } + public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): void { $this->moduleConfigMock->expects($this->once()) diff --git a/tests/unit/src/Services/NonceServiceTest.php b/tests/unit/src/Services/NonceServiceTest.php index 3ef6275e..44aa6224 100644 --- a/tests/unit/src/Services/NonceServiceTest.php +++ b/tests/unit/src/Services/NonceServiceTest.php @@ -4,6 +4,9 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use DateInterval; +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -25,20 +28,32 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; #[CoversClass(NonceService::class)] +#[AllowMockObjectsWithoutExpectations] class NonceServiceTest extends TestCase { protected MockObject $jwsMock; + protected MockObject $moduleConfigMock; + protected MockObject $loggerServiceMock; + protected MockObject $parsedJwsFactoryMock; + protected MockObject $parsedJwsMock; + protected MockObject $signatureKeyPairBagMock; + protected MockObject $signatureKeyPairMock; + protected MockObject $helpersMock; + protected MockObject $dateTimeHelperMock; + protected MockObject $oidcHelpersMock; + protected MockObject $oidcRandomMock; + public function setUp(): void { $this->jwsMock = $this->createMock(Jws::class); @@ -62,6 +77,7 @@ public function setUp(): void $this->moduleConfigMock->method('getVciSignatureKeyPairBag')->willReturn($this->signatureKeyPairBagMock); } + /** * A key pair whose public key is the given JWK, so a test can tell which key a nonce was checked * against. @@ -80,12 +96,13 @@ protected function buildSignatureKeyPair(array $publicJwk): MockObject return $signatureKeyPairMock; } + public function testGenerateNonce(): void { - $currentDateTime = new \DateTimeImmutable('2024-01-01 00:00:00'); + $currentDateTime = new DateTimeImmutable('2024-01-01 00:00:00'); $this->dateTimeHelperMock->method('getUtc')->willReturn($currentDateTime); $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); - $this->moduleConfigMock->method('getVciNonceTtl')->willReturn(new \DateInterval('PT5M')); + $this->moduleConfigMock->method('getVciNonceTtl')->willReturn(new DateInterval('PT5M')); $privateKeyMock = $this->createMock(JwkDecorator::class); $keyPairMock = $this->createMock(KeyPair::class); @@ -104,11 +121,9 @@ public function testGenerateNonce(): void ->with( $this->anything(), $this->anything(), - $this->callback(function (array $payload) use ($currentDateTime): bool { - return $payload['iat'] === $currentDateTime->getTimestamp() - && $payload['exp'] === $currentDateTime->getTimestamp() + 300 - && $payload['nonce_val'] === 'mocked_random_nonce'; - }), + $this->callback(fn(array $payload): bool => $payload['iat'] === $currentDateTime->getTimestamp() + && $payload['exp'] === $currentDateTime->getTimestamp() + 300 + && $payload['nonce_val'] === 'mocked_random_nonce'), $this->anything(), ) ->willReturn($this->parsedJwsMock); @@ -126,9 +141,10 @@ public function testGenerateNonce(): void $this->assertEquals('mocked_token', $nonce); } + public function testValidateNonceSuccess(): void { - $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable('2024-01-01 00:00:00')); $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); $publicKey = (new JwkDecoratorFactory())->fromData(['kty' => 'EC']); @@ -139,7 +155,7 @@ public function testValidateNonceSuccess(): void $this->parsedJwsMock->method('getIssuer')->willReturn('https://issuer.example.com'); $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); $this->parsedJwsMock->method('getExpirationTime') - ->willReturn((new \DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() + 100); + ->willReturn((new DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() + 100); $sut = new NonceService( $this->jwsMock, @@ -150,6 +166,7 @@ public function testValidateNonceSuccess(): void $this->assertTrue($sut->validateNonce('valid_token')); } + /** * A nonce handed out shortly before a key rollover is still this issuer's nonce. It names the key * it was signed with, so it is checked against that key rather than against whichever key has since @@ -158,7 +175,7 @@ public function testValidateNonceSuccess(): void */ public function testValidatesANonceSignedByAKeyWhichNoLongerSigns(): void { - $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable('2024-01-01 00:00:00')); $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); $this->signatureKeyPairBagMock->method('getByKeyId') @@ -173,7 +190,7 @@ public function testValidatesANonceSignedByAKeyWhichNoLongerSigns(): void $this->parsedJwsMock->method('getIssuer')->willReturn('https://issuer.example.com'); $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); $this->parsedJwsMock->method('getExpirationTime') - ->willReturn((new \DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() + 100); + ->willReturn((new DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() + 100); $sut = new NonceService( $this->jwsMock, @@ -184,6 +201,7 @@ public function testValidatesANonceSignedByAKeyWhichNoLongerSigns(): void $this->assertTrue($sut->validateNonce('nonce_from_previous_key')); } + /** * Naming a key is not the same as being able to sign with it, but a key this deployment does not * hold can not be checked against at all, so the nonce is refused rather than checked against some @@ -191,7 +209,7 @@ public function testValidatesANonceSignedByAKeyWhichNoLongerSigns(): void */ public function testRejectsANonceNamingAKeyWhichIsNotConfigured(): void { - $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable('2024-01-01 00:00:00')); $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); $this->signatureKeyPairBagMock->method('getByKeyId')->with('vci-discarded')->willReturn(null); @@ -211,9 +229,10 @@ public function testRejectsANonceNamingAKeyWhichIsNotConfigured(): void $this->assertFalse($sut->validateNonce('nonce_from_discarded_key')); } + public function testValidateNonceInvalidIssuer(): void { - $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable('2024-01-01 00:00:00')); $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); $publicKey = (new JwkDecoratorFactory())->fromData(['kty' => 'EC']); @@ -233,9 +252,10 @@ public function testValidateNonceInvalidIssuer(): void $this->assertFalse($sut->validateNonce('invalid_issuer_token')); } + public function testValidateNonceExpired(): void { - $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->dateTimeHelperMock->method('getUtc')->willReturn(new DateTimeImmutable('2024-01-01 00:00:00')); $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); $publicKey = (new JwkDecoratorFactory())->fromData(['kty' => 'EC']); @@ -246,7 +266,7 @@ public function testValidateNonceExpired(): void $this->parsedJwsMock->method('getIssuer')->willReturn('https://issuer.example.com'); $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); $this->parsedJwsMock->method('getExpirationTime') - ->willReturn((new \DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() - 10); + ->willReturn((new DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() - 10); $sut = new NonceService( $this->jwsMock, diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index d26b8b7d..48d259f0 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; @@ -22,17 +23,26 @@ /** * @covers \SimpleSAML\Module\oidc\Services\OpMetadataService */ +#[AllowMockObjectsWithoutExpectations] class OpMetadataServiceTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $routesMock; + protected MockObject $claimTranslatorExtractorMock; + protected MockObject $signatureAlgorithmBag; + protected MockObject $supportedAlgorithmsMock; + protected MockObject $signatureKeyPairBagMock; + protected MockObject $signatureKeyPairMock; + protected MockObject $uiLocalesResolverMock; + /** * @throws \Exception */ @@ -100,6 +110,7 @@ public function setUp(): void $this->uiLocalesResolverMock->method('getSupportedUiLocales')->willReturn(['en', 'pt-BR']); } + /** * @throws \Exception */ @@ -109,10 +120,10 @@ protected function sut( ?Routes $routes = null, ?UiLocalesResolver $uiLocalesResolver = null, ): OpMetadataService { - $moduleConfig = $moduleConfig ?? $this->moduleConfigMock; - $claimTranslatorExtractor = $claimTranslatorExtractor ?? $this->claimTranslatorExtractorMock; - $routes = $routes ?? $this->routesMock; - $uiLocalesResolver = $uiLocalesResolver ?? $this->uiLocalesResolverMock; + $moduleConfig ??= $this->moduleConfigMock; + $claimTranslatorExtractor ??= $this->claimTranslatorExtractorMock; + $routes ??= $this->routesMock; + $uiLocalesResolver ??= $this->uiLocalesResolverMock; return new OpMetadataService( $moduleConfig, @@ -122,6 +133,7 @@ protected function sut( ); } + /** * @throws \Exception */ @@ -133,6 +145,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \Exception */ @@ -178,6 +191,7 @@ public function testItReturnsExpectedMetadata(): void ); } + /** * @throws \Exception */ @@ -192,6 +206,7 @@ public function testDoesNotAdvertiseUiLocalesSupportedWhenNoneAvailable(): void ); } + public function testAdvertisesRegistrationEndpointWhenDcrEnabled(): void { $this->moduleConfigMock->method('getDcrEnabled')->willReturn(true); @@ -204,6 +219,7 @@ public function testAdvertisesRegistrationEndpointWhenDcrEnabled(): void ); } + public function testDoesNotAdvertiseRegistrationEndpointWhenDcrDisabled(): void { $this->moduleConfigMock->method('getDcrEnabled')->willReturn(false); @@ -214,6 +230,7 @@ public function testDoesNotAdvertiseRegistrationEndpointWhenDcrDisabled(): void ); } + public function testCanShowClaimsSupportedClaim(): void { $this->moduleConfigMock->method('getProtocolDiscoveryShowClaimsSupported')->willReturn(true); diff --git a/tests/unit/src/Services/SessionMessagesServiceTest.php b/tests/unit/src/Services/SessionMessagesServiceTest.php index 55435eed..e02091af 100644 --- a/tests/unit/src/Services/SessionMessagesServiceTest.php +++ b/tests/unit/src/Services/SessionMessagesServiceTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Services\SessionMessagesService; @@ -12,10 +13,12 @@ /** * @covers \SimpleSAML\Module\oidc\Services\SessionMessagesService */ +#[AllowMockObjectsWithoutExpectations] class SessionMessagesServiceTest extends TestCase { protected MockObject $sessionMock; + /** * @throws \Exception */ @@ -24,11 +27,13 @@ protected function setUp(): void $this->sessionMock = $this->createMock(Session::class); } + public function prepareMockedInstance(): SessionMessagesService { return new SessionMessagesService($this->sessionMock); } + public function testItIsInitializable(): void { $this->assertInstanceOf( @@ -37,6 +42,7 @@ public function testItIsInitializable(): void ); } + /** * @throws \Exception */ @@ -49,6 +55,7 @@ public function testItAddsMessage(): void $this->prepareMockedInstance()->addMessage('value'); } + public function testItGetsMessages(): void { $this->sessionMock->expects($this->once()) diff --git a/tests/unit/src/Services/SessionServiceTest.php b/tests/unit/src/Services/SessionServiceTest.php index ace9f3e0..77f1a776 100644 --- a/tests/unit/src/Services/SessionServiceTest.php +++ b/tests/unit/src/Services/SessionServiceTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; /** * @covers \SimpleSAML\Module\oidc\Services\SessionService */ +#[AllowMockObjectsWithoutExpectations] class SessionServiceTest extends TestCase { public function testIncomplete(): never diff --git a/tests/unit/src/Services/StateServiceTest.php b/tests/unit/src/Services/StateServiceTest.php index 5e9da590..ec7ea7c0 100644 --- a/tests/unit/src/Services/StateServiceTest.php +++ b/tests/unit/src/Services/StateServiceTest.php @@ -4,22 +4,25 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Services\StateService; /** * @covers \SimpleSAML\Module\oidc\Services\StateService */ +#[AllowMockObjectsWithoutExpectations] class StateServiceTest extends TestCase { /** - * @return StateService + * @return \SimpleSAML\Module\oidc\Services\StateService */ protected function mock(): StateService { return new StateService(); } + /** * @return void */ diff --git a/tests/unit/src/StatusList/CredentialStatusIssuerTest.php b/tests/unit/src/StatusList/CredentialStatusIssuerTest.php index 75594a27..025e99c7 100644 --- a/tests/unit/src/StatusList/CredentialStatusIssuerTest.php +++ b/tests/unit/src/StatusList/CredentialStatusIssuerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -23,6 +24,7 @@ use SimpleSAML\OpenID\TokenStatusList\StatusReference; #[CoversClass(CredentialStatusIssuer::class)] +#[AllowMockObjectsWithoutExpectations] class CredentialStatusIssuerTest extends TestCase { protected const string CONFIGURATION_ID = 'TestCredential'; @@ -33,12 +35,18 @@ class CredentialStatusIssuerTest extends TestCase protected const string LIST_URI = 'https://issuer.example.org/module.php/oidc/statuslist/list-1'; + protected MockObject $moduleConfigMock; + protected MockObject $statusIndexAllocatorMock; + protected MockObject $subjectRefHasherMock; + protected MockObject $loggerServiceMock; + protected TokenStatusList $tokenStatusList; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -52,6 +60,7 @@ protected function setUp(): void ->willReturn(new StatusReferenceFactory(new OpenIdHelpers())); } + protected function sut(): CredentialStatusIssuer { return new CredentialStatusIssuer( @@ -63,6 +72,7 @@ protected function sut(): CredentialStatusIssuer ); } + /** * @throws \SimpleSAML\OpenID\Exceptions\StatusListException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException @@ -75,6 +85,7 @@ protected function expectAllocation(int $idx = 5): void ->willReturn(new StatusAllocation('list-1', new StatusReference(self::LIST_URI, $idx))); } + /** * @throws \Exception */ @@ -97,6 +108,7 @@ public function testBuildsTheClaimFromTheAllocation(): void ); } + /** * A configuration which belongs to no pool was never set up to be revocable, so its credentials * are issued exactly as they were before. @@ -113,6 +125,7 @@ public function testIssuesNoClaimForAConfigurationWithoutAPool(): void ); } + /** * Swallowing this would produce a credential which can never be withdrawn, with nothing on it to * say that it is the exception. @@ -131,6 +144,7 @@ public function testRaisesAFailureToAllocateRatherThanIssuingWithoutAClaim(): vo $this->sut()->issueFor(self::CONFIGURATION_ID, self::CREDENTIAL_ID, self::USER_IDENTIFIER); } + /** * The user identifier itself never reaches storage: what is recorded is a keyed hash of it. * diff --git a/tests/unit/src/StatusList/CredentialStatusServiceTest.php b/tests/unit/src/StatusList/CredentialStatusServiceTest.php index 0181a5fd..e29e57f7 100644 --- a/tests/unit/src/StatusList/CredentialStatusServiceTest.php +++ b/tests/unit/src/StatusList/CredentialStatusServiceTest.php @@ -6,6 +6,7 @@ use DateInterval; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,6 +23,7 @@ use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; #[CoversClass(CredentialStatusService::class)] +#[AllowMockObjectsWithoutExpectations] class CredentialStatusServiceTest extends TestCase { protected const string CREDENTIAL_ID = 'https://issuer.example.org/vc/abc'; @@ -32,12 +34,18 @@ class CredentialStatusServiceTest extends TestCase protected const int IDX = 42; + protected MockObject $statusListEntryRepositoryMock; + protected MockObject $statusUpdaterMock; + protected MockObject $statusAuditRepositoryMock; + protected MockObject $loggerServiceMock; + protected Helpers $helpers; + protected function setUp(): void { $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); @@ -48,6 +56,7 @@ protected function setUp(): void $this->helpers = new Helpers(); } + protected function sut(): CredentialStatusService { return new CredentialStatusService( @@ -59,6 +68,7 @@ protected function sut(): CredentialStatusService ); } + protected function entry( int $status = StatusTypeEnum::Valid->value, bool $isAllocated = true, @@ -74,6 +84,7 @@ protected function entry( return $entry; } + /** * @throws \Exception */ @@ -97,6 +108,7 @@ public function testChangesTheStatusOfTheEntryTheCredentialSitsIn(): void $this->assertSame(StatusTypeEnum::Valid->value, $change->getPreviousStatus()); } + /** * Nothing here is atomic, so the ordering decides which way the two writes may disagree. Recording * first leaves a row for a change which did not take effect, which reconciliation can find. The @@ -129,6 +141,7 @@ function () use (&$order): bool { $this->assertSame(['audit', 'update'], $order); } + /** * @throws \Exception */ @@ -158,6 +171,7 @@ public function testRecordsWhoAskedAndTheStatusItObserved(): void ); } + /** * The credential identifier is a durable, externally held value; the trail stores only its hash so * that it does not outlive the linkage which is deliberately deleted at expiry. @@ -176,6 +190,7 @@ public function testTheAuditTrailNeverSeesTheCredentialIdentifier(): void $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); } + /** * A caller which never saw the answer to its request repeats it. That is a success, and it writes * nothing: the change it is repeating was recorded when it first happened. @@ -201,6 +216,7 @@ public function testRepeatingARequestChangesNothingAndRecordsNothing(): void $this->assertSame(StatusTypeEnum::Invalid, $change->getStatus()); } + /** * @throws \Exception */ @@ -213,6 +229,7 @@ public function testReportsNothingToActOnForAnUnknownCredential(): void ); } + /** * Every index of a list exists as a row from the moment the list is created, so an unallocated row * describes no credential at all. @@ -229,6 +246,7 @@ public function testReportsNothingToActOnForAnUnallocatedEntry(): void ); } + /** * An expired credential is already refused on its own claims, so withdrawing it changes nothing * that is not already true. It is answered the same way as an unknown one, which is also what will @@ -249,6 +267,7 @@ public function testReportsNothingToActOnForAnExpiredCredential(): void ); } + /** * @throws \Exception */ @@ -264,6 +283,7 @@ public function testActsOnACredentialWhichHasNotExpiredYet(): void ); } + /** * Raised, not swallowed: the caller has to be told the credential does not hold what it asked for. * @@ -280,6 +300,7 @@ public function testRaisesAFailureToApplyTheChange(): void $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); } + /** * Whether a list can carry a status is fixed when the list is created, so this is not a change * which failed but one which was never possible. Recording it would leave a permanent row @@ -301,6 +322,7 @@ public function testRecordsNothingForAStatusTheListCouldNeverCarry(): void $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Suspended, StatusChangeSourceEnum::Api); } + /** * A change which is recorded and then lost leaves a row someone has to find. Naming it in the log * is the difference between finding it and hunting by timestamp. @@ -328,6 +350,7 @@ public function testNamesTheAuditRowItLeftBehindWhenTheChangeFails(): void $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); } + /** * @throws \Exception */ @@ -339,6 +362,7 @@ public function testReportsTheStatusACredentialHolds(): void $this->assertSame(StatusTypeEnum::Suspended->value, $this->sut()->getStatusValue(self::CREDENTIAL_ID)); } + /** * @throws \Exception */ diff --git a/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php b/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php index 7de02c51..da461826 100644 --- a/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php +++ b/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php @@ -4,6 +4,9 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use DateTimeImmutable; +use Exception; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -32,6 +35,7 @@ * would leave only the parts of the allocator which were never in doubt. */ #[CoversClass(DbStatusIndexAllocator::class)] +#[AllowMockObjectsWithoutExpectations] class DbStatusIndexAllocatorTest extends TestCase { protected const string POOL_ID = 'test-pool'; @@ -41,14 +45,22 @@ class DbStatusIndexAllocatorTest extends TestCase /** Small enough that a list can be filled by hand, and a multiple of 8 as a capacity must be. */ protected const int CAPACITY = 8; + protected Database $database; + protected StatusListRepository $statusListRepository; + protected StatusListEntryRepository $statusListEntryRepository; + protected MockObject $keyResolverMock; + protected MockObject $routesMock; + protected MockObject $loggerServiceMock; + protected string $signingKeyId = 'signing-key-1'; + /** * @throws \Exception */ @@ -70,6 +82,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + /** * @throws \Exception */ @@ -112,6 +125,7 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut(): DbStatusIndexAllocator { return new DbStatusIndexAllocator( @@ -125,6 +139,7 @@ protected function sut(): DbStatusIndexAllocator ); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -140,6 +155,7 @@ protected function pool(int $capacity = self::CAPACITY): StatusListPool ); } + /** * Allocates a credential which never expires, so the lists these tests work with are in the * non-expiring lane unless a test says otherwise. @@ -157,6 +173,7 @@ protected function allocate(string $credentialId): StatusAllocation ); } + /** * @throws \Exception */ @@ -169,10 +186,11 @@ protected function allocateExpiring( $credentialId, self::CREDENTIAL_CONFIGURATION_ID, 'subject-ref-hash', - new \DateTimeImmutable($expiresAt), + new DateTimeImmutable($expiresAt), ); } + /** * The point of the whole arrangement: the two kinds of credential never share a list, so a list of * expiring credentials can always eventually be retired. @@ -196,6 +214,7 @@ public function testKeepsExpiringAndNonExpiringCredentialsInSeparateLists(): voi ); } + /** * Two credentials of the same kind still share, or the lane would have bought a split herd for * nothing. @@ -210,6 +229,7 @@ public function testKeepsCredentialsOfTheSameKindTogether(): void $this->assertSame($first->getStatusListId(), $second->getStatusListId()); } + /** * An open list in the other lane must never be selected. If it were, every probe against it would be * refused by the allocation guard, the allocator would read ten refusals as a full list, and it would @@ -231,6 +251,7 @@ public function testDoesNotSelectAnOpenListFromTheOtherLane(): void $this->assertNull($this->statusListRepository->findByIdOnPrimary($otherLaneListId)?->getDeactivatedAt()); } + /** * A list being seeded in the other lane is not something this request may stand down for: it could * never allocate into it, so it would delete its own list and then find nothing to adopt. @@ -267,6 +288,7 @@ public function testDoesNotStandDownForAListBeingPreparedInTheOtherLane(): void $this->assertTrue($statusList?->isActive()); } + /** * Generations are counted within a pool, policy and lane, so the two lanes do not push each other's * numbering along and cannot collide on the unique constraint. A collision across lanes would be @@ -289,6 +311,7 @@ public function testCountsGenerationsSeparatelyInEachLane(): void ); } + /** * @return array */ @@ -299,6 +322,7 @@ protected function statusListRows(): array )->fetchAll(); } + /** * @throws \Exception */ @@ -327,6 +351,7 @@ public function testCreatesSeedsAndActivatesAListOnTheFirstAllocation(): void $this->assertSame(self::CAPACITY, (int)$seeded[0]['total']); } + /** * The reference handed back is what goes into the credential, so it has to be the URI which was * stored, byte for byte, rather than one rebuilt later from the current base URL. @@ -345,6 +370,7 @@ public function testReturnsTheStoredUriAndAnInRangeIndex(): void $this->assertLessThan(self::CAPACITY, $allocation->getIdx()); } + /** * Claiming the index and recording what claimed it are one statement, so there is never a moment * where an index is taken but unattributable. @@ -375,6 +401,7 @@ public function testRecordsTheCredentialLinkageWithTheClaim(): void $this->assertNull($entry->getExpiresAt()); } + /** * @throws \Exception */ @@ -394,6 +421,7 @@ public function testNeverHandsOutTheSameIndexTwice(): void $this->assertCount(4, $seen); } + /** * A claim on an index someone else already took simply affects no rows. Nothing has to classify a * database error to discover that, which matters because this module can not do so reliably. @@ -424,6 +452,7 @@ public function testClaimingAnAlreadyTakenIndexAffectsNoRowsRatherThanRaising(): $this->assertSame('https://op.example.org/vc/first', $entry?->getCredentialId()); } + /** * A list which stopped accepting allocations must not take any more, even for an index which is * still free, otherwise a credential could land in a list already on its way to retirement. @@ -452,6 +481,7 @@ public function testWillNotClaimAnIndexInADeactivatedList(): void $this->assertFalse($wasClaimed); } + /** * Deactivating is what settles which of several workers goes on to create the successor. * @@ -465,6 +495,7 @@ public function testOnlyOneCallerWinsDeactivation(): void $this->assertFalse($this->statusListRepository->deactivate($allocation->getStatusListId())); } + /** * Running out of picks must never surface as a failure to issue a credential. The list is closed, a * successor is started, and the credential is allocated there. @@ -502,6 +533,7 @@ public function testRunningOutOfPicksRotatesInsteadOfFailing(): void $this->assertFalse($this->statusListRepository->findByIdOnPrimary($firstListId)?->isActive()); } + /** * The advisory counter is what triggers rotating early, before picks start colliding. * @@ -521,6 +553,7 @@ public function testRotatesOnceTheLoadFactorIsReached(): void $this->assertCount(2, $this->statusListRows()); } + /** * During a key rotation the issuer signs credentials with the current key. A list bound to the * previous one must stop being selected, or the profile saying the two are the same key breaks. @@ -545,6 +578,7 @@ public function testDoesNotAllocateIntoAListBoundToASupersededSigningKey(): void $this->assertTrue($this->statusListRepository->findByIdOnPrimary($first->getStatusListId())?->isActive()); } + /** * Changing a pool setting must likewise not leave lists created under the old policy eligible. * @@ -574,6 +608,7 @@ public function testDoesNotAllocateIntoAListCreatedUnderADifferentPolicy(): void $this->assertSame(2, $this->statusListRepository->findByIdOnPrimary($second->getStatusListId())?->getBits()); } + /** * Successive lists in a pool take successive generations, which is what the unique constraint uses * to let exactly one of several workers create the successor. @@ -585,7 +620,7 @@ public function testASecondListForTheSamePoolAndGenerationCannotBeCreated(): voi $allocation = $this->allocate('https://op.example.org/vc/first'); $existing = $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId()); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->statusListRepository->create( 'some-other-id', @@ -605,6 +640,7 @@ public function testASecondListForTheSamePoolAndGenerationCannotBeCreated(): voi ); } + /** * A list which was closed is not picked again; the next allocation starts the next generation. * @@ -626,6 +662,7 @@ public function testStartsTheNextGenerationOnceTheOpenListIsClosed(): void $this->assertCount(2, $this->statusListRows()); } + /** * Losing the race to create a successor is recoverable without working out why the insert failed. * @@ -650,6 +687,7 @@ public function testAdoptsAListAnotherRequestCreatedWhenItsOwnInsertFails(): voi ) extends StatusListRepository { public bool $pretendNoListIsOpen = true; + public function findActiveForPolicy( string $poolId, string $policyFingerprint, @@ -666,6 +704,7 @@ public function findActiveForPolicy( return parent::findActiveForPolicy($poolId, $policyFingerprint, $expiryLane); } + public function create( string $id, string $uri, @@ -682,7 +721,7 @@ public function create( string $signingKeyId, StatusListKeyProfileEnum $keyProfile, ): void { - throw new \Exception('Database error: duplicate generation.'); + throw new Exception('Database error: duplicate generation.'); } }; @@ -707,6 +746,7 @@ public function create( $this->assertCount(1, $this->statusListRows()); } + /** * A request arriving while another is still seeding a list must join that list rather than start a * second one. Privacy comes from many credentials sharing a list, so splitting a pool across @@ -744,6 +784,7 @@ public function testJoinsAListAnotherRequestIsStillSeedingRatherThanStartingASec ) extends StatusListRepository { public int $activeLookups = 0; + public function findActiveForPolicy( string $poolId, string $policyFingerprint, @@ -782,6 +823,7 @@ public function findActiveForPolicy( $this->assertCount(1, $this->statusListRows(), 'A second list should not have been started.'); } + /** * A list left unopened by a request which died mid-seed must not stall every later request. Past * its staleness window it is ignored and a fresh list is started, so issuance still succeeds. @@ -827,6 +869,7 @@ public function testIgnoresAnAbandonedHalfSeededListInsteadOfWaitingForever(): v ); } + /** * The unique constraint on (pool_id, generation) only settles a race between requests which picked * the same generation. Two requests reading the highest generation a moment apart pick different @@ -866,11 +909,12 @@ public function testStandsDownWhenAnotherRequestIsPreparingAnEarlierGeneration() ) extends StatusListRepository { public int $activeLookups = 0; + public function findBeingPreparedForPolicy( string $poolId, string $policyFingerprint, StatusListExpiryLaneEnum $expiryLane, - \DateTimeImmutable $createdAfter, + DateTimeImmutable $createdAfter, ?int $belowGeneration = null, ): array { // Hide the in-progress list from the pre-creation check only, so this request goes @@ -889,6 +933,7 @@ public function findBeingPreparedForPolicy( ); } + public function findActiveForPolicy( string $poolId, string $policyFingerprint, @@ -927,6 +972,7 @@ public function findActiveForPolicy( $this->assertCount(1, $this->statusListRows()); } + /** * Standing down must never leave the redundant list behind, and it must only ever remove one which * was never opened, so that a list a credential could point at is untouchable. @@ -964,6 +1010,7 @@ public function testOnlyRemovesAListWhichWasNeverOpened(): void $this->assertNull($this->statusListRepository->findByIdOnPrimary('never-opened')); } + /** * A request which died after creating a list but before opening it must not take the pool down * with it. Standing down repeatedly for a list nobody is finishing would fail every issuance until @@ -1005,6 +1052,7 @@ public function testTakesOverWhenTheListItStoodDownForIsNeverOpened(): void $this->assertLessThan(8.0, $elapsed); } + /** * A list which has reached the point where a successor is started must be closed, not merely * skipped. Left open it would come back in every candidate query for ever, and retirement waits on @@ -1040,6 +1088,7 @@ public function testClosesAListOnceItIsFullRatherThanLeavingItOpen(): void ); } + /** * The allocation counter is advisory and may undercount, so failing to bump it must not undo an * index which is already durably claimed. Throwing here would consume the slot and then fail the @@ -1057,7 +1106,7 @@ public function testStillReturnsTheAllocationWhenTheAdvisoryCounterCannotBeUpdat ) extends StatusListRepository { public function incrementAllocatedCount(string $id): void { - throw new \Exception('Database error: deadlock found.'); + throw new Exception('Database error: deadlock found.'); } }; @@ -1086,6 +1135,7 @@ public function incrementAllocatedCount(string $id): void $this->assertSame('https://op.example.org/vc/counter', $entry?->getCredentialId()); } + /** * The most common way to lose the race is on a cold start, where the winner is still seeding at the * moment the loser's insert fails -- that is *why* it failed. Giving up on the winner at that point @@ -1125,11 +1175,12 @@ public function testWaitsForAWinnerWhichIsStillSeedingWhenItsOwnInsertFails(): v ) extends StatusListRepository { public int $activeLookups = 0; + public function findBeingPreparedForPolicy( string $poolId, string $policyFingerprint, StatusListExpiryLaneEnum $expiryLane, - \DateTimeImmutable $createdAfter, + DateTimeImmutable $createdAfter, ?int $belowGeneration = null, ): array { // Hidden from the check before creating, so this request goes ahead and tries to insert @@ -1147,6 +1198,7 @@ public function findBeingPreparedForPolicy( ); } + public function findActiveForPolicy( string $poolId, string $policyFingerprint, @@ -1162,6 +1214,7 @@ public function findActiveForPolicy( return parent::findActiveForPolicy($poolId, $policyFingerprint, $expiryLane); } + public function create( string $id, string $uri, @@ -1178,7 +1231,7 @@ public function create( string $signingKeyId, StatusListKeyProfileEnum $keyProfile, ): void { - throw new \Exception('Database error: duplicate generation.'); + throw new Exception('Database error: duplicate generation.'); } }; @@ -1202,6 +1255,7 @@ public function create( $this->assertCount(1, $this->statusListRows(), 'The pool should have converged on one list.'); } + /** * When the insert fails and there is genuinely no other list, there is nothing to fall back to. * @@ -1231,7 +1285,7 @@ public function create( string $signingKeyId, StatusListKeyProfileEnum $keyProfile, ): void { - throw new \Exception('Database error: disk full.'); + throw new Exception('Database error: disk full.'); } }; @@ -1254,6 +1308,7 @@ public function create( ); } + /** * @throws \Exception */ @@ -1268,6 +1323,7 @@ public function testCountsAllocationsForTheAdvisoryCounter(): void $this->assertSame(1, $this->statusListEntryRepository->countAllocated($allocation->getStatusListId())); } + /** * Only a failure to obtain any list at all is an error. * diff --git a/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php b/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php index 13d77aa2..f7c64268 100644 --- a/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php +++ b/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -37,6 +38,7 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; #[CoversClass(DbStatusListTokenProvider::class)] +#[AllowMockObjectsWithoutExpectations] class DbStatusListTokenProviderTest extends TestCase { protected const string LIST_ID = 'a-status-list-id'; @@ -51,16 +53,26 @@ class DbStatusListTokenProviderTest extends TestCase protected const string PUBLISHED_TOKEN = 'already.published.token'; + protected MockObject $statusListRepositoryMock; + protected MockObject $statusListEntryRepositoryMock; + protected MockObject $statusListKeyResolverMock; + protected MockObject $statusListTokenFactoryMock; + protected MockObject $moduleConfigMock; + protected MockObject $didJwkResolverMock; + protected MockObject $loggerServiceMock; + protected StatusListContentHasher $statusListContentHasher; + protected Helpers $helpers; + /** * @throws \Exception */ @@ -85,6 +97,7 @@ protected function setUp(): void $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::DID_JWK); } + /** * Assembled here rather than in setUp so that a test can put its own token factory in place first, * which is the only way to assert what a token was signed over: a second stub of an already stubbed @@ -116,6 +129,7 @@ protected function sut(): DbStatusListTokenProvider ); } + /** * @throws \PHPUnit\Framework\MockObject\Exception */ @@ -135,6 +149,7 @@ protected function signatureKeyPair(): SignatureKeyPair return $signatureKeyPair; } + /** * @throws \PHPUnit\Framework\MockObject\Exception */ @@ -146,6 +161,7 @@ protected function signedTokenStub(): StatusListToken return $statusListToken; } + /** * @throws \Exception */ @@ -186,6 +202,7 @@ protected function record( ); } + /** * @throws \Exception */ @@ -194,6 +211,7 @@ protected function moment(?string $moment): ?DateTimeImmutable return $moment === null ? null : new DateTimeImmutable($moment, new DateTimeZone('UTC')); } + /** * @param array $statuses */ @@ -202,6 +220,7 @@ protected function contentHashFor(array $statuses): string return $this->statusListContentHasher->hash(2, 64, $statuses); } + /** * A published token signed just now, with its full life ahead of it. * @@ -218,6 +237,7 @@ protected function freshlyPublishedRecord(array $statuses = []): StatusListRecor ); } + /** * @throws \Exception */ @@ -228,6 +248,7 @@ public function testReturnsNothingForAnUnknownList(): void $this->assertNull($this->sut()->getToken(self::LIST_ID)); } + /** * Retirement, not deactivation, is what ends publication: a list stops taking new credentials long * before the ones already in it stop needing a status. @@ -242,6 +263,7 @@ public function testReturnsNothingForARetiredList(): void $this->assertNull($this->sut()->getToken(self::LIST_ID)); } + /** * The common path: one row is read and nothing else, which is what makes serving a list of a hundred * thousand entries cheap. @@ -261,6 +283,7 @@ public function testServesThePublishedTokenWithoutReadingTheEntries(): void $this->assertSame(43200, $result->getTtlSeconds()); } + /** * @return array */ @@ -298,6 +321,7 @@ public function testPublishesAFreshTokenWhenThePublishedOneWillNotDo( $this->assertSame(self::SIGNED_TOKEN, $result->getToken()); } + /** * The compare-and-set has to be given the hash which was on the row, so that a signer whose snapshot * was superseded fails to publish rather than overwriting a newer token. @@ -320,6 +344,7 @@ public function testPublishesAgainstTheHashItObserved(): void $this->assertInstanceOf(StatusListTokenResult::class, $this->sut()->getToken(self::LIST_ID)); } + /** * While the content hash is empty it cannot settle publication on its own: an invalidation arriving * after this signer took its snapshot finds the hash already empty and leaves it empty, so the @@ -343,6 +368,7 @@ public function testPublishesAgainstTheInvalidationCounterItObserved(): void $this->assertInstanceOf(StatusListTokenResult::class, $this->sut()->getToken(self::LIST_ID)); } + /** * Fail closed. A token signed with a key the credential's holder never bound to is not one they can * verify, and reaching for the current key instead would look like success. @@ -367,6 +393,7 @@ public function testFailsWhenTheListsSigningKeyIsGone(): void $this->sut()->getToken(self::LIST_ID); } + /** * A revocation landing while the token is being signed is not visible to the compare-and-set, which * looks at the list row rather than at the entries. Re-reading them is what catches it. @@ -398,6 +425,7 @@ public function testDiscardsATokenSupersededWhileItWasBeingSigned(): void $this->sut()->getToken(self::LIST_ID); } + /** * Losing the race is not a failure. The winner's token describes the same list, so it is served * rather than signed again. @@ -418,6 +446,7 @@ public function testServesTheTokenAnotherRequestPublishedFirst(): void $this->assertSame(self::PUBLISHED_TOKEN, $result->getToken()); } + /** * A list retired between the read which decided to re-sign and the authoritative one is gone, not * broken. @@ -433,6 +462,7 @@ public function testReturnsNothingWhenTheListIsRetiredWhileRepublishing(): void $this->assertNull($this->sut()->getToken(self::LIST_ID)); } + /** * The token has to name the list by the URI which was stored, since a Relying Party compares it byte * for byte with the one its credential carries. @@ -462,6 +492,7 @@ public function testSignsWithTheStoredUriAndTheDidJwkIdentity(): void $this->sut()->getToken(self::LIST_ID); } + /** * Under the JWKS profile the key is resolved through the issuer's published key set instead, so the * token names the issuer and the plain key identifier. @@ -491,6 +522,7 @@ public function testSignsWithTheIssuerAndKeyIdUnderTheJwksProfile(): void $this->sut()->getToken(self::LIST_ID); } + /** * @throws \Exception */ diff --git a/tests/unit/src/StatusList/DbStatusUpdaterTest.php b/tests/unit/src/StatusList/DbStatusUpdaterTest.php index bf9def98..ba5e46ce 100644 --- a/tests/unit/src/StatusList/DbStatusUpdaterTest.php +++ b/tests/unit/src/StatusList/DbStatusUpdaterTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -29,17 +30,23 @@ * actually match. */ #[CoversClass(DbStatusUpdater::class)] +#[AllowMockObjectsWithoutExpectations] class DbStatusUpdaterTest extends TestCase { protected const string LIST_ID = 'list-1'; protected const int CAPACITY = 8; + protected Database $database; + protected StatusListRepository $statusListRepository; + protected StatusListEntryRepository $statusListEntryRepository; + protected MockObject $loggerServiceMock; + /** * @throws \Exception */ @@ -61,6 +68,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + /** * @throws \Exception */ @@ -89,6 +97,7 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function sut(): DbStatusUpdater { return new DbStatusUpdater( @@ -98,6 +107,7 @@ protected function sut(): DbStatusUpdater ); } + /** * @throws \Exception */ @@ -124,6 +134,7 @@ protected function givenList(int $bits = 2, string $allowedStatuses = '0,1,2'): $this->statusListRepository->activate(self::LIST_ID); } + /** * @throws \Exception */ @@ -140,6 +151,7 @@ protected function givenAllocatedEntry(int $idx = 3): void ); } + /** * @throws \Exception */ @@ -152,6 +164,7 @@ protected function givenPublishedToken(string $contentHash = 'published-hash'): ); } + /** * @throws \Exception */ @@ -169,6 +182,7 @@ public function testChangesTheStatusOfAnAllocatedEntry(): void $this->assertSame(StatusTypeEnum::Invalid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); } + /** * The published token has to stop counting as current the moment the content it was signed over * changes, or the endpoint would keep serving a token reporting the old status. @@ -189,6 +203,7 @@ public function testInvalidatesThePublishedTokenAfterAChange(): void ); } + /** * Repeating a revocation is expected to be harmless, and must not cost a re-sign of the whole list. * @@ -210,6 +225,7 @@ public function testSettingTheStatusItAlreadyHoldsChangesNothing(): void ); } + /** * An index which was never handed out does not describe any credential, so setting its status would * be making a statement about something which does not exist. @@ -226,6 +242,7 @@ public function testRefusesToChangeAnUnallocatedEntry(): void $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid); } + /** * @throws \Exception */ @@ -236,6 +253,7 @@ public function testReportsNoStatusForAnUnallocatedEntry(): void $this->assertNull($this->sut()->getStatusValue(self::LIST_ID, 3)); } + /** * @throws \Exception */ @@ -248,6 +266,7 @@ public function testRaisesForAnIndexOutsideTheList(): void $this->sut()->setStatus(self::LIST_ID, self::CAPACITY + 1, StatusTypeEnum::Invalid); } + /** * @throws \Exception */ @@ -259,6 +278,7 @@ public function testRaisesForAListWhichDoesNotExist(): void $this->sut()->setStatus('no-such-list', 0, StatusTypeEnum::Invalid); } + /** * The number of bits per entry is fixed when a list is created and can not be retrofitted, so a * status which does not fit is a permanent property of that list rather than a transient failure. @@ -276,6 +296,7 @@ public function testRefusesAStatusWhichDoesNotFitTheListsBits(): void $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Suspended); } + /** * A list records the statuses it was created allowing, so widening a pool later does not widen * lists which already exist. @@ -293,6 +314,7 @@ public function testRefusesAStatusTheListWasNotCreatedAllowing(): void $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Suspended); } + /** * @throws \Exception */ @@ -305,6 +327,7 @@ public function testAllowsSuspensionOnAListCreatedForIt(): void $this->assertSame(StatusTypeEnum::Suspended->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); } + /** * @throws \Exception */ @@ -319,6 +342,7 @@ public function testCanReinstateARevokedEntry(): void $this->assertSame(StatusTypeEnum::Valid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); } + /** * A change which lands between another caller's read and its write must stand, rather than being * silently overwritten by the value that caller had already decided on. @@ -342,6 +366,7 @@ public function testDoesNotOverwriteAChangeMadeSinceTheStatusWasRead(): void $this->assertSame(StatusTypeEnum::Valid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); } + /** * Losing one round of the compare-and-set is not a failure: the retry reads what actually got * there and applies the change on top, so the caller still ends up with what it asked for. @@ -361,6 +386,7 @@ public function testRetriesAgainstTheValueAnotherChangeLeftBehind(): void ) extends StatusListEntryRepository { public int $updateAttempts = 0; + public function updateStatus( string $statusListId, int $idx, @@ -391,6 +417,7 @@ public function updateStatus( $this->assertSame(StatusTypeEnum::Invalid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); } + /** * Losing every round has to be reported as a conflict rather than as the no-op that a false return * value means, since the entry ends up holding somebody else's value rather than the requested one. diff --git a/tests/unit/src/StatusList/StatusListContentHasherTest.php b/tests/unit/src/StatusList/StatusListContentHasherTest.php index aab130d0..2e446586 100644 --- a/tests/unit/src/StatusList/StatusListContentHasherTest.php +++ b/tests/unit/src/StatusList/StatusListContentHasherTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\StatusList\StatusListContentHasher; #[CoversClass(StatusListContentHasher::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListContentHasherTest extends TestCase { protected function sut(): StatusListContentHasher @@ -16,11 +18,13 @@ protected function sut(): StatusListContentHasher return new StatusListContentHasher(); } + public function testProducesAHashOfFixedWidth(): void { $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $this->sut()->hash(1, 64, [])); } + /** * A list with nothing revoked still has a hash, and it must not be the empty string, which is * reserved for "there is no published token". @@ -33,6 +37,7 @@ public function testAListWithNothingRevokedStillHashesToSomething(): void $this->assertSame($hash, $this->sut()->hash(1, 64, [])); } + /** * The compare-and-set which publishes a token compares hashes produced in different processes, so * the order the entries happened to arrive in must not change the result. @@ -45,6 +50,7 @@ public function testIsIndependentOfTheOrderTheEntriesArriveIn(): void ); } + public function testChangesWhenAStatusChanges(): void { $this->assertNotSame( @@ -53,6 +59,7 @@ public function testChangesWhenAStatusChanges(): void ); } + public function testChangesWhenAnEntryIsAdded(): void { $this->assertNotSame( @@ -61,6 +68,7 @@ public function testChangesWhenAnEntryIsAdded(): void ); } + public function testChangesWhenAnEntryMoves(): void { $this->assertNotSame( @@ -69,12 +77,14 @@ public function testChangesWhenAnEntryMoves(): void ); } + public function testDistinguishesListsOfDifferentShape(): void { $this->assertNotSame($this->sut()->hash(1, 64, []), $this->sut()->hash(2, 64, [])); $this->assertNotSame($this->sut()->hash(1, 64, []), $this->sut()->hash(1, 128, [])); } + /** * The parts are labelled and delimited precisely so that two different lists can not produce the * same input by running together. Bits of 1 with a capacity of 12 and bits of 11 with a capacity of @@ -85,6 +95,7 @@ public function testDoesNotCollideOnAmbiguouslyConcatenatedParts(): void $this->assertNotSame($this->sut()->hash(1, 12, []), $this->sut()->hash(11, 2, [])); } + /** * Likewise for the entries: index 1 with status 12 and index 11 with status 2 have to differ. */ diff --git a/tests/unit/src/StatusList/StatusListKeyResolverTest.php b/tests/unit/src/StatusList/StatusListKeyResolverTest.php index e4377e8c..7ad6be6e 100644 --- a/tests/unit/src/StatusList/StatusListKeyResolverTest.php +++ b/tests/unit/src/StatusList/StatusListKeyResolverTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -17,11 +18,14 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; #[CoversClass(StatusListKeyResolver::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListKeyResolverTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $signatureKeyPairBagMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -30,11 +34,13 @@ protected function setUp(): void ->willReturn($this->signatureKeyPairBagMock); } + protected function sut(): StatusListKeyResolver { return new StatusListKeyResolver($this->moduleConfigMock); } + protected function buildSignatureKeyPair(string $keyId): MockObject { $keyPairMock = $this->createMock(KeyPair::class); @@ -47,6 +53,7 @@ protected function buildSignatureKeyPair(string $keyId): MockObject return $signatureKeyPairMock; } + /** * A new list has to be bound to the same key credentials are being signed with, otherwise a * credential would be signed with one key while the Status List Token its holder is told to check @@ -61,6 +68,7 @@ public function testCurrentKeyIsTheActiveCredentialSigningKey(): void $this->assertSame('vci-01', $this->sut()->getCurrentKeyId()); } + /** * Reported as a Status List failure rather than as whatever the configuration layer threw, since * the caller's problem is that it can not sign a list. @@ -76,6 +84,7 @@ public function testCurrentKeyFailureIsReportedAsAStatusListFailure(): void $this->sut()->getCurrent(); } + /** * An existing list is re-signed from the key it was created with, which may no longer be the one * signing now. Resolving by key ID is what lets a list outlive a key rollover. @@ -90,6 +99,7 @@ public function testAListIsResolvedToTheKeyItWasCreatedWith(): void $this->assertSame($retiredSignatureKeyPair, $this->sut()->getByKeyId('vci-retired')); } + /** * Falling back to the current key here would produce a token signed with a key the credential's * holder never bound to, and would do so while looking like success. diff --git a/tests/unit/src/StatusList/StatusListLifecycleTest.php b/tests/unit/src/StatusList/StatusListLifecycleTest.php index b2e29883..2ae341d8 100644 --- a/tests/unit/src/StatusList/StatusListLifecycleTest.php +++ b/tests/unit/src/StatusList/StatusListLifecycleTest.php @@ -6,6 +6,7 @@ use DateInterval; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -26,20 +27,29 @@ use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; #[CoversClass(StatusListLifecycle::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListLifecycleTest extends TestCase { protected const string LIST_ID = 'a-status-list-id'; protected const string SIGNING_KEY_ID = 'a-signing-key-id'; + protected MockObject $moduleConfigMock; + protected MockObject $statusListRepositoryMock; + protected MockObject $statusListEntryRepositoryMock; + protected MockObject $statusAuditRepositoryMock; + protected MockObject $statusListKeyResolverMock; + protected MockObject $loggerServiceMock; + protected Helpers $helpers; + /** * @throws \Exception */ @@ -67,6 +77,7 @@ protected function setUp(): void $this->statusAuditRepositoryMock->method('removeOlderThan')->willReturn(0); } + protected function sut(): StatusListLifecycle { return new StatusListLifecycle( @@ -80,6 +91,7 @@ protected function sut(): StatusListLifecycle ); } + /** * @throws \Exception */ @@ -98,6 +110,7 @@ protected function pool(string $id = 'default'): StatusListPool ); } + /** * @throws \Exception */ @@ -114,6 +127,7 @@ public function testKeepsClearingLinkageWhileBatchesComeBackFull(): void $this->assertSame(1120, $this->sut()->clearExpiredCredentialLinkage()); } + /** * @throws \Exception */ @@ -127,6 +141,7 @@ public function testStopsClearingLinkageAsSoonAsABatchComesBackShort(): void $this->assertSame(3, $this->sut()->clearExpiredCredentialLinkage()); } + /** * @throws \Exception */ @@ -155,6 +170,7 @@ public function testDeactivatesListsWhosePolicyIsNoLongerCurrent(): void $this->assertSame(2, $this->sut()->deactivateSupersededStatusLists()); } + /** * A pool which allocates into both lanes has two current lists, and neither may be deactivated. Got * wrong, this is a rotation loop: one lane's list deactivated on every run and recreated by the next @@ -188,6 +204,7 @@ public function testTreatsBothLanesOfAMixedPoolAsCurrent(): void $this->assertSame(0, $this->sut()->deactivateSupersededStatusLists()); } + /** * An operator who has switched the feature off for a moment has not asked for every list they have * to start winding down, and switching it back on would not undo it. @@ -205,6 +222,7 @@ public function testDeactivatesNothingWhileStatusListsAreSwitchedOff(): void $this->assertSame(0, $this->sut()->deactivateSupersededStatusLists()); } + /** * @throws \Exception */ @@ -232,6 +250,7 @@ static function (string $id, DateTimeImmutable $moment) use (&$spentBefore): boo $this->assertLessThan($this->helpers->dateTime()->getUtc(), $spentBefore); } + /** * Whether anything is still holding the list is decided by the statement which retires it, not by a * read beforehand. A list which stopped qualifying in between -- an issuance which was already in @@ -249,6 +268,7 @@ public function testDoesNotCountAListTheRetiringStatementRefused(): void $this->assertSame(0, $this->sut()->retireSpentStatusLists()); } + /** * Nothing is read from the entries here. Deciding first and retiring second leaves a gap, and there * are no transactions to close it with. @@ -269,6 +289,7 @@ public function testDoesNotReadTheEntriesBeforeRetiring(): void $this->assertSame(1, $this->sut()->retireSpentStatusLists()); } + /** * Retiring a list takes it out of the set being paged through, so the next query has to resume after * the last identifier seen rather than at a numeric offset. @@ -297,6 +318,7 @@ static function (DateTimeImmutable $before, int $limit, ?string $afterId) use (& $this->assertSame([null, 'list-100'], $seenCursors); } + /** * @throws \Exception */ @@ -315,6 +337,7 @@ public function testRemovesTheEntriesOfRetiredListsUntilNoneAreLeft(): void $this->assertSame(2250, $this->sut()->purgeRetiredStatusListEntries()); } + /** * @throws \Exception */ @@ -325,6 +348,7 @@ public function testRemovesNoEntriesWhenNoListHasBeenRetired(): void $this->assertSame(0, $this->sut()->purgeRetiredStatusListEntries()); } + /** * A list retired a moment ago is left alone. Retirement can not be serialised against an issuance * which was already in flight, so a credential can land in a list just after it was retired -- @@ -352,6 +376,7 @@ static function (int $limit, DateTimeImmutable $moment) use (&$retiredBefore): a $this->assertLessThan($this->helpers->dateTime()->getUtc(), $retiredBefore); } + /** * How long a record of who revoked what needs keeping follows from the deployment's own obligations, * so nothing is discarded unless an operator has said how long is long enough. @@ -366,6 +391,7 @@ public function testPrunesNoAuditRowsWithoutAConfiguredRetention(): void $this->assertSame(0, $this->sut()->pruneStatusAuditTrail()); } + /** * @throws \Exception */ @@ -390,6 +416,7 @@ static function (DateTimeImmutable $createdBefore) use (&$cutOff): int { $this->assertLessThan($this->helpers->dateTime()->getUtc(), $cutOff); } + /** * @throws \Exception */ @@ -418,6 +445,7 @@ public function testRunReportsWhatEachStepGotThrough(): void $this->assertTrue($report->hasChanges()); } + /** * The steps share tables but not purposes, and one of them is an undertaking made to the people the * credentials were issued to. A failure elsewhere must not quietly suspend it. @@ -445,6 +473,7 @@ public function testRunCarriesOnAfterAStepFails(): void $this->assertStringContainsString('the database went away', $report->getFailures()[0]); } + /** * @throws \Exception */ diff --git a/tests/unit/src/StatusList/StatusListRateLimiterTest.php b/tests/unit/src/StatusList/StatusListRateLimiterTest.php index e8660cfb..96c4fa5f 100644 --- a/tests/unit/src/StatusList/StatusListRateLimiterTest.php +++ b/tests/unit/src/StatusList/StatusListRateLimiterTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,16 +16,21 @@ use SimpleSAML\Module\oidc\Utils\ProtocolCache; #[CoversClass(StatusListRateLimiter::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListRateLimiterTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $protocolCacheMock; + protected MockObject $loggerServiceMock; + protected Helpers $helpers; /** @var array */ protected array $cached = []; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -46,6 +52,7 @@ function (mixed $value, mixed $ttl, string ...$keyElements): void { ); } + protected function sut(?ProtocolCache $protocolCache = null): StatusListRateLimiter { return new StatusListRateLimiter( @@ -56,6 +63,7 @@ protected function sut(?ProtocolCache $protocolCache = null): StatusListRateLimi ); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -69,6 +77,7 @@ public function testAllowsUpToTheLimitAndThenRefuses(): void $this->assertFalse($sut->allows('198.51.100.7')); } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -84,6 +93,7 @@ public function testCountsEachClientSeparately(): void $this->assertTrue($sut->allows('198.51.100.8')); } + /** * The address is only needed to tell one client from another, never to report who asked for what. * @@ -100,6 +110,7 @@ public function testDoesNotKeepTheClientAddressInTheCache(): void } } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -116,6 +127,7 @@ public function testAppliesNoLimitWhenNoneIsConfigured(): void } } + /** * Without somewhere to count, there is nothing to count -- and refusing on that basis would take a * public endpoint down for the sake of a limit which was never being applied anyway. @@ -136,6 +148,7 @@ public function testAllowsEverythingWithoutACache(): void } } + /** * @throws \SimpleSAML\Error\ConfigurationError */ @@ -145,6 +158,7 @@ public function testAllowsWhenThereIsNothingToCountAgainst(): void $this->assertTrue($this->sut()->allows('')); } + /** * A cache which is down must not take the endpoint down with it. * diff --git a/tests/unit/src/StatusList/StatusListReconcilerTest.php b/tests/unit/src/StatusList/StatusListReconcilerTest.php index 8010d105..bcf8a8bc 100644 --- a/tests/unit/src/StatusList/StatusListReconcilerTest.php +++ b/tests/unit/src/StatusList/StatusListReconcilerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,13 +16,18 @@ use SimpleSAML\Module\oidc\StatusList\Values\StatusListReconciliationCandidate; #[CoversClass(StatusListReconciler::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListReconcilerTest extends TestCase { protected MockObject $statusListRepositoryMock; + protected MockObject $statusListEntryRepositoryMock; + protected MockObject $loggerServiceMock; + protected StatusListContentHasher $statusListContentHasher; + protected function setUp(): void { $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); @@ -30,6 +36,7 @@ protected function setUp(): void $this->statusListContentHasher = new StatusListContentHasher(); } + protected function sut(): StatusListReconciler { return new StatusListReconciler( @@ -40,6 +47,7 @@ protected function sut(): StatusListReconciler ); } + protected function record( string $id, string $signedTokenContentHash, @@ -48,6 +56,7 @@ protected function record( return new StatusListReconciliationCandidate($id, 2, 64, $signedTokenContentHash, $invalidationCounter); } + /** * @param array $statuses */ @@ -56,6 +65,7 @@ protected function hashFor(array $statuses): string return $this->statusListContentHasher->hash(2, 64, $statuses); } + /** * @throws \Exception */ @@ -67,6 +77,7 @@ public function testDoesNothingWhenNothingIsPublished(): void $this->assertSame(0, $this->sut()->reconcile()); } + /** * @throws \Exception */ @@ -80,6 +91,7 @@ public function testLeavesATokenWhichStillDescribesItsList(): void $this->assertSame(0, $this->sut()->reconcile()); } + /** * The failure this exists for: the entry update landed and the invalidation which should have * followed it did not, leaving a published token which reports a revoked credential as valid. @@ -100,6 +112,7 @@ public function testInvalidatesATokenWhichNoLongerDescribesItsList(): void $this->assertSame(1, $this->sut()->reconcile()); } + /** * A signer may publish a correct token between the batch being read and this decision. Clearing * that would be churn, and repeated runs could keep defeating a signer doing the right thing, so @@ -120,6 +133,7 @@ public function testLeavesATokenPublishedSinceTheBatchWasRead(): void $this->assertSame(0, $this->sut()->reconcile()); } + /** * A short page means there is no next one, so nothing more is asked for. * @@ -134,6 +148,7 @@ public function testStopsOnceAPageIsNotFull(): void $this->assertSame(0, $this->sut()->reconcile()); } + /** * Invalidating a list takes it out of the set being paged through, so a numeric offset would step * over exactly as many unexamined lists as were invalidated. Resuming after the last identifier @@ -171,6 +186,7 @@ function (int $limit, ?string $afterId = null) use (&$cursors, &$pages): array { $this->assertSame([null, 'list-099'], $cursors); } + /** * Stopping short is not a normal outcome, because every run starts from the beginning: the lists * past the ceiling are examined by no run at all. It has to be said out loud rather than passed diff --git a/tests/unit/src/StatusList/SubjectRefHasherTest.php b/tests/unit/src/StatusList/SubjectRefHasherTest.php index 23171273..96ea6507 100644 --- a/tests/unit/src/StatusList/SubjectRefHasherTest.php +++ b/tests/unit/src/StatusList/SubjectRefHasherTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -11,21 +12,25 @@ use SimpleSAML\Module\oidc\StatusList\SubjectRefHasher; #[CoversClass(SubjectRefHasher::class)] +#[AllowMockObjectsWithoutExpectations] class SubjectRefHasherTest extends TestCase { protected MockObject $moduleConfigMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->moduleConfigMock->method('getEncryptionKey')->willReturn('a-secret-salt'); } + protected function sut(?ModuleConfig $moduleConfig = null): SubjectRefHasher { return new SubjectRefHasher($moduleConfig ?? $this->moduleConfigMock); } + public function testProducesAValueSizedForItsColumn(): void { $hash = $this->sut()->hash('student@example.org'); @@ -34,6 +39,7 @@ public function testProducesAValueSizedForItsColumn(): void $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash); } + public function testIsStableForTheSameIdentifier(): void { $this->assertSame( @@ -42,6 +48,7 @@ public function testIsStableForTheSameIdentifier(): void ); } + public function testDistinguishesIdentifiers(): void { $this->assertNotSame( @@ -50,6 +57,7 @@ public function testDistinguishesIdentifiers(): void ); } + /** * The point of keying the hash is that an identifier with little entropy, such as an email address, * can not be confirmed by guessing it and hashing. Whoever holds the database but not the key must @@ -66,6 +74,7 @@ public function testDependsOnTheKeyAndNotOnlyOnTheIdentifier(): void ); } + /** * A plain SHA-256 of the identifier is exactly what this must not be. */ @@ -77,6 +86,7 @@ public function testIsNotAnUnkeyedDigestOfTheIdentifier(): void ); } + /** * Deriving the key from the module's encryption key means there is no separate secret to manage, * but it must not be usable as, or derivable back to, that key. diff --git a/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php b/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php index 69b80129..b28b268e 100644 --- a/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php +++ b/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList\Values; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\StatusList\Values\StatusListLifecycleReport; #[CoversClass(StatusListLifecycleReport::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListLifecycleReportTest extends TestCase { public function testCarriesWhatEachStepGotThrough(): void @@ -23,6 +25,7 @@ public function testCarriesWhatEachStepGotThrough(): void $this->assertSame(['something went wrong'], $report->getFailures()); } + /** * A cron which had nothing to do should say nothing, rather than adding a line reporting five zeroes * to every run of every deployment. @@ -32,12 +35,14 @@ public function testARunWhichChangedNothingSaysSo(): void $this->assertFalse((new StatusListLifecycleReport())->hasChanges()); } + public function testAnyStepGettingSomethingDoneCounts(): void { $this->assertTrue((new StatusListLifecycleReport(0, 0, 0, 0, 1))->hasChanges()); $this->assertTrue((new StatusListLifecycleReport(1))->hasChanges()); } + /** * A run which got nothing done because everything failed still has something to report. */ diff --git a/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php b/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php index c2d8b9d6..b370649a 100644 --- a/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php +++ b/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList\Values; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Error\ConfigurationError; @@ -12,6 +13,7 @@ use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; #[CoversClass(StatusListPoolBag::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListPoolBagTest extends TestCase { /** @@ -23,6 +25,7 @@ protected function sut(array $config): StatusListPoolBag return StatusListPoolBag::fromConfig($config, StatusListKeyProfileEnum::DidJwk); } + public function testAnEmptyConfigurationYieldsAnEmptyBag(): void { $bag = $this->sut([]); @@ -32,6 +35,7 @@ public function testAnEmptyConfigurationYieldsAnEmptyBag(): void $this->assertNull($bag->getForCredentialConfigurationId('Anything')); } + public function testResolvesACredentialConfigurationToItsPool(): void { $bag = $this->sut([ @@ -46,6 +50,7 @@ public function testResolvesACredentialConfigurationToItsPool(): void $this->assertSame('degrees', $bag->getById('degrees')?->getId()); } + /** * A configuration in no pool is not an error: its credentials are simply issued without a status * claim, and so can not be revoked. @@ -59,6 +64,7 @@ public function testACredentialConfigurationInNoPoolResolvesToNothing(): void $this->assertNull($bag->getForCredentialConfigurationId('EmployeeBadge')); } + /** * Allocation needs one answer to which policy a credential is issued under, so two pools claiming * the same configuration is a configuration error rather than something to resolve by precedence. @@ -74,6 +80,7 @@ public function testRejectsACredentialConfigurationListedInTwoPools(): void ]); } + public function testRejectsAPoolWhoseSettingsAreNotAnArray(): void { $this->expectException(ConfigurationError::class); @@ -81,6 +88,7 @@ public function testRejectsAPoolWhoseSettingsAreNotAnArray(): void $this->sut(['degrees' => 'UniversityDegree']); } + public function testRejectsAPoolWithoutAnIdentifier(): void { $this->expectException(ConfigurationError::class); @@ -88,6 +96,7 @@ public function testRejectsAPoolWithoutAnIdentifier(): void $this->sut([[StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree']]]); } + public function testListsEveryCredentialConfigurationItCovers(): void { $bag = $this->sut([ diff --git a/tests/unit/src/StatusList/Values/StatusListPoolTest.php b/tests/unit/src/StatusList/Values/StatusListPoolTest.php index b922fed7..3678e2a1 100644 --- a/tests/unit/src/StatusList/Values/StatusListPoolTest.php +++ b/tests/unit/src/StatusList/Values/StatusListPoolTest.php @@ -4,7 +4,9 @@ namespace SimpleSAML\Test\Module\oidc\unit\StatusList\Values; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; @@ -12,12 +14,14 @@ use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; #[CoversClass(StatusListPool::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListPoolTest extends TestCase { protected const string POOL_ID = 'default'; protected const string KEY_ID = 'signing-key-1'; + /** * @param array $overrides * @throws \SimpleSAML\Error\ConfigurationError @@ -36,6 +40,7 @@ protected function sut( ); } + public function testAppliesDefaultsForEverythingNotConfigured(): void { $pool = $this->sut(); @@ -49,6 +54,7 @@ public function testAppliesDefaultsForEverythingNotConfigured(): void $this->assertSame(['SomeCredential'], $pool->getCredentialConfigurationIds()); } + public function testDefaultCapacityIsDivisibleByEight(): void { // The specification recommends this for the list size, and it is what keeps the number of @@ -56,6 +62,7 @@ public function testDefaultCapacityIsDivisibleByEight(): void $this->assertSame(0, StatusListPool::DEFAULT_CAPACITY % 8); } + public function testTakesTheGlobalKeyProfileAndAllowsAPoolToOverrideIt(): void { $this->assertSame( @@ -81,6 +88,7 @@ public function testTakesTheGlobalKeyProfileAndAllowsAPoolToOverrideIt(): void ); } + public function testRejectsAnUnknownKeyProfile(): void { $this->expectException(ConfigurationError::class); @@ -89,6 +97,7 @@ public function testRejectsAnUnknownKeyProfile(): void $this->sut([StatusListPool::KEY_KEY_PROFILE => 'x509']); } + public function testRejectsAPoolWithNoCredentialConfigurations(): void { $this->expectException(ConfigurationError::class); @@ -97,6 +106,7 @@ public function testRejectsAPoolWithNoCredentialConfigurations(): void StatusListPool::fromConfig(self::POOL_ID, [], StatusListKeyProfileEnum::DidJwk); } + /** * @return array */ @@ -105,7 +115,8 @@ public static function invalidBitsProvider(): array return ['zero' => [0], 'three' => [3], 'five' => [5], 'sixteen' => [16], 'negative' => [-1]]; } - #[\PHPUnit\Framework\Attributes\DataProvider('invalidBitsProvider')] + + #[DataProvider('invalidBitsProvider')] public function testRejectsBitsWhichAreNotOneOfTheAllowedValues(int $bits): void { $this->expectException(ConfigurationError::class); @@ -113,18 +124,21 @@ public function testRejectsBitsWhichAreNotOneOfTheAllowedValues(int $bits): void $this->sut([StatusListPool::KEY_BITS => $bits]); } + public function testRejectsACapacityWhichIsNotAPositiveMultipleOfEight(): void { $this->expectException(ConfigurationError::class); $this->sut([StatusListPool::KEY_CAPACITY => 100]); } + public function testRejectsANonPositiveCapacity(): void { $this->expectException(ConfigurationError::class); $this->sut([StatusListPool::KEY_CAPACITY => 0]); } + /** * The number of bits fixes the largest status a list can ever carry, and reconfiguring it later * can not retrofit lists which already exist. A pool which may suspend has to say so up front. @@ -140,6 +154,7 @@ public function testRejectsAStatusWhichDoesNotFitTheConfiguredBits(): void ]); } + public function testAcceptsSuspendedOnceThereAreEnoughBits(): void { $pool = $this->sut([ @@ -150,6 +165,7 @@ public function testAcceptsSuspendedOnceThereAreEnoughBits(): void $this->assertTrue($pool->isStatusAllowed(StatusTypeEnum::Suspended)); } + /** * An entry which can be revoked has to be able to be reinstated, and an index which was never * allocated reads as Valid regardless of configuration. @@ -162,6 +178,7 @@ public function testAlwaysAllowsValidEvenWhenItWasNotConfigured(): void $this->assertSame('0,1', $pool->getAllowedStatusesAsString()); } + public function testAcceptsAStatusGivenAsItsRegisteredIntegerValue(): void { $pool = $this->sut([ @@ -173,6 +190,7 @@ public function testAcceptsAStatusGivenAsItsRegisteredIntegerValue(): void $this->assertSame('0,1,2', $pool->getAllowedStatusesAsString()); } + /** * Casting a string to an integer turns every typo into 0, which is Valid, so a misspelt status * would silently configure the pool to allow nothing rather than being reported. @@ -184,6 +202,7 @@ public function testRejectsAStatusGivenAsAString(): void $this->sut([StatusListPool::KEY_ALLOWED_STATUSES => ['invalid']]); } + public function testRejectsAnUnregisteredStatusValue(): void { $this->expectException(ConfigurationError::class); @@ -191,6 +210,7 @@ public function testRejectsAnUnregisteredStatusValue(): void $this->sut([StatusListPool::KEY_BITS => 4, StatusListPool::KEY_ALLOWED_STATUSES => [7]]); } + /** * Getting this the wrong way round leaves a recurring window in every cycle where the published * token has expired and its replacement has not been produced yet. @@ -206,6 +226,7 @@ public function testRejectsARefreshIntervalWhichDoesNotFitInsideTheTokenValidity ]); } + public function testRejectsARefreshIntervalLeavingLessThanTheSafetyMargin(): void { $this->expectException(ConfigurationError::class); @@ -217,6 +238,7 @@ public function testRejectsARefreshIntervalLeavingLessThanTheSafetyMargin(): voi ]); } + public function testAcceptsARefreshIntervalWithEnoughHeadroom(): void { $pool = $this->sut([ @@ -227,6 +249,7 @@ public function testAcceptsARefreshIntervalWithEnoughHeadroom(): void $this->assertSame(1800, $pool->getRefreshIntervalInSeconds()); } + public function testRejectsAnUnparsableDuration(): void { $this->expectException(ConfigurationError::class); @@ -234,6 +257,7 @@ public function testRejectsAnUnparsableDuration(): void $this->sut([StatusListPool::KEY_TTL => 'twelve hours']); } + public function testRejectsANonIntegerBitsValue(): void { $this->expectException(ConfigurationError::class); @@ -241,6 +265,7 @@ public function testRejectsANonIntegerBitsValue(): void $this->sut([StatusListPool::KEY_BITS => '2']); } + public function testTellsWhichCredentialConfigurationsItServes(): void { $pool = $this->sut([ @@ -252,6 +277,7 @@ public function testTellsWhichCredentialConfigurationsItServes(): void $this->assertFalse($pool->hasCredentialConfigurationId('C')); } + public function testPolicyFingerprintIsStableForTheSamePolicy(): void { $this->assertSame( @@ -260,6 +286,7 @@ public function testPolicyFingerprintIsStableForTheSamePolicy(): void ); } + /** * A duration has no length until something anchors it, and anchoring it to "now" in a timezone * which observes daylight saving makes P7D worth an hour more or less at certain times of year. @@ -290,6 +317,7 @@ public function testDurationsDoNotDependOnTheServerTimezoneOrTheCurrentDate(): v } } + /** * The same, seen through the value which actually matters: the fingerprint allocation filters on. */ @@ -310,6 +338,7 @@ public function testPolicyFingerprintDoesNotDependOnTheServerTimezone(): void } } + /** * @return array}> */ @@ -331,7 +360,7 @@ public static function policyChangingOverrideProvider(): array /** * @param array $override */ - #[\PHPUnit\Framework\Attributes\DataProvider('policyChangingOverrideProvider')] + #[DataProvider('policyChangingOverrideProvider')] public function testPolicyFingerprintChangesWithAnySettingBakedIntoALists(array $override): void { $this->assertNotSame( @@ -340,6 +369,7 @@ public function testPolicyFingerprintChangesWithAnySettingBakedIntoALists(array ); } + /** * During a key rotation the issuer signs credentials with the current key, so a list still bound to * the previous one must stop being selected, or the profile saying the two are the same key breaks. @@ -352,6 +382,7 @@ public function testPolicyFingerprintChangesWithTheSigningKey(): void ); } + /** * The refresh interval governs when a token is re-signed, not what any credential resolves to, so * changing it must not strand a half filled list. @@ -366,6 +397,7 @@ public function testPolicyFingerprintIgnoresTheRefreshInterval(): void ); } + /** * The pool a credential belongs to is not part of what a list carries, but two pools sharing a * fingerprint would let one pool's credentials be allocated into the other's list. diff --git a/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php b/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php index d10f5013..985b8e08 100644 --- a/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php +++ b/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php @@ -6,11 +6,13 @@ use DateTimeImmutable; use DateTimeZone; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\StatusList\Values\StatusListTokenResult; #[CoversClass(StatusListTokenResult::class)] +#[AllowMockObjectsWithoutExpectations] class StatusListTokenResultTest extends TestCase { protected function sut( @@ -27,11 +29,13 @@ protected function sut( ); } + protected function moment(string $moment): DateTimeImmutable { return new DateTimeImmutable($moment, new DateTimeZone('UTC')); } + public function testCarriesWhatItWasGiven(): void { $result = $this->sut(); @@ -42,6 +46,7 @@ public function testCarriesWhatItWasGiven(): void $this->assertSame('2026-08-08 12:00:00', $result->getExpiresAt()->format('Y-m-d H:i:s')); } + public function testTheEntityTagIsQuotedAndDerivedFromTheToken(): void { $this->assertMatchesRegularExpression('/^"[0-9a-f]{64}"$/', $this->sut()->getEntityTag()); @@ -50,6 +55,7 @@ public function testTheEntityTagIsQuotedAndDerivedFromTheToken(): void $this->assertNotSame($this->sut()->getEntityTag(), $this->sut('other.token.here')->getEntityTag()); } + /** * An encoded body and an unencoded one are different representations, so a shared cache holding both * has to be able to tell them apart. @@ -60,6 +66,7 @@ public function testTheEntityTagNamesTheContentCoding(): void $this->assertStringEndsWith('-gzip"', $this->sut()->getEntityTag('gzip')); } + /** * The `ttl` is what the specification offers a Relying Party, so it is the ceiling while the token * has longer to live than that. @@ -69,6 +76,7 @@ public function testCachesForTheTimeToLiveWhileThereIsRoomForIt(): void $this->assertSame(43200, $this->sut()->getMaxAgeSeconds($this->moment('2026-08-01 12:00:00'))); } + /** * Close to expiry the token's own remaining life is shorter than the `ttl`, and a cached copy must * not outlive the token: past expiry it is not stale but invalid. @@ -81,6 +89,7 @@ public function testNeverCachesPastTheTokensOwnExpiry(): void ); } + public function testAnAlreadyExpiredTokenIsNotCacheableAtAll(): void { $this->assertSame(0, $this->sut()->getMaxAgeSeconds($this->moment('2026-08-09 12:00:00'))); diff --git a/tests/unit/src/Stores/Session/LogoutTicketStoreBuilderTest.php b/tests/unit/src/Stores/Session/LogoutTicketStoreBuilderTest.php index 87887109..83676faf 100644 --- a/tests/unit/src/Stores/Session/LogoutTicketStoreBuilderTest.php +++ b/tests/unit/src/Stores/Session/LogoutTicketStoreBuilderTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Stores\Session; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; @@ -12,6 +13,7 @@ /** * @covers \SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder */ +#[AllowMockObjectsWithoutExpectations] class LogoutTicketStoreBuilderTest extends TestCase { public function testConstructWithDefaultStore(): void diff --git a/tests/unit/src/Stores/Session/LogoutTicketStoreDbTest.php b/tests/unit/src/Stores/Session/LogoutTicketStoreDbTest.php index 68fa5f32..b498ff61 100644 --- a/tests/unit/src/Stores/Session/LogoutTicketStoreDbTest.php +++ b/tests/unit/src/Stores/Session/LogoutTicketStoreDbTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Stores\Session; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Services\DatabaseMigration; @@ -12,6 +13,7 @@ /** * @covers \SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb */ +#[AllowMockObjectsWithoutExpectations] class LogoutTicketStoreDbTest extends TestCase { public static function setUpBeforeClass(): void @@ -29,6 +31,7 @@ public static function setUpBeforeClass(): void (new DatabaseMigration())->migrate(); } + /** * @throws \Exception */ @@ -46,6 +49,7 @@ public function testCanAddAndDeleteTickets(): void $this->assertEmpty($store->getAll()); } + /** * @throws \Exception */ @@ -75,6 +79,7 @@ public function testCanDeleteMultipleTickets(): void $this->assertEmpty($store->getAll()); } + /** * @throws \Exception */ diff --git a/tests/unit/src/TranslationCatalogCoverageTest.php b/tests/unit/src/TranslationCatalogCoverageTest.php index a6e005a1..e8beb693 100644 --- a/tests/unit/src/TranslationCatalogCoverageTest.php +++ b/tests/unit/src/TranslationCatalogCoverageTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use RecursiveDirectoryIterator; @@ -27,15 +28,18 @@ * keeping on each line but the last the space which joins it to the next. */ #[CoversNothing] +#[AllowMockObjectsWithoutExpectations] class TranslationCatalogCoverageTest extends TestCase { protected static string $projectRoot; + public static function setUpBeforeClass(): void { self::$projectRoot = dirname(__DIR__, 3); } + /** * Every string the code marks for translation must be in every catalog. */ @@ -60,6 +64,7 @@ public function testEveryTranslatableStringIsInEveryCatalog(): void } } + /** * A translatable string must not carry leading or trailing whitespace. * @@ -85,6 +90,7 @@ public function testNoTranslatableStringCarriesEdgeWhitespace(): void ); } + /** * `Translate::noop()` exists to put a literal in front of the extractor. An argument it can not * resolve is a string which can never reach a catalog. @@ -104,6 +110,7 @@ public function testEveryNoopArgumentIsALiteral(): void $this->assertSame([], array_values(array_unique($unresolvable))); } + /** * @return string[] */ @@ -133,6 +140,7 @@ protected function translatableStrings(): array return $strings; } + /** * @return string[] */ @@ -144,6 +152,7 @@ protected function catalogPaths(): array return $paths; } + /** * Every PHP file which can mark a string for translation. * @@ -157,6 +166,7 @@ protected function phpSources(): array return array_merge($this->filesUnder('src', 'php'), $this->filesUnder('hooks', 'php')); } + /** * @return string[] */ @@ -179,6 +189,7 @@ protected function filesUnder(string $directory, string $extension): array return $found; } + /** * Every `Translate::noop()` argument in a PHP file, as PHP itself would resolve it. Concatenated * string literals resolve to the joined value; anything else yields null, meaning unresolvable. @@ -243,6 +254,7 @@ protected function noopArguments(string $path): array return $arguments; } + /** * The three ways a Twig template in this module marks a string for translation. * @@ -274,6 +286,7 @@ protected function twigStrings(string $path): array return array_merge($strings, $this->parenthesisedTransStrings($contents)); } + /** * Literals inside a parenthesised expression which is then piped to `trans`. * @@ -328,6 +341,7 @@ protected function parenthesisedTransStrings(string $contents): array return $strings; } + /** * Catalog msgids, joining the continuation lines of a wrapped entry back together. * diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php index 96bba9be..b390dc4c 100644 --- a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -4,16 +4,19 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use RuntimeException; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\DateTime; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -32,29 +35,47 @@ use Symfony\Component\HttpFoundation\Request; #[CoversClass(AuthenticatedOAuth2ClientResolver::class)] +#[AllowMockObjectsWithoutExpectations] class AuthenticatedOAuth2ClientResolverTest extends TestCase { - protected const CLIENT_ID = 'test-client-id'; - protected const CLIENT_SECRET = 'test-client-secret'; - protected const TOKEN_ENDPOINT = 'https://example.org/oidc/token.php'; - protected const ISSUER = 'https://example.org'; + protected const string CLIENT_ID = 'test-client-id'; + + protected const string CLIENT_SECRET = 'test-client-secret'; + + protected const string TOKEN_ENDPOINT = 'https://example.org/oidc/token.php'; + + protected const string ISSUER = 'https://example.org'; + protected MockObject $clientRepositoryMock; + protected MockObject $requestParamsResolverMock; + protected MockObject $loggerServiceMock; + protected MockObject $psrHttpBridgeMock; + protected MockObject $psrHttpFactoryMock; + protected MockObject $jwksResolverMock; + protected MockObject $moduleConfigMock; + protected MockObject $routesMock; + protected MockObject $helpersMock; + protected MockObject $dateTimeHelperMock; + protected Stub $protocolCacheStub; protected MockObject $serverRequestMock; + protected MockObject $clientEntityMock; + protected MockObject $clientAssertionMock; + protected function setUp(): void { $this->clientRepositoryMock = $this->createMock(ClientRepository::class); @@ -73,7 +94,7 @@ protected function setUp(): void [RoutesEnum::Authorization->value, 'https://example.org/oidc/authorization.php'], [RoutesEnum::PushedAuthorizationRequest->value, 'https://example.org/oidc/par'], ]); - $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); + $this->dateTimeHelperMock = $this->createMock(DateTime::class); $this->helpersMock = $this->createMock(Helpers::class); $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); $this->protocolCacheStub = $this->createStub(ProtocolCache::class); @@ -93,6 +114,7 @@ protected function setUp(): void $this->clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); } + protected function sut(?ProtocolCache $protocolCache = null): AuthenticatedOAuth2ClientResolver { return new AuthenticatedOAuth2ClientResolver( @@ -128,6 +150,7 @@ public function testForPublicClientReturnsNullWhenNoClientIdInRequest(): void $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); } + public function testForPublicClientReturnsNullWhenClientIdIsEmptyString(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(''); @@ -135,6 +158,7 @@ public function testForPublicClientReturnsNullWhenClientIdIsEmptyString(): void $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); } + public function testForPublicClientThrowsWhenClientIsConfidential(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -147,6 +171,7 @@ public function testForPublicClientThrowsWhenClientIsConfidential(): void $this->sut()->forPublicClient($this->serverRequestMock, null); } + public function testForPublicClientThrowsWhenClientNotFound(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -158,6 +183,7 @@ public function testForPublicClientThrowsWhenClientNotFound(): void $this->sut()->forPublicClient($this->serverRequestMock, null); } + public function testForPublicClientReturnsResolvedResultForPublicClient(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -172,6 +198,7 @@ public function testForPublicClientReturnsResolvedResultForPublicClient(): void $this->assertSame(ClientAuthenticationMethodsEnum::None, $result->getClientAuthenticationMethod()); } + public function testForPublicClientUsesPreFetchedClientWhenProvided(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -196,6 +223,7 @@ public function testForClientSecretBasicReturnsNullWhenNoAuthorizationHeader(): $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); } + public function testForClientSecretBasicReturnsNullWhenHeaderIsNotBasic(): void { $this->serverRequestMock->method('getHeader')->with('Authorization') @@ -204,6 +232,7 @@ public function testForClientSecretBasicReturnsNullWhenHeaderIsNotBasic(): void $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); } + public function testForClientSecretBasicReturnsNullWhenBase64DecodeFailsStrictMode(): void { // Characters outside [A-Za-z0-9+/=] are invalid in strict mode. @@ -214,6 +243,7 @@ public function testForClientSecretBasicReturnsNullWhenBase64DecodeFailsStrictMo $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); } + public function testForClientSecretBasicReturnsNullWhenDecodedValueHasNoColon(): void { // Valid base64 of a string with no colon. @@ -224,6 +254,7 @@ public function testForClientSecretBasicReturnsNullWhenDecodedValueHasNoColon(): $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); } + public function testForClientSecretBasicReturnsNullWhenClientIdIsEmpty(): void { // Colon present but client ID part is empty: ":secret" @@ -234,6 +265,7 @@ public function testForClientSecretBasicReturnsNullWhenClientIdIsEmpty(): void $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); } + public function testForClientSecretBasicThrowsWhenClientIsNotConfidential(): void { $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); @@ -247,6 +279,7 @@ public function testForClientSecretBasicThrowsWhenClientIsNotConfidential(): voi $this->sut()->forClientSecretBasic($this->serverRequestMock); } + public function testForClientSecretBasicThrowsWhenSecretIsEmpty(): void { // Colon present but secret part is empty: "clientid:" @@ -261,6 +294,7 @@ public function testForClientSecretBasicThrowsWhenSecretIsEmpty(): void $this->sut()->forClientSecretBasic($this->serverRequestMock); } + public function testForClientSecretBasicThrowsWhenSecretIsInvalid(): void { $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':wrong-secret'); @@ -275,6 +309,7 @@ public function testForClientSecretBasicThrowsWhenSecretIsInvalid(): void $this->sut()->forClientSecretBasic($this->serverRequestMock); } + public function testForClientSecretBasicReturnsResolvedResultOnSuccess(): void { $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); @@ -294,6 +329,7 @@ public function testForClientSecretBasicReturnsResolvedResultOnSuccess(): void ); } + public function testForClientSecretBasicConvertsSymfonyRequestToPsr(): void { $symfonyRequest = Request::create('/', 'POST'); @@ -323,6 +359,7 @@ public function testForClientSecretPostReturnsNullWhenNoClientIdInPostBody(): vo $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); } + public function testForClientSecretPostReturnsNullWhenClientIdIsEmpty(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -331,6 +368,7 @@ public function testForClientSecretPostReturnsNullWhenClientIdIsEmpty(): void $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); } + public function testForClientSecretPostThrowsWhenClientIsNotConfidential(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -343,6 +381,7 @@ public function testForClientSecretPostThrowsWhenClientIsNotConfidential(): void $this->sut()->forClientSecretPost($this->serverRequestMock); } + public function testForClientSecretPostReturnsNullWhenSecretIsNull(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -351,6 +390,7 @@ public function testForClientSecretPostReturnsNullWhenSecretIsNull(): void $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); } + public function testForClientSecretPostReturnsNullWhenSecretIsEmpty(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -359,6 +399,7 @@ public function testForClientSecretPostReturnsNullWhenSecretIsEmpty(): void $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); } + public function testForClientSecretPostThrowsWhenSecretIsInvalid(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -372,6 +413,7 @@ public function testForClientSecretPostThrowsWhenSecretIsInvalid(): void $this->sut()->forClientSecretPost($this->serverRequestMock); } + public function testForClientSecretPostReturnsResolvedResultOnSuccess(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -402,6 +444,7 @@ public function testForPrivateKeyJwtReturnsNullWhenNoClientAssertionParam(): voi $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); } + public function testForPrivateKeyJwtReturnsNullWhenAssertionTypeIsNotJwtBearer(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -410,6 +453,7 @@ public function testForPrivateKeyJwtReturnsNullWhenAssertionTypeIsNotJwtBearer() $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); } + public function testForPrivateKeyJwtThrowsWhenJwksNotAvailable(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -425,6 +469,7 @@ public function testForPrivateKeyJwtThrowsWhenJwksNotAvailable(): void $this->sut()->forPrivateKeyJwt($this->serverRequestMock); } + public function testForPrivateKeyJwtThrowsWhenSignatureVerificationFails(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -442,6 +487,7 @@ public function testForPrivateKeyJwtThrowsWhenSignatureVerificationFails(): void $this->sut()->forPrivateKeyJwt($this->serverRequestMock); } + public function testForPrivateKeyJwtThrowsWhenJtiAlreadyUsed(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -462,6 +508,7 @@ public function testForPrivateKeyJwtThrowsWhenJtiAlreadyUsed(): void $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); } + public function testForPrivateKeyJwtThrowsWhenIssuerClaimDoesNotMatchClientId(): void { // The assertion issuer is CLIENT_ID, but we pass a pre-fetched client with a different @@ -483,6 +530,7 @@ public function testForPrivateKeyJwtThrowsWhenIssuerClaimDoesNotMatchClientId(): $this->sut()->forPrivateKeyJwt($this->serverRequestMock, $mismatchedClient); } + public function testForPrivateKeyJwtThrowsWhenSubjectClaimDoesNotMatchClientId(): void { $clientAssertionMock = $this->createMock(ClientAssertion::class); @@ -503,6 +551,7 @@ public function testForPrivateKeyJwtThrowsWhenSubjectClaimDoesNotMatchClientId() $this->sut()->forPrivateKeyJwt($this->serverRequestMock); } + public function testForPrivateKeyJwtThrowsWhenAudienceClaimDoesNotContainExpectedValue(): void { $clientAssertionMock = $this->createMock(ClientAssertion::class); @@ -525,6 +574,7 @@ public function testForPrivateKeyJwtThrowsWhenAudienceClaimDoesNotContainExpecte $this->sut()->forPrivateKeyJwt($this->serverRequestMock); } + public function testForPrivateKeyJwtAcceptsPushedAuthorizationRequestEndpointAsAudience(): void { // RFC 9126 Section 2: to facilitate interoperability, the AS MUST accept its issuer identifier, @@ -550,6 +600,7 @@ public function testForPrivateKeyJwtAcceptsPushedAuthorizationRequestEndpointAsA ); } + public function testForPrivateKeyJwtAcceptsIssuerIdentifierAsAudience(): void { $clientAssertionMock = $this->createMock(ClientAssertion::class); @@ -573,6 +624,7 @@ public function testForPrivateKeyJwtAcceptsIssuerIdentifierAsAudience(): void ); } + public function testForPrivateKeyJwtReturnsResolvedResultOnSuccess(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -593,6 +645,7 @@ public function testForPrivateKeyJwtReturnsResolvedResultOnSuccess(): void ); } + public function testForPrivateKeyJwtStoresJtiInCacheAfterSuccess(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -617,6 +670,7 @@ public function testForPrivateKeyJwtStoresJtiInCacheAfterSuccess(): void $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); } + public function testForPrivateKeyJwtSkipsJtiCheckWhenNoCacheProvided(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') @@ -646,11 +700,12 @@ public function testForAnySupportedMethodReturnsNullWhenNoMethodMatches(): void $this->assertNull($this->sut()->forAnySupportedMethod($this->serverRequestMock)); } + public function testForAnySupportedMethodReturnsNullAndLogsErrorOnException(): void { // Trigger a hard exception to verify the catch-all swallows it and logs. $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') - ->willThrowException(new \RuntimeException('Unexpected error')); + ->willThrowException(new RuntimeException('Unexpected error')); $this->loggerServiceMock->expects($this->once())->method('error'); @@ -659,6 +714,7 @@ public function testForAnySupportedMethodReturnsNullAndLogsErrorOnException(): v $this->assertNull($result); } + public function testForAnySupportedMethodPrefersPrivateKeyJwtOverOtherMethods(): void { // private_key_jwt assertion present — should resolve first and win. @@ -702,6 +758,7 @@ public function testFindActiveClientReturnsNullWhenClientNotFound(): void $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); } + public function testFindActiveClientReturnsNullWhenClientIsDisabled(): void { $disabledClient = $this->createMock(ClientEntityInterface::class); @@ -712,6 +769,7 @@ public function testFindActiveClientReturnsNullWhenClientIsDisabled(): void $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); } + public function testFindActiveClientReturnsNullWhenClientIsExpired(): void { $expiredClient = $this->createMock(ClientEntityInterface::class); @@ -723,6 +781,7 @@ public function testFindActiveClientReturnsNullWhenClientIsExpired(): void $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); } + public function testFindActiveClientReturnsClientWhenActive(): void { $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); @@ -743,6 +802,7 @@ public function testFindActiveClientOrFailThrowsWhenClientNotActive(): void $this->sut()->findActiveClientOrFail(self::CLIENT_ID); } + public function testFindActiveClientOrFailReturnsClientWhenActive(): void { $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); @@ -763,6 +823,7 @@ public function testValidateClientSecretThrowsWhenSecretDoesNotMatch(): void $this->sut()->validateClientSecret($this->clientEntityMock, 'wrong-secret'); } + public function testValidateClientSecretDoesNotThrowWhenSecretMatches(): void { $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); diff --git a/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php b/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php index 75510502..1ca8b929 100644 --- a/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php +++ b/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Stub; @@ -13,14 +14,18 @@ use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Utils\Attributes; +use stdClass; #[CoversClass(ClaimTranslatorExtractor::class)] +#[AllowMockObjectsWithoutExpectations] class ClaimTranslatorExtractorTest extends TestCase { /** @var string[] */ protected static array $userIdAttrs = ['uid']; + protected Stub $claimSetEntityFactoryStub; + protected function setUp(): void { $this->claimSetEntityFactoryStub = $this->createStub(ClaimSetEntityFactory::class); @@ -35,6 +40,7 @@ function (string $scope, array $claims): Stub { ); } + protected function mock( array $claimSets = [], array $translationTable = [], @@ -49,6 +55,7 @@ protected function mock( ); } + /** * Test various type conversions work, including types in subobjects * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -144,6 +151,7 @@ public function testTypeConversion(): void $this->assertSame($expectedClaims, $releasedClaims); } + /** * Test that the default translator configuration sets address correctly. * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -170,6 +178,7 @@ public function testDefaultTypeConversion(): void $this->assertSame($expectedClaims, $releasedClaims); } + /** * Test we can set the non-string standard claims * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -225,6 +234,7 @@ public function testStandardClaimTypesCanBeSet(): void $this->assertSame($expectedClaims, $releasedClaims); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -243,6 +253,7 @@ public function testInvalidTypeConversion(): void $claimTranslator->extract(['typeConversion'], $userAttributes); } + public function testConvertsIntegerSubjectClaimToString(): void { $releasedClaims = $this->mock()->extract( @@ -253,6 +264,7 @@ public function testConvertsIntegerSubjectClaimToString(): void $this->assertSame(['sub' => '123'], $releasedClaims); } + #[DataProvider('unsafeStringValuesProvider')] public function testRejectsUnsafeStringConversion(mixed $value, string $type): void { @@ -273,14 +285,16 @@ public function testRejectsUnsafeStringConversion(mixed $value, string $type): v ); } + public static function unsafeStringValuesProvider(): array { return [ 'null' => [null, 'null'], - 'non-stringable object' => [new \stdClass(), 'stdClass'], + 'non-stringable object' => [new stdClass(), 'stdClass'], ]; } + public function testRejectsEmptySubjectClaimAfterStringConversion(): void { $this->expectException(RuntimeException::class); @@ -292,6 +306,7 @@ public function testRejectsEmptySubjectClaimAfterStringConversion(): void ); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -308,6 +323,7 @@ public function testExtractRequestClaimsUserInfo(): void $this->assertEquals(['name' => 'bob'], $claims); } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -324,6 +340,7 @@ public function testExtractRequestClaimsIdToken(): void $this->assertEquals(['name' => 'bob'], $claims); } + public function testCanGetSupportedClaims(): void { $translate = [ @@ -336,6 +353,7 @@ public function testCanGetSupportedClaims(): void $this->assertTrue(in_array('custom', $this->mock([], $translate)->getSupportedClaims(), true)); } + public function testCanUnsetClaimWhichIsSupportedByDefault(): void { $this->assertTrue(in_array('nickname', $this->mock()->getSupportedClaims(), true)); @@ -344,6 +362,7 @@ public function testCanUnsetClaimWhichIsSupportedByDefault(): void $this->assertFalse(in_array('nickname', $this->mock([], $translate)->getSupportedClaims(), true)); } + public function testCanReleaseMultiValueClaims(): void { $claimSet = new ClaimSetEntity( @@ -376,6 +395,7 @@ public function testCanReleaseMultiValueClaims(): void $this->assertSame($expectedClaims, $releasedClaims); } + public function testWillReleaseSingleValueClaimsIfMultiValueNotAllowed(): void { $claimSet = new ClaimSetEntity( @@ -406,6 +426,7 @@ public function testWillReleaseSingleValueClaimsIfMultiValueNotAllowed(): void $this->assertSame($expectedClaims, $releasedClaims); } + public function testWillReleaseSingleValueClaimsForMandatorySingleValueClaims(): void { $claimSet = new ClaimSetEntity( diff --git a/tests/unit/src/Utils/DateIntervalFormatterTest.php b/tests/unit/src/Utils/DateIntervalFormatterTest.php index eb0c210a..f5acd114 100644 --- a/tests/unit/src/Utils/DateIntervalFormatterTest.php +++ b/tests/unit/src/Utils/DateIntervalFormatterTest.php @@ -5,12 +5,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; use DateInterval; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; #[CoversClass(DateIntervalFormatter::class)] +#[AllowMockObjectsWithoutExpectations] class DateIntervalFormatterTest extends TestCase { protected function sut(): DateIntervalFormatter @@ -18,11 +20,13 @@ protected function sut(): DateIntervalFormatter return new DateIntervalFormatter(); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(DateIntervalFormatter::class, $this->sut()); } + public static function humanReadableProvider(): array { return [ @@ -39,12 +43,14 @@ public static function humanReadableProvider(): array ]; } + #[DataProvider('humanReadableProvider')] public function testCanRenderHumanReadable(string $durationSpec, string $expected): void { $this->assertSame($expected, $this->sut()->toHumanReadable(new DateInterval($durationSpec))); } + public static function durationSpecProvider(): array { return [ @@ -59,6 +65,7 @@ public static function durationSpecProvider(): array ]; } + #[DataProvider('durationSpecProvider')] public function testCanRenderBackToDurationSpec(string $durationSpec): void { diff --git a/tests/unit/src/Utils/Debug/ArrayLoggerTest.php b/tests/unit/src/Utils/Debug/ArrayLoggerTest.php index 0a090174..a51b8066 100644 --- a/tests/unit/src/Utils/Debug/ArrayLoggerTest.php +++ b/tests/unit/src/Utils/Debug/ArrayLoggerTest.php @@ -4,30 +4,38 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils\Debug; +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\InvalidArgumentException; use Psr\Log\LogLevel; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\DateTime; use SimpleSAML\Module\oidc\Utils\Debug\ArrayLogger; #[CoversClass(ArrayLogger::class)] +#[AllowMockObjectsWithoutExpectations] class ArrayLoggerTest extends TestCase { protected MockObject $helpersMock; + protected MockObject $dateTimeMock; + protected int $weight; + protected function setUp(): void { $this->helpersMock = $this->createMock(Helpers::class); - $this->dateTimeMock = $this->createMock(Helpers\DateTime::class); + $this->dateTimeMock = $this->createMock(DateTime::class); $this->helpersMock->method('dateTime')->willReturn($this->dateTimeMock); - $this->dateTimeMock->method('getUtc')->willReturn(new \DateTimeImmutable()); + $this->dateTimeMock->method('getUtc')->willReturn(new DateTimeImmutable()); $this->weight = ArrayLogger::WEIGHT_DEBUG; } + protected function sut( ?Helpers $helpers = null, ?int $weight = null, @@ -38,11 +46,13 @@ protected function sut( return new ArrayLogger($helpers, $weight); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(ArrayLogger::class, $this->sut()); } + public function testCanLogEntriesBasedOnWeight(): void { $sut = $this->sut(); @@ -61,6 +71,7 @@ public function testCanLogEntriesBasedOnWeight(): void $this->assertCount(9, $sut->getEntries()); } + public function testWontLogLessThanEmergency(): void { $sut = $this->sut(weight: ArrayLogger::WEIGHT_EMERGENCY); @@ -79,6 +90,7 @@ public function testWontLogLessThanEmergency(): void $this->assertNotEmpty($sut->getEntries()); } + public function testThrowsOnInvalidLogLevel(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/unit/src/Utils/FederationParticipationValidatorTest.php b/tests/unit/src/Utils/FederationParticipationValidatorTest.php index 867823d2..e550be29 100644 --- a/tests/unit/src/Utils/FederationParticipationValidatorTest.php +++ b/tests/unit/src/Utils/FederationParticipationValidatorTest.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use Exception; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -18,16 +20,24 @@ use SimpleSAML\OpenID\Federation\TrustMarkValidator; #[CoversClass(FederationParticipationValidator::class)] +#[AllowMockObjectsWithoutExpectations] class FederationParticipationValidatorTest extends TestCase { protected MockObject $moduleConfigMock; + protected MockObject $federationMock; + protected MockObject $loggerMock; + protected MockObject $trustMarkValidatorMock; + protected MockObject $leafEntityConfiguration; + protected MockObject $trustAnchorEntityConfiguration; + protected MockObject $trustChainMock; + protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -49,6 +59,7 @@ protected function setUp(): void ->willReturn($this->trustAnchorEntityConfiguration); } + protected function sut( ?ModuleConfig $moduleConfig = null, ?Federation $federation = null, @@ -65,11 +76,13 @@ protected function sut( ); } + public function testCanConstruct(): void { $this->assertInstanceOf(FederationParticipationValidator::class, $this->sut()); } + public function testByTrustMarksFor(): void { $this->moduleConfigMock->expects($this->once()) @@ -89,6 +102,7 @@ public function testByTrustMarksFor(): void $this->sut()->byTrustMarksFor($this->trustChainMock); } + public function testByTrustMarksForEmptyLimitsDoesNotRunValidations(): void { $this->moduleConfigMock->expects($this->once()) @@ -102,6 +116,7 @@ public function testByTrustMarksForEmptyLimitsDoesNotRunValidations(): void $this->sut()->byTrustMarksFor($this->trustChainMock); } + public function testValidateForOneOfLimitDoesNotRunValidationOnEmptyLimit(): void { $this->trustMarkValidatorMock->expects($this->never()) @@ -114,12 +129,13 @@ public function testValidateForOneOfLimitDoesNotRunValidationOnEmptyLimit(): voi ); } + public function testValidateForOneOfLimitThrowsIfNoneAreValid(): void { $this->trustMarkValidatorMock->expects($this->atLeastOnce()) ->method('fromCacheOrDoForTrustMarkType') ->with('trustMarkType') - ->willThrowException(new \Exception('error')); + ->willThrowException(new Exception('error')); $this->expectException(TrustMarkException::class); $this->expectExceptionMessage('OneOf limit rule failed'); @@ -131,6 +147,7 @@ public function testValidateForOneOfLimitThrowsIfNoneAreValid(): void ); } + public function testValidateForAllOfLimitDoesNotRunValidationOnEmptyLimit(): void { $this->trustMarkValidatorMock->expects($this->never()) @@ -143,12 +160,13 @@ public function testValidateForAllOfLimitDoesNotRunValidationOnEmptyLimit(): voi ); } + public function testValidateForAllOfLimitThrowsIfAnyIsInvalid(): void { $this->trustMarkValidatorMock->expects($this->atLeastOnce()) ->method('fromCacheOrDoForTrustMarkType') ->with('trustMarkType') - ->willThrowException(new \Exception('error')); + ->willThrowException(new Exception('error')); $this->expectException(TrustMarkException::class); $this->expectExceptionMessage('AllOf limit rule failed'); diff --git a/tests/unit/src/Utils/HttpContentNegotiatorTest.php b/tests/unit/src/Utils/HttpContentNegotiatorTest.php index 9f7f329f..8fb71e42 100644 --- a/tests/unit/src/Utils/HttpContentNegotiatorTest.php +++ b/tests/unit/src/Utils/HttpContentNegotiatorTest.php @@ -4,21 +4,25 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Utils\HttpContentNegotiator; #[CoversClass(HttpContentNegotiator::class)] +#[AllowMockObjectsWithoutExpectations] class HttpContentNegotiatorTest extends TestCase { protected const string MEDIA_TYPE = 'application/statuslist+jwt'; + protected function sut(): HttpContentNegotiator { return new HttpContentNegotiator(); } + /** * @return array */ @@ -43,12 +47,14 @@ public static function acceptHeaders(): array ]; } + #[DataProvider('acceptHeaders')] public function testAcceptsMediaType(?string $accept, bool $expected): void { $this->assertSame($expected, $this->sut()->acceptsMediaType($accept, self::MEDIA_TYPE)); } + /** * The specific range wins over the general one whichever way round the weights fall, which is what * lets a client say "anything but this" -- and, the other way round, "only this". @@ -69,6 +75,7 @@ public function testTheMoreSpecificRangeDecidesRegardlessOfWeight(): void ); } + /** * Everything past the weight is accept-ext rather than a second weight. */ @@ -79,28 +86,33 @@ public function testIgnoresAcceptExtensionsAfterTheWeight(): void ); } + public function testNoAcceptEncodingMeansNoEncoding(): void { $this->assertNull($this->sut()->preferredContentCoding(null, 'gzip')); $this->assertNull($this->sut()->preferredContentCoding('', 'gzip')); } + public function testChoosesAnOfferedCoding(): void { $this->assertSame('gzip', $this->sut()->preferredContentCoding('gzip, deflate', 'gzip')); $this->assertSame('gzip', $this->sut()->preferredContentCoding('GZIP', 'gzip')); } + public function testDeclinesACodingWhichIsNotOffered(): void { $this->assertNull($this->sut()->preferredContentCoding('br, zstd', 'gzip')); } + public function testHonoursAWildcard(): void { $this->assertSame('gzip', $this->sut()->preferredContentCoding('*', 'gzip')); } + /** * A weight of zero is a refusal, and an explicit refusal beats a permissive wildcard. */ @@ -110,6 +122,7 @@ public function testARefusedCodingIsNotUsed(): void $this->assertNull($this->sut()->preferredContentCoding('*, gzip;q=0', 'gzip')); } + public function testPrefersTheHigherWeightedOfSeveralOfferedCodings(): void { $this->assertSame( @@ -118,6 +131,7 @@ public function testPrefersTheHigherWeightedOfSeveralOfferedCodings(): void ); } + /** * Where the client has no preference between two codings, the order they are offered in decides. */ diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index 136206d4..10ac67e3 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,6 +15,7 @@ use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Helpers\Http; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; @@ -29,21 +31,35 @@ use SimpleSAML\OpenID\RequestObject\RequestObjectParser; #[CoversClass(RequestParamsResolver::class)] +#[AllowMockObjectsWithoutExpectations] class RequestParamsResolverTest extends TestCase { protected MockObject $helpersMock; + protected MockObject $httpHelperMock; + protected MockObject $coreMock; + protected MockObject $requestMock; + protected MockObject $requestObjectMock; + protected MockObject $requestObjectFactoryMock; + protected MockObject $federationMock; + protected MockObject $psrHttpBridgeMock; + protected MockObject $requestObjectFacadeMock; + protected MockObject $requestObjectParserMock; + protected MockObject $moduleConfigMock; + protected MockObject $clientRepositoryMock; + protected MockObject $pushedAuthorizationRequestRepositoryMock; + protected MockObject $loggerServiceMock; protected array $queryParams = [ @@ -58,10 +74,11 @@ class RequestParamsResolverTest extends TestCase 'e' => 'f', ]; + protected function setUp(): void { $this->requestMock = $this->createMock(ServerRequestInterface::class); - $this->httpHelperMock = $this->createMock(Helpers\Http::class); + $this->httpHelperMock = $this->createMock(Http::class); $this->httpHelperMock->method('getAllRequestParams') ->willReturn(array_merge($this->queryParams, $this->bodyParams)); $this->helpersMock = $this->createMock(Helpers::class); @@ -89,6 +106,7 @@ protected function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); } + protected function mock( ?MockObject $helpersMock = null, ?MockObject $coreMock = null, @@ -113,6 +131,7 @@ protected function mock( ); } + protected function bagWithCore(): MockObject { $bag = $this->createMock(RequestObjectBag::class); @@ -121,9 +140,10 @@ protected function bagWithCore(): MockObject return $bag; } + protected function helpersWithParams(array $params): MockObject { - $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock = $this->createMock(Http::class); $httpHelperMock->method('getAllRequestParams')->willReturn($params); $httpHelperMock->method('getAllRequestParamsBasedOnAllowedMethods')->willReturn($params); $helpersMock = $this->createMock(Helpers::class); @@ -132,11 +152,13 @@ protected function helpersWithParams(array $params): MockObject return $helpersMock; } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestParamsResolver::class, $this->mock()); } + public function testCanGetAllFromRequest(): void { $this->assertSame( @@ -145,6 +167,7 @@ public function testCanGetAllFromRequest(): void ); } + public function testCanGetAllFromRequestBasedOnAllowedMethods(): void { $this->httpHelperMock->expects($this->once())->method('getAllRequestParamsBasedOnAllowedMethods') @@ -156,6 +179,7 @@ public function testCanGetAllFromRequestBasedOnAllowedMethods(): void ); } + public function testCanGetAllWithNoRequestObject(): void { $this->assertSame( @@ -164,6 +188,7 @@ public function testCanGetAllWithNoRequestObject(): void ); } + public function testCanGetAllWithRequestObject(): void { $queryParams = [...$this->queryParams, 'request' => 'token']; @@ -177,6 +202,7 @@ public function testCanGetAllWithRequestObject(): void ); } + public function testCanGetAllBasedOnAllowedMethods(): void { $this->httpHelperMock->expects($this->once())->method('getAllRequestParamsBasedOnAllowedMethods'); @@ -185,6 +211,7 @@ public function testCanGetAllBasedOnAllowedMethods(): void $this->mock()->getAllBasedOnAllowedMethods($this->requestMock, [HttpMethodsEnum::GET]); } + public function testCanGetBasedOnAllowedMethods(): void { $this->httpHelperMock->method('getAllRequestParamsBasedOnAllowedMethods') @@ -195,6 +222,7 @@ public function testCanGetBasedOnAllowedMethods(): void ); } + public function testCanGetAsStringBasedOnAllowedMethods(): void { $this->httpHelperMock->method('getAllRequestParamsBasedOnAllowedMethods') @@ -207,6 +235,7 @@ public function testCanGetAsStringBasedOnAllowedMethods(): void $this->assertNull($this->mock()->getAsStringBasedOnAllowedMethods('b', $this->requestMock)); } + public function testCanGetFromRequestBasedOnAllowedMethods(): void { $this->httpHelperMock->method('getAllRequestParamsBasedOnAllowedMethods') @@ -217,6 +246,7 @@ public function testCanGetFromRequestBasedOnAllowedMethods(): void ); } + public function testCanGetAllWithPushedAuthorizationRequestUri(): void { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; @@ -244,6 +274,7 @@ public function testCanGetAllWithPushedAuthorizationRequestUri(): void ); } + public function testGetAllResolvesNothingForInvalidPushedAuthorizationRequestUri(): void { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; @@ -258,6 +289,7 @@ public function testGetAllResolvesNothingForInvalidPushedAuthorizationRequestUri ); } + public function testGetAllSkipsRequestUriResolutionIfRequestParamIsAlsoPresent(): void { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; @@ -270,6 +302,7 @@ public function testGetAllSkipsRequestUriResolutionIfRequestParamIsAlsoPresent() $this->mock($helpersMock)->getAll($this->requestMock); } + public function testCanGetAllWithHttpsRequestUriForRegisteredClient(): void { $requestUri = 'https://client.example.org/request-object.jwt'; @@ -296,6 +329,7 @@ public function testCanGetAllWithHttpsRequestUriForRegisteredClient(): void $sut->getAll($this->requestMock); } + public function testGetAllDoesNotFetchHttpsRequestUriIfNotRegisteredForClient(): void { $requestUri = 'https://client.example.org/request-object.jwt'; @@ -312,6 +346,7 @@ public function testGetAllDoesNotFetchHttpsRequestUriIfNotRegisteredForClient(): $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); } + public function testGetAllDoesNotFetchHttpsRequestUriIfNotSupported(): void { $requestUri = 'https://client.example.org/request-object.jwt'; @@ -327,6 +362,7 @@ public function testGetAllDoesNotFetchHttpsRequestUriIfNotSupported(): void $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); } + public function testCanFetchHttpsRequestUriForFederationClient(): void { $requestUri = 'https://rp.example.org/request-object.jwt'; @@ -350,6 +386,7 @@ public function testCanFetchHttpsRequestUriForFederationClient(): void ); } + public function testCanFetchHttpsRequestUriForFederationClientWithAllowedPrefix(): void { $requestUri = 'https://rp.example.org/request-object.jwt'; @@ -372,6 +409,7 @@ public function testCanFetchHttpsRequestUriForFederationClientWithAllowedPrefix( ); } + public function testDoesNotFetchHttpsRequestUriForFederationClientWithDisallowedPrefix(): void { $requestUri = 'https://attacker.example.org/request-object.jwt'; @@ -392,6 +430,7 @@ public function testDoesNotFetchHttpsRequestUriForFederationClientWithDisallowed $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); } + public function testDoesNotFetchHttpsRequestUriForFederationClientWhenPrefixListIsEmpty(): void { $requestUri = 'https://rp.example.org/request-object.jwt'; @@ -408,6 +447,7 @@ public function testDoesNotFetchHttpsRequestUriForFederationClientWhenPrefixList $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); } + public function testDoesNotFetchHttpsRequestUriForUnknownClientWhenFederationDisabled(): void { $requestUri = 'https://rp.example.org/request-object.jwt'; @@ -422,6 +462,7 @@ public function testDoesNotFetchHttpsRequestUriForUnknownClientWhenFederationDis $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); } + public function testGetRequestObjectBagForRequestParam(): void { $queryParams = [...$this->queryParams, 'request' => 'token']; @@ -436,6 +477,7 @@ public function testGetRequestObjectBagForRequestParam(): void ); } + public function testGetRequestObjectBagReturnsNullForParUrn(): void { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; @@ -447,6 +489,7 @@ public function testGetRequestObjectBagReturnsNullForParUrn(): void ); } + public function testGetRequestObjectBagReturnsNullWhenNoSource(): void { $this->assertNull( diff --git a/tests/unit/src/Utils/ResponseTypeGrantTypeCorrespondenceTest.php b/tests/unit/src/Utils/ResponseTypeGrantTypeCorrespondenceTest.php index 3dbf2fa0..7f5b2990 100644 --- a/tests/unit/src/Utils/ResponseTypeGrantTypeCorrespondenceTest.php +++ b/tests/unit/src/Utils/ResponseTypeGrantTypeCorrespondenceTest.php @@ -4,12 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Utils\ResponseTypeGrantTypeCorrespondence; /** * @covers \SimpleSAML\Module\oidc\Utils\ResponseTypeGrantTypeCorrespondence */ +#[AllowMockObjectsWithoutExpectations] class ResponseTypeGrantTypeCorrespondenceTest extends TestCase { public function testRequiredGrantTypesForSupportedResponseTypes(): void @@ -23,12 +25,14 @@ public function testRequiredGrantTypesForSupportedResponseTypes(): void ); } + public function testRequiredGrantTypesIgnoresUnknownResponseTypes(): void { $this->assertSame([], ResponseTypeGrantTypeCorrespondence::requiredGrantTypes(['unknown', 'whatever'])); $this->assertSame([], ResponseTypeGrantTypeCorrespondence::requiredGrantTypes([])); } + public function testMergeAugmentsWithoutDuplicatesAndKeepsOrder(): void { // refresh_token is preserved; implicit is added because of id_token; authorization_code not duplicated. @@ -41,6 +45,7 @@ public function testMergeAugmentsWithoutDuplicatesAndKeepsOrder(): void ); } + public function testMergeDerivesGrantTypesWhenNoneGiven(): void { $this->assertSame( diff --git a/tests/unit/src/Utils/UiLocalesResolverTest.php b/tests/unit/src/Utils/UiLocalesResolverTest.php index dc660651..f23a4e20 100644 --- a/tests/unit/src/Utils/UiLocalesResolverTest.php +++ b/tests/unit/src/Utils/UiLocalesResolverTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -12,6 +13,7 @@ use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; #[CoversClass(UiLocalesResolver::class)] +#[AllowMockObjectsWithoutExpectations] class UiLocalesResolverTest extends TestCase { protected function sut(?array $availableLanguages = null): UiLocalesResolver @@ -23,11 +25,13 @@ protected function sut(?array $availableLanguages = null): UiLocalesResolver return new UiLocalesResolver($sspConfiguration, new SspBridge()); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(UiLocalesResolver::class, $this->sut()); } + public static function uiLocalesProvider(): array { return [ @@ -48,6 +52,7 @@ public static function uiLocalesProvider(): array ]; } + #[DataProvider('uiLocalesProvider')] public function testCanResolveUiLocales( ?string $uiLocales, @@ -57,6 +62,7 @@ public function testCanResolveUiLocales( $this->assertSame($expectedLanguage, $this->sut($availableLanguages)->resolve($uiLocales)); } + public function testFallsBackToDefaultAvailableLanguage(): void { // When language.available is not configured, the SSP fallback language (en) is used. @@ -64,16 +70,19 @@ public function testFallsBackToDefaultAvailableLanguage(): void $this->assertNull($this->sut()->resolve('de')); } + public function testCanGetSupportedUiLocalesAsBcp47Tags(): void { $this->assertSame(['en', 'hr', 'pt-BR'], $this->sut(['en', 'hr', 'pt_BR'])->getSupportedUiLocales()); } + public function testSupportedUiLocalesFallBackToDefaultAvailableLanguage(): void { $this->assertSame(['en'], $this->sut()->getSupportedUiLocales()); } + public function testSupportedUiLocalesExcludeCodesUnknownToTranslationSystem(): void { $this->assertSame(['en'], $this->sut(['en', 'xx'])->getSupportedUiLocales()); diff --git a/tests/unit/src/Utils/UserIdentifierResolverTest.php b/tests/unit/src/Utils/UserIdentifierResolverTest.php index 5d39d0bb..30b232b9 100644 --- a/tests/unit/src/Utils/UserIdentifierResolverTest.php +++ b/tests/unit/src/Utils/UserIdentifierResolverTest.php @@ -4,12 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver; /** * @covers \SimpleSAML\Module\oidc\Utils\UserIdentifierResolver */ +#[AllowMockObjectsWithoutExpectations] class UserIdentifierResolverTest extends TestCase { protected function sut(): UserIdentifierResolver @@ -17,6 +19,7 @@ protected function sut(): UserIdentifierResolver return new UserIdentifierResolver(); } + public function testResolvesSingleCandidate(): void { $this->assertSame( @@ -25,6 +28,7 @@ public function testResolvesSingleCandidate(): void ); } + public function testRespectsCandidatePriority(): void { $attributes = [ @@ -38,6 +42,7 @@ public function testRespectsCandidatePriority(): void ); } + public function testFallsBackToLaterCandidateWhenEarlierMissing(): void { $this->assertSame( @@ -46,6 +51,7 @@ public function testFallsBackToLaterCandidateWhenEarlierMissing(): void ); } + public function testSkipsCandidatesWithEmptyValue(): void { $attributes = [ @@ -59,6 +65,7 @@ public function testSkipsCandidatesWithEmptyValue(): void ); } + public function testReturnsNullWhenNoCandidateMatches(): void { $this->assertNull( @@ -66,6 +73,7 @@ public function testReturnsNullWhenNoCandidateMatches(): void ); } + public function testReturnsNullForNonArrayOrEmptyAttributeValues(): void { $this->assertNull( @@ -73,6 +81,7 @@ public function testReturnsNullForNonArrayOrEmptyAttributeValues(): void ); } + public function testUsesFirstValueOfMultiValuedAttribute(): void { $this->assertSame( diff --git a/tests/unit/src/ValueAbstracts/IntrospectionAuthorizationTest.php b/tests/unit/src/ValueAbstracts/IntrospectionAuthorizationTest.php index 3e2b6915..7867e073 100644 --- a/tests/unit/src/ValueAbstracts/IntrospectionAuthorizationTest.php +++ b/tests/unit/src/ValueAbstracts/IntrospectionAuthorizationTest.php @@ -4,11 +4,13 @@ namespace SimpleSAML\Test\Module\oidc\unit\ValueAbstracts; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\ValueAbstracts\IntrospectionAuthorization; #[CoversClass(IntrospectionAuthorization::class)] +#[AllowMockObjectsWithoutExpectations] class IntrospectionAuthorizationTest extends TestCase { public function testCanCreateInstance(): void @@ -20,6 +22,7 @@ public function testCanCreateInstance(): void ); } + public function testCallerTrustedWithAnyTokenIsNotLimitedToAClient(): void { $sut = IntrospectionAuthorization::forAnyToken(); @@ -30,6 +33,7 @@ public function testCallerTrustedWithAnyTokenIsNotLimitedToAClient(): void $this->assertTrue($sut->mayIntrospectTokenOf(null)); } + public function testClientMayOnlyIntrospectItsOwnTokens(): void { $sut = IntrospectionAuthorization::forTokensOfClient('client-id'); @@ -39,11 +43,13 @@ public function testClientMayOnlyIntrospectItsOwnTokens(): void $this->assertFalse($sut->mayIntrospectTokenOf('some-other-client-id')); } + public function testClientMayNotIntrospectTokenWithoutEstablishedOwner(): void { $this->assertFalse(IntrospectionAuthorization::forTokensOfClient('client-id')->mayIntrospectTokenOf(null)); } + /** * Identifiers are compared as they are: a client which registered under a differently cased identifier * is a different client, so it is not to be told about this one's tokens. diff --git a/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php index 73ea7dfd..c260a30a 100644 --- a/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php +++ b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\ValueAbstracts; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -12,15 +13,18 @@ use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; #[CoversClass(ResolvedClientAuthenticationMethod::class)] +#[AllowMockObjectsWithoutExpectations] class ResolvedClientAuthenticationMethodTest extends TestCase { protected MockObject $clientMock; + protected function setUp(): void { $this->clientMock = $this->createMock(ClientEntityInterface::class); } + protected function sut( ?ClientEntityInterface $client = null, ?ClientAuthenticationMethodsEnum $clientAuthenticationMethod = null, @@ -34,11 +38,13 @@ protected function sut( ); } + public function testCanCreateInstance(): void { $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $this->sut()); } + public function testCanGetProperties(): void { $sut = $this->sut( From 44d296a3151ed08148b4df7de9ba4adf25f8679a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 24 Aug 2026 10:09:37 +0200 Subject: [PATCH 3/3] Ensure phpcov --- .github/workflows/test.yaml | 4 ++-- composer.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 82d7adb8..f3e65f5d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} extensions: mbstring, xml - tools: composer:v2, phpcov + tools: composer:v2 coverage: pcov - name: Setup problem matchers for PHP @@ -72,7 +72,7 @@ jobs: - name: Merge coverage data if: ${{ matrix.php-versions == '8.5' }} run: | - phpcov merge --clover build/logs/clover.xml build/logs/partial_clover/ + ./vendor/bin/phpcov merge --clover build/logs/clover.xml build/logs/partial_clover/ - name: Save coverage data if: ${{ matrix.php-versions == '8.5' }} diff --git a/composer.json b/composer.json index b0dcad85..e271d9fd 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", + "phpunit/phpcov": "^11.0", "rector/rector": "^2.0", "simplesamlphp/simplesamlphp-test-framework": "^1.11.6", "testcontainers/testcontainers": "^0.2",