diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 9842067f..ec75d924 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -74,7 +74,7 @@ public function register(IRegistrationContext $context): void {
public function boot(IBootContext $context): void {
$context->injectFn(\Closure::fromCallable([$this->backend, 'injectSession']));
- $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken']));
+ // $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken']));
/** @var IUserSession $userSession */
$userSession = $this->getContainer()->get(IUserSession::class);
if ($userSession->isLoggedIn()) {
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index da786660..49dc2861 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;
@@ -554,6 +555,24 @@ public function code(string $state = '', string $code = '', string $scope = '',
}
if ($autoProvisionAllowed) {
+ $user = null;
+
+ try {
+ // use potential user from other backend, create it in our backend if it does not exist
+ $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser);
+ } 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);
+ }
+ }
+
if (!$softAutoProvisionAllowed && $existingUser !== null && $existingUser->getBackendClassName() !== Application::APP_ID) {
// if soft auto-provisioning is disabled,
// we refuse login for a user that already exists in another backend
diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php
new file mode 100644
index 00000000..de9fb5e5
--- /dev/null
+++ b/lib/Event/UserAccountChangeEvent.php
@@ -0,0 +1,125 @@
+
+ *
+ * @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 {
+
+ /** @var string */
+ private $uid;
+
+ /** @var string|null */
+ private $displayname;
+
+ /** @var string|null */
+ private $mainEmail;
+
+ /** @var string|null */
+ private $quota;
+
+ /** @var object */
+ private $claims;
+
+ /** @var UserAccountChangeResult */
+ 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');
+ }
+
+ /**
+ * Get the user ID (UID) associated with the event.
+ *
+ * @return string
+ */
+ public function getUid(): string {
+ return $this->uid;
+ }
+
+ /**
+ * Get the display name for the account.
+ *
+ * @return string|null
+ */
+ public function getDisplayName(): ?string {
+ return $this->displayname;
+ }
+
+ /**
+ * Get the primary email address.
+ *
+ * @return string|null
+ */
+ public function getMainEmail(): ?string {
+ return $this->mainEmail;
+ }
+
+ /**
+ * Get the quota assigned to the account.
+ *
+ * @return string|null
+ */
+ public function getQuota(): ?string {
+ return $this->quota;
+ }
+
+ /**
+ * Get the OIDC claims associated with the event.
+ *
+ * @return object
+ */
+ public function getClaims(): object {
+ return $this->claims;
+ }
+
+ /**
+ * Get the current result object.
+ *
+ * @return UserAccountChangeResult
+ */
+ public function getResult(): UserAccountChangeResult {
+ return $this->result;
+ }
+
+ /**
+ * Replace the result object with a new one.
+ *
+ * @param bool $accessAllowed Whether access should be allowed
+ * @param string $reason Optional reason for the decision
+ * @param string|null $redirectUrl Optional redirect URL
+ * @return void
+ */
+ 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..1b19d639
--- /dev/null
+++ b/lib/Event/UserAccountChangeResult.php
@@ -0,0 +1,92 @@
+
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ */
+
+declare(strict_types=1);
+
+namespace OCA\UserOIDC\Event;
+
+/**
+ * Represents the result of an account change event decision.
+ * Used to signal whether access is allowed and optional redirect/reason info.
+ */
+class UserAccountChangeResult {
+
+ /** @var bool */
+ private $accessAllowed;
+
+ /** @var string */
+ private $reason;
+
+ /** @var string|null */
+ private $redirectUrl;
+
+ public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) {
+ $this->accessAllowed = $accessAllowed;
+ $this->redirectUrl = $redirectUrl;
+ $this->reason = $reason;
+ }
+
+ /**
+ * Whether access for this user is allowed.
+ *
+ * @return bool
+ */
+ public function isAccessAllowed(): bool {
+ return $this->accessAllowed;
+ }
+
+ /**
+ * Set whether access for this user is allowed.
+ *
+ * @param bool $accessAllowed
+ * @return void
+ */
+ public function setAccessAllowed(bool $accessAllowed): void {
+ $this->accessAllowed = $accessAllowed;
+ }
+
+ /**
+ * Returns the optional alternate redirect URL.
+ *
+ * @return string|null
+ */
+ public function getRedirectUrl(): ?string {
+ return $this->redirectUrl;
+ }
+
+ /**
+ * Sets the optional alternate redirect URL.
+ *
+ * @param string|null $redirectUrl
+ * @return void
+ */
+ public function setRedirectUrl(?string $redirectUrl): void {
+ $this->redirectUrl = $redirectUrl;
+ }
+
+ /**
+ * Returns the decision reason.
+ *
+ * @return string
+ */
+ public function getReason(): string {
+ return $this->reason;
+ }
+
+ /**
+ * Sets the decision reason.
+ *
+ * @param string $reason
+ * @return void
+ */
+ public function setReason(string $reason): void {
+ $this->reason = $reason;
+ }
+}
diff --git a/lib/Exception/AttributeValueException.php b/lib/Exception/AttributeValueException.php
new file mode 100644
index 00000000..a4c80de4
--- /dev/null
+++ b/lib/Exception/AttributeValueException.php
@@ -0,0 +1,32 @@
+error;
+ }
+
+ public function getErrorDescription(): ?string {
+ return $this->errorDescription;
+ }
+}
diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php
new file mode 100644
index 00000000..9f317170
--- /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..abcaf940
--- /dev/null
+++ b/lib/Service/ProvisioningEventService.php
@@ -0,0 +1,184 @@
+
+ *
+ * @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 OCA\UserOIDC\Exception\AttributeValueException;
+use OCP\Accounts\IAccountManager;
+use OCP\DB\Exception;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Http\Client\IClientService;
+use OCP\IAvatarManager;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\ISession;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\NotFoundExceptionInterface;
+use Psr\Log\LoggerInterface;
+
+// FIXME there should be an interface for both variations
+class ProvisioningEventService extends ProvisioningService {
+
+ /** @var IEventDispatcher */
+ private $eventDispatcher;
+
+ /** @var LoggerInterface */
+ 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,
+ LoggerInterface $logger,
+ IAccountManager $accountManager,
+ IClientService $clientService,
+ IAvatarManager $avatarManager,
+ IConfig $config,
+ ISession $session,
+ IFactory $l10nFactory,
+ ) {
+ parent::__construct($idService,
+ $providerService,
+ $userMapper,
+ $userManager,
+ $groupManager,
+ $eventDispatcher,
+ $logger,
+ $accountManager,
+ $clientService,
+ $avatarManager,
+ $config,
+ $session,
+ $l10nFactory,
+ );
+ $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
+ * @param IUser|null $existingLocalUser
+ * @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 = $tokenUserId;
+ $displayname = $this->mapDispatchDisplayname($providerId, $idTokenPayload);
+ $email = $this->mapDispatchEmail($providerId, $idTokenPayload);
+ $quota = $this->mapDispatchQuota($providerId, $idTokenPayload);
+ } catch (AttributeValueException $eAttribute) {
+ $this->logger->info("{$tokenUserId}: 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' => $user,
+ 'userData' => get_object_vars($idTokenPayload), // optional, analog zu ProvisioningService
+ ];
+ } else {
+ $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason());
+ throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl());
+ }
+ }
+}
diff --git a/tests/unit/BaseTest/OpenidTokenTestCase.php b/tests/unit/BaseTest/OpenidTokenTestCase.php
new file mode 100644
index 00000000..c0955093
--- /dev/null
+++ b/tests/unit/BaseTest/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 = [
+ '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' => ['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..85ac4d2a
--- /dev/null
+++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php
@@ -0,0 +1,481 @@
+
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ */
+
+declare(strict_types=1);
+
+use OC\AppFramework\Bootstrap\Coordinator;
+use OC\Authentication\Token\IProvider;
+use OC\Security\Crypto;
+use OCA\UserOIDC\AppInfo\Application;
+use OCA\UserOIDC\BaseTest\OpenidTokenTestCase;
+use OCA\UserOIDC\Controller\LoginController;
+use OCA\UserOIDC\Db\Provider;
+use OCA\UserOIDC\Db\ProviderMapper;
+use OCA\UserOIDC\Db\SessionMapper;
+use OCA\UserOIDC\Db\UserMapper;
+use OCA\UserOIDC\Event\AttributeMappedEvent;
+use OCA\UserOIDC\Event\UserAccountChangeEvent;
+use OCA\UserOIDC\Service\DiscoveryService;
+use OCA\UserOIDC\Service\LdapService;
+use OCA\UserOIDC\Service\LocalIdService;
+use OCA\UserOIDC\Service\ProviderService;
+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 OCP\Http\Client\IClient;
+use OCP\Http\Client\IClientService;
+use OCP\Http\Client\IResponse;
+use OCP\IAvatarManager;
+use OCP\ICacheFactory;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IL10N; // deprecated!
+use OCP\IRequest;
+use OCP\ISession;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+
+
+use OCP\Security\ISecureRandom;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Psr\Log\LoggerInterface;
+
+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([ '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 = [ '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..eb714da9
--- /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 OC\AppFramework\Bootstrap\Coordinator;
+use OCA\UserOIDC\AppInfo\Application;
+use OCA\UserOIDC\Service\ProvisioningEventService;
+use OCA\UserOIDC\Service\ProvisioningService;
+
+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);
+ }
+}