diff --git a/.github/workflows/nmc-custom-app-release.yml b/.github/workflows/nmc-custom-app-release.yml new file mode 100644 index 000000000..ec889c38c --- /dev/null +++ b/.github/workflows/nmc-custom-app-release.yml @@ -0,0 +1,68 @@ +### +# SPDX-License-Identifier: AGPL-3.0 +# +# Author: Bernd rederlechner +# +# Builds a stable release package based on a release assembly +# customisation-- +# +# As soon as a package is deployed to production, the tag and the branch +# MUST STAY FOR 2 years and not deleted. +# +# Release packages, tags and customisation branches not delivered to production should +# be deleted asap a newer release is available. +# + +name: MCLOUD custom app release + +on: + workflow_dispatch: + inputs: + increment: + description: 'Release increment' + required: true + type: number + branch: + type: choice + description: Branch to build a package from + options: + - main + - stable32 + - stable33 + - stable34 + default: main + +jobs: + check-custom: + uses: nextmcloud/.github/.github/workflows/nmc-app-precond.yml@master + with: + versionbranch: ${{ inputs.branch }} + increment: ${{ inputs.increment }} + secrets: inherit + assemble-custom: + uses: nextmcloud/.github/.github/workflows/nmc-custom-assembly.yml@master + needs: check-custom + with: + trunk: 'main' + stable: ${{ inputs.branch }} + result: ${{ format('customisation-{0}-{1}', inputs.branch, inputs.increment ) }} + secrets: inherit + + composerdep: + strategy: + fail-fast: false + uses: ./.github/workflows/nmc-custom-oidc-composer.yml + needs: assemble-custom + with: + assembly: ${{ format('customisation-{0}-{1}', inputs.branch, inputs.increment) }} + secrets: inherit + + build-custom: + uses: nextmcloud/.github/.github/workflows/nmc-custom-app-build.yml@master + needs: [ check-custom, composerdep ] + with: + appname: ${{ needs.check-custom.outputs.appname }} + assembly: ${{ format('customisation-{0}-{1}', inputs.branch , inputs.increment ) }} + tag: ${{ needs.check-custom.outputs.tag }} + prerelease: ${{ inputs.branch == 'main' && true || false }} + secrets: inherit diff --git a/.github/workflows/nmc-custom-app-versions.yml b/.github/workflows/nmc-custom-app-versions.yml new file mode 100644 index 000000000..e18f19412 --- /dev/null +++ b/.github/workflows/nmc-custom-app-versions.yml @@ -0,0 +1,72 @@ +### +# SPDX-License-Identifier: AGPL-3.0 +# +# Author: Bernd rederlechner +# +# Assemble a customisation for trunk (no backports) and stable +# (backport xor trunk) +# +# It creates review (user-specific) customisations branches +# - customisation-- +# - customisation-- + +name: MCLOUD custom app versions + +### +# The customisation-* branches are always reassembled if a customisation branch +# is updated or included into a custom PR +on: + workflow_dispatch: + pull_request: + types: + - opened + - reopened + - synchronize + branches: + - master + - main + - trunk + - nmcstable/** + # - stable/** + +jobs: + + assemble: + strategy: + fail-fast: false + matrix: + custombase: [ "main" ] + uses: nextmcloud/.github/.github/workflows/nmc-custom-assembly.yml@master + with: + trunk: "main" + stable: ${{ matrix.custombase }} + result: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} + secrets: inherit + + composerdep: + strategy: + fail-fast: false + matrix: + custombase: [ "main" ] + uses: ./.github/workflows/nmc-custom-oidc-composer.yml + needs: assemble + with: + assembly: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} + secrets: inherit + + phpunit: + strategy: + fail-fast: false + matrix: + phpversion: ['8.0', '8.1'] + database: ['mysql'] + custombase: [ "main" ] + uses: nextmcloud/.github/.github/workflows/nmc-custom-app-phpunit.yml@master + needs: composerdep + with: + assembly: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} + appname: 'user_oidc' + server-branch: ${{ matrix.custombase }} + phpversion: ${{ matrix.phpversion }} + database: ${{ matrix.database }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/nmc-custom-oidc-composer.yml b/.github/workflows/nmc-custom-oidc-composer.yml new file mode 100644 index 000000000..71080bea7 --- /dev/null +++ b/.github/workflows/nmc-custom-oidc-composer.yml @@ -0,0 +1,132 @@ +### +# SPDX-License-Identifier: AGPL-3.0 +# +# Author: Mauro Mura +# +# user_oidc brings its PHP dependencies via composer.json. +# composer install also runs Mozart via post-install-cmd. +# We add these commandline based in build to avoid continuous +# merge conflicts due to "composer.lock" merge problems. + +name: MCLOUD custom user_oidc dependencies + +on: + workflow_call: + inputs: + assembly: + description: name of the customisation assembly branch + required: true + type: string + +jobs: + build-custom: + runs-on: ubuntu-latest + env: + BUILD_USER: ${{ github.actor }} + BUILD_EMAIL: ${{ github.actor }}@users.noreply.github.com + BUILD_TOKEN: ${{ secrets.BUILD_TOKEN || secrets.GITHUB_TOKEN }} + PHP_VERSION: ${{ vars.PHP_VERSION || '8.3' }} + ASSEMBLY_BRANCH: ${{ inputs.assembly }} + + steps: + - name: Fetch custom assembly + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ inputs.assembly }} + fetch-depth: 0 + token: ${{ env.BUILD_TOKEN }} + + - name: Prepare GIT modifications + run: | + git config user.name "$BUILD_USER" + git config user.email "$BUILD_EMAIL" + + - name: Set up PHP ${{ env.PHP_VERSION }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@v1 + with: + files: "./composer.json" + + - name: Patch composer.json for custom user_oidc dependencies + if: steps.check_composer.outputs.files_exists == 'true' + run: | + php <<'PHP' + OpenID Connect user backend Use an OpenID Connect backend to login to your Nextcloud Allows flexible configuration of an OIDC server as Nextcloud login user backend. - 8.10.1 + 8.1.1 agpl Roeland Jago Douma Julius Härtl diff --git a/appinfo/routes.php b/appinfo/routes.php index faf0ae16b..5ab4ea73b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,6 +16,7 @@ ['name' => 'login#code', 'url' => '/code', 'verb' => 'GET'], ['name' => 'login#singleLogoutService', 'url' => '/sls', 'verb' => 'GET'], ['name' => 'login#backChannelLogout', 'url' => '/backchannel-logout/{providerIdentifier}', 'verb' => 'POST'], + ['name' => 'login#telekomBackChannelLogout', 'url' => '/logout', 'verb' => 'POST'], ['name' => 'id4me#showLogin', 'url' => '/id4me', 'verb' => 'GET'], ['name' => 'id4me#login', 'url' => '/id4me', 'verb' => 'POST'], @@ -31,8 +32,5 @@ ['name' => 'Settings#setID4ME', 'url' => '/api/{apiVersion}/provider/id4me', 'verb' => 'POST', 'requirements' => $requirements], ['name' => 'Settings#getSupportedSettings', 'url' => '/api/{apiVersion}/supported-settings', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'Settings#setAdminConfig', 'url' => '/api/{apiVersion}/admin-config', 'verb' => 'POST', 'requirements' => $requirements], - - ['name' => 'ocsApi#createUser', 'url' => '/api/{apiVersion}/user', 'verb' => 'POST', 'requirements' => $requirements], - ['name' => 'ocsApi#deleteUser', 'url' => '/api/{apiVersion}/user/{userId}', 'verb' => 'DELETE', 'requirements' => $requirements], ], ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 192220cab..f4169d70c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,11 +21,13 @@ use OCA\UserOIDC\Listener\InternalTokenRequestedListener; use OCA\UserOIDC\Listener\TimezoneHandlingListener; use OCA\UserOIDC\Listener\TokenInvalidatedListener; +use OCA\UserOIDC\MagentaBearer\MBackend; 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; -use OCA\UserOIDC\User\Backend; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -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,11 +53,16 @@ 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); /* Register our own user backend */ - $this->backend = $this->getContainer()->get(Backend::class); + $this->backend = $this->getContainer()->get(MBackend::class); $config = $this->getContainer()->get(IConfig::class); if (version_compare($config->getSystemValueString('version', '0.0.0'), '32.0.0', '>=')) { @@ -84,7 +92,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()) { @@ -93,6 +101,7 @@ public function boot(IBootContext $context): void { try { $context->injectFn(\Closure::fromCallable([$this, 'registerRedirect'])); + $context->injectFn(\Closure::fromCallable([$this, 'registerNmcClientFlow'])); if (version_compare($this->getContainer()->get(IConfig::class)->getSystemValueString('version', '0.0.0'), '34.0.0', '<')) { $context->injectFn(\Closure::fromCallable([$this, 'registerLogin'])); } @@ -104,6 +113,61 @@ private function checkLoginToken(TokenService $tokenService): void { $tokenService->checkLoginToken(); } + /** + * This is the automatic redirect exclusively for Nextcloud/Magentacloud clients, completely skipping consent layer. + */ + private function registerNmcClientFlow( + IRequest $request, + IURLGenerator $urlGenerator, + ProviderMapper $providerMapper, + \OCP\ISession $session, + \OCP\Security\ISecureRandom $random, + ): void { + $providers = $this->getCachedProviders($providerMapper); + + try { + $isClientLoginFlow = $request->getPathInfo() === '/login/flow'; + } catch (Exception) { + return; + } + + if (!$isClientLoginFlow) { + return; + } + + $tproviders = array_values(array_filter($providers, static function ($provider): bool { + return strtolower($provider->getIdentifier()) === 'telekom'; + })); + + if (count($tproviders) === 0) { + return; + } + + $stateToken = $random->generate( + 64, + \OCP\Security\ISecureRandom::CHAR_LOWER + . \OCP\Security\ISecureRandom::CHAR_UPPER + . \OCP\Security\ISecureRandom::CHAR_DIGITS + ); + + $session->set('client.flow.state.token', $stateToken); + + $redirectUrl = $urlGenerator->linkToRoute('core.ClientFlowLogin.grantPage', [ + 'stateToken' => $stateToken, + 'clientIdentifier' => $request->getParam('clientIdentifier', ''), + 'direct' => $request->getParam('direct', '0'), + ]); + + $targetUrl = $urlGenerator->linkToRoute(self::APP_ID . '.login.login', [ + 'providerId' => $tproviders[0]->getId(), + 'redirectUrl' => $redirectUrl, + ]); + + header('Location: ' . $targetUrl); + + exit(); + } + private function registerRedirect(IRequest $request, IURLGenerator $urlGenerator, SettingsService $settings, ProviderMapper $providerMapper): void { $redirectUrl = $request->getParam('redirect_url'); $absoluteRedirectUrl = !empty($redirectUrl) ? $urlGenerator->getAbsoluteURL($redirectUrl) : $redirectUrl; diff --git a/lib/Command/UpsertProvider.php b/lib/Command/UpsertProvider.php index f24575676..1d44cdc3e 100644 --- a/lib/Command/UpsertProvider.php +++ b/lib/Command/UpsertProvider.php @@ -185,6 +185,7 @@ protected function configure(): void { ->addOption('clientsecret-file', null, InputOption::VALUE_REQUIRED, 'File that contains the OpenID client secret') ->addOption('clientsecret-env', null, InputOption::VALUE_REQUIRED, 'Environment variable that contains the OpenID client secret') ->addOption('discoveryuri', 'd', InputOption::VALUE_REQUIRED, 'OpenID discovery endpoint uri') + ->addOption('bearersecret', 'bs', InputOption::VALUE_OPTIONAL, 'Telekom bearer token requires a different client secret for bearer tokens') ->addOption('endsessionendpointuri', 'e', InputOption::VALUE_REQUIRED, 'OpenID end session endpoint uri') ->addOption('postlogouturi', 'p', InputOption::VALUE_REQUIRED, 'Post logout URI') ->addOption('scope', 'o', InputOption::VALUE_OPTIONAL, 'OpenID requested value scopes, if not set defaults to "openid email profile"'); @@ -217,10 +218,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->listProviders($input, $output); } + // bearersecret is usually base64 encoded, + // but SAM delivers it non-encoded by default + // so always encode/decode for this field + $bearersecret = $input->getOption('bearersecret'); + if ($bearersecret !== null) { + $bearersecret = $this->crypto->encrypt($this->base64UrlEncode($bearersecret)); + } + // check if any option for updating is provided $updateOptions = array_filter($input->getOptions(), static function ($value, $option) { return in_array($option, [ - 'identifier', 'clientid', 'clientsecret', 'discoveryuri', 'endsessionendpointuri', 'postlogouturi', 'scope', + 'identifier', 'clientid', 'clientsecret', 'discoveryuri', 'endsessionendpointuri', 'postlogouturi', 'scope', 'bearersecret', ...array_keys(self::EXTRA_OPTIONS), ]) && $value !== null; }, ARRAY_FILTER_USE_BOTH); @@ -261,7 +270,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { $provider = $this->providerMapper->createOrUpdateProvider( - $identifier, $clientId, $clientSecret, $discoveryuri, $scope, $endsessionendpointuri, $postLogoutUri + $identifier, $clientId, $clientSecret, $discoveryuri, $scope, $endsessionendpointuri, $postLogoutUri, $bearersecret ); // invalidate JWKS cache (even if it was just created) $this->providerService->setSetting($provider->getId(), ProviderService::SETTING_JWKS_CACHE, ''); @@ -283,6 +292,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + private function base64UrlEncode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + private function listProviders(InputInterface $input, OutputInterface $output): int { $outputFormat = $input->getOption('output') ?? 'table'; $providers = $this->providerMapper->getProviders(); diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index abba810cc..c9ec9e339 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; @@ -370,6 +371,11 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); if ($error !== '') { + if (!$this->isMobileDevice()) { + $cancelRedirectUrl = $this->config->getSystemValue('user_oidc.cancel_redirect_url', 'https://cloud.telekom-dienste.de/'); + return new RedirectResponse($cancelRedirectUrl); + } + $this->logger->warning('Code login error', ['error' => $error, 'error_description' => $error_description]); if ($this->isDebugModeEnabled()) { return new JSONResponse([ @@ -654,8 +660,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 @@ -702,17 +721,6 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $userId, null, false)); } - $storeLoginTokenEnabled = $this->appConfig->getValueString(Application::APP_ID, 'store_login_token', '0', lazy: true) === '1'; - if ($storeLoginTokenEnabled) { - // store all token information for potential token exchange requests - $tokenData = array_merge( - $data, - ['provider_id' => $providerId], - ); - $this->tokenService->storeToken($tokenData); - } - $this->config->setUserValue($user->getUID(), Application::APP_ID, 'had_token_once', '1'); - // Set last password confirm to the future as we don't have passwords to confirm against with SSO $this->session->set('last-password-confirm', $this->timeFactory->getTime() + 4 * 365 * 24 * 3600); @@ -720,7 +728,7 @@ public function code(string $state = '', string $code = '', string $scope = '', try { $authToken = $this->authTokenProvider->getToken($this->session->getId()); $this->sessionMapper->createOrUpdateSession( - $idTokenPayload->sid ?? 'fallback-sid', + $idTokenPayload->{'urn:telekom.com:session_token'} ?? 'fallback-sid', $idTokenPayload->sub ?? 'fallback-sub', $idTokenPayload->iss ?? 'fallback-iss', $authToken->getId(), @@ -1070,7 +1078,7 @@ private function getBackchannelLogoutErrorResponse( 'error' => $error, 'error_description' => $description, ], - Http::STATUS_BAD_REQUEST, + Http::STATUS_OK, ); // Tell the Idp not to cache the response // Per RFC : https://openid.net/specs/openid-connect-backchannel-1_0.html#BCResponse @@ -1078,6 +1086,22 @@ private function getBackchannelLogoutErrorResponse( return $response; } + private function isMobileDevice(): bool { + $mobileKeywords = $this->config->getSystemValue('user_oidc.mobile_keywords', ['Android', 'iPhone', 'iPad', 'iPod', 'Windows Phone', 'Mobile', 'webOS', 'BlackBerry', 'Opera Mini', 'IEMobile']); + + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return false; // if no user-agent is set, assume desktop + } + + foreach ($mobileKeywords as $keyword) { + if (stripos($_SERVER['HTTP_USER_AGENT'], $keyword) !== false) { + return true; // device is mobile + } + } + + return false; // device is desktop + } + private function toCodeChallenge(string $data): string { // Basically one big work around for the base64url decode being weird $h = pack('H*', hash('sha256', $data)); @@ -1099,4 +1123,15 @@ private function cleanupSessionState(string $sessionKeySuffix): void { $this->session->remove(self::CODE_VERIFIER . $sessionKeySuffix); $this->session->remove(self::TIMESTAMP . $sessionKeySuffix); } + + /** + * Backward compatible function for MagentaCLOUD to smoothly transition to new config + * + * @PublicPage + * @NoCSRFRequired + * @BruteForceProtection(action: 'userOidcBackchannelLogout') + */ + public function telekomBackChannelLogout(string $logout_token = ''): JSONResponse { + return $this->backChannelLogout('Telekom', $logout_token); + } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index ce50f3b62..1aa28a79c 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -101,7 +101,7 @@ private function isDiscoveryEndpointValid($url) { */ #[PasswordConfirmationRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['user_oidc_settings'])] - public function createProvider(string $identifier, string $clientId, string $clientSecret, string $discoveryEndpoint, + public function createProvider(string $identifier, string $clientId, string $clientSecret, string $discoveryEndpoint, string $bearerSecret, array $settings = [], string $scope = 'openid email profile', ?string $endSessionEndpoint = null, ?string $postLogoutUri = null): DataResponse { if ($this->providerService->getProviderByIdentifier($identifier) !== null) { @@ -126,6 +126,8 @@ public function createProvider(string $identifier, string $clientId, string $cli $provider->setEndSessionEndpoint($endSessionEndpoint ?: null); $provider->setPostLogoutUri($postLogoutUri ?: null); $provider->setScope($scope); + $encryptedBearerSecret = $this->crypto->encrypt($this->base64UrlEncode($bearerSecret)); + $provider->setBearerSecret($encryptedBearerSecret); $provider = $this->providerMapper->insert($provider); $providerSettings = $this->providerService->setSettings($provider->getId(), $settings); @@ -153,7 +155,7 @@ public function createProvider(string $identifier, string $clientId, string $cli */ #[PasswordConfirmationRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['user_oidc_settings'])] - public function updateProvider(int $providerId, string $identifier, string $clientId, string $discoveryEndpoint, ?string $clientSecret = null, + public function updateProvider(int $providerId, string $identifier, string $clientId, string $discoveryEndpoint, ?string $clientSecret = null, ?string $bearerSecret = null, array $settings = [], string $scope = 'openid email profile', ?string $endSessionEndpoint = null, ?string $postLogoutUri = null): DataResponse { $provider = $this->providerMapper->getProvider($providerId); @@ -177,6 +179,10 @@ public function updateProvider(int $providerId, string $identifier, string $clie $encryptedClientSecret = $this->crypto->encrypt($clientSecret); $provider->setClientSecret($encryptedClientSecret); } + if ($bearerSecret) { + $encryptedBearerSecret = $this->crypto->encrypt($this->base64UrlEncode($bearerSecret)); + $provider->setBearerSecret($encryptedBearerSecret); + } $provider->setDiscoveryEndpoint($discoveryEndpoint); $provider->setEndSessionEndpoint($endSessionEndpoint ?: null); $provider->setPostLogoutUri($postLogoutUri ?: null); @@ -191,6 +197,10 @@ public function updateProvider(int $providerId, string $identifier, string $clie return new DataResponse(array_merge($provider->jsonSerialize(), ['settings' => $providerSettings])); } + private function base64UrlEncode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + /** * Delete a provider * diff --git a/lib/Db/Provider.php b/lib/Db/Provider.php index a08cd4ebe..23deaad50 100644 --- a/lib/Db/Provider.php +++ b/lib/Db/Provider.php @@ -23,6 +23,9 @@ * @method \void setEndSessionEndpoint(?string $endSessionEndpoint) * @method \string|\null getPostLogoutUri() * @method \void setPostLogoutUri(?string $postLogoutUri) + * @method \string|\null getBearerSecret() + * @method \void setBearerSecret(string $bearerSecret) + * @method \string|\null getScope() * @method \void setScope(string $scope) */ class Provider extends Entity implements \JsonSerializable { @@ -40,6 +43,8 @@ class Provider extends Entity implements \JsonSerializable { /** @var string */ protected $postLogoutUri; /** @var string */ + protected $bearerSecret; + /** @var string */ protected $scope; /** diff --git a/lib/Db/ProviderMapper.php b/lib/Db/ProviderMapper.php index ceb780885..1d02e60df 100644 --- a/lib/Db/ProviderMapper.php +++ b/lib/Db/ProviderMapper.php @@ -75,13 +75,14 @@ public function getProviders(): array { * @throws MultipleObjectsReturnedException */ public function createOrUpdateProvider( - string $identifier, + ?string $identifier = null, ?string $clientId = null, ?string $clientSecret = null, ?string $discoveryUri = null, string $scope = 'openid email profile', ?string $endSessionEndpointUri = null, ?string $postLogoutUri = null, + ?string $bearersecret = null, ): Provider { try { $provider = $this->findProviderByIdentifier($identifier); @@ -102,6 +103,9 @@ public function createOrUpdateProvider( if ($postLogoutUri !== null) { $provider->setPostLogoutUri($postLogoutUri); } + if ($bearersecret !== null) { + $provider->setBearerSecret($bearersecret); + } $provider->setScope($scope); return $this->update($provider); @@ -118,6 +122,7 @@ public function createOrUpdateProvider( $provider->setDiscoveryEndpoint($discoveryUri); $provider->setEndSessionEndpoint($endSessionEndpointUri); $provider->setPostLogoutUri($postLogoutUri); + $provider->setBearerSecret($bearersecret ?? ''); $provider->setScope($scope); return $this->insert($provider); diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php new file mode 100644 index 000000000..718e3296a --- /dev/null +++ b/lib/Event/UserAccountChangeEvent.php @@ -0,0 +1,59 @@ +result = new UserAccountChangeResult(); + } + + 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..1a8bb43ab --- /dev/null +++ b/lib/Event/UserAccountChangeResult.php @@ -0,0 +1,50 @@ +accessAllowed !== null; + } + + public function isAccessAllowed(): bool { + return $this->accessAllowed === true; + } + + 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/MagentaBearer/InvalidTokenException.php b/lib/MagentaBearer/InvalidTokenException.php new file mode 100644 index 000000000..af97581b7 --- /dev/null +++ b/lib/MagentaBearer/InvalidTokenException.php @@ -0,0 +1,8 @@ +request->getHeader(Application::OIDC_API_REQ_HEADER); + + return preg_match('/^\s*bearer\s+/i', $headerToken) === 1; + } + + public function getCurrentUserId(): string { + $headerToken = $this->request->getHeader(Application::OIDC_API_REQ_HEADER); + + if (preg_match('/^\s*bearer\s+/i', $headerToken) !== 1) { + $this->logger->debug('No Bearer token'); + return ''; + } + + $headerToken = preg_replace('/^\s*bearer\s+/i', '', $headerToken); + if (!is_string($headerToken) || $headerToken === '') { + $this->logger->debug('No Bearer token'); + return ''; + } + + $providers = $this->providerMapper->getProviders(); + if (count($providers) === 0) { + $this->logger->debug('No OIDC providers'); + return ''; + } + + foreach ($providers as $provider) { + if ($this->providerService->getSetting($provider->getId(), ProviderService::SETTING_CHECK_BEARER, '0') !== '1') { + continue; + } + + try { + $sharedSecret = $this->crypto->decrypt($provider->getBearerSecret()); + $bearerToken = $this->mtokenService->decryptToken($headerToken, $sharedSecret); + $this->mtokenService->verifySignature($bearerToken, $sharedSecret); + + $payload = $this->mtokenService->decode($bearerToken); + $this->mtokenService->verifyClaims($payload, ['http://auth.magentacloud.de']); + } catch (InvalidTokenException $e) { + $this->logger->debug('Invalid token: ' . $e->getMessage() . '. Trying another provider.'); + continue; + } catch (SignatureException $e) { + $this->logger->debug($e->getMessage() . '. Trying another provider.'); + continue; + } catch (\Throwable $e) { + $this->logger->debug('General non-matching provider problem: ' . $e->getMessage()); + continue; + } + + $uidAttribute = $this->providerService->getSetting($provider->getId(), ProviderService::SETTING_MAPPING_UID, 'sub'); + $userId = is_object($payload) ? ($payload->{$uidAttribute} ?? null) : null; + + if (!$this->isAcceptableUserId($userId)) { + $this->logger->debug('No extractable user id, check mapping!'); + return ''; + } + + try { + $provisioningResult = $this->provisioningService->provisionUser($userId, $provider->getId(), $payload); + $provisionedUser = $provisioningResult['user'] ?? null; + + if ($provisionedUser instanceof IUser) { + $userId = $provisionedUser->getUID(); + } + + $this->checkFirstLogin($userId); + + return $userId; + } catch (ProvisioningDeniedException $e) { + $this->logger->error('Bearer token access denied: ' . $e->getMessage()); + return ''; + } + } + + $this->logger->debug('Could not find provider for token'); + + return ''; + } +} diff --git a/lib/MagentaBearer/SignatureException.php b/lib/MagentaBearer/SignatureException.php new file mode 100644 index 000000000..ef04a4e0e --- /dev/null +++ b/lib/MagentaBearer/SignatureException.php @@ -0,0 +1,6 @@ +jweDecrypter = new JWEDecrypter( + $keyEncryptionAlgorithmManager, + $contentEncryptionAlgorithmManager, + $compressionMethodManager, + ); + + $this->encryptionSerializerManager = new JWESerializerManager([ + new JWECompactSerializer(), + ]); + + $this->jwsVerifier = new JWSVerifier($signatureAlgorithmManager); + + $this->serializerManager = new JWSSerializerManager([ + new JWSCompactSerializer(), + ]); + } + + public function decryptToken(string $rawToken, string $decryptKey): JWS { + $numSegments = substr_count($rawToken, '.') + 1; + $this->logger->debug('Bearer access token received', [ + 'segments' => $numSegments, + ]); + + $key = new JWK([ + 'kty' => 'oct', + 'k' => $decryptKey, + ]); + + if ($numSegments > 3) { + try { + $jwe = $this->encryptionSerializerManager->unserialize($rawToken); + } catch (\InvalidArgumentException $e) { + throw new InvalidTokenException('Invalid encrypted bearer token', 0, $e); + } + + if (!$this->jweDecrypter->decryptUsingKey($jwe, $key, 0)) { + throw new InvalidTokenException('Unknown bearer encryption format'); + } + + $payload = $jwe->getPayload(); + if ($payload === null || $payload === '') { + throw new InvalidTokenException('Empty decrypted bearer token payload'); + } + + return $this->serializerManager->unserialize($payload); + } + + try { + return $this->serializerManager->unserialize($rawToken); + } catch (\InvalidArgumentException $e) { + throw new InvalidTokenException('Invalid bearer token', 0, $e); + } + } + + public function decode(JWS $decodedToken): object { + $payload = $decodedToken->getPayload(); + if ($payload === null || $payload === '') { + throw new InvalidTokenException('Empty bearer token payload'); + } + + $samContent = json_decode($payload, false); + if (!is_object($samContent)) { + throw new InvalidTokenException('Invalid bearer token JSON payload'); + } + + $attributeName = 'urn:telekom.com:idm:at:attributes'; + if (isset($samContent->{$attributeName}) && is_iterable($samContent->{$attributeName})) { + foreach ($samContent->{$attributeName} as $claimKeyValue) { + if (isset($claimKeyValue->name, $claimKeyValue->value)) { + $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value; + } + } + + unset($samContent->{$attributeName}); + } + + $this->logger->debug('Adapted OpenID-like Telekom SAM3 access token'); + + return $samContent; + } + + public function verifySignature(JWS $decodedToken, string $signKey): void { + $key = new JWK([ + 'kty' => 'oct', + 'k' => $signKey, + ]); + + if (!$this->jwsVerifier->verifyWithKey($decodedToken, $key, 0)) { + throw new SignatureException('Invalid signature'); + } + } + + public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60): void { + $timestamp = $this->timeFactory->getTime(); + + if (isset($claims->nbf) && is_numeric($claims->nbf) && (int)$claims->nbf > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . date(\DateTimeInterface::ATOM, (int)$claims->nbf) + ); + } + + if (isset($claims->iat) && is_numeric($claims->iat) && (int)$claims->iat > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . date(\DateTimeInterface::ATOM, (int)$claims->iat) + ); + } + + if (isset($claims->exp) && is_numeric($claims->exp) && ($timestamp - $leeway) >= (int)$claims->exp) { + throw new InvalidTokenException('Expired token'); + } + + if ($audiences !== []) { + $tokenAudiences = $claims->aud ?? []; + if (is_string($tokenAudiences)) { + $tokenAudiences = [$tokenAudiences]; + } + + if (!is_array($tokenAudiences) || array_intersect($tokenAudiences, $audiences) === []) { + throw new InvalidTokenException('No acceptable audience in token.'); + } + } + } +} diff --git a/lib/Migration/Version00008Date20211114183344.php b/lib/Migration/Version00008Date20211114183344.php new file mode 100644 index 000000000..ba2cb904e --- /dev/null +++ b/lib/Migration/Version00008Date20211114183344.php @@ -0,0 +1,26 @@ +getTable('user_oidc_providers'); + $table->addColumn('bearer_secret', 'string', [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + + return $schema; + } +} diff --git a/lib/Migration/Version010304Date20230902125945.php b/lib/Migration/Version010304Date20230902125945.php new file mode 100644 index 000000000..4a49b88d8 --- /dev/null +++ b/lib/Migration/Version010304Date20230902125945.php @@ -0,0 +1,76 @@ +connection = $connection; + $this->crypto = $crypto; + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $tableName = 'user_oidc_providers'; + + if ($schema->hasTable($tableName)) { + $table = $schema->getTable($tableName); + if ($table->hasColumn('bearer_secret')) { + $column = $table->getColumn('bearer_secret'); + $column->setLength(512); + return $schema; + } + } + + return null; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) { + $tableName = 'user_oidc_providers'; + + // update secrets in user_oidc_providers and user_oidc_id4me + $qbUpdate = $this->connection->getQueryBuilder(); + $qbUpdate->update($tableName) + ->set('bearer_secret', $qbUpdate->createParameter('updateSecret')) + ->where( + $qbUpdate->expr()->eq('id', $qbUpdate->createParameter('updateId')) + ); + + $qbSelect = $this->connection->getQueryBuilder(); + $qbSelect->select('id', 'bearer_secret') + ->from($tableName); + $req = $qbSelect->executeQuery(); + while ($row = $req->fetch()) { + $id = $row['id']; + $secret = $row['bearer_secret']; + $encryptedSecret = $this->crypto->encrypt($secret); + $qbUpdate->setParameter('updateSecret', $encryptedSecret, IQueryBuilder::PARAM_STR); + $qbUpdate->setParameter('updateId', $id, IQueryBuilder::PARAM_INT); + $qbUpdate->executeStatement(); + } + $req->closeCursor(); + } +} 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..a3bf8d71d --- /dev/null +++ b/lib/Service/ProvisioningEventService.php @@ -0,0 +1,200 @@ +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, + ): UserAccountChangeResult { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + + $result = $event->getResult(); + + if ($result->hasDecision() && !$result->isAccessAllowed()) { + throw new ProvisioningDeniedException( + $result->getReason(), + $result->getRedirectUrl(), + ); + } + + return $result; + } + + /** + * 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->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), + ]; + } + + $this->logger->info($uid . ': account rejected, reason: ' . $userReaction->getReason()); + + throw new ProvisioningDeniedException( + $userReaction->getReason(), + $userReaction->getRedirectUrl(), + ); + } + + return parent::provisionUser($tokenUserId, $providerId, $idTokenPayload, $existingLocalUser); + } +} diff --git a/lib/User/AbstractOidcBackend.php b/lib/User/AbstractOidcBackend.php new file mode 100644 index 000000000..dad8e9614 --- /dev/null +++ b/lib/User/AbstractOidcBackend.php @@ -0,0 +1,192 @@ +userMapper->countUsers(); + + if ($limit > 0 && $count > $limit) { + return $limit; + } + + return $count; + } catch (\Throwable $e) { + $this->logger->error('Failed to count OIDC users', [ + 'exception' => $e, + ]); + + return false; + } + } + + public function deleteUser($uid): bool { + if (!is_string($uid) || $uid === '') { + return false; + } + + try { + $user = $this->userMapper->getUser($uid); + $this->userMapper->delete($user); + return true; + } catch (DoesNotExistException $e) { + $this->logger->info('Tried to delete non-existent user', [ + 'uid' => $uid, + 'exception' => $e, + ]); + return false; + } catch (Exception $e) { + $this->logger->error('Failed to delete user', [ + 'uid' => $uid, + 'exception' => $e, + ]); + return false; + } + } + + public function getUsers($search = '', $limit = null, $offset = null): array { + if (!is_string($search) + || ($limit !== null && !is_int($limit)) + || ($offset !== null && !is_int($offset)) + ) { + return []; + } + + return array_map( + static fn ($user) => $user->getUserId(), + $this->userMapper->find($search, $limit, $offset) + ); + } + + public function userExists($uid): bool { + return is_string($uid) && $uid !== '' && $this->userMapper->userExists($uid); + } + + public function getDisplayName($uid): string { + if (!is_string($uid) || $uid === '') { + return (string)$uid; + } + + try { + $user = $this->userMapper->getUser($uid); + return $user->getDisplayName(); + } catch (DoesNotExistException) { + return $uid; + } + } + + public function getDisplayNames($search = '', $limit = null, $offset = null): array { + if (!is_string($search) + || ($limit !== null && !is_int($limit)) + || ($offset !== null && !is_int($offset)) + ) { + return []; + } + + return $this->userMapper->findDisplayNames($search, $limit, $offset); + } + + public function hasUserListings(): bool { + return true; + } + + public function canConfirmPassword(string $uid): bool { + return false; + } + + public function injectSession(ISession $session): void { + $this->session = $session; + } + + public function getLogoutUrl(): string { + return $this->urlGenerator->linkToRouteAbsolute('user_oidc.login.singleLogoutService'); + } + + protected function isAcceptableUserId(mixed $userId): bool { + return is_string($userId) && trim($userId) !== ''; + } + + protected function checkFirstLogin(string $userId): bool { + $user = $this->userManager->get($userId); + if ($user === null) { + return false; + } + + $firstLogin = $user->getLastLogin() === 0; + + if ($firstLogin) { + try { + if (version_compare($this->config->getSystemValueString('version', '0.0.0'), '34.0.0', '>=') + && interface_exists(ISetupManager::class) + ) { + Server::get(ISetupManager::class)->setupForUser($user); + } else { + \OC_Util::setupFS($userId); + } + + $userFolder = Server::get(IRootFolder::class)->getUserFolder($userId); + \OC_Util::copySkeleton($userId, $userFolder); + } catch (\Throwable $e) { + $this->logger->warning('Could not fully set up user filesystem on first login', [ + 'userId' => $userId, + 'exception' => $e, + ]); + } + + if (class_exists(UserFirstTimeLoggedInEvent::class)) { + $this->eventDispatcher->dispatchTyped(new UserFirstTimeLoggedInEvent($user)); + } + } + + $user->updateLastLoginTimestamp(); + + return $firstLogin; + } +} diff --git a/src/components/SettingsForm.vue b/src/components/SettingsForm.vue index 8abdab669..c6a70ed6a 100644 --- a/src/components/SettingsForm.vue +++ b/src/components/SettingsForm.vue @@ -32,6 +32,15 @@ :required="!update" autocomplete="off">

