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']);
+ }
+}