From 196f985a3c1d427bfbe965eb806acb3b02c801b0 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 6 May 2026 16:40:07 +0200 Subject: [PATCH 1/6] added event based provisioning --- lib/AppInfo/Application.php | 8 + lib/Controller/LoginController.php | 16 +- lib/Event/UserAccountChangeEvent.php | 60 ++ lib/Event/UserAccountChangeResult.php | 46 ++ lib/Service/ProvisioningDeniedException.php | 31 + lib/Service/ProvisioningEventService.php | 186 ++++++ .../unit/MagentaCloud/OpenidTokenTestCase.php | 122 ++++ .../ProvisioningEventServiceTest.php | 613 ++++++++++++++++++ tests/unit/MagentaCloud/RegistrationsTest.php | 33 + 9 files changed, 1114 insertions(+), 1 deletion(-) create mode 100644 lib/Event/UserAccountChangeEvent.php create mode 100644 lib/Event/UserAccountChangeResult.php create mode 100644 lib/Service/ProvisioningDeniedException.php create mode 100644 lib/Service/ProvisioningEventService.php create mode 100644 tests/unit/MagentaCloud/OpenidTokenTestCase.php create mode 100644 tests/unit/MagentaCloud/ProvisioningEventServiceTest.php create mode 100644 tests/unit/MagentaCloud/RegistrationsTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 192220cab..6958a6c4d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -22,6 +22,8 @@ use OCA\UserOIDC\Listener\TimezoneHandlingListener; use OCA\UserOIDC\Listener\TokenInvalidatedListener; use OCA\UserOIDC\Service\ID4MeService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Service\RequestClassificationService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; @@ -36,6 +38,7 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Container\ContainerInterface; use Throwable; class Application extends App implements IBootstrap { @@ -50,6 +53,11 @@ public function __construct(array $urlParams = []) { } public function register(IRegistrationContext $context): void { + // override registration of provisioning service to use event-based solution + $this->getContainer()->registerService(ProvisioningService::class, function (ContainerInterface $c): ProvisioningService { + return $c->get(ProvisioningEventService::class); + }); + /** @var IUserManager $userManager */ $userManager = $this->getContainer()->get(IUserManager::class); diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index abba810cc..44945864e 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -24,6 +24,7 @@ use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\OIDCService; use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; @@ -654,8 +655,21 @@ public function code(string $state = '', string $code = '', string $scope = '', $message = $this->l10n->t('User conflict'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict'], false); } + // use potential user from other backend, create it in our backend if it does not exist - $provisioningResult = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); + try { + $provisioningResult = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); + } catch (ProvisioningDeniedException $denied) { + $redirectUrl = $denied->getRedirectUrl(); + + if ($redirectUrl === null) { + $message = $this->l10n->t('Failed to provision user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); + } + + return new RedirectResponse($redirectUrl); + } + $user = $provisioningResult['user']; if ($existingUser === null && $user !== null) { // we know we just created a user diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php new file mode 100644 index 000000000..94a2064d0 --- /dev/null +++ b/lib/Event/UserAccountChangeEvent.php @@ -0,0 +1,60 @@ +result = new UserAccountChangeResult($accessAllowed, 'default'); + } + + public function getUid(): string { + return $this->uid; + } + + public function getDisplayName(): ?string { + return $this->displayName; + } + + public function getMainEmail(): ?string { + return $this->mainEmail; + } + + public function getQuota(): ?string { + return $this->quota; + } + + public function getClaims(): object { + return $this->claims; + } + + public function getResult(): UserAccountChangeResult { + return $this->result; + } + + public function setResult(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null): void { + $this->result = new UserAccountChangeResult($accessAllowed, $reason, $redirectUrl); + } +} diff --git a/lib/Event/UserAccountChangeResult.php b/lib/Event/UserAccountChangeResult.php new file mode 100644 index 000000000..aace323ab --- /dev/null +++ b/lib/Event/UserAccountChangeResult.php @@ -0,0 +1,46 @@ +accessAllowed; + } + + public function setAccessAllowed(bool $accessAllowed): void { + $this->accessAllowed = $accessAllowed; + } + + public function getReason(): string { + return $this->reason; + } + + public function setReason(string $reason): void { + $this->reason = $reason; + } + + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + public function setRedirectUrl(?string $redirectUrl): void { + $this->redirectUrl = $redirectUrl; + } +} diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php new file mode 100644 index 000000000..35cc4ac60 --- /dev/null +++ b/lib/Service/ProvisioningDeniedException.php @@ -0,0 +1,31 @@ +redirectUrl; + } + + public function __toString(): string { + $redirect = $this->redirectUrl ?? ''; + + return self::class . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } +} diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php new file mode 100644 index 000000000..4fe22ed3e --- /dev/null +++ b/lib/Service/ProvisioningEventService.php @@ -0,0 +1,186 @@ +providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + + if (!is_string($mappedUserId) || trim($mappedUserId) === '') { + throw new AttributeValueException('Mapped uid is empty or invalid'); + } + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + if (!is_string($value) || trim($value) === '') { + throw new AttributeValueException('Mapped uid is empty or invalid'); + } + + return $value; + } + + protected function mapDispatchDisplayname(int $providerId, object $payload): ?string { + $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (is_string($mappedDisplayName) && $mappedDisplayName !== '') { + $mappedDisplayName = mb_substr($mappedDisplayName, 0, 255); + } elseif ($mappedDisplayName !== null) { + $mappedDisplayName = (string)$mappedDisplayName; + } + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $mappedDisplayName); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + + return $value === null ? null : (string)$value; + } + + protected function mapDispatchEmail(int $providerId, object $payload): ?string { + $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); + $mappedEmail = $payload->{$emailAttribute} ?? null; + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_EMAIL, $payload, $mappedEmail); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + + return $value === null ? null : (string)$value; + } + + protected function mapDispatchQuota(int $providerId, object $payload): ?string { + $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); + $mappedQuota = $payload->{$quotaAttribute} ?? null; + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_QUOTA, $payload, $mappedQuota); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + + return $value === null ? null : (string)$value; + } + + protected function dispatchUserAccountUpdate( + string $uid, + ?string $displayName, + ?string $email, + ?string $quota, + object $payload, + ): mixed { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + + return $event->getResult(); + } + + /** + * Trigger provisioning via event system. + * + * @return array{user: ?IUser, userData: array} + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser( + string $tokenUserId, + int $providerId, + object $idTokenPayload, + ?IUser $existingLocalUser = null, + ): array { + try { + $uid = $this->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $displayName = $this->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $e) { + $this->logger->info($tokenUserId . ': user rejected by OpenID web authorization, reason: ' . $e->getMessage()); + throw new ProvisioningDeniedException($e->getMessage()); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayName, $email, $quota, $idTokenPayload); + + if ($userReaction->isAccessAllowed()) { + $this->logger->info($uid . ': account accepted, reason: ' . $userReaction->getReason()); + + return [ + 'user' => $existingLocalUser ?? $this->userManager->get($uid), + 'userData' => get_object_vars($idTokenPayload), + ]; + } + + $this->logger->info($uid . ': account rejected, reason: ' . $userReaction->getReason()); + + throw new ProvisioningDeniedException( + $userReaction->getReason(), + $userReaction->getRedirectUrl(), + ); + } +} diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php new file mode 100644 index 000000000..ecc648c21 --- /dev/null +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -0,0 +1,122 @@ + */ + private array $realOidClaims = []; + + public function getProviderId(): int { + return 4711; + } + + /** @return array */ + public function getRealOidClaims(): array { + return $this->realOidClaims; + } + + public function getOidClientId(): string { + return 'USER_NC_OPENID_TEST'; + } + + public function getOidNonce(): string { + return 'CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K'; + } + + public function getOidClientSecret(): string { + return 'JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'; + } + + public function getOidServerKey(): string { + return Base64UrlSafe::encodeUnpadded('JQ17DAF8-C99A-4E27-FBW4-GV23B043C993'); + } + + /** @return array */ + public function getOidPrivateServerKey(): array { + return [ + 'p' => '9US9kD6Q8nicR1se1U_iRI9x1iK0__HF7E9yhqrza9DHldC2h7PLuR7y9bITAUtcBmVvqEQlVUXRZPMrNUpLFI9hTdZXAACRqYBYGHg7Mvyzq-2JXhEE5CFDy9wSCPunc8bRq4TsY0ocSXugXKGjx-t1uO3fkF1UgNgNMjdzSPM', + 'kty' => 'RSA', + 'q' => '85auJF6W3c91EebGpjMX-g_U0fLBMgO2oxBsldus9x2diRd3wVvUnrTg5fQctODdr4if8dBCPDdLxBUKul4MXULC_nCkGkDjORdESb7j8amGnOvxnaVcQT6C5yHivAawa4R8NchR7n23VrQWO8fHhQBYUHTTy01G3A8D6dznCC8', + 'd' => 'tP-lT4FJBKrhhBUk7J1fR0638jVjn46yIfSaB5l_JlqNItmRJtbz3QWopy4oDfvrY_ccZIYG9tLvJH-3LHtuEddwxFsL-9MSUx5qxWB4sKpKA6EpxWNR5EFnFKxR_B2P2yFYiRDdbBh8h9pNaOuNjZU5iitAGvSOfW4X5hyJyu9t9zsEX9O6stEtP3yK5sx-bt7osGDMIguFBMcPVHbYw_Pl7-aNPuQ4ioxVXa3JlO6tTcDrcyMy7d3CWuGACj3juEnO-1n8E_OSR9sMp1k_L7i-qQ3OnLCOx07HeTWklCvNxz7U9qLcQXGcfpdWmhWZt6MO3SIXV4f6Md0U836v0Q', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => '0123456789', + 'qi' => 'T3-NLCpVoITdS6PB9XYCsXsfhQSiE_4eTQnZf_Zya5hSd0xZDrrwNiXL8Dzy3YLjsZAFC0U6wAeC2wTBJ8c-6VxdP34J0sGj2I_TNhFFArksLy9ZaRbskCxKAPLipEFi8b1H2-aaRFRLs6BQJbfesQ5mcX2kB5AItAX3R6tcc0A', + 'dp' => 'ExUtFor3phXiOt8JEBmuBh2PAtUidgNuncs0ouusEshkrvBVM0u23wlcZ-dZ-TDO0SSVQmdC7FaJSyxsQTItk0TwkijKDhL9Qk3dDNJV8MqehBLwLCRw1_sKllLiCFbkGWrvp0OpTLRYbRM0T-C3qHdWanP_f_DzAS9OH4kW7Cc', + 'alg' => 'RS256', + 'dq' => 'xr3XAWeHkhw0uVFgHLQtSOJn0pBM3qC2_95jqfVc7xZjtTnHhKSHGqIbqKL-VPnvBcvkK-iuUfEPyUEdyqb3UZQqAm0nByCQA8Ge_shXtJGLejbroKMNXVJCfZBhLOYMRP0IVt1FM9-wmXY_ebDrcfGxHJvlPcekG-HIYKPSgBM', + 'n' => '6WCdDo8KuksEFaFlzvmsaoYhfOoMt5XgnX98dx-F1OUz53SG0lQlFt-xkwra5B4GZ-13lki0qCa2CjA1aLa9kEvDdYhz_0Uc5qOy5haDj8Jn547s6gFyaLzJ0RN5i5eKeDMHcjeEC0_NjiB2UNUFJJ61b2nXIlUvp_vBfKCv4A-8C3mLSbCKJQhX84QRDgt_Abz0MXj_ga72Ka2cwVLo4OFQAK5m57Qfu9ZvseMcgoinyhIQ18b98SkWinn3DM0W1KXLkWLk0S3XEMxLV1M7-9RLo4fgEGOpX1xmmM6KbsC5SxXvRUO7tjU-o35fcewDwXYHnRbxqhRkEFfWb7b8nQ', + ]; + } + + public function getOidPublicServerKey(): array { + return [ + '0123456789' => new \OCA\UserOIDC\Vendor\Firebase\JWT\Key( + $this->getOidClientSecret(), + 'HS256', + ), + ]; + } + + public function getOidTestCode(): string { + return '66844608'; + } + + public function getOidTestState(): string { + return '4VSL5T274MJEMLZI1810HUFDA07CEPXZ'; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $now = time(); + + $this->realOidClaims = [ + 'sub' => 'jgyros', + 'urn:custom.com:displayname' => 'Jonny G', + 'urn:custom.com:email' => 'jonny.gyros@x.y', + 'urn:custom.com:mainEmail' => 'jonny.gyuris@x.y.de', + 'iss' => 'https://accounts.login00.custom.de', + 'urn:custom.com:feat1' => '0', + 'urn:custom.com:uid' => '081500000001234', + 'urn:custom.com:feat2' => '1', + 'urn:custom.com:ext2' => '0', + 'urn:custom.com:feat3' => '1', + 'acr' => 'urn:custom:names:idm:THO:1.0:ac:classes:passid:00', + 'urn:custom.com:feat4' => '0', + 'urn:custom.com:ext4' => '0', + 'auth_time' => $now, + 'exp' => $now + 7200, + 'iat' => $now, + 'urn:custom.com:session_token' => 'ad0fff71-e013-11ec-9e17-39677d2c891c', + 'nonce' => $this->getOidNonce(), + 'aud' => [$this->getOidClientId()], + ]; + } + + protected function createSignToken(array $claims): string { + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWT::encode( + $claims, + $this->getOidClientSecret(), + 'HS256', + '0123456789', + ); + } +} diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php new file mode 100644 index 000000000..2a413e1c8 --- /dev/null +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -0,0 +1,613 @@ +createMock(IConfig::class); + + $config->expects($this->any()) + ->method('getSystemValue') + ->with($this->logicalOr($this->equalTo('user_oidc'), $this->equalTo('secret'))) + ->willReturnCallback(static function (string $key, mixed $default = null): mixed { + if ($key === 'user_oidc') { + return [ + 'auto_provisioning' => true, + 'auto_provision' => true, + 'soft_auto_provision' => true, + 'login_validation_audience_check' => false, + 'login_validation_azp_check' => false, + ]; + } + + if ($key === 'secret') { + return 'Streng_geheim'; + } + + return $default; + }); + + $config->expects($this->any()) + ->method('getSystemValueString') + ->willReturnCallback(static function (string $key, string $default = ''): string { + if ($key === 'version') { + return '32.0.0'; + } + + return $default; + }); + + $config->expects($this->any()) + ->method('setUserValue'); + + return $config; + } + + protected function getOidSessionSetup(): MockObject { + $session = $this->createMock(ISession::class); + + $session->expects($this->any()) + ->method('get') + ->willReturnCallback(function (string $key): mixed { + $state = $this->getOidTestState(); + $suffix = '-' . $state; + + $values = [ + 'oidc.state' . $suffix => $state, + 'oidc.login.providerid' . $suffix => $this->getProviderId(), + 'oidc.providerid' . $suffix => $this->getProviderId(), + 'oidc.nonce' . $suffix => $this->getOidNonce(), + 'oidc.redirect' . $suffix => 'https://welcome.to.magenta', + 'oidc.timestamp' . $suffix => time(), + 'oidc.code_verifier' . $suffix => 'test-code-verifier', + ]; + + return $values[$key] ?? null; + }); + + $session->expects($this->any()) + ->method('exists') + ->willReturnCallback(function (string $key): bool { + $state = $this->getOidTestState(); + $suffix = '-' . $state; + + return in_array($key, [ + 'oidc.state' . $suffix, + 'oidc.login.providerid' . $suffix, + 'oidc.providerid' . $suffix, + 'oidc.nonce' . $suffix, + 'oidc.redirect' . $suffix, + 'oidc.timestamp' . $suffix, + 'oidc.code_verifier' . $suffix, + ], true); + }); + + $session->expects($this->any()) + ->method('set'); + + $session->expects($this->any()) + ->method('remove'); + + $session->expects($this->any()) + ->method('getId') + ->willReturn('test-session-id'); + + return $session; + } + + protected function getProviderSetup(): Provider { + $provider = new Provider(); + $provider->setId($this->getProviderId()); + $provider->setIdentifier('telekom'); + $provider->setClientId($this->getOidClientId()); + $provider->setClientSecret($this->crypto->encrypt($this->getOidClientSecret())); + $provider->setScope('openid'); + $provider->setDiscoveryEndpoint('https://accounts.login00.custom.de/.well-known/openid-configuration'); + + $this->providerMapper->expects($this->any()) + ->method('getProvider') + ->with($this->equalTo($this->getProviderId())) + ->willReturn($provider); + + return $provider; + } + + protected function getProviderServiceSetup(): MockObject { + $providerService = $this->getMockBuilder(ProviderService::class) + ->setConstructorArgs([$this->appConfig, $this->providerMapper]) + ->getMock(); + + $providerService->expects($this->any()) + ->method('getSetting') + ->willReturnCallback(static function (int $providerId, string $key, string $default = ''): string { + $values = [ + ProviderService::SETTING_MAPPING_UID => 'sub', + ProviderService::SETTING_MAPPING_DISPLAYNAME => 'urn:custom.com:displayname', + ProviderService::SETTING_MAPPING_QUOTA => 'urn:custom.com:f556', + ProviderService::SETTING_MAPPING_EMAIL => 'urn:custom.com:mainEmail', + ProviderService::SETTING_MAPPING_GROUPS => '', + ProviderService::SETTING_RESTRICT_LOGIN_TO_GROUPS => '0', + ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING => '0', + ProviderService::SETTING_EXTRA_CLAIMS => '', + ]; + + return $values[$key] ?? $default; + }); + + return $providerService; + } + + protected function getUserManagerSetup(): MockObject { + $userManager = $this->getMockForAbstractClass(IUserManager::class); + + $this->user = $this->getMockForAbstractClass(IUser::class); + $this->user->expects($this->any()) + ->method('canChangeAvatar') + ->willReturn(false); + $this->user->expects($this->any()) + ->method('getUID') + ->willReturn('jgyros'); + + return $userManager; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->config = $this->getConfigSetup(); + $this->appConfig = $this->createMock(IAppConfig::class); + + $this->appConfig->expects($this->any()) + ->method('getValueString') + ->willReturn('0'); + + $this->appConfig->expects($this->any()) + ->method('getValueBool') + ->willReturn(false); + + $this->crypto = $this->getMockBuilder(Crypto::class) + ->setConstructorArgs([$this->config]) + ->getMock(); + + $this->request = $this->getMockForAbstractClass(IRequest::class); + $this->request->expects($this->any()) + ->method('getServerProtocol') + ->willReturn('https'); + + $this->providerMapper = $this->getMockBuilder(ProviderMapper::class) + ->setConstructorArgs([$this->getMockForAbstractClass(IDBConnection::class)]) + ->getMock(); + + $this->provider = $this->getProviderSetup(); + $this->providerService = $this->getProviderServiceSetup(); + + $this->localIdService = $this->getMockBuilder(LocalIdService::class) + ->setConstructorArgs([ + $this->providerService, + $this->providerMapper, + ]) + ->getMock(); + + $this->userMapper = $this->getMockBuilder(UserMapper::class) + ->setConstructorArgs([ + $this->getMockForAbstractClass(IDBConnection::class), + $this->localIdService, + $this->config, + ]) + ->getMock(); + + $this->token = [ + 'id_token' => $this->createSignToken($this->getRealOidClaims()), + ]; + + $this->httpClientHelper = $this->getMockBuilder(HttpClientHelper::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->httpClientHelper->expects($this->any()) + ->method('post') + ->willReturn(json_encode($this->token, JSON_THROW_ON_ERROR)); + + $this->discoveryService = $this->getMockBuilder(DiscoveryService::class) + ->setConstructorArgs([ + $this->app->getContainer()->get(LoggerInterface::class), + $this->httpClientHelper, + $this->providerService, + $this->app->getContainer()->get(IConfig::class), + $this->app->getContainer()->get(ITimeFactory::class), + $this->app->getContainer()->get(ICacheFactory::class), + ]) + ->getMock(); + + $this->discoveryService->expects($this->any()) + ->method('obtainDiscovery') + ->willReturn([ + 'token_endpoint' => 'https://whatever.to.discover/token', + 'authorization_endpoint' => 'https://whatever.to.discover/auth', + 'issuer' => 'https://accounts.login00.custom.de', + ]); + + $this->discoveryService->expects($this->any()) + ->method('obtainJWK') + ->willReturn($this->getOidPublicServerKey()); + + $this->session = $this->getOidSessionSetup(); + + $this->sessionMapper = $this->getMockBuilder(SessionMapper::class) + ->setConstructorArgs([ + $this->createMock(IDBConnection::class), + $this->app->getContainer()->get(ICrypto::class), + ]) + ->getMock(); + + $this->sessionMapper->expects($this->any()) + ->method('createOrUpdateSession'); + + $this->usersession = $this->getMockBuilder(IUserSession::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'setUser', + 'login', + 'logout', + 'getUser', + 'isLoggedIn', + 'getImpersonatingUserID', + 'setImpersonatingUserID', + 'setVolatileActiveUser', + ]) + ->addMethods([ + 'completeLogin', + 'createSessionToken', + 'createRememberMeToken', + ]) + ->getMock(); + + $this->usersession->expects($this->any()) + ->method('isLoggedIn') + ->willReturn(false); + + $this->usermanager = $this->getUserManagerSetup(); + $this->groupmanager = $this->getMockForAbstractClass(IGroupManager::class); + $this->dispatcher = $this->app->getContainer()->get(IEventDispatcher::class); + $this->l10nFactory = $this->app->getContainer()->get(IFactory::class); + + $this->provisioningService = new ProvisioningEventService( + $this->app->getContainer()->get(LocalIdService::class), + $this->providerService, + $this->userMapper, + $this->usermanager, + $this->groupmanager, + $this->dispatcher, + $this->app->getContainer()->get(LoggerInterface::class), + $this->app->getContainer()->get(IAccountManager::class), + $this->app->getContainer()->get(IClientService::class), + $this->app->getContainer()->get(IAvatarManager::class), + $this->config, + $this->session, + $this->l10nFactory, + $this->providerMapper, + $this->crypto, + ); + + $this->registrationContext = $this->app->getContainer() + ->get(Coordinator::class) + ->getRegistrationContext(); + + $this->settingsService = $this->getMockBuilder(SettingsService::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->settingsService->expects($this->any()) + ->method('getAllowMultipleUserBackEnds') + ->willReturn(true); + + $this->tokenService = $this->app->getContainer()->get(TokenService::class); + $this->oidcService = $this->app->getContainer()->get(OIDCService::class); + + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->expects($this->any()) + ->method('getTime') + ->willReturn(time()); + + $this->loginController = new LoginController( + $this->request, + $this->providerMapper, + $this->providerService, + $this->discoveryService, + $this->app->getContainer()->get(LdapService::class), + $this->settingsService, + $this->app->getContainer()->get(ISecureRandom::class), + $this->session, + $this->httpClientHelper, + $this->app->getContainer()->get(IURLGenerator::class), + $this->usersession, + $this->usermanager, + $this->timeFactory, + $this->dispatcher, + $this->config, + $this->appConfig, + $this->app->getContainer()->get(IProvider::class), + $this->sessionMapper, + $this->provisioningService, + $this->app->getContainer()->get(IL10N::class), + $this->app->getContainer()->get(LoggerInterface::class), + $this->crypto, + $this->tokenService, + $this->oidcService, + ); + + $this->attributeListener = null; + $this->accountListener = null; + } + + public function tearDown(): void { + if ($this->accountListener !== null) { + $this->dispatcher->removeListener(UserAccountChangeEvent::class, $this->accountListener); + } + + if ($this->attributeListener !== null) { + $this->dispatcher->removeListener(AttributeMappedEvent::class, $this->attributeListener); + } + + parent::tearDown(); + } + + protected function mockAssertLoginSuccess(): void { + $this->usermanager->expects($this->once()) + ->method('get') + ->willReturn($this->user); + + $this->usersession->expects($this->once()) + ->method('setUser') + ->with($this->equalTo($this->user)); + + $this->usersession->expects($this->any()) + ->method('completeLogin') + ->with($this->anything(), $this->anything()); + + $this->usersession->expects($this->any()) + ->method('createSessionToken'); + + $this->usersession->expects($this->any()) + ->method('createRememberMeToken'); + } + + protected function assertLoginRedirect(mixed $result): void { + if ($result instanceof TemplateResponse) { + $this->fail( + 'Expected RedirectResponse, got TemplateResponse. Template: ' + . $result->getTemplateName() + . ' Params: ' + . json_encode($result->getParams(), JSON_THROW_ON_ERROR) + ); + } + + $this->assertInstanceOf(RedirectResponse::class, $result); + } + + protected function assertLogin403(mixed $result): void { + $this->assertInstanceOf( + TemplateResponse::class, + $result, + 'LoginController->code() did not end with 403 Forbidden' + ); + } + + public function testNoMap_AccessOk(): void { + $this->mockAssertLoginSuccess(); + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(true, 'ok', null); + }; + + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertNotEmpty($result->getRedirectURL()); + } + + public function testUidNoMapEvent_AccessOk(): void { + $this->mockAssertLoginSuccess(); + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(true, 'ok', 'https://welcome.to.darkside'); + }; + + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('http://localhost', $result->getRedirectURL()); + } + + public function testDisplaynameMapEvent_NOk_NoRedirect(): void { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent + && $event->getAttribute() === ProviderService::SETTING_MAPPING_DISPLAYNAME + ) { + $event->setValue('Lisa, Mona'); + } + }; + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Lisa, Mona', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(false, 'not an original', null); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLogin403($result); + } + + public function testMainEmailMap_Nok_Redirect(): void { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent + && $event->getAttribute() === ProviderService::SETTING_MAPPING_EMAIL + ) { + $event->setValue('mona.lisa@louvre.fr'); + } + }; + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayName()); + $this->assertEquals('mona.lisa@louvre.fr', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(false, 'under restoration', 'https://welcome.to.louvre'); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('https://welcome.to.louvre', $result->getRedirectURL()); + } + + public function testDisplaynameUidQuotaMapped_AccessOK(): void { + $this->mockAssertLoginSuccess(); + + $this->attributeListener = function (Event $event): void { + if (!$event instanceof AttributeMappedEvent) { + return; + } + + if ($event->getAttribute() === ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue('Lisa, Mona'); + } + + if ($event->getAttribute() === ProviderService::SETTING_MAPPING_QUOTA) { + $event->setValue('5 TB'); + } + }; + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Lisa, Mona', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertEquals('5 TB', $event->getQuota()); + + $event->setResult(true, 'ok', 'https://welcome.to.louvre'); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('http://localhost', $result->getRedirectURL()); + } +} diff --git a/tests/unit/MagentaCloud/RegistrationsTest.php b/tests/unit/MagentaCloud/RegistrationsTest.php new file mode 100644 index 000000000..b5a2eac6e --- /dev/null +++ b/tests/unit/MagentaCloud/RegistrationsTest.php @@ -0,0 +1,33 @@ +app = new Application(); + + $coordinator = \OC::$server->get(Coordinator::class); + $this->app->register($coordinator->getRegistrationContext()->for(Application::APP_ID)); + } + + public function testProvisioningServiceRegistration(): void { + $provisioningService = $this->app->getContainer()->get(ProvisioningService::class); + + $this->assertInstanceOf(ProvisioningEventService::class, $provisioningService); + } +} From d8cf8d15259f20925ed3118574639c3b06febc52 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 6 May 2026 16:43:27 +0200 Subject: [PATCH 2/6] fixed coding style --- .../unit/MagentaCloud/OpenidTokenTestCase.php | 45 +++++++--------- .../ProvisioningEventServiceTest.php | 52 +++++++++---------- 2 files changed, 45 insertions(+), 52 deletions(-) diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php index ecc648c21..7d1a38949 100644 --- a/tests/unit/MagentaCloud/OpenidTokenTestCase.php +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -5,14 +5,7 @@ namespace OCA\UserOIDC\BaseTest; use OCA\UserOIDC\AppInfo\Application; -use OCA\UserOIDC\Vendor\Firebase\JWT\JWK as FirebaseJWK; -use OCA\UserOIDC\Vendor\Firebase\JWT\Key; -use OCA\UserOIDC\Vendor\Jose\Component\Core\AlgorithmManager; -use OCA\UserOIDC\Vendor\Jose\Component\Core\JWK; use OCA\UserOIDC\Vendor\Jose\Component\Core\Util\Base64UrlSafe; -use OCA\UserOIDC\Vendor\Jose\Component\Signature\Algorithm\RS256; -use OCA\UserOIDC\Vendor\Jose\Component\Signature\JWSBuilder; -use OCA\UserOIDC\Vendor\Jose\Component\Signature\Serializer\CompactSerializer; use OCP\AppFramework\App; use PHPUnit\Framework\TestCase; @@ -39,9 +32,9 @@ public function getOidNonce(): string { return 'CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K'; } - public function getOidClientSecret(): string { - return 'JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'; - } + public function getOidClientSecret(): string { + return 'JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'; + } public function getOidServerKey(): string { return Base64UrlSafe::encodeUnpadded('JQ17DAF8-C99A-4E27-FBW4-GV23B043C993'); @@ -65,14 +58,14 @@ public function getOidPrivateServerKey(): array { ]; } - public function getOidPublicServerKey(): array { - return [ - '0123456789' => new \OCA\UserOIDC\Vendor\Firebase\JWT\Key( - $this->getOidClientSecret(), - 'HS256', - ), - ]; - } + public function getOidPublicServerKey(): array { + return [ + '0123456789' => new \OCA\UserOIDC\Vendor\Firebase\JWT\Key( + $this->getOidClientSecret(), + 'HS256', + ), + ]; + } public function getOidTestCode(): string { return '66844608'; @@ -111,12 +104,12 @@ public function setUp(): void { ]; } - protected function createSignToken(array $claims): string { - return \OCA\UserOIDC\Vendor\Firebase\JWT\JWT::encode( - $claims, - $this->getOidClientSecret(), - 'HS256', - '0123456789', - ); - } + protected function createSignToken(array $claims): string { + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWT::encode( + $claims, + $this->getOidClientSecret(), + 'HS256', + '0123456789', + ); + } } diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php index 2a413e1c8..728fab851 100644 --- a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -180,21 +180,21 @@ protected function getOidSessionSetup(): MockObject { } protected function getProviderSetup(): Provider { - $provider = new Provider(); - $provider->setId($this->getProviderId()); - $provider->setIdentifier('telekom'); - $provider->setClientId($this->getOidClientId()); - $provider->setClientSecret($this->crypto->encrypt($this->getOidClientSecret())); - $provider->setScope('openid'); - $provider->setDiscoveryEndpoint('https://accounts.login00.custom.de/.well-known/openid-configuration'); - - $this->providerMapper->expects($this->any()) - ->method('getProvider') - ->with($this->equalTo($this->getProviderId())) - ->willReturn($provider); - - return $provider; - } + $provider = new Provider(); + $provider->setId($this->getProviderId()); + $provider->setIdentifier('telekom'); + $provider->setClientId($this->getOidClientId()); + $provider->setClientSecret($this->crypto->encrypt($this->getOidClientSecret())); + $provider->setScope('openid'); + $provider->setDiscoveryEndpoint('https://accounts.login00.custom.de/.well-known/openid-configuration'); + + $this->providerMapper->expects($this->any()) + ->method('getProvider') + ->with($this->equalTo($this->getProviderId())) + ->willReturn($provider); + + return $provider; + } protected function getProviderServiceSetup(): MockObject { $providerService = $this->getMockBuilder(ProviderService::class) @@ -579,18 +579,18 @@ public function testDisplaynameUidQuotaMapped_AccessOK(): void { $this->mockAssertLoginSuccess(); $this->attributeListener = function (Event $event): void { - if (!$event instanceof AttributeMappedEvent) { - return; - } + if (!$event instanceof AttributeMappedEvent) { + return; + } - if ($event->getAttribute() === ProviderService::SETTING_MAPPING_DISPLAYNAME) { - $event->setValue('Lisa, Mona'); - } + if ($event->getAttribute() === ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue('Lisa, Mona'); + } - if ($event->getAttribute() === ProviderService::SETTING_MAPPING_QUOTA) { - $event->setValue('5 TB'); - } - }; + if ($event->getAttribute() === ProviderService::SETTING_MAPPING_QUOTA) { + $event->setValue('5 TB'); + } + }; $this->accountListener = function (Event $event): void { $this->assertInstanceOf(UserAccountChangeEvent::class, $event); @@ -608,6 +608,6 @@ public function testDisplaynameUidQuotaMapped_AccessOK(): void { $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); $this->assertLoginRedirect($result); - $this->assertEquals('http://localhost', $result->getRedirectURL()); + $this->assertEquals('http://localhost', $result->getRedirectURL()); } } From 7c481568c55bafa9bf84e360da90bc58f1a89657 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 6 May 2026 17:04:33 +0200 Subject: [PATCH 3/6] fixed provisioning --- lib/Event/UserAccountChangeEvent.php | 3 +-- lib/Event/UserAccountChangeResult.php | 8 ++++++-- lib/Service/ProvisioningEventService.php | 11 +++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php index 94a2064d0..718e3296a 100644 --- a/lib/Event/UserAccountChangeEvent.php +++ b/lib/Event/UserAccountChangeEvent.php @@ -23,11 +23,10 @@ public function __construct( private ?string $mainEmail, private ?string $quota, private object $claims, - bool $accessAllowed = false, ) { parent::__construct(); - $this->result = new UserAccountChangeResult($accessAllowed, 'default'); + $this->result = new UserAccountChangeResult(); } public function getUid(): string { diff --git a/lib/Event/UserAccountChangeResult.php b/lib/Event/UserAccountChangeResult.php index aace323ab..1a8bb43ab 100644 --- a/lib/Event/UserAccountChangeResult.php +++ b/lib/Event/UserAccountChangeResult.php @@ -14,14 +14,18 @@ */ class UserAccountChangeResult { public function __construct( - private bool $accessAllowed, + private ?bool $accessAllowed = null, private string $reason = '', private ?string $redirectUrl = null, ) { } + public function hasDecision(): bool { + return $this->accessAllowed !== null; + } + public function isAccessAllowed(): bool { - return $this->accessAllowed; + return $this->accessAllowed === true; } public function setAccessAllowed(bool $accessAllowed): void { diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index 4fe22ed3e..239a6946d 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -13,6 +13,7 @@ use OCA\UserOIDC\Db\UserMapper; use OCA\UserOIDC\Event\AttributeMappedEvent; use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCA\UserOIDC\Event\UserAccountChangeResult; use OCP\Accounts\IAccountManager; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; @@ -133,11 +134,17 @@ protected function dispatchUserAccountUpdate( ?string $email, ?string $quota, object $payload, - ): mixed { + ): UserAccountChangeResult { $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); $this->eventDispatcher->dispatchTyped($event); - return $event->getResult(); + $result = $event->getResult(); + + if ($result->hasDecision() && !$result->isAccessAllowed()) { + throw new ProvisioningDeniedException($result->getReason()); + } + + return $result; } /** From 930c21a62dcd2486ce189dc2ae15ed1dc2000363 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 6 May 2026 17:09:45 +0200 Subject: [PATCH 4/6] fix --- lib/Service/ProvisioningEventService.php | 33 ++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index 239a6946d..a3bf8d71d 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -141,7 +141,10 @@ protected function dispatchUserAccountUpdate( $result = $event->getResult(); if ($result->hasDecision() && !$result->isAccessAllowed()) { - throw new ProvisioningDeniedException($result->getReason()); + throw new ProvisioningDeniedException( + $result->getReason(), + $result->getRedirectUrl(), + ); } return $result; @@ -174,20 +177,24 @@ public function provisionUser( $userReaction = $this->dispatchUserAccountUpdate($uid, $displayName, $email, $quota, $idTokenPayload); - if ($userReaction->isAccessAllowed()) { - $this->logger->info($uid . ': account accepted, reason: ' . $userReaction->getReason()); + if ($userReaction->hasDecision()) { + if ($userReaction->isAccessAllowed()) { + $this->logger->info($uid . ': account accepted, reason: ' . $userReaction->getReason()); - return [ - 'user' => $existingLocalUser ?? $this->userManager->get($uid), - 'userData' => get_object_vars($idTokenPayload), - ]; - } + return [ + 'user' => $existingLocalUser ?? $this->userManager->get($uid), + 'userData' => get_object_vars($idTokenPayload), + ]; + } - $this->logger->info($uid . ': account rejected, reason: ' . $userReaction->getReason()); + $this->logger->info($uid . ': account rejected, reason: ' . $userReaction->getReason()); - throw new ProvisioningDeniedException( - $userReaction->getReason(), - $userReaction->getRedirectUrl(), - ); + throw new ProvisioningDeniedException( + $userReaction->getReason(), + $userReaction->getRedirectUrl(), + ); + } + + return parent::provisionUser($tokenUserId, $providerId, $idTokenPayload, $existingLocalUser); } } From 18650bb6c24a8045d0fad946ed72ac3224187b59 Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 7 May 2026 09:24:11 +0200 Subject: [PATCH 5/6] fix --- lib/AppInfo/Application.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 6958a6c4d..abfa5c0fd 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -38,6 +38,8 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; + +// this is needed only for the event-based provisioning solution use Psr\Container\ContainerInterface; use Throwable; From b35c9115735603a80316f00375cb88f83dec11ab Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 7 May 2026 10:42:14 +0200 Subject: [PATCH 6/6] remove note --- lib/AppInfo/Application.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index abfa5c0fd..6958a6c4d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -38,8 +38,6 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; - -// this is needed only for the event-based provisioning solution use Psr\Container\ContainerInterface; use Throwable;