diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 970ef16e..2ca9240b 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -23,6 +23,8 @@ use OCA\UserOIDC\Service\DiscoveryService; use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\EventProvisioningService; +use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; use OCP\AppFramework\Db\DoesNotExistException; @@ -54,7 +56,68 @@ class LoginController extends BaseOidcController { public const PROVIDERID = 'oidc.providerid'; private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; private const ID_TOKEN = 'oidc.id_token'; - private const CODE_VERIFIER = 'oidc.code_verifier'; + + /** @var ISecureRandom */ + private $random; + + /** @var ISession */ + private $session; + + /** @var IClientService */ + private $clientService; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var IUserSession */ + private $userSession; + + /** @var IUserManager */ + private $userManager; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var ProviderMapper */ + private $providerMapper; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var ProviderService */ + private $providerService; + + /** @var DiscoveryService */ + private $discoveryService; + + /** @var IConfig */ + private $config; + + /** @var LdapService */ + private $ldapService; + + /** @var IProvider */ + private $authTokenProvider; + + /** @var SessionMapper */ + private $sessionMapper; + + /** @var EventProvisioningService */ + private $eventProvisioningService; + + /** @var ProvisioningService */ + private $provisioningService; + + /** @var IL10N */ + private $l10n; + /** + * @var ICrypto + */ + private $crypto; + public function __construct( IRequest $request, @@ -455,26 +518,39 @@ public function code(string $state = '', string $code = '', string $scope = '', } $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); + $softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']); + + $shouldDoUserLookup = !$autoProvisionAllowed || ($softAutoProvisionAllowed && !$this->provisioningService->hasOidcUserProvisitioned($userId)); + if ($shouldDoUserLookup && $this->ldapService->isLDAPEnabled()) { + // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results + // so new users will be directly available even if they were not synced before this login attempt + $this->userManager->search($userId, 1, 0); + $this->ldapService->syncUser($userId); + } - // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results - // so new users will be directly available even if they were not synced before this login attempt - $this->userManager->search($userId); - $this->ldapService->syncUser($userId); $userFromOtherBackend = $this->userManager->get($userId); if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { $userFromOtherBackend = null; } if ($autoProvisionAllowed) { - $softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']); - if (!$softAutoProvisionAllowed && $userFromOtherBackend !== null) { - // if soft auto-provisioning is disabled, - // we refuse login for a user that already exists in another backend - $message = $this->l10n->t('User conflict'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict'], false); + // TODO: (proposal) refactor all provisioning strategies into event handlers + $user = null; + try { + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); + } catch (ProvisioningDeniedException $denied) { + // TODO MagentaCLOUD should upstream the exception handling + $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()]); + } else { + // error response is a redirect, e.g. to a booking site + // so that you can immediately get the registration page + return new RedirectResponse($redirectUrl); + } } - // use potential user from other backend, create it in our backend if it does not exist - $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $userFromOtherBackend); + // no default exception handling to pass on unittest assertion failures } else { // when auto provision is disabled, we assume the user has been created by another user backend (or manually) $user = $userFromOtherBackend; @@ -566,7 +642,8 @@ public function singleLogoutService() { $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); $shouldSendIdToken = $this->providerService->getSetting( $provider->getId(), - ProviderService::SETTING_SEND_ID_TOKEN_HINT, '0' + ProviderService::SETTING_SEND_ID_TOKEN_HINT, + '0' ) === '1'; $idToken = $this->session->get(self::ID_TOKEN); if ($shouldSendIdToken && $idToken) { @@ -714,7 +791,10 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok * @return JSONResponse */ private function getBackchannelLogoutErrorResponse( - string $error, string $description, array $throttleMetadata = [], + string $error, + string $description, + array $throttleMetadata = [], + ?bool $throttle = null ): JSONResponse { $this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description); return new JSONResponse( diff --git a/lib/Db/UserMapper.php b/lib/Db/UserMapper.php index fe209967..9bc257cf 100644 --- a/lib/Db/UserMapper.php +++ b/lib/Db/UserMapper.php @@ -13,6 +13,7 @@ use OCP\AppFramework\Db\QBMapper; use OCP\Cache\CappedMemoryCache; use OCP\IDBConnection; +use Psr\Log\LoggerInterface; /** * @extends QBMapper @@ -20,13 +21,16 @@ class UserMapper extends QBMapper { private CappedMemoryCache $userCache; + private LoggerInterface $logger; public function __construct( IDBConnection $db, + LoggerInterface $logger, private LocalIdService $idService, ) { parent::__construct($db, 'user_oidc', User::class); $this->userCache = new CappedMemoryCache(); + $this->logger = $logger; } /** @@ -57,6 +61,29 @@ public function getUser(string $uid): User { public function find(string $search, $limit = null, $offset = null): array { $qb = $this->db->getQueryBuilder(); + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $stack = []; + + foreach ($backtrace as $index => $trace) { + $class = $trace['class'] ?? ''; + $type = $trace['type'] ?? ''; + $function = $trace['function'] ?? ''; + $file = $trace['file'] ?? 'unknown file'; + $line = $trace['line'] ?? 'unknown line'; + + $stack[] = sprintf( + "#%d %s%s%s() called at [%s:%s]", + $index, + $class, + $type, + $function, + $file, + $line + ); + } + + $this->logger->debug("Find user by string: " . $search . " -- Call Stack:\n" . implode("\n", $stack)); + $qb->select('user_id', 'display_name') ->from($this->getTableName(), 'u') ->leftJoin('u', 'preferences', 'p', $qb->expr()->andX( @@ -77,6 +104,29 @@ public function find(string $search, $limit = null, $offset = null): array { public function findDisplayNames(string $search, $limit = null, $offset = null): array { $qb = $this->db->getQueryBuilder(); + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $stack = []; + + foreach ($backtrace as $index => $trace) { + $class = $trace['class'] ?? ''; + $type = $trace['type'] ?? ''; + $function = $trace['function'] ?? ''; + $file = $trace['file'] ?? 'unknown file'; + $line = $trace['line'] ?? 'unknown line'; + + $stack[] = sprintf( + "#%d %s%s%s() called at [%s:%s]", + $index, + $class, + $type, + $function, + $file, + $line + ); + } + + $this->logger->debug("Find user display names by string: " . $search . " -- Call Stack:\n" . implode("\n", $stack)); + $qb->select('user_id', 'display_name') ->from($this->getTableName(), 'u') ->leftJoin('u', 'preferences', 'p', $qb->expr()->andX( diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php new file mode 100644 index 00000000..c6b698aa --- /dev/null +++ b/lib/Event/UserAccountChangeEvent.php @@ -0,0 +1,87 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +use OCP\EventDispatcher\Event; + +/** + * Event to provide custom mapping logic based on the OIDC token data + * In order to avoid further processing the event propagation should be stopped + * in the listener after processing as the value might get overwritten afterwards + * by other listeners through $event->stopPropagation(); + */ +class UserAccountChangeEvent extends Event { + private $uid; + private $displayname; + private $mainEmail; + private $quota; + private $claims; + private $result; + + + public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) { + parent::__construct(); + $this->uid = $uid; + $this->displayname = $displayname; + $this->mainEmail = $mainEmail; + $this->quota = $quota; + $this->claims = $claims; + $this->result = new UserAccountChangeResult($accessAllowed, 'default'); + } + + /** + * @return get event username (uid) + */ + public function getUid(): string { + return $this->uid; + } + + /** + * @return get event displayname + */ + public function getDisplayName(): ?string { + return $this->displayname; + } + + /** + * @return get event main email + */ + public function getMainEmail(): ?string { + return $this->mainEmail; + } + + /** + * @return get event quota + */ + public function getQuota(): ?string { + return $this->quota; + } + + /** + * @return array the array of claim values associated with the event + */ + public function getClaims(): object { + return $this->claims; + } + + /** + * @return value for the logged in user attribute + */ + 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 00000000..660e78f9 --- /dev/null +++ b/lib/Event/UserAccountChangeResult.php @@ -0,0 +1,74 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +/** + * Event to provide custom mapping logic based on the OIDC token data + * In order to avoid further processing the event propagation should be stopped + * in the listener after processing as the value might get overwritten afterwards + * by other listeners through $event->stopPropagation(); + */ +class UserAccountChangeResult { + + /** @var bool */ + private $accessAllowed; + /** @var string */ + private $reason; + /** @var string */ + private $redirectUrl; + + public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) { + $this->accessAllowed = $accessAllowed; + $this->redirectUrl = $redirectUrl; + $this->reason = $reason; + } + + /** + * @return value for the logged in user attribute + */ + public function isAccessAllowed(): bool { + return $this->accessAllowed; + } + + public function setAccessAllowed(bool $accessAllowed): void { + $this->accessAllowed = $accessAllowed; + } + + /** + * @return get optional alternate redirect address + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + /** + * @return set optional alternate redirect address + */ + public function setRedirectUrl(?string $redirectUrl): void { + $this->redirectUrl = $redirectUrl; + } + + /** + * @return get decision reason + */ + public function getReason(): string { + return $this->reason; + } + + /** + * @return set decision reason + */ + public function setReason(string $reason): void { + $this->reason = $reason; + } +} diff --git a/lib/Service/LdapService.php b/lib/Service/LdapService.php index 53cf80fa..f56fa12f 100644 --- a/lib/Service/LdapService.php +++ b/lib/Service/LdapService.php @@ -8,6 +8,7 @@ namespace OCA\UserOIDC\Service; +use OCP\App\IAppManager; use OCP\AppFramework\QueryException; use OCP\IUser; use Psr\Log\LoggerInterface; @@ -16,9 +17,14 @@ class LdapService { public function __construct( private LoggerInterface $logger, + private IAppManager $appManager, ) { } + public function isLDAPEnabled(): bool { + return $this->appManager->isEnabledForUser('user_ldap'); + } + /** * @param IUser $user * @return bool @@ -26,6 +32,10 @@ public function __construct( * @throws \Psr\Container\NotFoundExceptionInterface */ public function isLdapDeletedUser(IUser $user): bool { + if ($this->isLDAPEnabled()) { + return false; + } + $className = $user->getBackendClassName(); if ($className !== 'LDAP') { return false; diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php new file mode 100644 index 00000000..58c2fb9b --- /dev/null +++ b/lib/Service/ProvisioningDeniedException.php @@ -0,0 +1,69 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\UserOIDC\Service; + +/** + * Exception if the precondition of the config update method isn't met + * @since 1.4.0 + */ +class ProvisioningDeniedException extends \Exception { + private $redirectUrl; + + /** + * Exception constructor including an option redirect url. + * + * @param string $message The error message. It will be not revealed to the + * the user (unless the hint is empty) and thus + * should be not translated. + * @param string $hint A useful message that is presented to the end + * user. It should be translated, but must not + * contain sensitive data. + * @param int $code Set default to 403 (Forbidden) + * @param \Exception|null $previous + */ + public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, \Exception $previous = null) { + parent::__construct($message, $code, $previous); + $this->redirectUrl = $redirectUrl; + } + + /** + * Read optional failure redirect if available + * @return string|null + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + /** + * Include redirect in string serialisation. + * + * @return string + */ + public function __toString(): string { + $redirect = $this->redirectUrl ?? ''; + return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } +} diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php new file mode 100644 index 00000000..9074f1e1 --- /dev/null +++ b/lib/Service/ProvisioningEventService.php @@ -0,0 +1,161 @@ + + * + * @author Bernd Rederlechner + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Service; + +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\Event\AttributeMappedEvent; +use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCP\DB\Exception; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IGroupManager; +use OCP\ILogger; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; + +// FIXME there should be an interface for both variations +class ProvisioningEventService extends ProvisioningService { + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var IUserManager */ + private $userManager; + + /** @var ProviderService */ + private $providerService; + + public function __construct( + LocalIdService $idService, + ProviderService $providerService, + UserMapper $userMapper, + IUserManager $userManager, + IGroupManager $groupManager, + IEventDispatcher $eventDispatcher, + ILogger $logger + ) { + parent::__construct($idService, + $providerService, + $userMapper, + $userManager, + $groupManager, + $eventDispatcher, + $logger); + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->userManager = $userManager; + $this->providerService = $providerService; + } + + protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) { + $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchDisplayname(int $providerid, object $payload) { + $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (isset($mappedDisplayName)) { + $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); + } else { + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); + } + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchEmail(int $providerid, object $payload) { + $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); + return $event->getValue(); + } + + protected function mapDispatchQuota(int $providerid, object $payload) { + $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); + return $event->getValue(); + } + + protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + return $event->getResult(); + } + + /** + * Trigger a provisioning via event system. + * This allows to flexibly implement complex provisioning strategies - + * even in a separate app. + * + * On error, the provisioning logic can deliver failure reasons and + * even a redirect to a different endpoint. + * + * @param string $tokenUserId + * @param int $providerId + * @param object $idTokenPayload + * @return IUser|null + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser { + try { + // for multiple reasons, it is better to take the uid directly from a token field + //$uid = $this->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $uid = $tokenUserId; + $displayname = $this->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $eAttribute) { + $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $eAttribute->getMessage()); + throw new ProvisioningDeniedException($eAttribute->getMessage()); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $idTokenPayload); + if ($userReaction->isAccessAllowed()) { + $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); + $user = $this->userManager->get($uid); + return $user; + } else { + $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); + } + } +} diff --git a/lib/Service/ProvisioningService.php b/lib/Service/ProvisioningService.php index 02122a1d..e06d07bd 100644 --- a/lib/Service/ProvisioningService.php +++ b/lib/Service/ProvisioningService.php @@ -10,6 +10,8 @@ use OCA\UserOIDC\Db\UserMapper; use OCA\UserOIDC\Event\AttributeMappedEvent; use OCP\Accounts\IAccountManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use OCP\Http\Client\IClientService; @@ -40,6 +42,15 @@ public function __construct( ) { } + public function hasOidcUserProvisitioned(string $userId): bool { + try { + $this->userMapper->getUser($userId); + return true; + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + } + return false; + } + /** * @param string $tokenUserId * @param int $providerId diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php new file mode 100644 index 00000000..cbaa9847 --- /dev/null +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -0,0 +1,141 @@ +realOidClaims; + } + + public function getOidClientId() { + return "USER_NC_OPENID_TEST"; + } + + public function getOidNonce() { + return "CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K"; + } + + public function getOidClientSecret() { + return \Base64Url\Base64Url::encode('JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'); + } + + public function getOidServerKey() { + return \Base64Url\Base64Url::encode('JQ17DAF8-C99A-4E27-FBW4-GV23B043C993'); + } + + public function getOidPrivateServerKey() { + 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() { + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWK::parseKeySet([ "keys" => [[ + "kty" => "RSA", + "e" => "AQAB", + "use" => "sig", + "kid" => "0123456789", + "alg" => "RS256", + "n" => "6WCdDo8KuksEFaFlzvmsaoYhfOoMt5XgnX98dx-F1OUz53SG0lQlFt-xkwra5B4GZ-13lki0qCa2CjA1aLa9kEvDdYhz_0Uc5qOy5haDj8Jn547s6gFyaLzJ0RN5i5eKeDMHcjeEC0_NjiB2UNUFJJ61b2nXIlUvp_vBfKCv4A-8C3mLSbCKJQhX84QRDgt_Abz0MXj_ga72Ka2cwVLo4OFQAK5m57Qfu9ZvseMcgoinyhIQ18b98SkWinn3DM0W1KXLkWLk0S3XEMxLV1M7-9RLo4fgEGOpX1xmmM6KbsC5SxXvRUO7tjU-o35fcewDwXYHnRbxqhRkEFfWb7b8nQ" + ]]]); + } + + public function getOidTestCode() { + return "66844608"; + } + + public function getOidTestState() { + return "4VSL5T274MJEMLZI1810HUFDA07CEPXZ"; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->realOidClaims = array( + "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" => time(), + "exp" => time() + 7200, + 'iat' => time(), + "urn:custom.com:session_token" => "ad0fff71-e013-11ec-9e17-39677d2c891c", + "nonce" => "CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K", + "aud" => array("USER_NC_OPENID_TEST") ); + } + + protected function createSignToken(array $claims) : string { + // The algorithm manager with the HS256 algorithm. + $algorithmManager = new AlgorithmManager([ + new RS256(), + ]); + + // use a different key for an invalid signature + $jwk = new JWK($this->getOidPrivateServerKey()); + $jwsBuilder = new JWSBuilder($algorithmManager); + $jws = $jwsBuilder->create() // We want to create a new JWS + ->withPayload(json_encode($claims)) // We set the payload + ->addSignature($jwk, ['alg' => 'RS256', "kid" => "0123456789"]) // We add a signature with a simple protected header + ->build(); + + $serializer = new CompactSerializer(); + return $serializer->serialize($jws, 0); + } +} diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php new file mode 100644 index 00000000..b844f640 --- /dev/null +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -0,0 +1,477 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\Controller\LoginController; +use OCA\UserOIDC\Service\DiscoveryService; +use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\LdapService; +use OCA\UserOIDC\Service\LocalIdService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCP\Accounts\IAccountManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCA\UserOIDC\Event\AttributeMappedEvent; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IResponse; +use OCP\IAvatarManager; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IUser; +use OCP\IDBConnection; +use OCP\ICacheFactory; +use Psr\Log\LoggerInterface; +use OCP\ILogger; // deprecated! +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\IUserSession; + + +use OCP\Security\ISecureRandom; +use OC\Security\Crypto; + +use OCP\AppFramework\App; + +use PHPUnit\Framework\MockObject\MockObject; +use OCA\UserOIDC\BaseTest\OpenidTokenTestCase; + +class ProvisioningEventServiceTest extends OpenidTokenTestCase { + /** + * Set up needed system and app configurations + */ + protected function getConfigSetup() :MockObject { + $config = $this->getMockForAbstractClass(IConfig::class); + + $config->expects($this->any()) + ->method("getSystemValue") + ->with($this->logicalOr($this->equalTo('user_oidc'), $this->equalTo('secret'))) + ->willReturn($this->returnCallback(function ($key, $default) { + if ($key == 'user_oidc') { + return [ + 'auto_provisioning' => true, + ]; + } elseif ($key == 'secret') { + return "Streng_geheim"; + } + })); + return $config; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getOidSessionSetup() :MockObject { + $session = $this->getMockForAbstractClass(ISession::class); + + $session->expects($this->any()) + ->method('get') + ->willReturn($this->returnCallback(function ($key) { + $values = [ + 'oidc.state' => $this->getOidTestState(), + 'oidc.providerid' => $this->getProviderId(), + 'oidc.nonce' => $this->getOidNonce(), + 'oidc.redirect' => 'https://welcome.to.magenta' + ]; + + return $values[$key] ? $values[$key] : "some_" . $key; + })); + $this->sessionMapper = $this->getMockBuilder(SessionMapper::class) + ->setConstructorArgs([ $this->getMockForAbstractClass(IDBConnection::class) ]) + ->getMock(); + $this->sessionMapper->expects($this->any()) + ->method('createSession'); + + return $session; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getProviderSetup() :MockObject { + $provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getClientId', 'getClientSecret']) + ->getMock(); + $provider->expects($this->any()) + ->method('getClientId') + ->willReturn($this->getOidClientId()); + $provider->expects($this->once()) + ->method('getClientSecret') + ->willReturn($this->crypto->encrypt($this->getOidClientSecret())); + $this->providerMapper->expects($this->once()) + ->method('getProvider') + ->with($this->equalTo($this->getProviderId())) + ->willReturn($provider); + + return $provider; + } + + + /** + * Prepare a proper mapping configuration for the provider + */ + protected function getProviderServiceSetup() :MockObject { + $providerService = $this->getMockBuilder(ProviderService::class) + ->setConstructorArgs([ $this->config, $this->providerMapper]) + ->getMock(); + $providerService->expects($this->any()) + ->method('getSetting') + ->with($this->equalTo($this->getProviderId()), $this->logicalOr( + $this->equalTo(ProviderService::SETTING_MAPPING_UID), + $this->equalTo(ProviderService::SETTING_MAPPING_DISPLAYNAME), + $this->equalTo(ProviderService::SETTING_MAPPING_QUOTA), + $this->equalTo(ProviderService::SETTING_MAPPING_EMAIL), + $this->anything())) + ->will($this->returnCallback(function ($providerid, $key, $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' + ]; + return $values[$key]; + })); + return $providerService; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getUserManagerSetup() :MockObject { + $userManager = $this->getMockForAbstractClass(IUserManager::class); + $this->user = $this->getMockForAbstractClass(IUser::class); + $this->user->expects($this->any()) + ->method("canChangeAvatar") + ->willReturn(false); + + return $userManager; + } + + + /** + * This is the standard execution sequence until provisoning + * is triggered in LoginController, set up with an artificial + * yet valid OpenID token. + */ + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->config = $this->getConfigSetup(); + $this->crypto = $this->getMockBuilder(Crypto::class) + ->setConstructorArgs([ $this->config ]) + ->getMock(); + + $this->request = $this->getMockForAbstractClass(IRequest::class); + $this->request->expects($this->once()) + ->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 ]) + ->getMock(); + $this->discoveryService = $this->getMockBuilder(DiscoveryService::class) + ->setConstructorArgs([ $this->app->getContainer()->get(LoggerInterface::class), + $this->getMockForAbstractClass(IClientService::class), + $this->providerService, + $this->app->getContainer()->get(ICacheFactory::class) ]) + ->getMock(); + $this->discoveryService->expects($this->once()) + ->method('obtainDiscovery') + ->willReturn(array( 'token_endpoint' => 'https://whatever.to.discover/token', + 'issuer' => 'https:\/\/accounts.login00.custom.de' )); + $this->discoveryService->expects($this->once()) + ->method('obtainJWK') + ->willReturn($this->getOidPublicServerKey()); + $this->session = $this->getOidSessionSetup(); + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->response = $this->getMockForAbstractClass(IResponse::class); + //$this->usersession = $this->getMockForAbstractClass(IUserSession::class); + $this->usersession = $this->getMockBuilder(IUserSession::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'setUser', + 'login', + 'logout', + 'getUser', + 'isLoggedIn', + 'getImpersonatingUserID', + 'setImpersonatingUserID', + 'setVolatileActiveUser' // Diese Methode hinzufügen, falls sie gebraucht wird. + ]) + ->addMethods([ + 'completeLogin', + 'createSessionToken', + 'createRememberMeToken' + ]) + ->getMock(); + + $this->usermanager = $this->getUserManagerSetup(); + $this->groupmanager = $this->getMockForAbstractClass(IGroupManager::class); + $this->dispatcher = $this->app->getContainer()->get(IEventDispatcher::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->app->getContainer()->get(IConfig::class)); + // here is where the token magic comes in + $this->token = array( 'id_token' => + $this->createSignToken($this->getRealOidClaims(), + $this->getOidServerKey())); + $this->tokenResponse = $this->getMockForAbstractClass(IResponse::class); + $this->tokenResponse->expects($this->once()) + ->method("getBody") + ->willReturn(json_encode($this->token)); + + // mock token retrieval + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->client->expects($this->once()) + ->method("post") + ->with($this->equalTo('https://whatever.to.discover/token'), $this->arrayHasKey('body')) + ->willReturn($this->tokenResponse); + $this->clientService = $this->getMockForAbstractClass(IClientService::class); + $this->clientService->expects($this->once()) + ->method("newClient") + ->willReturn($this->client); + $this->registrationContext = + $this->app->getContainer()->get(Coordinator::class)->getRegistrationContext(); + $this->loginController = new LoginController($this->request, + $this->providerMapper, + $this->providerService, + $this->discoveryService, + $this->app->getContainer()->get(LdapService::class), + $this->app->getContainer()->get(ISecureRandom::class), + $this->session, + $this->clientService, + $this->app->getContainer()->get(IUrlGenerator::class), + $this->usersession, + $this->usermanager, + $this->app->getContainer()->get(ITimeFactory::class), + $this->dispatcher, + $this->config, + $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->attributeListener = null; + $this->accountListener = null; + } + + /** + * Seems like the event dispatcher requires explicit unregistering + */ + public function tearDown(): void { + parent::tearDown(); + if ($this->accountListener != null) { + $this->dispatcher->removeListener(UserAccountChangeEvent::class, $this->accountListener); + } + if ($this->attributeListener != null) { + $this->dispatcher->removeListener(AttributeMappedEvent::class, $this->attributeListener); + } + } + + protected function mockAssertLoginSuccess() { + $this->usermanager->expects($this->once()) + ->method('get') + ->willReturn($this->user); + $this->session->expects($this->exactly(2)) + ->method("set") + ->withConsecutive([$this->equalTo('oidc.id_token'), $this->anything()], + [$this->equalTo('last-password-confirm'), $this->anything()]); + $this->usersession->expects($this->once()) + ->method("setUser") + ->with($this->equalTo($this->user)); + $this->usersession->expects($this->once()) + ->method("completeLogin") + ->with($this->anything(), $this->anything()); + $this->usersession->expects($this->once()) + ->method("createSessionToken"); + $this->usersession->expects($this->once()) + ->method("createRememberMeToken"); + } + + protected function assertLoginRedirect($result) { + $this->assertInstanceOf(RedirectResponse::class, + $result, "LoginController->code() did not end with success redirect: Status: " . + strval($result->getStatus() . ' ' . json_encode($result->getThrottleMetadata()))); + } + + protected function assertLogin403($result) { + $this->assertInstanceOf(TemplateResponse::class, + $result, "LoginController->code() did not end with 403 Forbidden: Actual status: " . + strval($result->getStatus() . ' ' . json_encode($result->getThrottleMetadata()))); + } + + /** + * Test with the default mapping, no mapping by attribute events + * provisioning with successful result. + */ + public function testNoMap_AccessOk() { + $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->assertEquals('https://welcome.to.magenta', $result->getRedirectURL()); + } + + /** + * For multiple reasons, uid should com directly from a token + * field, usually sub. Thus, uid is not remapped by event, even + * if you try with a listener. + */ + public function testUidNoMapEvent_AccessOk() { + $this->mockAssertLoginSuccess(); + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_UID) { + $this->fail('UID event mapping not supported'); + } + }; + $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(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.magenta', $result->getRedirectURL()); + } + + + + /** + * Test displayname set by event scheduling and negative result + */ + public function testDisplaynameMapEvent_NOk_NoRedirect() { + $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() { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_EMAIL) { + //$defaultUID = $event->getValue(); + $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() { + $this->mockAssertLoginSuccess(); + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent) { + if ($event->getAttribute() == ProviderService::SETTING_MAPPING_UID) { + $this->fail('UID event mapping not supported'); + } elseif ($event->getAttribute() == ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue("Lisa, Mona"); + } elseif ($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('https://welcome.to.magenta', $result->getRedirectURL()); + } +} diff --git a/tests/unit/MagentaCloud/RegistrationsTest.php b/tests/unit/MagentaCloud/RegistrationsTest.php new file mode 100644 index 00000000..5a70f556 --- /dev/null +++ b/tests/unit/MagentaCloud/RegistrationsTest.php @@ -0,0 +1,33 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Service\ProvisioningService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OC\AppFramework\Bootstrap\Coordinator; + +use PHPUnit\Framework\TestCase; + +class RegistrationsTest extends TestCase { + public function setUp() :void { + parent::setUp(); + + $this->app = new Application(); + $coordinator = \OC::$server->get(Coordinator::class); + $this->app->register($coordinator->getRegistrationContext()->for('user_oidc')); + } + + public function testRegistration() :void { + $provisioningService = $this->app->getContainer()->get(ProvisioningService::class); + $this->assertInstanceOf(ProvisioningEventService::class, $provisioningService); + } +}