From 56fb1cba9601fdbc19a6796cf4eddf1e85b5fb08 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Mon, 10 Jul 2023 17:09:32 +0200 Subject: [PATCH 01/33] Override upstream Readme in .github --- .github/README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/README.md diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 00000000..76c7b0ac --- /dev/null +++ b/.github/README.md @@ -0,0 +1,56 @@ +# user_oidc: Customisation of OpenID app for MagentaCLOUD + +The app extends the standard `user_oidc` Nextcloud app, +see [upstream configuration hints for basic setup](https://github.com/nextcloud/user_oidc/README.md) + +The app is extended by the following features + +## Event-based provisioning (upstream contribution candidate) +The mechanism allows to implement custom puser provisioning logic in a separate Nextcloud app by +registering and handling a attribute change and provisioning event: + +``` +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +class Application extends App implements IBootstrap { +... + public function register(IRegistrationContext $context): void { + $context->registerEventListener(AttributeMappedEvent::class, MyUserAttributeListener::class); + $context->registerEventListener(UserAccountChangeEvent::class, MyUserAccountChangeListener::class); + } +... +} +``` +The provisioning handler should return a `OCA\UserOIDC\Event\UserAccountChangeResult` object + +## Telekom-specific bearer token + +Due to historic reason, Telekom bearer tokens have a close to standard structure, but +require special security implementation in detail. The customisation overrides te standard + + +### Requiring web-token libraries +The central configuration branch `nmc/2372-central-setup` automatic merge will frequently fail if composer +upstream + +The fast and easy way to bring it back to sync with upstream is: +``` +git checkout nmc/2372-central-setup +git rebase --onto main nmc/2372-central-setup +# manually take over everything from upstream for composer.lock (TODO: automate that) + +# update web-token dependencies in composer.lock +composer update web-token +``` +It is recommended to leave the version management for all other libraries to upstream +and only update web-token with the dedicated `composer update web-token`. + + +### Configuring an additional Bearer preshared secret with provider +TODO + +### Testing Bearer secrets +TODO \ No newline at end of file From 0d4fb92a291d481cf1291de42bc36c48965c8ea6 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Mon, 10 Jul 2023 17:18:04 +0200 Subject: [PATCH 02/33] Correct some wordings --- .github/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/README.md b/.github/README.md index 76c7b0ac..cc522ede 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,9 +1,11 @@ -# user_oidc: Customisation of OpenID app for MagentaCLOUD +# MagentaCLOUD user_oidc + +Customisation of the Nextcloud delivered OpenID connect app for MagentaCLOUD. The app extends the standard `user_oidc` Nextcloud app, -see [upstream configuration hints for basic setup](https://github.com/nextcloud/user_oidc/README.md) +see [upstream configuration hints for basic setup](https://github.com/nextcloud/user_oidc/blob/main/README.md) -The app is extended by the following features +The app is extended by the following features: ## Event-based provisioning (upstream contribution candidate) The mechanism allows to implement custom puser provisioning logic in a separate Nextcloud app by From cf631b60067cdd8d4c33de20f4b28f894a28f2f0 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Tue, 11 Jul 2023 18:06:01 +0200 Subject: [PATCH 03/33] Add automatic phpunit run after assembly --- .github/workflows/nmc-custom-phpunit.yml | 39 ++++++++++++++++++++ .github/workflows/nmc-custom-versions.yml | 43 +++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 .github/workflows/nmc-custom-phpunit.yml create mode 100644 .github/workflows/nmc-custom-versions.yml diff --git a/.github/workflows/nmc-custom-phpunit.yml b/.github/workflows/nmc-custom-phpunit.yml new file mode 100644 index 00000000..ab230b4b --- /dev/null +++ b/.github/workflows/nmc-custom-phpunit.yml @@ -0,0 +1,39 @@ +### +# 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 phpunit (customisation change) + +### +# The automated unittets cycles are started as soon as a new +# customisation branch is pushed +on: + push: + branches: + - customisation-*master + - customisation-*nmcstable/** + +jobs: + build-custom: + strategy: + fail-fast: false + matrix: + phpversion: ['8.0', '8.1'] + database: ['mysql'] + custombase: [ "main", "nmcstable/25.0.6" ] + uses: nextmcloud/.github/.github/workflows/nmc-custom-app-phpunit.yml@master + 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-versions.yml b/.github/workflows/nmc-custom-versions.yml new file mode 100644 index 00000000..63579131 --- /dev/null +++ b/.github/workflows/nmc-custom-versions.yml @@ -0,0 +1,43 @@ +### +# 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: + build-custom: + strategy: + fail-fast: false + matrix: + custombase: [ "main", "nmcstable/25.0.6" ] + 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 From 38ef829285f314bebf9f400342c9a96a4cb3d71d Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Tue, 11 Jul 2023 18:19:30 +0200 Subject: [PATCH 04/33] Correct trunk name for customisation branch --- .github/workflows/nmc-custom-phpunit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nmc-custom-phpunit.yml b/.github/workflows/nmc-custom-phpunit.yml index ab230b4b..76e04740 100644 --- a/.github/workflows/nmc-custom-phpunit.yml +++ b/.github/workflows/nmc-custom-phpunit.yml @@ -18,8 +18,8 @@ name: MCLOUD phpunit (customisation change) on: push: branches: - - customisation-*master - - customisation-*nmcstable/** + - customisation-*-main + - customisation-*-nmcstable/** jobs: build-custom: From b46a89714236dc11c451ace1a7b803774dc64a78 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Tue, 11 Jul 2023 18:37:51 +0200 Subject: [PATCH 05/33] Debug scheduling on push --- .github/workflows/nmc-custom-phpunit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nmc-custom-phpunit.yml b/.github/workflows/nmc-custom-phpunit.yml index 76e04740..1d5426f3 100644 --- a/.github/workflows/nmc-custom-phpunit.yml +++ b/.github/workflows/nmc-custom-phpunit.yml @@ -18,8 +18,8 @@ name: MCLOUD phpunit (customisation change) on: push: branches: - - customisation-*-main - - customisation-*-nmcstable/** + - 'customisation*-main' + - 'customisation*-nmcstable/25.0.6' jobs: build-custom: From b93100d243d7ff0b16929d816934b8c0352107e1 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Wed, 12 Jul 2023 08:50:24 +0200 Subject: [PATCH 06/33] Include phpunit in versions assembling --- .github/workflows/nmc-custom-phpunit.yml | 39 ----------------------- .github/workflows/nmc-custom-versions.yml | 20 +++++++++++- 2 files changed, 19 insertions(+), 40 deletions(-) delete mode 100644 .github/workflows/nmc-custom-phpunit.yml diff --git a/.github/workflows/nmc-custom-phpunit.yml b/.github/workflows/nmc-custom-phpunit.yml deleted file mode 100644 index 1d5426f3..00000000 --- a/.github/workflows/nmc-custom-phpunit.yml +++ /dev/null @@ -1,39 +0,0 @@ -### -# 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 phpunit (customisation change) - -### -# The automated unittets cycles are started as soon as a new -# customisation branch is pushed -on: - push: - branches: - - 'customisation*-main' - - 'customisation*-nmcstable/25.0.6' - -jobs: - build-custom: - strategy: - fail-fast: false - matrix: - phpversion: ['8.0', '8.1'] - database: ['mysql'] - custombase: [ "main", "nmcstable/25.0.6" ] - uses: nextmcloud/.github/.github/workflows/nmc-custom-app-phpunit.yml@master - 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-versions.yml b/.github/workflows/nmc-custom-versions.yml index 63579131..6b0c3eb6 100644 --- a/.github/workflows/nmc-custom-versions.yml +++ b/.github/workflows/nmc-custom-versions.yml @@ -30,7 +30,8 @@ on: # - stable/** jobs: - build-custom: + + assemble: strategy: fail-fast: false matrix: @@ -41,3 +42,20 @@ jobs: stable: ${{ matrix.custombase }} result: ${{ 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", "nmcstable/25.0.6" ] + uses: nextmcloud/.github/.github/workflows/nmc-custom-app-phpunit.yml@master + need: assemble + 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 From e5d81f9fc6e8c77caf6a6479de6fbaab2a1f6450 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Wed, 12 Jul 2023 09:08:29 +0200 Subject: [PATCH 07/33] Fix syntax --- .github/workflows/nmc-custom-versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nmc-custom-versions.yml b/.github/workflows/nmc-custom-versions.yml index 6b0c3eb6..ff976790 100644 --- a/.github/workflows/nmc-custom-versions.yml +++ b/.github/workflows/nmc-custom-versions.yml @@ -57,5 +57,5 @@ jobs: appname: 'user_oidc' server-branch: ${{ matrix.custombase }} phpversion: ${{ matrix.phpversion }} - database: ${{ matrix.database }} + database: ${{ matrix.database }} secrets: inherit \ No newline at end of file From 93e7995592302747a87c85cf1eb67ea9bb46d741 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Wed, 12 Jul 2023 09:16:58 +0200 Subject: [PATCH 08/33] Fix needs syntax --- .github/workflows/nmc-custom-versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nmc-custom-versions.yml b/.github/workflows/nmc-custom-versions.yml index ff976790..7fa8a832 100644 --- a/.github/workflows/nmc-custom-versions.yml +++ b/.github/workflows/nmc-custom-versions.yml @@ -51,7 +51,7 @@ jobs: database: ['mysql'] custombase: [ "main", "nmcstable/25.0.6" ] uses: nextmcloud/.github/.github/workflows/nmc-custom-app-phpunit.yml@master - need: assemble + needs: assemble with: assembly: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} appname: 'user_oidc' From dc53689e901bb3d1f95c2c22d77e52f851cfb843 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Wed, 12 Jul 2023 12:32:57 +0200 Subject: [PATCH 09/33] Move readme to central setup --- .github/README.md | 58 ----------------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/README.md diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index cc522ede..00000000 --- a/.github/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# MagentaCLOUD user_oidc - -Customisation of the Nextcloud delivered OpenID connect app for MagentaCLOUD. - -The app extends the standard `user_oidc` Nextcloud app, -see [upstream configuration hints for basic setup](https://github.com/nextcloud/user_oidc/blob/main/README.md) - -The app is extended by the following features: - -## Event-based provisioning (upstream contribution candidate) -The mechanism allows to implement custom puser provisioning logic in a separate Nextcloud app by -registering and handling a attribute change and provisioning event: - -``` -use OCP\AppFramework\App; -use OCP\AppFramework\Bootstrap\IBootContext; -use OCP\AppFramework\Bootstrap\IBootstrap; -use OCP\AppFramework\Bootstrap\IRegistrationContext; - -class Application extends App implements IBootstrap { -... - public function register(IRegistrationContext $context): void { - $context->registerEventListener(AttributeMappedEvent::class, MyUserAttributeListener::class); - $context->registerEventListener(UserAccountChangeEvent::class, MyUserAccountChangeListener::class); - } -... -} -``` -The provisioning handler should return a `OCA\UserOIDC\Event\UserAccountChangeResult` object - -## Telekom-specific bearer token - -Due to historic reason, Telekom bearer tokens have a close to standard structure, but -require special security implementation in detail. The customisation overrides te standard - - -### Requiring web-token libraries -The central configuration branch `nmc/2372-central-setup` automatic merge will frequently fail if composer -upstream - -The fast and easy way to bring it back to sync with upstream is: -``` -git checkout nmc/2372-central-setup -git rebase --onto main nmc/2372-central-setup -# manually take over everything from upstream for composer.lock (TODO: automate that) - -# update web-token dependencies in composer.lock -composer update web-token -``` -It is recommended to leave the version management for all other libraries to upstream -and only update web-token with the dedicated `composer update web-token`. - - -### Configuring an additional Bearer preshared secret with provider -TODO - -### Testing Bearer secrets -TODO \ No newline at end of file From 3fc8495d557cc18cfd9c37f163cfe62d6e685e7c Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Sat, 19 Aug 2023 08:54:14 +0200 Subject: [PATCH 10/33] Refactor for working fast-fail precheck --- .github/workflows/nmc-custom-release.yml | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/nmc-custom-release.yml diff --git a/.github/workflows/nmc-custom-release.yml b/.github/workflows/nmc-custom-release.yml new file mode 100644 index 00000000..5962a538 --- /dev/null +++ b/.github/workflows/nmc-custom-release.yml @@ -0,0 +1,58 @@ +### +# 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 + - stable25 + - stable26 + - stable27 + 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 + build-custom: + uses: nextmcloud/.github/.github/workflows/nmc-custom-app-build.yml@master + needs: [ check-custom, assemble-custom ] + 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 From d41baa1ebeb195c524d818332a4d5f050401a092 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 12:37:19 +0200 Subject: [PATCH 11/33] Remove obsolete stable versions base in matrix --- .github/workflows/nmc-custom-versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nmc-custom-versions.yml b/.github/workflows/nmc-custom-versions.yml index 7fa8a832..6d262e27 100644 --- a/.github/workflows/nmc-custom-versions.yml +++ b/.github/workflows/nmc-custom-versions.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - custombase: [ "main", "nmcstable/25.0.6" ] + custombase: [ "main" ] uses: nextmcloud/.github/.github/workflows/nmc-custom-assembly.yml@master with: trunk: "main" From d525678a033b18c30214c9b963681e604ddf6d93 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 19:19:41 +0200 Subject: [PATCH 12/33] Add required composer dependencies programmatically --- ...release.yml => nmc-custom-app-release.yml} | 14 +++- ...rsions.yml => nmc-custom-app-versions.yml} | 15 +++- .../workflows/nmc-custom-oidc-composer.yml | 82 +++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) rename .github/workflows/{nmc-custom-release.yml => nmc-custom-app-release.yml} (83%) rename .github/workflows/{nmc-custom-versions.yml => nmc-custom-app-versions.yml} (82%) create mode 100644 .github/workflows/nmc-custom-oidc-composer.yml diff --git a/.github/workflows/nmc-custom-release.yml b/.github/workflows/nmc-custom-app-release.yml similarity index 83% rename from .github/workflows/nmc-custom-release.yml rename to .github/workflows/nmc-custom-app-release.yml index 5962a538..b34abf18 100644 --- a/.github/workflows/nmc-custom-release.yml +++ b/.github/workflows/nmc-custom-app-release.yml @@ -47,9 +47,21 @@ jobs: stable: ${{ inputs.branch }} result: ${{ format('customisation-{0}-{1}', inputs.branch, inputs.increment ) }} secrets: inherit + + composerdep: + strategy: + fail-fast: false + matrix: + custombase: [ "main" ] + uses: ./.github/workflows/nmc-custom-oidc-composer.yml + needs: assemble-custom + with: + assembly: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} + secrets: inherit + build-custom: uses: nextmcloud/.github/.github/workflows/nmc-custom-app-build.yml@master - needs: [ check-custom, assemble-custom ] + needs: [ check-custom, composerdep ] with: appname: ${{ needs.check-custom.outputs.appname }} assembly: ${{ format('customisation-{0}-{1}', inputs.branch , inputs.increment ) }} diff --git a/.github/workflows/nmc-custom-versions.yml b/.github/workflows/nmc-custom-app-versions.yml similarity index 82% rename from .github/workflows/nmc-custom-versions.yml rename to .github/workflows/nmc-custom-app-versions.yml index 6d262e27..e18f1941 100644 --- a/.github/workflows/nmc-custom-versions.yml +++ b/.github/workflows/nmc-custom-app-versions.yml @@ -43,15 +43,26 @@ jobs: 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", "nmcstable/25.0.6" ] + custombase: [ "main" ] uses: nextmcloud/.github/.github/workflows/nmc-custom-app-phpunit.yml@master - needs: assemble + needs: composerdep with: assembly: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} appname: 'user_oidc' diff --git a/.github/workflows/nmc-custom-oidc-composer.yml b/.github/workflows/nmc-custom-oidc-composer.yml new file mode 100644 index 00000000..474b3257 --- /dev/null +++ b/.github/workflows/nmc-custom-oidc-composer.yml @@ -0,0 +1,82 @@ +### +# SPDX-License-Identifier: AGPL-3.0 +# +# Author: Bernd Rederlechner Date: Fri, 1 Sep 2023 19:33:57 +0200 Subject: [PATCH 13/33] Remove commit push blocker --- .github/workflows/nmc-custom-oidc-composer.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/nmc-custom-oidc-composer.yml b/.github/workflows/nmc-custom-oidc-composer.yml index 474b3257..ca247fbd 100644 --- a/.github/workflows/nmc-custom-oidc-composer.yml +++ b/.github/workflows/nmc-custom-oidc-composer.yml @@ -45,7 +45,6 @@ jobs: # set user in case commits are needed git config user.name $BUILD_USER git config user.email $BUILD_EMAIL - git remote set-url origin http://no.such.host # install php dependencies - name: Set up php ${{ env.PHP_VERSION }} From 77aa3687458a34318750c2cf93145f54ef694d4c Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Mon, 4 Sep 2023 08:49:39 +0200 Subject: [PATCH 14/33] Fix assembly branch name for dependency check --- .github/workflows/nmc-custom-app-release.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/nmc-custom-app-release.yml b/.github/workflows/nmc-custom-app-release.yml index b34abf18..64d287ce 100644 --- a/.github/workflows/nmc-custom-app-release.yml +++ b/.github/workflows/nmc-custom-app-release.yml @@ -51,12 +51,10 @@ jobs: composerdep: strategy: fail-fast: false - matrix: - custombase: [ "main" ] uses: ./.github/workflows/nmc-custom-oidc-composer.yml needs: assemble-custom with: - assembly: ${{ format('customisation-{0}-{1}', github.actor, matrix.custombase) }} + assembly: ${{ format('customisation-{0}-{1}', inputs.branch, inputs.increment) }} secrets: inherit build-custom: From 1b44600ebaba387923d429698c849042f010bb57 Mon Sep 17 00:00:00 2001 From: Mauro Mura Date: Wed, 29 Oct 2025 07:57:31 +0100 Subject: [PATCH 15/33] Update nmc-custom-oidc-composer.yml --- .github/workflows/nmc-custom-oidc-composer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nmc-custom-oidc-composer.yml b/.github/workflows/nmc-custom-oidc-composer.yml index ca247fbd..d4f2c527 100644 --- a/.github/workflows/nmc-custom-oidc-composer.yml +++ b/.github/workflows/nmc-custom-oidc-composer.yml @@ -26,7 +26,7 @@ jobs: 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.1' }} + PHP_VERSION: ${{ vars.PHP_VERSION || '8.2' }} steps: - name: Fetch custom assembly id: checkout_custom From 66b80f8e67b080f0113bef1d31d8f7fc967c96ab Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 16:12:00 +0100 Subject: [PATCH 16/33] return status ok --- lib/Controller/LoginController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index da786660..225f4b79 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -897,7 +897,7 @@ private function getBackchannelLogoutErrorResponse( 'error' => $error, 'error_description' => $description, ], - Http::STATUS_BAD_REQUEST, + Http::STATUS_OK, ); } From 2ae69f411de6bcefd2e4cc3055e886bdd595ab0e Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 16:14:33 +0100 Subject: [PATCH 17/33] added check and redirect --- lib/Controller/LoginController.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index da786660..2e4664f1 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -321,6 +321,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([ @@ -901,6 +906,22 @@ private function getBackchannelLogoutErrorResponse( ); } + 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)); From 9ddcb5216dc22ee24ea6363e7900dd913e3e9848 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 17:03:47 +0100 Subject: [PATCH 18/33] added central customization --- .github/README.md | 54 +++++++++++++++++++++++++ COPYING.DTAG | 5 +++ appinfo/routes.php | 5 ++- lib/AppInfo/Application.php | 81 ++++++++++++++++++++++++++++++++++++- tests/bootstrap.php | 5 +++ 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 .github/README.md create mode 100644 COPYING.DTAG diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 00000000..a942804d --- /dev/null +++ b/.github/README.md @@ -0,0 +1,54 @@ +# MagentaCLOUD user_oidc + +Customisation of the Nextcloud delivered OpenID connect app for MagentaCLOUD. + +The app extends the standard `user_oidc` Nextcloud app, +see [upstream configuration hints for basic setup](https://github.com/nextcloud/user_oidc/blob/main/README.md) + + +## Feature: Event-based provisioning (upstream contribution candidate) +The mechanism allows to implement custom puser provisioning logic in a separate Nextcloud app by +registering and handling a attribute change and provisioning event: + +``` +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +class Application extends App implements IBootstrap { +... + public function register(IRegistrationContext $context): void { + $context->registerEventListener(AttributeMappedEvent::class, MyUserAttributeListener::class); + $context->registerEventListener(UserAccountChangeEvent::class, MyUserAccountChangeListener::class); + } +... +} +``` +The provisioning handler should return a `OCA\UserOIDC\Event\UserAccountChangeResult` object + +## Feature: Telekom-specific bearer token + +Due to historic reason, Telekom bearer tokens have a close to standard structure, but +require special security implementation in detail. The customisation overrides te standard + + +### Requiring web-token libraries +The central configuration branch `nmc/2372-central-setup` automatic merge will frequently fail if composer +upstream + +The fast and easy way to bring it back to sync with upstream is: +``` +git checkout nmc/2372-central-setup +git rebase --onto main nmc/2372-central-setup +# manually take over everything from upstream for composer.lock (TODO: automate that) +# ALWAYS update web-token dependencies in composer.lock +# to avoid upstream conflicts. The lock file diff should only contain adds to upstream state! +composer update "web-token/jwt-*" +``` + + +### Configuring an additional Bearer preshared secret with provider +TODO + +### Testing Bearer secrets +TODO diff --git a/COPYING.DTAG b/COPYING.DTAG new file mode 100644 index 00000000..85c2c3b9 --- /dev/null +++ b/COPYING.DTAG @@ -0,0 +1,5 @@ +Although this Nextcloud app code is free and available under the AGPL3 license, Deutsche Telekom +(including T-Systems) fully reserves all rights to the Telekom brand. To prevent users from getting confused about +the source of a digital product or experience, there are stringent restrictions on using the Telekom brand and design, +even when built into code that we provide. For any customization other than explicitly for Telekom or T-Systems, you must +replace the Deutsche Telekom and T-Systems brand elements contained in the provided sources. diff --git a/appinfo/routes.php b/appinfo/routes.php index a8c6bb52..58a0b9e4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,9 +16,10 @@ ['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' => 'api#createUser', 'url' => '/user', 'verb' => 'POST'], - ['name' => 'api#deleteUser', 'url' => '/user/{userId}', 'verb' => 'DELETE'], + // ['name' => 'api#createUser', 'url' => '/user', 'verb' => 'POST'], + // ['name' => 'api#deleteUser', 'url' => '/user/{userId}', 'verb' => 'DELETE'], ['name' => 'id4me#showLogin', 'url' => '/id4me', 'verb' => 'GET'], ['name' => 'id4me#login', 'url' => '/id4me', 'verb' => 'POST'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9842067f..7221eac3 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -20,7 +20,10 @@ 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\SettingsService; use OCA\UserOIDC\Service\TokenService; use OCA\UserOIDC\User\Backend; @@ -31,9 +34,14 @@ use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; +use OCP\ISession; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Security\ISecureRandom; + +// this is needed only for the special, shortened client login flow +use Psr\Container\ContainerInterface; use Throwable; class Application extends App implements IBootstrap { @@ -48,11 +56,19 @@ public function __construct(array $urlParams = []) { } public function register(IRegistrationContext $context): void { + // Register the composer autoloader required for the added jwt-token libs + include_once __DIR__ . '/../../vendor/autoload.php'; + + // override registration of provisioning srevice 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,10 +100,73 @@ public function boot(IBootContext $context): void { try { $context->injectFn(\Closure::fromCallable([$this, 'registerRedirect'])); $context->injectFn(\Closure::fromCallable([$this, 'registerLogin'])); + + // this is the custom auto-redirect for MagentaCLOUD client access + $context->injectFn(\Closure::fromCallable([$this, 'registerNmcClientFlow'])); } catch (Throwable $e) { } } + /** + * This is the automatic redirect exclusively for Nextcloud/Magentacloud clients completely skipping consent layer + */ + private function registerNmcClientFlow(IRequest $request, + IURLGenerator $urlGenerator, + ProviderMapper $providerMapper, + ISession $session, + ISecureRandom $random): void { + $providers = $this->getCachedProviders($providerMapper); + + // Handle immediate redirect on client first-time login + $isClientLoginFlow = false; + + try { + $isClientLoginFlow = $request->getPathInfo() === '/login/flow'; + } catch (Exception $e) { + // in case any errors happen when checking for the path do not apply redirect logic as it is only needed for the login + } + + if ($isClientLoginFlow) { + // only redirect if Telekom provider registered + $tproviders = array_values(array_filter($providers, function ($p) { + return strtolower($p->getIdentifier()) === 'telekom'; + })); + + if (count($tproviders) == 0) { + // always show normal login flow as error fallback + return; + } + + $stateToken = $random->generate(64, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS); + $session->set('client.flow.state.token', $stateToken); + + // call the service to get the params, but suppress the template + // compute grant redirect Url to go directly to Telekom login + $redirectUrl = $urlGenerator->linkToRoute('core.ClientFlowLogin.grantPage', [ + 'stateToken' => $stateToken, + // grantPage service operation is deriving oauth2 client name (again), + // so we simply pass on clientIdentifier or empty string + 'clientIdentifier' => $request->getParam('clientIdentifier', ''), + 'direct' => $request->getParam('direct', '0') + ]); + + if ($redirectUrl === null) { + + // always show normal login flow as error fallback + return; + } + + // direct login, consent layer later + $targetUrl = $urlGenerator->linkToRoute(self::APP_ID . '.login.login', [ + 'providerId' => $tproviders[0]->getId(), + 'redirectUrl' => $redirectUrl + ]); + + header('Location: ' . $targetUrl); + exit(); + } + } + private function checkLoginToken(TokenService $tokenService): void { $tokenService->checkLoginToken(); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 942fe846..511150ac 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,4 +18,9 @@ require_once __DIR__ . '/../../../tests/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php'; +\OC::$loader->addValidRoot(OC::$SERVERROOT . '/tests'); +\OC::$composerAutoloader->addPsr4('OCA\\UserOIDC\\BaseTest\\', dirname(__FILE__) . '/unit/MagentaCloud/', true); + Server::get(IAppManager::class)->loadApp('user_oidc'); + +OC_Hook::clear(); From cf3fc5bcc79980674fc3f617d3f1cbf7ac7b87b2 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 17:19:14 +0100 Subject: [PATCH 19/33] added event based provisioning --- lib/AppInfo/Application.php | 2 +- lib/Controller/LoginController.php | 19 + lib/Event/UserAccountChangeEvent.php | 87 ++++ lib/Event/UserAccountChangeResult.php | 74 +++ lib/Service/ProvisioningDeniedException.php | 69 +++ lib/Service/ProvisioningEventService.php | 180 +++++++ .../unit/MagentaCloud/OpenidTokenTestCase.php | 141 +++++ .../ProvisioningEventServiceTest.php | 481 ++++++++++++++++++ tests/unit/MagentaCloud/RegistrationsTest.php | 33 ++ 9 files changed, 1085 insertions(+), 1 deletion(-) create mode 100644 lib/Event/UserAccountChangeEvent.php create mode 100644 lib/Event/UserAccountChangeResult.php create mode 100644 lib/Service/ProvisioningDeniedException.php create mode 100644 lib/Service/ProvisioningEventService.php create mode 100644 tests/unit/MagentaCloud/OpenidTokenTestCase.php create mode 100644 tests/unit/MagentaCloud/ProvisioningEventServiceTest.php create mode 100644 tests/unit/MagentaCloud/RegistrationsTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9842067f..ec75d924 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -74,7 +74,7 @@ public function register(IRegistrationContext $context): void { public function boot(IBootContext $context): void { $context->injectFn(\Closure::fromCallable([$this->backend, 'injectSession'])); - $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken'])); + // $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken'])); /** @var IUserSession $userSession */ $userSession = $this->getContainer()->get(IUserSession::class); if ($userSession->isLoggedIn()) { diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index da786660..49dc2861 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -24,6 +24,7 @@ use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\OIDCService; use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; @@ -554,6 +555,24 @@ public function code(string $state = '', string $code = '', string $scope = '', } if ($autoProvisionAllowed) { + $user = null; + + try { + // use potential user from other backend, create it in our backend if it does not exist + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); + } catch (ProvisioningDeniedException $denied) { + // TODO: MagentaCLOUD should upstream the exception handling + $redirectUrl = $denied->getRedirectUrl(); + if ($redirectUrl === null) { + $message = $this->l10n->t('Failed to provision user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); + } else { + // error response is a redirect, e.g. to a booking site + // so that you can immediately get the registration page + return new RedirectResponse($redirectUrl); + } + } + if (!$softAutoProvisionAllowed && $existingUser !== null && $existingUser->getBackendClassName() !== Application::APP_ID) { // if soft auto-provisioning is disabled, // we refuse login for a user that already exists in another backend diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php new file mode 100644 index 00000000..c6b698aa --- /dev/null +++ b/lib/Event/UserAccountChangeEvent.php @@ -0,0 +1,87 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +use OCP\EventDispatcher\Event; + +/** + * Event to provide custom mapping logic based on the OIDC token data + * In order to avoid further processing the event propagation should be stopped + * in the listener after processing as the value might get overwritten afterwards + * by other listeners through $event->stopPropagation(); + */ +class UserAccountChangeEvent extends Event { + private $uid; + private $displayname; + private $mainEmail; + private $quota; + private $claims; + private $result; + + + public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) { + parent::__construct(); + $this->uid = $uid; + $this->displayname = $displayname; + $this->mainEmail = $mainEmail; + $this->quota = $quota; + $this->claims = $claims; + $this->result = new UserAccountChangeResult($accessAllowed, 'default'); + } + + /** + * @return get event username (uid) + */ + public function getUid(): string { + return $this->uid; + } + + /** + * @return get event displayname + */ + public function getDisplayName(): ?string { + return $this->displayname; + } + + /** + * @return get event main email + */ + public function getMainEmail(): ?string { + return $this->mainEmail; + } + + /** + * @return get event quota + */ + public function getQuota(): ?string { + return $this->quota; + } + + /** + * @return array the array of claim values associated with the event + */ + public function getClaims(): object { + return $this->claims; + } + + /** + * @return value for the logged in user attribute + */ + public function getResult(): UserAccountChangeResult { + return $this->result; + } + + public function setResult(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) : void { + $this->result = new UserAccountChangeResult($accessAllowed, $reason, $redirectUrl); + } +} diff --git a/lib/Event/UserAccountChangeResult.php b/lib/Event/UserAccountChangeResult.php new file mode 100644 index 00000000..660e78f9 --- /dev/null +++ b/lib/Event/UserAccountChangeResult.php @@ -0,0 +1,74 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +/** + * Event to provide custom mapping logic based on the OIDC token data + * In order to avoid further processing the event propagation should be stopped + * in the listener after processing as the value might get overwritten afterwards + * by other listeners through $event->stopPropagation(); + */ +class UserAccountChangeResult { + + /** @var bool */ + private $accessAllowed; + /** @var string */ + private $reason; + /** @var string */ + private $redirectUrl; + + public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) { + $this->accessAllowed = $accessAllowed; + $this->redirectUrl = $redirectUrl; + $this->reason = $reason; + } + + /** + * @return value for the logged in user attribute + */ + public function isAccessAllowed(): bool { + return $this->accessAllowed; + } + + public function setAccessAllowed(bool $accessAllowed): void { + $this->accessAllowed = $accessAllowed; + } + + /** + * @return get optional alternate redirect address + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + /** + * @return set optional alternate redirect address + */ + public function setRedirectUrl(?string $redirectUrl): void { + $this->redirectUrl = $redirectUrl; + } + + /** + * @return get decision reason + */ + public function getReason(): string { + return $this->reason; + } + + /** + * @return set decision reason + */ + public function setReason(string $reason): void { + $this->reason = $reason; + } +} diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php new file mode 100644 index 00000000..9f317170 --- /dev/null +++ b/lib/Service/ProvisioningDeniedException.php @@ -0,0 +1,69 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\UserOIDC\Service; + +/** + * Exception if the precondition of the config update method isn't met + * @since 1.4.0 + */ +class ProvisioningDeniedException extends \Exception { + private $redirectUrl; + + /** + * Exception constructor including an option redirect url. + * + * @param string $message The error message. It will be not revealed to the + * the user (unless the hint is empty) and thus + * should be not translated. + * @param string $hint A useful message that is presented to the end + * user. It should be translated, but must not + * contain sensitive data. + * @param int $code Set default to 403 (Forbidden) + * @param \Exception|null $previous + */ + public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, ?\Exception $previous = null) { + parent::__construct($message, $code, $previous); + $this->redirectUrl = $redirectUrl; + } + + /** + * Read optional failure redirect if available + * @return string|null + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + /** + * Include redirect in string serialisation. + * + * @return string + */ + public function __toString(): string { + $redirect = $this->redirectUrl ?? ''; + return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } +} diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php new file mode 100644 index 00000000..d544bd60 --- /dev/null +++ b/lib/Service/ProvisioningEventService.php @@ -0,0 +1,180 @@ + + * + * @author Bernd Rederlechner + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Service; + +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\Event\AttributeMappedEvent; +use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCP\Accounts\IAccountManager; +use OCP\DB\Exception; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClientService; +use OCP\IAvatarManager; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; + +// FIXME there should be an interface for both variations +class ProvisioningEventService extends ProvisioningService { + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var LoggerInterface */ + private $logger; + + /** @var IUserManager */ + private $userManager; + + /** @var ProviderService */ + private $providerService; + + public function __construct( + LocalIdService $idService, + ProviderService $providerService, + UserMapper $userMapper, + IUserManager $userManager, + IGroupManager $groupManager, + IEventDispatcher $eventDispatcher, + LoggerInterface $logger, + IAccountManager $accountManager, + IClientService $clientService, + IAvatarManager $avatarManager, + IConfig $config, + ISession $session, + ) { + parent::__construct($idService, + $providerService, + $userMapper, + $userManager, + $groupManager, + $eventDispatcher, + $logger, + $accountManager, + $clientService, + $avatarManager, + $config, + $session, + ); + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->userManager = $userManager; + $this->providerService = $providerService; + } + + protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) { + $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchDisplayname(int $providerid, object $payload) { + $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (isset($mappedDisplayName)) { + $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); + } else { + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); + } + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchEmail(int $providerid, object $payload) { + $emailAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_EMAIL, 'email'); + $mappedEmail = $payload->{$emailAttribute} ?? null; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_EMAIL, $payload, $mappedEmail); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchQuota(int $providerid, object $payload) { + $quotaAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); + $mappedQuota = $payload->{$quotaAttribute} ?? null; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_QUOTA, $payload, $mappedQuota); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + return $event->getResult(); + } + + /** + * Trigger a provisioning via event system. + * This allows to flexibly implement complex provisioning strategies - + * even in a separate app. + * + * On error, the provisioning logic can deliver failure reasons and + * even a redirect to a different endpoint. + * + * @param string $tokenUserId + * @param int $providerId + * @param object $idTokenPayload + * @param IUser|null $existingLocalUser + * @return array{user: ?IUser, userData: array} + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload, ?IUser $existingLocalUser = null): array { + try { + $uid = $tokenUserId; + $displayname = $this->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $eAttribute) { + $this->logger->info("{$tokenUserId}: user rejected by OpenId web authorization, reason: " . $eAttribute->getMessage()); + throw new ProvisioningDeniedException($eAttribute->getMessage()); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $idTokenPayload); + + if ($userReaction->isAccessAllowed()) { + $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); + $user = $this->userManager->get($uid); + return [ + 'user' => $user, + 'userData' => get_object_vars($idTokenPayload), // optional, analog zu ProvisioningService + ]; + } else { + $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); + } + } +} diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php new file mode 100644 index 00000000..c0955093 --- /dev/null +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -0,0 +1,141 @@ +realOidClaims; + } + + public function getOidClientId() { + return 'USER_NC_OPENID_TEST'; + } + + public function getOidNonce() { + return 'CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K'; + } + + public function getOidClientSecret() { + return \Base64Url\Base64Url::encode('JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'); + } + + public function getOidServerKey() { + return \Base64Url\Base64Url::encode('JQ17DAF8-C99A-4E27-FBW4-GV23B043C993'); + } + + public function getOidPrivateServerKey() { + return [ + 'p' => '9US9kD6Q8nicR1se1U_iRI9x1iK0__HF7E9yhqrza9DHldC2h7PLuR7y9bITAUtcBmVvqEQlVUXRZPMrNUpLFI9hTdZXAACRqYBYGHg7Mvyzq-2JXhEE5CFDy9wSCPunc8bRq4TsY0ocSXugXKGjx-t1uO3fkF1UgNgNMjdzSPM', + 'kty' => 'RSA', + 'q' => '85auJF6W3c91EebGpjMX-g_U0fLBMgO2oxBsldus9x2diRd3wVvUnrTg5fQctODdr4if8dBCPDdLxBUKul4MXULC_nCkGkDjORdESb7j8amGnOvxnaVcQT6C5yHivAawa4R8NchR7n23VrQWO8fHhQBYUHTTy01G3A8D6dznCC8', + 'd' => 'tP-lT4FJBKrhhBUk7J1fR0638jVjn46yIfSaB5l_JlqNItmRJtbz3QWopy4oDfvrY_ccZIYG9tLvJH-3LHtuEddwxFsL-9MSUx5qxWB4sKpKA6EpxWNR5EFnFKxR_B2P2yFYiRDdbBh8h9pNaOuNjZU5iitAGvSOfW4X5hyJyu9t9zsEX9O6stEtP3yK5sx-bt7osGDMIguFBMcPVHbYw_Pl7-aNPuQ4ioxVXa3JlO6tTcDrcyMy7d3CWuGACj3juEnO-1n8E_OSR9sMp1k_L7i-qQ3OnLCOx07HeTWklCvNxz7U9qLcQXGcfpdWmhWZt6MO3SIXV4f6Md0U836v0Q', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => '0123456789', + 'qi' => 'T3-NLCpVoITdS6PB9XYCsXsfhQSiE_4eTQnZf_Zya5hSd0xZDrrwNiXL8Dzy3YLjsZAFC0U6wAeC2wTBJ8c-6VxdP34J0sGj2I_TNhFFArksLy9ZaRbskCxKAPLipEFi8b1H2-aaRFRLs6BQJbfesQ5mcX2kB5AItAX3R6tcc0A', + 'dp' => 'ExUtFor3phXiOt8JEBmuBh2PAtUidgNuncs0ouusEshkrvBVM0u23wlcZ-dZ-TDO0SSVQmdC7FaJSyxsQTItk0TwkijKDhL9Qk3dDNJV8MqehBLwLCRw1_sKllLiCFbkGWrvp0OpTLRYbRM0T-C3qHdWanP_f_DzAS9OH4kW7Cc', + 'alg' => 'RS256', + 'dq' => 'xr3XAWeHkhw0uVFgHLQtSOJn0pBM3qC2_95jqfVc7xZjtTnHhKSHGqIbqKL-VPnvBcvkK-iuUfEPyUEdyqb3UZQqAm0nByCQA8Ge_shXtJGLejbroKMNXVJCfZBhLOYMRP0IVt1FM9-wmXY_ebDrcfGxHJvlPcekG-HIYKPSgBM', + 'n' => '6WCdDo8KuksEFaFlzvmsaoYhfOoMt5XgnX98dx-F1OUz53SG0lQlFt-xkwra5B4GZ-13lki0qCa2CjA1aLa9kEvDdYhz_0Uc5qOy5haDj8Jn547s6gFyaLzJ0RN5i5eKeDMHcjeEC0_NjiB2UNUFJJ61b2nXIlUvp_vBfKCv4A-8C3mLSbCKJQhX84QRDgt_Abz0MXj_ga72Ka2cwVLo4OFQAK5m57Qfu9ZvseMcgoinyhIQ18b98SkWinn3DM0W1KXLkWLk0S3XEMxLV1M7-9RLo4fgEGOpX1xmmM6KbsC5SxXvRUO7tjU-o35fcewDwXYHnRbxqhRkEFfWb7b8nQ' + ]; + } + + + public function getOidPublicServerKey() { + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWK::parseKeySet([ 'keys' => [[ + 'kty' => 'RSA', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => '0123456789', + 'alg' => 'RS256', + 'n' => '6WCdDo8KuksEFaFlzvmsaoYhfOoMt5XgnX98dx-F1OUz53SG0lQlFt-xkwra5B4GZ-13lki0qCa2CjA1aLa9kEvDdYhz_0Uc5qOy5haDj8Jn547s6gFyaLzJ0RN5i5eKeDMHcjeEC0_NjiB2UNUFJJ61b2nXIlUvp_vBfKCv4A-8C3mLSbCKJQhX84QRDgt_Abz0MXj_ga72Ka2cwVLo4OFQAK5m57Qfu9ZvseMcgoinyhIQ18b98SkWinn3DM0W1KXLkWLk0S3XEMxLV1M7-9RLo4fgEGOpX1xmmM6KbsC5SxXvRUO7tjU-o35fcewDwXYHnRbxqhRkEFfWb7b8nQ' + ]]]); + } + + public function getOidTestCode() { + return '66844608'; + } + + public function getOidTestState() { + return '4VSL5T274MJEMLZI1810HUFDA07CEPXZ'; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->realOidClaims = [ + 'sub' => 'jgyros', + 'urn:custom.com:displayname' => 'Jonny G', + 'urn:custom.com:email' => 'jonny.gyros@x.y', + 'urn:custom.com:mainEmail' => 'jonny.gyuris@x.y.de', + 'iss' => "https:\/\/accounts.login00.custom.de", + 'urn:custom.com:feat1' => '0', + 'urn:custom.com:uid' => '081500000001234', + 'urn:custom.com:feat2' => '1', + 'urn:custom.com:ext2' => '0', + 'urn:custom.com:feat3' => '1', + 'acr' => 'urn:custom:names:idm:THO:1.0:ac:classes:passid:00', + 'urn:custom.com:feat4' => '0', + 'urn:custom.com:ext4' => '0', + 'auth_time' => time(), + 'exp' => time() + 7200, + 'iat' => time(), + 'urn:custom.com:session_token' => 'ad0fff71-e013-11ec-9e17-39677d2c891c', + 'nonce' => 'CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K', + 'aud' => ['USER_NC_OPENID_TEST'] ]; + } + + protected function createSignToken(array $claims) : string { + // The algorithm manager with the HS256 algorithm. + $algorithmManager = new AlgorithmManager([ + new RS256(), + ]); + + // use a different key for an invalid signature + $jwk = new JWK($this->getOidPrivateServerKey()); + $jwsBuilder = new JWSBuilder($algorithmManager); + $jws = $jwsBuilder->create() // We want to create a new JWS + ->withPayload(json_encode($claims)) // We set the payload + ->addSignature($jwk, ['alg' => 'RS256', 'kid' => '0123456789']) // We add a signature with a simple protected header + ->build(); + + $serializer = new CompactSerializer(); + return $serializer->serialize($jws, 0); + } +} diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php new file mode 100644 index 00000000..85ac4d2a --- /dev/null +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -0,0 +1,481 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\Authentication\Token\IProvider; +use OC\Security\Crypto; +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\BaseTest\OpenidTokenTestCase; +use OCA\UserOIDC\Controller\LoginController; +use OCA\UserOIDC\Db\Provider; +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Db\SessionMapper; +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\Event\AttributeMappedEvent; +use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCA\UserOIDC\Service\DiscoveryService; +use OCA\UserOIDC\Service\LdapService; +use OCA\UserOIDC\Service\LocalIdService; +use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCP\Accounts\IAccountManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; +use OCP\IAvatarManager; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IL10N; // deprecated! +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; + + +use OCP\Security\ISecureRandom; + +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; + +class ProvisioningEventServiceTest extends OpenidTokenTestCase { + /** + * Set up needed system and app configurations + */ + protected function getConfigSetup() :MockObject { + $config = $this->getMockForAbstractClass(IConfig::class); + + $config->expects($this->any()) + ->method('getSystemValue') + ->with($this->logicalOr($this->equalTo('user_oidc'), $this->equalTo('secret'))) + ->willReturn($this->returnCallback(function ($key, $default) { + if ($key == 'user_oidc') { + return [ + 'auto_provisioning' => true, + ]; + } elseif ($key == 'secret') { + return 'Streng_geheim'; + } + })); + return $config; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getOidSessionSetup() :MockObject { + $session = $this->getMockForAbstractClass(ISession::class); + + $session->expects($this->any()) + ->method('get') + ->willReturn($this->returnCallback(function ($key) { + $values = [ + 'oidc.state' => $this->getOidTestState(), + 'oidc.providerid' => $this->getProviderId(), + 'oidc.nonce' => $this->getOidNonce(), + 'oidc.redirect' => 'https://welcome.to.magenta' + ]; + + return $values[$key] ? $values[$key] : 'some_' . $key; + })); + $this->sessionMapper = $this->getMockBuilder(SessionMapper::class) + ->setConstructorArgs([ $this->getMockForAbstractClass(IDBConnection::class) ]) + ->getMock(); + $this->sessionMapper->expects($this->any()) + ->method('createSession'); + + return $session; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getProviderSetup() :MockObject { + $provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getClientId', 'getClientSecret']) + ->getMock(); + $provider->expects($this->any()) + ->method('getClientId') + ->willReturn($this->getOidClientId()); + $provider->expects($this->once()) + ->method('getClientSecret') + ->willReturn($this->crypto->encrypt($this->getOidClientSecret())); + $this->providerMapper->expects($this->once()) + ->method('getProvider') + ->with($this->equalTo($this->getProviderId())) + ->willReturn($provider); + + return $provider; + } + + + /** + * Prepare a proper mapping configuration for the provider + */ + protected function getProviderServiceSetup() :MockObject { + $providerService = $this->getMockBuilder(ProviderService::class) + ->setConstructorArgs([ $this->config, $this->providerMapper]) + ->getMock(); + $providerService->expects($this->any()) + ->method('getSetting') + ->with($this->equalTo($this->getProviderId()), $this->logicalOr( + $this->equalTo(ProviderService::SETTING_MAPPING_UID), + $this->equalTo(ProviderService::SETTING_MAPPING_DISPLAYNAME), + $this->equalTo(ProviderService::SETTING_MAPPING_QUOTA), + $this->equalTo(ProviderService::SETTING_MAPPING_EMAIL), + $this->anything())) + ->will($this->returnCallback(function ($providerid, $key, $default):string { + $values = [ + ProviderService::SETTING_MAPPING_UID => 'sub', + ProviderService::SETTING_MAPPING_DISPLAYNAME => 'urn:custom.com:displayname', + ProviderService::SETTING_MAPPING_QUOTA => 'urn:custom.com:f556', + ProviderService::SETTING_MAPPING_EMAIL => 'urn:custom.com:mainEmail' + ]; + return $values[$key]; + })); + return $providerService; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getUserManagerSetup() :MockObject { + $userManager = $this->getMockForAbstractClass(IUserManager::class); + $this->user = $this->getMockForAbstractClass(IUser::class); + $this->user->expects($this->any()) + ->method('canChangeAvatar') + ->willReturn(false); + + return $userManager; + } + + + /** + * This is the standard execution sequence until provisoning + * is triggered in LoginController, set up with an artificial + * yet valid OpenID token. + */ + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->config = $this->getConfigSetup(); + $this->crypto = $this->getMockBuilder(Crypto::class) + ->setConstructorArgs([ $this->config ]) + ->getMock(); + + $this->request = $this->getMockForAbstractClass(IRequest::class); + $this->request->expects($this->once()) + ->method('getServerProtocol') + ->willReturn('https'); + $this->providerMapper = $this->getMockBuilder(ProviderMapper::class) + ->setConstructorArgs([ $this->getMockForAbstractClass(IDBConnection::class) ]) + ->getMock(); + $this->provider = $this->getProviderSetup(); + $this->providerService = $this->getProviderServiceSetup(); + $this->localIdService = $this->getMockBuilder(LocalIdService::class) + ->setConstructorArgs([ $this->providerService, + $this->providerMapper]) + ->getMock(); + $this->userMapper = $this->getMockBuilder(UserMapper::class) + ->setConstructorArgs([ $this->getMockForAbstractClass(IDBConnection::class), + $this->localIdService ]) + ->getMock(); + $this->discoveryService = $this->getMockBuilder(DiscoveryService::class) + ->setConstructorArgs([ $this->app->getContainer()->get(LoggerInterface::class), + $this->getMockForAbstractClass(IClientService::class), + $this->providerService, + $this->app->getContainer()->get(ICacheFactory::class) ]) + ->getMock(); + $this->discoveryService->expects($this->once()) + ->method('obtainDiscovery') + ->willReturn([ 'token_endpoint' => 'https://whatever.to.discover/token', + 'issuer' => 'https:\/\/accounts.login00.custom.de' ]); + $this->discoveryService->expects($this->once()) + ->method('obtainJWK') + ->willReturn($this->getOidPublicServerKey()); + $this->session = $this->getOidSessionSetup(); + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->response = $this->getMockForAbstractClass(IResponse::class); + //$this->usersession = $this->getMockForAbstractClass(IUserSession::class); + $this->usersession = $this->getMockBuilder(IUserSession::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'setUser', + 'login', + 'logout', + 'getUser', + 'isLoggedIn', + 'getImpersonatingUserID', + 'setImpersonatingUserID', + 'setVolatileActiveUser' // Diese Methode hinzufügen, falls sie gebraucht wird. + ]) + ->addMethods([ + 'completeLogin', + 'createSessionToken', + 'createRememberMeToken' + ]) + ->getMock(); + + $this->usermanager = $this->getUserManagerSetup(); + $this->groupmanager = $this->getMockForAbstractClass(IGroupManager::class); + $this->dispatcher = $this->app->getContainer()->get(IEventDispatcher::class); + + $this->provisioningService = new ProvisioningEventService( + $this->app->getContainer()->get(LocalIdService::class), + $this->providerService, + $this->userMapper, + $this->usermanager, + $this->groupmanager, + $this->dispatcher, + $this->app->getContainer()->get(LoggerInterface::class), + $this->app->getContainer()->get(IAccountManager::class), + $this->app->getContainer()->get(IClientService::class), + $this->app->getContainer()->get(IAvatarManager::class), + $this->app->getContainer()->get(IConfig::class)); + // here is where the token magic comes in + $this->token = [ 'id_token' => + $this->createSignToken($this->getRealOidClaims(), + $this->getOidServerKey())]; + $this->tokenResponse = $this->getMockForAbstractClass(IResponse::class); + $this->tokenResponse->expects($this->once()) + ->method('getBody') + ->willReturn(json_encode($this->token)); + + // mock token retrieval + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->client->expects($this->once()) + ->method('post') + ->with($this->equalTo('https://whatever.to.discover/token'), $this->arrayHasKey('body')) + ->willReturn($this->tokenResponse); + $this->clientService = $this->getMockForAbstractClass(IClientService::class); + $this->clientService->expects($this->once()) + ->method('newClient') + ->willReturn($this->client); + $this->registrationContext = + $this->app->getContainer()->get(Coordinator::class)->getRegistrationContext(); + $this->loginController = new LoginController($this->request, + $this->providerMapper, + $this->providerService, + $this->discoveryService, + $this->app->getContainer()->get(LdapService::class), + $this->app->getContainer()->get(ISecureRandom::class), + $this->session, + $this->clientService, + $this->app->getContainer()->get(IUrlGenerator::class), + $this->usersession, + $this->usermanager, + $this->app->getContainer()->get(ITimeFactory::class), + $this->dispatcher, + $this->config, + $this->app->getContainer()->get(IProvider::class), + $this->sessionMapper, + $this->provisioningService, + $this->app->getContainer()->get(IL10N::class), + $this->app->getContainer()->get(LoggerInterface::class), + $this->crypto); + + $this->attributeListener = null; + $this->accountListener = null; + } + + /** + * Seems like the event dispatcher requires explicit unregistering + */ + public function tearDown(): void { + parent::tearDown(); + if ($this->accountListener != null) { + $this->dispatcher->removeListener(UserAccountChangeEvent::class, $this->accountListener); + } + if ($this->attributeListener != null) { + $this->dispatcher->removeListener(AttributeMappedEvent::class, $this->attributeListener); + } + } + + protected function mockAssertLoginSuccess() { + $this->usermanager->expects($this->once()) + ->method('get') + ->willReturn($this->user); + $this->session->expects($this->exactly(2)) + ->method('set') + ->withConsecutive([$this->equalTo('oidc.id_token'), $this->anything()], + [$this->equalTo('last-password-confirm'), $this->anything()]); + $this->usersession->expects($this->once()) + ->method('setUser') + ->with($this->equalTo($this->user)); + $this->usersession->expects($this->once()) + ->method('completeLogin') + ->with($this->anything(), $this->anything()); + $this->usersession->expects($this->once()) + ->method('createSessionToken'); + $this->usersession->expects($this->once()) + ->method('createRememberMeToken'); + } + + protected function assertLoginRedirect($result) { + $this->assertInstanceOf(RedirectResponse::class, + $result, 'LoginController->code() did not end with success redirect: Status: ' . + strval($result->getStatus() . ' ' . json_encode($result->getThrottleMetadata()))); + } + + protected function assertLogin403($result) { + $this->assertInstanceOf(TemplateResponse::class, + $result, 'LoginController->code() did not end with 403 Forbidden: Actual status: ' . + strval($result->getStatus() . ' ' . json_encode($result->getThrottleMetadata()))); + } + + /** + * Test with the default mapping, no mapping by attribute events + * provisioning with successful result. + */ + public function testNoMap_AccessOk() { + $this->mockAssertLoginSuccess(); + $this->accountListener = function (Event $event) :void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayname()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + $event->setResult(true, 'ok', null); + }; + + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('https://welcome.to.magenta', $result->getRedirectURL()); + } + + /** + * For multiple reasons, uid should com directly from a token + * field, usually sub. Thus, uid is not remapped by event, even + * if you try with a listener. + */ + public function testUidNoMapEvent_AccessOk() { + $this->mockAssertLoginSuccess(); + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_UID) { + $this->fail('UID event mapping not supported'); + } + }; + $this->accountListener = function (Event $event) :void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayname()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + $event->setResult(true, 'ok', 'https://welcome.to.darkside'); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('https://welcome.to.magenta', $result->getRedirectURL()); + } + + + + /** + * Test displayname set by event scheduling and negative result + */ + public function testDisplaynameMapEvent_NOk_NoRedirect() { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue('Lisa, Mona'); + } + }; + $this->accountListener = function (Event $event) :void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Lisa, Mona', $event->getDisplayname()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + $event->setResult(false, 'not an original', null); + }; + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLogin403($result); + } + + public function testMainEmailMap_Nok_Redirect() { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_EMAIL) { + //$defaultUID = $event->getValue(); + $event->setValue('mona.lisa@louvre.fr'); + } + }; + + $this->accountListener = function (Event $event) :void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayname()); + $this->assertEquals('mona.lisa@louvre.fr', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + $event->setResult(false, 'under restoration', 'https://welcome.to.louvre'); + }; + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('https://welcome.to.louvre', $result->getRedirectURL()); + } + + public function testDisplaynameUidQuotaMapped_AccessOK() { + $this->mockAssertLoginSuccess(); + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent) { + if ($event->getAttribute() == ProviderService::SETTING_MAPPING_UID) { + $this->fail('UID event mapping not supported'); + } elseif ($event->getAttribute() == ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue('Lisa, Mona'); + } elseif ($event->getAttribute() == ProviderService::SETTING_MAPPING_QUOTA) { + $event->setValue('5 TB'); + } + } + }; + $this->accountListener = function (Event $event) :void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Lisa, Mona', $event->getDisplayname()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertEquals('5 TB', $event->getQuota()); + $event->setResult(true, 'ok', 'https://welcome.to.louvre'); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('https://welcome.to.magenta', $result->getRedirectURL()); + } +} diff --git a/tests/unit/MagentaCloud/RegistrationsTest.php b/tests/unit/MagentaCloud/RegistrationsTest.php new file mode 100644 index 00000000..eb714da9 --- /dev/null +++ b/tests/unit/MagentaCloud/RegistrationsTest.php @@ -0,0 +1,33 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +use OC\AppFramework\Bootstrap\Coordinator; +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCA\UserOIDC\Service\ProvisioningService; + +use PHPUnit\Framework\TestCase; + +class RegistrationsTest extends TestCase { + public function setUp() :void { + parent::setUp(); + + $this->app = new Application(); + $coordinator = \OC::$server->get(Coordinator::class); + $this->app->register($coordinator->getRegistrationContext()->for('user_oidc')); + } + + public function testRegistration() :void { + $provisioningService = $this->app->getContainer()->get(ProvisioningService::class); + $this->assertInstanceOf(ProvisioningEventService::class, $provisioningService); + } +} From 9e9e6687da9d96cc1b58af6668a5b1045ca482d2 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 17:26:02 +0100 Subject: [PATCH 20/33] added bearer token handling --- lib/MagentaBearer/InvalidTokenException.php | 8 + lib/MagentaBearer/MBackend.php | 199 +++++++++++++++ lib/MagentaBearer/SignatureException.php | 6 + lib/MagentaBearer/TokenService.php | 203 +++++++++++++++ lib/User/AbstractOidcBackend.php | 220 ++++++++++++++++ .../MagentaCloud/BearerTokenServiceTest.php | 103 ++++++++ .../unit/MagentaCloud/BearerTokenTestCase.php | 225 +++++++++++++++++ .../MagentaCloud/HeaderBearerTokenTest.php | 236 ++++++++++++++++++ .../unit/MagentaCloud/SamBearerTokenTest.php | 85 +++++++ 9 files changed, 1285 insertions(+) create mode 100644 lib/MagentaBearer/InvalidTokenException.php create mode 100644 lib/MagentaBearer/MBackend.php create mode 100644 lib/MagentaBearer/SignatureException.php create mode 100644 lib/MagentaBearer/TokenService.php create mode 100644 lib/User/AbstractOidcBackend.php create mode 100644 tests/unit/MagentaCloud/BearerTokenServiceTest.php create mode 100644 tests/unit/MagentaCloud/BearerTokenTestCase.php create mode 100644 tests/unit/MagentaCloud/HeaderBearerTokenTest.php create mode 100644 tests/unit/MagentaCloud/SamBearerTokenTest.php diff --git a/lib/MagentaBearer/InvalidTokenException.php b/lib/MagentaBearer/InvalidTokenException.php new file mode 100644 index 00000000..af97581b --- /dev/null +++ b/lib/MagentaBearer/InvalidTokenException.php @@ -0,0 +1,8 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\UserOIDC\MagentaBearer; + +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Db\Provider; +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\Event\TokenValidatedEvent; +use OCA\UserOIDC\Service\DiscoveryService; +use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\ProvisioningDeniedException; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCA\UserOIDC\User\AbstractOidcBackend; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\Security\ICrypto; +use Psr\Log\LoggerInterface; + +class MBackend extends AbstractOidcBackend { + + /** + * @var TokenService + */ + protected $mtokenService; + + /** + * @var ProvisioningEventService + */ + protected $provisioningService; + + /** + * @var ICrypto + */ + protected $crypto; + + public function __construct(IConfig $config, + UserMapper $userMapper, + LoggerInterface $logger, + IRequest $request, + ISession $session, + IURLGenerator $urlGenerator, + IEventDispatcher $eventDispatcher, + DiscoveryService $discoveryService, + ProviderMapper $providerMapper, + ProviderService $providerService, + IUserManager $userManager, + ICrypto $crypto, + TokenService $mtokenService, + ProvisioningEventService $provisioningService, + ) { + parent::__construct($config, $userMapper, $logger, $request, $session, + $urlGenerator, $eventDispatcher, $discoveryService, + $providerMapper, $providerService, $userManager); + + $this->mtokenService = $mtokenService; + $this->provisioningService = $provisioningService; + $this->crypto = $crypto; + } + + public function getBackendName(): string { + return Application::APP_ID . '\\MagentaBearer'; + } + + /** + * Backend is activated if header bearer token is detected. + * + * @return bool ture if bearer header found + */ + public function isSessionActive(): bool { + // if this returns true, getCurrentUserId is called + // not sure if we should rather to the validation in here as otherwise it might fail for other backends or bave other side effects + $headerToken = $this->request->getHeader(Application::OIDC_API_REQ_HEADER); + // session is active if we have a bearer token (API request) OR if we logged in via user_oidc (we have a provider ID in the session) + return (preg_match('/^\s*bearer\s+/i', $headerToken) != false); + } + + /** + * Return the id of the current user + * @return string + */ + public function getCurrentUserId(): string { + // get the bearer token from headers + $headerToken = $this->request->getHeader(Application::OIDC_API_REQ_HEADER); + $headerToken = preg_replace('/^bearer\s+/i', '', $headerToken); + if ($headerToken === '') { + $this->logger->debug('No Bearer token'); + return ''; + } + + $providers = $this->providerMapper->getProviders(); + if (count($providers) === 0) { + $this->logger->debug('no OIDC providers'); + return ''; + } + + // we implement only Telekom behavior (which includes auto-provisioning) + // so we neglect switches from the upstream Nexrcloud oidc handling + + // try to validate with all providers + foreach ($providers as $provider) { + if ($this->providerService->getSetting($provider->getId(), ProviderService::SETTING_CHECK_BEARER, '0') === '1') { + 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 $eToken) { + // there is + $this->logger->debug('Invalid token:' . $eToken->getMessage() . '. Trying another provider.'); + continue; + } catch (SignatureException $eSignature) { + // only the key seems not to fit, so try the next provider + $this->logger->debug($eSignature->getMessage() . '. Trying another provider.'); + continue; + } catch (\Throwable $e) { + // there is + $this->logger->debug('General non matching provider problem:' . $e->getMessage()); + continue; + } + + $uidAttribute = $this->providerService->getSetting($provider->getId(), ProviderService::SETTING_MAPPING_UID, 'sub'); + $userId = $payload->{$uidAttribute}; + if ($userId === null) { + $this->logger->debug('No extractable user id, check mapping!'); + return ''; + } + + // check bearercache here, not skipping validation for security reasons + + // Telekom bearer does not support refersh_token, so the pupose of TokenValidatedEvent is not given, + // but could produce trouble if not send with the field, apart from performance aspects. + // + // $discovery = $this->discoveryService->obtainDiscovery($provider); + // $this->eventDispatcher->dispatchTyped(new TokenValidatedEvent(['token' => $payload], $provider, $discovery)); + + try { + $this->provisioningService->provisionUser($userId, $provider->getId(), $payload); + $this->checkFirstLogin($userId); // create the folders same as on web login + return $userId; + } catch (ProvisioningDeniedException $denied) { + $this->logger->error('Bearer token access denied: ' . $denied->getMessage()); + return ''; + } + } + } + + $this->logger->debug('Could not find provider for token'); + return ''; + } + + /** + * FIXXME: send proper error status from BAckend errors + * + * This function sets an https status code here (early in the failing backend operation) + * to pass on bearer errors cleanly with correct status code and a readable reason + * + * For this, there is a "tricky" setting of a header needed to make it working in all + * known situations, see + * https://stackoverflow.com/questions/3258634/php-how-to-send-http-response-code + */ + // protected function sendHttpStatus(int $httpStatusCode, string $httpStatusMsg) { + // $phpSapiName = substr(php_sapi_name(), 0, 3); + // if ($phpSapiName == 'cgi' || $phpSapiName == 'fpm') { + // header('Status: ' . $httpStatusCode . ' ' . $httpStatusMsg); + // } else { + // $protocol = isset($_SERVER['SERVER_PROTOCOL']) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0'; + // header($protocol . ' ' . $httpStatusCode . ' ' . $httpStatusMsg); + // } + // } +} diff --git a/lib/MagentaBearer/SignatureException.php b/lib/MagentaBearer/SignatureException.php new file mode 100644 index 00000000..ef04a4e0 --- /dev/null +++ b/lib/MagentaBearer/SignatureException.php @@ -0,0 +1,6 @@ + + * + * @author Bernd Rederlechner + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +declare(strict_types=1); + +namespace OCA\UserOIDC\MagentaBearer; + +use Jose\Component\Core\Algorithm; +use Jose\Component\Core\AlgorithmManager; + +use Jose\Component\Core\JWK; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW; + +use Jose\Component\Encryption\Algorithm\KeyEncryption\PBES2HS512A256KW; +use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256; +use Jose\Component\Encryption\Compression\CompressionMethodManager; + +use Jose\Component\Encryption\Compression\Deflate; +use Jose\Component\Encryption\JWEDecrypter; +use Jose\Component\Signature\Algorithm\HS256; +use Jose\Component\Signature\Algorithm\HS384; + +use Jose\Component\Signature\Algorithm\HS512; +use Jose\Component\Signature\JWS; +use Jose\Component\Signature\JWSVerifier; +use OCP\AppFramework\Utility\ITimeFactory; +use Psr\Log\LoggerInterface; + +class TokenService { + + /** @var LoggerInterface */ + private $logger; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var DiscoveryService */ + private $discoveryService; + + public function __construct(LoggerInterface $logger, + ITimeFactory $timeFactory) { + $this->logger = $logger; + $this->timeFactory = $timeFactory; + + // The key encryption algorithm manager with the A256KW algorithm. + $keyEncryptionAlgorithmManager = new AlgorithmManager([ + new PBES2HS512A256KW(), + new RSAOAEP256(), + new ECDHESA256KW() ]); + + // The content encryption algorithm manager with the A256CBC-HS256 algorithm. + $contentEncryptionAlgorithmManager = new AlgorithmManager([ + new A256CBCHS512(), + ]); + + // The compression method manager with the DEF (Deflate) method. + $compressionMethodManager = new CompressionMethodManager([ + new Deflate(), + ]); + + $signatureAlgorithmManager = new AlgorithmManager([ + new HS256(), + new HS384(), + new HS512(), + ]); + + // We instantiate our JWE Decrypter. + $this->jweDecrypter = new JWEDecrypter( + $keyEncryptionAlgorithmManager, + $contentEncryptionAlgorithmManager, + $compressionMethodManager + ); + + // We try to load the token. + $this->encryptionSerializerManager = new \Jose\Component\Encryption\Serializer\JWESerializerManager([ + new \Jose\Component\Encryption\Serializer\CompactSerializer(), + ]); + + + // We instantiate our JWS Verifier. + $this->jwsVerifier = new JWSVerifier( + $signatureAlgorithmManager + ); + + // The serializer manager. We only use the JWE Compact Serialization Mode. + $this->serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ + new \Jose\Component\Signature\Serializer\CompactSerializer(), + ]); + } + + /** + * Implement JOSE decryption for SAM3 tokens + */ + public function decryptToken(string $rawToken, string $decryptKey) : JWS { + + // web-token library does not like underscores in headers, so replace them with - (which is valid in JWT) + $numSegments = substr_count($rawToken, '.') + 1; + $this->logger->debug('Bearer access token(segments=' . $numSegments . ')=' . $rawToken); + if ($numSegments > 3) { + // trusted authenticator and myself share the client secret, + // so use it is used for encrypted web tokens + $clientSecret = new JWK([ + 'kty' => 'oct', + 'k' => $decryptKey + ]); + + $jwe = $this->encryptionSerializerManager->unserialize($rawToken); + + // We decrypt the token. This method does NOT check the header. + if ($this->jweDecrypter->decryptUsingKey($jwe, $clientSecret, 0)) { + return $this->serializerManager->unserialize($jwe->getPayload()); + } else { + throw new InvalidTokenException('Unknown bearer encryption format'); + } + } else { + return $this->serializerManager->unserialize($rawToken); + } + } + + /** + * Get claims (even before verification to access e.g. aud standard field ...) + * Transform them in a format compatible with id_token representation. + */ + public function decode(JWS $decodedToken) : object { + $this->logger->debug('Telekom SAM3 access token: ' . $decodedToken->getPayload()); + $samContent = json_decode($decodedToken->getPayload(), false); + + // remap all the custom claims + // adapt into OpenId id_token format (as far as possible) + $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'}; + foreach ($claimArray as $claimKeyValue) { + $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value; + } + unset($samContent->{'urn:telekom.com:idm:at:attributes'}); + + $this->logger->debug('Adapted OpenID-like token; ' . json_encode($samContent)); + return $samContent; + } + + + public function verifySignature(JWS $decodedToken, string $signKey) { + $accessSecret = new JWK([ + 'kty' => 'oct', + 'k' => $signKey + ]); // TODO: take the additional access key secret from settings + + if (!$this->jwsVerifier->verifyWithKey($decodedToken, $accessSecret, 0)) { + throw new SignatureException('Invalid Signature'); + } + } + + public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60) { + $timestamp = $this->timeFactory->getTime(); + + // Check the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($claims->nbf) && $claims->nbf > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->nbf) + ); + } + + // Check that this token has been created before 'now'. This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (isset($claims->iat) && $claims->iat > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->iat) + ); + } + + // Check if this token has expired. + if (isset($claims->exp) && ($timestamp - $leeway) >= $claims->exp) { + throw new InvalidTokenException('Expired token'); + } + + // Check target audience (if given) + // Check if this token has expired. + if (empty(array_intersect($claims->aud, $audiences))) { + throw new InvalidTokenException('No acceptable audience in token.'); + } + } +} diff --git a/lib/User/AbstractOidcBackend.php b/lib/User/AbstractOidcBackend.php new file mode 100644 index 00000000..d1e5de7b --- /dev/null +++ b/lib/User/AbstractOidcBackend.php @@ -0,0 +1,220 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\UserOIDC\User; + +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\Service\DiscoveryService; +use OCA\UserOIDC\Service\ProviderService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Authentication\IApacheBackend; +use OCP\DB\Exception; +use OCP\EventDispatcher\GenericEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\NotPermittedException; +use OCP\IConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\User\Backend\ABackend; +use OCP\User\Backend\ICustomLogout; +use OCP\User\Backend\IGetDisplayNameBackend; +use OCP\User\Backend\IPasswordConfirmationBackend; +use Psr\Log\LoggerInterface; + +/** + * Introduce a baseclass to derive multiple backend from depending on + * the required bearer behavior. + * + * The class contains the OIDC part without the bearer aspects. + * + * FIXME: we should derive also the previous standard bearer backend from + * this class + */ +abstract class AbstractOidcBackend extends ABackend implements IPasswordConfirmationBackend, IGetDisplayNameBackend, IApacheBackend, ICustomLogout { + + /** @var UserMapper */ + protected $userMapper; + /** @var LoggerInterface */ + protected $logger; + /** @var IRequest */ + protected $request; + /** @var ProviderMapper */ + protected $providerMapper; + /** + * @var ProviderService + */ + protected $providerService; + /** + * @var IConfig + */ + protected $config; + /** + * @var IEventDispatcher + */ + protected $eventDispatcher; + /** + * @var DiscoveryService + */ + protected $discoveryService; + /** + * @var IURLGenerator + */ + protected $urlGenerator; + /** + * @var ISession + */ + protected $session; + /** + * @var IUserManager + */ + protected $userManager; + + public function __construct(IConfig $config, + UserMapper $userMapper, + LoggerInterface $logger, + IRequest $request, + ISession $session, + IURLGenerator $urlGenerator, + IEventDispatcher $eventDispatcher, + DiscoveryService $discoveryService, + ProviderMapper $providerMapper, + ProviderService $providerService, + IUserManager $userManager) { + $this->config = $config; + $this->userMapper = $userMapper; + $this->logger = $logger; + $this->request = $request; + $this->providerMapper = $providerMapper; + $this->providerService = $providerService; + $this->eventDispatcher = $eventDispatcher; + $this->discoveryService = $discoveryService; + $this->session = $session; + $this->urlGenerator = $urlGenerator; + $this->userManager = $userManager; + } + + public function deleteUser($uid): bool { + try { + $user = $this->userMapper->getUser($uid); + $this->userMapper->delete($user); + return true; + } catch (Exception $e) { + $this->logger->error('Failed to delete user', [ 'exception' => $e ]); + return false; + } + } + + public function getUsers($search = '', $limit = null, $offset = null) { + return array_map(function ($user) { + return $user->getUserId(); + }, $this->userMapper->find($search, $limit, $offset)); + } + + public function userExists($uid): bool { + return $this->userMapper->userExists($uid); + } + + public function getDisplayName($uid): string { + try { + $user = $this->userMapper->getUser($uid); + } catch (DoesNotExistException $e) { + return $uid; + } + + return $user->getDisplayName(); + } + + public function getDisplayNames($search = '', $limit = null, $offset = null): array { + return $this->userMapper->findDisplayNames($search, $limit, $offset); + } + + public function hasUserListings(): bool { + return true; + } + + public function canConfirmPassword(string $uid): bool { + return false; + } + + /** + * As session cannot be injected in the constructor here, we inject it later + * + * @param ISession $session + * @return void + */ + public function injectSession(ISession $session): void { + $this->session = $session; + } + + /** + * {@inheritdoc} + */ + public function getLogoutUrl(): string { + return $this->urlGenerator->linkToRouteAbsolute( + 'user_oidc.login.singleLogoutService', + [ + 'requesttoken' => \OC::$server->getCsrfTokenManager()->getToken()->getEncryptedValue(), + ] + ); + } + + + /** + * Inspired by lib/private/User/Session.php::prepareUserLogin() + * + * @param string $userId + * @return bool + * @throws NotFoundException + */ + protected function checkFirstLogin(string $userId): bool { + $user = $this->userManager->get($userId); + + if ($user === null) { + return false; + } + + $firstLogin = $user->getLastLogin() === 0; + if ($firstLogin) { + \OC_Util::setupFS($userId); + // trigger creation of user home and /files folder + $userFolder = \OC::$server->getUserFolder($userId); + try { + // copy skeleton + \OC_Util::copySkeleton($userId, $userFolder); + } catch (NotPermittedException $ex) { + // read only uses + } + + // trigger any other initialization + \OC::$server->getEventDispatcher()->dispatch(IUser::class . '::firstLogin', new GenericEvent($user)); + } + $user->updateLastLoginTimestamp(); + return $firstLogin; + } +} diff --git a/tests/unit/MagentaCloud/BearerTokenServiceTest.php b/tests/unit/MagentaCloud/BearerTokenServiceTest.php new file mode 100644 index 00000000..2c6ab49c --- /dev/null +++ b/tests/unit/MagentaCloud/BearerTokenServiceTest.php @@ -0,0 +1,103 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +use OCA\UserOIDC\MagentaBearer\SignatureException; +use OCA\UserOIDC\MagentaBearer\TokenService; + +use PHPUnit\Framework\TestCase; + +class BearerTokenServiceTest extends TestCase { + public const EXPIRED_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdHMwMC5pZG0udmVyLnN1bC50LW9ubGluZS5kZSIsInVybjp0ZWxla29tLmNvbTppZG06YXQ6c3ViamVjdFR5cGUiOnsiZm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6bmFtZWlkLWZvcm1hdDphbmlkIiwicmVhbG0iOiJ2ZXIuc3VsLnQtb25saW5lLmRlIn0sImFjciI6InVybjp0ZWxla29tOm5hbWVzOmlkbTpUSE86MS4wOmFjOmNsYXNzZXM6cHdkIiwic3ViIjoiMTIwMDQ5MDEwMDAwMDAwMDA3MjEwMjA3IiwiaWF0IjoxNjM1NTgxODAyLCJuYmYiOjE2MzU1ODE4MDIsImV4cCI6MTYzNTU4OTAwMiwidXJuOnRlbGVrb20uY29tOmlkbTphdDphdXRoTlN0YXRlbWVudHMiOnsidXJuOnRlbGVrb206bmFtZXM6aWRtOlRITzoxLjA6YWM6Y2xhc3Nlczpwd2QiOnsiYXV0aGVudGljYXRpbmdBdXRob3JpdHkiOm51bGwsImF1dGhOSW5zdGFudCI6MTYzNTU4MTUzNX19LCJhdWQiOlsiaHR0cDovL2F1dGgubWFnZW50YWNsb3VkLmRlIl0sImp0aSI6IlNUUy0xZTIyYTA2Zi03OTBjLTQwZmItYWQxZC02ZGUyZGRjZjI0MzEiLCJ1cm46dGVsZWtvbS5jb206aWRtOmF0OmF0dHJpYnV0ZXMiOlt7Im5hbWUiOiJjbGllbnRfaWQiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIxMFRWTDBTQU0zMDAwMDAwNDkwMU5FWFRNQUdFTlRBQ0xPVUQwMDAwIn0seyJuYW1lIjoiZGlzcGxheW5hbWUiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiJubWNsb3VkMDFAdmVyLnN1bC50LW9ubGluZS5kZSJ9LHsibmFtZSI6ImVtYWlsIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoibm1jbG91ZDAxQHZlci5zdWwudC1vbmxpbmUuZGUifSx7Im5hbWUiOiJhbmlkIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMTIwMDQ5MDEwMDAwMDAwMDA3MjEwMjA3In0seyJuYW1lIjoiZDU1NiIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjAifSx7Im5hbWUiOiJkb210IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoidmVyLnN1bC50LW9ubGluZS5kZSJ9LHsibmFtZSI6ImYwNDgiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIxIn0seyJuYW1lIjoiZjA0OSIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjEifSx7Im5hbWUiOiJmMDUxIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMCJ9LHsibmFtZSI6ImY0NjAiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIwIn0seyJuYW1lIjoiZjQ2NyIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjAifSx7Im5hbWUiOiJmNDY4IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMCJ9LHsibmFtZSI6ImY0NjkiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIwIn0seyJuYW1lIjoiZjQ3MSIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjAifSx7Im5hbWUiOiJmNTU2IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMSJ9LHsibmFtZSI6ImY3MzQiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIwIn0seyJuYW1lIjoibWFpbkVtYWlsIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoibm1jbG91ZDAxQHZlci5zdWwudC1vbmxpbmUuZGUifSx7Im5hbWUiOiJzNTU2IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMCJ9LHsibmFtZSI6InVzdGEiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIxIn1dLCJ1cm46dGVsZWtvbS5jb206aWRtOmF0OnZlcnNpb24iOiIxLjAifQ.5zbr7Uvx2KmU8uR412jHhptWEjykJ_n2awBRcQL8fLE'; + public const INVALID_SIGN_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdHMwMC5pZG0udmVyLnN1bC50LW9ubGluZS5kZSIsInVybjp0ZWxla29tLmNvbTppZG06YXQ6c3ViamVjdFR5cGUiOnsiZm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6bmFtZWlkLWZvcm1hdDphbmlkIiwicmVhbG0iOiJ2ZXIuc3VsLnQtb25saW5lLmRlIn0sImFjciI6InVybjp0ZWxla29tOm5hbWVzOmlkbTpUSE86MS4wOmFjOmNsYXNzZXM6cHdkIiwic3ViIjoiMTIwMDQ5MDEwMDAwMDAwMDA3MjEwMjA3IiwiaWF0IjoxNjM1NTgxODAyLCJuYmYiOjE2MzU1ODE4MDIsImV4cCI6MTYzNTU4OTAwMiwidXJuOnRlbGVrb20uY29tOmlkbTphdDphdXRoTlN0YXRlbWVudHMiOnsidXJuOnRlbGVrb206bmFtZXM6aWRtOlRITzoxLjA6YWM6Y2xhc3Nlczpwd2QiOnsiYXV0aGVudGljYXRpbmdBdXRob3JpdHkiOm51bGwsImF1dGhOSW5zdGFudCI6MTYzNTU4MTUzNX19LCJhdWQiOlsiaHR0cDovL2F1dGgubWFnZW50YWNsb3VkLmRlIl0sImp0aSI6IlNUUy0xZTIyYTA2Zi03OTBjLTQwZmItYWQxZC02ZGUyZGRjZjI0MzEiLCJ1cm46dGVsZWtvbS5jb206aWRtOmF0OmF0dHJpYnV0ZXMiOlt7Im5hbWUiOiJjbGllbnRfaWQiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIxMFRWTDBTQU0zMDAwMDAwNDkwMU5FWFRNQUdFTlRBQ0xPVUQwMDAwIn0seyJuYW1lIjoiZGlzcGxheW5hbWUiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiJubWNsb3VkMDFAdmVyLnN1bC50LW9ubGluZS5kZSJ9LHsibmFtZSI6ImVtYWlsIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoibm1jbG91ZDAxQHZlci5zdWwudC1vbmxpbmUuZGUifSx7Im5hbWUiOiJhbmlkIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMTIwMDQ5MDEwMDAwMDAwMDA3MjEwMjA3In0seyJuYW1lIjoiZDU1NiIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjAifSx7Im5hbWUiOiJkb210IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoidmVyLnN1bC50LW9ubGluZS5kZSJ9LHsibmFtZSI6ImYwNDgiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIxIn0seyJuYW1lIjoiZjA0OSIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjEifSx7Im5hbWUiOiJmMDUxIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMCJ9LHsibmFtZSI6ImY0NjAiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIwIn0seyJuYW1lIjoiZjQ2NyIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjAifSx7Im5hbWUiOiJmNDY4IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMCJ9LHsibmFtZSI6ImY0NjkiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIwIn0seyJuYW1lIjoiZjQ3MSIsIm5hbWVGb3JtYXQiOiJ1cm46Y29tOnRlbGVrb206aWRtOjEuMDphdHRybmFtZS1mb3JtYXQ6ZmllbGQiLCJ2YWx1ZSI6IjAifSx7Im5hbWUiOiJmNTU2IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMSJ9LHsibmFtZSI6ImY3MzQiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIwIn0seyJuYW1lIjoibWFpbkVtYWlsIiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoibm1jbG91ZDAxQHZlci5zdWwudC1vbmxpbmUuZGUifSx7Im5hbWUiOiJzNTU2IiwibmFtZUZvcm1hdCI6InVybjpjb206dGVsZWtvbTppZG06MS4wOmF0dHJuYW1lLWZvcm1hdDpmaWVsZCIsInZhbHVlIjoiMCJ9LHsibmFtZSI6InVzdGEiLCJuYW1lRm9ybWF0IjoidXJuOmNvbTp0ZWxla29tOmlkbToxLjA6YXR0cm5hbWUtZm9ybWF0OmZpZWxkIiwidmFsdWUiOiIxIn1dLCJ1cm46dGVsZWtvbS5jb206aWRtOmF0OnZlcnNpb24iOiIxLjAifQ.5zbr7Uvx2KmU8uR412jHhptWEjykJ_n2awBRcQL9fLE'; + public const ENCRPYT1_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 const ENCRPYT2_SIGN_TOKEN = 'eyJwMnMiOiI2UWMyblpRYkxyRSIsInAyYyI6MTAwMCwiY3R5IjoiSldUIiwiZW5jIjoiQTI1NkNCQy1IUzUxMiIsImFsZyI6IlBCRVMyLUhTNTEyK0EyNTZLVyJ9.WqxhY5Qk2uYhlwqtH7JLb3l8QLxjo9Keq4p0iT_Xy4gIxbnQvIlkOYyf9b7QcBiOlStPm9-RMvt-MmgV_dibTvmrDtEq9CFy.J1NxAp-hkYrh4SHjvErdhw.cb9NQdiQDAoAJKn0jGm_nK9UzuxGcAdaycTwYv1IkYOPg4nteGXUoEZH0Yzh6KJh95vNBbVVhpKnQaYmYfnR7WzcufJJ9XpvsHGP2_KV_kItWTijClXun8Eyg5DDY5lK0Z656-f_walGoZnUxCGTxmra_3yOGbKMKaEq-lxBuf1lPdmi3IpGm1H4IOiiHLpu0ZjEUt_S-L4mOLmnc4TR17sEgB260e7-8np_GqwN5vns7ug3-M_AGFJUyDqKV6hRGu3qn7lLJynldNhIEitT759O-PWyCEff353WZv9d9PwdOQDE8N_pHRgm2JVDGeagDRzygEqhSMwct5o_IWJWNQs6zllnNYGyaB8B9fu9UhFq2dutIS24JwERnlNUcy_RXGq8CGEmnctEX_Has3fXUHanBqBGPJW7cZ2sei5O64YkndCMvePAWaScyd3j9QGvYHrBsriecxk565wjU905OHyXBax_UupbPeCKivDYOWou-HESV_xMHJhFqLJKDiCT7g-33OgXpLPpkzNi4a66KCYXngkYeCUhoGV_W7sVYHDe7GPwz99V7-6YuFnCI4qAvZkUQuzUtDILzrP8PBvsQ6DikYWkqcp9ypAMdej3OTh0TNZdu1ROEYFQB5eNv6I5N-V1ifD9WO1ch5ijYJ1xX-4FbUGt9d6Y-voPzR-6LK5NKhLs9xhNVv26mUZ3HNH78-qoZxXTrHtiarrkE7qc60-oh9BH0XZKsSZcCLpxukX8ibFl4-adYsFhFRAzU-XZT_LdXsBmX3U4DRGC-maM4Bqretaj5DdtjQrPuSETVNO8cMb8rf4UdLBKdXYpp2uqtbIhu327BDCtSptQ5uiZjwlxkMzYFBWCx0W6KN9PPvXhrKpPUzZ_rN98JdSZ0AtyQbR0Oqos7jF5OXs2HDEtOa9EQora3hVGiaOpy-u-AruQ-5SDl0Yh_1n3O2sI0HMO1fMvPEwq6GbetLg3a_xHNvPV6gTZv4-vhVG7b53m526pvx86pYqRp1XKCzQwBTsVUSDAW4mIqtu1R2Yilnb0Ep6wHic0n710YRco4H_tkRr7nYd526WxXYe_neEdD2du-3m3eakZ1IKOdcqiSNvPXyNTDpAPl6U9ap7VsqzksfO3Xo06RwbMv9HcLf9hqWYS6HYRvVVLcShTF9yVMc-XWIZaS0tL_M4jcD7SJsFhLo5p021j68vYoZFcniqFKRwC7ChoBv2Vh4L-eu9o9qlLkUm56g5QhZWQr7OuLxtoKpfoNuRcsg6cNl2TPDl1Hd36s6LtpHneEDaBuRY-s-46pa_gdYWN5i_LHUO807ZE6OmvFBLXy9qY38L5zoXm1FUtP6Td1kXnsiYiluSz8PFelqmkqYALRc1byfvjtjnZBAyMQ9P4xTP3_mEaPKw18O3NYtANYTJjvPvzxuDi7h_slKryJqtvkUcUUd1g45Qibj-jIW9WZdEEWjmDFQ9PA5CB32CfLIq0BqPCuwpFSHiukQ9ZKIpkw0aaDDS25MtwTc3zieiFxfvLSh3Y0lZrcStogk8f4FZecIM7Gi2joGBKZEHg5l30C4JlrZhnxVoIW197XVDDcXl_tRUFaxKAjyRCqQRMcFbapH1pTdA5Hqbwwron6GPnfZbSQoZmSNUuVbphK4DMMDlaMi9nNmIc5ckcR93m0io7fQkcMzAMjwcmMdWqy9boEva8ZEagmRlFoF0ryTsWNCe839dYtZAYuvDzh2JlG6lkisH5Begrx2sK6krKg0hKuvl2oy-eL0iPHff8AxGyrXp8VKF_afikaZyepIavfyW4AXg_1eSh_CP_WVxIvUpCUwdamoowYP0bvTNn_5dwywWD4lgF6FiQygBPCs7A3mQRUsvTyja0sS2LI7yRMWKiJ98mLt3aLNWUCwc4T37Uprk3yQjwFWZXBLsb40sCy_pOwuBzRsabANLbQ9KBThoeLp4FTrCXAK8VdVgUvZyzW8cuOix_EuV32V0HTYQpfbOVlOriAvT2_u2p7QQD8NjgTwy2XEI6zoIXOiXCZJ1ijowCKdmck95nOr4C50_bOFhQTInt3FAlxaAnKRWpdZ5IQ5xDqCsIY4T5gMAbm5SmHmDbwvGTsC7Ugsd6yattg907pmpFF_S9oR6hxKoxV9QfWdVcpXBgvYAc8k0zia1RyCUChAr76AMwHSpmuqGobhBU0J0Cpz11WA9QCQJMNcIezw07Dj-cf-XHbrVxGuXnJy7D1HrKddj8naPuTiRXw4UreJo4yBYQJxu2u1NXZfWyqgVdNi5hyH8F6l2fkMUD4Zmesplmp-NGsCxWV4KCioZXtZit_bpViBeqGQ1GTzvmYCgftqrZ_lJRCAbYjkZ_jZKfnysErxpp5fBpJYgBIUMLaKo4X47edQdygqZGC-58DFO5PTeCRSDcILYUgddTLG4D4lXg2l4SFGdidct-WSNHQhbTpjZeZZ4cX-fgZ2MzjcMf60rNRvp_AkvEqfdXEmnbWhL0820szKWaSPaJXqyapAaW4L4CYJGaIS8q9o8sZNQaMcAOzBVBdFS84AhnnIJeNQHxyUiVhR966KXewj7qKgxP9_bIkRLp3CoWvN8YsJXpg2NjryJNgTeswBdxTJiNeYM1v15rRMDBkpkLB-MfuGRjBnPiNB_KpTMfHdPi_J1d3wyOYT3bk27BIl43Mpubmz-2kyyyCZko9C7QvELaEgGNpKDgMfv6viqHw3BpLvBcMIsLxaT1arAHagSMyPu5KDKuAfD93Xl1ydEnkkSN11zmTks7rk7S3oc3kMPNyKrjhzXFMShgpjHBmJvSBimZgBUG9R4VmjSEjffvw55PZakAzA4f0TfN2hEgFdH1ABZoqGDrJXFRmjZY9hlGREwZ5DzV61144OShZ4yIagF2Efd7T4JBcu_9NLFg74M_CNCIJdJRerdOUhk2i7v2jcVT-N19Uo3CB6oQ9YfOecRqcl9jbX-r1GjnpYjdzEtYuuce03XNLVH5R__WgUssTYvupvCcPa8C9ZASSUQKVnM0r3-dD52E2JEBON123Z69OkUfx9QXqgJirT2mfwlpbarZ5Fk9DgE9J5M960DjUdRi31iP-KC8TDLJpoMyCvKLYKVdrqFwDx2xJRTOhpN02TWpkqMGXUech3QUndyEORq_FVgY3Xl1RXd9XFKK9Bv403lPIqt0421IFazWA6UAB5La0nf1iPrbkF9x-4uUBnLrEV20mlXWNh8X5FqVErjmqhQoAZwON1F-wRBju_bS8JZDpSYnzE6_8_550b-d9ZXtY1FD5b0uuF-6sU0gjiZmR9NuSc5JN-g_M8IrU8wCeuBovKobuvmUh1v2PXjJJ5iG1sjs3U3UnoaMdKMaUiMGf_yZLRTZFaMGpFfynJfksYUETaQ58hpjG4hIoNEBkMiC41O0SlsqyBAJ2R249K9BByULup504LdwVdRkyhoDRulZ7pEHxZbU9jKMvjPq9bfvfzHqPnSXzzFuQpO_HOsJZlk0Z1f8FtPAFCwCCclpRdvXOursYEnTIcpYLx4mZrr0wWUsmsxCYZHbEXEdtwTm9tJEsOMcLzNic2C9FZgQuE56Hyh5cLTOsEA3C8eoZBhVfxpVExJTSFal63_1wMvymXdXbLiDlGHTAnnPhL5RtaD6aFprNVEiK56vd7cyao7P_g2pciVkJ6EpvjdYCKOI_9uQGZTrdU2HEM__5l_b6ejIXh1EzcttCdqUOb3dUNEGfdMCMJdIYfYttlVmEGALlXTnvy4kH-XgaiGZiKxAU0ZKvCysqbd8GtXbSyHQ5AS8qHx3pxR8_Mws6rV55O5uycGYYCYWTfeZ0-8UBEKFAnSWi8MvIilsurAmYj8r9etUOLZ9ah-lKjedmURU3jQfYFa7r_-dcTJbRipzY5cNUKE6hJkdn0i9s3mz-fT-j0LcKEGyLfij55zUkKFQrkwyj3X6f1X_YcTkxXoSOZ23_zLisQIecOSgaPTyIiKQr7G_cwnoUBLNgUH_GB66uirM5Q2hhBD8DIToJYybaSD8XwScGtp3PirfVmP2ZbkFdh1021Mx65u3lh5Lpy9NVfyZ0m4GWGFRwPjqL7AcEtoPGi-PqEh61NrXNlKwsT_IMc3_D1hOb1CAYaNX3QjGYfgncc3tSpz--ZJvq7eDGNj6Z63qudum-pm7Sfgu9eBcknlw_TKjp67Ttnzt6UECZD47DFAgcAAnCs9nEthOU6B-J9hLYaLy2z3vGFyBPfaEH-B-YHRlriIaM5vCTHEmV3gY2iJxFA1DD7VDj3vx6Qw9uMLbcfl4eVXQRNs-HiW-ML6yUv0_zxIspC8HTX1l62vMi2_ofue9lDtjKfxVV-2inRhjae1fhViHb7DcwArnw2XaZYlTC0Oe_neA2pY4DuTAAuMikfQbaf50sU-gszAD_Xmmh6WvDHr4FdrTtf8ew3I1YCmP5lguc_w0QuC-fAAsVvz7bsIcSMFSFWGB71H9dAGSQ53bmboqUrRL-kUjLEF2hF9FqnVGmsx6lH9eMn9tWCApwm1zQNNMM5c.a1VctJwTCcJQ9LC1xGoyKn_2743jHhGpU5G8ucFR2ts'; + + /** + * @var ProviderService + */ + private $provider; + + /** + * @var tokenService + */ + private $tokenService; + + public function setUp(): void { + parent::setUp(); + $this->tokenService = \OC::$server->get(TokenService::class); + $this->access_secret = \Base64Url\Base64Url::encode('JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'); + } + + public function testDecodeAndValidSignature() { + $testtoken = self::EXPIRED_TOKEN; + + $serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ + new \Jose\Component\Signature\Serializer\CompactSerializer() + ]); + + $decodedToken = $this->tokenService->decryptToken($testtoken, $this->access_secret); + $this->tokenService->verifySignature($decodedToken, $this->access_secret); + $claims = $this->tokenService->decode($decodedToken); + $this->assertNotNull($claims->exp); + $this->assertNotNull($claims->aud); + } + + public function decryptDecodeAndValidate(string $testtoken) { + $serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ + new \Jose\Component\Signature\Serializer\CompactSerializer() + ]); + + $decodedToken = $this->tokenService->decryptToken($testtoken, $this->access_secret); + $this->tokenService->verifySignature($decodedToken, $this->access_secret); + $claims = $this->tokenService->decode($decodedToken); + $this->assertNotNull($claims->exp); + $this->assertNotNull($claims->aud); + return $claims; + } + + public function testDecryptDecodeAndValidSignature1() { + //this is not a good unittest style: $this->expectNotToPerformAssertions(); + $claims = $this->decryptDecodeAndValidate(self::ENCRPYT1_SIGN_TOKEN); + $this->assertEquals('10TESTSAM30000004901VOLKERKRIEGEL0000000', $claims->{'urn:telekom.com:client_id'}); + } + + public function testDecryptDecodeAndValidSignature2() { + //this is not a good unittest style: $this->expectNotToPerformAssertions(); + $claims = $this->decryptDecodeAndValidate(self::ENCRPYT2_SIGN_TOKEN); + } + + public function testDecodeAndInvalidSignature() { + $this->expectException(SignatureException::class); + $testtoken = self::INVALID_SIGN_TOKEN; + + $serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ + new \Jose\Component\Signature\Serializer\CompactSerializer() + ]); + + $decodedToken = $this->tokenService->decryptToken($testtoken, $this->access_secret); + $this->tokenService->verifySignature($decodedToken, $this->access_secret); + } +} diff --git a/tests/unit/MagentaCloud/BearerTokenTestCase.php b/tests/unit/MagentaCloud/BearerTokenTestCase.php new file mode 100644 index 00000000..16a6615f --- /dev/null +++ b/tests/unit/MagentaCloud/BearerTokenTestCase.php @@ -0,0 +1,225 @@ +realExampleClaims; + } + + /** + * Test bearer secret + */ + public function getTestBearerSecret() { + return \Base64Url\Base64Url::encode('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); + $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' => time(), + 'nbf' => time(), + 'exp' => time() + 7200, + 'urn:telekom.com:idm:at:authNStatements' => [ + 'urn:telekom:names:idm:THO:1.0:ac:classes:pwd' => [ + 'authenticatingAuthority' => null, + 'authNInstant' => time() ] + ], + '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 { + // The algorithm manager with the HS256 algorithm. + $algorithmManager = new AlgorithmManager([ + new HS256(), + ]); + + if (!$invalidate) { + $jwk = new JWK([ + 'kty' => 'oct', + 'k' => $signKey]); + } else { + // use a different key for an invalid signature + $jwk = new JWK([ + 'kty' => 'oct', + 'k' => 'BnWHlEdffC0hfKSxrh01g7/M3djHIiOU6jNwJChYWP8=']); + } + // We instantiate our JWS Builder. + $jwsBuilder = new JWSBuilder($algorithmManager); + + $jws = $jwsBuilder->create() // We want to create a new JWS + ->withPayload(json_encode($claims)) // We set the payload + ->addSignature($jwk, ['alg' => 'HS256']) // We add a signature with a simple protected header + ->build(); + + return $jws; + } + + protected function setupSignedToken(array $claims, string $signKey) { + $serializer = new \Jose\Component\Signature\Serializer\CompactSerializer(); + return $serializer->serialize($this->signToken($claims, $signKey), 0); + } + + protected function setupEncryptedToken(JWS $token, string $decryptKey) { + // The key encryption algorithm manager with the A256KW algorithm. + $keyEncryptionAlgorithmManager = new AlgorithmManager([ + new PBES2HS512A256KW(), + new RSAOAEP256(), + new ECDHESA256KW() + ]); + // The content encryption algorithm manager with the A256CBC-HS256 algorithm. + $contentEncryptionAlgorithmManager = new AlgorithmManager([ + new A256CBCHS512(), + ]); + // The compression method manager with the DEF (Deflate) method. + $compressionMethodManager = new CompressionMethodManager([ + new Deflate(), + ]); + $signSerializer = new \Jose\Component\Signature\Serializer\CompactSerializer(); + + $jwk = new JWK([ + 'kty' => 'oct', + 'k' => $decryptKey]); + + // We instantiate our JWE Builder. + $jweBuilder = new JWEBuilder( + $keyEncryptionAlgorithmManager, + $contentEncryptionAlgorithmManager, + $compressionMethodManager + ); + + $jwe = $jweBuilder + ->create() // We want to create a new JWE + ->withPayload($signSerializer->serialize($token, 0)) // We set the payload + ->withSharedProtectedHeader([ + 'alg' => 'PBES2-HS512+A256KW', // Key Encryption Algorithm + 'enc' => 'A256CBC-HS512', // Content Encryption Algorithm + 'zip' => 'DEF' // We enable the compression (just for the example). + ]) + ->addRecipient($jwk) + ->build(); // We build it + + $encryptionSerializer = new \Jose\Component\Encryption\Serializer\CompactSerializer(); // The serializer + return $encryptionSerializer->serialize($jwe, 0); + } + + + protected function setupSignEncryptToken(array $claims, string $secret, bool $invalidate = false) { + 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 00000000..842b9466 --- /dev/null +++ b/tests/unit/MagentaCloud/HeaderBearerTokenTest.php @@ -0,0 +1,236 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\BaseTest\BearerTokenTestCase; + +use OCA\UserOIDC\Db\Provider; +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\MagentaBearer\MBackend; +use OCA\UserOIDC\MagentaBearer\TokenService; +use OCA\UserOIDC\Service\DiscoveryService; +use OCA\UserOIDC\Service\ProviderService; + +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCP\EventDispatcher\IEventDispatcher; + +use OCP\IConfig; + +//use OCA\UserOIDC\Db\User; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; + +use OCP\Security\ICrypto; +use Psr\Log\LoggerInterface; + +class HeaderBearerTokenTest extends BearerTokenTestCase { + + /** + * @var ProviderService + */ + private $provider; + + /** + * @var MBackend + */ + private $backend; + + /** + * @var IConfig; + */ + private $config; + + public function setUp(): void { + parent::setUp(); + + $app = new \OCP\AppFramework\App(Application::APP_ID); + $this->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->getTestBearerSecret()); + + $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($this->any()) + ->method('getSetting') + ->with($this->anything(), $this->logicalOr($this->equalTo(ProviderService::SETTING_CHECK_BEARER), + $this->equalTo(ProviderService::SETTING_MAPPING_UID))) + ->willReturnCallback(function ($id, $field, $default) :string { + if ($field === ProviderService::SETTING_MAPPING_UID) { + return 'sub'; + } elseif ($field === ProviderService::SETTING_CHECK_BEARER) { + return '1'; + } else { + return ''; + } + }); + + + $user = $this->createMock(IUser::class); + $user->expects($this->any()) + ->method('getUID') + ->willReturn('1200490100000000100XXXXX'); + $user->expects($this->any()) + ->method('getDisplayName') + ->willReturn('nmc01'); + $user->expects($this->any()) + ->method('getEMailAddress') + ->willReturn('nmc01@ver.sul.t-online.de'); + + $userManager = $this->createMock(IUserManager::class); + $userManager->expects($this->any()) + ->method('get') + ->willReturn($user); + + $provisioningService = $this->createMock(ProvisioningEventService::class); + $provisioningService->expects($this->any()) + ->method('provisionUser') + ->willReturn($user); + + $this->backend = new MBackend($app->getContainer()->get(IConfig::class), + $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), + $app->getContainer()->get(DiscoveryService::class), + $this->providerMapper, + $this->providerService, + $userManager, + $crypto, + $app->getContainer()->get(TokenService::class), + $provisioningService); + } + + public function testValidSignature() { + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->b64BearerToken); + $this->requestMock->expects($this->any()) + ->method('getHeader') + ->with($this->equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $testtoken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('1200490100000000100XXXXX', $this->backend->getCurrentUserId()); + } + + public function testInvalidSignature() { + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->b64BearerToken); + $invalidSignToken = mb_substr($testtoken, 0, -1); // shorten sign to invalidate + $this->requestMock->expects($this->any()) + ->method('getHeader') + ->with($this->equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $invalidSignToken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } + + public function testEncryptedValidSignature() { + $testtoken = $this->setupSignEncryptToken($this->getRealExampleClaims(), $this->b64BearerToken); + $this->requestMock->expects($this->any()) + ->method('getHeader') + ->with($this->equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $testtoken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('1200490100000000100XXXXX', $this->backend->getCurrentUserId()); + } + + public function testEncryptedInvalidSignature() { + $invalidEncToken = $this->setupSignEncryptToken($this->getRealExampleClaims(), + $this->b64BearerToken, true); + $this->requestMock->expects($this->any()) + ->method('getHeader') + ->with($this->equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . $invalidEncToken); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } + + public const ENCRPYT1_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() { + $this->requestMock->expects($this->any()) + ->method('getHeader') + ->with($this->equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . self::ENCRPYT1_SIGN_TOKEN); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } + + public const ENCRPYT2_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() { + $this->requestMock->expects($this->any()) + ->method('getHeader') + ->with($this->equalTo(Application::OIDC_API_REQ_HEADER)) + ->willReturn('Bearer ' . self::ENCRPYT2_SIGN_TOKEN); + + $this->assertTrue($this->backend->isSessionActive()); + $this->assertEquals('', $this->backend->getCurrentUserId()); + } +} diff --git a/tests/unit/MagentaCloud/SamBearerTokenTest.php b/tests/unit/MagentaCloud/SamBearerTokenTest.php new file mode 100644 index 00000000..0c87a33f --- /dev/null +++ b/tests/unit/MagentaCloud/SamBearerTokenTest.php @@ -0,0 +1,85 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\BaseTest\BearerTokenTestCase; + + +use OCA\UserOIDC\MagentaBearer\InvalidTokenException; +use OCA\UserOIDC\MagentaBearer\SignatureException; + +class SamBearerTokenTest extends BearerTokenTestCase { + + /** + * @var ProviderService + */ + private $provider; + + + public function setUp(): void { + parent::setUp(); + } + + public function testValidSignature() { + $this->expectNotToPerformAssertions(); + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + //fwrite(STDERR, '[' . $testtoken . ']'); + $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() { + $this->expectException(SignatureException::class); + $testtoken = $this->setupSignedToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + $invalidSignToken = mb_substr($testtoken, 0, -1); // shorten sign to invalidate + // fwrite(STDERR, '[' . $testtoken . ']'); + $bearerToken = $this->tokenService->decryptToken($invalidSignToken, $this->getTestBearerSecret()); + $this->tokenService->verifySignature($bearerToken, $this->getTestBearerSecret()); + $claims = $this->tokenService->decode($bearerToken); + $this->tokenService->verifyClaims($claims, ['http://auth.magentacloud.de']); + } + + public function testEncryptedValidSignature() { + $this->expectNotToPerformAssertions(); + $testtoken = $this->setupSignEncryptToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + //fwrite(STDERR, '[' . $testtoken . ']'); + $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() { + $this->expectException(InvalidTokenException::class); + $testtoken = $this->setupSignEncryptToken($this->getRealExampleClaims(), $this->getTestBearerSecret()); + $invalidEncryption = mb_substr($testtoken, 0, -1); // shorten sign to invalidate + //fwrite(STDERR, '[' . $testtoken . ']'); + $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']); + } +} From e2d9cf9f380bd3186eeeb59dccde525d5edb811a Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 18:02:38 +0100 Subject: [PATCH 21/33] added bearer token secret --- lib/Command/UpsertProvider.php | 16 +- lib/Controller/SettingsController.php | 13 +- lib/Db/Provider.php | 5 + lib/Db/ProviderMapper.php | 7 +- .../Version00008Date20211114183344.php | 25 ++ .../Version010304Date20230902125945.php | 97 ++++ src/components/SettingsForm.vue | 9 + .../unit/MagentaCloud/BearerSettingsTest.php | 420 ++++++++++++++++++ 8 files changed, 587 insertions(+), 5 deletions(-) create mode 100644 lib/Migration/Version00008Date20211114183344.php create mode 100644 lib/Migration/Version010304Date20230902125945.php create mode 100644 tests/unit/MagentaCloud/BearerSettingsTest.php diff --git a/lib/Command/UpsertProvider.php b/lib/Command/UpsertProvider.php index f6690046..825100e4 100644 --- a/lib/Command/UpsertProvider.php +++ b/lib/Command/UpsertProvider.php @@ -179,6 +179,7 @@ protected function configure() { ->addOption('clientid', 'c', InputOption::VALUE_REQUIRED, 'OpenID client identifier') ->addOption('clientsecret', 's', InputOption::VALUE_REQUIRED, '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"'); @@ -206,10 +207,17 @@ protected function execute(InputInterface $input, OutputInterface $output) { 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); @@ -250,7 +258,7 @@ protected function execute(InputInterface $input, OutputInterface $output) { } 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, ''); @@ -306,4 +314,8 @@ private function listProviders(InputInterface $input, OutputInterface $output) { $table->render(); return 0; } + + private function base64UrlEncode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index d4d6b42d..319c55ba 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -77,7 +77,7 @@ public function isDiscoveryEndpointValid($url) { } #[PasswordConfirmationRequired] - 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): JSONResponse { if ($this->providerService->getProviderByIdentifier($identifier) !== null) { @@ -102,6 +102,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); @@ -110,7 +112,7 @@ public function createProvider(string $identifier, string $clientId, string $cli } #[PasswordConfirmationRequired] - 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): JSONResponse { $provider = $this->providerMapper->getProvider($providerId); @@ -134,6 +136,9 @@ public function updateProvider(int $providerId, string $identifier, string $clie $encryptedClientSecret = $this->crypto->encrypt($clientSecret); $provider->setClientSecret($encryptedClientSecret); } + if ($bearerSecret) { + $provider->setBearerSecret($this->base64UrlEncode($bearerSecret)); + } $provider->setDiscoveryEndpoint($discoveryEndpoint); $provider->setEndSessionEndpoint($endSessionEndpoint ?: null); $provider->setPostLogoutUri($postLogoutUri ?: null); @@ -185,4 +190,8 @@ public function setAdminConfig(array $values): JSONResponse { } return new JSONResponse([]); } + + private function base64UrlEncode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } } diff --git a/lib/Db/Provider.php b/lib/Db/Provider.php index 838f10ff..6bf61ea8 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 getBearerSecret() + * @method void setBearerSecret(string $bearerSecret) + * @method string 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 d724436d..107d7101 100644 --- a/lib/Db/ProviderMapper.php +++ b/lib/Db/ProviderMapper.php @@ -81,6 +81,7 @@ public function getProviders() { * @param string|null $clientid * @param string|null $clientsecret * @param string|null $discoveryuri + * @param string|null $bearersecret * @param string $scope * @param string|null $endsessionendpointuri * @param string|null $postLogoutUri @@ -90,7 +91,7 @@ public function getProviders() { * @throws MultipleObjectsReturnedException */ public function createOrUpdateProvider(string $identifier, ?string $clientid = null, - ?string $clientsecret = null, ?string $discoveryuri = null, string $scope = 'openid email profile', + ?string $clientsecret = null, ?string $discoveryuri = null, string $scope = 'openid email profile', ?string $bearersecret = null, ?string $endsessionendpointuri = null, ?string $postLogoutUri = null) { try { $provider = $this->findProviderByIdentifier($identifier); @@ -109,6 +110,7 @@ public function createOrUpdateProvider(string $identifier, ?string $clientid = n $provider->setDiscoveryEndpoint($discoveryuri); $provider->setEndSessionEndpoint($endsessionendpointuri); $provider->setPostLogoutUri($postLogoutUri); + $provider->setBearerSecret($bearersecret ?? ''); $provider->setScope($scope); return $this->insert($provider); } else { @@ -127,6 +129,9 @@ public function createOrUpdateProvider(string $identifier, ?string $clientid = n if ($postLogoutUri !== null) { $provider->setPostLogoutUri($postLogoutUri ?: null); } + if ($bearersecret !== null) { + $provider->setBearerSecret($bearersecret); + } $provider->setScope($scope); return $this->update($provider); } diff --git a/lib/Migration/Version00008Date20211114183344.php b/lib/Migration/Version00008Date20211114183344.php new file mode 100644 index 00000000..1c9cf6ea --- /dev/null +++ b/lib/Migration/Version00008Date20211114183344.php @@ -0,0 +1,25 @@ +getTable('user_oidc_providers'); + $table->addColumn('bearer_secret', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + + return $schema; + } +} diff --git a/lib/Migration/Version010304Date20230902125945.php b/lib/Migration/Version010304Date20230902125945.php new file mode 100644 index 00000000..bbc04849 --- /dev/null +++ b/lib/Migration/Version010304Date20230902125945.php @@ -0,0 +1,97 @@ + + * + * @author B. Rederlechner + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\UserOIDC\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use OCP\Security\ICrypto; + +class Version010304Date20230902125945 extends SimpleMigrationStep { + + /** + * @var IDBConnection + */ + private $connection; + /** + * @var ICrypto + */ + private $crypto; + + public function __construct( + IDBConnection $connection, + ICrypto $crypto, + ) { + $this->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/src/components/SettingsForm.vue b/src/components/SettingsForm.vue index 4e79259b..31712128 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/unit/MagentaCloud/BearerSettingsTest.php b/tests/unit/MagentaCloud/BearerSettingsTest.php new file mode 100644 index 00000000..eb142675 --- /dev/null +++ b/tests/unit/MagentaCloud/BearerSettingsTest.php @@ -0,0 +1,420 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Command\UpsertProvider; + +use OCA\UserOIDC\Db\Provider; + +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Service\ProviderService; +use OCP\IConfig; + +use OCP\IRequest; + +use OCP\Security\ICrypto; +use PHPUnit\Framework\TestCase; + + +use Symfony\Component\Console\Tester\CommandTester; + +class BearerSettingsTest extends TestCase { + /** + * @var ProviderService + */ + private $provider; + + /** + * @var IConfig; + */ + private $config; + + public function setUp(): void { + parent::setUp(); + + $app = new \OCP\AppFramework\App(Application::APP_ID); + $this->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', + ]); + } +} From 5a2ffd1c870b28f49f5f68b984175fff313a4e0a Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 20:13:20 +0100 Subject: [PATCH 22/33] backchannel logout fix --- lib/Controller/LoginController.php | 66 ++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index da786660..99a01280 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -171,12 +171,26 @@ public function login(int $providerId, ?string $redirectUrl = null) { return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); } - $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::STATE, $state); - $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); + // $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + // $this->session->set(self::STATE, $state); + // $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); - $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::NONCE, $nonce); + // $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + // $this->session->set(self::NONCE, $nonce); + + // check if oidc state is present in session data + if ($this->session->exists(self::STATE)) { + $state = $this->session->get(self::STATE); + $nonce = $this->session->get(self::NONCE); + } else { + $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::STATE, $state); + $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); + + $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::NONCE, $nonce); + $this->session->set(self::PROVIDERID, $providerId); + } $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true); @@ -188,7 +202,7 @@ public function login(int $providerId, ?string $redirectUrl = null) { $this->session->set(self::CODE_VERIFIER, $code_verifier); } - $this->session->set(self::PROVIDERID, $providerId); + // $this->session->set(self::PROVIDERID, $providerId); $this->session->close(); // get attribute mapping settings @@ -601,16 +615,20 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $user->getUID(), null, false)); } - $storeLoginTokenEnabled = $this->appConfig->getValueString(Application::APP_ID, 'store_login_token', '0') === '1'; - if ($storeLoginTokenEnabled) { + // $storeLoginTokenEnabled = $this->appConfig->getValueString(Application::APP_ID, 'store_login_token', '0') === '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'); + // $tokenData = array_merge( + // $data, + // ['provider_id' => $providerId], + // ); + // $this->tokenService->storeToken($tokenData); + // } + // $this->config->setUserValue($user->getUID(), Application::APP_ID, 'had_token_once', '1'); + + // remove code login session values + $this->session->remove(self::STATE); + $this->session->remove(self::NONCE); // Set last password confirm to the future as we don't have passwords to confirm against with SSO $this->session->set('last-password-confirm', strtotime('+4 year', time())); @@ -619,7 +637,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(), @@ -901,6 +919,22 @@ private function getBackchannelLogoutErrorResponse( ); } + /** + * Backward compatible function for MagentaCLOUD to smoothly transition to new config + * + * @PublicPage + * @NoCSRFRequired + * @BruteForceProtection(action=userOidcBackchannelLogout) + * + * @param string $logout_token + * @return JSONResponse + * @throws Exception + * @throws \JsonException + */ + public function telekomBackChannelLogout(string $logout_token = '') { + return $this->backChannelLogout('Telekom', $logout_token); + } + private function toCodeChallenge(string $data): string { // Basically one big work around for the base64url decode being weird $h = pack('H*', hash('sha256', $data)); From 8c19fdc2b64f987e2c9623d11e3a9424bc040a37 Mon Sep 17 00:00:00 2001 From: memurats Date: Wed, 29 Oct 2025 20:23:52 +0100 Subject: [PATCH 23/33] fixed merge error --- lib/Controller/LoginController.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 2e4664f1..487db098 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -906,6 +906,16 @@ private function getBackchannelLogoutErrorResponse( ); } + private function toCodeChallenge(string $data): string { + // Basically one big work around for the base64url decode being weird + $h = pack('H*', hash('sha256', $data)); + $s = base64_encode($h); // Regular base64 encoder + $s = explode('=', $s)[0]; // Remove any trailing '='s + $s = str_replace('+', '-', $s); // 62nd char of encoding + $s = str_replace('/', '_', $s); // 63rd char of encoding + return $s; + } + private function isMobileDevice(): bool { $mobileKeywords = $this->config->getSystemValue('user_oidc.mobile_keywords', ['Android', 'iPhone', 'iPad', 'iPod', 'Windows Phone', 'Mobile', 'webOS', 'BlackBerry', 'Opera Mini', 'IEMobile']); @@ -921,14 +931,4 @@ private function isMobileDevice(): bool { 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)); - $s = base64_encode($h); // Regular base64 encoder - $s = explode('=', $s)[0]; // Remove any trailing '='s - $s = str_replace('+', '-', $s); // 62nd char of encoding - $s = str_replace('/', '_', $s); // 63rd char of encoding - return $s; - } } From 19f8f03968962d49ed87061e674c0450f068c0ba Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 08:29:04 +0100 Subject: [PATCH 24/33] added missing argument --- lib/Service/ProvisioningEventService.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index d544bd60..c9695886 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -38,6 +38,7 @@ use OCP\ISession; use OCP\IUser; use OCP\IUserManager; +use OCP\L10N\IFactory; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; @@ -70,6 +71,7 @@ public function __construct( IAvatarManager $avatarManager, IConfig $config, ISession $session, + IFactory $l10nFactory, ) { parent::__construct($idService, $providerService, @@ -83,6 +85,7 @@ public function __construct( $avatarManager, $config, $session, + $l10nFactory, ); $this->eventDispatcher = $eventDispatcher; $this->logger = $logger; From d6f09381a47b348faa9eefe002227ab2316eb76f Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 08:43:09 +0100 Subject: [PATCH 25/33] moved test class --- tests/unit/{MagentaCloud => BaseTest}/OpenidTokenTestCase.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/{MagentaCloud => BaseTest}/OpenidTokenTestCase.php (100%) diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/BaseTest/OpenidTokenTestCase.php similarity index 100% rename from tests/unit/MagentaCloud/OpenidTokenTestCase.php rename to tests/unit/BaseTest/OpenidTokenTestCase.php From dd2a2538f6140e12fb0c089da377f053a9b26332 Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 08:53:32 +0100 Subject: [PATCH 26/33] added exception --- lib/Exception/AttributeValueException.php | 32 +++++++++++++++++++++++ lib/Service/ProvisioningEventService.php | 1 + 2 files changed, 33 insertions(+) create mode 100644 lib/Exception/AttributeValueException.php diff --git a/lib/Exception/AttributeValueException.php b/lib/Exception/AttributeValueException.php new file mode 100644 index 00000000..a4c80de4 --- /dev/null +++ b/lib/Exception/AttributeValueException.php @@ -0,0 +1,32 @@ +error; + } + + public function getErrorDescription(): ?string { + return $this->errorDescription; + } +} diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index c9695886..abcaf940 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -28,6 +28,7 @@ use OCA\UserOIDC\Db\UserMapper; use OCA\UserOIDC\Event\AttributeMappedEvent; use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCA\UserOIDC\Exception\AttributeValueException; use OCP\Accounts\IAccountManager; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; From 3b4d492063b58115257d282bb8b51dbbfb36a049 Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 08:57:43 +0100 Subject: [PATCH 27/33] fixed psalm errors --- lib/Event/UserAccountChangeResult.php | 39 +++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/Event/UserAccountChangeResult.php b/lib/Event/UserAccountChangeResult.php index 660e78f9..d8af8e49 100644 --- a/lib/Event/UserAccountChangeResult.php +++ b/lib/Event/UserAccountChangeResult.php @@ -5,7 +5,6 @@ * @author B. Rederlechner * * @license GNU AGPL version 3 or any later version - * */ declare(strict_types=1); @@ -13,18 +12,18 @@ namespace OCA\UserOIDC\Event; /** - * Event to provide custom mapping logic based on the OIDC token data - * In order to avoid further processing the event propagation should be stopped - * in the listener after processing as the value might get overwritten afterwards - * by other listeners through $event->stopPropagation(); + * Represents the result of an account change event decision. + * Used to signal whether access is allowed and optional redirect/reason info. */ class UserAccountChangeResult { /** @var bool */ private $accessAllowed; + /** @var string */ private $reason; - /** @var string */ + + /** @var string|null */ private $redirectUrl; public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) { @@ -34,39 +33,57 @@ public function __construct(bool $accessAllowed, string $reason = '', ?string $r } /** - * @return value for the logged in user attribute + * Whether access for this user is allowed. + * + * @return bool */ public function isAccessAllowed(): bool { return $this->accessAllowed; } + /** + * Set whether access for this user is allowed. + * + * @param bool $accessAllowed + * @return void + */ public function setAccessAllowed(bool $accessAllowed): void { $this->accessAllowed = $accessAllowed; } /** - * @return get optional alternate redirect address + * Returns the optional alternate redirect URL. + * + * @return string|null */ public function getRedirectUrl(): ?string { return $this->redirectUrl; } /** - * @return set optional alternate redirect address + * Sets the optional alternate redirect URL. + * + * @param string|null $redirectUrl + * @return void */ public function setRedirectUrl(?string $redirectUrl): void { $this->redirectUrl = $redirectUrl; } /** - * @return get decision reason + * Returns the decision reason. + * + * @return string */ public function getReason(): string { return $this->reason; } /** - * @return set decision reason + * Sets the decision reason. + * + * @param string $reason + * @return void */ public function setReason(string $reason): void { $this->reason = $reason; From d482982666bb5fba3ed3ef417a89d6016e7b1137 Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 09:03:02 +0100 Subject: [PATCH 28/33] fixed psalm errors --- lib/Event/UserAccountChangeEvent.php | 56 ++++++++++++++++++++++----- lib/Event/UserAccountChangeResult.php | 1 + 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php index c6b698aa..de9fb5e5 100644 --- a/lib/Event/UserAccountChangeEvent.php +++ b/lib/Event/UserAccountChangeEvent.php @@ -21,15 +21,33 @@ * by other listeners through $event->stopPropagation(); */ class UserAccountChangeEvent extends Event { + + /** @var string */ private $uid; + + /** @var string|null */ private $displayname; + + /** @var string|null */ private $mainEmail; + + /** @var string|null */ private $quota; + + /** @var object */ private $claims; - private $result; + /** @var UserAccountChangeResult */ + private $result; - public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) { + public function __construct( + string $uid, + ?string $displayname, + ?string $mainEmail, + ?string $quota, + object $claims, + bool $accessAllowed = false + ) { parent::__construct(); $this->uid = $uid; $this->displayname = $displayname; @@ -40,48 +58,68 @@ public function __construct(string $uid, ?string $displayname, ?string $mainEmai } /** - * @return get event username (uid) + * Get the user ID (UID) associated with the event. + * + * @return string */ public function getUid(): string { return $this->uid; } /** - * @return get event displayname + * Get the display name for the account. + * + * @return string|null */ public function getDisplayName(): ?string { return $this->displayname; } /** - * @return get event main email + * Get the primary email address. + * + * @return string|null */ public function getMainEmail(): ?string { return $this->mainEmail; } /** - * @return get event quota + * Get the quota assigned to the account. + * + * @return string|null */ public function getQuota(): ?string { return $this->quota; } /** - * @return array the array of claim values associated with the event + * Get the OIDC claims associated with the event. + * + * @return object */ public function getClaims(): object { return $this->claims; } /** - * @return value for the logged in user attribute + * Get the current result object. + * + * @return UserAccountChangeResult */ public function getResult(): UserAccountChangeResult { return $this->result; } - public function setResult(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) : void { + /** + * Replace the result object with a new one. + * + * @param bool $accessAllowed Whether access should be allowed + * @param string $reason Optional reason for the decision + * @param string|null $redirectUrl Optional redirect URL + * @return void + */ + public function setResult(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null): void { $this->result = new UserAccountChangeResult($accessAllowed, $reason, $redirectUrl); } } diff --git a/lib/Event/UserAccountChangeResult.php b/lib/Event/UserAccountChangeResult.php index d8af8e49..1b19d639 100644 --- a/lib/Event/UserAccountChangeResult.php +++ b/lib/Event/UserAccountChangeResult.php @@ -5,6 +5,7 @@ * @author B. Rederlechner * * @license GNU AGPL version 3 or any later version + * */ declare(strict_types=1); From b3f0c47f57915e3c80a59f39d056bd0d6fa0428b Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 09:38:13 +0100 Subject: [PATCH 29/33] fixed errors --- lib/MagentaBearer/TokenService.php | 340 ++++++++++++++++------------- lib/User/AbstractOidcBackend.php | 1 - 2 files changed, 183 insertions(+), 158 deletions(-) diff --git a/lib/MagentaBearer/TokenService.php b/lib/MagentaBearer/TokenService.php index 3645580e..9e3fd7f3 100644 --- a/lib/MagentaBearer/TokenService.php +++ b/lib/MagentaBearer/TokenService.php @@ -24,180 +24,206 @@ namespace OCA\UserOIDC\MagentaBearer; -use Jose\Component\Core\Algorithm; use Jose\Component\Core\AlgorithmManager; - use Jose\Component\Core\JWK; use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512; use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW; - use Jose\Component\Encryption\Algorithm\KeyEncryption\PBES2HS512A256KW; use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256; use Jose\Component\Encryption\Compression\CompressionMethodManager; - use Jose\Component\Encryption\Compression\Deflate; use Jose\Component\Encryption\JWEDecrypter; +use Jose\Component\Encryption\Serializer\JWESerializerManager; +use Jose\Component\Encryption\Serializer\CompactSerializer as JWECompactSerializer; use Jose\Component\Signature\Algorithm\HS256; use Jose\Component\Signature\Algorithm\HS384; - use Jose\Component\Signature\Algorithm\HS512; use Jose\Component\Signature\JWS; use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Signature\Serializer\JWSSerializerManager; +use Jose\Component\Signature\Serializer\CompactSerializer as JWSCompactSerializer; use OCP\AppFramework\Utility\ITimeFactory; use Psr\Log\LoggerInterface; +/** + * Token service for handling Magenta/SAM3 bearer tokens (decrypt/verify/claims). + */ class TokenService { - /** @var LoggerInterface */ - private $logger; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var DiscoveryService */ - private $discoveryService; - - public function __construct(LoggerInterface $logger, - ITimeFactory $timeFactory) { - $this->logger = $logger; - $this->timeFactory = $timeFactory; - - // The key encryption algorithm manager with the A256KW algorithm. - $keyEncryptionAlgorithmManager = new AlgorithmManager([ - new PBES2HS512A256KW(), - new RSAOAEP256(), - new ECDHESA256KW() ]); - - // The content encryption algorithm manager with the A256CBC-HS256 algorithm. - $contentEncryptionAlgorithmManager = new AlgorithmManager([ - new A256CBCHS512(), - ]); - - // The compression method manager with the DEF (Deflate) method. - $compressionMethodManager = new CompressionMethodManager([ - new Deflate(), - ]); - - $signatureAlgorithmManager = new AlgorithmManager([ - new HS256(), - new HS384(), - new HS512(), - ]); - - // We instantiate our JWE Decrypter. - $this->jweDecrypter = new JWEDecrypter( - $keyEncryptionAlgorithmManager, - $contentEncryptionAlgorithmManager, - $compressionMethodManager - ); - - // We try to load the token. - $this->encryptionSerializerManager = new \Jose\Component\Encryption\Serializer\JWESerializerManager([ - new \Jose\Component\Encryption\Serializer\CompactSerializer(), - ]); - - - // We instantiate our JWS Verifier. - $this->jwsVerifier = new JWSVerifier( - $signatureAlgorithmManager - ); - - // The serializer manager. We only use the JWE Compact Serialization Mode. - $this->serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ - new \Jose\Component\Signature\Serializer\CompactSerializer(), - ]); - } - - /** - * Implement JOSE decryption for SAM3 tokens - */ - public function decryptToken(string $rawToken, string $decryptKey) : JWS { - - // web-token library does not like underscores in headers, so replace them with - (which is valid in JWT) - $numSegments = substr_count($rawToken, '.') + 1; - $this->logger->debug('Bearer access token(segments=' . $numSegments . ')=' . $rawToken); - if ($numSegments > 3) { - // trusted authenticator and myself share the client secret, - // so use it is used for encrypted web tokens - $clientSecret = new JWK([ - 'kty' => 'oct', - 'k' => $decryptKey - ]); - - $jwe = $this->encryptionSerializerManager->unserialize($rawToken); - - // We decrypt the token. This method does NOT check the header. - if ($this->jweDecrypter->decryptUsingKey($jwe, $clientSecret, 0)) { - return $this->serializerManager->unserialize($jwe->getPayload()); - } else { - throw new InvalidTokenException('Unknown bearer encryption format'); - } - } else { - return $this->serializerManager->unserialize($rawToken); - } - } - - /** - * Get claims (even before verification to access e.g. aud standard field ...) - * Transform them in a format compatible with id_token representation. - */ - public function decode(JWS $decodedToken) : object { - $this->logger->debug('Telekom SAM3 access token: ' . $decodedToken->getPayload()); - $samContent = json_decode($decodedToken->getPayload(), false); - - // remap all the custom claims - // adapt into OpenId id_token format (as far as possible) - $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'}; - foreach ($claimArray as $claimKeyValue) { - $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value; - } - unset($samContent->{'urn:telekom.com:idm:at:attributes'}); - - $this->logger->debug('Adapted OpenID-like token; ' . json_encode($samContent)); - return $samContent; - } - - - public function verifySignature(JWS $decodedToken, string $signKey) { - $accessSecret = new JWK([ - 'kty' => 'oct', - 'k' => $signKey - ]); // TODO: take the additional access key secret from settings - - if (!$this->jwsVerifier->verifyWithKey($decodedToken, $accessSecret, 0)) { - throw new SignatureException('Invalid Signature'); - } - } - - public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60) { - $timestamp = $this->timeFactory->getTime(); - - // Check the nbf if it is defined. This is the time that the - // token can actually be used. If it's not yet that time, abort. - if (isset($claims->nbf) && $claims->nbf > ($timestamp + $leeway)) { - throw new InvalidTokenException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->nbf) - ); - } - - // Check that this token has been created before 'now'. This prevents - // using tokens that have been created for later use (and haven't - // correctly used the nbf claim). - if (isset($claims->iat) && $claims->iat > ($timestamp + $leeway)) { - throw new InvalidTokenException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->iat) - ); - } - - // Check if this token has expired. - if (isset($claims->exp) && ($timestamp - $leeway) >= $claims->exp) { - throw new InvalidTokenException('Expired token'); - } - - // Check target audience (if given) - // Check if this token has expired. - if (empty(array_intersect($claims->aud, $audiences))) { - throw new InvalidTokenException('No acceptable audience in token.'); - } - } + /** @var LoggerInterface */ + private $logger; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var DiscoveryService|null */ + private $discoveryService; + + /** @var JWEDecrypter */ + private $jweDecrypter; + + /** @var JWESerializerManager */ + private $encryptionSerializerManager; + + /** @var JWSVerifier */ + private $jwsVerifier; + + /** @var JWSSerializerManager */ + private $serializerManager; + + public function __construct(LoggerInterface $logger, ITimeFactory $timeFactory, ?DiscoveryService $discoveryService = null) { + $this->logger = $logger; + $this->timeFactory = $timeFactory; + $this->discoveryService = $discoveryService; + + // Key encryption algorithms manager + $keyEncryptionAlgorithmManager = new AlgorithmManager([ + new PBES2HS512A256KW(), + new RSAOAEP256(), + new ECDHESA256KW(), + ]); + + // Content encryption algorithm manager + $contentEncryptionAlgorithmManager = new AlgorithmManager([ + new A256CBCHS512(), + ]); + + // Compression manager + $compressionMethodManager = new CompressionMethodManager([ + new Deflate(), + ]); + + // Signature algorithms manager + $signatureAlgorithmManager = new AlgorithmManager([ + new HS256(), + new HS384(), + new HS512(), + ]); + + // JWE decrypter + $this->jweDecrypter = new JWEDecrypter( + $keyEncryptionAlgorithmManager, + $contentEncryptionAlgorithmManager, + $compressionMethodManager + ); + + // JWESerializerManager + $this->encryptionSerializerManager = new JWESerializerManager([ + new JWECompactSerializer(), + ]); + + // JWS verifier + $this->jwsVerifier = new JWSVerifier($signatureAlgorithmManager); + + // JWSSerializerManager + $this->serializerManager = new JWSSerializerManager([ + new JWSCompactSerializer(), + ]); + } + + /** + * Implement JOSE decryption for SAM3 tokens + * + * @param string $rawToken + * @param string $decryptKey + * @return JWS + * @throws InvalidTokenException + */ + public function decryptToken(string $rawToken, string $decryptKey) : JWS { + $numSegments = substr_count($rawToken, '.') + 1; + $this->logger->debug('Bearer access token(segments=' . $numSegments . ')=' . $rawToken); + + if ($numSegments > 3) { + $clientSecret = new JWK([ + 'kty' => 'oct', + 'k' => $decryptKey, + ]); + + $jwe = $this->encryptionSerializerManager->unserialize($rawToken); + + if ($this->jweDecrypter->decryptUsingKey($jwe, $clientSecret, 0)) { + return $this->serializerManager->unserialize($jwe->getPayload()); + } + + throw new InvalidTokenException('Unknown bearer encryption format'); + } + + return $this->serializerManager->unserialize($rawToken); + } + + /** + * Decode the token payload into an object and remap claims + * + * @param JWS $decodedToken + * @return object + */ + public function decode(JWS $decodedToken) : object { + $this->logger->debug('Telekom SAM3 access token: ' . $decodedToken->getPayload()); + $samContent = json_decode($decodedToken->getPayload(), false); + + $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'} ?? null; + if (is_array($claimArray)) { + foreach ($claimArray as $claimKeyValue) { + if (isset($claimKeyValue->name)) { + $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value ?? null; + } + } + unset($samContent->{'urn:telekom.com:idm:at:attributes'}); + } + + $this->logger->debug('Adapted OpenID-like token; ' . json_encode($samContent)); + return $samContent; + } + + /** + * Verify the JWS signature using the given symmetric key + * + * @param JWS $decodedToken + * @param string $signKey + * @return void + * @throws SignatureException + */ + public function verifySignature(JWS $decodedToken, string $signKey): void { + $accessSecret = new JWK([ + 'kty' => 'oct', + 'k' => $signKey, + ]); + + if (!$this->jwsVerifier->verifyWithKey($decodedToken, $accessSecret, 0)) { + throw new SignatureException('Invalid Signature'); + } + } + + /** + * Verify standard claims (nbf/iat/exp/aud) + * + * @param object $claims + * @param array $audiences + * @param int $leeway + * @return void + * @throws InvalidTokenException + */ + public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60): void { + $timestamp = $this->timeFactory->getTime(); + + if (isset($claims->nbf) && $claims->nbf > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . \date(\DATE_ATOM, (int)$claims->nbf) + ); + } + + if (isset($claims->iat) && $claims->iat > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . \date(\DATE_ATOM, (int)$claims->iat) + ); + } + + if (isset($claims->exp) && ($timestamp - $leeway) >= $claims->exp) { + throw new InvalidTokenException('Expired token'); + } + + if (empty(array_intersect((array)($claims->aud ?? []), $audiences))) { + throw new InvalidTokenException('No acceptable audience in token.'); + } + } } diff --git a/lib/User/AbstractOidcBackend.php b/lib/User/AbstractOidcBackend.php index d1e5de7b..7bfb95a0 100644 --- a/lib/User/AbstractOidcBackend.php +++ b/lib/User/AbstractOidcBackend.php @@ -190,7 +190,6 @@ public function getLogoutUrl(): string { * * @param string $userId * @return bool - * @throws NotFoundException */ protected function checkFirstLogin(string $userId): bool { $user = $this->userManager->get($userId); From bccd1a05a167d2b9f1cf4281f291dcc5faf65465 Mon Sep 17 00:00:00 2001 From: memurats Date: Thu, 30 Oct 2025 09:47:42 +0100 Subject: [PATCH 30/33] revert tokenservice changes --- lib/MagentaBearer/TokenService.php | 340 +++++++++++++---------------- 1 file changed, 157 insertions(+), 183 deletions(-) diff --git a/lib/MagentaBearer/TokenService.php b/lib/MagentaBearer/TokenService.php index 9e3fd7f3..3645580e 100644 --- a/lib/MagentaBearer/TokenService.php +++ b/lib/MagentaBearer/TokenService.php @@ -24,206 +24,180 @@ namespace OCA\UserOIDC\MagentaBearer; +use Jose\Component\Core\Algorithm; use Jose\Component\Core\AlgorithmManager; + use Jose\Component\Core\JWK; use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512; use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW; + use Jose\Component\Encryption\Algorithm\KeyEncryption\PBES2HS512A256KW; use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256; use Jose\Component\Encryption\Compression\CompressionMethodManager; + use Jose\Component\Encryption\Compression\Deflate; use Jose\Component\Encryption\JWEDecrypter; -use Jose\Component\Encryption\Serializer\JWESerializerManager; -use Jose\Component\Encryption\Serializer\CompactSerializer as JWECompactSerializer; use Jose\Component\Signature\Algorithm\HS256; use Jose\Component\Signature\Algorithm\HS384; + use Jose\Component\Signature\Algorithm\HS512; use Jose\Component\Signature\JWS; use Jose\Component\Signature\JWSVerifier; -use Jose\Component\Signature\Serializer\JWSSerializerManager; -use Jose\Component\Signature\Serializer\CompactSerializer as JWSCompactSerializer; use OCP\AppFramework\Utility\ITimeFactory; use Psr\Log\LoggerInterface; -/** - * Token service for handling Magenta/SAM3 bearer tokens (decrypt/verify/claims). - */ class TokenService { - /** @var LoggerInterface */ - private $logger; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var DiscoveryService|null */ - private $discoveryService; - - /** @var JWEDecrypter */ - private $jweDecrypter; - - /** @var JWESerializerManager */ - private $encryptionSerializerManager; - - /** @var JWSVerifier */ - private $jwsVerifier; - - /** @var JWSSerializerManager */ - private $serializerManager; - - public function __construct(LoggerInterface $logger, ITimeFactory $timeFactory, ?DiscoveryService $discoveryService = null) { - $this->logger = $logger; - $this->timeFactory = $timeFactory; - $this->discoveryService = $discoveryService; - - // Key encryption algorithms manager - $keyEncryptionAlgorithmManager = new AlgorithmManager([ - new PBES2HS512A256KW(), - new RSAOAEP256(), - new ECDHESA256KW(), - ]); - - // Content encryption algorithm manager - $contentEncryptionAlgorithmManager = new AlgorithmManager([ - new A256CBCHS512(), - ]); - - // Compression manager - $compressionMethodManager = new CompressionMethodManager([ - new Deflate(), - ]); - - // Signature algorithms manager - $signatureAlgorithmManager = new AlgorithmManager([ - new HS256(), - new HS384(), - new HS512(), - ]); - - // JWE decrypter - $this->jweDecrypter = new JWEDecrypter( - $keyEncryptionAlgorithmManager, - $contentEncryptionAlgorithmManager, - $compressionMethodManager - ); - - // JWESerializerManager - $this->encryptionSerializerManager = new JWESerializerManager([ - new JWECompactSerializer(), - ]); - - // JWS verifier - $this->jwsVerifier = new JWSVerifier($signatureAlgorithmManager); - - // JWSSerializerManager - $this->serializerManager = new JWSSerializerManager([ - new JWSCompactSerializer(), - ]); - } - - /** - * Implement JOSE decryption for SAM3 tokens - * - * @param string $rawToken - * @param string $decryptKey - * @return JWS - * @throws InvalidTokenException - */ - public function decryptToken(string $rawToken, string $decryptKey) : JWS { - $numSegments = substr_count($rawToken, '.') + 1; - $this->logger->debug('Bearer access token(segments=' . $numSegments . ')=' . $rawToken); - - if ($numSegments > 3) { - $clientSecret = new JWK([ - 'kty' => 'oct', - 'k' => $decryptKey, - ]); - - $jwe = $this->encryptionSerializerManager->unserialize($rawToken); - - if ($this->jweDecrypter->decryptUsingKey($jwe, $clientSecret, 0)) { - return $this->serializerManager->unserialize($jwe->getPayload()); - } - - throw new InvalidTokenException('Unknown bearer encryption format'); - } - - return $this->serializerManager->unserialize($rawToken); - } - - /** - * Decode the token payload into an object and remap claims - * - * @param JWS $decodedToken - * @return object - */ - public function decode(JWS $decodedToken) : object { - $this->logger->debug('Telekom SAM3 access token: ' . $decodedToken->getPayload()); - $samContent = json_decode($decodedToken->getPayload(), false); - - $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'} ?? null; - if (is_array($claimArray)) { - foreach ($claimArray as $claimKeyValue) { - if (isset($claimKeyValue->name)) { - $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value ?? null; - } - } - unset($samContent->{'urn:telekom.com:idm:at:attributes'}); - } - - $this->logger->debug('Adapted OpenID-like token; ' . json_encode($samContent)); - return $samContent; - } - - /** - * Verify the JWS signature using the given symmetric key - * - * @param JWS $decodedToken - * @param string $signKey - * @return void - * @throws SignatureException - */ - public function verifySignature(JWS $decodedToken, string $signKey): void { - $accessSecret = new JWK([ - 'kty' => 'oct', - 'k' => $signKey, - ]); - - if (!$this->jwsVerifier->verifyWithKey($decodedToken, $accessSecret, 0)) { - throw new SignatureException('Invalid Signature'); - } - } - - /** - * Verify standard claims (nbf/iat/exp/aud) - * - * @param object $claims - * @param array $audiences - * @param int $leeway - * @return void - * @throws InvalidTokenException - */ - public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60): void { - $timestamp = $this->timeFactory->getTime(); - - if (isset($claims->nbf) && $claims->nbf > ($timestamp + $leeway)) { - throw new InvalidTokenException( - 'Cannot handle token prior to ' . \date(\DATE_ATOM, (int)$claims->nbf) - ); - } - - if (isset($claims->iat) && $claims->iat > ($timestamp + $leeway)) { - throw new InvalidTokenException( - 'Cannot handle token prior to ' . \date(\DATE_ATOM, (int)$claims->iat) - ); - } - - if (isset($claims->exp) && ($timestamp - $leeway) >= $claims->exp) { - throw new InvalidTokenException('Expired token'); - } - - if (empty(array_intersect((array)($claims->aud ?? []), $audiences))) { - throw new InvalidTokenException('No acceptable audience in token.'); - } - } + /** @var LoggerInterface */ + private $logger; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var DiscoveryService */ + private $discoveryService; + + public function __construct(LoggerInterface $logger, + ITimeFactory $timeFactory) { + $this->logger = $logger; + $this->timeFactory = $timeFactory; + + // The key encryption algorithm manager with the A256KW algorithm. + $keyEncryptionAlgorithmManager = new AlgorithmManager([ + new PBES2HS512A256KW(), + new RSAOAEP256(), + new ECDHESA256KW() ]); + + // The content encryption algorithm manager with the A256CBC-HS256 algorithm. + $contentEncryptionAlgorithmManager = new AlgorithmManager([ + new A256CBCHS512(), + ]); + + // The compression method manager with the DEF (Deflate) method. + $compressionMethodManager = new CompressionMethodManager([ + new Deflate(), + ]); + + $signatureAlgorithmManager = new AlgorithmManager([ + new HS256(), + new HS384(), + new HS512(), + ]); + + // We instantiate our JWE Decrypter. + $this->jweDecrypter = new JWEDecrypter( + $keyEncryptionAlgorithmManager, + $contentEncryptionAlgorithmManager, + $compressionMethodManager + ); + + // We try to load the token. + $this->encryptionSerializerManager = new \Jose\Component\Encryption\Serializer\JWESerializerManager([ + new \Jose\Component\Encryption\Serializer\CompactSerializer(), + ]); + + + // We instantiate our JWS Verifier. + $this->jwsVerifier = new JWSVerifier( + $signatureAlgorithmManager + ); + + // The serializer manager. We only use the JWE Compact Serialization Mode. + $this->serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ + new \Jose\Component\Signature\Serializer\CompactSerializer(), + ]); + } + + /** + * Implement JOSE decryption for SAM3 tokens + */ + public function decryptToken(string $rawToken, string $decryptKey) : JWS { + + // web-token library does not like underscores in headers, so replace them with - (which is valid in JWT) + $numSegments = substr_count($rawToken, '.') + 1; + $this->logger->debug('Bearer access token(segments=' . $numSegments . ')=' . $rawToken); + if ($numSegments > 3) { + // trusted authenticator and myself share the client secret, + // so use it is used for encrypted web tokens + $clientSecret = new JWK([ + 'kty' => 'oct', + 'k' => $decryptKey + ]); + + $jwe = $this->encryptionSerializerManager->unserialize($rawToken); + + // We decrypt the token. This method does NOT check the header. + if ($this->jweDecrypter->decryptUsingKey($jwe, $clientSecret, 0)) { + return $this->serializerManager->unserialize($jwe->getPayload()); + } else { + throw new InvalidTokenException('Unknown bearer encryption format'); + } + } else { + return $this->serializerManager->unserialize($rawToken); + } + } + + /** + * Get claims (even before verification to access e.g. aud standard field ...) + * Transform them in a format compatible with id_token representation. + */ + public function decode(JWS $decodedToken) : object { + $this->logger->debug('Telekom SAM3 access token: ' . $decodedToken->getPayload()); + $samContent = json_decode($decodedToken->getPayload(), false); + + // remap all the custom claims + // adapt into OpenId id_token format (as far as possible) + $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'}; + foreach ($claimArray as $claimKeyValue) { + $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value; + } + unset($samContent->{'urn:telekom.com:idm:at:attributes'}); + + $this->logger->debug('Adapted OpenID-like token; ' . json_encode($samContent)); + return $samContent; + } + + + public function verifySignature(JWS $decodedToken, string $signKey) { + $accessSecret = new JWK([ + 'kty' => 'oct', + 'k' => $signKey + ]); // TODO: take the additional access key secret from settings + + if (!$this->jwsVerifier->verifyWithKey($decodedToken, $accessSecret, 0)) { + throw new SignatureException('Invalid Signature'); + } + } + + public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60) { + $timestamp = $this->timeFactory->getTime(); + + // Check the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($claims->nbf) && $claims->nbf > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->nbf) + ); + } + + // Check that this token has been created before 'now'. This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (isset($claims->iat) && $claims->iat > ($timestamp + $leeway)) { + throw new InvalidTokenException( + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->iat) + ); + } + + // Check if this token has expired. + if (isset($claims->exp) && ($timestamp - $leeway) >= $claims->exp) { + throw new InvalidTokenException('Expired token'); + } + + // Check target audience (if given) + // Check if this token has expired. + if (empty(array_intersect($claims->aud, $audiences))) { + throw new InvalidTokenException('No acceptable audience in token.'); + } + } } From 1424e83b977a4c74d972bcd9203335fd31aeddbd Mon Sep 17 00:00:00 2001 From: Mauro Mura Date: Fri, 5 Dec 2025 10:51:51 +0100 Subject: [PATCH 31/33] Clean up LoginController by removing unused comments Removed commented-out code related to login token storage. --- lib/Controller/LoginController.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 99a01280..83ba4d30 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -615,17 +615,6 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $user->getUID(), null, false)); } - // $storeLoginTokenEnabled = $this->appConfig->getValueString(Application::APP_ID, 'store_login_token', '0') === '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'); - // remove code login session values $this->session->remove(self::STATE); $this->session->remove(self::NONCE); From ba440fb36caa07ee4608d1983b259b08c1432a37 Mon Sep 17 00:00:00 2001 From: memurats Date: Fri, 12 Dec 2025 14:23:11 +0100 Subject: [PATCH 32/33] fix errors --- lib/MagentaBearer/TokenService.php | 149 +++++++++++++---------------- lib/Service/TokenService.php | 139 +++++++++++---------------- 2 files changed, 122 insertions(+), 166 deletions(-) diff --git a/lib/MagentaBearer/TokenService.php b/lib/MagentaBearer/TokenService.php index 3645580e..f0762c46 100644 --- a/lib/MagentaBearer/TokenService.php +++ b/lib/MagentaBearer/TokenService.php @@ -5,151 +5,133 @@ * @author Bernd Rederlechner * * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * */ declare(strict_types=1); namespace OCA\UserOIDC\MagentaBearer; -use Jose\Component\Core\Algorithm; +use DateTime; use Jose\Component\Core\AlgorithmManager; - use Jose\Component\Core\JWK; use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512; use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW; - use Jose\Component\Encryption\Algorithm\KeyEncryption\PBES2HS512A256KW; use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256; use Jose\Component\Encryption\Compression\CompressionMethodManager; - use Jose\Component\Encryption\Compression\Deflate; use Jose\Component\Encryption\JWEDecrypter; +use Jose\Component\Encryption\Serializer\CompactSerializer as JWECompactSerializer; +use Jose\Component\Encryption\Serializer\JWESerializerManager; use Jose\Component\Signature\Algorithm\HS256; use Jose\Component\Signature\Algorithm\HS384; - use Jose\Component\Signature\Algorithm\HS512; use Jose\Component\Signature\JWS; use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Signature\Serializer\CompactSerializer as JWSCompactSerializer; +use Jose\Component\Signature\Serializer\JWSSerializerManager; use OCP\AppFramework\Utility\ITimeFactory; use Psr\Log\LoggerInterface; class TokenService { - /** @var LoggerInterface */ - private $logger; + private LoggerInterface $logger; + private ITimeFactory $timeFactory; - /** @var ITimeFactory */ - private $timeFactory; + // ✅ FIX: vorher dynamisch gesetzt + private JWEDecrypter $jweDecrypter; + private JWESerializerManager $encryptionSerializerManager; + private JWSVerifier $jwsVerifier; + private JWSSerializerManager $serializerManager; - /** @var DiscoveryService */ - private $discoveryService; - - public function __construct(LoggerInterface $logger, - ITimeFactory $timeFactory) { + public function __construct(LoggerInterface $logger, ITimeFactory $timeFactory) { $this->logger = $logger; $this->timeFactory = $timeFactory; - - // The key encryption algorithm manager with the A256KW algorithm. + + // Key encryption algorithms $keyEncryptionAlgorithmManager = new AlgorithmManager([ new PBES2HS512A256KW(), new RSAOAEP256(), - new ECDHESA256KW() ]); - - // The content encryption algorithm manager with the A256CBC-HS256 algorithm. + new ECDHESA256KW(), + ]); + + // Content encryption algorithms $contentEncryptionAlgorithmManager = new AlgorithmManager([ new A256CBCHS512(), ]); - - // The compression method manager with the DEF (Deflate) method. + + // Compression methods $compressionMethodManager = new CompressionMethodManager([ new Deflate(), ]); - + + // Signature algorithms $signatureAlgorithmManager = new AlgorithmManager([ new HS256(), new HS384(), new HS512(), ]); - // We instantiate our JWE Decrypter. $this->jweDecrypter = new JWEDecrypter( $keyEncryptionAlgorithmManager, $contentEncryptionAlgorithmManager, $compressionMethodManager ); - // We try to load the token. - $this->encryptionSerializerManager = new \Jose\Component\Encryption\Serializer\JWESerializerManager([ - new \Jose\Component\Encryption\Serializer\CompactSerializer(), + $this->encryptionSerializerManager = new JWESerializerManager([ + new JWECompactSerializer(), ]); + $this->jwsVerifier = new JWSVerifier($signatureAlgorithmManager); - // We instantiate our JWS Verifier. - $this->jwsVerifier = new JWSVerifier( - $signatureAlgorithmManager - ); - - // The serializer manager. We only use the JWE Compact Serialization Mode. - $this->serializerManager = new \Jose\Component\Signature\Serializer\JWSSerializerManager([ - new \Jose\Component\Signature\Serializer\CompactSerializer(), + $this->serializerManager = new JWSSerializerManager([ + new JWSCompactSerializer(), ]); } - + /** * Implement JOSE decryption for SAM3 tokens */ - public function decryptToken(string $rawToken, string $decryptKey) : JWS { - + public function decryptToken(string $rawToken, string $decryptKey): JWS { // web-token library does not like underscores in headers, so replace them with - (which is valid in JWT) $numSegments = substr_count($rawToken, '.') + 1; $this->logger->debug('Bearer access token(segments=' . $numSegments . ')=' . $rawToken); + if ($numSegments > 3) { // trusted authenticator and myself share the client secret, - // so use it is used for encrypted web tokens + // so use it for encrypted web tokens $clientSecret = new JWK([ 'kty' => 'oct', - 'k' => $decryptKey + 'k' => $decryptKey, ]); - + $jwe = $this->encryptionSerializerManager->unserialize($rawToken); - + // We decrypt the token. This method does NOT check the header. if ($this->jweDecrypter->decryptUsingKey($jwe, $clientSecret, 0)) { return $this->serializerManager->unserialize($jwe->getPayload()); - } else { - throw new InvalidTokenException('Unknown bearer encryption format'); } - } else { - return $this->serializerManager->unserialize($rawToken); + + throw new InvalidTokenException('Unknown bearer encryption format'); } + + return $this->serializerManager->unserialize($rawToken); } /** * Get claims (even before verification to access e.g. aud standard field ...) * Transform them in a format compatible with id_token representation. */ - public function decode(JWS $decodedToken) : object { + public function decode(JWS $decodedToken): object { $this->logger->debug('Telekom SAM3 access token: ' . $decodedToken->getPayload()); $samContent = json_decode($decodedToken->getPayload(), false); - // remap all the custom claims - // adapt into OpenId id_token format (as far as possible) - $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'}; - foreach ($claimArray as $claimKeyValue) { - $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value; + $claimArray = $samContent->{'urn:telekom.com:idm:at:attributes'} ?? null; + if (is_iterable($claimArray)) { + foreach ($claimArray as $claimKeyValue) { + if (isset($claimKeyValue->name, $claimKeyValue->value)) { + $samContent->{'urn:telekom.com:' . $claimKeyValue->name} = $claimKeyValue->value; + } + } } unset($samContent->{'urn:telekom.com:idm:at:attributes'}); @@ -157,46 +139,51 @@ public function decode(JWS $decodedToken) : object { return $samContent; } - - public function verifySignature(JWS $decodedToken, string $signKey) { + public function verifySignature(JWS $decodedToken, string $signKey): void { $accessSecret = new JWK([ 'kty' => 'oct', - 'k' => $signKey - ]); // TODO: take the additional access key secret from settings + 'k' => $signKey, + ]); if (!$this->jwsVerifier->verifyWithKey($decodedToken, $accessSecret, 0)) { throw new SignatureException('Invalid Signature'); } } - public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60) { + /** + * @param object $claims decoded token payload + * @param string[] $audiences acceptable audiences + */ + public function verifyClaims(object $claims, array $audiences = [], int $leeway = 60): void { $timestamp = $this->timeFactory->getTime(); - // Check the nbf if it is defined. This is the time that the - // token can actually be used. If it's not yet that time, abort. if (isset($claims->nbf) && $claims->nbf > ($timestamp + $leeway)) { throw new InvalidTokenException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->nbf) + 'Cannot handle token prior to ' . date(DateTime::ISO8601, (int)$claims->nbf) ); } - // Check that this token has been created before 'now'. This prevents - // using tokens that have been created for later use (and haven't - // correctly used the nbf claim). if (isset($claims->iat) && $claims->iat > ($timestamp + $leeway)) { throw new InvalidTokenException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $claims->iat) + 'Cannot handle token prior to ' . date(DateTime::ISO8601, (int)$claims->iat) ); } - // Check if this token has expired. if (isset($claims->exp) && ($timestamp - $leeway) >= $claims->exp) { throw new InvalidTokenException('Expired token'); } - // Check target audience (if given) - // Check if this token has expired. - if (empty(array_intersect($claims->aud, $audiences))) { + // aud kann String ODER Array sein -> normalisieren + $tokenAud = []; + if (isset($claims->aud)) { + if (is_array($claims->aud)) { + $tokenAud = $claims->aud; + } elseif (is_string($claims->aud)) { + $tokenAud = [$claims->aud]; + } + } + + if (!empty($audiences) && empty(array_intersect($tokenAud, $audiences))) { throw new InvalidTokenException('No acceptable audience in token.'); } } diff --git a/lib/Service/TokenService.php b/lib/Service/TokenService.php index 1cab6845..e6a41936 100644 --- a/lib/Service/TokenService.php +++ b/lib/Service/TokenService.php @@ -25,7 +25,6 @@ use OCP\Authentication\Exceptions\WipeTokenException; use OCP\Authentication\Token\IToken; use OCP\EventDispatcher\IEventDispatcher; -use OCP\Http\Client\IClient; use OCP\IAppConfig; use OCP\IConfig; use OCP\IRequest; @@ -46,8 +45,6 @@ class TokenService { private const SESSION_TOKEN_KEY = Application::APP_ID . '-user-token'; - private IClient $client; - public function __construct( public HttpClientHelper $clientService, private ISession $session, @@ -64,7 +61,8 @@ public function __construct( private DiscoveryService $discoveryService, private ProviderMapper $providerMapper, ) { - + // nothing else needed + // If you really need an IClient instance, inject it explicitly here and store it as a promoted property. } public function storeToken(array $tokenData): Token { @@ -75,11 +73,6 @@ public function storeToken(array $tokenData): Token { } /** - * Get the token stored in the session - * If it has expired: try to refresh it - * - * @param bool $refreshIfExpired - * @return Token|null Return a token only if it is valid or has been successfully refreshed * @throws \JsonException */ public function getToken(bool $refreshIfExpired = true): ?Token { @@ -91,16 +84,24 @@ public function getToken(bool $refreshIfExpired = true): ?Token { } $token = new Token(json_decode($sessionData, true, 512, JSON_THROW_ON_ERROR)); + // token is still valid if (!$token->isExpired()) { - $this->logger->debug('[TokenService] getToken: token is still valid, it expires in ' . strval($token->getExpiresInFromNow()) . ' and refresh expires in ' . strval($token->getRefreshExpiresInFromNow())); + $this->logger->debug( + '[TokenService] getToken: token is still valid, it expires in ' . + (string)$token->getExpiresInFromNow() . + ' and refresh expires in ' . + (string)$token->getRefreshExpiresInFromNow() + ); return $token; } - // token has expired - // try to refresh the token if there is a refresh token and it is still valid + // token has expired -> try refresh if ($refreshIfExpired && $token->getRefreshToken() !== null && !$token->refreshIsExpired()) { - $this->logger->debug('[TokenService] getToken: token is expired and refresh token is still valid, refresh expires in ' . strval($token->getRefreshExpiresInFromNow())); + $this->logger->debug( + '[TokenService] getToken: token is expired and refresh token is still valid, refresh expires in ' . + (string)$token->getRefreshExpiresInFromNow() + ); return $this->refresh($token); } @@ -109,9 +110,6 @@ public function getToken(bool $refreshIfExpired = true): ?Token { } /** - * Check to make sure the login token is still valid - * - * @return void * @throws \JsonException * @throws PreConditionNotMetException */ @@ -132,22 +130,20 @@ public function checkLoginToken(): void { return; } - // Do not check the OIDC login token when not logged in via user_oidc (app password or direct login for example) - // Inspired from https://github.com/nextcloud/server/pull/43942/files#diff-c5cef03f925f97933ff9b3eb10217d21ef6516342e5628762756f1ba0469ac84R81-R92 try { $sessionId = $this->session->getId(); $sessionAuthToken = $this->tokenProvider->getToken($sessionId); } catch (SessionNotAvailableException|InvalidTokenException|WipeTokenException|ExpiredTokenException $e) { - // States we do not deal with here. $this->logger->debug('[TokenService] checkLoginToken: error getting the session auth token', ['exception' => $e]); return; } + $scope = $sessionAuthToken->getScopeAsArray(); - // since 30, we still support 29 + if (defined(IToken::class . '::SCOPE_SKIP_PASSWORD_VALIDATION') && ( !isset($scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION]) - || $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] === false + || $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] === false ) ) { $this->logger->debug('[TokenService] checkLoginToken: most likely not using user_oidc, the session auth token does not have the "skip pwd validation" scope'); @@ -157,36 +153,31 @@ public function checkLoginToken(): void { $token = $this->getToken(); if ($token === null) { $this->logger->debug('[TokenService] checkLoginToken: token is null'); - // if we don't have a token but we had one once, - // it means the session (where we store the token) has died - // so we need to reauthenticate $this->logger->debug('[TokenService] checkLoginToken: token is null and user had_token_once -> logout'); $this->userSession->logout(); return; - } elseif ($token->isExpired()) { + } + if ($token->isExpired()) { $this->logger->debug('[TokenService] checkLoginToken: token is still expired -> reauthenticate'); - // if the token is not valid, it means we couldn't refresh it so we need to reauthenticate to get a fresh token $this->reauthenticate($token->getProviderId()); return; } + $this->logger->debug('[TokenService] checkLoginToken: all good'); } - public function reauthenticate(int $providerId) { - // Logout the user and redirect to the oidc login flow to gather a fresh token + public function reauthenticate(int $providerId): void { $this->userSession->logout(); $redirectUrl = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.login', [ 'providerId' => $providerId, 'redirectUrl' => $this->request->getRequestUri(), ]); - header('Location: ' . $redirectUrl); $this->logger->debug('[TokenService] reauthenticate', ['redirectUrl' => $redirectUrl]); + header('Location: ' . $redirectUrl); exit(); } /** - * @param Token $token - * @return Token * @throws \JsonException * @throws DoesNotExistException * @throws MultipleObjectsReturnedException @@ -201,9 +192,10 @@ public function refresh(Token $token): Token { try { $clientSecret = $this->crypto->decrypt($clientSecret); } catch (\Exception $e) { - $this->logger->error('[TokenService] Failed to decrypt oidc client secret to refresh the token'); + $this->logger->error('[TokenService] Failed to decrypt oidc client secret to refresh the token', ['exception' => $e]); } } + $this->logger->debug('[TokenService] Refreshing the token: ' . $discovery['token_endpoint']); $body = $this->clientService->post( $discovery['token_endpoint'], @@ -214,24 +206,16 @@ public function refresh(Token $token): Token { 'refresh_token' => $token->getRefreshToken(), ] ); - $this->logger->debug('[TokenService] Token refresh request params', [ - 'client_id' => $oidcProvider->getClientId(), - // 'client_secret' => $clientSecret, - 'grant_type' => 'refresh_token', - // 'refresh_token' => $token->getRefreshToken(), - ]); $bodyArray = json_decode(trim($body), true, 512, JSON_THROW_ON_ERROR); $this->logger->debug('[TokenService] ---- Refresh token success'); - return $this->storeToken( - array_merge( - $bodyArray, - ['provider_id' => $token->getProviderId()], - ) - ); - } catch (\Exception $e) { + + return $this->storeToken(array_merge( + $bodyArray, + ['provider_id' => $token->getProviderId()], + )); + } catch (\Throwable $e) { $this->logger->error('[TokenService] Failed to refresh token ', ['exception' => $e]); - // Failed to refresh, return old token which will be retried or otherwise timeout if expired return $token; } } @@ -245,10 +229,6 @@ public function decodeIdToken(Token $token): array { } /** - * Exchange the login token for another audience (client ID) - * - * @param string $targetAudience - * @return Token * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws TokenExchangeFailedException @@ -262,6 +242,7 @@ public function getExchangedToken(string $targetAudience, array $extraScopes = [ 0, ); } + $this->logger->debug('[TokenService] Starting token exchange'); $loginToken = $this->getToken(); @@ -273,8 +254,10 @@ public function getExchangedToken(string $targetAudience, array $extraScopes = [ $this->logger->debug('[TokenService] Failed to exchange token, the login token is expired'); throw new TokenExchangeFailedException('Failed to exchange token, the login token is expired'); } + $oidcProvider = $this->providerMapper->getProvider($loginToken->getProviderId()); $discovery = $this->discoveryService->obtainDiscovery($oidcProvider); + $scope = $oidcProvider->getScope(); if (!empty($extraScopes)) { $scope .= ' ' . implode(' ', $extraScopes); @@ -286,55 +269,48 @@ public function getExchangedToken(string $targetAudience, array $extraScopes = [ try { $clientSecret = $this->crypto->decrypt($clientSecret); } catch (\Exception $e) { - $this->logger->error('[TokenService] Token Exchange: Failed to decrypt oidc client secret'); + $this->logger->error('[TokenService] Token Exchange: Failed to decrypt oidc client secret', ['exception' => $e]); } } + $this->logger->debug('[TokenService] Exchanging the token: ' . $discovery['token_endpoint']); + $tokenEndpointParams = [ 'client_id' => $oidcProvider->getClientId(), 'client_secret' => $clientSecret, 'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange', 'subject_token' => $loginToken->getAccessToken(), 'subject_token_type' => 'urn:ietf:params:oauth:token-type:access_token', - // can also be - // urn:ietf:params:oauth:token-type:access_token - // or urn:ietf:params:oauth:token-type:id_token - // this one will get us an access token and refresh token within the response 'requested_token_type' => 'urn:ietf:params:oauth:token-type:refresh_token', 'audience' => $targetAudience, 'scope' => $scope, ]; + $oidcConfig = $this->config->getSystemValue('user_oidc', []); if (isset($oidcConfig['prompt']) && is_string($oidcConfig['prompt'])) { - // none, consent, login and internal for oauth2 passport server $tokenEndpointParams['prompt'] = $oidcConfig['prompt']; } - // more in https://www.keycloak.org/securing-apps/token-exchange + $body = $this->clientService->post( $discovery['token_endpoint'], $tokenEndpointParams, ); - $this->logger->debug('[TokenService] Token exchange request params', [ - 'client_id' => $oidcProvider->getClientId(), - // 'client_secret' => $clientSecret, - 'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange', - // 'subject_token' => $loginToken->getAccessToken(), - 'subject_token_type' => 'urn:ietf:params:oauth:token-type:access_token', - 'requested_token_type' => 'urn:ietf:params:oauth:token-type:refresh_token', - 'audience' => $targetAudience, - ]); $bodyArray = json_decode(trim($body), true, 512, JSON_THROW_ON_ERROR); - $this->logger->debug('[TokenService] Token exchange success: "' . trim($body) . '"'); + $tokenData = array_merge( $bodyArray, ['provider_id' => $loginToken->getProviderId()], ); + return new Token($tokenData); } catch (ClientException|ServerException $e) { $response = $e->getResponse(); $body = (string)$response->getBody(); - $this->logger->error('[TokenService] Failed to exchange token, client/server error in the exchange request', ['response_body' => $body, 'exception' => $e]); + $this->logger->error('[TokenService] Failed to exchange token, client/server error in the exchange request', [ + 'response_body' => $body, + 'exception' => $e, + ]); $parsedBody = json_decode(trim($body), true); if (is_array($parsedBody) && isset($parsedBody['error'], $parsedBody['error_description'])) { @@ -345,26 +321,19 @@ public function getExchangedToken(string $targetAudience, array $extraScopes = [ $parsedBody['error'], $parsedBody['error_description'], ); - } else { - throw new TokenExchangeFailedException( - 'Failed to exchange token, client/server error in the exchange request: ' . $body, - 0, - $e, - ); } - } catch (\Exception|\Throwable $e) { + + throw new TokenExchangeFailedException( + 'Failed to exchange token, client/server error in the exchange request: ' . $body, + 0, + $e, + ); + } catch (\Throwable $e) { $this->logger->error('[TokenService] Failed to exchange token ', ['exception' => $e]); throw new TokenExchangeFailedException('Failed to exchange token, error in the exchange request', 0, $e); } } - /** - * Try to get a token from the Oidc provider app for a user and a specific audience (client ID) - * - * @param string $userId - * @param string $targetAudience - * @return Token|null - */ public function getTokenFromOidcProviderApp(string $userId, string $targetAudience, array $extraScopes = [], string $resource = ''): ?Token { if (!class_exists(\OCA\OIDCIdentityProvider\AppInfo\Application::class)) { $this->logger->warning('[TokenService] Failed to get token from Oidc provider app, oidc app is not installed'); @@ -383,12 +352,13 @@ public function getTokenFromOidcProviderApp(string $userId, string $targetAudien $scope = implode(' ', $extraScopes); $generationEvent = new \OCA\OIDCIdentityProvider\Event\TokenGenerationRequestEvent($targetAudience, $userId, $scope, $resource); $this->eventDispatcher->dispatchTyped($generationEvent); + if ($generationEvent->getAccessToken() === null || $generationEvent->getIdToken() === null) { $this->logger->debug('[TokenService] The Oidc provider app did not generate any access/id token'); return null; } - } catch (\Exception|\Throwable $e) { - $this->logger->debug('[TokenService] The Oidc provider app failed to generate a token'); + } catch (\Throwable $e) { + $this->logger->debug('[TokenService] The Oidc provider app failed to generate a token', ['exception' => $e]); return null; } @@ -397,7 +367,6 @@ public function getTokenFromOidcProviderApp(string $userId, string $targetAudien 'id_token' => $generationEvent->getIdToken(), 'refresh_token' => $generationEvent->getRefreshToken(), 'expires_in' => $generationEvent->getExpiresIn(), - // the getRefreshExpiresIn method will appear after oidc v1.4.0, see https://github.com/H2CK/oidc/pull/530 'refresh_expires_in' => method_exists($generationEvent, 'getRefreshExpiresIn') ? $generationEvent->getRefreshExpiresIn() : $generationEvent->getExpiresIn(), From 2d3cb70ca83b911e310ad4ef1089ddec32a6ebd5 Mon Sep 17 00:00:00 2001 From: memurats Date: Fri, 12 Dec 2025 13:27:20 +0000 Subject: [PATCH 33/33] Add jwt-token composer library dependencies --- composer.json | 12 +- composer.lock | 1240 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1249 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 438248e8..a100b2fc 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,17 @@ "require": { "id4me/id4me-rp": "^1.2", "firebase/php-jwt": "^6.8.1", - "bamarni/composer-bin-plugin": "^1.4" + "bamarni/composer-bin-plugin": "^1.4", + "web-token/jwt-core": "^2.0", + "web-token/jwt-encryption": "^2.2", + "web-token/jwt-signature": "^2.2", + "web-token/jwt-encryption-algorithm-aescbc": "^2.2", + "web-token/jwt-encryption-algorithm-ecdh-es": "^2.2", + "web-token/jwt-encryption-algorithm-rsa": "^2.2", + "web-token/jwt-encryption-algorithm-pbes2": "^2.2", + "web-token/jwt-signature-algorithm-hmac": "^2.2", + "web-token/jwt-signature-algorithm-rsa": "^2.2", + "web-token/jwt-util-ecc": "^2.2" }, "require-dev": { "nextcloud/coding-standard": "^1.0.0", diff --git a/composer.lock b/composer.lock index 0955e1d7..db8dd2fd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "04286fb7b53817717fac1f09a0a61a40", + "content-hash": "bb379f676922e3656d7bc0ed16205df8", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -63,6 +63,142 @@ }, "time": "2022-10-31T08:38:03+00:00" }, + { + "name": "brick/math", + "version": "0.9.3", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "ca57d18f028f84f777b2168cd1911b0dee2343ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/ca57d18f028f84f777b2168cd1911b0dee2343ae", + "reference": "ca57d18f028f84f777b2168cd1911b0dee2343ae", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.0", + "vimeo/psalm": "4.9.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "brick", + "math" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.9.3" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/brick/math", + "type": "tidelift" + } + ], + "time": "2021-08-15T20:50:18+00:00" + }, + { + "name": "fgrosse/phpasn1", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/fgrosse/PHPASN1.git", + "reference": "42060ed45344789fb9f21f9f1864fc47b9e3507b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fgrosse/PHPASN1/zipball/42060ed45344789fb9f21f9f1864fc47b9e3507b", + "reference": "42060ed45344789fb9f21f9f1864fc47b9e3507b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "~2.0", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + }, + "suggest": { + "ext-bcmath": "BCmath is the fallback extension for big integer calculations", + "ext-curl": "For loading OID information from the web if they have not bee defined statically", + "ext-gmp": "GMP is the preferred extension for big integer calculations", + "phpseclib/bcmath_compat": "BCmath polyfill for servers where neither GMP nor BCmath is available" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "FG\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Friedrich Große", + "email": "friedrich.grosse@gmail.com", + "homepage": "https://github.com/FGrosse", + "role": "Author" + }, + { + "name": "All contributors", + "homepage": "https://github.com/FGrosse/PHPASN1/contributors" + } + ], + "description": "A PHP Framework that allows you to encode and decode arbitrary ASN.1 structures using the ITU-T X.690 Encoding Rules.", + "homepage": "https://github.com/FGrosse/PHPASN1", + "keywords": [ + "DER", + "asn.1", + "asn1", + "ber", + "binary", + "decoding", + "encoding", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/fgrosse/PHPASN1/issues", + "source": "https://github.com/fgrosse/PHPASN1/tree/v2.5.0" + }, + "abandoned": true, + "time": "2022-12-19T11:08:26+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -279,6 +415,1106 @@ } ], "time": "2024-12-14T21:03:54+00:00" + }, + { + "name": "spomky-labs/aes-key-wrap", + "version": "v6.0.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/aes-key-wrap.git", + "reference": "97388255a37ad6fb1ed332d07e61fa2b7bb62e0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/aes-key-wrap/zipball/97388255a37ad6fb1ed332d07e61fa2b7bb62e0d", + "reference": "97388255a37ad6fb1ed332d07e61fa2b7bb62e0d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "lib-openssl": "*", + "php": ">=7.2", + "thecodingmachine/safe": "^1.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-beberlei-assert": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^7.0|^8.0|^9.0", + "thecodingmachine/phpstan-safe-rule": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "AESKW\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/aes-key-wrap/contributors" + } + ], + "description": "AES Key Wrap for PHP.", + "homepage": "https://github.com/Spomky-Labs/aes-key-wrap", + "keywords": [ + "A128KW", + "A192KW", + "A256KW", + "RFC3394", + "RFC5649", + "aes", + "key", + "padding", + "wrap" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/aes-key-wrap/issues", + "source": "https://github.com/Spomky-Labs/aes-key-wrap/tree/v6.0.0" + }, + "time": "2020-08-01T14:07:55+00:00" + }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v1.3.3", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpstan/phpstan": "^0.12", + "squizlabs/php_codesniffer": "^3.2", + "thecodingmachine/phpstan-strict-rules": "^0.12" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.1-dev" + } + }, + "autoload": { + "files": [ + "deprecated/apc.php", + "deprecated/libevent.php", + "deprecated/mssql.php", + "deprecated/stats.php", + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/ingres-ii.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/msql.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/mysqlndMs.php", + "generated/mysqlndQc.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/password.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pdf.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/simplexml.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "psr-4": { + "Safe\\": [ + "lib/", + "deprecated/", + "generated/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v1.3.3" + }, + "time": "2020-10-28T17:51:34+00:00" + }, + { + "name": "web-token/jwt-core", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-core.git", + "reference": "53beb6f6c1eec4fa93c1c3e5d9e5701e71fa1678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-core/zipball/53beb6f6c1eec4fa93c1c3e5d9e5701e71fa1678", + "reference": "53beb6f6c1eec4fa93c1c3e5d9e5701e71fa1678", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.17|^0.9", + "ext-json": "*", + "ext-mbstring": "*", + "fgrosse/phpasn1": "^2.0", + "php": ">=7.2", + "spomky-labs/base64url": "^1.0|^2.0" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Core\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "Core component of the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-core/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-03-17T14:55:52+00:00" + }, + { + "name": "web-token/jwt-encryption", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-encryption.git", + "reference": "3b8d67d7c5c013750703e7c27f1001544407bbb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-encryption/zipball/3b8d67d7c5c013750703e7c27f1001544407bbb2", + "reference": "3b8d67d7c5c013750703e7c27f1001544407bbb2", + "shasum": "" + }, + "require": { + "web-token/jwt-core": "^2.1" + }, + "suggest": { + "web-token/jwt-encryption-algorithm-aescbc": "AES CBC Based Content Encryption Algorithms", + "web-token/jwt-encryption-algorithm-aesgcm": "AES GCM Based Content Encryption Algorithms", + "web-token/jwt-encryption-algorithm-aesgcmkw": "AES GCM Key Wrapping Based Key Encryption Algorithms", + "web-token/jwt-encryption-algorithm-aeskw": "AES Key Wrapping Based Key Encryption Algorithms", + "web-token/jwt-encryption-algorithm-dir": "Direct Key Encryption Algorithms", + "web-token/jwt-encryption-algorithm-ecdh-es": "ECDH-ES Based Key Encryption Algorithms", + "web-token/jwt-encryption-algorithm-experimental": "Experimental Key and Signature Algorithms", + "web-token/jwt-encryption-algorithm-pbes2": "PBES2 Based Key Encryption Algorithms", + "web-token/jwt-encryption-algorithm-rsa": "RSA Based Key Encryption Algorithms" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Encryption\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-encryption/contributors" + } + ], + "description": "Encryption component of the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-encryption/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-03-17T14:55:52+00:00" + }, + { + "name": "web-token/jwt-encryption-algorithm-aescbc", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-encryption-algorithm-aescbc.git", + "reference": "0359b82b349c8bbc82c19ba0382e1a1b3f788421" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-encryption-algorithm-aescbc/zipball/0359b82b349c8bbc82c19ba0382e1a1b3f788421", + "reference": "0359b82b349c8bbc82c19ba0382e1a1b3f788421", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "web-token/jwt-encryption": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Encryption\\Algorithm\\ContentEncryption\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "AES CBC Based Content Encryption Algorithms the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-encryption-algorithm-aescbc/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-01-21T19:18:03+00:00" + }, + { + "name": "web-token/jwt-encryption-algorithm-ecdh-es", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-encryption-algorithm-ecdh-es.git", + "reference": "736c2c5a3997e4cfb84e9f8f63bf17b5d7ca4fd0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-encryption-algorithm-ecdh-es/zipball/736c2c5a3997e4cfb84e9f8f63bf17b5d7ca4fd0", + "reference": "736c2c5a3997e4cfb84e9f8f63bf17b5d7ca4fd0", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "spomky-labs/aes-key-wrap": "^5.0|^6.0", + "web-token/jwt-encryption": "^2.1", + "web-token/jwt-util-ecc": "^2.1" + }, + "suggest": { + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Encryption\\Algorithm\\KeyEncryption\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "ECDH-ES Based Key Encryption Algorithms the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-encryption-algorithm-ecdh-es/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-03-17T14:55:52+00:00" + }, + { + "name": "web-token/jwt-encryption-algorithm-pbes2", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-encryption-algorithm-pbes2.git", + "reference": "d0294e7821d4a9b70454d3b13441add59c525275" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-encryption-algorithm-pbes2/zipball/d0294e7821d4a9b70454d3b13441add59c525275", + "reference": "d0294e7821d4a9b70454d3b13441add59c525275", + "shasum": "" + }, + "require": { + "web-token/jwt-encryption": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Encryption\\Algorithm\\KeyEncryption\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "PBES2* Based Key Encryption Algorithms the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-encryption-algorithm-pbes2/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-01-21T19:18:03+00:00" + }, + { + "name": "web-token/jwt-encryption-algorithm-rsa", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-encryption-algorithm-rsa.git", + "reference": "2aab79c4cda093d2ee94756d0b1b46e93b380f55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-encryption-algorithm-rsa/zipball/2aab79c4cda093d2ee94756d0b1b46e93b380f55", + "reference": "2aab79c4cda093d2ee94756d0b1b46e93b380f55", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.17|^0.9", + "ext-openssl": "*", + "symfony/polyfill-mbstring": "^1.12", + "web-token/jwt-encryption": "^2.1" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Encryption\\Algorithm\\KeyEncryption\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "RSA Based Key Encryption Algorithms the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-encryption-algorithm-rsa/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-01-21T19:18:03+00:00" + }, + { + "name": "web-token/jwt-signature", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-signature.git", + "reference": "015b59aaf3b6e8fb9f5bd1338845b7464c7d8103" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-signature/zipball/015b59aaf3b6e8fb9f5bd1338845b7464c7d8103", + "reference": "015b59aaf3b6e8fb9f5bd1338845b7464c7d8103", + "shasum": "" + }, + "require": { + "web-token/jwt-core": "^2.1" + }, + "suggest": { + "web-token/jwt-signature-algorithm-ecdsa": "ECDSA Based Signature Algorithms", + "web-token/jwt-signature-algorithm-eddsa": "EdDSA Based Signature Algorithms", + "web-token/jwt-signature-algorithm-experimental": "Experimental Signature Algorithms", + "web-token/jwt-signature-algorithm-hmac": "HMAC Based Signature Algorithms", + "web-token/jwt-signature-algorithm-none": "None Signature Algorithm", + "web-token/jwt-signature-algorithm-rsa": "RSA Based Signature Algorithms" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Signature\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-signature/contributors" + } + ], + "description": "Signature component of the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-signature/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-03-01T19:55:28+00:00" + }, + { + "name": "web-token/jwt-signature-algorithm-hmac", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-signature-algorithm-hmac.git", + "reference": "d208b1c50b408fa711bfeedeed9fb5d9be1d3080" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-signature-algorithm-hmac/zipball/d208b1c50b408fa711bfeedeed9fb5d9be1d3080", + "reference": "d208b1c50b408fa711bfeedeed9fb5d9be1d3080", + "shasum": "" + }, + "require": { + "web-token/jwt-signature": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Signature\\Algorithm\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "HMAC Based Signature Algorithms the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-signature-algorithm-hmac/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-01-21T19:18:03+00:00" + }, + { + "name": "web-token/jwt-signature-algorithm-rsa", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-signature-algorithm-rsa.git", + "reference": "513ad90eb5ef1886ff176727a769bda4618141b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-signature-algorithm-rsa/zipball/513ad90eb5ef1886ff176727a769bda4618141b0", + "reference": "513ad90eb5ef1886ff176727a769bda4618141b0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.17|^0.9", + "ext-openssl": "*", + "web-token/jwt-signature": "^2.1" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Signature\\Algorithm\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "RSA Based Signature Algorithms the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-signature-algorithm-rsa/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-01-21T19:18:03+00:00" + }, + { + "name": "web-token/jwt-util-ecc", + "version": "v2.2.11", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-util-ecc.git", + "reference": "915f3fde86f5236c205620d61177b9ef43863deb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-util-ecc/zipball/915f3fde86f5236c205620d61177b9ef43863deb", + "reference": "915f3fde86f5236c205620d61177b9ef43863deb", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.17|^0.9" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\Core\\Util\\Ecc\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "ECC Tools for the JWT Framework.", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "source": "https://github.com/web-token/jwt-util-ecc/tree/v2.2.11" + }, + "funding": [ + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "abandoned": "web-token/jwt-library", + "time": "2021-03-24T13:35:17+00:00" } ], "packages-dev": [ @@ -2609,5 +3845,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" }