From e0f990838b410b7a78e9d8349961db85dee3ac77 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Sun, 19 Jul 2026 10:39:14 +0800 Subject: [PATCH 1/3] test: e2e coverage for external-IdP OIDC login and JWT bearer auth via Keycloak Adds an 'auth-idp' tagged e2e suite covering the external-IdP code path (DhisOidcProvider/DhisOidcUser/Dhis2JwtAuthenticationManagerResolver), which had zero automated coverage: - docker-compose.e2e-auth.yml overlay: Keycloak 26 with an imported realm (config/keycloak/realm-dhis2.json) and a dedicated DHIS2_HOME (config/dhis2_home_auth/) so the extra login provider never affects the default e2e suite. - KeycloakOidcLoginTest: browser redirect flow via Selenium, email claim -> User.openId mapping, authorities from DHIS2 roles, unmapped IdP user fails to /login/?oidcFailure=true, loginConfig advertises the provider. - KeycloakJwtBearerTest: direct-access-grant tokens against the API, issuer/audience validation, unmapped and garbage token rejection, and the @RequiresAuthority 403 message contract + superuser bypass over bearer auth. - pom.xml: default profile now excludes 'auth-idp'; new auth-idp profile runs the tagged tests. - run-auth-idp-tests.yml: runs the suite on PRs touching security paths, nightly on master, and on manual dispatch. Verified locally: full stack boot + 8/8 tests green inside the compose network. AI Assisted --- .github/workflows/run-auth-idp-tests.yml | 87 ++++++++ .../config/dhis2_home_auth/dhis.conf | 68 ++++++ .../config/keycloak/realm-dhis2.json | 83 +++++++ .../dhis-test-e2e/docker-compose.e2e-auth.yml | 30 +++ dhis-2/dhis-test-e2e/pom.xml | 18 +- .../org/hisp/dhis/oidc/KeycloakBaseTest.java | 207 ++++++++++++++++++ .../hisp/dhis/oidc/KeycloakJwtBearerTest.java | 133 +++++++++++ .../hisp/dhis/oidc/KeycloakOidcLoginTest.java | 178 +++++++++++++++ 8 files changed, 802 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/run-auth-idp-tests.yml create mode 100644 dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf create mode 100644 dhis-2/dhis-test-e2e/config/keycloak/realm-dhis2.json create mode 100644 dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml create mode 100644 dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakBaseTest.java create mode 100644 dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakJwtBearerTest.java create mode 100644 dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakOidcLoginTest.java diff --git a/.github/workflows/run-auth-idp-tests.yml b/.github/workflows/run-auth-idp-tests.yml new file mode 100644 index 000000000000..b4f88bc9d88e --- /dev/null +++ b/.github/workflows/run-auth-idp-tests.yml @@ -0,0 +1,87 @@ +name: Run auth IdP tests + +# External-IdP authentication e2e suite (Keycloak OIDC, LDAP): runs the dhis-test-e2e +# "auth-idp" tagged tests against the docker-compose.e2e-auth.yml stack. +# Triggered by PRs touching security-relevant paths, nightly on master, and manually. + +env: + MAVEN_OPTS: -Xmx1024m -Xms1024m -Dhttp.keepAlive=false -Dmaven.wagon.http.pool=false -Dmaven.wagon.http.retryHandler.class=standard -Dmaven.wagon.http.retryHandler.count=3 -Dmaven.wagon.httpconnectionManager.ttlSeconds=125 + +on: + pull_request: + paths: + - "dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/**" + - "dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/**" + - "dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/**" + - "dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/**" + - "dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/**" + - "dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/mvc/interceptor/**" + - "dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/**" + - "dhis-2/dhis-test-e2e/**" + - ".github/workflows/run-auth-idp-tests.yml" + schedule: + # Nightly on the default branch + - cron: "30 3 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + auth-idp-test: + runs-on: ubuntu-latest + env: + CORE_IMAGE_NAME: "dhis2/core-dev:local" + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + cache: maven + + - name: Build container image + run: | + mvn clean verify --threads 2C --batch-mode --no-transfer-progress \ + -DskipTests -Dpackaging.type=jar -Dmdep.analyze.skip --update-snapshots --file dhis-2/pom.xml \ + --projects dhis-web-server --also-make --activate-profiles embedded,jibDockerBuild \ + -Djib.to.image=$CORE_IMAGE_NAME + + - name: Run auth IdP tests + run: | + cd dhis-2/dhis-test-e2e + DHIS2_E2E_TEST_ARGS="-Pauth-idp" SELENIUM_IMAGE=selenium/standalone-chrome:latest DHIS2_IMAGE=$CORE_IMAGE_NAME \ + docker compose -f docker-compose.yml -f docker-compose.e2e.yml -f docker-compose.e2e-auth.yml \ + up --remove-orphans --exit-code-from test + + - name: Upload logs + if: failure() + run: | + cd dhis-2/dhis-test-e2e + docker compose -f docker-compose.yml -f docker-compose.e2e.yml -f docker-compose.e2e-auth.yml logs web > ~/web-logs.txt + docker compose -f docker-compose.yml -f docker-compose.e2e.yml -f docker-compose.e2e-auth.yml logs keycloak > ~/keycloak-logs.txt + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: "auth_idp_logs" + path: | + ~/web-logs.txt + ~/keycloak-logs.txt + + send-slack-message: + runs-on: ubuntu-latest + if: | + always() && + contains(needs.*.result, 'failure') && + github.event_name == 'schedule' + needs: [auth-idp-test] + steps: + - uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_BACKEND_WEBHOOK }} + SLACK_CHANNEL: "team-backend" + SLACK_MESSAGE: "Nightly auth IdP (Keycloak/LDAP) e2e run failed and needs investigation :detective-duck:." + SLACK_COLOR: "#ff0000" diff --git a/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf b/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf new file mode 100644 index 000000000000..603e9461ae1e --- /dev/null +++ b/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf @@ -0,0 +1,68 @@ +# DHIS2_HOME config for the auth-idp e2e stack (docker-compose.e2e-auth.yml). +# Identical to config/dhis2_home/dhis.conf plus the external Keycloak OIDC provider, +# kept separate so the extra login provider never perturbs the default e2e suite. + +connection.dialect = org.hibernate.dialect.PostgreSQLDialect +connection.driver_class = org.postgresql.Driver +connection.url = jdbc:postgresql://db/dhis +connection.username = dhis +connection.password = dhis + +# Redis configuration +redis.enabled = true +redis.host = redis + +filestore.provider = s3 +filestore.container = dhis2 +filestore.location = eu-west-1 +filestore.endpoint = http://minio:9000 +filestore.identity = root +filestore.secret = dhisdhis + +tracker.import.preheat.cache.enabled=off +audit.logger=off + +# Analytics configuration +analytics.table.unlogged = on + +login.security.totp_2fa.enabled = on +login.security.email_2fa.enabled = on + +oauth2.server.enabled = on +server.base.url = http://web:8080/ +oidc.jwt.token.authentication.enabled = on + +oidc.oauth2.login.enabled = on +oidc.logout.redirect_url = http://web:8080/ +oidc.provider.dhis2.client_id = dhis2-client +oidc.provider.dhis2.client_secret = secret +oidc.provider.dhis2.mapping_claim = email +oidc.provider.dhis2.display_alias = Sign in with dhis2 +oidc.provider.dhis2.enable_logout = on +oidc.provider.dhis2.scopes = email +oidc.provider.dhis2.authorization_uri = http://web:8080/oauth2/authorize +oidc.provider.dhis2.token_uri = http://web:8080/oauth2/token +oidc.provider.dhis2.issuer_uri = http://web:8080/ +oidc.provider.dhis2.jwk_uri = http://web:8080/oauth2/jwks +oidc.provider.dhis2.user_info_uri = http://web:8080/userinfo + +# External IdP: Keycloak (see config/keycloak/realm-dhis2.json) +# Generic provider id "keycloak"; callback is {baseUrl}/oauth2/code/keycloak. +# issuer_uri must exactly match the "iss" claim of Keycloak tokens (KC_HOSTNAME). +oidc.provider.keycloak.client_id = dhis2-oidc +oidc.provider.keycloak.client_secret = dhis2-oidc-secret +oidc.provider.keycloak.mapping_claim = email +oidc.provider.keycloak.display_alias = Sign in with Keycloak +oidc.provider.keycloak.scopes = email +oidc.provider.keycloak.authorization_uri = http://keycloak:8080/realms/dhis2/protocol/openid-connect/auth +oidc.provider.keycloak.token_uri = http://keycloak:8080/realms/dhis2/protocol/openid-connect/token +oidc.provider.keycloak.user_info_uri = http://keycloak:8080/realms/dhis2/protocol/openid-connect/userinfo +oidc.provider.keycloak.jwk_uri = http://keycloak:8080/realms/dhis2/protocol/openid-connect/certs +oidc.provider.keycloak.issuer_uri = http://keycloak:8080/realms/dhis2 + +route.remote_servers_allowed = http://web:8080 + +# Enable monitoring API for /api/metrics endpoint testing +monitoring.api.enabled = on +monitoring.jvm.enabled = on +monitoring.dbpool.enabled = on diff --git a/dhis-2/dhis-test-e2e/config/keycloak/realm-dhis2.json b/dhis-2/dhis-test-e2e/config/keycloak/realm-dhis2.json new file mode 100644 index 000000000000..bf8f84c6a04b --- /dev/null +++ b/dhis-2/dhis-test-e2e/config/keycloak/realm-dhis2.json @@ -0,0 +1,83 @@ +{ + "realm": "dhis2", + "enabled": true, + "registrationAllowed": false, + "sslRequired": "none", + "clients": [ + { + "clientId": "dhis2-oidc", + "name": "DHIS2 e2e OIDC client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "dhis2-oidc-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://web:8080/oauth2/code/keycloak", + "http://localhost:8080/oauth2/code/keycloak" + ], + "webOrigins": ["+"], + "protocolMappers": [ + { + "name": "dhis2-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "dhis2-oidc", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + } + ], + "users": [ + { + "username": "kcmapped", + "enabled": true, + "email": "kcmapped@dhis2.org", + "emailVerified": true, + "firstName": "Kc", + "lastName": "Mapped", + "credentials": [ + { "type": "password", "value": "KcMapped123###", "temporary": false } + ] + }, + { + "username": "kclimited", + "enabled": true, + "email": "kclimited@dhis2.org", + "emailVerified": true, + "firstName": "Kc", + "lastName": "Limited", + "credentials": [ + { "type": "password", "value": "KcLimited123###", "temporary": false } + ] + }, + { + "username": "kcloginmapped", + "enabled": true, + "email": "kcloginmapped@dhis2.org", + "emailVerified": true, + "firstName": "Kc", + "lastName": "Loginmapped", + "credentials": [ + { "type": "password", "value": "KcLogin123###", "temporary": false } + ] + }, + { + "username": "kcunmapped", + "enabled": true, + "email": "kcunmapped@dhis2.org", + "emailVerified": true, + "firstName": "Kc", + "lastName": "Unmapped", + "credentials": [ + { "type": "password", "value": "KcUnmapped123###", "temporary": false } + ] + } + ] +} diff --git a/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml b/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml new file mode 100644 index 000000000000..135632f714e4 --- /dev/null +++ b/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml @@ -0,0 +1,30 @@ +# Overlay for the auth-idp e2e suite (external IdP tests: Keycloak OIDC, LDAP). +# Usage: +# DHIS2_E2E_TEST_ARGS="-Pauth-idp" docker compose \ +# -f docker-compose.yml -f docker-compose.e2e.yml -f docker-compose.e2e-auth.yml \ +# up --remove-orphans --exit-code-from test +# +# The web service gets a dedicated DHIS2_HOME (config/dhis2_home_auth/) so the extra +# login providers never affect the default e2e suite. + +services: + web: + volumes: + - ./config/dhis2_home_auth/dhis.conf:/opt/dhis2/dhis.conf:ro + depends_on: + keycloak: + condition: service_started + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + # Fixed hostname so token "iss" is stable (http://keycloak:8080/realms/dhis2) + # and matches oidc.provider.keycloak.issuer_uri in dhis.conf. + KC_HOSTNAME: http://keycloak:8080 + volumes: + - ./config/keycloak/realm-dhis2.json:/opt/keycloak/data/import/realm-dhis2.json:ro + ports: + - "8080" diff --git a/dhis-2/dhis-test-e2e/pom.xml b/dhis-2/dhis-test-e2e/pom.xml index ef7f832680ef..7b05a672d919 100644 --- a/dhis-2/dhis-test-e2e/pom.xml +++ b/dhis-2/dhis-test-e2e/pom.xml @@ -289,7 +289,7 @@ - + default @@ -301,7 +301,7 @@ org.apache.maven.plugins maven-surefire-plugin - analytics + analytics,auth-idp @@ -342,5 +342,19 @@ + + auth-idp + + + + org.apache.maven.plugins + maven-surefire-plugin + + auth-idp + + + + + diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakBaseTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakBaseTest.java new file mode 100644 index 000000000000..38dc758a552f --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakBaseTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.oidc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.BaseE2ETest; +import org.hisp.dhis.test.e2e.helpers.config.TestConfiguration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +/** + * Shared infrastructure for the external-IdP (Keycloak) e2e tests. Runs only in the auth-idp stack: + * {@code docker-compose.e2e-auth.yml} provides the {@code keycloak} service and the {@code + * config/dhis2_home_auth/dhis.conf} server config with the {@code oidc.provider.keycloak.*} block. + * + *