+

+ + +

{{ t('user_oidc', 'Warning, if the protocol of the URLs in the discovery content is HTTP, the ID token will be delivered through an insecure connection.') }} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 942fe8465..67398610e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,6 +16,26 @@ require_once __DIR__ . '/../../../lib/base.php'; require_once __DIR__ . '/../../../tests/autoload.php'; -require_once __DIR__ . '/../vendor/autoload.php'; + +/** + * Register composer autoloader once + */ +$composerAutoloader = require __DIR__ . '/../vendor/autoload.php'; + +/** + * Register test namespace via Composer autoload + */ +$composerAutoloader->addPsr4( + 'OCA\\UserOIDC\\BaseTest\\', + __DIR__ . '/unit/MagentaCloud/', + true +); Server::get(IAppManager::class)->loadApp('user_oidc'); + +/** + * Cleanup hooks to ensure test isolation + */ +if (class_exists(\OC_Hook::class)) { + \OC_Hook::clear(); +} diff --git "a/tests/unit/MagentaCloud/BearerSettingsTest.php\342\200\216" "b/tests/unit/MagentaCloud/BearerSettingsTest.php\342\200\216" new file mode 100644 index 000000000..bee3d735f --- /dev/null +++ "b/tests/unit/MagentaCloud/BearerSettingsTest.php\342\200\216" @@ -0,0 +1,393 @@ +requestMock = $this->createMock(IRequest::class); + + $this->config = $this->createMock(IConfig::class); + $this->providerMapper = $this->createMock(ProviderMapper::class); + $providers = [ + new \OCA\UserOIDC\Db\Provider(), + ]; + $providers[0]->setId(1); + $providers[0]->setIdentifier('Fraesbook'); + + $this->providerMapper->expects(self::any()) + ->method('getProviders') + ->willReturn($providers); + + $this->providerService = $this->getMockBuilder(ProviderService::class) + ->setConstructorArgs([ $this->config, $this->providerMapper]) + ->onlyMethods(['getProviderByIdentifier']) + ->getMock(); + $this->crypto = $app->getContainer()->get(ICrypto::class); + } + + protected function mockCreateUpdate( + string $providername, + ?string $clientid, + ?string $clientsecret, + ?string $discovery, + string $scope, + ?string $bearersecret, + array $options, + int $id = 2, + ) { + $provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getIdentifier', 'getId']) + ->getMock(); + $provider->expects($this->any()) + ->method('getIdentifier') + ->willReturn($providername); + $provider->expects($this->any()) + ->method('getId') + ->willReturn($id); + + $this->providerMapper->expects($this->once()) + ->method('createOrUpdateProvider') + ->with( + $this->equalTo($providername), + $this->equalTo($clientid), + $this->anything(), + $this->equalTo($discovery), + $this->equalTo($scope), + $this->anything() + ) + ->willReturnCallback(function ($id, $clientid, $secret, $discovery, $scope, $bsecret) use ($clientsecret, $bearersecret, $provider) { + if ($secret !== null) { + $this->assertEquals($clientsecret, $this->crypto->decrypt($secret)); + } else { + $this->assertNull($secret); + } + if ($bsecret !== null) { + $this->assertEquals($bearersecret, \Base64Url\Base64Url::decode($this->crypto->decrypt($bsecret))); + } else { + $this->assertNull($bsecret); + } + return $provider; + }); + + + $this->config->expects($this->any()) + ->method('setAppValue') + ->with($this->equalTo(Application::APP_ID), $this->anything(), $this->anything()) + ->willReturnCallback(function ($appid, $key, $value) use ($options) { + if (array_key_exists($key, $options)) { + $this->assertEquals($options[$key], $value); + } + return ''; + }); + } + + + public function testCommandAddProvider() { + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn(null); + + $this->mockCreateUpdate('Telekom', + '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', + 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', + 'bearersecret***', + [ + 'provider-2-' . ProviderService::SETTING_UNIQUE_UID => '0', + 'provider-2-' . ProviderService::SETTING_MAPPING_DISPLAYNAME => 'urn:telekom.com:displayname', + 'provider-2-' . ProviderService::SETTING_MAPPING_EMAIL => 'urn:telekom.com:mainEmail', + 'provider-2-' . ProviderService::SETTING_MAPPING_QUOTA => 'quota', + 'provider-2-' . ProviderService::SETTING_MAPPING_UID => 'sub' + ]); + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--clientid' => '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', + '--clientsecret' => 'clientsecret***', + '--bearersecret' => 'bearersecret***', + '--discoveryuri' => 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + '--scope' => 'openid email profile', + '--unique-uid' => '0', + '--mapping-display-name' => 'urn:telekom.com:displayname', + '--mapping-email' => 'urn:telekom.com:mainEmail', + '--mapping-quota' => 'quota', + '--mapping-uid' => 'sub', + ]); + + + //$output = $commandTester->getOutput(); + //$this->assertContains('done', $output); + } + + protected function mockProvider(string $providername, + string $clientid, + string $clientsecret, + string $discovery, + string $scope, + string $bearersecret, + int $id = 2) : Provider { + $provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getIdentifier', 'getClientId', 'getClientSecret', 'getBearerSecret', 'getDiscoveryEndpoint']) + ->setMethods(['getScope', 'getId']) + ->getMock(); + $provider->expects($this->any()) + ->method('getIdentifier') + ->willReturn($providername); + $provider->expects($this->any()) + ->method('getId') + ->willReturn(2); + $provider->expects($this->any()) + ->method('getClientId') + ->willReturn($clientid); + $provider->expects($this->any()) + ->method('getClientSecret') + ->willReturn($clientsecret); + $provider->expects($this->any()) + ->method('getBearerSecret') + ->willReturn(\Base64Url\Base64Url::encode($bearersecret)); + $provider->expects($this->any()) + ->method('getDiscoveryEndpoint') + ->willReturn($discovery); + $provider->expects($this->any()) + ->method('getScope') + ->willReturn($scope); + + return $provider; + } + + public function testCommandUpdateFull() { + $provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getIdentifier', 'getClientId', 'getClientSecret', 'getBearerSecret', 'getDiscoveryEndpoint']) + ->setMethods(['getScope']) + ->getMock(); + $provider->expects($this->any()) + ->method('getIdentifier') + ->willReturn('Telekom'); + $provider->expects($this->never())->method('getClientId'); + $provider->expects($this->never())->method('getClientSecret'); + $provider->expects($this->never())->method('getBearerSecret'); + $provider->expects($this->never())->method('getDiscoveryEndpoint'); + $provider->expects($this->never())->method('getScope'); + + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn(null); + $this->mockCreateUpdate('Telekom', + '10TVL0SAM30000004902NEXTMAGENTACLOUDTEST', + 'client*secret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-unknown/openid-configuration', + 'openid profile', + 'bearer*secret***', + [ + 'provider-2-' . ProviderService::SETTING_UNIQUE_UID => '1', + 'provider-2-' . ProviderService::SETTING_MAPPING_DISPLAYNAME => 'urn:telekom.com:displaykrame', + 'provider-2-' . ProviderService::SETTING_MAPPING_EMAIL => 'urn:telekom.com:mainDemail', + 'provider-2-' . ProviderService::SETTING_MAPPING_QUOTA => 'quotas', + 'provider-2-' . ProviderService::SETTING_MAPPING_UID => 'flop' + ]); + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'identifier' => 'Telekom', + '--clientid' => '10TVL0SAM30000004902NEXTMAGENTACLOUDTEST', + '--clientsecret' => 'client*secret***', + '--bearersecret' => 'bearer*secret***', + '--discoveryuri' => 'https://accounts.login00.idm.ver.sul.t-online.de/.well-unknown/openid-configuration', + '--scope' => 'openid profile', + '--mapping-display-name' => 'urn:telekom.com:displaykrame', + '--mapping-email' => 'urn:telekom.com:mainDemail', + '--mapping-quota' => 'quotas', + '--mapping-uid' => 'flop', + '--unique-uid' => '1' + ]); + } + + public function testCommandUpdateSingleClientId() { + $provider = $this->mockProvider('Telekom', '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', 'bearersecret***'); + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn($provider); + $this->mockCreateUpdate( + 'Telekom', + '10TVL0SAM30000004903NEXTMAGENTACLOUDTEST', + null, + null, + 'openid email profile', + null, + []); + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--clientid' => '10TVL0SAM30000004903NEXTMAGENTACLOUDTEST', + ]); + } + + + public function testCommandUpdateSingleClientSecret() { + $provider = $this->mockProvider('Telekom', '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', 'bearersecret***'); + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn($provider); + $this->mockCreateUpdate( + 'Telekom', + null, + '***clientsecret***', + null, + 'openid email profile', + null, + []); + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--clientsecret' => '***clientsecret***', + ]); + } + + public function testCommandUpdateSingleBearerSecret() { + $provider = $this->mockProvider('Telekom', '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', 'bearersecret***'); + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn($provider); + $this->mockCreateUpdate( + 'Telekom', + null, + null, + null, + 'openid email profile', + '***bearersecret***', + []); + + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--bearersecret' => '***bearersecret***', + ]); + } + + public function testCommandUpdateSingleDiscoveryEndpoint() { + $provider = $this->mockProvider('Telekom', '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', 'bearersecret***'); + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn($provider); + $this->mockCreateUpdate( + 'Telekom', + null, + null, + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-unknown/openid-configuration', + 'openid email profile', + null, []); + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--discoveryuri' => 'https://accounts.login00.idm.ver.sul.t-online.de/.well-unknown/openid-configuration', + ]); + } + + public function testCommandUpdateSingleScope() { + $provider = $this->mockProvider('Telekom', '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', 'bearersecret***'); + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn($provider); + $this->mockCreateUpdate( + 'Telekom', + null, + null, + null, + 'openid profile', + '***bearersecret***', + []); + + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--scope' => 'openid profile', + ]); + } + + public function testCommandUpdateSingleUniqueUid() { + $provider = $this->mockProvider('Telekom', '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST', 'clientsecret***', + 'https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration', + 'openid email profile', 'bearersecret***'); + $this->providerService->expects($this->once()) + ->method('getProviderByIdentifier') + ->with($this->equalTo('Telekom')) + ->willReturn($provider); + $this->mockCreateUpdate( + 'Telekom', + null, + null, + null, + 'openid email profile', + null, + ['provider-2-' . ProviderService::SETTING_UNIQUE_UID => '1']); + + $command = new UpsertProvider($this->providerService, $this->providerMapper, $this->crypto); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + 'identifier' => 'Telekom', + '--unique-uid' => '1', + ]); + } +} \ No newline at end of file diff --git a/tests/unit/MagentaCloud/BearerTokenServiceTest.php b/tests/unit/MagentaCloud/BearerTokenServiceTest.php new file mode 100644 index 000000000..b559593f0 --- /dev/null +++ b/tests/unit/MagentaCloud/BearerTokenServiceTest.php @@ -0,0 +1,72 @@ +tokenService = \OC::$server->get(TokenService::class); + $this->accessSecret = Base64UrlSafe::encodeUnpadded('JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'); + } + + public function testDecodeAndValidSignature(): void { + $decodedToken = $this->tokenService->decryptToken(self::EXPIRED_TOKEN, $this->accessSecret); + + $this->tokenService->verifySignature($decodedToken, $this->accessSecret); + $claims = $this->tokenService->decode($decodedToken); + + $this->assertNotNull($claims->exp); + $this->assertNotNull($claims->aud); + } + + private function decryptDecodeAndValidate(string $testToken): object { + $decodedToken = $this->tokenService->decryptToken($testToken, $this->accessSecret); + + $this->tokenService->verifySignature($decodedToken, $this->accessSecret); + $claims = $this->tokenService->decode($decodedToken); + + $this->assertNotNull($claims->exp); + $this->assertNotNull($claims->aud); + + return $claims; + } + + public function testDecryptDecodeAndValidSignature1(): void { + $claims = $this->decryptDecodeAndValidate(self::ENCRYPT1_SIGN_TOKEN); + + $this->assertEquals( + '10TESTSAM30000004901VOLKERKRIEGEL0000000', + $claims->{'urn:telekom.com:client_id'}, + ); + } + + public function testDecryptDecodeAndValidSignature2(): void { + $this->decryptDecodeAndValidate(self::ENCRYPT2_SIGN_TOKEN); + } + + public function testDecodeAndInvalidSignature(): void { + $this->expectException(SignatureException::class); + + $decodedToken = $this->tokenService->decryptToken(self::INVALID_SIGN_TOKEN, $this->accessSecret); + $this->tokenService->verifySignature($decodedToken, $this->accessSecret); + } +} diff --git a/tests/unit/MagentaCloud/BearerTokenTestCase.php b/tests/unit/MagentaCloud/BearerTokenTestCase.php new file mode 100644 index 000000000..7ee2d19c7 --- /dev/null +++ b/tests/unit/MagentaCloud/BearerTokenTestCase.php @@ -0,0 +1,202 @@ + */ + private array $realExampleClaims = []; + + /** @return array */ + public function getRealExampleClaims(): array { + return $this->realExampleClaims; + } + + public function getTestBearerSecret(): string { + return Base64UrlSafe::encodeUnpadded('JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'); + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->tokenService = $this->app->getContainer()->get(TokenService::class); + + $now = time(); + + $this->realExampleClaims = [ + 'iss' => 'sts00.idm.ver.sul.t-online.de', + 'urn:telekom.com:idm:at:subjectType' => [ + 'format' => 'urn:com:telekom:idm:1.0:nameid-format:anid', + 'realm' => 'ver.sul.t-online.de', + ], + 'acr' => 'urn:telekom:names:idm:THO:1.0:ac:classes:pwd', + 'sub' => '1200490100000000100XXXXX', + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + 7200, + 'urn:telekom.com:idm:at:authNStatements' => [ + 'urn:telekom:names:idm:THO:1.0:ac:classes:pwd' => [ + 'authenticatingAuthority' => null, + 'authNInstant' => $now, + ], + ], + 'aud' => ['http://auth.magentacloud.de'], + 'jti' => 'STS-1e22a06f-790c-40fb-ad1d-6de2ddcf2431', + 'urn:telekom.com:idm:at:attributes' => [ + [ 'name' => 'client_id', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '10TVL0SAM30000004901NEXTMAGENTACLOUDTEST'], + [ 'name' => 'displayname', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => 'nmc01@ver.sul.t-online.de'], + [ 'name' => 'email', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => 'nmc01@ver.sul.t-online.de'], + [ 'name' => 'anid', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '1200490100000000100XXXXX'], + [ 'name' => 'd556', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'domt', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => 'ver.sul.t-online.de'], + [ 'name' => 'f048', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '1'], + [ 'name' => 'f049', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '1'], + [ 'name' => 'f051', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'f460', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'f467', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'f468', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'f469', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'f471', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'f556', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '1'], + [ 'name' => 'f734', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'mainEmail', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => 'nmc01@ver.sul.t-online.de'], + [ 'name' => 's556', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '0'], + [ 'name' => 'usta', + 'nameFormat' => 'urn:com:telekom:idm:1.0:attrname-format:field', + 'value' => '1'] + ], + 'urn:telekom.com:idm:at:version' => '1.0', + ]; + } + + protected function signToken(array $claims, string $signKey, bool $invalidate = false): JWS { + $algorithmManager = new AlgorithmManager([ + new HS256(), + ]); + + $jwk = new JWK([ + 'kty' => 'oct', + 'k' => $invalidate + ? Base64UrlSafe::encodeUnpadded('JQ17C99A-DAF8-4E27-FBW4-GV23B043C994') + : $signKey, + ]); + + return (new JWSBuilder($algorithmManager)) + ->create() + ->withPayload((string)json_encode($claims)) + ->addSignature($jwk, ['alg' => 'HS256']) + ->build(); + } + + protected function setupSignedToken(array $claims, string $signKey): string { + return (new JWSCompactSerializer())->serialize($this->signToken($claims, $signKey), 0); + } + + protected function setupEncryptedToken(JWS $token, string $decryptKey): string { + $keyEncryptionAlgorithmManager = new AlgorithmManager([ + new PBES2HS512A256KW(), + new RSAOAEP256(), + new ECDHESA256KW(), + ]); + + $contentEncryptionAlgorithmManager = new AlgorithmManager([ + new A256CBCHS512(), + ]); + + $compressionMethodManager = new CompressionMethodManager([ + new Deflate(), + ]); + + $jwk = new JWK([ + 'kty' => 'oct', + 'k' => $decryptKey, + ]); + + $jwe = (new JWEBuilder( + $keyEncryptionAlgorithmManager, + $contentEncryptionAlgorithmManager, + $compressionMethodManager, + )) + ->create() + ->withPayload((new JWSCompactSerializer())->serialize($token, 0)) + ->withSharedProtectedHeader([ + 'alg' => 'PBES2-HS512+A256KW', + 'enc' => 'A256CBC-HS512', + 'zip' => 'DEF', + ]) + ->addRecipient($jwk) + ->build(); + + return (new JWECompactSerializer())->serialize($jwe, 0); + } + + protected function setupSignEncryptToken(array $claims, string $secret, bool $invalidate = false): string { + return $this->setupEncryptedToken($this->signToken($claims, $secret, $invalidate), $secret); + } +} diff --git a/tests/unit/MagentaCloud/HeaderBearerTokenTest.php b/tests/unit/MagentaCloud/HeaderBearerTokenTest.php new file mode 100644 index 000000000..3bb18bf88 --- /dev/null +++ b/tests/unit/MagentaCloud/HeaderBearerTokenTest.php @@ -0,0 +1,234 @@ +requestMock = $this->createMock(IRequest::class); + + $this->config = $this->createMock(IConfig::class); + $this->config->expects(self::any()) + ->method('getAppValue') + ->willReturnMap([ + [Application::APP_ID, 'provider-2-' . ProviderService::SETTING_MAPPING_UID, 'sub', 'uid'], + [Application::APP_ID, 'provider-2-' . ProviderService::SETTING_MAPPING_DISPLAYNAME, 'urn:telekom.com:displayname', 'dn'], + [Application::APP_ID, 'provider-2-' . ProviderService::SETTING_MAPPING_EMAIL, 'urn:telekom.com:mainEmail', 'mail'], + [Application::APP_ID, 'provider-2-' . ProviderService::SETTING_MAPPING_QUOTA, 'quota', '1g'], + [Application::APP_ID, 'provider-2-' . ProviderService::SETTING_UNIQUE_UID, '0', '0'], + ]); + + $crypto = $app->getContainer()->get(ICrypto::class); + + $this->b64BearerToken = $this->getTestBearerSecret(); + $encryptedB64BearerToken = $crypto->encrypt($this->b64BearerToken); + + $this->providerMapper = $this->createMock(ProviderMapper::class); + + $provider1 = $this->getMockBuilder(Provider::class) + ->addMethods([ + 'getId', + 'getIdentifier', + 'getClientId', + 'getClientSecret', + 'getBearerSecret', + ]) + ->getMock(); + + $provider1->expects(self::any())->method('getId')->willReturn(1); + $provider1->expects(self::any())->method('getIdentifier')->willReturn('Fraesbook'); + $provider1->expects(self::any())->method('getClientId')->willReturn('FraesRein1'); + $provider1->expects(self::any())->method('getClientSecret')->willReturn('client****'); + $provider1->expects(self::any())->method('getBearerSecret')->willReturn('xx***'); + + $provider2 = $this->getMockBuilder(Provider::class) + ->addMethods([ + 'getId', + 'getIdentifier', + 'getClientId', + 'getClientSecret', + 'getBearerSecret', + 'getDiscoveryEndpoint', + ]) + ->getMock(); + + $provider2->expects(self::any())->method('getId')->willReturn(2); + $provider2->expects(self::any())->method('getIdentifier')->willReturn('Telekom'); + $provider2->expects(self::any())->method('getClientId')->willReturn('10TVL0SAM30000004901NEXTMAGENTACLOUDTEST'); + $provider2->expects(self::any())->method('getClientSecret')->willReturn('client****'); + $provider2->expects(self::any())->method('getBearerSecret')->willReturn($encryptedB64BearerToken); + $provider2->expects(self::any())->method('getDiscoveryEndpoint')->willReturn('https://accounts.login00.idm.ver.sul.t-online.de/.well-known/openid-configuration'); + + $this->providerMapper->expects(self::any()) + ->method('getProviders') + ->willReturn([$provider1, $provider2]); + + $this->providerService = $this->createMock(ProviderService::class); + $this->providerService->expects(self::any()) + ->method('getSetting') + ->willReturnCallback(static function (int $id, string $field, string $default): string { + if ($field === ProviderService::SETTING_MAPPING_UID) { + return 'sub'; + } + + if ($field === ProviderService::SETTING_CHECK_BEARER) { + return '1'; + } + + return $default; + }); + + $user = $this->createMock(IUser::class); + $user->expects(self::any()) + ->method('getUID') + ->willReturn('1200490100000000100XXXXX'); + $user->expects(self::any()) + ->method('getDisplayName') + ->willReturn('nmc01'); + $user->expects(self::any()) + ->method('getEMailAddress') + ->willReturn('nmc01@ver.sul.t-online.de'); + + $userManager = $this->createMock(IUserManager::class); + $userManager->expects(self::any()) + ->method('get') + ->willReturn($user); + + $provisioningService = $this->createMock(ProvisioningEventService::class); + $provisioningService->expects(self::any()) + ->method('provisionUser') + ->willReturn([ + 'user' => $user, + 'userData' => [], + ]); + + $this->backend = new MBackend( + $this->config, + $app->getContainer()->get(UserMapper::class), + $app->getContainer()->get(LoggerInterface::class), + $this->requestMock, + $app->getContainer()->get(ISession::class), + $app->getContainer()->get(IURLGenerator::class), + $app->getContainer()->get(IEventDispatcher::class), + $this->createMock(DiscoveryService::class), + $this->providerMapper, + $this->providerService, + $userManager, + $crypto, + $app->getContainer()->get(TokenService::class), + $provisioningService, + ); + } + + public function testValidSignature(): void { + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->b64BearerToken); + + $this->requestMock->expects(self::any()) + ->method('getHeader') + ->with(self::equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $testtoken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('1200490100000000100XXXXX', $this->backend->getCurrentUserId()); + } + + public function testInvalidSignature(): void { + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->b64BearerToken); + $invalidSignToken = mb_substr($testtoken, 0, -1); + + $this->requestMock->expects(self::any()) + ->method('getHeader') + ->with(self::equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $invalidSignToken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } + + public function testEncryptedValidSignature(): void { + $testtoken = $this->setupSignEncryptToken($this->getRealExampleClaims(), $this->b64BearerToken); + + $this->requestMock->expects(self::any()) + ->method('getHeader') + ->with(self::equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $testtoken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('1200490100000000100XXXXX', $this->backend->getCurrentUserId()); + } + + public function testEncryptedInvalidSignature(): void { + $invalidEncToken = $this->setupSignEncryptToken( + $this->getRealExampleClaims(), + $this->b64BearerToken, + true, + ); + + $this->requestMock->expects(self::any()) + ->method('getHeader') + ->with(self::equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $invalidEncToken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } + + public const ENCRYPT1_SIGN_TOKEN = 'eyJwMnMiOiI4VzhYY21iaHJPSSIsInAyYyI6MTAwMCwiY3R5IjoiSldUIiwiZW5jIjoiQTI1NkNCQy1IUzUxMiIsImFsZyI6IlBCRVMyLUhTNTEyK0EyNTZLVyJ9.5bA_ctLbQOnMojJW3MPo83AIvCAu3MpmaaD7j2GzqBv5_-D4w69ONqcPEsc6LYMG9B-rw3HDXng4Mqye4KqpW70ECpf9HXV6.6zl4Zqp4wbcO_AqqmpA3sQ.y7dHcwxXveYkuh4UaqHhE4nvP_avZsxaf7aAbnJdDHHKbBKvEKKqHkPg593i14ypWuRHd2i9Opsuyppfxx9Hw7C7N7LJ8UCTYMihHqlJkHecB08xgJ3ciE0L2Qtvg9hfxQbHNVV4p1_KL3ubAXt9ovwDCOJvN6PXyixUDtYYF1D_Km7Ze1ptUNbwS2H4vf-MKHwwrm5uhTvXOppGNO-0tYnIMOZ8BkiTtrrlO6IQbRcC4EMw74PzbFsQXY9u1xsNZ9IOrzbBl_EyPBLr5ool1BGlvNog4XFsHLgxUa5cjIcZVRMgZSLWdToTiXYFAWdO6fbQrRWT8ERRDWjiDxJEaPlfI_61G5NzJN2NKnSAY7fR8i3Rfs_JoF1TtpR5dGU28Lk1vcLjKYBLqp2hjW97QsANVgmalkkJMUpiAvNN48ZSCK9T3vTfiH7unFRNWvTKvZXyHIkYQPZ0-b3Z9s5oLMx93Snvcq9jQVKA1dWU_bEUIOnwP65ADU_FIkYB8gsZXp5Za3HrK63u03Lij6rwkJpEPbwcnxhBkMhtKOOwQVZm1ZBf_lVyn39MFXmLN_gDD052vFpxl1NnG0KEg8XJQ_usE9e64q7W6IG4gRm9NYG6rdeik6Dm45K8fA4oUiyjdgHjveR6GW8uXQR-tWXf3IC-_2jws2PJ31acdoEbDU30XlVeCqENW-ylPJ10rP28XxboQVJMRrzMiEzu39IH3c02czHh81U09TREVsO2S8CCQcahboaplDg9kpr1UZpsRrjg40bEtdm2cKubTbczGiXiF7sI0qE-kHm0aiK5c6mO8fHETMCmvh2vhxcYo_T6q7VklbwiZVbn47z-oriEDyPlLrB_PzYR6fNRbtObttj0CHRgf-NI69RU2pAGxujSi2lEhNkG-CAFNfASKm8uSUCg8UPr7v38c5vr4IuYC1gYjxgebXIh0EFX4G8jZM6ljPSzmMFDyErWJQ5OrtJjuKrUa96Yp3oOZTemtCwc--mrDXmpwVlaBMCuuJDz6zucxwSeVK0mP0t56zHeK59jxz0OfV62TrcVeZaLqSl3o-pVsY5KrLxL1qf2QIry-uy_c1zi9AuZnSbH3t1RvmyG5-QIh5WSPOLXG9ivuHKAdQTvBnchXWfkUVkoPYuPFyBydlPAhpRQyBLHboqdT6lIdoQ5lBRI8vsGb9wQVSQx08hbpEFOPMe-SJqzjZp36sUurJrgj_ethbIWkTSe_HPkcvBv8X0kyvhnyTKYJoroE5HDM0dtgFW8xK8NmOZOuREzJW5fpqzJML8iY0p1IX3bvGrCeVMEJtM0T6KSJFdPHBAzkWNNMBUc2jhuxa6B2cSaMz60bwSCw8n5NWz8wkXUFJJkHKEnK8tFbtOQXHeGG48k7Wl6kgrQkAFAHZqQt9gRDdmGcYAYHVK7cESjABV9LWQIQYy0eyveU0sWE5sYXKCwsk8rLiKt5GmZlRQ0rOltuFXRTu_EZYuqR0DCRXrjQWVN1zLTy0LMqAvDR-PJcFtekbT9CXLEW6M6GHzJhYfNyMc_cPitG8QwS5EWGzJjQIiNsJBRyV7cPlHeMhKzDtEk3DR3l-qQJa9-54RQB-kStJjB0AAZ21ku7eBS6orT0lljj935eghlHxAzyr1fvlDjIpHc--ob_7DOPc9sBGqcwdYoZ28zD1d02rpJujOwTe4zgll4vffJ_aFP8hm19pmroCwFsZPWIK6GN_cllJaxnllkJ_9c-7eBj1rKkNX0DLyNwKoMYttugeQFWAxaaqWhoOpQXnRHaVt5hTzoexi5C2j_aVBUAzyMPZtvuYgY1uc8zeKt5X8rAy3Y7WqYeOy8Q6IezVyTE6p0kzYgzUT1Vg2XZEr7dBgNkv8ySfYQNG5d8_PtvBHX-SOy25rtes7oUHHgZx0AkpomhNGSwfrW4dyIWCa6j5qUexqs3TPip_FAJwdW38OnyfPQ5SHLTt8D6OCOLN70MdbPpeoFkGnx1oj1Xjx_UW8mtueWAkxidv6Lamf_D5j8sJvkksne8Nos2YvGNkaGZwQK8YfjvPP-VVdukLMqoloovOuvgxLVLSvnDYcRRjfwAdiKwFNGdMbdV5LwfAzVAlncyWPJso3Lk9fPYd88YW8e6o7xiboiushcbDQU0ZN_Zh9YGk-8R4VnvAuI3yWxLrBB8NFUwKYkNBupVWrxRHJbJEebsLv9r_PZstBHHfMFpcQYX05NYfQiezhQ9l-aseC9Ay4FLbcxyXkIiPEBfiwZESqQbYoL3OeBQYzsV8AFe4GVdUUwPCuPjKR52UlkPiUJthxGkLFfcEPbqfX_lByN5YZRMSruOt6yKysbBIw0gcC6n7wuA_URaFNSPfyHe6nqAtveh1YjZpwZszAERyk2ziFXKFYFppdjMPvxF37uWoH_BEpv9Bs7yaxPRK7pfniS105RBsDFS093-3sUYM6W7IrmPfKAe71OtdWtQQqQKOAX3WGFShCIKyz-aOJWJPRG35Q2DOGu0nehFetGVsSnt-ehmru-Zuv4IanlF0_3SjQ7l7l6gg3Sfyy6sN8SVvxTtw4jLkaAM6cpmVMQVP8uQeJ9IFSHyq1kFceQcguh5tbwMknJzcMNzmZ9zEOG4ifyk9zmeulX9Rtf3lIXIOU-1lEs5bVm42eg1IKpxaY8PeTrT4qvPIyVkOprpKGIAcGyD0tP11vvDCvbltEWBo72gdbtD9tUdUPK0XRD_TgEPy2YU6I6BsKBStd40Fk6nOCGrq-mjYmH6OK3JUF3EVV7E0fEg7BgnYPLxcla0l7H6LpY4sqmFwapDqknjhgbqK0dyZDGWEPJ7Ph_5K6BazKuV_1bf6ZFOuRbm72cmT6vAJM8BhihAdTQt92QbTPikjLS2he5AfSV1ieDgLT26dsLNuLkyExyBqUGkrFoojh4fvW9K-wDKtgvQwCYZYABlC9JY72gtpaV2OV2UrB4aXuJX6n1NNXaSzpPqSupAIGK3Gaw39yrzBgBjTYAe0nnRu10BO7-gNRvKGIMCBTa7c-c0o0eNGe81xv1w8_-6auoKZYS8rzXQ8T6XLUjC1mRZD_cGxnfEra2G96-Cqm9WZO5hVX5fpXZhybz7neyGKlUKZG_An-jGmc9j_m03-5EEOfKAXJNlmOT1IynNVudtzTTrh8O5Dp4nD6fKsyOrg-6yRePCiP4FeItLCH6uVLWWdR65WZzklQuPrBELg58OzIsaBuKCKNjODSA4dGVE4JurhmgnnSmaqz2z6s0Zd1gXERebk_1WEmkWd03jO7dXMk3hOM9zV9BrZALOAll3GsvCqgh9kfouX-3ZNSNO7Lah6ecLD_zK228ap6r1MeY2VK-PiHUEnH58jh2HuutZB1Ge0GVvsYBue_r0FjGVNh6a9XYwIaf1Um2Z81WgHpWHZ-pLVZlkbN1vxgqLNBpjDy6UWpPJzOUv829C31WID92Wa6XPsfq6sIvYRUEx03DE2sbXKjUNX2t8InuLCgC6_wmq-GOoZ5vLKt1KHMicJUM9YFZYYKd-7c25X6DLplAnP-Hw_URgRINQdD8kOWzZ_70SiEq0om6OWniva6czSiwrcml_UBDA5Xr8pNtSWqtNbHh1LJzJenVIZl9gPLRs_o-OxB9gylqk7HwQZgKPCbvccYyh162Iy_Kg2j07hnDuoiUyZ93o9x_3Asf8Ms_E_ov6CqpFgKICX6rEE0oOgFO_pKvwtNH8fF-uNkVGKQwNYX6S33SlWh_pULYLSl-YrXVP0hLLmGlunnOGXUIVTXjQcc6AheR8Dmg9jDIefpgHMH6hegAnoZL0_AVuG-yd9LSRSh2qH_rABtJHTOx-0qQ6yYnrzHcMuvatCwDuIePK5DcxBj8KhKq9F4y_i5Ym9drIskRvAzwygZuIIuT3uyXl5nI6YE_jd6F9w4PZ7SkOs9JvfCnt-Wm7UKI6dxLnCRoTarUwop1wDZ77-rRwYoo5zYwF73BragZBZuWNB8ImLlktcAyCBF6P2_F2j4jvnQNLShYZ5HsJKsJNljjIiKYEAeJ2ScT2tjPSfMsdssWQPPByDgwnWtGpx2z6JTFGLUHaj_WbQe3hciyl7jGM2U1JrA610-Jb0X_OiGslZuYBasmPkEXFbDhZy_QZ4Pjs4RddBqrS15-H4FphxsB4knYHtfAzvJno80QmR69zvIfBSIScEx48foHjbeObNpW51IGbg2-yhssa9YtLpjpafnc1-yJ5xj6tJWYZcpskhgADRQvoxF8Xa7BE8o0D9-I7r2Yp0wMfYrbX8NCTBUWczxBZt2juBIERwgjHZzphIGVXNJ6ARm9F12UMf2OwUEk56J6SiSfB1ho7EDdARwj6Nfkm1LjpYLDhii-IRVJUN8tphw6SHVJBbMucYsXsL8viafUwdh7MbBwLKOPgZM4H9BqWFePgEglf7nzrALd2WV40tOai-sm4e4UCKh9bQ1qNw-uHQLP81NNzMA.bMWJdVmxAg2RZm7NE9wTz4H4LwjDb21tFV8hGtTKGFI'; + + public function testEncryptedRealSignature1(): void { + $this->requestMock->expects(self::any()) + ->method('getHeader') + ->with(self::equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . self::ENCRYPT1_SIGN_TOKEN); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } + + public const ENCRYPT2_SIGN_TOKEN = 'eyJwMnMiOiJWSTRQS0ZCeVRyUSIsInAyYyI6MTAwMCwiY3R5IjoiSldUIiwiZW5jIjoiQTI1NkNCQy1IUzUxMiIsImFsZyI6IlBCRVMyLUhTNTEyK0EyNTZLVyJ9.YQlaJwr-og6DNQhCkszfsts2z2NLuWsP5czCbMQdyhqjBuhutAvdZlqkFD6el4OeupoXXkTb7XkNyNZVq5S-rfUNGptv27J9.mNCv0KWUDXJoVLxkyppGqg.BdjbqWD14kmuJfLhVMWInuDjTh5O_qxjF9n9rD3viGH1WXZvQtiPT9U2ZKN17jLyzhLXtmvPP_bGZZPrGc5p68WoAteCSxzwJRGcF0hzO6gBhgvx_CcddG0jWcfaXgsFbOeLBpZMKR3w8_6I6shxDcrm0vwL_xeSOd_m4me_VVPQGkaOPKrMy4Ywlh-H7DTquI4NgC1vqt-B7Mpowj82PifFSgEDVrFPkNsustl4PE_2IiL5s_YAPme-OKq50wXzjcjsKAWEbgfsTk5iPoEJNaNWPyWUKiQ8Zp3w6qQgsiY7EGKB5D_-cgbkpq7GmASTiV0FbWHlKleQmHlZ0yJe-WMn0Ai_feVrNwsDM1X5QJ0YMyk5otef-s_64vnLCyo4VbLexO3d67dUqut03xdb9c2SLrupLzpONAJ-nNJ2vNbfr2EBZiSHYjttsmRXlAXgRhiJZIdUGDxBJO-ydEaR22VtPK8pdX9s2Sv8t609xeNQA9hjxCT6IRtEv7vJ0sODV-LSJetO3RKYdBOzNUUvz5VHDE6ogLWNF5blvQ8JoImJd8XP4rNmasassb1NHOPFr4lO7r4ZIn4vmb_idBjzWO2940o48vO5MoRT9gN9rUZDhTwK2enuKdek10PmsVIII5Q18DwvDZhRfM1ZbqZRdkKpnkVb-nWqXChHcSgFcR-TXZGmh3WaH6OJWKpckBAoQ1OHZDl2h_lIfCJ7-eOHR2i3tpXEp6URi31iABcsUZniv8hxB1XYORu9Bl63BQ_t6ns3L-wlMb-LAcvk_sruyObIAuhiZzCyJGxaugje0znGMd3vSXi4U-oqnuGKlKu_1-o7-qB-f1Pkfl6UCk5mS6Vnq-P78FN0iIGaeT8FwsrX-uAFpO8HH4YYEeE8yTi0CQShXVYPiisAQIQFg6QBjy5zEXUZnMBfG-iQ4lfxBJg2sGZ7-HAZpYB2RXDVXAUi4fqI8A1RdHpQofqFGyQZtfVPviOhfNw9Xx79GXb7Cw7viaHFFeyocbyk-55bqjRKpWPP758oxsmP7LZn7yVbMRciCiGDB0LNA1_vJ-7qi9oIUFGdoEW0r3y9I8Su3TH2H2P7HjVaIojOwY4z5_EuADg3lzoSACPvR_I7_r5zMqm7g89HDOo7b-_wh46JVpORbCemQwvJQehN6MUJTbBv_rLKCJ_wjNNMF9sa29yUvoUmEFvlLLy2e2p_r-4AnfGP5P1givxxh12pS_c64XZ1SLqaALTARRwkv1HCnufTNmit80-5rRghgAANf4KXcppXDoMqKW-mrI1Q_ckrkVb7vJuEHaPB1cka5MLIpQ9dFz2iwAEZcFDXXpx2u_ySSDSzRItgazuSOk7DMJzTER_aMTOP2IwzVPoGK8K3RT7wS0lNGfalepX-BAcAbZz4md2PAgHPcfKt1czhdBO5DO9mhKLSSHNA2cc4MmE4_3Ir3BfQCL7mQExvy5mESVr05eTIvLBAzae6SimwzkAUz3o6sxU0neTfxyM47zwQYutOvyC5MCHcA00HdLcyRG9PaE3Bsu5n1WJpIY8i217eFvBZXTIBM-b9vS2_lfC_nNC9DB4N33B2DFEkH02uk9L8vOY90vunGKX-qLXahFOWV_WrFxi_jKzav1FIGV0FcK8QPU8UC9tF9cbxKE1DyLu_G1I9XHP8KO7y9bKOGNv1sRDSUiGZX1_COPM6cifpJsEhOLsucmGsybKg2C77cXhuou9OSen89Devr2ZzWtSZOg1HQdAJuFVkQhjAKcygW49mKqvXsUytRkWEN1mOPsuIJgmt3t4-bxvxeH9qITjy7gR8KYCY5sgdeaIhiEmc2hVp1cBo_HMQNo1E1ew0l8K5X1gavEbUd3RCcRBEtsekwTsfGFoQ6rivH_F5PwAlhMde9jN-I3fnPZMlPnTQEBpb3RdcPV8YNJ7RzRVbQJktdDqb_be1L3BYzKuK8hnv4aEu4Y0wYLRkBxYNIW70X06bIeyCC7B07xn5yLrUaC0MS4UxO9gSPEdauj1OBP7Z_va7zNIbOr4CI68QLfUwtoWpYLPag1exLADeQO3Cdd1qX9LU2trhNVNsw_NapqVkguAI3A3YTuaCQpt68kKGhsugiJ7DsxHuWoNzou4hejBQAvJ1Lm-N38DFKB47gDrwraafpRAezpCyclpaQeYbMK_rz12YCbl35PkFqDefL7B4EESJyk_Wzqpl6Y3AU81rrXK2aVaO0iuVuunWc492tullX_TQ4rtcX_URyZBKz9eF6dxwMJM5UNTtnz7uq-oOmxL3o80XSLpSbfHM4p9elkZGsXfsgpPj0DQJ7EAneLGRqncdLC-6d_ry2E5HwtcC8iWS51CFttDoVyatDDdEWOB7WxD0wy63uc8XK58PPc_ped8W53bid3jB2E5Bg0_c63KQ83U7fezzMtFhUzLIc83FzsG9D4hAPGvZowj3IOAh-E1FlvjvHThse_iH2lIoA1sC9WHpUFx3RkalAaN76fAWP-3xO-bckk9AR3XX1pPxYnx0kOq0a4GR9G7y_ylBt6zGZ0E8TUg8VHS5i834V_rh15R3o8pHncq8b7kwAA--EWCuiLP8B7gTgMqS58r9G89PfZa7u9Wf4NkjoBvZbKzfbnZmPzXkuSLPyC4VBcAp9hZSzdTTd67zLYikGij5dSZ3TRFFG6MSvGDBYvs2P9KaixhcJbY6a7ULGbeBpB29rnq4OEXoGMjOoyG171ZzIeuXAvZnk_ujhEWlCFvznvfQu8H5mTjtFb17I9BJ4YS5gT3E5UwHEH_bAaJI8KtRjfbhKkv09cxaYqRjCMoPlLEPnwDxc2Ousux5SHOjgIqWp9z1acIUzLqkbK3euZNL1YpCNRJTMn4qDPhel5gyY9IjoqgEhfQFJ4ckp2_DLGcFZj3Wwwh-WGmkduvTr2TE_kIA-SmXcqwyGdLse3n7JUHVxcumvXgr5oxe2I_h6UQGSPLxz-KwKxeIUAARQhM9f2mjBcnJ3hkaJj-ciuAjof-WBVCZJsjlccogXhXtxLbjz8ZSntQuaLdjb-ci2wMANhPWnWh9R2KqnREhp-PTllAG4Bj-BWmpzTTRy7tZGkFKoL1xiZMCFA_5egS9V1lqwz62BVOVZ7AeZ5NK8hjGnzSgq6E3bhLoTDupPJLUl3f7fC16PqHQjb049Srme9lK13s8oR79g9UUufW-jQloUhA5fRql45ArveLSTSgg-nUCk22Dso1-Cjk7BIqsEFmeBcyhQoqpjCiuKT6iiVTuEnQXAJ8WEi_hJKTXJ2NxEOdaCG1VaZNycggvX4urmkD53HLpXABitdYpBqJvu-DkO-K8OZA0v8tThBZx4zrIY5EMUPi9YikMrWOqeJtXhA6ZYpeUjK8FHM-sAb3i377lw0CarC8XDzzeNCHRJvaksZdhviuBqNjWXQ_VtU6xEqXsXc8FSftvK2SoSiW19qgiQkrUMJxSy6A_daXT0b7FucBACN1O3YDQ2-x6juM1uMjLico4I1OeFP0RsbUazYVdW0wL6CXiC81ygyTk_XE85xyWwNyiooBuJc377qapNcbUVAYca6R5YVHLVsVLjr3h_BlO1KWv064dypH1faO8cYatSwXp5ttcUg8xoI6E_q0N3IUepfTleZBiCRncoFyKcOT7xUlqojhkC4YirwgtV5Pv3hp6MQ9hjibUeX8mNLFepE1tDFyzZmMXM2kr0Q99WVINbRqv8vGjt82wuZScuJiBy8P6BV-FJLAXsECrAtauSQlDP7YTWsibeqQ3_LEDRd4G9BMj7RorJg6Z0jFloIVfzQOHkZCEZITbh8ifrDrnpMO84l-__kRVImb1rW6I-1KdTubMAaZbAYPhpiYWmC5FJfmyyCSA7uuqeP7RWSm3fZeJK-YinLKH6dUHgwchPQ1godY97ywznP5YuM9pmve75iaNcd3ILuljGx8eBj2Ig7lkPK00JId6FfDwfg9h9cgAKfqueZRBPEN0D3grwZkplG7-_6B1ZhmwjRHaFY88L4EUVnqNh9F73190G-oOuM8Ztw0ItfLU-EvshvMLZ_4W-FUN8B_okqAGH0F088j5ZADxS7HdWMq0DNDIaXpDgPjPhLT7mng20O7BWfG8nTSMEqTBGfvpgoeTL5LjBuDESG4H7FhxGXlfum8asCs8WgdhZ0Zh-SRV8bcLTcpOSEuutdCOK0DxMjs30MTijfLDfpHQP9_fWuG__3n-9g-7Rs6OIaU9jwJ2yWarC-CfPX7yzZcgcsAbT_UEHqRZXQU5vhepV5tmvM5RTv9k7a16b6xIEJIBNLaDRw7LZaauowiaF40vrMNZNGnqqTED_bqMcnfYXvp2R0QFZihNgey1rh2ndhYcSmXSC0F4Wm6r4T6q9VfW_T4Y7NGb31a001Mq_edR2xa_uSBETzybCsHNUq5bD_F3Qj4JUivq2nyh-UAbxP71MdlGE8RN5RYL7b5j25o1oyw5tSYbndIjfp_oVHkdWtnYJsH6T131lUwM0-DwMWWtLParbukDjDjy08aTEDR0vW6LaJJ9bh1_Po-XR6sG4lAeTcJo7XjptIWQCbkSrV6gD7GXOOJgF2qVlvM02ARNLl6DNo3Y7ar_H4LkZ3aAkkV1Yy7-vnVpIEx-UoSnilNRQN_rp6icTwNilt1UnuuLutxKISHRMDP3Pv9vEATDQy-z.w6KkNgIIeh8SPlMtA6l7dbywsDAKFLkTmrVc65q-BL8'; + + public function testEncryptedRealSignature2(): void { + $this->requestMock->expects(self::any()) + ->method('getHeader') + ->with(self::equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . self::ENCRYPT2_SIGN_TOKEN); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } +} diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php new file mode 100644 index 000000000..7d1a38949 --- /dev/null +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -0,0 +1,115 @@ + */ + 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..728fab851 --- /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); + } +} diff --git a/tests/unit/MagentaCloud/SamBearerTokenTest.php b/tests/unit/MagentaCloud/SamBearerTokenTest.php new file mode 100644 index 000000000..ff1edfd22 --- /dev/null +++ b/tests/unit/MagentaCloud/SamBearerTokenTest.php @@ -0,0 +1,61 @@ +expectNotToPerformAssertions(); + + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + $bearerToken = $this->tokenService->decryptToken($testtoken, $this->getTestBearerSecret()); + + $this->tokenService->verifySignature($bearerToken, $this->getTestBearerSecret()); + $claims = $this->tokenService->decode($bearerToken); + $this->tokenService->verifyClaims($claims, ['http://auth.magentacloud.de']); + } + + public function testInvalidSignature(): void { + $this->expectException(SignatureException::class); + + $bearerToken = $this->signToken( + $this->getRealExampleClaims(), + $this->getTestBearerSecret(), + true, + ); + + $this->tokenService->verifySignature($bearerToken, $this->getTestBearerSecret()); + } + + public function testEncryptedValidSignature(): void { + $this->expectNotToPerformAssertions(); + + $testtoken = $this->setupSignEncryptToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + $bearerToken = $this->tokenService->decryptToken($testtoken, $this->getTestBearerSecret()); + + $this->tokenService->verifySignature($bearerToken, $this->getTestBearerSecret()); + $claims = $this->tokenService->decode($bearerToken); + $this->tokenService->verifyClaims($claims, ['http://auth.magentacloud.de']); + } + + public function testEncryptedInvalidEncryption(): void { + $this->expectException(InvalidTokenException::class); + + $testtoken = $this->setupSignEncryptToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + $invalidEncryption = mb_substr($testtoken, 0, -1); + + $bearerToken = $this->tokenService->decryptToken($invalidEncryption, $this->getTestBearerSecret()); + $this->tokenService->verifySignature($bearerToken, $this->getTestBearerSecret()); + $claims = $this->tokenService->decode($bearerToken); + $this->tokenService->verifyClaims($claims, ['http://auth.magentacloud.de']); + } +}