From 2b38242ebaea27e2b4e1282475a93185b0742e2a Mon Sep 17 00:00:00 2001 From: Damian Ujma <92095156+damianujma@users.noreply.github.com> Date: Wed, 14 May 2025 15:25:45 +0200 Subject: [PATCH] ACS-9416 Backport ACS-9414 Enhance the Identity Provider configuration ( #3263) (#3342) --- .github/workflows/ci.yml | 188 +- .secrets.baseline | 10 +- ...dentityServiceAuthenticationComponent.java | 244 ++- .../IdentityServiceConfig.java | 743 ++++---- .../IdentityServiceFacade.java | 514 ++--- .../IdentityServiceFacadeFactoryBean.java | 1650 +++++++++-------- ...IdentityServiceJITProvisioningHandler.java | 174 +- .../IdentityServiceRemoteUserMapper.java | 362 ++-- .../SpringBasedIdentityServiceFacade.java | 113 +- ...ntityServiceAdminConsoleAuthenticator.java | 73 +- .../AccessTokenToDecodedTokenUserMapper.java | 66 + .../user/DecodedTokenUser.java | 44 + .../{ => user}/OIDCUserInfo.java | 2 +- .../user/TokenUserToOIDCUserMapper.java | 76 + .../user/UserInfoAttrMapping.java | 41 + ...dentity-service-authentication-context.xml | 23 +- ...identity-service-authentication.properties | 9 +- .../java/org/alfresco/AllUnitTestsSuite.java | 447 ++--- .../ClientRegistrationProviderUnitTest.java | 83 +- ...ityServiceAuthenticationComponentTest.java | 345 ++-- ...tityServiceJITProvisioningHandlerTest.java | 65 +- ...ServiceJITProvisioningHandlerUnitTest.java | 157 +- .../IdentityServiceRemoteUserMapperTest.java | 29 +- ...ingBasedIdentityServiceFacadeUnitTest.java | 197 +- ...viceAdminConsoleAuthenticatorUnitTest.java | 39 +- ...TokenToDecodedTokenUserMapperUnitTest.java | 109 ++ .../TokenUserToOIDCUserMapperUnitTest.java | 95 + .../test/resources/alfresco-global.properties | 3 + 28 files changed, 3262 insertions(+), 2639 deletions(-) create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java rename repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/{ => user}/OIDCUserInfo.java (99%) create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3926433b33..95710e18bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,14 +44,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 - - id: changed-files - uses: Alfresco/alfresco-build-tools/.github/actions/github-list-changes@v8.13.0 - with: - write-list-to-env: true - - uses: Alfresco/alfresco-build-tools/.github/actions/pre-commit@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/pre-commit@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Prepare maven cache and check compilation" @@ -69,12 +65,12 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - - uses: Alfresco/alfresco-build-tools/.github/actions/veracode@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/veracode@v8.16.0 continue-on-error: true with: srcclr-api-token: ${{ secrets.SRCCLR_API_TOKEN }} @@ -92,10 +88,10 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/github-download-file@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/github-download-file@v8.16.0 with: token: ${{ secrets.BOT_GITHUB_TOKEN }} repository: "Alfresco/veracode-baseline-archive" @@ -148,10 +144,10 @@ jobs: !contains(github.event.head_commit.message, '[skip tests]') && !contains(github.event.head_commit.message, '[force]') steps: - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 - - uses: Alfresco/ya-pmd-scan@v4.1.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 + - uses: Alfresco/ya-pmd-scan@v4.3.0 with: classpath-build-command: "mvn test-compile -ntp -Pags -pl \"-:alfresco-community-repo-docker\"" @@ -181,14 +177,14 @@ jobs: testAttributes: "-Dtest=AllMmtUnitTestSuite" steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} - ${{ matrix.testModule }} @@ -219,7 +215,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -261,9 +257,9 @@ jobs: REQUIRES_INSTALLED_ARTIFACTS: true steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Build" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} run: | @@ -276,7 +272,7 @@ jobs: run: docker compose -f ./scripts/ci/docker-compose/docker-compose.yaml --profile ${{ matrix.compose-profile }} up -d - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} - ${{ matrix.testSuite }} @@ -307,7 +303,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -340,9 +336,9 @@ jobs: version: ['10.5', '10.6'] steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: Run MariaDB ${{ matrix.version }} database @@ -351,7 +347,7 @@ jobs: MARIADB_VERSION: ${{ matrix.version }} - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} - ${{ matrix.version }} @@ -382,7 +378,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -411,9 +407,9 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run MariaDB 10.11 database" @@ -422,7 +418,7 @@ jobs: MARIADB_VERSION: 10.11 - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -453,7 +449,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -482,9 +478,9 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run MySQL 8 database" @@ -493,7 +489,7 @@ jobs: MYSQL_VERSION: 8 - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -524,7 +520,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -552,9 +548,9 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run PostgreSQL 14.15 database" @@ -563,7 +559,7 @@ jobs: POSTGRES_VERSION: 14.15 - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -594,7 +590,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -622,9 +618,9 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run PostgreSQL 15.10 database" @@ -633,7 +629,7 @@ jobs: POSTGRES_VERSION: 15.10 - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -664,7 +660,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -692,9 +688,9 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run PostgreSQL 16.6 database" @@ -703,7 +699,7 @@ jobs: POSTGRES_VERSION: 16.6 - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -734,7 +730,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -760,16 +756,16 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run ActiveMQ" run: docker compose -f ./scripts/ci/docker-compose/docker-compose.yaml --profile activemq up -d - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -800,7 +796,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -860,9 +856,9 @@ jobs: mvn-options: '-Dencryption.ssl.keystore.location=${CI_WORKSPACE}/keystores/alfresco/alfresco.keystore -Dencryption.ssl.truststore.location=${CI_WORKSPACE}/keystores/alfresco/alfresco.truststore' steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Set transformers tag" @@ -885,7 +881,7 @@ jobs: run: docker compose -f ./scripts/ci/docker-compose/docker-compose.yaml --profile ${{ matrix.compose-profile }} up -d - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} - ${{ matrix.testSuite }} ${{ matrix.idp }} @@ -916,7 +912,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -974,9 +970,9 @@ jobs: REQUIRES_LOCAL_IMAGES: true steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Build" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} run: | @@ -992,7 +988,7 @@ jobs: run: mvn install -pl :alfresco-community-repo-integration-test -am -DskipTests -Pall-tas-tests - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} - ${{ matrix.test-name }} @@ -1030,7 +1026,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.tests.outcome }} @@ -1056,16 +1052,16 @@ jobs: !contains(github.event.head_commit.message, '[force') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Init" run: bash ./scripts/ci/init.sh - name: "Run Postgres 16.6 database" run: docker compose -f ./scripts/ci/docker-compose/docker-compose.yaml --profile postgres up -d - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -1096,7 +1092,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -1130,9 +1126,9 @@ jobs: REQUIRES_INSTALLED_ARTIFACTS: true steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Build" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} run: | @@ -1140,7 +1136,7 @@ jobs: bash ./scripts/ci/build.sh - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} 0${{ matrix.part }} - (PostgreSQL) ${{ matrix.test-name }} @@ -1176,9 +1172,9 @@ jobs: REQUIRES_INSTALLED_ARTIFACTS: true steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Build" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} run: | @@ -1186,7 +1182,7 @@ jobs: bash ./scripts/ci/build.sh - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} 0${{ matrix.part }} - (MySQL) ${{ matrix.test-name }} @@ -1218,9 +1214,9 @@ jobs: REQUIRES_LOCAL_IMAGES: true steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Build" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} run: | @@ -1234,7 +1230,7 @@ jobs: mvn -B install -pl :alfresco-governance-services-automation-community-rest-api -am -Pags -Pall-tas-tests -DskipTests - name: "Prepare Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-prepare@v8.16.0 id: rp-prepare with: rp-launch-prefix: ${{ env.RP_LAUNCH_PREFIX }} @@ -1266,7 +1262,7 @@ jobs: continue-on-error: true - name: "Summarize Report Portal" if: github.ref_name == 'master' - uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.13.0 + uses: Alfresco/alfresco-build-tools/.github/actions/reportportal-summarize@v8.16.0 id: rp-summarize with: tests-outcome: ${{ steps.run-tests.outcome }} @@ -1308,9 +1304,9 @@ jobs: !contains(github.event.head_commit.message, '[force]') steps: - uses: actions/checkout@v4 - - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.13.0 - - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.13.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 + - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - name: "Build" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} run: | diff --git a/.secrets.baseline b/.secrets.baseline index b160381264..c1b3709032 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,21 +133,21 @@ "filename": ".github/workflows/ci.yml", "hashed_secret": "b86dc2f033a63f2b7b9e7d270ab806d2910d7572", "is_verified": false, - "line_number": 299 + "line_number": 295 }, { "type": "Secret Keyword", "filename": ".github/workflows/ci.yml", "hashed_secret": "1bfb0e20f886150ba59b853bcd49dea893e00966", "is_verified": false, - "line_number": 374 + "line_number": 370 }, { "type": "Secret Keyword", "filename": ".github/workflows/ci.yml", "hashed_secret": "128f14373ccfaff49e3664045d3a11b50cbb7b39", "is_verified": false, - "line_number": 908 + "line_number": 904 } ], ".github/workflows/master_release.yml": [ @@ -1607,7 +1607,7 @@ "filename": "repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 46, + "line_number": 48, "is_secret": false } ], @@ -1868,5 +1868,5 @@ } ] }, - "generated_at": "2025-02-26T15:13:52Z" + "generated_at": "2025-05-13T13:17:41Z" } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java index 90d0d9e0a6..e48f9bf7f0 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java @@ -1,123 +1,121 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import org.alfresco.repo.management.subsystems.ActivateableBean; -import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; -import org.alfresco.repo.security.authentication.AuthenticationException; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -/** - * - * Authenticates a user against Identity Service (Keycloak/Authorization Server). - * {@link IdentityServiceFacade} is used to verify provided user credentials. User is set as the current user if the - * user credentials are valid. - *
- * The {@link IdentityServiceAuthenticationComponent#identityServiceFacade} can be null in which case this authenticator - * will just fall through to the next one in the chain. - * - */ -public class IdentityServiceAuthenticationComponent extends AbstractAuthenticationComponent implements ActivateableBean -{ - private final Log LOGGER = LogFactory.getLog(IdentityServiceAuthenticationComponent.class); - /** client used to authenticate user credentials against Authorization Server **/ - private IdentityServiceFacade identityServiceFacade; - /** enabled flag for the identity service subsystem**/ - private boolean active; - - private IdentityServiceJITProvisioningHandler jitProvisioningHandler; - - private boolean allowGuestLogin; - - public void setIdentityServiceFacade(IdentityServiceFacade identityServiceFacade) - { - this.identityServiceFacade = identityServiceFacade; - } - - public void setAllowGuestLogin(boolean allowGuestLogin) - { - this.allowGuestLogin = allowGuestLogin; - } - - public void setJitProvisioningHandler(IdentityServiceJITProvisioningHandler jitProvisioningHandler) - { - this.jitProvisioningHandler = jitProvisioningHandler; - } - - @Override - public void authenticateImpl(String userName, char[] password) throws AuthenticationException - { - if (identityServiceFacade == null) - { - if (LOGGER.isDebugEnabled()) - { - LOGGER.debug("IdentityServiceFacade was not set, possibly due to the 'identity-service.authentication.enable-username-password-authentication=false' property."); - } - - throw new AuthenticationException("User not authenticated because IdentityServiceFacade was not set."); - } - - try - { - // Attempt to verify user credentials - IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize(AuthorizationGrant.password(userName, String.valueOf(password))); - - String normalizedUsername = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded(accessTokenAuthorization.getAccessToken().getTokenValue()) - .map(OIDCUserInfo::username) - .orElseThrow(() -> new AuthenticationException("Failed to extract username from token and user info endpoint.")); - // Verification was successful so treat as authenticated user - setCurrentUser(normalizedUsername); - } - catch (IdentityServiceFacadeException e) - { - throw new AuthenticationException("Failed to verify user credentials against the OAuth2 Authorization Server.", e); - } - catch (RuntimeException e) - { - throw new AuthenticationException("Failed to verify user credentials.", e); - } - } - - public void setActive(boolean active) - { - this.active = active; - } - - @Override - public boolean isActive() - { - return active; - } - - @Override - protected boolean implementationAllowsGuestLogin() - { - return allowGuestLogin; - } -} +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; + +/** + * + * Authenticates a user against Identity Service (Keycloak/Authorization Server). {@link IdentityServiceFacade} is used to verify provided user credentials. User is set as the current user if the user credentials are valid.
+ * The {@link IdentityServiceAuthenticationComponent#identityServiceFacade} can be null in which case this authenticator will just fall through to the next one in the chain. + * + */ +public class IdentityServiceAuthenticationComponent extends AbstractAuthenticationComponent implements ActivateableBean +{ + private final Log LOGGER = LogFactory.getLog(IdentityServiceAuthenticationComponent.class); + /** client used to authenticate user credentials against Authorization Server **/ + private IdentityServiceFacade identityServiceFacade; + /** enabled flag for the identity service subsystem **/ + private boolean active; + + private IdentityServiceJITProvisioningHandler jitProvisioningHandler; + + private boolean allowGuestLogin; + + public void setIdentityServiceFacade(IdentityServiceFacade identityServiceFacade) + { + this.identityServiceFacade = identityServiceFacade; + } + + public void setAllowGuestLogin(boolean allowGuestLogin) + { + this.allowGuestLogin = allowGuestLogin; + } + + public void setJitProvisioningHandler(IdentityServiceJITProvisioningHandler jitProvisioningHandler) + { + this.jitProvisioningHandler = jitProvisioningHandler; + } + + @Override + public void authenticateImpl(String userName, char[] password) throws AuthenticationException + { + if (identityServiceFacade == null) + { + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("IdentityServiceFacade was not set, possibly due to the 'identity-service.authentication.enable-username-password-authentication=false' property."); + } + + throw new AuthenticationException("User not authenticated because IdentityServiceFacade was not set."); + } + + try + { + // Attempt to verify user credentials + IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize(AuthorizationGrant.password(userName, String.valueOf(password))); + + String normalizedUsername = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded(accessTokenAuthorization.getAccessToken().getTokenValue()) + .map(OIDCUserInfo::username) + .orElseThrow(() -> new AuthenticationException("Failed to extract username from token and user info endpoint.")); + // Verification was successful so treat as authenticated user + setCurrentUser(normalizedUsername); + } + catch (IdentityServiceFacadeException e) + { + throw new AuthenticationException("Failed to verify user credentials against the OAuth2 Authorization Server.", e); + } + catch (RuntimeException e) + { + throw new AuthenticationException("Failed to verify user credentials.", e); + } + } + + public void setActive(boolean active) + { + this.active = active; + } + + @Override + public boolean isActive() + { + return active; + } + + @Override + protected boolean implementationAllowsGuestLogin() + { + return allowGuestLogin; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java index 5189de298a..7593e982e9 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java @@ -1,330 +1,413 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2025 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Class to hold configuration for the Identity Service. - * - * @author Gavin Cornwell - */ -@SuppressWarnings("PMD.ExcessivePublicCount") -public class IdentityServiceConfig -{ - private static final String REALMS = "realms"; - - private int clientConnectionTimeout; - private int clientSocketTimeout; - private String issuerUrl; - private String audience; - // client id - private String resource; - private String clientSecret; - private String authServerUrl; - private String realm; - private int connectionPoolSize; - private boolean allowAnyHostname; - private boolean disableTrustManager; - private String truststore; - private String truststorePassword; - private String clientKeystore; - private String clientKeystorePassword; - private String clientKeyPassword; - private String realmKey; - private int publicKeyCacheTtl; - private boolean publicClient; - private String principalAttribute; - private boolean clientIdValidationDisabled; - private String adminConsoleRedirectPath; - private String signatureAlgorithms; - - /** - * - * @return Client connection timeout in milliseconds. - */ - public int getClientConnectionTimeout() - { - return clientConnectionTimeout; - } - - /** - * - * @param clientConnectionTimeout Client connection timeout in milliseconds. - */ - public void setClientConnectionTimeout(int clientConnectionTimeout) - { - this.clientConnectionTimeout = clientConnectionTimeout; - } - - /** - * - * @return Client socket timeout in milliseconds.s - */ - public int getClientSocketTimeout() - { - return clientSocketTimeout; - } - - /** - * - * @param clientSocketTimeout Client socket timeout in milliseconds. - */ - public void setClientSocketTimeout(int clientSocketTimeout) - { - this.clientSocketTimeout = clientSocketTimeout; - } - - public void setConnectionPoolSize(int connectionPoolSize) - { - this.connectionPoolSize = connectionPoolSize; - } - - public int getConnectionPoolSize() - { - return connectionPoolSize; - } - - public String getIssuerUrl() - { - return issuerUrl; - } - - public void setIssuerUrl(String issuerUrl) - { - this.issuerUrl = issuerUrl; - } - - public String getAudience() - { - return audience; - } - - public void setAudience(String audience) - { - this.audience = audience; - } - - public String getAuthServerUrl() - { - return Optional.ofNullable(realm) - .filter(StringUtils::isNotBlank) - .filter(realm -> StringUtils.isNotBlank(authServerUrl)) - .map(realm -> UriComponentsBuilder.fromUriString(authServerUrl) - .pathSegment(REALMS, realm) - .build() - .toString()) - .orElse(authServerUrl); - } - - public void setAuthServerUrl(String authServerUrl) - { - this.authServerUrl = authServerUrl; - } - - public String getRealm() - { - return realm; - } - - public void setRealm(String realm) - { - this.realm = realm; - } - - public String getResource() - { - return resource; - } - - public void setResource(String resource) - { - this.resource = resource; - } - - public void setClientSecret(String clientSecret) - { - this.clientSecret = clientSecret; - } - - public String getClientSecret() - { - return Optional.ofNullable(clientSecret) - .orElse(""); - } - - public void setAllowAnyHostname(boolean allowAnyHostname) - { - this.allowAnyHostname = allowAnyHostname; - } - - public boolean isAllowAnyHostname() - { - return allowAnyHostname; - } - - public void setDisableTrustManager(boolean disableTrustManager) - { - this.disableTrustManager = disableTrustManager; - } - - public boolean isDisableTrustManager() - { - return disableTrustManager; - } - - public void setTruststore(String truststore) - { - this.truststore = truststore; - } - - public String getTruststore() - { - return truststore; - } - - public void setTruststorePassword(String truststorePassword) - { - this.truststorePassword = truststorePassword; - } - - public String getTruststorePassword() - { - return truststorePassword; - } - - public void setClientKeystore(String clientKeystore) - { - this.clientKeystore = clientKeystore; - } - - public String getClientKeystore() - { - return clientKeystore; - } - - public void setClientKeystorePassword(String clientKeystorePassword) - { - this.clientKeystorePassword = clientKeystorePassword; - } - - public String getClientKeystorePassword() - { - return clientKeystorePassword; - } - - public void setClientKeyPassword(String clientKeyPassword) - { - this.clientKeyPassword = clientKeyPassword; - } - - public String getClientKeyPassword() - { - return clientKeyPassword; - } - - public void setRealmKey(String realmKey) - { - this.realmKey = realmKey; - } - - public String getRealmKey() - { - return realmKey; - } - - public void setPublicKeyCacheTtl(int publicKeyCacheTtl) - { - this.publicKeyCacheTtl = publicKeyCacheTtl; - } - - public int getPublicKeyCacheTtl() - { - return publicKeyCacheTtl; - } - - public void setPublicClient(boolean publicClient) - { - this.publicClient = publicClient; - } - - public boolean isPublicClient() - { - return publicClient; - } - - public String getPrincipalAttribute() - { - return principalAttribute; - } - - public void setPrincipalAttribute(String principalAttribute) - { - this.principalAttribute = principalAttribute; - } - - public boolean isClientIdValidationDisabled() - { - return clientIdValidationDisabled; - } - - public void setClientIdValidationDisabled(boolean clientIdValidationDisabled) - { - this.clientIdValidationDisabled = clientIdValidationDisabled; - } - - public String getAdminConsoleRedirectPath() - { - return adminConsoleRedirectPath; - } - - public void setAdminConsoleRedirectPath(String adminConsoleRedirectPath) - { - this.adminConsoleRedirectPath = adminConsoleRedirectPath; - } - - public Set getSignatureAlgorithms() - { - return Stream.of(signatureAlgorithms.split(",")) - .map(String::trim) - .map(SignatureAlgorithm::from) - .filter(Objects::nonNull) - .collect(Collectors.toUnmodifiableSet()); - } - - public void setSignatureAlgorithms(String signatureAlgorithms) - { - this.signatureAlgorithms = signatureAlgorithms; - } -} \ No newline at end of file +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Class to hold configuration for the Identity Service. + * + * @author Gavin Cornwell + */ +@SuppressWarnings("PMD.ExcessivePublicCount") +public class IdentityServiceConfig +{ + private static final String REALMS = "realms"; + + private int clientConnectionTimeout; + private int clientSocketTimeout; + private String issuerUrl; + private String audience; + // client id + private String resource; + private String clientSecret; + private String authServerUrl; + private String realm; + private int connectionPoolSize; + private boolean allowAnyHostname; + private boolean disableTrustManager; + private String truststore; + private String truststorePassword; + private String clientKeystore; + private String clientKeystorePassword; + private String clientKeyPassword; + private String realmKey; + private int publicKeyCacheTtl; + private boolean publicClient; + private String principalAttribute; + private boolean clientIdValidationDisabled; + private String adminConsoleRedirectPath; + private String signatureAlgorithms; + private String adminConsoleScopes; + private String passwordGrantScopes; + private String issuerAttribute; + private String firstNameAttribute; + private String lastNameAttribute; + private String emailAttribute; + private long jwtClockSkewMs; + + /** + * + * @return Client connection timeout in milliseconds. + */ + public int getClientConnectionTimeout() + { + return clientConnectionTimeout; + } + + /** + * + * @param clientConnectionTimeout + * Client connection timeout in milliseconds. + */ + public void setClientConnectionTimeout(int clientConnectionTimeout) + { + this.clientConnectionTimeout = clientConnectionTimeout; + } + + /** + * + * @return Client socket timeout in milliseconds.s + */ + public int getClientSocketTimeout() + { + return clientSocketTimeout; + } + + /** + * + * @param clientSocketTimeout + * Client socket timeout in milliseconds. + */ + public void setClientSocketTimeout(int clientSocketTimeout) + { + this.clientSocketTimeout = clientSocketTimeout; + } + + public void setConnectionPoolSize(int connectionPoolSize) + { + this.connectionPoolSize = connectionPoolSize; + } + + public int getConnectionPoolSize() + { + return connectionPoolSize; + } + + public String getIssuerUrl() + { + return issuerUrl; + } + + public void setIssuerUrl(String issuerUrl) + { + this.issuerUrl = issuerUrl; + } + + public String getAudience() + { + return audience; + } + + public void setAudience(String audience) + { + this.audience = audience; + } + + public String getAuthServerUrl() + { + return Optional.ofNullable(realm) + .filter(StringUtils::isNotBlank) + .filter(realm -> StringUtils.isNotBlank(authServerUrl)) + .map(realm -> UriComponentsBuilder.fromUriString(authServerUrl) + .pathSegment(REALMS, realm) + .build() + .toString()) + .orElse(authServerUrl); + } + + public void setAuthServerUrl(String authServerUrl) + { + this.authServerUrl = authServerUrl; + } + + public String getRealm() + { + return realm; + } + + public void setRealm(String realm) + { + this.realm = realm; + } + + public String getResource() + { + return resource; + } + + public void setResource(String resource) + { + this.resource = resource; + } + + public void setClientSecret(String clientSecret) + { + this.clientSecret = clientSecret; + } + + public String getClientSecret() + { + return Optional.ofNullable(clientSecret) + .orElse(""); + } + + public void setAllowAnyHostname(boolean allowAnyHostname) + { + this.allowAnyHostname = allowAnyHostname; + } + + public boolean isAllowAnyHostname() + { + return allowAnyHostname; + } + + public void setDisableTrustManager(boolean disableTrustManager) + { + this.disableTrustManager = disableTrustManager; + } + + public boolean isDisableTrustManager() + { + return disableTrustManager; + } + + public void setTruststore(String truststore) + { + this.truststore = truststore; + } + + public String getTruststore() + { + return truststore; + } + + public void setTruststorePassword(String truststorePassword) + { + this.truststorePassword = truststorePassword; + } + + public String getTruststorePassword() + { + return truststorePassword; + } + + public void setClientKeystore(String clientKeystore) + { + this.clientKeystore = clientKeystore; + } + + public String getClientKeystore() + { + return clientKeystore; + } + + public void setClientKeystorePassword(String clientKeystorePassword) + { + this.clientKeystorePassword = clientKeystorePassword; + } + + public String getClientKeystorePassword() + { + return clientKeystorePassword; + } + + public void setClientKeyPassword(String clientKeyPassword) + { + this.clientKeyPassword = clientKeyPassword; + } + + public String getClientKeyPassword() + { + return clientKeyPassword; + } + + public void setRealmKey(String realmKey) + { + this.realmKey = realmKey; + } + + public String getRealmKey() + { + return realmKey; + } + + public void setPublicKeyCacheTtl(int publicKeyCacheTtl) + { + this.publicKeyCacheTtl = publicKeyCacheTtl; + } + + public int getPublicKeyCacheTtl() + { + return publicKeyCacheTtl; + } + + public void setPublicClient(boolean publicClient) + { + this.publicClient = publicClient; + } + + public boolean isPublicClient() + { + return publicClient; + } + + public String getPrincipalAttribute() + { + return principalAttribute; + } + + public void setPrincipalAttribute(String principalAttribute) + { + this.principalAttribute = principalAttribute; + } + + public boolean isClientIdValidationDisabled() + { + return clientIdValidationDisabled; + } + + public void setClientIdValidationDisabled(boolean clientIdValidationDisabled) + { + this.clientIdValidationDisabled = clientIdValidationDisabled; + } + + public String getAdminConsoleRedirectPath() + { + return adminConsoleRedirectPath; + } + + public void setAdminConsoleRedirectPath(String adminConsoleRedirectPath) + { + this.adminConsoleRedirectPath = adminConsoleRedirectPath; + } + + public Set getSignatureAlgorithms() + { + return Stream.of(signatureAlgorithms.split(",")) + .map(String::trim) + .map(SignatureAlgorithm::from) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + } + + public void setSignatureAlgorithms(String signatureAlgorithms) + { + this.signatureAlgorithms = signatureAlgorithms; + } + + public String getIssuerAttribute() + { + return issuerAttribute; + } + + public void setIssuerAttribute(String issuerAttribute) + { + this.issuerAttribute = issuerAttribute; + } + + public Set getAdminConsoleScopes() + { + return Stream.of(adminConsoleScopes.split(",")) + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } + + public void setAdminConsoleScopes(String adminConsoleScopes) + { + this.adminConsoleScopes = adminConsoleScopes; + } + + public Set getPasswordGrantScopes() + { + return Stream.of(passwordGrantScopes.split(",")) + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } + + public void setPasswordGrantScopes(String passwordGrantScopes) + { + this.passwordGrantScopes = passwordGrantScopes; + } + + public void setFirstNameAttribute(String firstNameAttribute) + { + this.firstNameAttribute = firstNameAttribute; + } + + public void setLastNameAttribute(String lastNameAttribute) + { + this.lastNameAttribute = lastNameAttribute; + } + + public void setEmailAttribute(String emailAttribute) + { + this.emailAttribute = emailAttribute; + } + + public void setJwtClockSkewMs(long jwtClockSkewMs) + { + this.jwtClockSkewMs = jwtClockSkewMs; + } + + public String getFirstNameAttribute() + { + return firstNameAttribute; + } + + public String getLastNameAttribute() + { + return lastNameAttribute; + } + + public String getEmailAttribute() + { + return emailAttribute; + } + + public long getJwtClockSkewMs() + { + return jwtClockSkewMs; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java index cf764b6d11..3054869dbe 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java @@ -1,249 +1,265 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2025 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import static java.util.Objects.nonNull; -import static java.util.Objects.requireNonNull; - -import java.time.Instant; -import java.util.Objects; -import java.util.Optional; - -import org.springframework.security.oauth2.client.registration.ClientRegistration; - -/** - * Allows to interact with the Identity Service - */ -public interface IdentityServiceFacade -{ - /** - * Returns {@link AccessToken} based authorization for provided {@link AuthorizationGrant}. - * @param grant the OAuth2 grant provided by the Resource Owner. - * @return {@link AccessTokenAuthorization} containing access token and optional refresh token. - * @throws {@link AuthorizationException} when provided grant cannot be exchanged for the access token. - */ - AccessTokenAuthorization authorize(AuthorizationGrant grant) throws AuthorizationException; - - /** - * Decodes the access token into the {@link DecodedAccessToken} which contains claims connected with a given token. - * @param token {@link String} with encoded access token value. - * @return {@link DecodedAccessToken} containing decoded claims. - * @throws {@link TokenDecodingException} when token decoding failed. - */ - DecodedAccessToken decodeToken(String token) throws TokenDecodingException; - - /** - * Gets claims about the authenticated user, - * such as name and email address, via the UserInfo endpoint of the OpenID provider. - * @param token {@link String} with encoded access token value. - * @param principalAttribute {@link String} the attribute name used to access the user's name from the user info response. - * @return {@link OIDCUserInfo} containing user claims. - */ - Optional getUserInfo(String token, String principalAttribute); - - /** - * Gets a client registration - */ - ClientRegistration getClientRegistration(); - - class IdentityServiceFacadeException extends RuntimeException - { - public IdentityServiceFacadeException(String message) - { - super(message); - } - - IdentityServiceFacadeException(String message, Throwable cause) - { - super(message, cause); - } - } - - class AuthorizationException extends IdentityServiceFacadeException - { - AuthorizationException(String message) - { - super(message); - } - - AuthorizationException(String message, Throwable cause) - { - super(message, cause); - } - } - - class UserInfoException extends IdentityServiceFacadeException - { - - UserInfoException(String message) - { - super(message); - } - - UserInfoException(String message, Throwable cause) - { - super(message, cause); - } - } - - class TokenDecodingException extends IdentityServiceFacadeException - { - TokenDecodingException(String message) - { - super(message); - } - - TokenDecodingException(String message, Throwable cause) - { - super(message, cause); - } - } - - /** - * Represents access token authorization with optional refresh token. - */ - interface AccessTokenAuthorization - { - /** - * Required {@link AccessToken} - * @return {@link AccessToken} - */ - AccessToken getAccessToken(); - - /** - * Optional refresh token. - * @return Refresh token or {@code null} - */ - String getRefreshTokenValue(); - } - - interface AccessToken { - String getTokenValue(); - Instant getExpiresAt(); - } - - interface DecodedAccessToken extends AccessToken - { - Object getClaim(String claim); - } - - class AuthorizationGrant { - private final String username; - private final String password; - private final String refreshToken; - private final String authorizationCode; - private final String redirectUri; - - private AuthorizationGrant(String username, String password, String refreshToken, String authorizationCode, String redirectUri) - { - this.username = username; - this.password = password; - this.refreshToken = refreshToken; - this.authorizationCode = authorizationCode; - this.redirectUri = redirectUri; - } - - public static AuthorizationGrant password(String username, String password) - { - return new AuthorizationGrant(requireNonNull(username), requireNonNull(password), null, null, null); - } - - public static AuthorizationGrant refreshToken(String refreshToken) - { - return new AuthorizationGrant(null, null, requireNonNull(refreshToken), null, null); - } - - public static AuthorizationGrant authorizationCode(String authorizationCode, String redirectUri) - { - return new AuthorizationGrant(null, null, null, requireNonNull(authorizationCode), requireNonNull(redirectUri)); - } - - boolean isPassword() - { - return nonNull(username); - } - - boolean isRefreshToken() - { - return nonNull(refreshToken); - } - - boolean isAuthorizationCode() - { - return nonNull(authorizationCode); - } - - String getUsername() - { - return username; - } - - String getPassword() - { - return password; - } - - String getRefreshToken() - { - return refreshToken; - } - - String getAuthorizationCode() - { - return authorizationCode; - } - - String getRedirectUri() - { - return redirectUri; - } - - @Override - public boolean equals(Object o) - { - if (this == o) - { - return true; - } - if (o == null || getClass() != o.getClass()) - { - return false; - } - AuthorizationGrant that = (AuthorizationGrant) o; - return Objects.equals(username, that.username) && - Objects.equals(password, that.password) && - Objects.equals(refreshToken, that.refreshToken) && - Objects.equals(authorizationCode, that.authorizationCode) && - Objects.equals(redirectUri, that.redirectUri); - } - - @Override - public int hashCode() - { - return Objects.hash(username, password, refreshToken, authorizationCode, redirectUri); - } - } -} \ No newline at end of file +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static java.util.Objects.nonNull; +import static java.util.Objects.requireNonNull; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; + +/** + * Allows to interact with the Identity Service + */ +public interface IdentityServiceFacade +{ + /** + * Returns {@link AccessToken} based authorization for provided {@link AuthorizationGrant}. + * + * @param grant + * the OAuth2 grant provided by the Resource Owner. + * @return {@link AccessTokenAuthorization} containing access token and optional refresh token. + * @throws {@link + * AuthorizationException} when provided grant cannot be exchanged for the access token. + */ + AccessTokenAuthorization authorize(AuthorizationGrant grant) throws AuthorizationException; + + /** + * Decodes the access token into the {@link DecodedAccessToken} which contains claims connected with a given token. + * + * @param token + * {@link String} with encoded access token value. + * @return {@link DecodedAccessToken} containing decoded claims. + * @throws {@link + * TokenDecodingException} when token decoding failed. + */ + DecodedAccessToken decodeToken(String token) throws TokenDecodingException; + + /** + * Gets claims about the authenticated user, such as name and email address, via the UserInfo endpoint of the OpenID provider. + * + * @param token + * {@link String} with encoded access token value. + * @param userInfoAttrMapping + * {@link UserInfoAttrMapping} containing the mapping of claims. + * @return {@link DecodedTokenUser} containing user claims or {@link Optional#empty()} if the token does not contain a username claim. + */ + Optional getUserInfo(String token, UserInfoAttrMapping userInfoAttrMapping); + + /** + * Gets a client registration + */ + ClientRegistration getClientRegistration(); + + class IdentityServiceFacadeException extends RuntimeException + { + public IdentityServiceFacadeException(String message) + { + super(message); + } + + IdentityServiceFacadeException(String message, Throwable cause) + { + super(message, cause); + } + } + + class AuthorizationException extends IdentityServiceFacadeException + { + AuthorizationException(String message) + { + super(message); + } + + AuthorizationException(String message, Throwable cause) + { + super(message, cause); + } + } + + class UserInfoException extends IdentityServiceFacadeException + { + + UserInfoException(String message) + { + super(message); + } + + UserInfoException(String message, Throwable cause) + { + super(message, cause); + } + } + + class TokenDecodingException extends IdentityServiceFacadeException + { + TokenDecodingException(String message) + { + super(message); + } + + TokenDecodingException(String message, Throwable cause) + { + super(message, cause); + } + } + + /** + * Represents access token authorization with optional refresh token. + */ + interface AccessTokenAuthorization + { + /** + * Required {@link AccessToken} + * + * @return {@link AccessToken} + */ + AccessToken getAccessToken(); + + /** + * Optional refresh token. + * + * @return Refresh token or {@code null} + */ + String getRefreshTokenValue(); + } + + interface AccessToken + { + String getTokenValue(); + + Instant getExpiresAt(); + } + + interface DecodedAccessToken extends AccessToken + { + Object getClaim(String claim); + } + + class AuthorizationGrant + { + private final String username; + private final String password; + private final String refreshToken; + private final String authorizationCode; + private final String redirectUri; + + private AuthorizationGrant(String username, String password, String refreshToken, String authorizationCode, String redirectUri) + { + this.username = username; + this.password = password; + this.refreshToken = refreshToken; + this.authorizationCode = authorizationCode; + this.redirectUri = redirectUri; + } + + public static AuthorizationGrant password(String username, String password) + { + return new AuthorizationGrant(requireNonNull(username), requireNonNull(password), null, null, null); + } + + public static AuthorizationGrant refreshToken(String refreshToken) + { + return new AuthorizationGrant(null, null, requireNonNull(refreshToken), null, null); + } + + public static AuthorizationGrant authorizationCode(String authorizationCode, String redirectUri) + { + return new AuthorizationGrant(null, null, null, requireNonNull(authorizationCode), requireNonNull(redirectUri)); + } + + boolean isPassword() + { + return nonNull(username); + } + + boolean isRefreshToken() + { + return nonNull(refreshToken); + } + + boolean isAuthorizationCode() + { + return nonNull(authorizationCode); + } + + String getUsername() + { + return username; + } + + String getPassword() + { + return password; + } + + String getRefreshToken() + { + return refreshToken; + } + + String getAuthorizationCode() + { + return authorizationCode; + } + + String getRedirectUri() + { + return redirectUri; + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } + AuthorizationGrant that = (AuthorizationGrant) o; + return Objects.equals(username, that.username) && + Objects.equals(password, that.password) && + Objects.equals(refreshToken, that.refreshToken) && + Objects.equals(authorizationCode, that.authorizationCode) && + Objects.equals(redirectUri, that.redirectUri); + } + + @Override + public int hashCode() + { + return Objects.hash(username, password, refreshToken, authorizationCode, redirectUri); + } + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java index a3465ec3c9..bcafe791d4 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java @@ -1,818 +1,832 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2025 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import static java.util.Objects.requireNonNull; -import static java.util.Optional.ofNullable; -import static java.util.function.Predicate.not; - -import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.AUDIENCE; -import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.SCOPES_SUPPORTED; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.security.interfaces.RSAPublicKey; -import java.time.Duration; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import com.nimbusds.jose.JOSEObjectType; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.source.DefaultJWKSetCache; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.RemoteJWKSet; -import com.nimbusds.jose.proc.BadJOSEException; -import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; -import com.nimbusds.jose.proc.JWSVerificationKeySelector; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jose.util.ResourceRetriever; -import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; -import com.nimbusds.oauth2.sdk.Scope; -import com.nimbusds.oauth2.sdk.id.Identifier; -import com.nimbusds.oauth2.sdk.id.Issuer; -import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; - -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.hc.client5.http.classic.HttpClient; -import org.apache.hc.client5.http.config.ConnectionConfig; -import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; -import org.apache.hc.client5.http.ssl.TrustAllStrategy; -import org.apache.hc.core5.ssl.SSLContextBuilder; -import org.apache.hc.core5.ssl.SSLContexts; -import org.springframework.beans.factory.FactoryBean; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.ClientHttpRequest; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.converter.FormHttpMessageConverter; -import org.springframework.security.converter.RsaKeyConverters; -import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; -import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistration.Builder; -import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.ClientAuthenticationMethod; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.OAuth2ErrorCodes; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; -import org.springframework.security.oauth2.core.converter.ClaimTypeConverter; -import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.jwt.JwtClaimValidator; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtTimestampValidator; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.web.client.RestOperations; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Creates an instance of {@link IdentityServiceFacade}.
- * This factory can return a null if it is disabled. - */ -public class IdentityServiceFacadeFactoryBean implements FactoryBean -{ - private static final Log LOGGER = LogFactory.getLog(IdentityServiceFacadeFactoryBean.class); - - private static final JOSEObjectType AT_JWT = new JOSEObjectType("at+jwt"); - - private boolean enabled; - private SpringBasedIdentityServiceFacadeFactory factory; - - public void setEnabled(boolean enabled) - { - this.enabled = enabled; - } - - public void setIdentityServiceConfig(IdentityServiceConfig identityServiceConfig) - { - factory = new SpringBasedIdentityServiceFacadeFactory( - new HttpClientProvider(identityServiceConfig)::createHttpClient, - new ClientRegistrationProvider(identityServiceConfig)::createClientRegistration, - new JwtDecoderProvider(identityServiceConfig)::createJwtDecoder - ); - } - - @Override - public IdentityServiceFacade getObject() throws Exception - { - // The creation of the client can be disabled for testing or when the username/password authentication is not required, - // for instance when Identity Service is configured for 'bearer only' authentication or Direct Access Grants are disabled. - if (!enabled) - { - return null; - } - - return new LazyInstantiatingIdentityServiceFacade(factory::createIdentityServiceFacade); - } - - @Override - public Class getObjectType() - { - return IdentityServiceFacade.class; - } - - @Override - public boolean isSingleton() - { - return true; - } - - private static IdentityServiceFacadeException authorizationServerCantBeUsedException(RuntimeException cause) - { - return new IdentityServiceFacadeException("Unable to use the Authorization Server.", cause); - } - - // The target facade is created lazily to improve resiliency on Identity Service - // (Keycloak/Authorization Server) failures when Spring Context is starting up. - static class LazyInstantiatingIdentityServiceFacade implements IdentityServiceFacade - { - private final AtomicReference targetFacade = new AtomicReference<>(); - private final Supplier targetFacadeCreator; - - LazyInstantiatingIdentityServiceFacade(Supplier targetFacadeCreator) - { - this.targetFacadeCreator = requireNonNull(targetFacadeCreator); - } - - @Override - public AccessTokenAuthorization authorize(AuthorizationGrant grant) throws AuthorizationException - { - return getTargetFacade().authorize(grant); - } - - @Override - public DecodedAccessToken decodeToken(String token) throws TokenDecodingException - { - return getTargetFacade().decodeToken(token); - } - - @Override - public Optional getUserInfo(String token, String principalAttribute) - { - return getTargetFacade().getUserInfo(token, principalAttribute); - } - - @Override - public ClientRegistration getClientRegistration() - { - return getTargetFacade().getClientRegistration(); - } - - private IdentityServiceFacade getTargetFacade() - { - return ofNullable(targetFacade.get()) - .orElseGet(() -> targetFacade.updateAndGet(prev -> - ofNullable(prev).orElseGet(this::createTargetFacade))); - } - - private IdentityServiceFacade createTargetFacade() - { - try - { - return targetFacadeCreator.get(); - } - catch (IdentityServiceFacadeException e) - { - throw e; - } - catch (RuntimeException e) - { - LOGGER.warn("Failed to instantiate IdentityServiceFacade.", e); - throw authorizationServerCantBeUsedException(e); - } - } - } - - private static class SpringBasedIdentityServiceFacadeFactory - { - private final Supplier httpClientProvider; - private final Function clientRegistrationProvider; - private final BiFunction jwtDecoderProvider; - - SpringBasedIdentityServiceFacadeFactory( - Supplier httpClientProvider, - Function clientRegistrationProvider, - BiFunction jwtDecoderProvider) - { - this.httpClientProvider = requireNonNull(httpClientProvider); - this.clientRegistrationProvider = requireNonNull(clientRegistrationProvider); - this.jwtDecoderProvider = requireNonNull(jwtDecoderProvider); - } - - private IdentityServiceFacade createIdentityServiceFacade() - { - //Here we preserve the behaviour of previously used Keycloak Adapter - // * Client is authenticating itself using basic auth - // * Resource Owner Password Credentials Flow is used to authenticate Resource Owner - - final ClientHttpRequestFactory httpRequestFactory = new CustomClientHttpRequestFactory( - httpClientProvider.get()); - final RestTemplate restTemplate = new RestTemplate(httpRequestFactory); - final ClientRegistration clientRegistration = clientRegistrationProvider.apply(restTemplate); - final JwtDecoder jwtDecoder = jwtDecoderProvider.apply(restTemplate, - clientRegistration.getProviderDetails()); - - return new SpringBasedIdentityServiceFacade(createOAuth2RestTemplate(httpRequestFactory), - clientRegistration, jwtDecoder); - } - - private RestTemplate createOAuth2RestTemplate(ClientHttpRequestFactory requestFactory) - { - final RestTemplate restTemplate = new RestTemplate( - Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())); - restTemplate.setRequestFactory(requestFactory); - restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); - - return restTemplate; - } - } - - private static class HttpClientProvider - { - private final IdentityServiceConfig config; - - private HttpClientProvider(IdentityServiceConfig config) - { - this.config = requireNonNull(config); - } - - private HttpClient createHttpClient() - { - try - { - HttpClientBuilder clientBuilder = HttpClients.custom(); - applyConfiguration(clientBuilder); - return clientBuilder.build(); - } - catch (Exception e) - { - throw new IllegalStateException("Failed to create ClientHttpRequestFactory. " + e.getMessage(), e); - } - } - - private void applyConfiguration(HttpClientBuilder builder) throws Exception - { - final PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); - - applyConnectionConfiguration(connectionManagerBuilder); - applySSLConfiguration(connectionManagerBuilder); - - builder.setConnectionManager(connectionManagerBuilder.build()); - } - - private void applyConnectionConfiguration(PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder) - { - final ConnectionConfig connectionConfig = ConnectionConfig.custom() - .setConnectTimeout(config.getClientConnectionTimeout(), TimeUnit.MILLISECONDS) - .setSocketTimeout(config.getClientSocketTimeout(), TimeUnit.MILLISECONDS) - .build(); - - connectionManagerBuilder.setMaxConnTotal(config.getConnectionPoolSize()); - connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig); - } - - private void applySSLConfiguration(PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder) - throws Exception - { - SSLContextBuilder sslContextBuilder = null; - if (config.isDisableTrustManager()) - { - sslContextBuilder = SSLContexts.custom() - .loadTrustMaterial(TrustAllStrategy.INSTANCE); - - } - else if (isDefined(config.getTruststore())) - { - final char[] truststorePassword = asCharArray(config.getTruststorePassword(), null); - sslContextBuilder = SSLContexts.custom() - .loadTrustMaterial(new File(config.getTruststore()), truststorePassword); - } - - if (isDefined(config.getClientKeystore())) - { - if (sslContextBuilder == null) - { - sslContextBuilder = SSLContexts.custom(); - } - final char[] keystorePassword = asCharArray(config.getClientKeystorePassword(), null); - final char[] keyPassword = asCharArray(config.getClientKeyPassword(), keystorePassword); - sslContextBuilder.loadKeyMaterial(new File(config.getClientKeystore()), keystorePassword, keyPassword); - } - - final SSLConnectionSocketFactoryBuilder sslConnectionSocketFactoryBuilder = SSLConnectionSocketFactoryBuilder.create(); - - if (sslContextBuilder != null) - { - sslConnectionSocketFactoryBuilder.setSslContext(sslContextBuilder.build()); - } - - if (config.isDisableTrustManager() || config.isAllowAnyHostname()) - { - sslConnectionSocketFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE); - } - final SSLConnectionSocketFactory sslConnectionSocketFactory = sslConnectionSocketFactoryBuilder.build(); - connectionManagerBuilder.setSSLSocketFactory(sslConnectionSocketFactory); - } - - private char[] asCharArray(String value, char... nullValue) - { - return ofNullable(value) - .filter(not(String::isBlank)) - .map(String::toCharArray) - .orElse(nullValue); - } - } - - static class ClientRegistrationProvider - { - private final IdentityServiceConfig config; - - private static final Set SCOPES = Set.of("openid", "profile", "email"); - - ClientRegistrationProvider(IdentityServiceConfig config) - { - this.config = requireNonNull(config); - } - - public ClientRegistration createClientRegistration(final RestOperations rest) - { - return possibleMetadataURIs() - .stream() - .map(u -> extractMetadata(rest, u)) - .filter(Optional::isPresent) - .map(Optional::get) - .findFirst() - .map(this::validateDiscoveryDocument) - .map(this::createBuilder) - .map(this::configureClientAuthentication) - .map(Builder::build) - .orElseThrow(() -> new IllegalStateException("Failed to create ClientRegistration.")); - } - - private OIDCProviderMetadata validateDiscoveryDocument(OIDCProviderMetadata metadata) - { - validateOIDCEndpoint(metadata.getTokenEndpointURI(), "Token"); - validateOIDCEndpoint(metadata.getAuthorizationEndpointURI(), "Authorization"); - validateOIDCEndpoint(metadata.getUserInfoEndpointURI(), "User Info"); - validateOIDCEndpoint(metadata.getJWKSetURI(), "JWK Set"); - - if (metadata.getIssuer() != null) - { - try - { - URI metadataIssuerURI = new URI(metadata.getIssuer().getValue()); - validateOIDCEndpoint(metadataIssuerURI, "Issuer"); - if (StringUtils.isNotBlank(config.getIssuerUrl()) && - !metadataIssuerURI.equals(URI.create(config.getIssuerUrl()))) - { - throw new IdentityServiceException("Failed to create ClientRegistration. " - + "The Issuer value from the OIDC Discovery Endpoint does not align with the provided Issuer. Expected `%s` but found `%s`" - .formatted(config.getIssuerUrl(), metadata.getIssuer().getValue())); - } - } - catch (URISyntaxException e) - { - throw new IdentityServiceException("The provided Issuer value could not be parsed as a URI reference.", e); - } - } - else - { - throw new IdentityServiceException("The Issuer retrieved from the OIDC Discovery Endpoint cannot be null."); - } - - return metadata; - } - - private void validateOIDCEndpoint(URI value, String endpointName) - { - if (value == null || value.toASCIIString().isBlank()) - { - throw new IdentityServiceException("The `%s` Endpoint retrieved from the OIDC Discovery Endpoint cannot be empty.".formatted(endpointName)); - } - } - - private ClientRegistration.Builder createBuilder(OIDCProviderMetadata metadata) - { - final String authUri = Optional.of(metadata) - .map(OIDCProviderMetadata::getAuthorizationEndpointURI) - .map(URI::toASCIIString) - .orElse(null); - - final String issuerUri = Optional.of(metadata) - .map(OIDCProviderMetadata::getIssuer) - .map(Issuer::getValue) - .orElseGet(() -> (StringUtils.isNotBlank(config.getRealm()) && StringUtils.isBlank(config.getIssuerUrl())) ? - config.getAuthServerUrl() : - config.getIssuerUrl()); - - return ClientRegistration - .withRegistrationId("ids") - .authorizationUri(authUri) - .tokenUri(metadata.getTokenEndpointURI().toASCIIString()) - .jwkSetUri(metadata.getJWKSetURI().toASCIIString()) - .issuerUri(issuerUri) - .userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()) - .scope(getSupportedScopes(metadata.getScopes())) - .providerConfigurationMetadata(createMetadata(metadata)) - .authorizationGrantType(AuthorizationGrantType.PASSWORD); - } - - private Map createMetadata(OIDCProviderMetadata metadata) - { - Map configurationMetadata = new LinkedHashMap<>(); - if(metadata.getScopes() != null) - { - configurationMetadata.put(SCOPES_SUPPORTED.getValue(), metadata.getScopes()); - } - if(StringUtils.isNotBlank(config.getAudience())) - { - configurationMetadata.put(AUDIENCE.getValue(), config.getAudience()); - } - return configurationMetadata; - } - - private Builder configureClientAuthentication(Builder builder) - { - builder.clientId(config.getResource()); - if (config.isPublicClient()) - { - return builder.clientSecret(null) - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST); - } - return builder.clientSecret(config.getClientSecret()) - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); - } - - private Set getSupportedScopes(Scope scopes) - { - return scopes.stream().filter(scope -> SCOPES.contains(scope.getValue())) - .map(Identifier::getValue) - .collect(Collectors.toSet()); - } - - private Optional extractMetadata(RestOperations rest, URI metadataUri) - { - final String response; - try - { - final ResponseEntity r = rest.exchange(RequestEntity.get(metadataUri).build(), String.class); - if (r.getStatusCode() != HttpStatus.OK || !r.hasBody()) - { - LOGGER.warn("Unexpected response from " + metadataUri + ". Status code: " + r.getStatusCode() - + ", has body: " + r.hasBody() + "."); - return Optional.empty(); - } - response = r.getBody(); - } - catch (Exception e) - { - LOGGER.warn("Failed to get response from " + metadataUri + ". " + e.getMessage(), e); - return Optional.empty(); - } - try - { - return Optional.of(OIDCProviderMetadata.parse(response)); - } - catch (Exception e) - { - LOGGER.warn("Failed to parse metadata. " + e.getMessage(), e); - return Optional.empty(); - } - } - - private Collection possibleMetadataURIs() - { - if (StringUtils.isBlank(config.getAuthServerUrl()) && StringUtils.isBlank(config.getIssuerUrl())) - { - throw new IdentityServiceException( - "Failed to create ClientRegistration. The values of issuer url and auth server url cannot both be empty."); - } - - String baseUrl = StringUtils.isNotBlank(config.getAuthServerUrl()) ? - config.getAuthServerUrl() : - config.getIssuerUrl(); - - return List.of(UriComponentsBuilder.fromUriString(baseUrl) - .pathSegment(".well-known", "openid-configuration") - .build().toUri()); - } - } - - static class JwtDecoderProvider - { - private static final SignatureAlgorithm DEFAULT_SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256; - private final IdentityServiceConfig config; - private final Set signatureAlgorithms; - - JwtDecoderProvider(IdentityServiceConfig config) - { - this.config = requireNonNull(config); - this.signatureAlgorithms = ofNullable(config.getSignatureAlgorithms()) - .filter(not(Set::isEmpty)) - .orElseGet(() -> { - LOGGER.warn("Unable to find any valid signature algorithms in the configuration. " - + "Using the default signature algorithm: " + DEFAULT_SIGNATURE_ALGORITHM.getName() + "."); - return Set.of(DEFAULT_SIGNATURE_ALGORITHM); - }); - } - - public JwtDecoder createJwtDecoder(RestOperations rest, ProviderDetails providerDetails) - { - try - { - final NimbusJwtDecoder decoder = buildJwtDecoder(rest, providerDetails); - - decoder.setJwtValidator(createJwtTokenValidator(providerDetails)); - decoder.setClaimSetConverter( - new ClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters())); - - return decoder; - } - catch (RuntimeException e) - { - LOGGER.warn("Failed to create JwtDecoder.", e); - throw authorizationServerCantBeUsedException(e); - } - } - - private NimbusJwtDecoder buildJwtDecoder(RestOperations rest, ProviderDetails providerDetails) - { - if (isDefined(config.getRealmKey())) - { - final RSAPublicKey publicKey = parsePublicKey(config.getRealmKey()); - return NimbusJwtDecoder.withPublicKey(publicKey) - .signatureAlgorithm(DEFAULT_SIGNATURE_ALGORITHM) - .build(); - } - final String jwkSetUri = requireValidJwkSetUri(providerDetails); - final NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder decoderBuilder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri); - signatureAlgorithms.forEach(decoderBuilder::jwsAlgorithm); - return decoderBuilder - .restOperations(rest) - .jwtProcessorCustomizer(this::reconfigureJWKSCache) - .build(); - } - - private void reconfigureJWKSCache(ConfigurableJWTProcessor jwtProcessor) - { - final Optional> jwkSource = ofNullable(jwtProcessor) - .map(ConfigurableJWTProcessor::getJWSKeySelector) - .filter(JWSVerificationKeySelector.class::isInstance) - .map(o -> (JWSVerificationKeySelector) o) - .map(JWSVerificationKeySelector::getJWKSource) - .filter(RemoteJWKSet.class::isInstance).map(o -> (RemoteJWKSet) o); - if (jwkSource.isEmpty()) - { - LOGGER.warn("Not able to reconfigure the JWK Cache. Unexpected JWKSource."); - return; - } - - final Optional jwkSetUrl = jwkSource.map(RemoteJWKSet::getJWKSetURL); - if (jwkSetUrl.isEmpty()) - { - LOGGER.warn("Not able to reconfigure the JWK Cache. Unknown JWKSetURL."); - return; - } - - final Optional resourceRetriever = jwkSource.map(RemoteJWKSet::getResourceRetriever); - if (resourceRetriever.isEmpty()) - { - LOGGER.warn("Not able to reconfigure the JWK Cache. Unknown ResourceRetriever."); - return; - } - - final DefaultJWKSetCache cache = new DefaultJWKSetCache(config.getPublicKeyCacheTtl(), -1, - TimeUnit.SECONDS); - final JWKSource cachingJWKSource = new RemoteJWKSet<>(jwkSetUrl.get(), - resourceRetriever.get(), cache); - - jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>( - signatureAlgorithms.stream() - .map(signatureAlgorithm -> JWSAlgorithm.parse(signatureAlgorithm.getName())) - .collect(Collectors.toSet()), - cachingJWKSource)); - jwtProcessor.setJWSTypeVerifier(new CustomJOSEObjectTypeVerifier(JOSEObjectType.JWT, AT_JWT)); - } - - private OAuth2TokenValidator createJwtTokenValidator(ProviderDetails providerDetails) - { - List> validators = new ArrayList<>(); - validators.add(new JwtTimestampValidator(Duration.of(0, ChronoUnit.MILLIS))); - validators.add(new JwtIssuerValidator(providerDetails.getIssuerUri())); - if (!config.isClientIdValidationDisabled()) - { - validators.add(new JwtClaimValidator("azp", config.getResource()::equals)); - } - if (StringUtils.isNotBlank(config.getAudience())) - { - validators.add(new JwtAudienceValidator(config.getAudience())); - } - return new DelegatingOAuth2TokenValidator<>(validators); - } - - private RSAPublicKey parsePublicKey(String pem) - { - try - { - return tryToParsePublicKey(pem); - } - catch (Exception e) - { - if (isPemFormatException(e)) - { - //For backward compatibility with Keycloak adapter - return tryToParsePublicKey("-----BEGIN PUBLIC KEY-----\n" + pem + "\n-----END PUBLIC KEY-----"); - } - throw e; - } - } - - private RSAPublicKey tryToParsePublicKey(String pem) - { - final InputStream pemStream = new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)); - return RsaKeyConverters.x509().convert(pemStream); - } - - private boolean isPemFormatException(Exception e) - { - return e.getMessage() != null && e.getMessage().contains("-----BEGIN PUBLIC KEY-----"); - } - - private String requireValidJwkSetUri(ProviderDetails providerDetails) - { - final String uri = providerDetails.getJwkSetUri(); - if (!isDefined(uri)) - { - OAuth2Error oauth2Error = new OAuth2Error("missing_signature_verifier", - "Failed to find a Signature Verifier for: '" - + providerDetails.getIssuerUri() - + "'. Check to ensure you have configured the JwkSet URI.", - null); - throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); - } - return uri; - } - } - - static class JwtIssuerValidator implements OAuth2TokenValidator - { - private final String requiredIssuer; - - public JwtIssuerValidator(String issuer) - { - this.requiredIssuer = requireNonNull(issuer, "issuer cannot be null"); - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt token) - { - requireNonNull(token, "token cannot be null"); - final Object issuer = token.getClaim(JwtClaimNames.ISS); - if (issuer != null && requiredIssuer.equals(issuer.toString())) - { - return OAuth2TokenValidatorResult.success(); - } - - final OAuth2Error error = new OAuth2Error( - OAuth2ErrorCodes.INVALID_TOKEN, - "The iss claim is not valid. Expected `%s` but got `%s`.".formatted(requiredIssuer, issuer), - "https://tools.ietf.org/html/rfc6750#section-3.1"); - return OAuth2TokenValidatorResult.failure(error); - } - - } - - static class JwtAudienceValidator implements OAuth2TokenValidator - { - private final String configuredAudience; - - public JwtAudienceValidator(String configuredAudience) - { - this.configuredAudience = configuredAudience; - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt token) - { - requireNonNull(token, "token cannot be null"); - final Object audience = token.getClaim(JwtClaimNames.AUD); - if (audience != null) - { - if(audience instanceof List && ((List) audience).contains(configuredAudience)) - { - return OAuth2TokenValidatorResult.success(); - } - if(audience instanceof String && audience.equals(configuredAudience)) - { - return OAuth2TokenValidatorResult.success(); - } - } - - final OAuth2Error error = new OAuth2Error( - OAuth2ErrorCodes.INVALID_TOKEN, - "The aud claim is not valid. Expected configured audience `%s` not found.".formatted(configuredAudience), - "https://tools.ietf.org/html/rfc6750#section-3.1"); - return OAuth2TokenValidatorResult.failure(error); - } - } - - static class CustomClientHttpRequestFactory extends HttpComponentsClientHttpRequestFactory - { - CustomClientHttpRequestFactory(HttpClient httpClient) - { - super(httpClient); - } - - @Override - public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException - { - /* - * This is to avoid the Brotli content encoding that is not well-supported by the combination of - * the Apache Http Client and the Spring RestTemplate - */ - ClientHttpRequest request = super.createRequest(uri, httpMethod); - request.getHeaders() - .add("Accept-Encoding", "gzip, deflate"); - return request; - } - } - - static class CustomJOSEObjectTypeVerifier extends DefaultJOSEObjectTypeVerifier - { - public CustomJOSEObjectTypeVerifier(JOSEObjectType... allowedTypes) - { - super(Set.of(allowedTypes)); - } - - @Override - public void verify(JOSEObjectType type, SecurityContext context) throws BadJOSEException - { - super.verify(type, context); - } - } - - private static boolean isDefined(String value) - { - return value != null && !value.isBlank(); - } -} \ No newline at end of file +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; +import static java.util.function.Predicate.not; + +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.AUDIENCE; +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.SCOPES_SUPPORTED; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.DefaultJWKSetCache; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.ResourceRetriever; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.id.Identifier; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.client5.http.ssl.TrustAllStrategy; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.apache.hc.core5.ssl.SSLContexts; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.converter.RsaKeyConverters; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.Builder; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.core.converter.ClaimTypeConverter; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; + +/** + * Creates an instance of {@link IdentityServiceFacade}.
+ * This factory can return a null if it is disabled. + */ +public class IdentityServiceFacadeFactoryBean implements FactoryBean +{ + private static final Log LOGGER = LogFactory.getLog(IdentityServiceFacadeFactoryBean.class); + + private static final JOSEObjectType AT_JWT = new JOSEObjectType("at+jwt"); + private static final String DEFAULT_ISSUER_ATTR = "issuer"; + + private boolean enabled; + private SpringBasedIdentityServiceFacadeFactory factory; + + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + public void setIdentityServiceConfig(IdentityServiceConfig identityServiceConfig) + { + factory = new SpringBasedIdentityServiceFacadeFactory( + new HttpClientProvider(identityServiceConfig)::createHttpClient, + new ClientRegistrationProvider(identityServiceConfig)::createClientRegistration, + new JwtDecoderProvider(identityServiceConfig)::createJwtDecoder); + } + + @Override + public IdentityServiceFacade getObject() throws Exception + { + // The creation of the client can be disabled for testing or when the username/password authentication is not required, + // for instance when Identity Service is configured for 'bearer only' authentication or Direct Access Grants are disabled. + if (!enabled) + { + return null; + } + + return new LazyInstantiatingIdentityServiceFacade(factory::createIdentityServiceFacade); + } + + @Override + public Class getObjectType() + { + return IdentityServiceFacade.class; + } + + @Override + public boolean isSingleton() + { + return true; + } + + private static IdentityServiceFacadeException authorizationServerCantBeUsedException(RuntimeException cause) + { + return new IdentityServiceFacadeException("Unable to use the Authorization Server.", cause); + } + + // The target facade is created lazily to improve resiliency on Identity Service + // (Keycloak/Authorization Server) failures when Spring Context is starting up. + static class LazyInstantiatingIdentityServiceFacade implements IdentityServiceFacade + { + private final AtomicReference targetFacade = new AtomicReference<>(); + private final Supplier targetFacadeCreator; + + LazyInstantiatingIdentityServiceFacade(Supplier targetFacadeCreator) + { + this.targetFacadeCreator = requireNonNull(targetFacadeCreator); + } + + @Override + public AccessTokenAuthorization authorize(AuthorizationGrant grant) throws AuthorizationException + { + return getTargetFacade().authorize(grant); + } + + @Override + public DecodedAccessToken decodeToken(String token) throws TokenDecodingException + { + return getTargetFacade().decodeToken(token); + } + + @Override + public Optional getUserInfo(String token, UserInfoAttrMapping userInfoAttrMapping) + { + return getTargetFacade().getUserInfo(token, userInfoAttrMapping); + } + + @Override + public ClientRegistration getClientRegistration() + { + return getTargetFacade().getClientRegistration(); + } + + private IdentityServiceFacade getTargetFacade() + { + return ofNullable(targetFacade.get()) + .orElseGet(() -> targetFacade.updateAndGet(prev -> ofNullable(prev).orElseGet(this::createTargetFacade))); + } + + private IdentityServiceFacade createTargetFacade() + { + try + { + return targetFacadeCreator.get(); + } + catch (IdentityServiceFacadeException e) + { + throw e; + } + catch (RuntimeException e) + { + LOGGER.warn("Failed to instantiate IdentityServiceFacade.", e); + throw authorizationServerCantBeUsedException(e); + } + } + } + + private static class SpringBasedIdentityServiceFacadeFactory + { + private final Supplier httpClientProvider; + private final Function clientRegistrationProvider; + private final BiFunction jwtDecoderProvider; + + SpringBasedIdentityServiceFacadeFactory( + Supplier httpClientProvider, + Function clientRegistrationProvider, + BiFunction jwtDecoderProvider) + { + this.httpClientProvider = requireNonNull(httpClientProvider); + this.clientRegistrationProvider = requireNonNull(clientRegistrationProvider); + this.jwtDecoderProvider = requireNonNull(jwtDecoderProvider); + } + + private IdentityServiceFacade createIdentityServiceFacade() + { + // Here we preserve the behaviour of previously used Keycloak Adapter + // * Client is authenticating itself using basic auth + // * Resource Owner Password Credentials Flow is used to authenticate Resource Owner + + final ClientHttpRequestFactory httpRequestFactory = new CustomClientHttpRequestFactory( + httpClientProvider.get()); + final RestTemplate restTemplate = new RestTemplate(httpRequestFactory); + final ClientRegistration clientRegistration = clientRegistrationProvider.apply(restTemplate); + final JwtDecoder jwtDecoder = jwtDecoderProvider.apply(restTemplate, + clientRegistration.getProviderDetails()); + + return new SpringBasedIdentityServiceFacade(createOAuth2RestTemplate(httpRequestFactory), + clientRegistration, jwtDecoder); + } + + private RestTemplate createOAuth2RestTemplate(ClientHttpRequestFactory requestFactory) + { + final RestTemplate restTemplate = new RestTemplate( + Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter(), new MappingJackson2HttpMessageConverter())); + restTemplate.setRequestFactory(requestFactory); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + + return restTemplate; + } + } + + private static class HttpClientProvider + { + private final IdentityServiceConfig config; + + private HttpClientProvider(IdentityServiceConfig config) + { + this.config = requireNonNull(config); + } + + private HttpClient createHttpClient() + { + try + { + HttpClientBuilder clientBuilder = HttpClients.custom(); + applyConfiguration(clientBuilder); + return clientBuilder.build(); + } + catch (Exception e) + { + throw new IllegalStateException("Failed to create ClientHttpRequestFactory. " + e.getMessage(), e); + } + } + + private void applyConfiguration(HttpClientBuilder builder) throws Exception + { + final PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); + + applyConnectionConfiguration(connectionManagerBuilder); + applySSLConfiguration(connectionManagerBuilder); + + builder.setConnectionManager(connectionManagerBuilder.build()); + } + + private void applyConnectionConfiguration(PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder) + { + final ConnectionConfig connectionConfig = ConnectionConfig.custom() + .setConnectTimeout(config.getClientConnectionTimeout(), TimeUnit.MILLISECONDS) + .setSocketTimeout(config.getClientSocketTimeout(), TimeUnit.MILLISECONDS) + .build(); + + connectionManagerBuilder.setMaxConnTotal(config.getConnectionPoolSize()); + connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig); + } + + private void applySSLConfiguration(PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder) + throws Exception + { + SSLContextBuilder sslContextBuilder = null; + if (config.isDisableTrustManager()) + { + sslContextBuilder = SSLContexts.custom() + .loadTrustMaterial(TrustAllStrategy.INSTANCE); + + } + else if (isDefined(config.getTruststore())) + { + final char[] truststorePassword = asCharArray(config.getTruststorePassword(), null); + sslContextBuilder = SSLContexts.custom() + .loadTrustMaterial(new File(config.getTruststore()), truststorePassword); + } + + if (isDefined(config.getClientKeystore())) + { + if (sslContextBuilder == null) + { + sslContextBuilder = SSLContexts.custom(); + } + final char[] keystorePassword = asCharArray(config.getClientKeystorePassword(), null); + final char[] keyPassword = asCharArray(config.getClientKeyPassword(), keystorePassword); + sslContextBuilder.loadKeyMaterial(new File(config.getClientKeystore()), keystorePassword, keyPassword); + } + + final SSLConnectionSocketFactoryBuilder sslConnectionSocketFactoryBuilder = SSLConnectionSocketFactoryBuilder.create(); + + if (sslContextBuilder != null) + { + sslConnectionSocketFactoryBuilder.setSslContext(sslContextBuilder.build()); + } + + if (config.isDisableTrustManager() || config.isAllowAnyHostname()) + { + sslConnectionSocketFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE); + } + final SSLConnectionSocketFactory sslConnectionSocketFactory = sslConnectionSocketFactoryBuilder.build(); + connectionManagerBuilder.setSSLSocketFactory(sslConnectionSocketFactory); + } + + private char[] asCharArray(String value, char... nullValue) + { + return ofNullable(value) + .filter(not(String::isBlank)) + .map(String::toCharArray) + .orElse(nullValue); + } + } + + static class ClientRegistrationProvider + { + private final IdentityServiceConfig config; + + ClientRegistrationProvider(IdentityServiceConfig config) + { + this.config = requireNonNull(config); + } + + public ClientRegistration createClientRegistration(final RestOperations rest) + { + return possibleMetadataURIs() + .stream() + .map(u -> extractMetadata(rest, u)) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst() + .map(this::validateDiscoveryDocument) + .map(this::createBuilder) + .map(this::configureClientAuthentication) + .map(Builder::build) + .orElseThrow(() -> new IllegalStateException("Failed to create ClientRegistration.")); + } + + private OIDCProviderMetadata validateDiscoveryDocument(OIDCProviderMetadata metadata) + { + validateOIDCEndpoint(metadata.getTokenEndpointURI(), "Token"); + validateOIDCEndpoint(metadata.getAuthorizationEndpointURI(), "Authorization"); + validateOIDCEndpoint(metadata.getUserInfoEndpointURI(), "User Info"); + validateOIDCEndpoint(metadata.getJWKSetURI(), "JWK Set"); + + if (metadata.getIssuer() != null) + { + try + { + URI metadataIssuerURI = new URI(metadata.getIssuer().getValue()); + validateOIDCEndpoint(metadataIssuerURI, "Issuer"); + if (StringUtils.isNotBlank(config.getIssuerUrl()) && + !metadataIssuerURI.equals(URI.create(config.getIssuerUrl()))) + { + throw new IdentityServiceException("Failed to create ClientRegistration. " + + "The Issuer value from the OIDC Discovery Endpoint does not align with the provided Issuer. Expected `%s` but found `%s`" + .formatted(config.getIssuerUrl(), metadata.getIssuer().getValue())); + } + } + catch (URISyntaxException e) + { + throw new IdentityServiceException("The provided Issuer value could not be parsed as a URI reference.", e); + } + } + else + { + throw new IdentityServiceException("The Issuer retrieved from the OIDC Discovery Endpoint cannot be null."); + } + + return metadata; + } + + private void validateOIDCEndpoint(URI value, String endpointName) + { + if (value == null || value.toASCIIString().isBlank()) + { + throw new IdentityServiceException("The `%s` Endpoint retrieved from the OIDC Discovery Endpoint cannot be empty.".formatted(endpointName)); + } + } + + private ClientRegistration.Builder createBuilder(OIDCProviderMetadata metadata) + { + final String authUri = Optional.of(metadata) + .map(OIDCProviderMetadata::getAuthorizationEndpointURI) + .map(URI::toASCIIString) + .orElse(null); + + var metadataIssuer = getMetadataIssuer(metadata, config); + final String issuerUri = metadataIssuer + .orElseGet(() -> (StringUtils.isNotBlank(config.getRealm()) && StringUtils.isBlank(config.getIssuerUrl())) ? config.getAuthServerUrl() : config.getIssuerUrl()); + + final var usernameAttribute = StringUtils.isNotBlank(config.getPrincipalAttribute()) ? config.getPrincipalAttribute() : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; + + return ClientRegistration + .withRegistrationId("ids") + .authorizationUri(authUri) + .tokenUri(metadata.getTokenEndpointURI().toASCIIString()) + .jwkSetUri(metadata.getJWKSetURI().toASCIIString()) + .issuerUri(issuerUri) + .userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()) + .userNameAttributeName(usernameAttribute) + .scope(getSupportedScopes(metadata.getScopes())) + .providerConfigurationMetadata(createMetadata(metadata)) + .authorizationGrantType(AuthorizationGrantType.PASSWORD); + } + + private Map createMetadata(OIDCProviderMetadata metadata) + { + Map configurationMetadata = new LinkedHashMap<>(); + if (metadata.getScopes() != null) + { + configurationMetadata.put(SCOPES_SUPPORTED.getValue(), metadata.getScopes()); + } + if (StringUtils.isNotBlank(config.getAudience())) + { + configurationMetadata.put(AUDIENCE.getValue(), config.getAudience()); + } + return configurationMetadata; + } + + private Builder configureClientAuthentication(Builder builder) + { + builder.clientId(config.getResource()); + if (config.isPublicClient()) + { + return builder.clientSecret(null) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST); + } + return builder.clientSecret(config.getClientSecret()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + } + + private Set getSupportedScopes(Scope scopes) + { + return scopes.stream() + .filter(this::hasPasswordGrantScope) + .map(Identifier::getValue) + .collect(Collectors.toSet()); + } + + private boolean hasPasswordGrantScope(Scope.Value scope) + { + return config.getPasswordGrantScopes().contains(scope.getValue()); + } + + private Optional extractMetadata(RestOperations rest, URI metadataUri) + { + final String response; + try + { + final ResponseEntity r = rest.exchange(RequestEntity.get(metadataUri).build(), String.class); + if (r.getStatusCode() != HttpStatus.OK || !r.hasBody()) + { + LOGGER.warn("Unexpected response from " + metadataUri + ". Status code: " + r.getStatusCode() + + ", has body: " + r.hasBody() + "."); + return Optional.empty(); + } + response = r.getBody(); + } + catch (Exception e) + { + LOGGER.warn("Failed to get response from " + metadataUri + ". " + e.getMessage(), e); + return Optional.empty(); + } + try + { + return Optional.of(OIDCProviderMetadata.parse(response)); + } + catch (Exception e) + { + LOGGER.warn("Failed to parse metadata. " + e.getMessage(), e); + return Optional.empty(); + } + } + + private Collection possibleMetadataURIs() + { + if (StringUtils.isBlank(config.getAuthServerUrl()) && StringUtils.isBlank(config.getIssuerUrl())) + { + throw new IdentityServiceException( + "Failed to create ClientRegistration. The values of issuer url and auth server url cannot both be empty."); + } + + String baseUrl = StringUtils.isNotBlank(config.getAuthServerUrl()) ? config.getAuthServerUrl() : config.getIssuerUrl(); + + return List.of(UriComponentsBuilder.fromUriString(baseUrl) + .pathSegment(".well-known", "openid-configuration") + .build().toUri()); + } + } + + private static Optional getMetadataIssuer(OIDCProviderMetadata metadata, IdentityServiceConfig config) + { + return DEFAULT_ISSUER_ATTR.equals(config.getIssuerAttribute()) ? Optional.of(metadata) + .map(OIDCProviderMetadata::getIssuer) + .map(Issuer::getValue) + : Optional.of(metadata) + .map(OIDCProviderMetadata::getCustomParameters) + .map(map -> map.get(config.getIssuerAttribute())) + .filter(String.class::isInstance) + .map(String.class::cast); + } + + static class JwtDecoderProvider + { + private static final SignatureAlgorithm DEFAULT_SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256; + private final IdentityServiceConfig config; + private final Set signatureAlgorithms; + + JwtDecoderProvider(IdentityServiceConfig config) + { + this.config = requireNonNull(config); + this.signatureAlgorithms = ofNullable(config.getSignatureAlgorithms()) + .filter(not(Set::isEmpty)) + .orElseGet(() -> { + LOGGER.warn("Unable to find any valid signature algorithms in the configuration. " + + "Using the default signature algorithm: " + DEFAULT_SIGNATURE_ALGORITHM.getName() + "."); + return Set.of(DEFAULT_SIGNATURE_ALGORITHM); + }); + } + + public JwtDecoder createJwtDecoder(RestOperations rest, ProviderDetails providerDetails) + { + try + { + final NimbusJwtDecoder decoder = buildJwtDecoder(rest, providerDetails); + + decoder.setJwtValidator(createJwtTokenValidator(providerDetails)); + decoder.setClaimSetConverter( + new ClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters())); + + return decoder; + } + catch (RuntimeException e) + { + LOGGER.warn("Failed to create JwtDecoder.", e); + throw authorizationServerCantBeUsedException(e); + } + } + + private NimbusJwtDecoder buildJwtDecoder(RestOperations rest, ProviderDetails providerDetails) + { + if (isDefined(config.getRealmKey())) + { + final RSAPublicKey publicKey = parsePublicKey(config.getRealmKey()); + return NimbusJwtDecoder.withPublicKey(publicKey) + .signatureAlgorithm(DEFAULT_SIGNATURE_ALGORITHM) + .build(); + } + final String jwkSetUri = requireValidJwkSetUri(providerDetails); + final NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder decoderBuilder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri); + signatureAlgorithms.forEach(decoderBuilder::jwsAlgorithm); + return decoderBuilder + .restOperations(rest) + .jwtProcessorCustomizer(this::reconfigureJWKSCache) + .build(); + } + + private void reconfigureJWKSCache(ConfigurableJWTProcessor jwtProcessor) + { + final Optional> jwkSource = ofNullable(jwtProcessor) + .map(ConfigurableJWTProcessor::getJWSKeySelector) + .filter(JWSVerificationKeySelector.class::isInstance) + .map(o -> (JWSVerificationKeySelector) o) + .map(JWSVerificationKeySelector::getJWKSource) + .filter(RemoteJWKSet.class::isInstance).map(o -> (RemoteJWKSet) o); + if (jwkSource.isEmpty()) + { + LOGGER.warn("Not able to reconfigure the JWK Cache. Unexpected JWKSource."); + return; + } + + final Optional jwkSetUrl = jwkSource.map(RemoteJWKSet::getJWKSetURL); + if (jwkSetUrl.isEmpty()) + { + LOGGER.warn("Not able to reconfigure the JWK Cache. Unknown JWKSetURL."); + return; + } + + final Optional resourceRetriever = jwkSource.map(RemoteJWKSet::getResourceRetriever); + if (resourceRetriever.isEmpty()) + { + LOGGER.warn("Not able to reconfigure the JWK Cache. Unknown ResourceRetriever."); + return; + } + + final DefaultJWKSetCache cache = new DefaultJWKSetCache(config.getPublicKeyCacheTtl(), -1, + TimeUnit.SECONDS); + final JWKSource cachingJWKSource = new RemoteJWKSet<>(jwkSetUrl.get(), + resourceRetriever.get(), cache); + + jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>( + signatureAlgorithms.stream() + .map(signatureAlgorithm -> JWSAlgorithm.parse(signatureAlgorithm.getName())) + .collect(Collectors.toSet()), + cachingJWKSource)); + jwtProcessor.setJWSTypeVerifier(new CustomJOSEObjectTypeVerifier(JOSEObjectType.JWT, AT_JWT)); + } + + private OAuth2TokenValidator createJwtTokenValidator(ProviderDetails providerDetails) + { + List> validators = new ArrayList<>(); + validators.add(new JwtTimestampValidator(Duration.of(config.getJwtClockSkewMs(), ChronoUnit.MILLIS))); + validators.add(new JwtIssuerValidator(providerDetails.getIssuerUri())); + if (!config.isClientIdValidationDisabled()) + { + validators.add(new JwtClaimValidator("azp", config.getResource()::equals)); + } + if (StringUtils.isNotBlank(config.getAudience())) + { + validators.add(new JwtAudienceValidator(config.getAudience())); + } + return new DelegatingOAuth2TokenValidator<>(validators); + } + + private RSAPublicKey parsePublicKey(String pem) + { + try + { + return tryToParsePublicKey(pem); + } + catch (Exception e) + { + if (isPemFormatException(e)) + { + // For backward compatibility with Keycloak adapter + return tryToParsePublicKey("-----BEGIN PUBLIC KEY-----\n" + pem + "\n-----END PUBLIC KEY-----"); + } + throw e; + } + } + + private RSAPublicKey tryToParsePublicKey(String pem) + { + final InputStream pemStream = new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)); + return RsaKeyConverters.x509().convert(pemStream); + } + + private boolean isPemFormatException(Exception e) + { + return e.getMessage() != null && e.getMessage().contains("-----BEGIN PUBLIC KEY-----"); + } + + private String requireValidJwkSetUri(ProviderDetails providerDetails) + { + final String uri = providerDetails.getJwkSetUri(); + if (!isDefined(uri)) + { + OAuth2Error oauth2Error = new OAuth2Error("missing_signature_verifier", + "Failed to find a Signature Verifier for: '" + + providerDetails.getIssuerUri() + + "'. Check to ensure you have configured the JwkSet URI.", + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + return uri; + } + } + + static class JwtIssuerValidator implements OAuth2TokenValidator + { + private final String requiredIssuer; + + public JwtIssuerValidator(String issuer) + { + this.requiredIssuer = requireNonNull(issuer, "issuer cannot be null"); + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) + { + requireNonNull(token, "token cannot be null"); + final Object issuer = token.getClaim(JwtClaimNames.ISS); + if (issuer != null && requiredIssuer.equals(issuer.toString())) + { + return OAuth2TokenValidatorResult.success(); + } + + final OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_TOKEN, + "The iss claim is not valid. Expected `%s` but got `%s`.".formatted(requiredIssuer, issuer), + "https://tools.ietf.org/html/rfc6750#section-3.1"); + return OAuth2TokenValidatorResult.failure(error); + } + + } + + static class JwtAudienceValidator implements OAuth2TokenValidator + { + private final String configuredAudience; + + public JwtAudienceValidator(String configuredAudience) + { + this.configuredAudience = configuredAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) + { + requireNonNull(token, "token cannot be null"); + final Object audience = token.getClaim(JwtClaimNames.AUD); + if (audience != null) + { + if (audience instanceof List && ((List) audience).contains(configuredAudience)) + { + return OAuth2TokenValidatorResult.success(); + } + if (audience instanceof String && audience.equals(configuredAudience)) + { + return OAuth2TokenValidatorResult.success(); + } + } + + final OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_TOKEN, + "The aud claim is not valid. Expected configured audience `%s` not found.".formatted(configuredAudience), + "https://tools.ietf.org/html/rfc6750#section-3.1"); + return OAuth2TokenValidatorResult.failure(error); + } + } + + static class CustomClientHttpRequestFactory extends HttpComponentsClientHttpRequestFactory + { + CustomClientHttpRequestFactory(HttpClient httpClient) + { + super(httpClient); + } + + @Override + public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException + { + /* This is to avoid the Brotli content encoding that is not well-supported by the combination of the Apache Http Client and the Spring RestTemplate */ + ClientHttpRequest request = super.createRequest(uri, httpMethod); + request.getHeaders() + .add("Accept-Encoding", "gzip, deflate"); + return request; + } + } + + static class CustomJOSEObjectTypeVerifier extends DefaultJOSEObjectTypeVerifier + { + public CustomJOSEObjectTypeVerifier(JOSEObjectType... allowedTypes) + { + super(Set.of(allowedTypes)); + } + + @Override + public void verify(JOSEObjectType type, SecurityContext context) throws BadJOSEException + { + super.verify(type, context); + } + } + + private static boolean isDefined(String value) + { + return value != null && !value.isBlank(); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java index 8c77c73054..599bc06292 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java @@ -30,59 +30,38 @@ import java.io.Serializable; import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.function.BiFunction; import java.util.function.Predicate; -import com.nimbusds.openid.connect.sdk.claims.PersonClaims; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; +import org.apache.commons.lang3.StringUtils; import org.alfresco.model.ContentModel; import org.alfresco.repo.security.authentication.AuthenticationUtil; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.DecodedAccessToken; +import org.alfresco.repo.security.authentication.identityservice.user.AccessTokenToDecodedTokenUserMapper; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.authentication.identityservice.user.TokenUserToOIDCUserMapper; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.namespace.QName; import org.alfresco.service.transaction.TransactionService; -import org.apache.commons.lang3.StringUtils; /** - * This class handles Just in Time user provisioning. It extracts {@link OIDCUserInfo} - * from {@link IdentityServiceFacade.DecodedAccessToken} or {@link UserInfo} - * and creates a new user if it does not exist in the repository. + * This class handles Just in Time user provisioning. It extracts {@link OIDCUserInfo} from the given bearer token and creates a new user if it does not exist in the repository. */ public class IdentityServiceJITProvisioningHandler { - private final IdentityServiceConfig identityServiceConfig; private final IdentityServiceFacade identityServiceFacade; private final PersonService personService; private final TransactionService transactionService; - - private final BiFunction> mapTokenToUserInfoResponse = (token, usernameMappingClaim) -> { - Optional firstName = Optional.ofNullable(token) - .map(jwtToken -> jwtToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)) - .filter(String.class::isInstance) - .map(String.class::cast); - Optional lastName = Optional.ofNullable(token) - .map(jwtToken -> jwtToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)) - .filter(String.class::isInstance) - .map(String.class::cast); - Optional email = Optional.ofNullable(token) - .map(jwtToken -> jwtToken.getClaim(PersonClaims.EMAIL_CLAIM_NAME)) - .filter(String.class::isInstance) - .map(String.class::cast); - - return Optional.ofNullable(token.getClaim(Optional.ofNullable(usernameMappingClaim) - .filter(StringUtils::isNotBlank) - .orElse(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME))) - .filter(String.class::isInstance) - .map(String.class::cast) - .map(this::normalizeUserId) - .map(username -> new OIDCUserInfo(username, firstName.orElse(""), lastName.orElse(""), email.orElse(""))); - }; + private final IdentityServiceConfig identityServiceConfig; + private UserInfoAttrMapping userInfoAttrMapping; + private TokenUserToOIDCUserMapper tokenUserToOIDCUserMapper; + private AccessTokenToDecodedTokenUserMapper tokenToDecodedTokenUserMapper; public IdentityServiceJITProvisioningHandler(IdentityServiceFacade identityServiceFacade, - PersonService personService, - TransactionService transactionService, - IdentityServiceConfig identityServiceConfig) + PersonService personService, + TransactionService transactionService, + IdentityServiceConfig identityServiceConfig) { this.identityServiceFacade = identityServiceFacade; this.personService = personService; @@ -90,94 +69,95 @@ public class IdentityServiceJITProvisioningHandler this.identityServiceConfig = identityServiceConfig; } + /** + * Extracts {@link OIDCUserInfo} from the given bearer token and creates a new user if it does not exist in the repository. Call to the UserInfo endpoint is made only if the token does not contain a username claim or if user needs to be created and some of the {@link OIDCUserInfo} fields are empty. + */ public Optional extractUserInfoAndCreateUserIfNeeded(String bearerToken) { - Optional userInfoResponse = Optional.ofNullable(bearerToken) - .filter(Predicate.not(String::isEmpty)) - .flatMap(token -> extractUserInfoResponseFromAccessToken(token) - .filter(userInfo -> StringUtils.isNotEmpty(userInfo.username())) - .or(() -> extractUserInfoResponseFromEndpoint(token))); - - if (transactionService.isReadOnly() || userInfoResponse.isEmpty()) + if (userInfoAttrMapping == null) { - return userInfoResponse; + initMappers(identityServiceConfig); } - return AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork>() + + Optional oidcUserInfo = Optional.ofNullable(bearerToken) + .filter(Predicate.not(String::isEmpty)) + .flatMap(token -> extractUserInfoResponseFromAccessToken(token).filter(decodedTokenUser -> StringUtils.isNotEmpty(decodedTokenUser.username())) + .or(() -> extractUserInfoResponseFromEndpoint(token, userInfoAttrMapping))) + .map(tokenUserToOIDCUserMapper::toOIDCUser); + + if (transactionService.isReadOnly() || oidcUserInfo.isEmpty()) { + return oidcUserInfo; + } + return AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork<>() { @Override public Optional doWork() throws Exception { - return userInfoResponse.map(userInfo -> { - if (userInfo.username() != null && personService.createMissingPeople() - && !personService.personExists(userInfo.username())) + return oidcUserInfo.map(oidcUser -> { + if (userDoesNotExistsAndCanBeCreated(oidcUser)) { - if (!userInfo.allFieldsNotEmpty()) + if (!oidcUser.allFieldsNotEmpty()) { - userInfo = extractUserInfoResponseFromEndpoint(bearerToken).orElse(userInfo); + oidcUser = extractUserInfoResponseFromEndpoint(bearerToken, userInfoAttrMapping) + .map(tokenUserToOIDCUserMapper::toOIDCUser) + .orElse(oidcUser); } - Map properties = new HashMap<>(); - properties.put(ContentModel.PROP_USERNAME, userInfo.username()); - properties.put(ContentModel.PROP_FIRSTNAME, userInfo.firstName()); - properties.put(ContentModel.PROP_LASTNAME, userInfo.lastName()); - properties.put(ContentModel.PROP_EMAIL, userInfo.email()); - properties.put(ContentModel.PROP_ORGID, ""); - properties.put(ContentModel.PROP_HOME_FOLDER_PROVIDER, null); - - properties.put(ContentModel.PROP_SIZE_CURRENT, 0L); - properties.put(ContentModel.PROP_SIZE_QUOTA, -1L); // no quota - - personService.createPerson(properties); + createPerson(oidcUser); } - return userInfo; + return oidcUser; }); } + }, AuthenticationUtil.getSystemUserName()); } - private Optional extractUserInfoResponseFromAccessToken(String bearerToken) + private void initMappers(IdentityServiceConfig identityServiceConfig) + { + this.userInfoAttrMapping = initUserInfoAttrMapping(identityServiceConfig); + this.tokenUserToOIDCUserMapper = new TokenUserToOIDCUserMapper(personService); + this.tokenToDecodedTokenUserMapper = new AccessTokenToDecodedTokenUserMapper(userInfoAttrMapping); + } + + private boolean userDoesNotExistsAndCanBeCreated(OIDCUserInfo userInfo) + { + return userInfo.username() != null && personService.createMissingPeople() + && !personService.personExists(userInfo.username()); + } + + private Optional extractUserInfoResponseFromAccessToken(String bearerToken) { return Optional.ofNullable(bearerToken) - .map(identityServiceFacade::decodeToken) - .flatMap(decodedToken -> mapTokenToUserInfoResponse.apply(decodedToken, - identityServiceConfig.getPrincipalAttribute())); + .map(identityServiceFacade::decodeToken) + .flatMap(tokenToDecodedTokenUserMapper::toDecodedTokenUser); } - private Optional extractUserInfoResponseFromEndpoint(String bearerToken) + private Optional extractUserInfoResponseFromEndpoint(String bearerToken, UserInfoAttrMapping userInfoAttrMapping) { - return identityServiceFacade.getUserInfo(bearerToken, - StringUtils.isNotBlank(identityServiceConfig.getPrincipalAttribute()) ? - identityServiceConfig.getPrincipalAttribute() : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME) - .filter(userInfo -> userInfo.username() != null && !userInfo.username().isEmpty()) - .map(userInfo -> new OIDCUserInfo(normalizeUserId(userInfo.username()), - Optional.ofNullable(userInfo.firstName()).orElse(""), - Optional.ofNullable(userInfo.lastName()).orElse(""), - Optional.ofNullable(userInfo.email()).orElse(""))); + return identityServiceFacade.getUserInfo(bearerToken, userInfoAttrMapping) + .filter(userInfo -> userInfo.username() != null && !userInfo.username().isEmpty()); } - /** - * Normalizes a user id, taking into account existing user accounts and case sensitivity settings. - * - * @param userId the user id - * @return the string - */ - private String normalizeUserId(final String userId) + private void createPerson(OIDCUserInfo userInfo) { - if (userId == null) - { - return null; - } + Map properties = new HashMap<>(); + properties.put(ContentModel.PROP_USERNAME, userInfo.username()); + properties.put(ContentModel.PROP_FIRSTNAME, userInfo.firstName()); + properties.put(ContentModel.PROP_LASTNAME, userInfo.lastName()); + properties.put(ContentModel.PROP_EMAIL, userInfo.email()); + properties.put(ContentModel.PROP_ORGID, ""); + properties.put(ContentModel.PROP_HOME_FOLDER_PROVIDER, null); + properties.put(ContentModel.PROP_SIZE_CURRENT, 0L); + properties.put(ContentModel.PROP_SIZE_QUOTA, -1L); // no quota - String normalized = AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork() - { - @Override - public String doWork() throws Exception - { - return personService.getUserIdentifier(userId); - } - }, AuthenticationUtil.getSystemUserName()); - - return normalized == null ? userId : normalized; + personService.createPerson(properties); } -} \ No newline at end of file + private UserInfoAttrMapping initUserInfoAttrMapping(IdentityServiceConfig identityServiceConfig) + { + return new UserInfoAttrMapping(identityServiceFacade.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(), + identityServiceConfig.getFirstNameAttribute(), + identityServiceConfig.getLastNameAttribute(), + identityServiceConfig.getEmailAttribute()); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java index 2c91ef75ac..5302116dc7 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java @@ -1,181 +1,181 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import jakarta.servlet.http.HttpServletRequest; - -import java.util.Optional; - -import org.alfresco.repo.management.subsystems.ActivateableBean; -import org.alfresco.repo.security.authentication.AuthenticationException; -import org.alfresco.repo.security.authentication.AuthenticationUtil; -import org.alfresco.repo.security.authentication.external.RemoteUserMapper; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; - -/** - * A {@link RemoteUserMapper} implementation that detects and validates JWTs - * issued by the Alfresco Identity Service. - * - * @author Gavin Cornwell - */ -public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, ActivateableBean -{ - private static final Log LOGGER = LogFactory.getLog(IdentityServiceRemoteUserMapper.class); - - /** Is the mapper enabled */ - private boolean isEnabled; - - /** Are token validation failures handled silently? */ - private boolean isValidationFailureSilent; - - private BearerTokenResolver bearerTokenResolver; - - private IdentityServiceJITProvisioningHandler jitProvisioningHandler; - - /** - * Sets the active flag - * - * @param isEnabled true to enable the subsystem - */ - public void setActive(boolean isEnabled) - { - this.isEnabled = isEnabled; - } - - /** - * Determines whether token validation failures are silent - * - * @param silent true to silently fail, false to throw an exception - */ - public void setValidationFailureSilent(boolean silent) - { - this.isValidationFailureSilent = silent; - } - - public void setBearerTokenResolver(BearerTokenResolver bearerTokenResolver) - { - this.bearerTokenResolver = bearerTokenResolver; - } - - public void setJitProvisioningHandler(IdentityServiceJITProvisioningHandler jitProvisioningHandler) - { - this.jitProvisioningHandler = jitProvisioningHandler; - } - - /* - * (non-Javadoc) - * @see org.alfresco.web.app.servlet.RemoteUserMapper#getRemoteUser(jakarta.servlet.http.HttpServletRequest) - */ - @Override - public String getRemoteUser(HttpServletRequest request) - { - LOGGER.trace("Retrieving username from http request..."); - - if (!this.isEnabled) - { - LOGGER.debug("IdentityServiceRemoteUserMapper is disabled, returning null."); - return null; - } - try - { - String normalizedUserId = extractUserFromHeader(request); - - - if (normalizedUserId != null) - { - // Normalize the user ID taking into account case sensitivity settings - LOGGER.trace("Returning userId: " + AuthenticationUtil.maskUsername(normalizedUserId)); - return normalizedUserId; - } - } - catch (IdentityServiceFacadeException e) - { - if (!isValidationFailureSilent) - { - throw new AuthenticationException("Failed to extract username from token: " + e.getMessage(), e); - } - LOGGER.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); - } - catch (RuntimeException e) - { - LOGGER.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); - } - LOGGER.trace("Could not identify a userId. Returning null."); - return null; - } - - /* - * (non-Javadoc) - * @see org.alfresco.repo.management.subsystems.ActivateableBean#isActive() - */ - public boolean isActive() - { - return this.isEnabled; - } - - /** - * Extracts the user name from the JWT in the given request. - * - * @param request The request containing the JWT - * @return The username or null if it can not be determined - */ - private String extractUserFromHeader(HttpServletRequest request) - { - // try authenticating with bearer token first - LOGGER.debug("Trying bearer token..."); - - final String bearerToken; - try - { - bearerToken = bearerTokenResolver.resolve(request); - } - catch (OAuth2AuthenticationException e) - { - LOGGER.debug("Failed to resolve Bearer token.", e); - return null; - } - - final Optional possibleUsername = jitProvisioningHandler - .extractUserInfoAndCreateUserIfNeeded(bearerToken) - .map(OIDCUserInfo::username); - - if (possibleUsername.isEmpty()) - { - LOGGER.debug("User could not be authenticated by IdentityServiceRemoteUserMapper."); - return null; - } - - String normalizedUsername = possibleUsername.get(); - LOGGER.trace("Extracted username: " + AuthenticationUtil.maskUsername(normalizedUsername)); - - return normalizedUsername; - } - -} +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import java.util.Optional; +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; + +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; + +/** + * A {@link RemoteUserMapper} implementation that detects and validates JWTs issued by the Alfresco Identity Service. + * + * @author Gavin Cornwell + */ +public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, ActivateableBean +{ + private static final Log LOGGER = LogFactory.getLog(IdentityServiceRemoteUserMapper.class); + + /** Is the mapper enabled */ + private boolean isEnabled; + + /** Are token validation failures handled silently? */ + private boolean isValidationFailureSilent; + + private BearerTokenResolver bearerTokenResolver; + + private IdentityServiceJITProvisioningHandler jitProvisioningHandler; + + /** + * Sets the active flag + * + * @param isEnabled + * true to enable the subsystem + */ + public void setActive(boolean isEnabled) + { + this.isEnabled = isEnabled; + } + + /** + * Determines whether token validation failures are silent + * + * @param silent + * true to silently fail, false to throw an exception + */ + public void setValidationFailureSilent(boolean silent) + { + this.isValidationFailureSilent = silent; + } + + public void setBearerTokenResolver(BearerTokenResolver bearerTokenResolver) + { + this.bearerTokenResolver = bearerTokenResolver; + } + + public void setJitProvisioningHandler(IdentityServiceJITProvisioningHandler jitProvisioningHandler) + { + this.jitProvisioningHandler = jitProvisioningHandler; + } + + /* (non-Javadoc) + * + * @see org.alfresco.web.app.servlet.RemoteUserMapper#getRemoteUser(jakarta.servlet.http.HttpServletRequest) */ + @Override + public String getRemoteUser(HttpServletRequest request) + { + LOGGER.trace("Retrieving username from http request..."); + + if (!this.isEnabled) + { + LOGGER.debug("IdentityServiceRemoteUserMapper is disabled, returning null."); + return null; + } + try + { + String normalizedUserId = extractUserFromHeader(request); + + if (normalizedUserId != null) + { + // Normalize the user ID taking into account case sensitivity settings + LOGGER.trace("Returning userId: " + AuthenticationUtil.maskUsername(normalizedUserId)); + return normalizedUserId; + } + } + catch (IdentityServiceFacadeException e) + { + if (!isValidationFailureSilent) + { + throw new AuthenticationException("Failed to extract username from token: " + e.getMessage(), e); + } + LOGGER.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); + } + catch (RuntimeException e) + { + LOGGER.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); + } + LOGGER.trace("Could not identify a userId. Returning null."); + return null; + } + + /* (non-Javadoc) + * + * @see org.alfresco.repo.management.subsystems.ActivateableBean#isActive() */ + public boolean isActive() + { + return this.isEnabled; + } + + /** + * Extracts the user name from the JWT in the given request. + * + * @param request + * The request containing the JWT + * @return The username or null if it can not be determined + */ + private String extractUserFromHeader(HttpServletRequest request) + { + // try authenticating with bearer token first + LOGGER.debug("Trying bearer token..."); + + final String bearerToken; + try + { + bearerToken = bearerTokenResolver.resolve(request); + } + catch (OAuth2AuthenticationException e) + { + LOGGER.debug("Failed to resolve Bearer token.", e); + return null; + } + + final Optional possibleUsername = jitProvisioningHandler + .extractUserInfoAndCreateUserIfNeeded(bearerToken) + .map(OIDCUserInfo::username); + + if (possibleUsername.isEmpty()) + { + LOGGER.debug("User could not be authenticated by IdentityServiceRemoteUserMapper."); + return null; + } + + String normalizedUsername = possibleUsername.get(); + LOGGER.trace("Extracted username: " + AuthenticationUtil.maskUsername(normalizedUsername)); + + return normalizedUsername; + } + +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java index 8eb0eff806..de39c53b27 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java @@ -30,21 +30,12 @@ import static java.util.Objects.requireNonNull; import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.AUDIENCE; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; import java.time.Instant; import java.util.Map; import java.util.Optional; -import java.util.function.Predicate; -import com.nimbusds.oauth2.sdk.ErrorObject; -import com.nimbusds.oauth2.sdk.ParseException; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; -import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse; -import com.nimbusds.openid.connect.sdk.UserInfoRequest; -import com.nimbusds.openid.connect.sdk.UserInfoResponse; -import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse; +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.core.convert.converter.Converter; @@ -59,27 +50,35 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRe import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.AbstractOAuth2Token; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2AuthorizationException; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestOperations; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; + class SpringBasedIdentityServiceFacade implements IdentityServiceFacade { private static final Log LOGGER = LogFactory.getLog(SpringBasedIdentityServiceFacade.class); private static final Instant SOME_INSIGNIFICANT_DATE_IN_THE_PAST = Instant.MIN.plusSeconds(12345); private final Map clients; + private final DefaultOAuth2UserService defaultOAuth2UserService; private final ClientRegistration clientRegistration; private final JwtDecoder jwtDecoder; @@ -93,6 +92,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade AuthorizationGrantType.AUTHORIZATION_CODE, createAuthorizationCodeClient(restOperations), AuthorizationGrantType.REFRESH_TOKEN, createRefreshTokenClient(restOperations), AuthorizationGrantType.PASSWORD, createPasswordClient(restOperations, clientRegistration)); + this.defaultOAuth2UserService = createOAuth2UserService(restOperations); } @Override @@ -121,51 +121,18 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade } @Override - public Optional getUserInfo(String tokenParameter, String principalAttribute) + public Optional getUserInfo(String token, UserInfoAttrMapping userInfoAttrMapping) { - return Optional.ofNullable(tokenParameter) - .filter(Predicate.not(String::isEmpty)) - .flatMap(token -> Optional.ofNullable(clientRegistration) - .map(ClientRegistration::getProviderDetails) - .map(ClientRegistration.ProviderDetails::getUserInfoEndpoint) - .map(ClientRegistration.ProviderDetails.UserInfoEndpoint::getUri) - .flatMap(uri -> { - try - { - return Optional.of( - new UserInfoRequest(new URI(uri), new BearerAccessToken(token)).toHTTPRequest().send()); - } - catch (IOException | URISyntaxException e) - { - LOGGER.warn("Failed to get user information. Reason: " + e.getMessage()); - return Optional.empty(); - } - }) - .flatMap(httpResponse -> { - try - { - UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse); - - if (userInfoResponse instanceof UserInfoErrorResponse userInfoErrorResponse) - { - String errorMessage = Optional.ofNullable(userInfoErrorResponse.getErrorObject()) - .map(ErrorObject::getDescription) - .orElse("No error description found"); - LOGGER.warn("User Info Request failed: " + errorMessage); - throw new UserInfoException(errorMessage); - } - return Optional.of(userInfoResponse); - } - catch (ParseException e) - { - LOGGER.warn("Failed to parse user info response. Reason: " + e.getMessage()); - return Optional.empty(); - } - }) - .map(UserInfoResponse::toSuccessResponse) - .map(UserInfoSuccessResponse::getUserInfo)) - .map(userInfo -> new OIDCUserInfo(userInfo.getStringClaim(principalAttribute), userInfo.getGivenName(), - userInfo.getFamilyName(), userInfo.getEmailAddress())); + try + { + return Optional.ofNullable(defaultOAuth2UserService.loadUser(new OAuth2UserRequest(clientRegistration, getSpringAccessToken(token)))) + .flatMap(oAuth2User -> mapOAuth2UserToDecodedTokenUser(oAuth2User, userInfoAttrMapping)); + } + catch (OAuth2AuthenticationException exception) + { + LOGGER.warn("User Info Request failed: " + exception.getMessage()); + return Optional.empty(); + } } @Override @@ -202,11 +169,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade if (grant.isRefreshToken()) { - final OAuth2AccessToken expiredAccessToken = new OAuth2AccessToken( - TokenType.BEARER, - "JUST_FOR_FULFILLING_THE_SPRING_API", - SOME_INSIGNIFICANT_DATE_IN_THE_PAST, - SOME_INSIGNIFICANT_DATE_IN_THE_PAST.plusSeconds(1)); + final OAuth2AccessToken expiredAccessToken = getSpringAccessToken("JUST_FOR_FULFILLING_THE_SPRING_API"); final OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(grant.getRefreshToken(), null); return new OAuth2RefreshTokenGrantRequest(clientRegistration, expiredAccessToken, refreshToken, @@ -258,6 +221,26 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade return client; } + private static DefaultOAuth2UserService createOAuth2UserService(RestOperations rest) + { + final DefaultOAuth2UserService userService = new DefaultOAuth2UserService(); + userService.setRestOperations(rest); + return userService; + } + + private Optional mapOAuth2UserToDecodedTokenUser(OAuth2User oAuth2User, UserInfoAttrMapping userInfoAttrMapping) + { + var preferredUsername = Optional.ofNullable(oAuth2User.getAttribute(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)) + .filter(String.class::isInstance) + .map(String.class::cast) + .filter(StringUtils::isNotEmpty); + var userName = Optional.ofNullable(oAuth2User.getName()).filter(username -> !username.isEmpty()).or(() -> preferredUsername); + return userName.map(name -> DecodedTokenUser.validateAndCreate(name, + oAuth2User.getAttribute(userInfoAttrMapping.firstNameClaim()), + oAuth2User.getAttribute(userInfoAttrMapping.lastNameClaim()), + oAuth2User.getAttribute(userInfoAttrMapping.emailClaim()))); + } + private static OAuth2AccessTokenResponseClient createPasswordClient(RestOperations rest, ClientRegistration clientRegistration) { @@ -288,6 +271,16 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade }; } + private static OAuth2AccessToken getSpringAccessToken(String token) + { + // Just for fulfilling the Spring API + return new OAuth2AccessToken( + TokenType.BEARER, + token, + SOME_INSIGNIFICANT_DATE_IN_THE_PAST, + SOME_INSIGNIFICANT_DATE_IN_THE_PAST.plusSeconds(1)); + } + private static class SpringAccessTokenAuthorization implements AccessTokenAuthorization { private final OAuth2AccessTokenResponse tokenResponse; diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java index fa9e38de53..8907c2f808 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java @@ -37,13 +37,19 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.oauth2.sdk.id.Identifier; import com.nimbusds.oauth2.sdk.id.State; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.web.util.UriComponentsBuilder; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.external.AdminConsoleAuthenticator; @@ -53,16 +59,9 @@ import org.alfresco.repo.security.authentication.identityservice.IdentityService import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; -import org.apache.commons.lang.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; -import org.springframework.web.util.UriComponentsBuilder; /** - * An {@link AdminConsoleAuthenticator} implementation to extract an externally authenticated user ID - * or to initiate the OIDC authorization code flow. + * An {@link AdminConsoleAuthenticator} implementation to extract an externally authenticated user ID or to initiate the OIDC authorization code flow. */ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAuthenticator, ActivateableBean { @@ -71,7 +70,6 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut private static final String ALFRESCO_ACCESS_TOKEN = "ALFRESCO_ACCESS_TOKEN"; private static final String ALFRESCO_REFRESH_TOKEN = "ALFRESCO_REFRESH_TOKEN"; private static final String ALFRESCO_TOKEN_EXPIRATION = "ALFRESCO_TOKEN_EXPIRATION"; - private static final Set SCOPES = Set.of("openid", "profile", "email", "offline_access"); private IdentityServiceConfig identityServiceConfig; private IdentityServiceFacade identityServiceFacade; @@ -145,7 +143,7 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut try { AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( - authorizationCode(code, request.getRequestURL().toString())); + authorizationCode(code, request.getRequestURL().toString())); addCookies(response, accessTokenAuthorization); bearerToken = accessTokenAuthorization.getAccessToken().getTokenValue(); } @@ -154,8 +152,8 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut if (LOGGER.isWarnEnabled()) { LOGGER.warn( - "Error while trying to retrieve a response using the Authorization Code at the Token Endpoint: {}", - exception.getMessage()); + "Error while trying to retrieve a response using the Authorization Code at the Token Endpoint: {}", + exception.getMessage()); } } return bearerToken; @@ -188,7 +186,7 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut { cookiesService.addCookie(ALFRESCO_ACCESS_TOKEN, accessTokenAuthorization.getAccessToken().getTokenValue(), response); cookiesService.addCookie(ALFRESCO_TOKEN_EXPIRATION, String.valueOf( - accessTokenAuthorization.getAccessToken().getExpiresAt().toEpochMilli()), response); + accessTokenAuthorization.getAccessToken().getExpiresAt().toEpochMilli()), response); cookiesService.addCookie(ALFRESCO_REFRESH_TOKEN, accessTokenAuthorization.getRefreshTokenValue(), response); } @@ -198,13 +196,13 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut State state = new State(); UriComponentsBuilder authRequestBuilder = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) - .queryParam("client_id", clientRegistration.getClientId()) - .queryParam("redirect_uri", getRedirectUri(request.getRequestURL().toString())) - .queryParam("response_type", "code") - .queryParam("scope", String.join("+", getScopes(clientRegistration))) - .queryParam("state", state.toString()); + .queryParam("client_id", clientRegistration.getClientId()) + .queryParam("redirect_uri", getRedirectUri(request.getRequestURL().toString())) + .queryParam("response_type", "code") + .queryParam("scope", String.join("+", getScopes(clientRegistration))) + .queryParam("state", state.toString()); - if(StringUtils.isNotBlank(identityServiceConfig.getAudience())) + if (StringUtils.isNotBlank(identityServiceConfig.getAudience())) { authRequestBuilder.queryParam("audience", identityServiceConfig.getAudience()); } @@ -215,20 +213,25 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut private Set getScopes(ClientRegistration clientRegistration) { return Optional.ofNullable(clientRegistration.getProviderDetails()) - .map(ProviderDetails::getConfigurationMetadata) - .map(metadata -> metadata.get(SCOPES_SUPPORTED.getValue())) - .filter(Scope.class::isInstance) - .map(Scope.class::cast) - .map(this::getSupportedScopes) - .orElse(clientRegistration.getScopes()); + .map(ProviderDetails::getConfigurationMetadata) + .map(metadata -> metadata.get(SCOPES_SUPPORTED.getValue())) + .filter(Scope.class::isInstance) + .map(Scope.class::cast) + .map(this::getSupportedScopes) + .orElse(clientRegistration.getScopes()); } private Set getSupportedScopes(Scope scopes) { return scopes.stream() - .filter(scope -> SCOPES.contains(scope.getValue())) - .map(Identifier::getValue) - .collect(Collectors.toSet()); + .filter(this::hasAdminConsoleScope) + .map(Identifier::getValue) + .collect(Collectors.toSet()); + } + + private boolean hasAdminConsoleScope(Scope.Value scope) + { + return identityServiceConfig.getAdminConsoleScopes().contains(scope.getValue()); } private String getRedirectUri(String requestURL) @@ -263,7 +266,7 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut private AccessTokenAuthorization doRefreshAuthToken(String refreshToken) { AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( - AuthorizationGrant.refreshToken(refreshToken)); + AuthorizationGrant.refreshToken(refreshToken)); if (accessTokenAuthorization == null || accessTokenAuthorization.getAccessToken() == null) { throw new AuthenticationException("AccessTokenResponse is null or empty"); @@ -284,7 +287,7 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut } public void setIdentityServiceFacade( - IdentityServiceFacade identityServiceFacade) + IdentityServiceFacade identityServiceFacade) { this.identityServiceFacade = identityServiceFacade; } @@ -295,13 +298,13 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut } public void setCookiesService( - AdminConsoleAuthenticationCookiesService cookiesService) + AdminConsoleAuthenticationCookiesService cookiesService) { this.cookiesService = cookiesService; } public void setIdentityServiceConfig( - IdentityServiceConfig identityServiceConfig) + IdentityServiceConfig identityServiceConfig) { this.identityServiceConfig = identityServiceConfig; } @@ -316,4 +319,4 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut { this.isEnabled = isEnabled; } -} \ No newline at end of file +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java new file mode 100644 index 0000000000..e94d6824e5 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java @@ -0,0 +1,66 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import java.util.Optional; + +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; +import org.apache.commons.lang3.StringUtils; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; + +public class AccessTokenToDecodedTokenUserMapper +{ + private static final String DEFAULT_USERNAME_CLAIM = PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; + + private final UserInfoAttrMapping userInfoAttrMapping; + + public AccessTokenToDecodedTokenUserMapper(UserInfoAttrMapping userInfoAttrMapping) + { + this.userInfoAttrMapping = userInfoAttrMapping; + } + + /** + * Maps the given {@link IdentityServiceFacade.DecodedAccessToken} to a {@link DecodedTokenUser}. + * + * @param token + * the token to map + * @return the mapped {@link DecodedTokenUser} or {@link Optional#empty()} if the token does not contain a username claim + */ + public Optional toDecodedTokenUser(IdentityServiceFacade.DecodedAccessToken token) + { + Object firstName = token.getClaim(userInfoAttrMapping.firstNameClaim()); + Object lastName = token.getClaim(userInfoAttrMapping.lastNameClaim()); + Object email = token.getClaim(userInfoAttrMapping.emailClaim()); + + return Optional.ofNullable(token.getClaim(Optional.ofNullable(userInfoAttrMapping.usernameClaim()) + .filter(StringUtils::isNotBlank) + .orElse(DEFAULT_USERNAME_CLAIM))) + .filter(String.class::isInstance) + .map(String.class::cast) + .map(username -> DecodedTokenUser.validateAndCreate(username, firstName, lastName, email)); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java new file mode 100644 index 0000000000..305c108944 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java @@ -0,0 +1,44 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import java.util.Optional; + +public record DecodedTokenUser(String username, String firstName, String lastName, String email) +{ + + private static final String EMPTY_STRING = ""; + + public static DecodedTokenUser validateAndCreate(String username, Object firstName, Object lastName, Object email) + { + return new DecodedTokenUser(username, getStringVal(firstName), getStringVal(lastName), getStringVal(email)); + } + + private static String getStringVal(Object firstName) + { + return Optional.ofNullable(firstName).filter(String.class::isInstance).map(String.class::cast).orElse(EMPTY_STRING); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OIDCUserInfo.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/OIDCUserInfo.java similarity index 99% rename from repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OIDCUserInfo.java rename to repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/OIDCUserInfo.java index 5f8a3c25d6..8d8e98f995 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OIDCUserInfo.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/OIDCUserInfo.java @@ -23,7 +23,7 @@ * along with Alfresco. If not, see . * #L% */ -package org.alfresco.repo.security.authentication.identityservice; +package org.alfresco.repo.security.authentication.identityservice.user; import java.util.stream.Stream; diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java new file mode 100644 index 0000000000..5e3cde4be9 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java @@ -0,0 +1,76 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.service.cmr.security.PersonService; + +public class TokenUserToOIDCUserMapper +{ + private final PersonService personService; + + public TokenUserToOIDCUserMapper(PersonService personService) + { + this.personService = personService; + } + + /** + * Maps a decoded token user to an OIDC user where the user id (username) is normalized. + * + * @param decodedTokenUser + * the decoded token user + * @return the OIDC user + */ + public OIDCUserInfo toOIDCUser(DecodedTokenUser decodedTokenUser) + { + return new OIDCUserInfo(usernameToUserId(decodedTokenUser.username()), decodedTokenUser.firstName(), decodedTokenUser.lastName(), decodedTokenUser.email()); + } + + /** + * Normalizes a username, taking into account existing user accounts and case sensitivity settings. + * + * @param caseSensitiveUserName + * the case-sensitive username + * @return the string + */ + private String usernameToUserId(final String caseSensitiveUserName) + { + if (caseSensitiveUserName == null) + { + return null; + } + + String normalized = AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork() { + @Override + public String doWork() throws Exception + { + return personService.getUserIdentifier(caseSensitiveUserName); + } + }, AuthenticationUtil.getSystemUserName()); + + return normalized == null ? caseSensitiveUserName : normalized; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java new file mode 100644 index 0000000000..d748dfe2fd --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java @@ -0,0 +1,41 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +/** + * The UserInfoAttrMapping record represents the mapping of claims fetched from the UserInfo endpoint to create an Alfresco user. + * + * @param usernameClaim + * the claim that represents the username + * @param firstNameClaim + * the claim that represents the first name + * @param lastNameClaim + * the claim that represents the last name + * @param emailClaim + * the claim that represents the email + */ +public record UserInfoAttrMapping(String usernameClaim, String firstNameClaim, String lastNameClaim, String emailClaim) +{} diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml index 748baf8cec..8bc16b3c06 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml @@ -149,6 +149,15 @@ ${identity-service.principal-attribute:preferred_username} + + ${identity-service.first-name-attribute:given_name} + + + ${identity-service.last-name-attribute:family_name} + + + ${identity-service.email-attribute:email} + ${identity-service.client-id.validation.disabled:true} @@ -158,6 +167,18 @@ ${identity-service.signature-algorithms:RS256,PS256} + + ${identity-service.admin-console.scopes:openid,profile,email,offline_access} + + + ${identity-service.password-grant.scopes:openid,profile,email} + + + ${identity-service.issuer-attribute:issuer} + + + ${identity-service.jwt-clock-skew-ms:0} + @@ -219,4 +240,4 @@ - \ No newline at end of file + diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties index 7357b01644..e6d517c1ad 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties @@ -12,4 +12,11 @@ identity-service.resource=alfresco identity-service.credentials.secret= identity-service.public-client=true identity-service.admin-console.redirect-path=/alfresco/s/admin/admin-communitysummary -identity-service.signature-algorithms=RS256,PS256 \ No newline at end of file +identity-service.signature-algorithms=RS256,PS256 +identity-service.first-name-attribute=given_name +identity-service.last-name-attribute=family_name +identity-service.email-attribute=email +identity-service.admin-console.scopes=openid,profile,email,offline_access +identity-service.password-grant.scopes=openid,profile,email +identity-service.issuer-attribute=issuer +identity-service.jwt-clock-skew-ms=0 diff --git a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java index cd6d76158e..fd5e9136a8 100644 --- a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java +++ b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java @@ -4,27 +4,31 @@ * %% * Copyright (C) 2005 - 2025 Alfresco Software Limited * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is * provided under the following open source license terms: - * + * * Alfresco is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * Alfresco 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 Lesser General Public License for more details. - * + * * You should have received a copy of the GNU Lesser General Public License * along with Alfresco. If not, see . * #L% */ package org.alfresco; +import org.junit.experimental.categories.Categories; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + import org.alfresco.repo.security.authentication.identityservice.ClientRegistrationProviderUnitTest; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBeanTest; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceJITProvisioningHandlerUnitTest; @@ -33,237 +37,236 @@ import org.alfresco.repo.security.authentication.identityservice.SpringBasedIden import org.alfresco.repo.security.authentication.identityservice.admin.AdminConsoleAuthenticationCookiesServiceUnitTest; import org.alfresco.repo.security.authentication.identityservice.admin.AdminConsoleHttpServletRequestWrapperUnitTest; import org.alfresco.repo.security.authentication.identityservice.admin.IdentityServiceAdminConsoleAuthenticatorUnitTest; +import org.alfresco.repo.security.authentication.identityservice.user.AccessTokenToDecodedTokenUserMapperUnitTest; +import org.alfresco.repo.security.authentication.identityservice.user.TokenUserToOIDCUserMapperUnitTest; import org.alfresco.util.testing.category.DBTests; import org.alfresco.util.testing.category.NonBuildTests; -import org.junit.experimental.categories.Categories; -import org.junit.runner.RunWith; -import org.junit.runners.Suite; /** - * All Repository project UNIT test classes (no application context) should be added to this test suite. - * Tests marked as DBTests are automatically excluded and are run as part of {@link AllDBTestsTestSuite}. + * All Repository project UNIT test classes (no application context) should be added to this test suite. Tests marked as DBTests are automatically excluded and are run as part of {@link AllDBTestsTestSuite}. */ @RunWith(Categories.class) @Categories.ExcludeCategory({DBTests.class, NonBuildTests.class}) -@Suite.SuiteClasses({ - org.alfresco.repo.site.SiteMembershipTest.class, - org.alfresco.encryption.EncryptorTest.class, - org.alfresco.encryption.KeyStoreKeyProviderTest.class, - org.alfresco.filesys.config.ServerConfigurationBeanTest.class, - org.alfresco.filesys.repo.rules.ShuffleTest.class, - org.alfresco.opencmis.AlfrescoCmisExceptionInterceptorTest.class, - org.alfresco.repo.admin.Log4JHierarchyInitTest.class, - org.alfresco.repo.attributes.PropTablesCleanupJobTest.class, - org.alfresco.repo.cache.AbstractCacheFactoryTest.class, - org.alfresco.repo.cache.DefaultCacheFactoryTest.class, - org.alfresco.repo.cache.DefaultSimpleCacheTest.class, - org.alfresco.repo.cache.InMemoryCacheStatisticsTest.class, - org.alfresco.repo.cache.TransactionStatsTest.class, - org.alfresco.repo.cache.lookup.EntityLookupCacheTest.class, - org.alfresco.repo.calendar.CalendarHelpersTest.class, - org.alfresco.repo.copy.CopyServiceImplUnitTest.class, - org.alfresco.repo.dictionary.RepoDictionaryDAOTest.class, - org.alfresco.repo.forms.processor.node.FieldProcessorTest.class, - org.alfresco.repo.forms.processor.workflow.TaskFormProcessorTest.class, - org.alfresco.repo.forms.processor.workflow.WorkflowFormProcessorTest.class, - org.alfresco.repo.invitation.site.InviteSenderTest.class, - org.alfresco.repo.invitation.site.InviteModeratedSenderTest.class, - org.alfresco.repo.jscript.ScriptSearchTest.class, - org.alfresco.repo.lock.LockUtilsTest.class, - org.alfresco.repo.lock.mem.LockStoreImplTest.class, - org.alfresco.repo.management.CheckRequiredClassesForLoggingConsoleUnitTest.class, - org.alfresco.repo.management.subsystems.CryptodocSwitchableApplicationContextFactoryTest.class, - org.alfresco.repo.module.ModuleDetailsImplTest.class, - org.alfresco.repo.module.ModuleVersionNumberTest.class, - org.alfresco.repo.module.DeprecatedModulesValidatorTest.class, - org.alfresco.repo.node.integrity.IntegrityEventTest.class, - org.alfresco.repo.policy.MTPolicyComponentTest.class, - org.alfresco.repo.policy.PolicyComponentTest.class, - org.alfresco.repo.rendition.RenditionNodeManagerTest.class, - org.alfresco.repo.rendition.RenditionServiceImplTest.class, - org.alfresco.repo.replication.ReplicationServiceImplTest.class, - org.alfresco.repo.rule.RuleServiceImplUnitTest.class, - org.alfresco.repo.service.StoreRedirectorProxyFactoryTest.class, - org.alfresco.repo.site.RoleComparatorImplTest.class, - org.alfresco.repo.template.UnsafeMethodsTest.class, - org.alfresco.repo.tenant.MultiTAdminServiceImplTest.class, - org.alfresco.repo.thumbnail.ThumbnailServiceImplParameterTest.class, - org.alfresco.repo.transfer.ContentChunkerImplTest.class, - org.alfresco.repo.transfer.HttpClientTransmitterImplTest.class, - org.alfresco.repo.transfer.manifest.TransferManifestTest.class, - org.alfresco.repo.transfer.TransferVersionCheckerImplTest.class, - org.alfresco.service.cmr.calendar.CalendarRecurrenceHelperTest.class, - org.alfresco.service.cmr.calendar.CalendarTimezoneHelperTest.class, - org.alfresco.tools.RenameUserTest.class, - org.alfresco.util.VersionNumberTest.class, - org.alfresco.util.FileNameValidatorTest.class, - org.alfresco.util.HttpClientHelperTest.class, - org.alfresco.util.JSONtoFmModelTest.class, - org.alfresco.util.ModelUtilTest.class, - org.alfresco.util.PropertyMapTest.class, - org.alfresco.util.ValueProtectingMapTest.class, - org.alfresco.util.json.ExceptionJsonSerializerTest.class, - org.alfresco.util.collections.CollectionUtilsTest.class, - org.alfresco.util.schemacomp.DbObjectXMLTransformerTest.class, - org.alfresco.util.schemacomp.DbPropertyTest.class, - org.alfresco.util.schemacomp.DefaultComparisonUtilsTest.class, - org.alfresco.util.schemacomp.DifferenceTest.class, - org.alfresco.util.schemacomp.MultiFileDumperTest.class, - org.alfresco.util.schemacomp.RedundantDbObjectTest.class, - org.alfresco.util.schemacomp.SchemaComparatorTest.class, - org.alfresco.util.schemacomp.SchemaToXMLTest.class, - org.alfresco.util.schemacomp.ValidatingVisitorTest.class, - org.alfresco.util.schemacomp.ValidationResultTest.class, - org.alfresco.util.schemacomp.XMLToSchemaTest.class, - org.alfresco.util.schemacomp.model.ColumnTest.class, - org.alfresco.util.schemacomp.model.ForeignKeyTest.class, - org.alfresco.util.schemacomp.model.IndexTest.class, - org.alfresco.util.schemacomp.model.PrimaryKeyTest.class, - org.alfresco.util.schemacomp.model.SchemaTest.class, - org.alfresco.util.schemacomp.model.SequenceTest.class, - org.alfresco.util.schemacomp.model.TableTest.class, - org.alfresco.util.schemacomp.validator.IndexColumnsValidatorTest.class, - org.alfresco.util.schemacomp.validator.NameValidatorTest.class, - org.alfresco.util.schemacomp.validator.SchemaVersionValidatorTest.class, - org.alfresco.util.schemacomp.validator.TypeNameOnlyValidatorTest.class, - org.alfresco.util.test.OmittedTestClassFinderUnitTest.class, - org.alfresco.util.test.junitrules.RetryAtMostRuleTest.class, - org.alfresco.util.test.junitrules.TemporaryMockOverrideTest.class, - org.alfresco.repo.search.impl.solr.AbstractSolrQueryHTTPClientTest.class, - org.alfresco.repo.search.impl.solr.SpellCheckDecisionManagerTest.class, - org.alfresco.repo.search.impl.solr.SolrStoreMappingWrapperTest.class, - org.alfresco.repo.search.impl.querymodel.impl.db.DBQueryEngineTest.class, - org.alfresco.repo.search.impl.querymodel.impl.db.NodePermissionAssessorLimitsTest.class, - org.alfresco.repo.search.impl.querymodel.impl.db.NodePermissionAssessorPermissionsTest.class, - org.alfresco.repo.search.impl.solr.DbOrIndexSwitchingQueryLanguageTest.class, - org.alfresco.repo.search.impl.solr.SolrQueryHTTPClientTest.class, - org.alfresco.repo.search.impl.solr.SolrSQLHttpClientTest.class, - org.alfresco.repo.search.impl.solr.SolrStatsResultTest.class, - org.alfresco.repo.search.impl.solr.SolrJSONResultTest.class, - org.alfresco.repo.search.impl.solr.SolrSQLJSONResultMetadataSetTest.class, - org.alfresco.repo.search.impl.solr.facet.SolrFacetComparatorTest.class, - org.alfresco.repo.search.impl.solr.facet.FacetQNameUtilsTest.class, - org.alfresco.util.BeanExtenderUnitTest.class, - org.alfresco.repo.solr.SOLRTrackingComponentUnitTest.class, - IdentityServiceFacadeFactoryBeanTest.class, - LazyInstantiatingIdentityServiceFacadeUnitTest.class, - SpringBasedIdentityServiceFacadeUnitTest.class, - IdentityServiceJITProvisioningHandlerUnitTest.class, - AdminConsoleAuthenticationCookiesServiceUnitTest.class, - AdminConsoleHttpServletRequestWrapperUnitTest.class, - IdentityServiceAdminConsoleAuthenticatorUnitTest.class, - ClientRegistrationProviderUnitTest.class, - org.alfresco.repo.security.authentication.CompositePasswordEncoderTest.class, - org.alfresco.repo.security.authentication.PasswordHashingTest.class, - org.alfresco.repo.security.authority.script.ScriptAuthorityService_RegExTest.class, - org.alfresco.repo.security.permissions.PermissionCheckCollectionTest.class, - org.alfresco.repo.security.sync.LDAPUserRegistryTest.class, - org.alfresco.traitextender.TraitExtenderIntegrationTest.class, - org.alfresco.traitextender.AJExtensionsCompileTest.class, +@Suite.SuiteClasses(value = { + org.alfresco.repo.site.SiteMembershipTest.class, + org.alfresco.encryption.EncryptorTest.class, + org.alfresco.encryption.KeyStoreKeyProviderTest.class, + org.alfresco.filesys.config.ServerConfigurationBeanTest.class, + org.alfresco.filesys.repo.rules.ShuffleTest.class, + org.alfresco.opencmis.AlfrescoCmisExceptionInterceptorTest.class, + org.alfresco.repo.admin.Log4JHierarchyInitTest.class, + org.alfresco.repo.attributes.PropTablesCleanupJobTest.class, + org.alfresco.repo.cache.AbstractCacheFactoryTest.class, + org.alfresco.repo.cache.DefaultCacheFactoryTest.class, + org.alfresco.repo.cache.DefaultSimpleCacheTest.class, + org.alfresco.repo.cache.InMemoryCacheStatisticsTest.class, + org.alfresco.repo.cache.TransactionStatsTest.class, + org.alfresco.repo.cache.lookup.EntityLookupCacheTest.class, + org.alfresco.repo.calendar.CalendarHelpersTest.class, + org.alfresco.repo.copy.CopyServiceImplUnitTest.class, + org.alfresco.repo.dictionary.RepoDictionaryDAOTest.class, + org.alfresco.repo.forms.processor.node.FieldProcessorTest.class, + org.alfresco.repo.forms.processor.workflow.TaskFormProcessorTest.class, + org.alfresco.repo.forms.processor.workflow.WorkflowFormProcessorTest.class, + org.alfresco.repo.invitation.site.InviteSenderTest.class, + org.alfresco.repo.invitation.site.InviteModeratedSenderTest.class, + org.alfresco.repo.jscript.ScriptSearchTest.class, + org.alfresco.repo.lock.LockUtilsTest.class, + org.alfresco.repo.lock.mem.LockStoreImplTest.class, + org.alfresco.repo.management.CheckRequiredClassesForLoggingConsoleUnitTest.class, + org.alfresco.repo.management.subsystems.CryptodocSwitchableApplicationContextFactoryTest.class, + org.alfresco.repo.module.ModuleDetailsImplTest.class, + org.alfresco.repo.module.ModuleVersionNumberTest.class, + org.alfresco.repo.module.DeprecatedModulesValidatorTest.class, + org.alfresco.repo.node.integrity.IntegrityEventTest.class, + org.alfresco.repo.policy.MTPolicyComponentTest.class, + org.alfresco.repo.policy.PolicyComponentTest.class, + org.alfresco.repo.rendition.RenditionNodeManagerTest.class, + org.alfresco.repo.rendition.RenditionServiceImplTest.class, + org.alfresco.repo.replication.ReplicationServiceImplTest.class, + org.alfresco.repo.rule.RuleServiceImplUnitTest.class, + org.alfresco.repo.service.StoreRedirectorProxyFactoryTest.class, + org.alfresco.repo.site.RoleComparatorImplTest.class, + org.alfresco.repo.template.UnsafeMethodsTest.class, + org.alfresco.repo.tenant.MultiTAdminServiceImplTest.class, + org.alfresco.repo.thumbnail.ThumbnailServiceImplParameterTest.class, + org.alfresco.repo.transfer.ContentChunkerImplTest.class, + org.alfresco.repo.transfer.HttpClientTransmitterImplTest.class, + org.alfresco.repo.transfer.manifest.TransferManifestTest.class, + org.alfresco.repo.transfer.TransferVersionCheckerImplTest.class, + org.alfresco.service.cmr.calendar.CalendarRecurrenceHelperTest.class, + org.alfresco.service.cmr.calendar.CalendarTimezoneHelperTest.class, + org.alfresco.tools.RenameUserTest.class, + org.alfresco.util.VersionNumberTest.class, + org.alfresco.util.FileNameValidatorTest.class, + org.alfresco.util.HttpClientHelperTest.class, + org.alfresco.util.JSONtoFmModelTest.class, + org.alfresco.util.ModelUtilTest.class, + org.alfresco.util.PropertyMapTest.class, + org.alfresco.util.ValueProtectingMapTest.class, + org.alfresco.util.json.ExceptionJsonSerializerTest.class, + org.alfresco.util.collections.CollectionUtilsTest.class, + org.alfresco.util.schemacomp.DbObjectXMLTransformerTest.class, + org.alfresco.util.schemacomp.DbPropertyTest.class, + org.alfresco.util.schemacomp.DefaultComparisonUtilsTest.class, + org.alfresco.util.schemacomp.DifferenceTest.class, + org.alfresco.util.schemacomp.MultiFileDumperTest.class, + org.alfresco.util.schemacomp.RedundantDbObjectTest.class, + org.alfresco.util.schemacomp.SchemaComparatorTest.class, + org.alfresco.util.schemacomp.SchemaToXMLTest.class, + org.alfresco.util.schemacomp.ValidatingVisitorTest.class, + org.alfresco.util.schemacomp.ValidationResultTest.class, + org.alfresco.util.schemacomp.XMLToSchemaTest.class, + org.alfresco.util.schemacomp.model.ColumnTest.class, + org.alfresco.util.schemacomp.model.ForeignKeyTest.class, + org.alfresco.util.schemacomp.model.IndexTest.class, + org.alfresco.util.schemacomp.model.PrimaryKeyTest.class, + org.alfresco.util.schemacomp.model.SchemaTest.class, + org.alfresco.util.schemacomp.model.SequenceTest.class, + org.alfresco.util.schemacomp.model.TableTest.class, + org.alfresco.util.schemacomp.validator.IndexColumnsValidatorTest.class, + org.alfresco.util.schemacomp.validator.NameValidatorTest.class, + org.alfresco.util.schemacomp.validator.SchemaVersionValidatorTest.class, + org.alfresco.util.schemacomp.validator.TypeNameOnlyValidatorTest.class, + org.alfresco.util.test.OmittedTestClassFinderUnitTest.class, + org.alfresco.util.test.junitrules.RetryAtMostRuleTest.class, + org.alfresco.util.test.junitrules.TemporaryMockOverrideTest.class, + org.alfresco.repo.search.impl.solr.AbstractSolrQueryHTTPClientTest.class, + org.alfresco.repo.search.impl.solr.SpellCheckDecisionManagerTest.class, + org.alfresco.repo.search.impl.solr.SolrStoreMappingWrapperTest.class, + org.alfresco.repo.search.impl.querymodel.impl.db.DBQueryEngineTest.class, + org.alfresco.repo.search.impl.querymodel.impl.db.NodePermissionAssessorLimitsTest.class, + org.alfresco.repo.search.impl.querymodel.impl.db.NodePermissionAssessorPermissionsTest.class, + org.alfresco.repo.search.impl.solr.DbOrIndexSwitchingQueryLanguageTest.class, + org.alfresco.repo.search.impl.solr.SolrQueryHTTPClientTest.class, + org.alfresco.repo.search.impl.solr.SolrSQLHttpClientTest.class, + org.alfresco.repo.search.impl.solr.SolrStatsResultTest.class, + org.alfresco.repo.search.impl.solr.SolrJSONResultTest.class, + org.alfresco.repo.search.impl.solr.SolrSQLJSONResultMetadataSetTest.class, + org.alfresco.repo.search.impl.solr.facet.SolrFacetComparatorTest.class, + org.alfresco.repo.search.impl.solr.facet.FacetQNameUtilsTest.class, + org.alfresco.util.BeanExtenderUnitTest.class, + org.alfresco.repo.solr.SOLRTrackingComponentUnitTest.class, + IdentityServiceFacadeFactoryBeanTest.class, + LazyInstantiatingIdentityServiceFacadeUnitTest.class, + SpringBasedIdentityServiceFacadeUnitTest.class, + IdentityServiceJITProvisioningHandlerUnitTest.class, + AccessTokenToDecodedTokenUserMapperUnitTest.class, + TokenUserToOIDCUserMapperUnitTest.class, + AdminConsoleAuthenticationCookiesServiceUnitTest.class, + AdminConsoleHttpServletRequestWrapperUnitTest.class, + IdentityServiceAdminConsoleAuthenticatorUnitTest.class, + ClientRegistrationProviderUnitTest.class, + org.alfresco.repo.security.authentication.CompositePasswordEncoderTest.class, + org.alfresco.repo.security.authentication.PasswordHashingTest.class, + org.alfresco.repo.security.authority.script.ScriptAuthorityService_RegExTest.class, + org.alfresco.repo.security.permissions.PermissionCheckCollectionTest.class, + org.alfresco.repo.security.sync.LDAPUserRegistryTest.class, + org.alfresco.traitextender.TraitExtenderIntegrationTest.class, + org.alfresco.traitextender.AJExtensionsCompileTest.class, - org.alfresco.repo.virtual.page.PageCollatorTest.class, - org.alfresco.repo.virtual.ref.GetChildByIdMethodTest.class, - org.alfresco.repo.virtual.ref.GetParentReferenceMethodTest.class, - org.alfresco.repo.virtual.ref.NewVirtualReferenceMethodTest.class, - org.alfresco.repo.virtual.ref.PlainReferenceParserTest.class, - org.alfresco.repo.virtual.ref.PlainStringifierTest.class, - org.alfresco.repo.virtual.ref.ProtocolTest.class, - org.alfresco.repo.virtual.ref.ReferenceTest.class, - org.alfresco.repo.virtual.ref.ResourceParameterTest.class, - org.alfresco.repo.virtual.ref.StringParameterTest.class, - org.alfresco.repo.virtual.ref.VirtualProtocolTest.class, - org.alfresco.repo.virtual.store.ReferenceComparatorTest.class, + org.alfresco.repo.virtual.page.PageCollatorTest.class, + org.alfresco.repo.virtual.ref.GetChildByIdMethodTest.class, + org.alfresco.repo.virtual.ref.GetParentReferenceMethodTest.class, + org.alfresco.repo.virtual.ref.NewVirtualReferenceMethodTest.class, + org.alfresco.repo.virtual.ref.PlainReferenceParserTest.class, + org.alfresco.repo.virtual.ref.PlainStringifierTest.class, + org.alfresco.repo.virtual.ref.ProtocolTest.class, + org.alfresco.repo.virtual.ref.ReferenceTest.class, + org.alfresco.repo.virtual.ref.ResourceParameterTest.class, + org.alfresco.repo.virtual.ref.StringParameterTest.class, + org.alfresco.repo.virtual.ref.VirtualProtocolTest.class, + org.alfresco.repo.virtual.store.ReferenceComparatorTest.class, - org.alfresco.repo.virtual.ref.ZeroReferenceParserTest.class, - org.alfresco.repo.virtual.ref.ZeroStringifierTest.class, + org.alfresco.repo.virtual.ref.ZeroReferenceParserTest.class, + org.alfresco.repo.virtual.ref.ZeroStringifierTest.class, - org.alfresco.repo.virtual.ref.HashStringifierTest.class, - org.alfresco.repo.virtual.ref.NodeRefRadixHasherTest.class, - org.alfresco.repo.virtual.ref.NumericPathHasherTest.class, - org.alfresco.repo.virtual.ref.StoredPathHasherTest.class, + org.alfresco.repo.virtual.ref.HashStringifierTest.class, + org.alfresco.repo.virtual.ref.NodeRefRadixHasherTest.class, + org.alfresco.repo.virtual.ref.NumericPathHasherTest.class, + org.alfresco.repo.virtual.ref.StoredPathHasherTest.class, - org.alfresco.repo.virtual.template.VirtualQueryImplTest.class, - org.alfresco.repo.virtual.store.TypeVirtualizationMethodUnitTest.class, + org.alfresco.repo.virtual.template.VirtualQueryImplTest.class, + org.alfresco.repo.virtual.store.TypeVirtualizationMethodUnitTest.class, - org.alfresco.repo.security.authentication.AuthenticationServiceImplTest.class, - org.alfresco.util.EmailHelperTest.class, - org.alfresco.repo.action.ParameterDefinitionImplTest.class, - org.alfresco.repo.action.ActionDefinitionImplTest.class, - org.alfresco.repo.action.ActionConditionDefinitionImplTest.class, - org.alfresco.repo.action.ActionImplTest.class, - org.alfresco.repo.action.ActionConditionImplTest.class, - org.alfresco.repo.action.CompositeActionImplTest.class, - org.alfresco.repo.action.CompositeActionConditionImplTest.class, - org.alfresco.repo.action.executer.TransformActionExecuterTest.class, - org.alfresco.repo.action.executer.ImporterActionExecutorUnitTest.class, - org.alfresco.repo.audit.AuditableAnnotationTest.class, - org.alfresco.repo.audit.PropertyAuditFilterTest.class, - org.alfresco.repo.audit.access.NodeChangeTest.class, - org.alfresco.repo.content.ContentServiceImplUnitTest.class, - org.alfresco.repo.content.directurl.SystemWideDirectUrlConfigUnitTest.class, - org.alfresco.repo.content.directurl.ContentStoreDirectUrlConfigUnitTest.class, - org.alfresco.repo.content.LimitedStreamCopierTest.class, - org.alfresco.repo.content.filestore.FileIOTest.class, - org.alfresco.repo.content.filestore.SpoofedTextContentReaderTest.class, - org.alfresco.repo.content.ContentDataTest.class, - org.alfresco.repo.content.replication.AggregatingContentStoreUnitTest.class, - org.alfresco.service.cmr.repository.TransformationOptionLimitsTest.class, - org.alfresco.service.cmr.repository.TransformationOptionPairTest.class, - org.alfresco.repo.content.transform.TransformerConfigTestSuite.class, - org.alfresco.repo.content.transform.TransformerDebugTest.class, - org.alfresco.service.cmr.repository.TemporalSourceOptionsTest.class, - org.alfresco.repo.content.metadata.MetadataExtracterLimitsTest.class, - org.alfresco.repo.content.caching.quota.StandardQuotaStrategyMockTest.class, - org.alfresco.repo.content.caching.quota.UnlimitedQuotaStrategyTest.class, - org.alfresco.repo.content.caching.CachingContentStoreTest.class, - org.alfresco.repo.content.caching.ContentCacheImplTest.class, - org.alfresco.repo.domain.permissions.FixedAclUpdaterUnitTest.class, - org.alfresco.repo.domain.propval.PropertyTypeConverterTest.class, - org.alfresco.repo.domain.schema.script.ScriptBundleExecutorImplTest.class, - org.alfresco.repo.search.MLAnaysisModeExpansionTest.class, - org.alfresco.repo.search.DocumentNavigatorTest.class, - org.alfresco.util.NumericEncodingTest.class, - org.alfresco.repo.search.impl.parsers.CMIS_FTSTest.class, - org.alfresco.repo.search.impl.parsers.CMISTest.class, - org.alfresco.repo.search.impl.parsers.FTSTest.class, - org.alfresco.repo.security.authentication.AlfrescoSSLSocketFactoryTest.class, - org.alfresco.repo.security.authentication.AuthorizationTest.class, - org.alfresco.repo.security.permissions.PermissionCheckedCollectionTest.class, - org.alfresco.repo.security.permissions.impl.acegi.FilteringResultSetTest.class, - org.alfresco.repo.security.permissions.impl.acegi.ACLEntryVoterUtilsTest.class, - org.alfresco.repo.security.authentication.ChainingAuthenticationServiceTest.class, - org.alfresco.repo.security.authentication.NameBasedUserNameGeneratorTest.class, - org.alfresco.repo.version.common.VersionImplTest.class, - org.alfresco.repo.version.common.VersionHistoryImplTest.class, - org.alfresco.repo.version.common.versionlabel.SerialVersionLabelPolicyTest.class, - org.alfresco.repo.workflow.activiti.WorklfowObjectFactoryTest.class, - org.alfresco.repo.workflow.activiti.properties.ActivitiPriorityPropertyHandlerTest.class, - org.alfresco.repo.workflow.WorkflowSuiteContextShutdownTest.class, - org.alfresco.repo.search.LuceneUtilsTest.class, + org.alfresco.repo.security.authentication.AuthenticationServiceImplTest.class, + org.alfresco.util.EmailHelperTest.class, + org.alfresco.repo.action.ParameterDefinitionImplTest.class, + org.alfresco.repo.action.ActionDefinitionImplTest.class, + org.alfresco.repo.action.ActionConditionDefinitionImplTest.class, + org.alfresco.repo.action.ActionImplTest.class, + org.alfresco.repo.action.ActionConditionImplTest.class, + org.alfresco.repo.action.CompositeActionImplTest.class, + org.alfresco.repo.action.CompositeActionConditionImplTest.class, + org.alfresco.repo.action.executer.TransformActionExecuterTest.class, + org.alfresco.repo.action.executer.ImporterActionExecutorUnitTest.class, + org.alfresco.repo.audit.AuditableAnnotationTest.class, + org.alfresco.repo.audit.PropertyAuditFilterTest.class, + org.alfresco.repo.audit.access.NodeChangeTest.class, + org.alfresco.repo.content.ContentServiceImplUnitTest.class, + org.alfresco.repo.content.directurl.SystemWideDirectUrlConfigUnitTest.class, + org.alfresco.repo.content.directurl.ContentStoreDirectUrlConfigUnitTest.class, + org.alfresco.repo.content.LimitedStreamCopierTest.class, + org.alfresco.repo.content.filestore.FileIOTest.class, + org.alfresco.repo.content.filestore.SpoofedTextContentReaderTest.class, + org.alfresco.repo.content.ContentDataTest.class, + org.alfresco.repo.content.replication.AggregatingContentStoreUnitTest.class, + org.alfresco.service.cmr.repository.TransformationOptionLimitsTest.class, + org.alfresco.service.cmr.repository.TransformationOptionPairTest.class, + org.alfresco.repo.content.transform.TransformerConfigTestSuite.class, + org.alfresco.repo.content.transform.TransformerDebugTest.class, + org.alfresco.service.cmr.repository.TemporalSourceOptionsTest.class, + org.alfresco.repo.content.metadata.MetadataExtracterLimitsTest.class, + org.alfresco.repo.content.caching.quota.StandardQuotaStrategyMockTest.class, + org.alfresco.repo.content.caching.quota.UnlimitedQuotaStrategyTest.class, + org.alfresco.repo.content.caching.CachingContentStoreTest.class, + org.alfresco.repo.content.caching.ContentCacheImplTest.class, + org.alfresco.repo.domain.permissions.FixedAclUpdaterUnitTest.class, + org.alfresco.repo.domain.propval.PropertyTypeConverterTest.class, + org.alfresco.repo.domain.schema.script.ScriptBundleExecutorImplTest.class, + org.alfresco.repo.search.MLAnaysisModeExpansionTest.class, + org.alfresco.repo.search.DocumentNavigatorTest.class, + org.alfresco.util.NumericEncodingTest.class, + org.alfresco.repo.search.impl.parsers.CMIS_FTSTest.class, + org.alfresco.repo.search.impl.parsers.CMISTest.class, + org.alfresco.repo.search.impl.parsers.FTSTest.class, + org.alfresco.repo.security.authentication.AlfrescoSSLSocketFactoryTest.class, + org.alfresco.repo.security.authentication.AuthorizationTest.class, + org.alfresco.repo.security.permissions.PermissionCheckedCollectionTest.class, + org.alfresco.repo.security.permissions.impl.acegi.FilteringResultSetTest.class, + org.alfresco.repo.security.permissions.impl.acegi.ACLEntryVoterUtilsTest.class, + org.alfresco.repo.security.authentication.ChainingAuthenticationServiceTest.class, + org.alfresco.repo.security.authentication.NameBasedUserNameGeneratorTest.class, + org.alfresco.repo.version.common.VersionImplTest.class, + org.alfresco.repo.version.common.VersionHistoryImplTest.class, + org.alfresco.repo.version.common.versionlabel.SerialVersionLabelPolicyTest.class, + org.alfresco.repo.workflow.activiti.WorklfowObjectFactoryTest.class, + org.alfresco.repo.workflow.activiti.properties.ActivitiPriorityPropertyHandlerTest.class, + org.alfresco.repo.workflow.WorkflowSuiteContextShutdownTest.class, + org.alfresco.repo.search.LuceneUtilsTest.class, - org.alfresco.heartbeat.HBDataCollectorServiceImplTest.class, - org.alfresco.heartbeat.jobs.LockingJobTest.class, - org.alfresco.heartbeat.jobs.QuartzJobSchedulerTest.class, - org.alfresco.heartbeat.AuthoritiesDataCollectorTest.class, - org.alfresco.heartbeat.ConfigurationDataCollectorTest.class, - org.alfresco.heartbeat.InfoDataCollectorTest.class, - org.alfresco.heartbeat.ModelUsageDataCollectorTest.class, - org.alfresco.heartbeat.SessionsUsageDataCollectorTest.class, - org.alfresco.heartbeat.SystemUsageDataCollectorTest.class, + org.alfresco.heartbeat.HBDataCollectorServiceImplTest.class, + org.alfresco.heartbeat.jobs.LockingJobTest.class, + org.alfresco.heartbeat.jobs.QuartzJobSchedulerTest.class, + org.alfresco.heartbeat.AuthoritiesDataCollectorTest.class, + org.alfresco.heartbeat.ConfigurationDataCollectorTest.class, + org.alfresco.heartbeat.InfoDataCollectorTest.class, + org.alfresco.heartbeat.ModelUsageDataCollectorTest.class, + org.alfresco.heartbeat.SessionsUsageDataCollectorTest.class, + org.alfresco.heartbeat.SystemUsageDataCollectorTest.class, - org.alfresco.util.BeanExtenderUnitTest.class, - org.alfresco.util.bean.HierarchicalBeanLoaderTest.class, - org.alfresco.util.resource.HierarchicalResourceLoaderTest.class, - org.alfresco.repo.events.ClientUtilTest.class, - org.alfresco.repo.rendition2.RenditionService2Test.class, - org.alfresco.repo.rendition2.TransformationOptionsConverterTest.class, + org.alfresco.util.BeanExtenderUnitTest.class, + org.alfresco.util.bean.HierarchicalBeanLoaderTest.class, + org.alfresco.util.resource.HierarchicalResourceLoaderTest.class, + org.alfresco.repo.events.ClientUtilTest.class, + org.alfresco.repo.rendition2.RenditionService2Test.class, + org.alfresco.repo.rendition2.TransformationOptionsConverterTest.class, - org.alfresco.repo.event2.RepoEvent2UnitSuite.class, + org.alfresco.repo.event2.RepoEvent2UnitSuite.class, - org.alfresco.util.schemacomp.SchemaDifferenceHelperUnitTest.class, - org.alfresco.repo.tagging.TaggingServiceImplUnitTest.class, - org.alfresco.repo.serviceaccount.ServiceAccountRegistryImplTest.class + org.alfresco.util.schemacomp.SchemaDifferenceHelperUnitTest.class, + org.alfresco.repo.tagging.TaggingServiceImplUnitTest.class, + org.alfresco.repo.serviceaccount.ServiceAccountRegistryImplTest.class }) public class AllUnitTestsSuite -{ -} \ No newline at end of file +{} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java index 40357e5ab7..4efd45cfb0 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java @@ -38,8 +38,7 @@ import java.util.Set; import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; - -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.ClientRegistrationProvider; +import net.minidev.json.JSONObject; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -51,12 +50,17 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.web.client.RestTemplate; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.ClientRegistrationProvider; + public class ClientRegistrationProviderUnitTest { private static final String CLIENT_ID = "alfresco"; private static final String OPENID_CONFIGURATION = "{\"token_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/token\",\"token_endpoint_auth_methods_supported\":[\"client_secret_post\",\"private_key_jwt\",\"client_secret_basic\"],\"jwks_uri\":\"https://login.serviceonline.alfresco/common/discovery/v2.0/keys\",\"response_modes_supported\":[\"query\",\"fragment\",\"form_post\"],\"subject_types_supported\":[\"pairwise\"],\"id_token_signing_alg_values_supported\":[\"RS256\"],\"response_types_supported\":[\"code\",\"id_token\",\"code id_token\",\"id_token token\"],\"scopes_supported\":[\"openid\",\"profile\",\"email\",\"offline_access\"],\"issuer\":\"https://login.serviceonline.alfresco/alfresco/v2.0\",\"request_uri_parameter_supported\":false,\"userinfo_endpoint\":\"https://graph.service.alfresco/oidc/userinfo\",\"authorization_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/authorize\",\"device_authorization_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/devicecode\",\"http_logout_supported\":true,\"frontchannel_logout_supported\":true,\"end_session_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/logout\",\"claims_supported\":[\"sub\",\"iss\",\"cloud_instance_name\",\"cloud_instance_host_name\",\"cloud_graph_host_name\",\"msgraph_host\",\"aud\",\"exp\",\"iat\",\"auth_time\",\"acr\",\"nonce\",\"preferred_username\",\"name\",\"tid\",\"ver\",\"at_hash\",\"c_hash\",\"email\"],\"kerberos_endpoint\":\"https://login.serviceonline.alfresco/common/kerberos\",\"tenant_region_scope\":null,\"cloud_instance_name\":\"serviceonline.alfresco\",\"cloud_graph_host_name\":\"graph.oidc.net\",\"msgraph_host\":\"graph.service.alfresco\",\"rbac_url\":\"https://pas.oidc.alfresco\"}"; private static final String DISCOVERY_PATH_SEGMENTS = "/.well-known/openid-configuration"; private static final String AUTH_SERVER = "https://login.serviceonline.alfresco"; + private static final String ADMIN_CONSOLE_SCOPES = "openid,email,profile,offline_access"; + private static final String PSSWD_GRANT_SCOPES = "openid,email,profile"; + private static final String ISSUER_ATRR = "issuer"; private IdentityServiceConfig config; private RestTemplate restTemplate; @@ -70,6 +74,9 @@ public class ClientRegistrationProviderUnitTest config = new IdentityServiceConfig(); config.setAuthServerUrl(AUTH_SERVER); config.setResource(CLIENT_ID); + config.setAdminConsoleScopes(ADMIN_CONSOLE_SCOPES); + config.setPasswordGrantScopes(PSSWD_GRANT_SCOPES); + config.setIssuerAttribute(ISSUER_ATRR); restTemplate = mock(RestTemplate.class); ResponseEntity responseEntity = mock(ResponseEntity.class); @@ -90,7 +97,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( - restTemplate); + restTemplate); assertThat(clientRegistration).isNotNull(); assertThat(clientRegistration.getClientId()).isNotNull(); assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()).isNotNull(); @@ -99,7 +106,7 @@ public class ClientRegistrationProviderUnitTest assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint()).isNotNull(); assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isNotNull(); assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( - AUTH_SERVER + DISCOVERY_PATH_SEGMENTS); + AUTH_SERVER + DISCOVERY_PATH_SEGMENTS); } } @@ -112,7 +119,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( - restTemplate); + restTemplate); assertThat(clientRegistration).isNotNull(); assertThat(clientRegistration.getClientId()).isNotNull(); assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()).isNotNull(); @@ -121,7 +128,7 @@ public class ClientRegistrationProviderUnitTest assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint()).isNotNull(); assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isNotNull(); assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( - AUTH_SERVER + DISCOVERY_PATH_SEGMENTS); + AUTH_SERVER + DISCOVERY_PATH_SEGMENTS); } } @@ -134,7 +141,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); assertThrows(IdentityServiceException.class, - () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); } } @@ -148,7 +155,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); assertThrows(IdentityServiceException.class, - () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); } } @@ -161,7 +168,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); assertThrows(IdentityServiceException.class, - () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); } } @@ -174,7 +181,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); assertThrows(IdentityServiceException.class, - () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); } } @@ -187,7 +194,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); assertThrows(IdentityServiceException.class, - () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); } } @@ -200,7 +207,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); assertThrows(IdentityServiceException.class, - () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); } } @@ -215,7 +222,7 @@ public class ClientRegistrationProviderUnitTest new ClientRegistrationProvider(config).createClientRegistration(restTemplate); assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( - AUTH_SERVER + "/realms/alfresco" + DISCOVERY_PATH_SEGMENTS); + AUTH_SERVER + "/realms/alfresco" + DISCOVERY_PATH_SEGMENTS); } } @@ -227,10 +234,10 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( - restTemplate); + restTemplate); assertThat( - clientRegistration.getScopes().containsAll( - Set.of("openid", "profile", "email"))).isTrue(); + clientRegistration.getScopes().containsAll( + Set.of("openid", "profile", "email"))).isTrue(); } } @@ -243,7 +250,7 @@ public class ClientRegistrationProviderUnitTest providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( - restTemplate); + restTemplate); assertThat(clientRegistration.getScopes().size()).isEqualTo(1); assertThat(clientRegistration.getScopes().stream().findFirst().get()).isEqualTo("openid"); } @@ -260,7 +267,45 @@ public class ClientRegistrationProviderUnitTest new ClientRegistrationProvider(config).createClientRegistration(restTemplate); assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( - "https://login.serviceonline.alfresco/alfresco/v2.0" + DISCOVERY_PATH_SEGMENTS); + "https://login.serviceonline.alfresco/alfresco/v2.0" + DISCOVERY_PATH_SEGMENTS); } } -} \ No newline at end of file + + @Test + public void shouldUseDefaultIssuerAttribute() + { + config.setIssuerUrl(null); + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( + restTemplate); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isEqualTo("https://login.serviceonline.alfresco/alfresco/v2.0"); + + } + } + + @Test + public void shouldUseCustomIssuerAttribute() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + config.setIssuerAttribute("access_token_issuer"); + when(oidcResponse.getCustomParameters()).thenReturn(createJSONObject("access_token_issuer", "https://login.serviceonline.alfresco/alfresco/v2.0/at_trust")); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( + restTemplate); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isEqualTo("https://login.serviceonline.alfresco/alfresco/v2.0/at_trust"); + + } + } + + private static JSONObject createJSONObject(String fieldName, String fieldValue) + { + JSONObject jsonObject = new JSONObject(); + jsonObject.appendField(fieldName, fieldValue); + return jsonObject; + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java index 5ca4b96162..2833b364c5 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java @@ -1,172 +1,173 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.net.ConnectException; -import java.util.Optional; - -import org.alfresco.error.ExceptionStackUtil; -import org.alfresco.repo.security.authentication.AuthenticationContext; -import org.alfresco.repo.security.authentication.AuthenticationException; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; -import org.alfresco.repo.security.sync.UserRegistrySynchronizer; -import org.alfresco.service.cmr.repository.NodeService; -import org.alfresco.service.cmr.security.PersonService; -import org.alfresco.service.transaction.TransactionService; -import org.alfresco.util.BaseSpringTest; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; - -public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest -{ - private final IdentityServiceAuthenticationComponent authComponent = new IdentityServiceAuthenticationComponent(); - - @Autowired - private AuthenticationContext authenticationContext; - - @Autowired - private TransactionService transactionService; - - @Autowired - private UserRegistrySynchronizer userRegistrySynchronizer; - - @Autowired - private NodeService nodeService; - - @Autowired - private PersonService personService; - - - private IdentityServiceJITProvisioningHandler jitProvisioning; - private IdentityServiceFacade mockIdentityServiceFacade; - - @Before - public void setUp() - { - authComponent.setAuthenticationContext(authenticationContext); - authComponent.setTransactionService(transactionService); - authComponent.setUserRegistrySynchronizer(userRegistrySynchronizer); - authComponent.setNodeService(nodeService); - authComponent.setPersonService(personService); - - jitProvisioning = mock(IdentityServiceJITProvisioningHandler.class); - mockIdentityServiceFacade = mock(IdentityServiceFacade.class); - authComponent.setJitProvisioningHandler(jitProvisioning); - authComponent.setIdentityServiceFacade(mockIdentityServiceFacade); - } - - @After - public void tearDown() - { - authenticationContext.clearCurrentSecurityContext(); - } - - @Test (expected=AuthenticationException.class) - public void testAuthenticationFail() - { - final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); - - doThrow(new AuthorizationException("Failed")).when(mockIdentityServiceFacade).authorize(grant); - - authComponent.authenticateImpl("username", "password".toCharArray()); - } - - @Test(expected = AuthenticationException.class) - public void testAuthenticationFail_connectionException() - { - final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); - - doThrow(new AuthorizationException("Couldn't connect to server", new ConnectException("ConnectionRefused"))) - .when(mockIdentityServiceFacade).authorize(grant); - - try - { - authComponent.authenticateImpl("username", "password".toCharArray()); - } - catch (RuntimeException ex) - { - Throwable cause = ExceptionStackUtil.getCause(ex, ConnectException.class); - assertNotNull(cause); - throw ex; - } - } - - @Test (expected=AuthenticationException.class) - public void testAuthenticationFail_otherException() - { - final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); - - doThrow(new RuntimeException("Some other errors!")) - .when(mockIdentityServiceFacade) - .authorize(grant); - - authComponent.authenticateImpl("username", "password".toCharArray()); - } - - @Test - public void testAuthenticationPass() - { - final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); - AccessTokenAuthorization authorization = mock(AccessTokenAuthorization.class); - IdentityServiceFacade.AccessToken accessToken = mock(IdentityServiceFacade.AccessToken.class); - - when(authorization.getAccessToken()).thenReturn(accessToken); - when(accessToken.getTokenValue()).thenReturn("JWT_TOKEN"); - when(mockIdentityServiceFacade.authorize(grant)).thenReturn(authorization); - when(jitProvisioning.extractUserInfoAndCreateUserIfNeeded("JWT_TOKEN")) - .thenReturn(Optional.of(new OIDCUserInfo("username", "", "", ""))); - - authComponent.authenticateImpl("username", "password".toCharArray()); - - // Check that the authenticated user has been set - assertEquals("User has not been set as expected.","username", authenticationContext.getCurrentUserName()); - } - - @Test (expected= AuthenticationException.class) - public void testFallthroughWhenIdentityServiceFacadeIsNull() - { - authComponent.setIdentityServiceFacade(null); - authComponent.authenticateImpl("username", "password".toCharArray()); - } - - @Test - public void testSettingAllowGuestUser() - { - authComponent.setAllowGuestLogin(true); - assertTrue(authComponent.guestUserAuthenticationAllowed()); - - authComponent.setAllowGuestLogin(false); - assertFalse(authComponent.guestUserAuthenticationAllowed()); - } -} +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.ConnectException; +import java.util.Optional; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import org.alfresco.error.ExceptionStackUtil; +import org.alfresco.repo.security.authentication.AuthenticationContext; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.sync.UserRegistrySynchronizer; +import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.security.PersonService; +import org.alfresco.service.transaction.TransactionService; +import org.alfresco.util.BaseSpringTest; + +public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest +{ + private final IdentityServiceAuthenticationComponent authComponent = new IdentityServiceAuthenticationComponent(); + + @Autowired + private AuthenticationContext authenticationContext; + + @Autowired + private TransactionService transactionService; + + @Autowired + private UserRegistrySynchronizer userRegistrySynchronizer; + + @Autowired + private NodeService nodeService; + + @Autowired + private PersonService personService; + + private IdentityServiceJITProvisioningHandler jitProvisioning; + private IdentityServiceFacade mockIdentityServiceFacade; + + @Before + public void setUp() + { + authComponent.setAuthenticationContext(authenticationContext); + authComponent.setTransactionService(transactionService); + authComponent.setUserRegistrySynchronizer(userRegistrySynchronizer); + authComponent.setNodeService(nodeService); + authComponent.setPersonService(personService); + + jitProvisioning = mock(IdentityServiceJITProvisioningHandler.class); + mockIdentityServiceFacade = mock(IdentityServiceFacade.class); + authComponent.setJitProvisioningHandler(jitProvisioning); + authComponent.setIdentityServiceFacade(mockIdentityServiceFacade); + } + + @After + public void tearDown() + { + authenticationContext.clearCurrentSecurityContext(); + } + + @Test(expected = AuthenticationException.class) + public void testAuthenticationFail() + { + final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); + + doThrow(new AuthorizationException("Failed")).when(mockIdentityServiceFacade).authorize(grant); + + authComponent.authenticateImpl("username", "password".toCharArray()); + } + + @Test(expected = AuthenticationException.class) + public void testAuthenticationFail_connectionException() + { + final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); + + doThrow(new AuthorizationException("Couldn't connect to server", new ConnectException("ConnectionRefused"))) + .when(mockIdentityServiceFacade).authorize(grant); + + try + { + authComponent.authenticateImpl("username", "password".toCharArray()); + } + catch (RuntimeException ex) + { + Throwable cause = ExceptionStackUtil.getCause(ex, ConnectException.class); + assertNotNull(cause); + throw ex; + } + } + + @Test(expected = AuthenticationException.class) + public void testAuthenticationFail_otherException() + { + final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); + + doThrow(new RuntimeException("Some other errors!")) + .when(mockIdentityServiceFacade) + .authorize(grant); + + authComponent.authenticateImpl("username", "password".toCharArray()); + } + + @Test + public void testAuthenticationPass() + { + final AuthorizationGrant grant = AuthorizationGrant.password("username", "password"); + AccessTokenAuthorization authorization = mock(AccessTokenAuthorization.class); + IdentityServiceFacade.AccessToken accessToken = mock(IdentityServiceFacade.AccessToken.class); + + when(authorization.getAccessToken()).thenReturn(accessToken); + when(accessToken.getTokenValue()).thenReturn("JWT_TOKEN"); + when(mockIdentityServiceFacade.authorize(grant)).thenReturn(authorization); + when(jitProvisioning.extractUserInfoAndCreateUserIfNeeded("JWT_TOKEN")) + .thenReturn(Optional.of(new OIDCUserInfo("username", "", "", ""))); + + authComponent.authenticateImpl("username", "password".toCharArray()); + + // Check that the authenticated user has been set + assertEquals("User has not been set as expected.", "username", authenticationContext.getCurrentUserName()); + } + + @Test(expected = AuthenticationException.class) + public void testFallthroughWhenIdentityServiceFacadeIsNull() + { + authComponent.setIdentityServiceFacade(null); + authComponent.authenticateImpl("username", "password".toCharArray()); + } + + @Test + public void testSettingAllowGuestUser() + { + authComponent.setAllowGuestLogin(true); + assertTrue(authComponent.guestUserAuthenticationAllowed()); + + authComponent.setAllowGuestLogin(false); + assertFalse(authComponent.guestUserAuthenticationAllowed()); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java index 2c2a5ce00c..52e4714d27 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java @@ -25,29 +25,29 @@ */ package org.alfresco.repo.security.authentication.identityservice; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import java.lang.reflect.Field; import java.util.Optional; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.alfresco.model.ContentModel; import org.alfresco.repo.management.subsystems.ChildApplicationContextFactory; import org.alfresco.repo.management.subsystems.DefaultChildApplicationContextManager; import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.transaction.TransactionService; import org.alfresco.util.BaseSpringTest; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; @SuppressWarnings("PMD.AvoidAccessibilityAlteration") public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest @@ -61,12 +61,12 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest private IdentityServiceJITProvisioningHandler jitProvisioningHandler; private final boolean isAuth0Enabled = Optional.ofNullable(System.getProperty("auth0.enabled")) - .map(Boolean::valueOf) - .orElse(false); + .map(Boolean::valueOf) + .orElse(false); private final String userPassword = Optional.ofNullable(System.getProperty("admin.password")) - .filter(password -> isAuth0Enabled) - .orElse("password"); + .filter(password -> isAuth0Enabled) + .orElse("password"); @Before public void setup() @@ -75,16 +75,16 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest nodeService = (NodeService) applicationContext.getBean("nodeService"); transactionService = (TransactionService) applicationContext.getBean("transactionService"); DefaultChildApplicationContextManager childApplicationContextManager = (DefaultChildApplicationContextManager) applicationContext - .getBean("Authentication"); + .getBean("Authentication"); ChildApplicationContextFactory childApplicationContextFactory = childApplicationContextManager.getChildApplicationContextFactory( - "identity-service1"); + "identity-service1"); identityServiceFacade = (IdentityServiceFacade) childApplicationContextFactory.getApplicationContext() - .getBean("identityServiceFacade"); + .getBean("identityServiceFacade"); jitProvisioningHandler = (IdentityServiceJITProvisioningHandler) childApplicationContextFactory.getApplicationContext() - .getBean("jitProvisioningHandler"); + .getBean("jitProvisioningHandler"); IdentityServiceConfig identityServiceConfig = (IdentityServiceConfig) childApplicationContextFactory.getApplicationContext() - .getBean("identityServiceConfig"); + .getBean("identityServiceConfig"); identityServiceConfig.setAllowAnyHostname(true); identityServiceConfig.setClientKeystore(null); identityServiceConfig.setDisableTrustManager(true); @@ -95,12 +95,11 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest { assertFalse(personService.personExists(IDS_USERNAME)); - IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = - identityServiceFacade.authorize( + IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, userPassword)); Optional userInfoOptional = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - accessTokenAuthorization.getAccessToken().getTokenValue()); + accessTokenAuthorization.getAccessToken().getTokenValue()); NodeRef person = personService.getPerson(IDS_USERNAME); @@ -125,23 +124,26 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest assertFalse(personService.personExists(IDS_USERNAME)); String principalAttribute = isAuth0Enabled ? PersonClaims.NICKNAME_CLAIM_NAME : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; - IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = - identityServiceFacade.authorize( + IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, userPassword)); + UserInfoAttrMapping userInfoAttrMapping = new UserInfoAttrMapping(principalAttribute, "given_name", "family_name", "email"); String accessToken = accessTokenAuthorization.getAccessToken().getTokenValue(); + ClientRegistration clientRegistration = mock(ClientRegistration.class, RETURNS_DEEP_STUBS); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(principalAttribute); IdentityServiceFacade idsServiceFacadeMock = mock(IdentityServiceFacade.class); when(idsServiceFacadeMock.decodeToken(accessToken)).thenReturn(null); - when(idsServiceFacadeMock.getUserInfo(accessToken, principalAttribute)).thenReturn(identityServiceFacade.getUserInfo(accessToken, principalAttribute)); + when(idsServiceFacadeMock.getUserInfo(accessToken, userInfoAttrMapping)).thenReturn(identityServiceFacade.getUserInfo(accessToken, userInfoAttrMapping)); + when(idsServiceFacadeMock.getClientRegistration()).thenReturn(clientRegistration); // Replace the original facade with a mocked one to prevent user information from being extracted from the access token. Field declaredField = jitProvisioningHandler.getClass() - .getDeclaredField("identityServiceFacade"); + .getDeclaredField("identityServiceFacade"); declaredField.setAccessible(true); declaredField.set(jitProvisioningHandler, idsServiceFacadeMock); Optional userInfoOptional = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - accessToken); + accessToken); declaredField.set(jitProvisioningHandler, identityServiceFacade); @@ -153,7 +155,7 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest assertEquals("johndoe123@alfresco.com", userInfoOptional.get().email()); assertEquals("johndoe123@alfresco.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL)); verify(idsServiceFacadeMock).decodeToken(accessToken); - verify(idsServiceFacadeMock, atLeast(1)).getUserInfo(accessToken, principalAttribute); + verify(idsServiceFacadeMock, atLeast(1)).getUserInfo(accessToken, userInfoAttrMapping); if (!isAuth0Enabled) { assertEquals("John", userInfoOptional.get().firstName()); @@ -166,18 +168,17 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest @After public void tearDown() { - AuthenticationUtil.runAsSystem(new AuthenticationUtil.RunAsWork() - { + AuthenticationUtil.runAsSystem(new AuthenticationUtil.RunAsWork() { @Override public Void doWork() throws Exception { transactionService.getRetryingTransactionHelper() - .doInTransaction((RetryingTransactionCallback) () -> { - personService.deletePerson(IDS_USERNAME); - return null; - }); + .doInTransaction((RetryingTransactionCallback) () -> { + personService.deletePerson(IDS_USERNAME); + return null; + }); return null; } }); } -} \ No newline at end of file +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java index ed0071fe97..2b5223f68f 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java @@ -38,12 +38,17 @@ import static org.mockito.MockitoAnnotations.initMocks; import java.util.Optional; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; - -import org.alfresco.service.cmr.security.PersonService; -import org.alfresco.service.transaction.TransactionService; import org.junit.Before; import org.junit.Test; +import org.mockito.Answers; import org.mockito.Mock; +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; +import org.alfresco.service.cmr.security.PersonService; +import org.alfresco.service.transaction.TransactionService; public class IdentityServiceJITProvisioningHandlerUnitTest { @@ -51,6 +56,9 @@ public class IdentityServiceJITProvisioningHandlerUnitTest @Mock private IdentityServiceFacade identityServiceFacade; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ClientRegistration clientRegistration; + @Mock private PersonService personService; @@ -64,11 +72,22 @@ public class IdentityServiceJITProvisioningHandlerUnitTest private IdentityServiceConfig identityServiceConfig; @Mock - private OIDCUserInfo userInfo; + private DecodedTokenUser decodedTokenUser; private IdentityServiceJITProvisioningHandler jitProvisioningHandler; + private UserInfoAttrMapping expectedMapping; + private static final String JWT_TOKEN = "myToken"; + private static final String USERNAME = "johny123"; + private static final String FIRST_NAME = "John"; + private static final String LAST_NAME = "Doe"; + private static final String EMAIL = "johny123@email.com"; + + public static final String USERNAME_CLAIM = "nickname"; + public static final String EMAIL_CLAIM = "email"; + public static final String FIRST_NAME_CLAIM = "given_name"; + public static final String LAST_NAME_CLAIM = "family_name"; @Before public void setup() @@ -78,149 +97,147 @@ public class IdentityServiceJITProvisioningHandlerUnitTest when(transactionService.isReadOnly()).thenReturn(false); when(identityServiceFacade.decodeToken(JWT_TOKEN)).thenReturn(decodedAccessToken); when(personService.createMissingPeople()).thenReturn(true); - jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, - personService, transactionService, identityServiceConfig); + when(identityServiceFacade.getClientRegistration()).thenReturn(clientRegistration); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(USERNAME_CLAIM); + when(identityServiceConfig.getEmailAttribute()).thenReturn(EMAIL_CLAIM); + when(identityServiceConfig.getFirstNameAttribute()).thenReturn(FIRST_NAME_CLAIM); + when(identityServiceConfig.getLastNameAttribute()).thenReturn(LAST_NAME_CLAIM); + expectedMapping = new UserInfoAttrMapping(USERNAME_CLAIM, FIRST_NAME_CLAIM, LAST_NAME_CLAIM, EMAIL_CLAIM); + jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, personService, transactionService, identityServiceConfig); } @Test public void shouldExtractUserInfoForExistingUser() { - when(personService.personExists("johny123")).thenReturn(true); - when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + when(personService.personExists(USERNAME)).thenReturn(true); + when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(USERNAME); + jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, personService, transactionService, identityServiceConfig); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertFalse(result.get().allFieldsNotEmpty()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldExtractUserInfoForExistingUserWithProviderPrincipalAttribute() { - when(identityServiceConfig.getPrincipalAttribute()).thenReturn("nickname"); - when(personService.personExists("johny123")).thenReturn(true); - when(decodedAccessToken.getClaim("nickname")).thenReturn("johny123"); + when(identityServiceConfig.getPrincipalAttribute()).thenReturn(USERNAME_CLAIM); + when(personService.personExists(USERNAME)).thenReturn(true); + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn(USERNAME); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertFalse(result.get().allFieldsNotEmpty()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, "nickname"); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldExtractUserInfoFromAccessTokenAndCreateUser() { - when(personService.personExists("johny123")).thenReturn(false); - - when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); - when(decodedAccessToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)).thenReturn("John"); - when(decodedAccessToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)).thenReturn("Doe"); - when(decodedAccessToken.getClaim(PersonClaims.EMAIL_CLAIM_NAME)).thenReturn("johny123@email.com"); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + when(personService.personExists(USERNAME)).thenReturn(false); + when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(USERNAME); + when(decodedAccessToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)).thenReturn(FIRST_NAME); + when(decodedAccessToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)).thenReturn(LAST_NAME); + when(decodedAccessToken.getClaim(PersonClaims.EMAIL_CLAIM_NAME)).thenReturn(EMAIL); + jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, personService, transactionService, identityServiceConfig); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); - assertEquals("John", result.get().firstName()); - assertEquals("Doe", result.get().lastName()); - assertEquals("johny123@email.com", result.get().email()); + assertEquals(USERNAME, result.get().username()); + assertEquals(FIRST_NAME, result.get().firstName()); + assertEquals(LAST_NAME, result.get().lastName()); + assertEquals(EMAIL, result.get().email()); assertTrue(result.get().allFieldsNotEmpty()); verify(personService).createPerson(any()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldExtractUserInfoFromUserInfoEndpointAndCreateUser() { - when(userInfo.username()).thenReturn("johny123"); - when(userInfo.firstName()).thenReturn("John"); - when(userInfo.lastName()).thenReturn("Doe"); - when(userInfo.email()).thenReturn("johny123@email.com"); - - when(personService.personExists("johny123")).thenReturn(false); - - when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); + when(decodedTokenUser.username()).thenReturn(USERNAME); + when(decodedTokenUser.firstName()).thenReturn(FIRST_NAME); + when(decodedTokenUser.lastName()).thenReturn(LAST_NAME); + when(decodedTokenUser.email()).thenReturn(EMAIL); + when(personService.personExists(USERNAME)).thenReturn(false); + when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(USERNAME); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(decodedTokenUser)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); - assertEquals("John", result.get().firstName()); - assertEquals("Doe", result.get().lastName()); - assertEquals("johny123@email.com", result.get().email()); + assertEquals(USERNAME, result.get().username()); + assertEquals(FIRST_NAME, result.get().firstName()); + assertEquals(LAST_NAME, result.get().lastName()); + assertEquals(EMAIL, result.get().email()); assertTrue(result.get().allFieldsNotEmpty()); verify(personService).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldReturnEmptyOptionalIfUsernameNotExtracted() { - - when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(decodedTokenUser)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertFalse(result.isPresent()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldCallUserInfoEndpointToGetUsername() { - when(personService.personExists("johny123")).thenReturn(true); - + when(personService.personExists(USERNAME)).thenReturn(true); when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(""); - - when(userInfo.username()).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); - + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(DecodedTokenUser.validateAndCreate(USERNAME, null, null, null))); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertEquals("", result.get().firstName()); assertEquals("", result.get().lastName()); assertEquals("", result.get().email()); assertFalse(result.get().allFieldsNotEmpty()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldCallUserInfoEndpointToGetUsernameWithProvidedPrincipalAttribute() { - when(identityServiceConfig.getPrincipalAttribute()).thenReturn("nickname"); - when(personService.personExists("johny123")).thenReturn(true); - - when(decodedAccessToken.getClaim("nickname")).thenReturn(""); - - when(userInfo.username()).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN, "nickname")).thenReturn(Optional.of(userInfo)); + when(identityServiceConfig.getPrincipalAttribute()).thenReturn(USERNAME_CLAIM); + when(personService.personExists(USERNAME)).thenReturn(true); + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn(""); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(DecodedTokenUser.validateAndCreate(USERNAME, null, null, null))); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( - JWT_TOKEN); + JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertEquals("", result.get().firstName()); assertEquals("", result.get().lastName()); assertEquals("", result.get().email()); assertFalse(result.get().allFieldsNotEmpty()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, "nickname"); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test @@ -232,8 +249,8 @@ public class IdentityServiceJITProvisioningHandlerUnitTest verify(personService, never()).createPerson(any()); verify(identityServiceFacade, never()).decodeToken(null); verify(identityServiceFacade, never()).decodeToken(""); - verify(identityServiceFacade, never()).getUserInfo(null, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); - verify(identityServiceFacade, never()).getUserInfo("", PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo(null, expectedMapping); + verify(identityServiceFacade, never()).getUserInfo(null, expectedMapping); } -} \ No newline at end of file +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java index f137bffa02..72e29c2d27 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java @@ -34,17 +34,18 @@ import java.time.Instant; import java.util.Map; import java.util.Vector; import java.util.function.Supplier; +import jakarta.servlet.http.HttpServletRequest; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; - -import jakarta.servlet.http.HttpServletRequest; import junit.framework.TestCase; +import org.mockito.Mockito; +import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; + import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.DecodedAccessToken; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenDecodingException; import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.transaction.TransactionService; -import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; /** * Tests the Identity Service based authentication subsystem. @@ -68,7 +69,9 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase public void testWrongTokenWithSilentValidation() { - final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("WrOnG-ToKeN", () -> {throw new TokenDecodingException("Expected ");})); + final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("WrOnG-ToKeN", () -> { + throw new TokenDecodingException("Expected "); + })); mapper.setValidationFailureSilent(true); HttpServletRequest mockRequest = createMockTokenRequest("WrOnG-ToKeN"); @@ -79,7 +82,9 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase public void testWrongTokenWithoutSilentValidation() { - final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("WrOnG-ToKeN", () -> {throw new TokenDecodingException("Expected");})); + final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("WrOnG-ToKeN", () -> { + throw new TokenDecodingException("Expected"); + })); mapper.setValidationFailureSilent(false); HttpServletRequest mockRequest = createMockTokenRequest("WrOnG-ToKeN"); @@ -92,12 +97,14 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase private IdentityServiceRemoteUserMapper givenMapper(Map> tokenToUser) { final TransactionService transactionService = mock(TransactionService.class); - final IdentityServiceFacade facade = mock(IdentityServiceFacade.class); + final IdentityServiceFacade facade = mock(IdentityServiceFacade.class, Mockito.RETURNS_DEEP_STUBS); final PersonService personService = mock(PersonService.class); final IdentityServiceConfig identityServiceConfig = mock(IdentityServiceConfig.class); when(transactionService.isReadOnly()).thenReturn(true); when(facade.decodeToken(anyString())) .thenAnswer(i -> new TestDecodedToken(tokenToUser.get(i.getArgument(0, String.class)))); + when(facade.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .thenReturn(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); when(personService.getUserIdentifier(anyString())).thenAnswer(i -> i.getArgument(0, String.class)); @@ -108,21 +115,21 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase mapper.setActive(true); mapper.setBearerTokenResolver(new DefaultBearerTokenResolver()); - return mapper; } /** * Utility method for creating a mocked Servlet request with a token. * - * @param token The token to add to the Authorization header + * @param token + * The token to add to the Authorization header * @return The mocked request object */ private HttpServletRequest createMockTokenRequest(String token) { // Mock a request with the token in the Authorization header (if supplied) HttpServletRequest mockRequest = mock(HttpServletRequest.class); - + Vector authHeaderValues = new Vector<>(1); if (token != null) { @@ -133,7 +140,7 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase .thenReturn(authHeaderValues.elements()); when(mockRequest.getHeader(AUTHORIZATION_HEADER)) .thenReturn(authHeaderValues.isEmpty() ? null : authHeaderValues.get(0)); - + return mockRequest; } @@ -166,4 +173,4 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase return PersonClaims.PREFERRED_USERNAME_CLAIM_NAME.equals(claim) ? usernameSupplier.get() : null; } } -} \ No newline at end of file +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java index 271d9b80b4..34670b0a88 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java @@ -1,98 +1,99 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2025 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco 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 Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenDecodingException; -import org.junit.Test; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.web.client.RestOperations; - -public class SpringBasedIdentityServiceFacadeUnitTest -{ - private static final String USER_NAME = "user"; - private static final String PASSWORD = "password"; - private static final String TOKEN = "tEsT-tOkEn"; - - @Test - public void shouldThrowVerificationExceptionOnFailure() - { - final RestOperations restOperations = mock(RestOperations.class); - final JwtDecoder jwtDecoder = mock(JwtDecoder.class); - when(restOperations.exchange(any(), any(Class.class))).thenThrow(new RuntimeException("Expected")); - - final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); - - assertThatExceptionOfType(AuthorizationException.class) - .isThrownBy(() -> facade.authorize(AuthorizationGrant.password(USER_NAME, PASSWORD))) - .havingCause().withNoCause().withMessage("Expected"); - } - - @Test - public void shouldThrowTokenExceptionOnFailure() - { - final RestOperations restOperations = mock(RestOperations.class); - final JwtDecoder jwtDecoder = mock(JwtDecoder.class); - when(jwtDecoder.decode(TOKEN)).thenThrow(new RuntimeException("Expected")); - - final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); - - assertThatExceptionOfType(TokenDecodingException.class) - .isThrownBy(() -> facade.decodeToken(TOKEN)) - .havingCause().withNoCause().withMessage("Expected"); - } - - - @Test - public void shouldReturnEmptyOptionalOnFailure() - { - final RestOperations restOperations = mock(RestOperations.class); - final JwtDecoder jwtDecoder = mock(JwtDecoder.class); - final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); - - - assertThat(facade.getUserInfo(TOKEN, "preferred_username").isEmpty()).isTrue(); - } - - private ClientRegistration testRegistration() - { - return ClientRegistration.withRegistrationId("test") - .tokenUri("http://localhost") - .clientId("test") - .userInfoUri("http://localhost/userinfo") - .authorizationGrantType(AuthorizationGrantType.PASSWORD) - .build(); - } -} \ No newline at end of file +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.web.client.RestOperations; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenDecodingException; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; + +public class SpringBasedIdentityServiceFacadeUnitTest +{ + private static final String USER_NAME = "user"; + private static final String PASSWORD = "password"; + private static final String TOKEN = "tEsT-tOkEn"; + private static final UserInfoAttrMapping USER_INFO_ATTR_MAPPING = new UserInfoAttrMapping("preferred_username", "given_name", "family_name", "email"); + + @Test + public void shouldThrowVerificationExceptionOnFailure() + { + final RestOperations restOperations = mock(RestOperations.class); + final JwtDecoder jwtDecoder = mock(JwtDecoder.class); + when(restOperations.exchange(any(), any(Class.class))).thenThrow(new RuntimeException("Expected")); + + final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); + + assertThatExceptionOfType(AuthorizationException.class) + .isThrownBy(() -> facade.authorize(AuthorizationGrant.password(USER_NAME, PASSWORD))) + .havingCause().withNoCause().withMessage("Expected"); + } + + @Test + public void shouldThrowTokenExceptionOnFailure() + { + final RestOperations restOperations = mock(RestOperations.class); + final JwtDecoder jwtDecoder = mock(JwtDecoder.class); + when(jwtDecoder.decode(TOKEN)).thenThrow(new RuntimeException("Expected")); + + final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); + + assertThatExceptionOfType(TokenDecodingException.class) + .isThrownBy(() -> facade.decodeToken(TOKEN)) + .havingCause().withNoCause().withMessage("Expected"); + } + + @Test + public void shouldReturnEmptyOptionalOnFailure() + { + final RestOperations restOperations = mock(RestOperations.class); + final JwtDecoder jwtDecoder = mock(JwtDecoder.class); + final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); + + assertThat(facade.getUserInfo(TOKEN, USER_INFO_ATTR_MAPPING).isEmpty()).isTrue(); + } + + private ClientRegistration testRegistration() + { + return ClientRegistration.withRegistrationId("test") + .tokenUri("http://localhost") + .clientId("test") + .userInfoUri("http://localhost/userinfo") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .build(); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java index 78eda0036b..6d1ca62de4 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java @@ -38,18 +38,11 @@ import java.io.IOException; import java.time.Instant; import java.util.Arrays; import java.util.Map; - -import com.nimbusds.oauth2.sdk.Scope; - +import java.util.Set; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.alfresco.repo.security.authentication.external.RemoteUserMapper; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessToken; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; + +import com.nimbusds.oauth2.sdk.Scope; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -58,6 +51,14 @@ import org.mockito.Mock; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessToken; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; + @SuppressWarnings("PMD.AvoidStringBufferField") public class IdentityServiceAdminConsoleAuthenticatorUnitTest { @@ -118,7 +119,7 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest { when(cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request)).thenReturn("JWT_TOKEN"); when(cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request)).thenReturn( - String.valueOf(Instant.now().plusSeconds(60).toEpochMilli())); + String.valueOf(Instant.now().plusSeconds(60).toEpochMilli())); when(remoteUserMapper.getRemoteUser(requestCaptor.capture())).thenReturn("admin"); String username = authenticator.getAdminConsoleUser(request, response); @@ -134,7 +135,7 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest when(cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request)).thenReturn("EXPIRED_JWT_TOKEN"); when(cookiesService.getCookie(ALFRESCO_REFRESH_TOKEN, request)).thenReturn("REFRESH_TOKEN"); when(cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request)).thenReturn( - String.valueOf(Instant.now().minusSeconds(60).toEpochMilli())); + String.valueOf(Instant.now().minusSeconds(60).toEpochMilli())); when(accessToken.getTokenValue()).thenReturn("REFRESHED_JWT_TOKEN"); when(accessToken.getExpiresAt()).thenReturn(Instant.now().plusSeconds(60)); when(accessTokenAuthorization.getAccessToken()).thenReturn(accessToken); @@ -155,10 +156,11 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest { String redirectPath = "/alfresco/s/admin/admin-communitysummary"; + when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn("/alfresco/s/admin/admin-communitysummary"); ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" - .formatted("http://localhost:8080", redirectPath); + .formatted("http://localhost:8080", redirectPath); authenticator.requestAuthentication(request, response); @@ -178,9 +180,10 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest String redirectPath = "/alfresco/s/admin/admin-communitysummary"; when(identityServiceConfig.getAudience()).thenReturn(audience); when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(redirectPath); + when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" - .formatted("http://localhost:8080", redirectPath); + .formatted("http://localhost:8080", redirectPath); authenticator.requestAuthentication(request, response); @@ -200,7 +203,7 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest when(cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request)).thenReturn("EXPIRED_JWT_TOKEN"); when(cookiesService.getCookie(ALFRESCO_REFRESH_TOKEN, request)).thenReturn("REFRESH_TOKEN"); when(cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request)).thenReturn( - String.valueOf(Instant.now().minusSeconds(60).toEpochMilli())); + String.valueOf(Instant.now().minusSeconds(60).toEpochMilli())); when(identityServiceFacade.authorize(any(AuthorizationGrant.class))).thenThrow(AuthorizationException.class); @@ -221,8 +224,8 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest when(accessTokenAuthorization.getAccessToken()).thenReturn(accessToken); when(accessTokenAuthorization.getRefreshTokenValue()).thenReturn("REFRESH_TOKEN"); when(identityServiceFacade.authorize( - AuthorizationGrant.authorizationCode("auth_code", adminConsoleURL.toString()))) - .thenReturn(accessTokenAuthorization); + AuthorizationGrant.authorizationCode("auth_code", adminConsoleURL.toString()))) + .thenReturn(accessTokenAuthorization); when(remoteUserMapper.getRemoteUser(requestCaptor.capture())).thenReturn("admin"); String username = authenticator.getAdminConsoleUser(request, response); @@ -242,4 +245,4 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest assertEquals("admin", username); } -} \ No newline at end of file +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java new file mode 100644 index 0000000000..f41a313e51 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java @@ -0,0 +1,109 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; + +public class AccessTokenToDecodedTokenUserMapperUnitTest +{ + + @Mock + private IdentityServiceFacade.DecodedAccessToken decodedAccessToken; + + private AccessTokenToDecodedTokenUserMapper tokenToDecodedTokenUserMapper; + + public static final String USERNAME_CLAIM = "nickname"; + public static final String EMAIL_CLAIM = "email"; + public static final String FIRST_NAME_CLAIM = "given_name"; + public static final String LAST_NAME_CLAIM = "family_name"; + + @Before + public void setup() + { + initMocks(this); + UserInfoAttrMapping userInfoAttrMapping = new UserInfoAttrMapping(USERNAME_CLAIM, FIRST_NAME_CLAIM, LAST_NAME_CLAIM, EMAIL_CLAIM); + tokenToDecodedTokenUserMapper = new AccessTokenToDecodedTokenUserMapper(userInfoAttrMapping); + } + + @Test + public void shouldMapToDecodedTokenUserWithAllFieldsPopulated() + { + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn("johny123"); + when(decodedAccessToken.getClaim(FIRST_NAME_CLAIM)).thenReturn("John"); + when(decodedAccessToken.getClaim(LAST_NAME_CLAIM)).thenReturn("Doe"); + when(decodedAccessToken.getClaim(EMAIL_CLAIM)).thenReturn("johny123@email.com"); + + Optional result = tokenToDecodedTokenUserMapper.toDecodedTokenUser(decodedAccessToken); + + assertTrue(result.isPresent()); + assertEquals("johny123", result.get().username()); + assertEquals("John", result.get().firstName()); + assertEquals("Doe", result.get().lastName()); + assertEquals("johny123@email.com", result.get().email()); + } + + @Test + public void shouldMapToDecodedTokenUserWithSomeFieldsEmpty() + { + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn("johny123"); + when(decodedAccessToken.getClaim(FIRST_NAME_CLAIM)).thenReturn(""); + when(decodedAccessToken.getClaim(LAST_NAME_CLAIM)).thenReturn("Doe"); + when(decodedAccessToken.getClaim(EMAIL_CLAIM)).thenReturn(""); + + Optional result = tokenToDecodedTokenUserMapper.toDecodedTokenUser(decodedAccessToken); + + assertTrue(result.isPresent()); + assertEquals("johny123", result.get().username()); + assertEquals("", result.get().firstName()); + assertEquals("Doe", result.get().lastName()); + assertEquals("", result.get().email()); + } + + @Test + public void shouldReturnEmptyOptionalForNullUsername() + { + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn(null); + when(decodedAccessToken.getClaim(FIRST_NAME_CLAIM)).thenReturn("John"); + when(decodedAccessToken.getClaim(LAST_NAME_CLAIM)).thenReturn("Doe"); + when(decodedAccessToken.getClaim(EMAIL_CLAIM)).thenReturn("johny123@email.com"); + + Optional result = tokenToDecodedTokenUserMapper.toDecodedTokenUser(decodedAccessToken); + + assertFalse(result.isPresent()); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java new file mode 100644 index 0000000000..6602f7dab5 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java @@ -0,0 +1,95 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import org.alfresco.service.cmr.security.PersonService; + +public class TokenUserToOIDCUserMapperUnitTest +{ + + @Mock + private PersonService personService; + + @InjectMocks + private TokenUserToOIDCUserMapper tokenUserToOIDCUserMapper; + + @Before + public void setup() + { + initMocks(this); + } + + @Test + public void shouldMapToOIDCUserWithAllFieldsPopulated() + { + DecodedTokenUser decodedTokenUser = new DecodedTokenUser("JOHNY123", "John", "Doe", "johny123@email.com"); + when(personService.getUserIdentifier("JOHNY123")).thenReturn("johny123"); + + OIDCUserInfo oidcUserInfo = tokenUserToOIDCUserMapper.toOIDCUser(decodedTokenUser); + + assertEquals("johny123", oidcUserInfo.username()); + assertEquals("John", oidcUserInfo.firstName()); + assertEquals("Doe", oidcUserInfo.lastName()); + assertEquals("johny123@email.com", oidcUserInfo.email()); + } + + @Test + public void shouldMapToOIDCUserWithSomeFieldsEmpty() + { + DecodedTokenUser decodedTokenUser = new DecodedTokenUser("johny123", "", "Doe", ""); + when(personService.getUserIdentifier("johny123")).thenReturn("johny123"); + + OIDCUserInfo oidcUserInfo = tokenUserToOIDCUserMapper.toOIDCUser(decodedTokenUser); + + assertEquals("johny123", oidcUserInfo.username()); + assertEquals("", oidcUserInfo.firstName()); + assertEquals("Doe", oidcUserInfo.lastName()); + assertEquals("", oidcUserInfo.email()); + } + + @Test + public void shouldReturnNullForNullUsername() + { + DecodedTokenUser decodedTokenUser = new DecodedTokenUser(null, "John", "Doe", "johny123@email.com"); + + OIDCUserInfo oidcUserInfo = tokenUserToOIDCUserMapper.toOIDCUser(decodedTokenUser); + + assertNull(oidcUserInfo.username()); + assertEquals("John", oidcUserInfo.firstName()); + assertEquals("Doe", oidcUserInfo.lastName()); + assertEquals("johny123@email.com", oidcUserInfo.email()); + } +} diff --git a/repository/src/test/resources/alfresco-global.properties b/repository/src/test/resources/alfresco-global.properties index 20f5389408..53848254c4 100644 --- a/repository/src/test/resources/alfresco-global.properties +++ b/repository/src/test/resources/alfresco-global.properties @@ -28,6 +28,9 @@ identity-service.register-node-at-startup=true identity-service.register-node-period=50 identity-service.token-store=SESSION identity-service.principal-attribute=preferred_username +identity-service.first-name-attribute=given_name +identity-service.last-name-attribute=family_name +identity-service.email-attribute=email identity-service.turn-off-change-session-id-on-login=true identity-service.token-minimum-time-to-live=10 identity-service.min-time-between-jwks-requests=60