Keycloak realm and users are seeded from {@code config/keycloak/realm-dhis2.json}. DHIS2-side + * users are created by the tests; the OIDC mapping key is {@code User.openId} == the IdP {@code + * email} claim (see {@code DhisOidcUserService} / {@code Dhis2JwtAuthenticationManagerResolver}). + * + * @author Morten Svanæs + */ +@Slf4j +public abstract class KeycloakBaseTest extends BaseE2ETest { + + public static final String CLIENT_ID = "dhis2-oidc"; + public static final String CLIENT_SECRET = "dhis2-oidc-secret"; + + /** Keycloak realm URL as reachable from the test JVM (inside the compose network). */ + public static String keycloakRealmUrl; + + protected static void setupKeycloakBase() throws JsonProcessingException { + serverApiUrl = TestConfiguration.get().baseUrl(); + serverHostUrl = serverApiUrl.replace("/api", ""); + keycloakRealmUrl = + System.getProperty("keycloak.realm.url", "http://keycloak:8080/realms/dhis2"); + log.info( + "[setupKeycloakBase] serverApiUrl={}, keycloakRealmUrl={}", serverApiUrl, keycloakRealmUrl); + + awaitKeycloak(); + + orgUnitUID = createOrgUnit(); + } + + /** Realm import on first boot can take a while; poll OIDC discovery until it responds. */ + private static void awaitKeycloak() { + RestTemplate template = new RestTemplate(); + String discoveryUrl = keycloakRealmUrl + "/.well-known/openid-configuration"; + long deadline = System.currentTimeMillis() + 180_000; + while (System.currentTimeMillis() < deadline) { + try { + ResponseEntity response = template.getForEntity(discoveryUrl, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + log.info("[awaitKeycloak] Keycloak realm is up at {}", keycloakRealmUrl); + return; + } + } catch (Exception e) { + log.info("[awaitKeycloak] not up yet ({}), retrying...", e.getMessage()); + } + try { + Thread.sleep(2_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted while waiting for Keycloak"); + } + } + fail("Keycloak realm did not become available within 180s: " + discoveryUrl); + } + + /** + * Obtains an access token from Keycloak via the direct access grant (resource owner password + * credentials). The realm client has directAccessGrantsEnabled and an audience mapper that puts + * {@value #CLIENT_ID} into the access token {@code aud} (required by DHIS2 JWT bearer auth). + */ + protected static String getKeycloakAccessToken(String username, String password) { + RestTemplate template = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "password"); + form.add("client_id", CLIENT_ID); + form.add("client_secret", CLIENT_SECRET); + form.add("username", username); + form.add("password", password); + form.add("scope", "openid email"); + + ResponseEntity response = + template.postForEntity( + keycloakRealmUrl + "/protocol/openid-connect/token", + new HttpEntity<>(form, headers), + String.class); + assertEquals(HttpStatus.OK, response.getStatusCode(), "Keycloak token endpoint failed"); + try { + JsonNode json = objectMapper.readTree(response.getBody()); + JsonNode accessToken = json.get("access_token"); + assertNotNull(accessToken, "No access_token in Keycloak response: " + response.getBody()); + return accessToken.asText(); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /** + * Creates a DHIS2 user mapped to a Keycloak user: {@code openId} must equal the IdP email claim + * and {@code externalAuth} must be true for the OIDC login flow to accept the user. + */ + protected static String createDhisOidcUser(String username, String email, String userRoleId) + throws JsonProcessingException { + Map userMap = + Map.of( + "username", + username, + "password", + "Test123###...", + "email", + email, + "openId", + email, + "externalAuth", + true, + "firstName", + "Oidc", + "surname", + "User", + "userRoles", + List.of(Map.of("id", userRoleId)), + "organisationUnits", + List.of(Map.of("id", orgUnitUID))); + + ResponseEntity response = postWithAdminBasicAuth("/users", userMap); + JsonNode json = objectMapper.readTree(response.getBody()); + assertEquals( + HttpStatus.CREATED, + response.getStatusCode(), + "user creation failed: " + response.getBody()); + String uid = json.get("response").get("uid").asText(); + assertNotNull(uid); + return uid; + } + + /** Creates a user role with NO authorities, for @RequiresAuthority denial tests. */ + protected static String createEmptyAuthorityRole(String name) throws JsonProcessingException { + ResponseEntity response = + postWithAdminBasicAuth("/userRoles", Map.of("name", name, "authorities", List.of())); + assertEquals( + HttpStatus.CREATED, + response.getStatusCode(), + "role creation failed: " + response.getBody()); + JsonNode json = objectMapper.readTree(response.getBody()); + String uid = json.get("response").get("uid").asText(); + assertNotNull(uid); + return uid; + } + + protected static ResponseEntity postWithBearerJwt(String fullUrl, String token) { + RestTemplate template = addBearerTokenAuthHeaders(new RestTemplate(), token); + try { + return template.exchange( + fullUrl, HttpMethod.POST, new HttpEntity<>("", jsonHeaders()), String.class); + } catch (HttpClientErrorException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getResponseBodyAsString()); + } + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakJwtBearerTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakJwtBearerTest.java new file mode 100644 index 000000000000..27f6725252e1 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakJwtBearerTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.oidc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * E2e coverage for API authentication with an external-IdP (Keycloak) JWT bearer token ({@code + * oidc.jwt.token.authentication.enabled}, {@code Dhis2JwtAuthenticationManagerResolver}). + * + *

Pinned contract: the token {@code iss} must match {@code oidc.provider.keycloak.issuer_uri}, + * the {@code aud} must contain the configured client id, and the {@code email} claim maps to {@code + * User.openId}. Authorization outcomes (403 message contract, ALL bypass) must be identical to + * every other authentication mechanism. + * + * @author Morten Svanæs + */ +@Tag("auth-idp") +@Slf4j +class KeycloakJwtBearerTest extends KeycloakBaseTest { + + private static final String MAINTENANCE_PATH = "/maintenance/categoryOptionComboUpdate"; + + @BeforeAll + static void setup() throws JsonProcessingException { + setupKeycloakBase(); + + // kcmapped -> DHIS2 superuser; kclimited -> DHIS2 user with an empty-authority role + createDhisOidcUser("kcmapped", "kcmapped@dhis2.org", SUPER_USER_ROLE_UID); + String emptyRole = createEmptyAuthorityRole("authIdpEmptyRoleJwt"); + createDhisOidcUser("kclimited", "kclimited@dhis2.org", emptyRole); + } + + @AfterAll + static void tearDown() { + invalidateAllSession(); + } + + @Test + void testBearerTokenAuthenticatesMappedUser() throws JsonProcessingException { + String token = getKeycloakAccessToken("kcmapped", "KcMapped123###"); + + ResponseEntity response = getWithBearerJwt(serverApiUrl + "/me", token); + + assertEquals(HttpStatus.OK, response.getStatusCode(), "body: " + response.getBody()); + JsonNode me = objectMapper.readTree(response.getBody()); + assertEquals("kcmapped", me.get("username").asText()); + } + + @Test + void testBearerTokenUnmappedUserIsRejected() { + String token = getKeycloakAccessToken("kcunmapped", "KcUnmapped123###"); + + ResponseEntity response = getWithBearerJwt(serverApiUrl + "/me", token); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue( + response + .getBody() + .contains( + "Found no matching DHIS2 user for the mapping claim: 'email' with the value:"), + "unexpected body: " + response.getBody()); + } + + @Test + void testGarbageBearerTokenIsRejected() { + ResponseEntity response = getWithBearerJwt(serverApiUrl + "/me", "garbage.token.value"); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void testRequiresAuthorityDeniedOverBearerToken() throws JsonProcessingException { + String token = getKeycloakAccessToken("kclimited", "KcLimited123###"); + + ResponseEntity response = postWithBearerJwt(serverApiUrl + MAINTENANCE_PATH, token); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode(), "body: " + response.getBody()); + JsonNode json = objectMapper.readTree(response.getBody()); + assertEquals( + "Access is denied, requires one Authority from [F_PERFORM_MAINTENANCE]", + json.get("message").asText()); + } + + @Test + void testRequiresAuthoritySuperuserBypassOverBearerToken() { + String token = getKeycloakAccessToken("kcmapped", "KcMapped123###"); + + ResponseEntity response = postWithBearerJwt(serverApiUrl + MAINTENANCE_PATH, token); + + assertEquals(HttpStatus.OK, response.getStatusCode(), "body: " + response.getBody()); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakOidcLoginTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakOidcLoginTest.java new file mode 100644 index 000000000000..8f69c4b58d38 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oidc/KeycloakOidcLoginTest.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.oidc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.test.e2e.helpers.config.TestConfiguration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Cookie; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * E2e coverage for the external-IdP OIDC login flow (browser redirect via {@code + * /oauth2/authorization/keycloak} -> Keycloak login form -> {@code /oauth2/code/keycloak} -> DHIS2 + * session with a {@code DhisOidcUser} principal). + * + *

Pinned contract: the IdP {@code email} claim maps to {@code User.openId}; the resulting + * session's authorities come from the DHIS2 user's roles (never from the IdP); unmapped IdP users + * fail to {@code /login/?oidcFailure=true}. + * + * @author Morten Svanæs + */ +@Tag("auth-idp") +@Slf4j +class KeycloakOidcLoginTest extends KeycloakBaseTest { + + private static String seleniumUrl; + + private WebDriver driver; + + @BeforeAll + static void setup() throws JsonProcessingException { + setupKeycloakBase(); + seleniumUrl = TestConfiguration.get().seleniumUrl(); + + createDhisOidcUser("kcloginmapped", "kcloginmapped@dhis2.org", SUPER_USER_ROLE_UID); + } + + @AfterEach + void cleanupDriver() { + if (driver != null) { + try { + driver.quit(); + } catch (Exception e) { + log.warn("[cleanupDriver] failed to quit WebDriver", e); + } + driver = null; + } + } + + @AfterAll + static void tearDown() { + invalidateAllSession(); + } + + @Test + void testLoginConfigListsKeycloakProvider() { + ResponseEntity response = getWithAdminBasicAuth("/loginConfig", null); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue( + response.getBody().contains("/oauth2/authorization/keycloak"), + "loginConfig should advertise the keycloak provider, body: " + response.getBody()); + } + + @Test + void testOidcLoginMappedUser() throws MalformedURLException, JsonProcessingException { + driver = createDriver(); + WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(90)); + + // Trigger the OIDC login redirect + driver.get(serverHostUrl + "/oauth2/authorization/keycloak"); + wait.until(ExpectedConditions.urlContains("/realms/dhis2/")); + log.info("[oidcLogin] on Keycloak login page: {}", driver.getCurrentUrl()); + + // Authenticate at Keycloak + wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("input#username"))); + driver.findElement(By.cssSelector("input#username")).sendKeys("kcloginmapped"); + driver.findElement(By.cssSelector("input#password")).sendKeys("KcLogin123###"); + driver.findElement(By.cssSelector("#kc-login")).click(); + + // Back at DHIS2 with an authenticated session + wait.until( + d -> { + String url = d.getCurrentUrl(); + return url.startsWith(serverHostUrl) && !url.contains("/realms/") ? url : null; + }); + log.info("[oidcLogin] back at DHIS2: {}", driver.getCurrentUrl()); + + Cookie session = driver.manage().getCookieNamed("JSESSIONID"); + assertNotNull(session, "no JSESSIONID cookie after OIDC login"); + + ResponseEntity me = getWithCookie("/me", session.getName() + "=" + session.getValue()); + assertEquals(HttpStatus.OK, me.getStatusCode(), "body: " + me.getBody()); + JsonNode json = objectMapper.readTree(me.getBody()); + assertEquals("kcloginmapped", json.get("username").asText()); + // authorities come from the DHIS2 role (superuser), never from the IdP + assertTrue(json.get("authorities").toString().contains("ALL"), "body: " + me.getBody()); + } + + @Test + void testOidcLoginUnmappedUserFails() throws MalformedURLException { + driver = createDriver(); + WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(90)); + + driver.get(serverHostUrl + "/oauth2/authorization/keycloak"); + wait.until(ExpectedConditions.urlContains("/realms/dhis2/")); + + wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("input#username"))); + driver.findElement(By.cssSelector("input#username")).sendKeys("kcunmapped"); + driver.findElement(By.cssSelector("input#password")).sendKeys("KcUnmapped123###"); + driver.findElement(By.cssSelector("#kc-login")).click(); + + // No matching DHIS2 user (User.openId) -> oauth2Login failureUrl + String failureUrl = + wait.until(ExpectedConditions.urlContains("oidcFailure")) ? driver.getCurrentUrl() : null; + assertNotNull(failureUrl); + assertTrue( + failureUrl.contains("oidcFailure=true"), + "expected redirect to /login/?oidcFailure=true, got: " + failureUrl); + } + + private WebDriver createDriver() throws MalformedURLException { + ChromeOptions chromeOptions = new ChromeOptions(); + chromeOptions.addArguments("--remote-allow-origins=*"); + chromeOptions.addArguments("--no-sandbox"); + chromeOptions.addArguments("--disable-dev-shm-usage"); + chromeOptions.addArguments("--disable-gpu"); + chromeOptions.setPageLoadTimeout(Duration.ofSeconds(60)); + return new RemoteWebDriver(new URL(seleniumUrl), chromeOptions); + } +} From 7d8547bf3b56970a43490f84120c7dbf56f5c102 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Sun, 19 Jul 2026 10:45:28 +0800 Subject: [PATCH 2/3] test: e2e coverage for LDAP bind authentication via OpenLDAP Extends the auth-idp e2e stack with an OpenLDAP container and LdapLoginTest, covering CustomLdapAuthenticationProvider/DhisBindAuthenticator which had zero automated coverage: - osixia/openldap service in docker-compose.e2e-auth.yml, seeded from config/ldap/users.ldif; ldap.* block in config/dhis2_home_auth/dhis.conf. - Pins: users route to LDAP only with ldapId + externalAuth=true; the (cn={0}) filter is filled with User.ldapId (fixtures make ldapId differ from the username to prove the rewrite); authorities come from DHIS2 roles; the DHIS2-stored password never authenticates an LDAP user; LDAP-only entries without a DHIS2 user are rejected; regular users keep DB-password login while LDAP is enabled; @RequiresAuthority 403 message contract and superuser bypass hold over LDAP sessions. Verified locally: full auth-idp stack boot, 14/14 green (8 Keycloak + 6 LDAP). AI Assisted --- .../config/dhis2_home_auth/dhis.conf | 10 + dhis-2/dhis-test-e2e/config/ldap/users.ldif | 27 +++ .../dhis-test-e2e/docker-compose.e2e-auth.yml | 14 ++ .../org/hisp/dhis/ldap/LdapLoginTest.java | 203 ++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 dhis-2/dhis-test-e2e/config/ldap/users.ldif create mode 100644 dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/ldap/LdapLoginTest.java diff --git a/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf b/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf index 603e9461ae1e..306644417803 100644 --- a/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf +++ b/dhis-2/dhis-test-e2e/config/dhis2_home_auth/dhis.conf @@ -60,6 +60,16 @@ oidc.provider.keycloak.user_info_uri = http://keycloak:8080/realms/dhis2/protoco oidc.provider.keycloak.jwk_uri = http://keycloak:8080/realms/dhis2/protocol/openid-connect/certs oidc.provider.keycloak.issuer_uri = http://keycloak:8080/realms/dhis2 +# LDAP bind authentication (see config/ldap/users.ldif). +# Active because ldap.url differs from the default and manager DN is set. +# DHIS2 users opt in via ldapId + externalAuth=true; the (cn={0}) filter is +# filled with User.ldapId, not the username. +ldap.url = ldap://openldap:389 +ldap.manager.dn = cn=admin,dc=dhis2,dc=org +ldap.manager.password = admin +ldap.search.base = ou=users,dc=dhis2,dc=org +ldap.search.filter = (cn={0}) + route.remote_servers_allowed = http://web:8080 # Enable monitoring API for /api/metrics endpoint testing diff --git a/dhis-2/dhis-test-e2e/config/ldap/users.ldif b/dhis-2/dhis-test-e2e/config/ldap/users.ldif new file mode 100644 index 000000000000..9dc2358abe8d --- /dev/null +++ b/dhis-2/dhis-test-e2e/config/ldap/users.ldif @@ -0,0 +1,27 @@ +# Seed entries for the auth-idp LDAP e2e tests (LdapLoginTest). +# Loaded by the osixia/openldap container (docker-compose.e2e-auth.yml). +# The DHIS2-side users are created by the tests; DHIS2 binds with the user's +# ldapId as the (cn={0}) filter value, so cn here deliberately differs from +# the DHIS2 username to pin that rewrite. + +dn: ou=users,dc=dhis2,dc=org +objectClass: organizationalUnit +ou: users + +dn: cn=ldapcn1,ou=users,dc=dhis2,dc=org +objectClass: inetOrgPerson +cn: ldapcn1 +sn: One +userPassword: ldapPass1### + +dn: cn=ldapcn2,ou=users,dc=dhis2,dc=org +objectClass: inetOrgPerson +cn: ldapcn2 +sn: Two +userPassword: ldapPass2### + +dn: cn=ldaporphancn,ou=users,dc=dhis2,dc=org +objectClass: inetOrgPerson +cn: ldaporphancn +sn: Orphan +userPassword: ldapOrphan### diff --git a/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml b/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml index 135632f714e4..6661d8e32249 100644 --- a/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml +++ b/dhis-2/dhis-test-e2e/docker-compose.e2e-auth.yml @@ -14,6 +14,8 @@ services: depends_on: keycloak: condition: service_started + openldap: + condition: service_started keycloak: image: quay.io/keycloak/keycloak:26.0 @@ -28,3 +30,15 @@ services: - ./config/keycloak/realm-dhis2.json:/opt/keycloak/data/import/realm-dhis2.json:ro ports: - "8080" + + openldap: + image: osixia/openldap:1.5.0 + command: ["--copy-service"] + environment: + LDAP_ORGANISATION: "DHIS2" + LDAP_DOMAIN: "dhis2.org" + LDAP_ADMIN_PASSWORD: "admin" + volumes: + - ./config/ldap/users.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/users.ldif:ro + ports: + - "389" diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/ldap/LdapLoginTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/ldap/LdapLoginTest.java new file mode 100644 index 000000000000..616c1ef740f0 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/ldap/LdapLoginTest.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.ldap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.BaseE2ETest; +import org.hisp.dhis.login.LoginResponse; +import org.hisp.dhis.test.e2e.helpers.config.TestConfiguration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; + +/** + * E2e coverage for LDAP bind authentication ({@code CustomLdapAuthenticationProvider} / {@code + * DhisBindAuthenticator}) against the OpenLDAP container in the auth-idp stack + * (docker-compose.e2e-auth.yml, seeded from config/ldap/users.ldif). + * + *

Pinned contract: a DHIS2 user routes to LDAP only with {@code ldapId} set AND {@code + * externalAuth=true}; the {@code (cn={0})} search filter is filled with {@code User.ldapId} (NOT + * the username — the fixtures deliberately differ); authorities always come from DHIS2 roles; + * password validation for such users happens exclusively against LDAP; non-LDAP users keep + * DB-password login while LDAP is enabled. + * + * @author Morten Svanæs + */ +@Tag("auth-idp") +@Slf4j +class LdapLoginTest extends BaseE2ETest { + + private static final String MAINTENANCE_PATH = "/maintenance/categoryOptionComboUpdate"; + + private static final String DHIS_STORED_PASSWORD = "DbStored123###"; + + @BeforeAll + static void setup() throws JsonProcessingException { + serverApiUrl = TestConfiguration.get().baseUrl(); + serverHostUrl = serverApiUrl.replace("/api", ""); + orgUnitUID = createOrgUnit(); + + // ldapuser1 -> superuser role, LDAP entry cn=ldapcn1 + createLdapUser("ldapuser1", "ldapcn1", SUPER_USER_ROLE_UID); + // ldapuser2 -> role without authorities, LDAP entry cn=ldapcn2 + String emptyRole = createEmptyRole("authIdpEmptyRoleLdap"); + createLdapUser("ldapuser2", "ldapcn2", emptyRole); + } + + @AfterAll + static void tearDown() { + invalidateAllSession(); + } + + @Test + void testLdapLoginSuccessAndAuthoritiesFromDhisRoles() throws JsonProcessingException { + String cookie = performInitialLogin("ldapuser1", "ldapPass1###"); + + ResponseEntity me = getWithCookie("/me", cookie); + assertEquals(HttpStatus.OK, me.getStatusCode(), "body: " + me.getBody()); + JsonNode json = objectMapper.readTree(me.getBody()); + assertEquals("ldapuser1", json.get("username").asText()); + // authorities come from the DHIS2 superuser role, never from the directory + assertTrue(json.get("authorities").toString().contains("ALL"), "body: " + me.getBody()); + } + + @Test + void testLdapLoginWrongPasswordIsRejected() { + ResponseEntity response = tryLogin("ldapuser1", "wrongPassword###"); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void testLdapUserRejectsDhisStoredPassword() { + // PIN: for ldapId+externalAuth users the DB password is never consulted; + // the stored DHIS2 password must NOT authenticate. + ResponseEntity response = tryLogin("ldapuser1", DHIS_STORED_PASSWORD); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void testLdapEntryWithoutDhisUserIsRejected() { + // cn=ldaporphancn exists in LDAP but has no DHIS2 user -> lookup by DHIS2 + // username fails before any bind is attempted. + ResponseEntity response = tryLogin("ldaporphancn", "ldapOrphan###"); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void testRequiresAuthorityContractOverLdapSession() throws JsonProcessingException { + String limitedCookie = performInitialLogin("ldapuser2", "ldapPass2###"); + + ResponseEntity denied = postWithCookie(MAINTENANCE_PATH, null, limitedCookie); + assertEquals(HttpStatus.FORBIDDEN, denied.getStatusCode(), "body: " + denied.getBody()); + JsonNode json = objectMapper.readTree(denied.getBody()); + assertEquals( + "Access is denied, requires one Authority from [F_PERFORM_MAINTENANCE]", + json.get("message").asText()); + + // superuser (ALL) bypass over an LDAP session + String superCookie = performInitialLogin("ldapuser1", "ldapPass1###"); + ResponseEntity allowed = postWithCookie(MAINTENANCE_PATH, null, superCookie); + assertEquals(HttpStatus.OK, allowed.getStatusCode(), "body: " + allowed.getBody()); + } + + @Test + void testNonLdapUserKeepsDbPasswordLogin() { + // LDAP being configured must not capture regular users (no ldapId/externalAuth) + ResponseEntity me = getWithAdminBasicAuth("/me", null); + assertEquals(HttpStatus.OK, me.getStatusCode()); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResponseEntity tryLogin(String username, String password) { + try { + return loginWithUsernameAndPassword(username, password, null); + } catch (HttpClientErrorException e) { + return ResponseEntity.status(e.getStatusCode()).build(); + } + } + + private static void createLdapUser(String username, String ldapId, String userRoleId) + throws JsonProcessingException { + Map userMap = + Map.of( + "username", + username, + "password", + DHIS_STORED_PASSWORD, + "email", + username + "@dhis2.org", + "ldapId", + ldapId, + "externalAuth", + true, + "firstName", + "Ldap", + "surname", + "User", + "userRoles", + List.of(Map.of("id", userRoleId)), + "organisationUnits", + List.of(Map.of("id", orgUnitUID))); + + ResponseEntity response = postWithAdminBasicAuth("/users", userMap); + assertEquals( + HttpStatus.CREATED, + response.getStatusCode(), + "user creation failed: " + response.getBody()); + JsonNode json = objectMapper.readTree(response.getBody()); + assertNotNull(json.get("response").get("uid").asText()); + } + + private static String createEmptyRole(String name) throws JsonProcessingException { + ResponseEntity response = + postWithAdminBasicAuth("/userRoles", Map.of("name", name, "authorities", List.of())); + assertEquals( + HttpStatus.CREATED, + response.getStatusCode(), + "role creation failed: " + response.getBody()); + JsonNode json = objectMapper.readTree(response.getBody()); + return json.get("response").get("uid").asText(); + } +} From 36abdfcfb2ab09df00fbd3258aee0bbb9878fecf Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 20 Jul 2026 19:37:17 +0800 Subject: [PATCH 3/3] ci: run auth IdP e2e tests on every PR, reusing the core-pr image For same-repo PRs, pull the dhis2/core-pr: image that the api tests workflow pushes to Docker Hub (verified against the PR head SHA via the DHIS2_BUILD_REVISION label) instead of rebuilding it. Fork and dependabot PRs, nightly and manual runs still build the image locally. --- .github/workflows/run-auth-idp-tests.yml | 40 +++++++++++++++++------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/run-auth-idp-tests.yml b/.github/workflows/run-auth-idp-tests.yml index b4f88bc9d88e..d02f8fdb2580 100644 --- a/.github/workflows/run-auth-idp-tests.yml +++ b/.github/workflows/run-auth-idp-tests.yml @@ -2,23 +2,14 @@ name: Run auth IdP tests # External-IdP authentication e2e suite (Keycloak OIDC, LDAP): runs the dhis-test-e2e # "auth-idp" tagged tests against the docker-compose.e2e-auth.yml stack. -# Triggered by PRs touching security-relevant paths, nightly on master, and manually. +# Runs on every PR (reusing the dhis2/core-pr image published by "Run api tests" +# when possible), nightly on master, and manually. env: MAVEN_OPTS: -Xmx1024m -Xms1024m -Dhttp.keepAlive=false -Dmaven.wagon.http.pool=false -Dmaven.wagon.http.retryHandler.class=standard -Dmaven.wagon.http.retryHandler.count=3 -Dmaven.wagon.httpconnectionManager.ttlSeconds=125 on: pull_request: - paths: - - "dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/**" - - "dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/**" - - "dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/**" - - "dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/**" - - "dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/**" - - "dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/mvc/interceptor/**" - - "dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/**" - - "dhis-2/dhis-test-e2e/**" - - ".github/workflows/run-auth-idp-tests.yml" schedule: # Nightly on the default branch - cron: "30 3 * * *" @@ -32,17 +23,42 @@ jobs: auth-idp-test: runs-on: ubuntu-latest env: - CORE_IMAGE_NAME: "dhis2/core-dev:local" + # Mirrors DOCKERHUB_PUSH in run-api-tests.yml: for same-repo PRs the api tests + # workflow pushes dhis2/core-pr: to Docker Hub, so we pull it instead of + # rebuilding. Fork/dependabot PRs, nightly and dispatch runs build the image here. + REUSE_PR_IMAGE: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }} + CORE_IMAGE_NAME: ${{ (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]') && format('dhis2/core-pr:{0}', github.event.number) || 'dhis2/core-dev:local' }} steps: - uses: actions/checkout@v6 - name: Set up JDK 17 + if: env.REUSE_PR_IMAGE != 'true' uses: actions/setup-java@v5 with: java-version: 17 distribution: temurin cache: maven + - name: Wait for PR image from api tests build + if: env.REUSE_PR_IMAGE == 'true' + run: | + expected="${{ github.event.pull_request.head.sha }}" + echo "Waiting for $CORE_IMAGE_NAME with revision $expected" + for i in $(seq 1 60); do + if docker pull --quiet "$CORE_IMAGE_NAME" > /dev/null 2>&1; then + revision=$(docker inspect --format '{{ index .Config.Labels "DHIS2_BUILD_REVISION" }}' "$CORE_IMAGE_NAME") + if [ "$revision" = "$expected" ]; then + echo "Found $CORE_IMAGE_NAME for revision $revision" + exit 0 + fi + echo "Image revision '$revision' is stale, retrying..." + fi + sleep 20 + done + echo "Timed out waiting for $CORE_IMAGE_NAME with revision $expected (did the api tests build fail?)" >&2 + exit 1 + - name: Build container image + if: env.REUSE_PR_IMAGE != 'true' run: | mvn clean verify --threads 2C --batch-mode --no-transfer-progress \ -DskipTests -Dpackaging.type=jar -Dmdep.analyze.skip --update-snapshots --file dhis-2/pom.xml \