From 904d3131a0dd59ae84b23c5b9a65cfce5ef3b651 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Wed, 6 May 2026 23:53:44 +0800 Subject: [PATCH 01/25] docs(oidc): add design spec for eSignet JWT userinfo support [DHIS2-20043] Per-provider config switch (user_info_response_type=json|jwt, default json) lets eSignet's signed-JWT userinfo flow be selected via dhis.conf without altering behaviour for any existing OIDC provider. Generic provider builder is the integration point. Captures scope, component contracts, validation rules, tests, and the decision to keep principalNameAttribute=sub in both modes. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-05-06-esignet-oidc-integration-design.md | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md diff --git a/dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md b/dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md new file mode 100644 index 000000000000..8434525d241b --- /dev/null +++ b/dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md @@ -0,0 +1,263 @@ +# eSignet OIDC Integration — Design Spec + +- Date: 2026-05-06 +- Author: Morten Svanæs +- Status: Approved +- Related: PR [#22027](https://github.com/dhis2/dhis2-core/pull/22027), Spring Security issue [#9583](https://github.com/spring-projects/spring-security/issues/9583) + +## 1. Background + +PR #22027 was a POC integration of MOSIP eSignet for HISP Sri Lanka. eSignet +returns its OIDC userinfo response as a signed JWT (`Content-Type: application/jwt`). +Spring Security's `OidcUserService` only handles `application/json` userinfo; +the open Spring issue confirms this has not been addressed upstream. + +The POC patched DHIS2's `DhisOidcUserService` to **always** treat userinfo as +a signed JWT. This works for eSignet but breaks every other configured OIDC +provider (Google, Azure AD, WSO2, generic OIDC, internal DHIS2-as-IdP), all +of which return JSON userinfo. The PR also `@Disabled`-ed the e2e +`OAuth2Test` to hide that regression and blanket-relaxed two required keys +in the generic-provider parser. + +## 2. Goal + +Land eSignet support on `master` so that: + +1. The default behaviour (JSON userinfo) is preserved byte-for-byte for + every existing provider. +2. The eSignet JWT-userinfo flow is reachable via `dhis.conf` configuration + only — no code changes to switch a provider over. +3. The generic provider builder is the integration point — no bespoke + `EsignetProvider` class — so future IdPs with the same userinfo pattern + are configured the same way. + +## 3. Non-goals + +- **JWE (encrypted) userinfo responses.** eSignet does not use them. If a + future IdP requires JWE, add `user_info_jwe_algorithm` + a key reference + pointing at the existing private-key keystore the registration already + loads. Out of scope here. +- **Signed authorization requests.** Not in PR #22027; separate feature. +- **Per-provider override of authorization-grant or scopes** beyond what the + generic builder already supports. + +## 4. Configuration + +Two new keys in the existing `oidc.provider..*` namespace, declared as +constants on `AbstractOidcProvider` and consumed by +`GenericOidcProviderBuilder` and `GenericOidcProviderConfigParser`. + +| Key | Values | Default | Required | Notes | +|---|---|---|---|---| +| `user_info_response_type` | `json`, `jwt` | `json` | no | `jwt` selects the eSignet path. | +| `user_info_jws_algorithm` | `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512` | `RS256` | no | Consulted only when `user_info_response_type=jwt`. Validated at parse time against a fixed allow-list. | + +Per-provider scope (no global flag) — confirmed. + +### Sample `dhis.conf` block (eSignet via generic provider) + +```properties +oidc.provider.esignet.client_id = dhis2-client +oidc.provider.esignet.authorization_uri = https://esignet.example/authorize +oidc.provider.esignet.token_uri = https://esignet.example/oauth/token +oidc.provider.esignet.user_info_uri = https://esignet.example/oidc/userinfo +oidc.provider.esignet.jwk_uri = https://esignet.example/.well-known/jwks.json +oidc.provider.esignet.client_authentication_method = private_key_jwt +oidc.provider.esignet.keystore_path = /opt/dhis2/esignet.p12 +oidc.provider.esignet.keystore_password = ... +oidc.provider.esignet.key_alias = esignet +oidc.provider.esignet.key_password = ... +oidc.provider.esignet.user_info_response_type = jwt +oidc.provider.esignet.user_info_jws_algorithm = RS256 +oidc.provider.esignet.mapping_claim = sub +``` + +## 5. Component changes + +### 5.1 `AbstractOidcProvider` + +Add two `String` constants: + +```java +public static final String USER_INFO_RESPONSE_TYPE = "user_info_response_type"; +public static final String USER_INFO_JWS_ALGORITHM = "user_info_jws_algorithm"; +``` + +### 5.2 `UserInfoResponseType` enum (new) + +```java +public enum UserInfoResponseType { JSON, JWT } +``` + +Lives next to `DhisOidcClientRegistration` in `org.hisp.dhis.security.oidc`. + +### 5.3 `DhisOidcClientRegistration` + +Two new fields: + +```java +@Builder.Default +private final UserInfoResponseType userInfoResponseType = UserInfoResponseType.JSON; + +private final JWSAlgorithm userInfoJwsAlgorithm; // null when JSON +``` + +### 5.4 `GenericOidcProviderBuilder` + +Read the two new keys from the provider config, default `JSON` / `RS256`, +populate the new `DhisOidcClientRegistration` fields. JWSAlgorithm parsed +from the validated allow-list. + +### 5.5 `GenericOidcProviderConfigParser` + +- Register `USER_INFO_RESPONSE_TYPE` and `USER_INFO_JWS_ALGORITHM` in + `KEY_REQUIRED_MAP` as `Boolean.FALSE`. +- Keep `CLIENT_SECRET` and `USERINFO_URI` as `Boolean.TRUE` in the static + map. +- Add a post-validation step in `validateConfig` that relaxes `CLIENT_SECRET` + to optional **only when** `client_authentication_method = private_key_jwt` + and the four `keystore_*` keys are present. Keeps "did you mean…" + diagnostics for ordinary misconfig and only loosens rules where the + alternative is genuinely satisfied. +- Validate `user_info_response_type` against the enum and + `user_info_jws_algorithm` against the fixed algorithm allow-list at parse + time. + +`USERINFO_URI` stays required in both modes — the JWT path still calls it, +only the `Accept` header changes. + +### 5.6 `DhisOidcUserService` — restore inheritance, branch on type + +```java +@Service +public class DhisOidcUserService extends OidcUserService { + + @Autowired private DhisOidcProviderRepository repo; + @Autowired private UserService userService; + @Autowired private SignedJwtUserInfoLoader signedJwtLoader; + + @Override + public OidcUser loadUser(OidcUserRequest req) throws OAuth2AuthenticationException { + DhisOidcClientRegistration reg = + repo.getDhisOidcClientRegistration(req.getClientRegistration().getRegistrationId()); + + return switch (reg.getUserInfoResponseType()) { + case JSON -> loadFromJsonUserInfo(req, reg); + case JWT -> signedJwtLoader.load(req, reg); + }; + } + + // loadFromJsonUserInfo == today's logic, unchanged: super.loadUser() + // -> resolve mappingClaimKey -> userService.getUserByOpenId -> DhisOidcUser +} +``` + +The JSON branch is the existing method body verbatim. **No behaviour change +for any existing provider.** + +### 5.7 `SignedJwtUserInfoLoader` (new collaborator) + +Encapsulates the eSignet-style fetch + verify + map-to-user. Roughly: + +```java +@Component +@RequiredArgsConstructor +public class SignedJwtUserInfoLoader { + + private final UserService userService; + private final RestTemplate restTemplate; // shared bean + private final JwkSourceCache jwkSourceCache; // per-registration + + public OidcUser load(OidcUserRequest req, DhisOidcClientRegistration reg) { + String jwt = fetchJwtUserInfo(req); // GET userinfo, Accept: application/jwt + JWTClaimsSet claims = verify(jwt, reg); // Nimbus, alg from reg + String mappingValue = requireMappingClaim(claims, reg); + User user = requireExistingExternalAuthUser(mappingValue, reg); + UserDetails details = userService.createUserDetails(user); + return new DhisOidcUser(details, claims.toJSONObject(), + IdTokenClaimNames.SUB, req.getIdToken()); + } +} +``` + +Notes: + +- `principalNameAttribute` stays `IdTokenClaimNames.SUB` in **both** JSON + and JWT modes. The mapping claim is used only for DHIS2 user lookup, not + as the principal name. This preserves audit-log behaviour. +- The `User` lookup applies the same `isExternalAuth() / isDisabled() / + isAccountNonExpired()` checks the JSON path applies today. +- Fetch failure, JWS-verification failure, missing mapping claim, and + user-not-found each raise `OAuth2AuthenticationException` with distinct + `OAuth2Error` codes (`invalid_user_info_response`, `jwt_processing_error`, + `missing_mapping_claim`, `user_not_found`). +- `RestTemplate` is the shared OIDC HTTP client (or a new properly + configured bean wired into `OidcConfig`) — not `new RestTemplate()` per + call. +- Algorithm is taken from `reg.getUserInfoJwsAlgorithm()`; not hard-coded. + +### 5.8 `JwkSourceCache` (new) + +Builds a Nimbus `JWKSource` once per registration id and +caches it in a `ConcurrentHashMap`. Constructed lazily on first JWT-mode +login (or eagerly when the registration is built — implementation choice +during the writing-plans pass). Nimbus's built-in remote-key cache and +refresh policy handles JWKS rotation; we just don't rebuild the source on +every call. + +### 5.9 `OAuth2Test` + +Remove `@Disabled`. The JSON e2e path is restored once `DhisOidcUserService` +keeps its `extends OidcUserService` contract; the test should pass without +further change. + +### 5.10 `PublicKeysController` + +Keep the PR's `x509CertSHA256Thumbprint` addition. Additive — only emits a +thumbprint when the underlying JWK has one. + +## 6. Tests + +- **Unit — `SignedJwtUserInfoLoaderTest`**: happy path, bad signature, + algorithm mismatch (config says RS256, token signed with PS256), missing + mapping claim, HTTP fetch failure, user-not-found. Each asserts the + correct `OAuth2Error.errorCode`. +- **Unit — `GenericOidcProviderConfigParserTest`** (extend existing): + - `user_info_response_type=jwt` round-trips to `UserInfoResponseType.JWT`. + - `user_info_jws_algorithm=PS256` round-trips to `JWSAlgorithm.PS256`. + - Invalid algorithm fails parse-time validation. + - `client_secret` missing without `private_key_jwt` → invalid. + - `client_secret` missing with `private_key_jwt` + keystore keys → valid. + - `user_info_uri` always required regardless of `user_info_response_type`. +- **Unit — `DhisOidcUserServiceTest`**: dispatch test that JSON-mode + registrations call the existing path and JWT-mode registrations call + `SignedJwtUserInfoLoader`. +- **E2E — `OAuth2Test`**: re-enabled, no other change. + +## 7. Documentation + +Short addition to `oauth.md` (the OIDC reference chapter): + +- Two-row table for the new keys. +- The eSignet `dhis.conf` example block from §4. +- One-paragraph note that JSON userinfo remains the default and existing + provider configs need no changes. + +## 8. Migration / coordination + +- No Flyway migration. +- No backwards-incompatible config change (new keys are optional with + defaults that match prior behaviour). +- No WOW coordination entry needed. + +## 9. Risks + +- **Algorithm allow-list drift.** If Nimbus introduces new JWS algorithms, + the allow-list won't include them until updated. Acceptable — failing + closed at parse time is preferable to surprising algorithm acceptance. +- **JWKS rotation latency.** Nimbus's default refresh policy applies; if an + IdP rotates keys aggressively we may need to expose its cache TTL as + config later. Not addressed in this spec. +- **`UserService.getUserByOpenId` mapping.** The JWT mode looks up the user + by the configured mapping claim's value, same as the JSON mode. Confirmed + no audit-log breakage because `principalNameAttribute` stays `sub`. From 4f5c50a934d5f19a3454664566affb280d5ff4ba Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 00:16:16 +0800 Subject: [PATCH 02/25] docs(oidc): add implementation plan for eSignet JWT userinfo support [DHIS2-20043] Eleven-task TDD plan covering enum, algorithm allow-list, registration fields, generic builder wiring, parser validation, JWKSource cache, SignedJwtUserInfoLoader, DhisOidcUserService dispatch, OAuth2Test re-enable, and PublicKeysController thumbprint cherry-pick. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-06-esignet-oidc-jwt-userinfo.md | 1606 +++++++++++++++++ 1 file changed, 1606 insertions(+) create mode 100644 dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md diff --git a/dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md b/dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md new file mode 100644 index 000000000000..ded5e7b5bbda --- /dev/null +++ b/dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md @@ -0,0 +1,1606 @@ +# eSignet OIDC JWT Userinfo Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-provider opt-in support for JWT-encoded OIDC userinfo responses (eSignet style) without changing default JSON-userinfo behaviour for any existing provider. + +**Architecture:** Two new generic-provider config keys (`user_info_response_type`, `user_info_jws_algorithm`). `DhisOidcUserService` regains `extends OidcUserService` and dispatches to either the existing JSON path (`super.loadUser`) or a new `SignedJwtUserInfoLoader` based on the registration's `userInfoResponseType`. Generic parser conditionally relaxes `client_secret` only under `private_key_jwt`. + +**Tech Stack:** Java 17, Spring Security OAuth2 Client 6.x, Nimbus JOSE+JWT (already on the classpath), JUnit 5, Mockito. + +**Spec:** `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md` + +--- + +## File Map + +**Create:** + +| Path | Responsibility | +|---|---| +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java` | Enum: `JSON`, `JWT` | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java` | Static allow-list + `parse(String)` returning `JWSAlgorithm` | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java` | Per-registration `JWKSource` cache | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java` | Fetches `application/jwt` userinfo, verifies, maps to DHIS2 user | +| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java` | Unit tests for allow-list parsing | +| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java` | Unit tests for the JWT path | +| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java` | Verifies JSON vs JWT branch dispatch | + +**Modify:** + +| Path | Change | +|---|---| +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java` | Add `USER_INFO_RESPONSE_TYPE` and `USER_INFO_JWS_ALGORITHM` constants | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java` | Add `userInfoResponseType` (default `JSON`) and `userInfoJwsAlgorithm` fields | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java` | Read new keys, populate new fields, relax `clientSecret` requirement under `private_key_jwt` | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java` | Register new keys, validate enum/algorithm values, conditionally skip `client_secret` required-check | +| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java` | Restore `extends OidcUserService`, branch on `userInfoResponseType` | +| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java` | New cases for the new keys and conditional client_secret relaxation | +| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java` | New cases for `userInfoResponseType` / `userInfoJwsAlgorithm` round-trip and `private_key_jwt` allowing missing secret | +| `dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java` | Remove `@Disabled` | + +**License header:** Every new `.java` file uses the standard DHIS2 BSD header with `$YEAR` placeholder. Spotless will fill it in. Author tag: `@author Morten Svanæs ` per CLAUDE.md ("Always add me as author when you create a new file"). + +--- + +## Conventions + +- Use Java 17. Confirm with `java -version` before any test/format step. +- After **every** code change run `mvn spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core` (or the relevant module) before committing. +- Stage files explicitly with their full path. Never `git add -A` / `git add .`. +- `git status --short` before each commit; `git diff --cached --stat | tail -1` before any amend. +- Module build prerequisite for test runs: `mvn install -pl dhis-2/dhis-services/dhis-service-core -am -DskipTests -q` once at the start of a working session. + +--- + +## Task 1: `UserInfoResponseType` enum + +**Files:** +- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java` + +- [ ] **Step 1: Create the enum** + +```java +/* + * Copyright (c) 2004-$YEAR, 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.security.oidc; + +import javax.annotation.CheckForNull; + +/** + * Selects how DHIS2 should consume the OIDC userinfo response for a given provider. + * + *
    + *
  • {@link #JSON} — Spring Security's default: userinfo endpoint returns + * {@code application/json}. + *
  • {@link #JWT} — userinfo endpoint returns a signed JWT + * ({@code application/jwt}); used by MOSIP eSignet and similar IdPs. + *
+ * + * @author Morten Svanæs + */ +public enum UserInfoResponseType { + JSON, + JWT; + + /** + * @param value config string from {@code dhis.conf}; case-insensitive + * @return matching enum, defaulting to {@link #JSON} when {@code value} is + * {@code null} or blank + * @throws IllegalArgumentException for unknown values + */ + public static UserInfoResponseType fromConfig(@CheckForNull String value) { + if (value == null || value.isBlank()) { + return JSON; + } + return UserInfoResponseType.valueOf(value.trim().toUpperCase()); + } +} +``` + +- [ ] **Step 2: Compile** + +Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Format** + +Run: `mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core` + +- [ ] **Step 4: Commit** + +```bash +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java +git commit -m "feat(oidc): add UserInfoResponseType enum [DHIS2-20043]" +``` + +--- + +## Task 2: `SupportedJwsAlgorithms` allow-list (TDD) + +**Files:** +- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java` +- Test: `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +/* (standard $YEAR header) */ +package org.hisp.dhis.security.oidc; + +import static org.junit.jupiter.api.Assertions.*; + +import com.nimbusds.jose.JWSAlgorithm; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SupportedJwsAlgorithmsTest { + + @Test + void parsesNullAsRs256Default() { + assertEquals(JWSAlgorithm.RS256, SupportedJwsAlgorithms.parseOrDefault(null)); + } + + @Test + void parsesBlankAsRs256Default() { + assertEquals(JWSAlgorithm.RS256, SupportedJwsAlgorithms.parseOrDefault(" ")); + } + + @ParameterizedTest + @ValueSource(strings = {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}) + void parsesAllSupportedAlgorithms(String name) { + assertEquals(name, SupportedJwsAlgorithms.parseOrDefault(name).getName()); + } + + @Test + void rejectsUnsupportedAlgorithm() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> SupportedJwsAlgorithms.parseOrDefault("HS256")); + assertTrue(ex.getMessage().contains("HS256")); + } + + @Test + void rejectsNonsense() { + assertThrows(IllegalArgumentException.class, () -> SupportedJwsAlgorithms.parseOrDefault("nope")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=SupportedJwsAlgorithmsTest +``` +Expected: COMPILATION FAILURE (`SupportedJwsAlgorithms` does not exist). + +- [ ] **Step 3: Implement** + +Create `SupportedJwsAlgorithms.java`: + +```java +/* (standard $YEAR header) */ +package org.hisp.dhis.security.oidc; + +import com.nimbusds.jose.JWSAlgorithm; +import java.util.Set; +import javax.annotation.CheckForNull; + +/** + * Allow-list of JWS algorithms accepted for OIDC userinfo JWT verification. Failing closed at parse + * time prevents accidental acceptance of unexpected signature algorithms (e.g. HMAC) configured in + * {@code dhis.conf}. + * + * @author Morten Svanæs + */ +public final class SupportedJwsAlgorithms { + + public static final JWSAlgorithm DEFAULT = JWSAlgorithm.RS256; + + private static final Set ALLOWED = + Set.of( + JWSAlgorithm.RS256, JWSAlgorithm.RS384, JWSAlgorithm.RS512, + JWSAlgorithm.PS256, JWSAlgorithm.PS384, JWSAlgorithm.PS512, + JWSAlgorithm.ES256, JWSAlgorithm.ES384, JWSAlgorithm.ES512); + + private SupportedJwsAlgorithms() {} + + /** + * Parses a configured JWS algorithm name against the allow-list. + * + * @param value config string from {@code dhis.conf}; case-sensitive (Nimbus algorithm names) + * @return the matching {@link JWSAlgorithm}, or {@link #DEFAULT} when {@code value} is null/blank + * @throws IllegalArgumentException when the algorithm is not in the allow-list + */ + public static JWSAlgorithm parseOrDefault(@CheckForNull String value) { + if (value == null || value.isBlank()) { + return DEFAULT; + } + JWSAlgorithm parsed = JWSAlgorithm.parse(value.trim()); + if (!ALLOWED.contains(parsed)) { + throw new IllegalArgumentException( + "Unsupported user_info_jws_algorithm: '" + value + "'. Allowed: " + ALLOWED); + } + return parsed; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=SupportedJwsAlgorithmsTest +``` +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 5: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java \ + dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java +git commit -m "feat(oidc): add JWS algorithm allow-list for userinfo verification [DHIS2-20043]" +``` + +--- + +## Task 3: New constants on `AbstractOidcProvider` + +**Files:** +- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java` (add after line 121, before closing brace) + +- [ ] **Step 1: Add the two constants** + +Append the following after the existing `JWK_SET_URL` constant (currently line 121): + +```java + /** + * Selects userinfo response handling: {@code json} (default; Spring Security's normal path) or + * {@code jwt} (eSignet-style signed JWT). See {@link + * org.hisp.dhis.security.oidc.UserInfoResponseType}. + */ + public static final String USER_INFO_RESPONSE_TYPE = "user_info_response_type"; + + /** + * JWS algorithm used to verify the userinfo JWT when {@link #USER_INFO_RESPONSE_TYPE} is + * {@code jwt}. Defaults to {@code RS256}. See {@link + * org.hisp.dhis.security.oidc.SupportedJwsAlgorithms}. + */ + public static final String USER_INFO_JWS_ALGORITHM = "user_info_jws_algorithm"; +``` + +- [ ] **Step 2: Compile** + +Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java +git commit -m "feat(oidc): add user_info_response_type and user_info_jws_algorithm config keys [DHIS2-20043]" +``` + +--- + +## Task 4: Extend `DhisOidcClientRegistration` with the two new fields + +**Files:** +- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java` + +- [ ] **Step 1: Add imports** + +Add (preserving existing import order, alphabetical within sections): + +```java +import com.nimbusds.jose.JWSAlgorithm; +import javax.annotation.CheckForNull; +``` + +- [ ] **Step 2: Add the two fields** + +Place inside the `@Data @Builder` class body, after the existing `private final String jwkSetUrl;` field and before `@Builder.Default private final boolean visibleOnLoginPage = true;`: + +```java + /** + * Selects how DHIS2 consumes this provider's userinfo response. Defaults to + * {@link UserInfoResponseType#JSON}, preserving the historical Spring Security + * behaviour for every existing provider. + */ + @Builder.Default + private final UserInfoResponseType userInfoResponseType = UserInfoResponseType.JSON; + + /** + * JWS algorithm used to verify the signed userinfo JWT. Only consulted when + * {@link #userInfoResponseType} is {@link UserInfoResponseType#JWT}. + */ + @CheckForNull private final JWSAlgorithm userInfoJwsAlgorithm; +``` + +- [ ] **Step 3: Compile** + +Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java +git commit -m "feat(oidc): track userInfo response type and JWS algorithm on registration [DHIS2-20043]" +``` + +--- + +## Task 5: `GenericOidcProviderConfigParser` — register keys, validate values, conditionally skip `client_secret` + +**Files:** +- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java` +- Modify (tests): `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java` + +- [ ] **Step 1: Write failing tests** + +Append to `GenericOidcProviderBuilderConfigParserTest`: + +```java + // --- new keys: user_info_response_type / user_info_jws_algorithm --- + + @Test + void parseAcceptsUserInfoResponseTypeJwt() { + Properties p = baseValidProvider("idporten"); + p.put("oidc.provider.idporten.user_info_response_type", "jwt"); + p.put("oidc.provider.idporten.user_info_jws_algorithm", "PS256"); + List parse = GenericOidcProviderConfigParser.parse(p); + assertThat(parse, hasSize(1)); + assertEquals(UserInfoResponseType.JWT, parse.get(0).getUserInfoResponseType()); + assertEquals("PS256", parse.get(0).getUserInfoJwsAlgorithm().getName()); + } + + @Test + void parseRejectsUnknownUserInfoResponseType() { + Properties p = baseValidProvider("idporten"); + p.put("oidc.provider.idporten.user_info_response_type", "yaml"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + @Test + void parseRejectsUnsupportedJwsAlgorithm() { + Properties p = baseValidProvider("idporten"); + p.put("oidc.provider.idporten.user_info_response_type", "jwt"); + p.put("oidc.provider.idporten.user_info_jws_algorithm", "HS256"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + // --- conditional client_secret relaxation --- + + @Test + void parseRejectsMissingClientSecretWithoutPrivateKeyJwt() { + Properties p = baseValidProvider("idporten"); + p.remove("oidc.provider.idporten.client_secret"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + @Test + void parseAcceptsMissingClientSecretWithPrivateKeyJwt() { + Properties p = baseValidProvider("idporten"); + p.remove("oidc.provider.idporten.client_secret"); + p.put("oidc.provider.idporten.client_authentication_method", "private_key_jwt"); + p.put("oidc.provider.idporten.keystore_path", "/tmp/does-not-need-to-exist.p12"); + p.put("oidc.provider.idporten.keystore_password", "x"); + p.put("oidc.provider.idporten.key_alias", "x"); + p.put("oidc.provider.idporten.key_password", "x"); + p.put("oidc.provider.idporten.jwk_set_url", "https://example.test/jwks"); + // Builder may still fail to load the keystore from disk; we only assert the + // *parser* accepts the config. Wrap to ignore loader-time IO errors. + try { + GenericOidcProviderConfigParser.parse(p); + } catch (IllegalStateException expected) { + // builder can throw when reading non-existent keystore — that's fine for + // this parser-level assertion: validation passed before construction. + return; + } + } + + @Test + void parseStillRequiresUserInfoUriEvenInJwtMode() { + Properties p = baseValidProvider("idporten"); + p.remove("oidc.provider.idporten.user_info_uri"); + p.put("oidc.provider.idporten.user_info_response_type", "jwt"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + private static Properties baseValidProvider(String id) { + Properties p = new Properties(); + String pre = "oidc.provider." + id + "."; + p.put(pre + "client_id", "testClientId"); + p.put(pre + "client_secret", "testClientSecret"); + p.put(pre + "authorization_uri", "https://oidc.test/authorize"); + p.put(pre + "token_uri", "https://oidc.test/token"); + p.put(pre + "user_info_uri", "https://oidc.test/userinfo"); + p.put(pre + "jwk_uri", "https://oidc.test/jwk"); + return p; + } +``` + +Add the corresponding imports at the top of the test class: + +```java +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.hisp.dhis.security.oidc.UserInfoResponseType; +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=GenericOidcProviderBuilderConfigParserTest +``` +Expected: failures on the five new tests. + +- [ ] **Step 3: Implement parser changes** + +In `GenericOidcProviderConfigParser.java`: + +a. Add imports: + +```java +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_KEYSTORE_PATH; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_KEYSTORE_PASSWORD; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_ALIAS; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_PASSWORD; + +import org.hisp.dhis.security.oidc.SupportedJwsAlgorithms; +import org.hisp.dhis.security.oidc.UserInfoResponseType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +``` + +(Some are already imported; only add the missing ones.) + +b. Register the new keys in `KEY_REQUIRED_MAP` (inside the static block, alongside the existing `Boolean.FALSE` entries): + +```java + builder.put(USER_INFO_RESPONSE_TYPE, Boolean.FALSE); + builder.put(USER_INFO_JWS_ALGORITHM, Boolean.FALSE); +``` + +c. Replace the body of `validateConfig` so it (1) skips the `client_secret` required-check under `private_key_jwt`, and (2) validates enum and algorithm values. Replace the existing method body with: + +```java + private static boolean validateConfig(Map providerConfig) { + Objects.requireNonNull(providerConfig); + + String providerId = providerConfig.get(PROVIDER_ID); + boolean privateKeyJwt = isPrivateKeyJwt(providerConfig); + + for (Map.Entry entry : KEY_REQUIRED_MAP.entrySet()) { + String key = entry.getKey(); + boolean isRequired = entry.getValue(); + String value = providerConfig.get(key); + + if (CLIENT_SECRET.equals(key) && privateKeyJwt) { + // client_secret is not used when authenticating with private_key_jwt + continue; + } + + if (isRequired && Strings.isNullOrEmpty(value)) { + log.error( + "OpenId Connect (OIDC) configuration for provider: '{}' is missing a required property: '{}'. " + + "Failed to configure the provider successfully!", + providerId, + key); + return false; + } + + UrlValidator urlValidator = new UrlValidator(UrlValidator.ALLOW_LOCAL_URLS); + if (value != null && key.endsWith("uri") && !urlValidator.isValid(value)) { + log.error( + "OpenId Connect (OIDC) configuration for provider: '{}' has a URI property: '{}', " + + "with a malformed value: '{}'. Failed to configure the provider successfully!", + providerId, + key, + value); + return false; + } + } + + return validateUserInfoResponseType(providerId, providerConfig); + } + + private static boolean isPrivateKeyJwt(Map providerConfig) { + String method = providerConfig.get(CLIENT_AUTHENTICATION_METHOD); + if (!ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equalsIgnoreCase(method)) { + return false; + } + return !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_KEYSTORE_PATH)) + && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_KEYSTORE_PASSWORD)) + && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_ALIAS)) + && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_PASSWORD)); + } + + private static boolean validateUserInfoResponseType( + String providerId, Map providerConfig) { + String type = providerConfig.get(USER_INFO_RESPONSE_TYPE); + UserInfoResponseType resolved; + try { + resolved = UserInfoResponseType.fromConfig(type); + } catch (IllegalArgumentException ex) { + log.error( + "OIDC provider '{}' has invalid user_info_response_type='{}'. Allowed: json, jwt.", + providerId, + type); + return false; + } + + if (resolved == UserInfoResponseType.JWT) { + String alg = providerConfig.get(USER_INFO_JWS_ALGORITHM); + try { + SupportedJwsAlgorithms.parseOrDefault(alg); + } catch (IllegalArgumentException ex) { + log.error( + "OIDC provider '{}' has unsupported user_info_jws_algorithm='{}'. {}", + providerId, + alg, + ex.getMessage()); + return false; + } + } + return true; + } +``` + +- [ ] **Step 4: Run tests, verify pass** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=GenericOidcProviderBuilderConfigParserTest +``` +Expected: all tests pass (existing + new). + +- [ ] **Step 5: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java \ + dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java +git commit -m "feat(oidc): validate userinfo response type and relax client_secret under private_key_jwt [DHIS2-20043]" +``` + +--- + +## Task 6: `GenericOidcProviderBuilder` — read new keys, allow null `clientSecret` under `private_key_jwt` + +**Files:** +- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java` +- Modify (tests): `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java` + +- [ ] **Step 1: Write failing tests** + +Append to `GenericOidcProviderBuilderTest`: + +```java + @Test + void buildPropagatesUserInfoResponseTypeAndAlgorithm() { + Map cfg = baseConfig(); + cfg.put(USER_INFO_RESPONSE_TYPE, "jwt"); + cfg.put(USER_INFO_JWS_ALGORITHM, "ES256"); + DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(cfg, Map.of()); + assertEquals(UserInfoResponseType.JWT, reg.getUserInfoResponseType()); + assertEquals("ES256", reg.getUserInfoJwsAlgorithm().getName()); + } + + @Test + void buildDefaultsToJsonAndNoAlgorithm() { + DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(baseConfig(), Map.of()); + assertEquals(UserInfoResponseType.JSON, reg.getUserInfoResponseType()); + assertNull(reg.getUserInfoJwsAlgorithm()); + } + + @Test + void buildAcceptsMissingSecretUnderPrivateKeyJwt() { + Map cfg = baseConfig(); + cfg.put(CLIENT_SECRET, ""); + cfg.put(CLIENT_AUTHENTICATION_METHOD, "private_key_jwt"); + // Don't load a real keystore in this test — getJWK only loads it when + // PRIVATE_KEY_JWT is set, so we guard against that throwing by using a path + // that getJWK rejects benignly. Easiest: keep the keystore_path absent so + // getJWK takes the "not private_key_jwt" early branch — but we want + // PRIVATE_KEY_JWT to skip the secret check too. So: stub by setting + // keystore_path to null and assert IllegalStateException is NOT thrown for + // the missing-secret reason but for the keystore-load reason instead. + assertThrows(IllegalStateException.class, () -> GenericOidcProviderBuilder.build(cfg, Map.of())); + } +``` + +Add imports: + +```java +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_AUTHENTICATION_METHOD; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_SECRET; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.hisp.dhis.security.oidc.DhisOidcClientRegistration; +import org.hisp.dhis.security.oidc.UserInfoResponseType; +``` + +If `baseConfig()` does not exist in the test, add a private helper: + +```java + private static Map baseConfig() { + Map cfg = new HashMap<>(); + cfg.put(AbstractOidcProvider.PROVIDER_ID, "idporten"); + cfg.put(AbstractOidcProvider.CLIENT_ID, "test-client"); + cfg.put(AbstractOidcProvider.CLIENT_SECRET, "test-secret"); + cfg.put(AbstractOidcProvider.AUTHORIZATION_URI, "https://oidc.test/authorize"); + cfg.put(AbstractOidcProvider.TOKEN_URI, "https://oidc.test/token"); + cfg.put(AbstractOidcProvider.USERINFO_URI, "https://oidc.test/userinfo"); + cfg.put(AbstractOidcProvider.JWK_URI, "https://oidc.test/jwk"); + return cfg; + } +``` + +- [ ] **Step 2: Run, verify failure** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=GenericOidcProviderBuilderTest +``` +Expected: compile or assertion failures on the new tests. + +- [ ] **Step 3: Implement builder changes** + +Replace the existing `build(...)` method body in `GenericOidcProviderBuilder.java` with: + +```java + public static DhisOidcClientRegistration build( + Map config, Map> externalClients) { + Objects.requireNonNull(config, "DhisConfigurationProvider is missing!"); + + String providerId = config.get(PROVIDER_ID); + String clientId = config.get(CLIENT_ID); + String clientSecret = config.get(CLIENT_SECRET); + + if (providerId == null || providerId.isEmpty() || clientId == null || clientId.isEmpty()) { + return null; + } + + boolean privateKeyJwt = isPrivateKeyJwt(config); + if ((clientSecret == null || clientSecret.isEmpty()) && !privateKeyJwt) { + throw new IllegalArgumentException(providerId + " client secret is missing!"); + } + + return DhisOidcClientRegistration.builder() + .clientRegistration(buildClientRegistration(config, providerId, clientId, clientSecret)) + .mappingClaimKey( + StringUtils.defaultIfEmpty(config.get(MAPPING_CLAIM), DEFAULT_MAPPING_CLAIM)) + .loginIcon(StringUtils.defaultIfEmpty(config.get(LOGIN_IMAGE), "")) + .loginIconPadding(StringUtils.defaultIfEmpty(config.get(LOGIN_IMAGE_PADDING), "0px 0px")) + .loginText(StringUtils.defaultIfEmpty(config.get(DISPLAY_ALIAS), providerId)) + .externalClients(externalClients) + .jwk(getJWK(config)) + .rsaPublicKey(getPublicKey(config)) + .keyId(config.get(JWT_PRIVATE_KEY_ALIAS)) + .jwkSetUrl(config.get(JWK_SET_URL)) + .userInfoResponseType(UserInfoResponseType.fromConfig(config.get(USER_INFO_RESPONSE_TYPE))) + .userInfoJwsAlgorithm( + UserInfoResponseType.fromConfig(config.get(USER_INFO_RESPONSE_TYPE)) + == UserInfoResponseType.JWT + ? SupportedJwsAlgorithms.parseOrDefault(config.get(USER_INFO_JWS_ALGORITHM)) + : null) + .build(); + } + + private static boolean isPrivateKeyJwt(Map config) { + String method = config.get(CLIENT_AUTHENTICATION_METHOD); + return ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equalsIgnoreCase(method); + } +``` + +Add imports: + +```java +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; + +import org.hisp.dhis.security.oidc.SupportedJwsAlgorithms; +import org.hisp.dhis.security.oidc.UserInfoResponseType; +``` + +(`ClientAuthenticationMethod` is already imported.) + +Additionally, in `buildClientRegistration`, change the `clientSecret` line to allow null: + +```java + if (clientSecret != null && !clientSecret.isEmpty()) { + builder.clientSecret(clientSecret); + } +``` + +(The current unconditional `builder.clientSecret(clientSecret);` would set null when secret is absent — Spring's builder accepts that, but skipping the call is cleaner.) + +- [ ] **Step 4: Run, verify pass** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=GenericOidcProviderBuilderTest +``` +Expected: all tests pass. + +- [ ] **Step 5: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java \ + dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java +git commit -m "feat(oidc): wire userInfo response type and JWS algorithm into generic builder [DHIS2-20043]" +``` + +--- + +## Task 7: `JwkSourceCache` (per-registration JWKSource cache) + +**Files:** +- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java` + +This component is small and tested via `SignedJwtUserInfoLoaderTest` in Task 8, so we don't add a separate unit test for it. + +- [ ] **Step 1: Implement** + +```java +/* (standard $YEAR header, author Morten Svanæs) */ +package org.hisp.dhis.security.oidc; + +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.stereotype.Component; + +/** + * Caches one Nimbus {@link JWKSource} per OIDC registration id. Building a {@link JWKSource} + * triggers an HTTPS fetch of the IdP's JWKS document; reusing the source preserves Nimbus's + * built-in remote-key cache and refresh policy across logins. + * + *

Sources are constructed lazily on first call to {@link #get(String, String)}. + * + * @author Morten Svanæs + */ +@Component +public class JwkSourceCache { + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 5_000; + + private final ConcurrentMap> sources = new ConcurrentHashMap<>(); + + /** + * @param registrationId the OIDC registration id to cache under + * @param jwkSetUri the IdP's JWKS endpoint URL + * @return a cached or freshly built {@link JWKSource} + * @throws IllegalArgumentException when {@code jwkSetUri} is malformed + */ + public JWKSource get(String registrationId, String jwkSetUri) { + return sources.computeIfAbsent(registrationId, id -> build(jwkSetUri)); + } + + private JWKSource build(String jwkSetUri) { + try { + DefaultResourceRetriever retriever = + new DefaultResourceRetriever( + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + JWKSourceBuilder.DEFAULT_HTTP_SIZE_LIMIT); + return JWKSourceBuilder.create(new URL(jwkSetUri), retriever).build(); + } catch (MalformedURLException ex) { + throw new IllegalArgumentException("Invalid JWKS URL: " + jwkSetUri, ex); + } + } +} +``` + +- [ ] **Step 2: Compile** + +Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java +git commit -m "feat(oidc): add per-registration JWKSource cache [DHIS2-20043]" +``` + +--- + +## Task 8: `SignedJwtUserInfoLoader` (TDD) + +**Files:** +- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java` +- Test: `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java` + +The test signs a JWT with a generated RSA keypair, stubs `RestTemplate.exchange` to return that JWT, and stubs the cache to return a JWKSource backed by the matching public JWK. + +- [ ] **Step 1: Write the failing test** + +```java +/* (standard $YEAR header, author Morten Svanæs) */ +package org.hisp.dhis.security.oidc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.time.Instant; +import java.util.Date; +import java.util.Set; +import org.hisp.dhis.security.oidc.provider.AbstractOidcProvider; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +class SignedJwtUserInfoLoaderTest { + + @Mock private UserService userService; + @Mock private JwkSourceCache jwkSourceCache; + @Mock private RestTemplate restTemplate; + @Mock private OidcUserRequest userRequest; + @Mock private OAuth2AccessToken accessToken; + @Mock private OidcIdToken idToken; + @Mock private ClientRegistration clientRegistration; + @Mock private ClientRegistration.ProviderDetails providerDetails; + @Mock private ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint; + + private RSAKey rsaJwk; + private DhisOidcClientRegistration registration; + private SignedJwtUserInfoLoader loader; + + @BeforeEach + void setUp() throws Exception { + rsaJwk = new RSAKeyGenerator(2048).keyID("test-key").generate(); + JWKSource source = new ImmutableJWKSet<>(new JWKSet(rsaJwk.toPublicJWK())); + + when(userRequest.getClientRegistration()).thenReturn(clientRegistration); + when(userRequest.getAccessToken()).thenReturn(accessToken); + when(userRequest.getIdToken()).thenReturn(idToken); + when(accessToken.getTokenValue()).thenReturn("at-value"); + when(clientRegistration.getRegistrationId()).thenReturn("esignet"); + when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); + when(providerDetails.getUserInfoEndpoint()).thenReturn(userInfoEndpoint); + when(userInfoEndpoint.getUri()).thenReturn("https://idp.test/userinfo"); + when(jwkSourceCache.get(eq("esignet"), anyString())).thenReturn(source); + + registration = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JWT) + .userInfoJwsAlgorithm(JWSAlgorithm.RS256) + .jwkSetUrl("https://idp.test/jwks") + .build(); + + loader = new SignedJwtUserInfoLoader(userService, jwkSourceCache, restTemplate); + } + + @Test + void happyPathReturnsDhisOidcUser() throws Exception { + String jwt = signJwt(claims("user-123")); + when(restTemplate.exchange(eq("https://idp.test/userinfo"), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + + User user = new User(); + user.setExternalAuth(true); + when(userService.getUserByOpenId("user-123")).thenReturn(user); + when(userService.createUserDetails(user)) + .thenReturn(UserDetails.fromUser(user, Set.of())); + + OidcUser result = loader.load(userRequest, registration); + + assertNotNull(result); + assertEquals("user-123", result.getAttributes().get("sub")); + } + + @Test + void httpFailureRaisesInvalidUserInfoResponse() { + when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenThrow(new RestClientException("boom")); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("invalid_user_info_response", ex.getError().getErrorCode()); + } + + @Test + void badSignatureRaisesJwtProcessingError() throws Exception { + RSAKey other = new RSAKeyGenerator(2048).keyID("other").generate(); + String jwt = signJwt(claims("user-123"), other); + when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("jwt_processing_error", ex.getError().getErrorCode()); + } + + @Test + void missingMappingClaimRaisesError() throws Exception { + JWTClaimsSet noSub = new JWTClaimsSet.Builder().issuer("idp").build(); + String jwt = signJwt(noSub); + when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("missing_mapping_claim", ex.getError().getErrorCode()); + } + + @Test + void unknownUserRaisesError() throws Exception { + String jwt = signJwt(claims("nobody")); + when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + when(userService.getUserByOpenId("nobody")).thenReturn(null); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("user_not_found", ex.getError().getErrorCode()); + } + + // --- helpers --- + + private JWTClaimsSet claims(String sub) { + return new JWTClaimsSet.Builder() + .subject(sub) + .issuer("idp") + .issueTime(Date.from(Instant.now())) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .build(); + } + + private String signJwt(JWTClaimsSet claims) throws JOSEException { + return signJwt(claims, rsaJwk); + } + + private String signJwt(JWTClaimsSet claims, RSAKey signingKey) throws JOSEException { + SignedJWT signed = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(), + claims); + signed.sign(new RSASSASigner(signingKey)); + return signed.serialize(); + } +} +``` + +- [ ] **Step 2: Run, verify failure** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=SignedJwtUserInfoLoaderTest +``` +Expected: COMPILATION FAILURE (`SignedJwtUserInfoLoader` not yet defined). + +- [ ] **Step 3: Implement** + +```java +/* (standard $YEAR header, author Morten Svanæs) */ +package org.hisp.dhis.security.oidc; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import java.util.Collections; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * Loads OIDC userinfo from providers that respond with a signed JWT + * ({@code application/jwt}) instead of JSON. Used when the registration's + * {@link UserInfoResponseType} is {@link UserInfoResponseType#JWT} (e.g. MOSIP eSignet). + * + *

The flow is: GET userinfo with {@code Accept: application/jwt} → verify the + * signature against the IdP's JWKS using the registered {@link JWSAlgorithm} → + * extract the configured mapping claim → resolve the local DHIS2 user. The + * principal-name attribute remains {@link IdTokenClaimNames#SUB} so audit logs + * keyed off {@code sub} match the JSON path. + * + * @author Morten Svanæs + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SignedJwtUserInfoLoader { + + private final UserService userService; + private final JwkSourceCache jwkSourceCache; + private final RestTemplate restTemplate; + + public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { + String userInfoUri = + userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); + String jwt = fetchJwt(userRequest, userInfoUri); + JWTClaimsSet claims = verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId()); + String mappingValue = requireMappingClaim(claims, reg); + User user = requireExternalAuthUser(mappingValue, reg); + UserDetails details = userService.createUserDetails(user); + return new DhisOidcUser( + details, claims.toJSONObject(), IdTokenClaimNames.SUB, userRequest.getIdToken()); + } + + private String fetchJwt(OidcUserRequest userRequest, String userInfoUri) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(userRequest.getAccessToken().getTokenValue()); + headers.setAccept(Collections.singletonList(MediaType.valueOf("application/jwt"))); + HttpEntity entity = new HttpEntity<>("", headers); + try { + ResponseEntity response = + restTemplate.exchange(userInfoUri, HttpMethod.GET, entity, String.class); + String body = response.getBody(); + if (body == null || body.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_user_info_response"), "Empty UserInfo JWT response"); + } + return body; + } catch (RestClientException ex) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_user_info_response"), + "Failed to fetch UserInfo response: " + ex.getMessage(), + ex); + } + } + + private JWTClaimsSet verify(String jwt, DhisOidcClientRegistration reg, String registrationId) { + try { + ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + JWKSource keySource = + jwkSourceCache.get(registrationId, reg.getJwkSetUrl()); + JWSKeySelector selector = + new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); + processor.setJWSKeySelector(selector); + return processor.process(jwt, null); + } catch (Exception ex) { + log.debug("UserInfo JWT verification failed for registration {}", registrationId, ex); + throw new OAuth2AuthenticationException( + new OAuth2Error("jwt_processing_error"), + "Failed to verify UserInfo JWT: " + ex.getMessage(), + ex); + } + } + + private String requireMappingClaim(JWTClaimsSet claims, DhisOidcClientRegistration reg) { + String mappingClaimKey = reg.getMappingClaimKey(); + Map claimsMap = claims.toJSONObject(); + Object value = claimsMap.get(mappingClaimKey); + if (!(value instanceof String s) || s.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_mapping_claim"), + "Mapping claim '" + mappingClaimKey + "' missing or empty in UserInfo JWT"); + } + return s; + } + + private User requireExternalAuthUser(String mappingValue, DhisOidcClientRegistration reg) { + User user = userService.getUserByOpenId(mappingValue); + if (user == null || !user.isExternalAuth()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_not_found"), + "No external-auth DHIS2 user found for mapping claim '" + + reg.getMappingClaimKey() + + "'='" + + mappingValue + + "'"); + } + if (user.isDisabled() || !user.isAccountNonExpired()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_disabled"), "DHIS2 user is disabled or expired"); + } + return user; + } +} +``` + +Add a `RestTemplate` bean wiring. The simplest path: configure as a `@Bean` if no shared one exists. Check first: + +```bash +grep -rn "RestTemplate" dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/ | head -5 +``` + +If no shared bean exists, add one in `DhisWebApiWebSecurityConfig.java` (or the closest existing config class adjacent to OIDC configuration): + +```java + @Bean + public RestTemplate oidcRestTemplate() { + return new RestTemplate(); + } +``` + +If a shared OIDC `RestTemplate` already exists, reuse it via `@Qualifier`. Document the choice in the commit message. + +- [ ] **Step 4: Run tests, verify pass** + +Run: +``` +mvn -q install -pl dhis-2/dhis-services/dhis-service-core -am -DskipTests -q +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=SignedJwtUserInfoLoaderTest +``` +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 5: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java \ + dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java +# Plus the security config file if a RestTemplate bean was added there. +git commit -m "feat(oidc): add SignedJwtUserInfoLoader for application/jwt userinfo [DHIS2-20043]" +``` + +--- + +## Task 9: Restore inheritance and branch in `DhisOidcUserService` + +**Files:** +- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java` +- Test: `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java` + +The dispatch test uses Mockito to stub a package-private `loadFromJsonUserInfo` seam (so we don't need real HTTP for the JSON branch). + +- [ ] **Step 1: Write failing test** + +```java +/* (standard $YEAR header, author Morten Svanæs) */ +package org.hisp.dhis.security.oidc; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.hisp.dhis.user.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +@ExtendWith(MockitoExtension.class) +class DhisOidcUserServiceDispatchTest { + + @Mock private DhisOidcProviderRepository repo; + @Mock private UserService userService; + @Mock private SignedJwtUserInfoLoader signedJwtLoader; + @Mock private OidcUserRequest request; + @Mock private ClientRegistration clientRegistration; + @Mock private OidcUser jsonResult; + @Mock private OidcUser jwtResult; + + @Spy private DhisOidcUserService service = new DhisOidcUserService(); + + @BeforeEach + void wire() { + service.userService = userService; + service.clientRegistrationRepository = repo; + service.signedJwtUserInfoLoader = signedJwtLoader; + when(request.getClientRegistration()).thenReturn(clientRegistration); + when(clientRegistration.getRegistrationId()).thenReturn("p1"); + } + + @Test + void jsonRegistrationUsesJsonPath() { + DhisOidcClientRegistration reg = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JSON) + .build(); + when(repo.getDhisOidcClientRegistration("p1")).thenReturn(reg); + doReturn(jsonResult).when(service).loadFromJsonUserInfo(request, reg); + + OidcUser result = service.loadUser(request); + + assertSame(jsonResult, result); + verify(signedJwtLoader, never()).load(any(), any()); + } + + @Test + void jwtRegistrationUsesJwtPath() { + DhisOidcClientRegistration reg = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JWT) + .build(); + when(repo.getDhisOidcClientRegistration("p1")).thenReturn(reg); + when(signedJwtLoader.load(request, reg)).thenReturn(jwtResult); + + OidcUser result = service.loadUser(request); + + assertSame(jwtResult, result); + verify(service, never()).loadFromJsonUserInfo(any(), any()); + } +} +``` + +- [ ] **Step 2: Run, verify failure** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=DhisOidcUserServiceDispatchTest +``` +Expected: COMPILATION FAILURE. + +- [ ] **Step 3: Replace `DhisOidcUserService` body** + +```java +/* (keep existing $YEAR header, author tag, package) */ +package org.hisp.dhis.security.oidc; + +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +/** + * DHIS2 extension of Spring Security's {@link OidcUserService}. Dispatches userinfo handling based + * on the provider registration's {@link UserInfoResponseType}: JSON (default; Spring's standard + * path) or JWT (eSignet-style signed JWT, handled by {@link SignedJwtUserInfoLoader}). On both + * paths it then resolves the configured {@code mapping_claim} value to a local DHIS2 user via + * {@link UserService#getUserByOpenId}. + * + *

The matched DHIS2 user must be flagged for external authentication, must not be disabled, and + * must not have an expired account; otherwise authentication fails with an + * {@link OAuth2AuthenticationException}. + * + * @author Morten Svanæs + */ +@Slf4j +@Service +public class DhisOidcUserService extends OidcUserService { + + @Autowired public UserService userService; + @Autowired DhisOidcProviderRepository clientRegistrationRepository; + @Autowired SignedJwtUserInfoLoader signedJwtUserInfoLoader; + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + ClientRegistration cr = userRequest.getClientRegistration(); + DhisOidcClientRegistration reg = + clientRegistrationRepository.getDhisOidcClientRegistration(cr.getRegistrationId()); + + return switch (reg.getUserInfoResponseType()) { + case JSON -> loadFromJsonUserInfo(userRequest, reg); + case JWT -> signedJwtUserInfoLoader.load(userRequest, reg); + }; + } + + /** + * JSON-userinfo path: delegates to Spring's {@link OidcUserService#loadUser(OidcUserRequest)}, + * then resolves the mapping claim to a local DHIS2 user. Package-private to allow direct + * stubbing in {@link DhisOidcUserServiceDispatchTest}. + */ + OidcUser loadFromJsonUserInfo(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { + OidcUser oidcUser = super.loadUser(userRequest); + + String mappingClaimKey = reg.getMappingClaimKey(); + Map attributes = oidcUser.getAttributes(); + Object claimValue = attributes.get(mappingClaimKey); + OidcUserInfo userInfo = oidcUser.getUserInfo(); + if (claimValue == null && userInfo != null) { + claimValue = userInfo.getClaim(mappingClaimKey); + } + + if (log.isDebugEnabled()) { + log.debug( + "Trying to look up DHIS2 user with OidcUser mapping mappingClaimKey='{}', claim value='{}'", + mappingClaimKey, + claimValue); + } + + if (claimValue instanceof String s && !s.isBlank()) { + User user = userService.getUserByOpenId(s); + if (user != null && user.isExternalAuth()) { + if (user.isDisabled() || !user.isAccountNonExpired()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_disabled"), "User is disabled"); + } + UserDetails userDetails = userService.createUserDetails(user); + return new DhisOidcUser( + userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken()); + } + } + + String errorMessage = + String.format( + "Failed to look up DHIS2 user with OidcUser mapping mapping; mappingClaimKey='%s', claimValue='%s'", + mappingClaimKey, claimValue); + if (log.isDebugEnabled()) { + log.debug(errorMessage); + } + OAuth2Error err = new OAuth2Error("could_not_map_oidc_user_to_dhis2_user", errorMessage, null); + throw new OAuth2AuthenticationException(err, err.toString()); + } +} +``` + +(The fields are package-private intentionally so the dispatch test can wire them. They're still `@Autowired` for production. `@Spy` requires Mockito to be able to write them; with `@Autowired` + package-private fields this works without reflection trickery.) + +- [ ] **Step 4: Run, verify pass** + +Run: +``` +mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ + -Dtest=DhisOidcUserServiceDispatchTest +``` +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 5: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core +git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java \ + dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java +git commit -m "feat(oidc): branch DhisOidcUserService on userInfoResponseType [DHIS2-20043]" +``` + +--- + +## Task 10: Re-enable `OAuth2Test` + +**Files:** +- Modify: `dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java` + +The PR (PR #22027) added `@Disabled` here. Since we've restored the JSON path, the test should run again. (Reminder: the test ran in a tagged e2e profile only — it will not run by accident in the unit/integration build.) + +- [ ] **Step 1: Remove `@Disabled` and the now-unused import** + +Edit: + +```java +@Tag("oauth2tests") +@Slf4j +class OAuth2Test extends BaseE2ETest { +``` + +Drop the line `@Disabled` from the class annotations. Drop `import org.junit.jupiter.api.Disabled;`. + +- [ ] **Step 2: Compile** + +Run: `mvn -q -pl dhis-2/dhis-test-e2e compile -DskipTests` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-test-e2e +git add dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java +git commit -m "test(oidc): re-enable OAuth2Test now that JSON userinfo path is preserved [DHIS2-20043]" +``` + +--- + +## Task 11: Whole-module test sweep + final verification + +- [ ] **Step 1: Build the whole core module with tests** + +Run: +``` +mvn -q -pl dhis-2/dhis-services/dhis-service-core -am test +``` +Expected: BUILD SUCCESS, no regressions. + +- [ ] **Step 2: Build dhis-web-api (downstream of core) just to be safe** + +Run: +``` +mvn -q install -pl dhis-2/dhis-web-api -am -DskipTests +``` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Spotless full-pass** + +Run: +``` +mvn -q spotless:apply -f dhis-2/pom.xml +mvn -q spotless:check -f dhis-2/pom.xml +``` +Expected: BUILD SUCCESS on the check. + +- [ ] **Step 4: Confirm git status clean** + +Run: `git status --short` +Expected: clean working tree on `feat/DHIS2-20043-esignet-oidc-jwt-userinfo`. + +- [ ] **Step 5: Open PR (manual — do NOT push without user confirmation)** + +PR description should include: +- One-paragraph summary: per-provider opt-in for JWT userinfo, no behaviour change for existing providers. +- Bullet list of new config keys with values and defaults. +- Note that **dhis2-docs needs a follow-up PR** (the OIDC reference chapter is in a separate repo) with the eSignet example block and key docs. Link the spec file: `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md`. +- Reference: closes [DHIS2-20043]. +- "AI Assisted" line per project rule. + +--- + +## Out-of-scope follow-ups + +- **JWE (encrypted) userinfo** — explicitly deferred per spec §3. +- **dhis2-docs reference chapter update** — `oauth.md` lives in the external `dhis2-docs` repo (no local copy). Follow-up PR there with the §4 example block from the spec. +- **Token-endpoint / JWKS HTTP timeouts via `dhis.conf`** — current values are hard-coded constants on `JwkSourceCache`. If field experience shows IdPs needing different timeouts, expose them. + +--- + +## Self-review notes + +- Spec §4 (config keys, allow-list) → Tasks 1, 2, 3, 5. +- Spec §5.3 (registration fields) → Task 4. +- Spec §5.4 (generic builder wiring) → Task 6. +- Spec §5.5 (parser relaxation + value validation) → Task 5. +- Spec §5.6 + §5.7 (user service branch + JWT loader) → Tasks 8, 9. +- Spec §5.8 (JWKSource cache) → Task 7. +- Spec §5.9 (re-enable OAuth2Test) → Task 10. +- Spec §5.10 (PublicKeysController thumbprint) — already on master? No: check. **Action:** the PR includes a small additive change in `PublicKeysController.java` (adds `x509CertSHA256Thumbprint` to the JWK output). Cherry-pick that hunk in Task 10 or as a small Task 10b. *Adding now as Task 10b.* +- Spec §6 (tests) → covered by Tasks 2, 5, 6, 8, 9 with parameterized + happy/edge coverage. +- Spec §7 (docs) → out of scope locally; PR description note in Task 11. +- Spec §8 (no Flyway) → confirmed. + +--- + +## Task 10b: Cherry-pick `PublicKeysController` thumbprint addition + +**Files:** +- Modify: `dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java` + +This is the additive `x509CertSHA256Thumbprint` change from PR #22027. + +- [ ] **Step 1: Apply the change** + +Find the existing `RSAKey.Builder(...)` chain (around line 80-87) and add `.x509CertSHA256Thumbprint(...)` to the chain: + +```java + new RSAKey.Builder(dhisOidcClientRegistration.getRsaPublicKey()) + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.parse(jwsAlgorithm.toString())) + .x509CertSHA256Thumbprint( + dhisOidcClientRegistration.getJwk().getX509CertSHA256Thumbprint()) + .keyID(dhisOidcClientRegistration.getKeyId()); +``` + +This is safe: `getX509CertSHA256Thumbprint()` returns null when the underlying JWK has none, and Nimbus's builder accepts null. + +- [ ] **Step 2: Compile** + +Run: `mvn -q install -pl dhis-2/dhis-web-api -am -DskipTests` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Format and commit** + +```bash +mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-web-api +git add dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java +git commit -m "feat(oidc): include x509 cert thumbprint in published JWK [DHIS2-20043]" +``` From a24c62729d5fc315b22627a3c9336844c909a65d Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 00:19:14 +0800 Subject: [PATCH 03/25] feat(oidc): add UserInfoResponseType enum [DHIS2-20043] --- .../security/oidc/UserInfoResponseType.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java new file mode 100644 index 000000000000..0e597c316cb1 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java @@ -0,0 +1,61 @@ +/* + * 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.security.oidc; + +import javax.annotation.CheckForNull; + +/** + * Selects how DHIS2 should consume the OIDC userinfo response for a given provider. + * + *

    + *
  • {@link #JSON} — Spring Security's default: userinfo endpoint returns {@code + * application/json}. + *
  • {@link #JWT} — userinfo endpoint returns a signed JWT ({@code application/jwt}); used by + * MOSIP eSignet and similar IdPs. + *
+ * + * @author Morten Svanæs + */ +public enum UserInfoResponseType { + JSON, + JWT; + + /** + * @param value config string from {@code dhis.conf}; case-insensitive + * @return matching enum, defaulting to {@link #JSON} when {@code value} is {@code null} or blank + * @throws IllegalArgumentException for unknown values + */ + public static UserInfoResponseType fromConfig(@CheckForNull String value) { + if (value == null || value.isBlank()) { + return JSON; + } + return UserInfoResponseType.valueOf(value.trim().toUpperCase()); + } +} From 734e26c57cddc47834a300a73e1670fc8d08592b Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 00:58:35 +0800 Subject: [PATCH 04/25] feat(oidc): add JWS algorithm allow-list for userinfo verification [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../security/oidc/SupportedJwsAlgorithms.java | 79 +++++++++++++++++++ .../oidc/SupportedJwsAlgorithmsTest.java | 76 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java create mode 100644 dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java new file mode 100644 index 000000000000..99ac8882df54 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java @@ -0,0 +1,79 @@ +/* + * 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.security.oidc; + +import com.nimbusds.jose.JWSAlgorithm; +import java.util.Set; +import javax.annotation.CheckForNull; + +/** + * Allow-list of JWS algorithms accepted for OIDC userinfo JWT verification. Failing closed at parse + * time prevents accidental acceptance of unexpected signature algorithms (e.g. HMAC) configured in + * {@code dhis.conf}. + * + * @author Morten Svanæs + */ +public final class SupportedJwsAlgorithms { + + public static final JWSAlgorithm DEFAULT = JWSAlgorithm.RS256; + + private static final Set ALLOWED = + Set.of( + JWSAlgorithm.RS256, + JWSAlgorithm.RS384, + JWSAlgorithm.RS512, + JWSAlgorithm.PS256, + JWSAlgorithm.PS384, + JWSAlgorithm.PS512, + JWSAlgorithm.ES256, + JWSAlgorithm.ES384, + JWSAlgorithm.ES512); + + private SupportedJwsAlgorithms() {} + + /** + * Parses a configured JWS algorithm name against the allow-list. + * + * @param value config string from {@code dhis.conf}; case-sensitive (Nimbus algorithm names) + * @return the matching {@link JWSAlgorithm}, or {@link #DEFAULT} when {@code value} is null/blank + * @throws IllegalArgumentException when the algorithm is not in the allow-list + */ + public static JWSAlgorithm parseOrDefault(@CheckForNull String value) { + if (value == null || value.isBlank()) { + return DEFAULT; + } + JWSAlgorithm parsed = JWSAlgorithm.parse(value.trim()); + if (!ALLOWED.contains(parsed)) { + throw new IllegalArgumentException( + "Unsupported user_info_jws_algorithm: '" + value + "'. Allowed: " + ALLOWED); + } + return parsed; + } +} diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java new file mode 100644 index 000000000000..e5dd25bd800b --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java @@ -0,0 +1,76 @@ +/* + * 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.security.oidc; + +import static org.junit.jupiter.api.Assertions.*; + +import com.nimbusds.jose.JWSAlgorithm; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for {@link SupportedJwsAlgorithms}. + * + * @author Morten Svanæs + */ +class SupportedJwsAlgorithmsTest { + + @Test + void parsesNullAsRs256Default() { + assertEquals(JWSAlgorithm.RS256, SupportedJwsAlgorithms.parseOrDefault(null)); + } + + @Test + void parsesBlankAsRs256Default() { + assertEquals(JWSAlgorithm.RS256, SupportedJwsAlgorithms.parseOrDefault(" ")); + } + + @ParameterizedTest + @ValueSource( + strings = {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}) + void parsesAllSupportedAlgorithms(String name) { + assertEquals(name, SupportedJwsAlgorithms.parseOrDefault(name).getName()); + } + + @Test + void rejectsUnsupportedAlgorithm() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, () -> SupportedJwsAlgorithms.parseOrDefault("HS256")); + assertTrue(ex.getMessage().contains("HS256")); + } + + @Test + void rejectsNonsense() { + assertThrows( + IllegalArgumentException.class, () -> SupportedJwsAlgorithms.parseOrDefault("nope")); + } +} From 5e5850d29b42be40dd4e610598c6f0e7a1cd4399 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 00:59:43 +0800 Subject: [PATCH 05/25] feat(oidc): add user_info_response_type and user_info_jws_algorithm config keys [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/provider/AbstractOidcProvider.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java index 578b6e34ac8a..89376c2141c0 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java @@ -119,4 +119,18 @@ public abstract class AbstractOidcProvider { public static final String CLIENT_AUTHENTICATION_METHOD = "client_authentication_method"; public static final String JWK_SET_URL = "jwk_set_url"; + + /** + * Selects userinfo response handling: {@code json} (default; Spring Security's normal path) or + * {@code jwt} (eSignet-style signed JWT). See {@link + * org.hisp.dhis.security.oidc.UserInfoResponseType}. + */ + public static final String USER_INFO_RESPONSE_TYPE = "user_info_response_type"; + + /** + * JWS algorithm used to verify the userinfo JWT when {@link #USER_INFO_RESPONSE_TYPE} is {@code + * jwt}. Defaults to {@code RS256}. See {@link + * org.hisp.dhis.security.oidc.SupportedJwsAlgorithms}. + */ + public static final String USER_INFO_JWS_ALGORITHM = "user_info_jws_algorithm"; } From 85e5026cd7cd3ce9b062480ba9d0c6cc6f784fd2 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 01:00:21 +0800 Subject: [PATCH 06/25] feat(oidc): track userInfo response type and JWS algorithm on registration [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/DhisOidcClientRegistration.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java index c394f3e1a3d9..f93b21269a3d 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java @@ -31,6 +31,7 @@ import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_ID; +import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWK; import java.security.interfaces.RSAPublicKey; import java.util.Collection; @@ -39,6 +40,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import javax.annotation.CheckForNull; import lombok.Builder; import lombok.Data; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -105,6 +107,20 @@ public class DhisOidcClientRegistration { private final String jwkSetUrl; + /** + * Selects how DHIS2 consumes this provider's userinfo response. Defaults to {@link + * UserInfoResponseType#JSON}, preserving the historical Spring Security behaviour for every + * existing provider. + */ + @Builder.Default + private final UserInfoResponseType userInfoResponseType = UserInfoResponseType.JSON; + + /** + * JWS algorithm used to verify the signed userinfo JWT. Only consulted when {@link + * #userInfoResponseType} is {@link UserInfoResponseType#JWT}. + */ + @CheckForNull private final JWSAlgorithm userInfoJwsAlgorithm; + @Builder.Default private final boolean visibleOnLoginPage = true; @Builder.Default private final Map> externalClients = new HashMap<>(); From 3121f65821b93609896e88af434ce2ff171c5cb9 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 01:06:21 +0800 Subject: [PATCH 07/25] feat(oidc): validate userinfo response type and relax client_secret under private_key_jwt [DHIS2-20043] - Register USER_INFO_RESPONSE_TYPE and USER_INFO_JWS_ALGORITHM in the KEY_REQUIRED_MAP allow-list so the parser accepts them as valid keys - Add validateUserInfoResponseType() to reject unknown response type values and unsupported JWS algorithms (via SupportedJwsAlgorithms) - Add isPrivateKeyJwt() helper and skip the client_secret required-check when private_key_jwt + all keystore properties are present - Wire userInfoResponseType and userInfoJwsAlgorithm in GenericOidcProviderBuilder.build() so the registration reflects config - Guard clientSecret null check in builder (was NPE for private_key_jwt) AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/GenericOidcProviderConfigParser.java | 57 ++++++++++++- .../provider/GenericOidcProviderBuilder.java | 12 ++- ...icOidcProviderBuilderConfigParserTest.java | 79 +++++++++++++++++++ 3 files changed, 144 insertions(+), 4 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java index 1836a46ca661..f38e9d842602 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java @@ -55,6 +55,8 @@ import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.SCOPES; import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.TOKEN_URI; import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USERINFO_URI; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; @@ -76,6 +78,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.validator.routines.UrlValidator; import org.hisp.dhis.security.oidc.provider.GenericOidcProviderBuilder; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; /** * Parses {@code dhis.conf} for generic OIDC provider configurations under the {@code @@ -139,6 +142,10 @@ public final class GenericOidcProviderConfigParser { builder.put(CLIENT_AUTHENTICATION_METHOD, Boolean.FALSE); builder.put(JWK_SET_URL, Boolean.FALSE); + // userinfo JWT response support + builder.put(USER_INFO_RESPONSE_TYPE, Boolean.FALSE); + builder.put(USER_INFO_JWS_ALGORITHM, Boolean.FALSE); + KEY_REQUIRED_MAP = builder.build(); } @@ -405,20 +412,24 @@ private static boolean validateConfig(Map providerConfig) { Objects.requireNonNull(providerConfig); String providerId = providerConfig.get(PROVIDER_ID); + boolean privateKeyJwt = isPrivateKeyJwt(providerConfig); for (Map.Entry entry : KEY_REQUIRED_MAP.entrySet()) { String key = entry.getKey(); boolean isRequired = entry.getValue(); - String value = providerConfig.get(key); + if (CLIENT_SECRET.equals(key) && privateKeyJwt) { + // client_secret is not used when authenticating with private_key_jwt + continue; + } + if (isRequired && Strings.isNullOrEmpty(value)) { log.error( "OpenId Connect (OIDC) configuration for provider: '{}' is missing a required property: '{}'. " + "Failed to configure the provider successfully!", providerId, key); - return false; } @@ -430,11 +441,51 @@ private static boolean validateConfig(Map providerConfig) { providerId, key, value); - return false; } } + return validateUserInfoResponseType(providerId, providerConfig); + } + + private static boolean isPrivateKeyJwt(Map providerConfig) { + String method = providerConfig.get(CLIENT_AUTHENTICATION_METHOD); + if (!ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equalsIgnoreCase(method)) { + return false; + } + return !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_KEYSTORE_PATH)) + && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_KEYSTORE_PASSWORD)) + && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_ALIAS)) + && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_PASSWORD)); + } + + private static boolean validateUserInfoResponseType( + String providerId, Map providerConfig) { + String type = providerConfig.get(USER_INFO_RESPONSE_TYPE); + UserInfoResponseType resolved; + try { + resolved = UserInfoResponseType.fromConfig(type); + } catch (IllegalArgumentException ex) { + log.error( + "OIDC provider '{}' has invalid user_info_response_type='{}'. Allowed: json, jwt.", + providerId, + type); + return false; + } + + if (resolved == UserInfoResponseType.JWT) { + String alg = providerConfig.get(USER_INFO_JWS_ALGORITHM); + try { + SupportedJwsAlgorithms.parseOrDefault(alg); + } catch (IllegalArgumentException ex) { + log.error( + "OIDC provider '{}' has unsupported user_info_jws_algorithm='{}'. {}", + providerId, + alg, + ex.getMessage()); + return false; + } + } return true; } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java index 8c45a25a7c2a..d47ec8d54695 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java @@ -49,6 +49,8 @@ import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.hisp.dhis.security.oidc.DhisOidcClientRegistration; import org.hisp.dhis.security.oidc.KeyStoreUtil; +import org.hisp.dhis.security.oidc.SupportedJwsAlgorithms; +import org.hisp.dhis.security.oidc.UserInfoResponseType; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthenticationMethod; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -118,10 +120,13 @@ public static DhisOidcClientRegistration build( return null; } - if (clientSecret.isEmpty()) { + if (clientSecret != null && clientSecret.isEmpty()) { throw new IllegalArgumentException(providerId + " client secret is missing!"); } + UserInfoResponseType userInfoResponseType = + UserInfoResponseType.fromConfig(config.get(USER_INFO_RESPONSE_TYPE)); + return DhisOidcClientRegistration.builder() .clientRegistration(buildClientRegistration(config, providerId, clientId, clientSecret)) .mappingClaimKey( @@ -134,6 +139,11 @@ public static DhisOidcClientRegistration build( .rsaPublicKey(getPublicKey(config)) .keyId(config.get(JWT_PRIVATE_KEY_ALIAS)) .jwkSetUrl(config.get(JWK_SET_URL)) + .userInfoResponseType(userInfoResponseType) + .userInfoJwsAlgorithm( + userInfoResponseType == UserInfoResponseType.JWT + ? SupportedJwsAlgorithms.parseOrDefault(config.get(USER_INFO_JWS_ALGORITHM)) + : null) .build(); } diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java index 4003c1d2b207..ceecd857e13f 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java @@ -31,6 +31,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; import java.util.Properties; @@ -166,4 +167,82 @@ void parseConfigInvalidURIParameter() { List parse = GenericOidcProviderConfigParser.parse(p); assertThat(parse, hasSize(0)); } + + // --- new keys: user_info_response_type / user_info_jws_algorithm --- + + @Test + void parseAcceptsUserInfoResponseTypeJwt() { + Properties p = baseValidProvider("idporten"); + p.put("oidc.provider.idporten.user_info_response_type", "jwt"); + p.put("oidc.provider.idporten.user_info_jws_algorithm", "PS256"); + List parse = GenericOidcProviderConfigParser.parse(p); + assertThat(parse, hasSize(1)); + assertEquals(UserInfoResponseType.JWT, parse.get(0).getUserInfoResponseType()); + assertEquals("PS256", parse.get(0).getUserInfoJwsAlgorithm().getName()); + } + + @Test + void parseRejectsUnknownUserInfoResponseType() { + Properties p = baseValidProvider("idporten"); + p.put("oidc.provider.idporten.user_info_response_type", "yaml"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + @Test + void parseRejectsUnsupportedJwsAlgorithm() { + Properties p = baseValidProvider("idporten"); + p.put("oidc.provider.idporten.user_info_response_type", "jwt"); + p.put("oidc.provider.idporten.user_info_jws_algorithm", "HS256"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + // --- conditional client_secret relaxation --- + + @Test + void parseRejectsMissingClientSecretWithoutPrivateKeyJwt() { + Properties p = baseValidProvider("idporten"); + p.remove("oidc.provider.idporten.client_secret"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + @Test + void parseAcceptsMissingClientSecretWithPrivateKeyJwt() { + Properties p = baseValidProvider("idporten"); + p.remove("oidc.provider.idporten.client_secret"); + p.put("oidc.provider.idporten.client_authentication_method", "private_key_jwt"); + p.put("oidc.provider.idporten.keystore_path", "/tmp/does-not-need-to-exist.p12"); + p.put("oidc.provider.idporten.keystore_password", "x"); + p.put("oidc.provider.idporten.key_alias", "x"); + p.put("oidc.provider.idporten.key_password", "x"); + p.put("oidc.provider.idporten.jwk_set_url", "https://oidc-ver2.difi.no/jwks"); + // Builder may still fail to load the keystore from disk; we only assert the + // *parser* accepts the config. Wrap to ignore loader-time IO errors. + try { + GenericOidcProviderConfigParser.parse(p); + } catch (IllegalStateException expected) { + // builder can throw when reading non-existent keystore — that's fine for + // this parser-level assertion: validation passed before construction. + return; + } + } + + @Test + void parseStillRequiresUserInfoUriEvenInJwtMode() { + Properties p = baseValidProvider("idporten"); + p.remove("oidc.provider.idporten.user_info_uri"); + p.put("oidc.provider.idporten.user_info_response_type", "jwt"); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); + } + + private static Properties baseValidProvider(String id) { + Properties p = new Properties(); + String pre = "oidc.provider." + id + "."; + p.put(pre + "client_id", "testClientId"); + p.put(pre + "client_secret", "testClientSecret"); + p.put(pre + "authorization_uri", "https://oidc-ver2.difi.no/authorize"); + p.put(pre + "token_uri", "https://oidc-ver2.difi.no/token"); + p.put(pre + "user_info_uri", "https://oidc-ver2.difi.no/userinfo"); + p.put(pre + "jwk_uri", "https://oidc-ver2.difi.no/jwk"); + return p; + } } From 43cc0f1545693cab3a98db21dfce9db741f1a7b0 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 01:11:18 +0800 Subject: [PATCH 08/25] feat(oidc): tighten missing-secret check and add direct builder tests [DHIS2-20043] - Replace lenient null-check with logic that throws unless private_key_jwt - Add isPrivateKeyJwt() helper for reusable auth-method detection - Guard getJWK() against null keystore path when no keystore configured - Skip clientSecret on ClientRegistration when null/empty - Add four direct GenericOidcProviderBuilder unit tests covering JWT/JSON userinfo defaults, missing-secret enforcement, and private_key_jwt bypass AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provider/GenericOidcProviderBuilder.java | 19 ++++-- .../GenericOidcProviderBuilderTest.java | 58 +++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java index d47ec8d54695..7468fb789b9b 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java @@ -120,7 +120,7 @@ public static DhisOidcClientRegistration build( return null; } - if (clientSecret != null && clientSecret.isEmpty()) { + if ((clientSecret == null || clientSecret.isEmpty()) && !isPrivateKeyJwt(config)) { throw new IllegalArgumentException(providerId + " client secret is missing!"); } @@ -180,11 +180,13 @@ private static JWK getJWK(Map config) { ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())); if (clientAuthenticationMethod.equals(ClientAuthenticationMethod.PRIVATE_KEY_JWT)) { + String keystorePath = config.get(JWT_PRIVATE_KEY_KEYSTORE_PATH); + if (keystorePath == null || keystorePath.isEmpty()) { + return null; + } try { KeyStore keyStore = - KeyStoreUtil.readKeyStore( - config.get(JWT_PRIVATE_KEY_KEYSTORE_PATH), - config.get(JWT_PRIVATE_KEY_KEYSTORE_PASSWORD)); + KeyStoreUtil.readKeyStore(keystorePath, config.get(JWT_PRIVATE_KEY_KEYSTORE_PASSWORD)); return JWK.load( keyStore, @@ -202,12 +204,19 @@ private static JWK getJWK(Map config) { return null; } + private static boolean isPrivateKeyJwt(Map config) { + String method = config.get(CLIENT_AUTHENTICATION_METHOD); + return ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equalsIgnoreCase(method); + } + private static ClientRegistration buildClientRegistration( Map config, String providerId, String clientId, String clientSecret) { ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(providerId); builder.clientName(providerId); builder.clientId(clientId); - builder.clientSecret(clientSecret); + if (clientSecret != null && !clientSecret.isEmpty()) { + builder.clientSecret(clientSecret); + } builder.clientAuthenticationMethod( new ClientAuthenticationMethod( StringUtils.defaultIfEmpty( diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java index 41f14fa780c8..a3d3dbabd8bc 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java @@ -29,9 +29,15 @@ */ package org.hisp.dhis.security.oidc.provider; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_AUTHENTICATION_METHOD; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_SECRET; import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.EXTRA_REQUEST_PARAMETERS; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; +import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashMap; @@ -40,6 +46,7 @@ import java.util.Properties; import org.hisp.dhis.security.oidc.DhisOidcClientRegistration; import org.hisp.dhis.security.oidc.GenericOidcProviderConfigParser; +import org.hisp.dhis.security.oidc.UserInfoResponseType; import org.junit.jupiter.api.Test; /** @@ -127,4 +134,55 @@ void testParseExtraRequestParameters() { assertEquals("five", params.get("test_param")); assertEquals("six", params.get("test_param2")); } + + @Test + void buildPropagatesUserInfoResponseTypeAndAlgorithm() { + Map cfg = baseConfig(); + cfg.put(USER_INFO_RESPONSE_TYPE, "jwt"); + cfg.put(USER_INFO_JWS_ALGORITHM, "ES256"); + DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(cfg, Map.of()); + assertEquals(UserInfoResponseType.JWT, reg.getUserInfoResponseType()); + assertEquals("ES256", reg.getUserInfoJwsAlgorithm().getName()); + } + + @Test + void buildDefaultsToJsonAndNoAlgorithm() { + DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(baseConfig(), Map.of()); + assertEquals(UserInfoResponseType.JSON, reg.getUserInfoResponseType()); + assertNull(reg.getUserInfoJwsAlgorithm()); + } + + @Test + void buildThrowsWhenSecretMissingAndNotPrivateKeyJwt() { + Map cfg = baseConfig(); + cfg.remove(CLIENT_SECRET); + assertThrows( + IllegalArgumentException.class, () -> GenericOidcProviderBuilder.build(cfg, Map.of())); + } + + @Test + void buildAcceptsMissingSecretUnderPrivateKeyJwt() { + Map cfg = baseConfig(); + cfg.remove(CLIENT_SECRET); + cfg.put(CLIENT_AUTHENTICATION_METHOD, "private_key_jwt"); + // No keystore configured here — getJWK() returns null, so build() should + // succeed and produce a registration without a secret. + DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(cfg, Map.of()); + assertNotNull(reg); + // Spring ClientRegistration returns "" (empty) when no secret was set, never null + String secret = reg.getClientRegistration().getClientSecret(); + assertTrue(secret == null || secret.isEmpty()); + } + + private static Map baseConfig() { + Map cfg = new HashMap<>(); + cfg.put(AbstractOidcProvider.PROVIDER_ID, "idporten"); + cfg.put(AbstractOidcProvider.CLIENT_ID, "test-client"); + cfg.put(AbstractOidcProvider.CLIENT_SECRET, "test-secret"); + cfg.put(AbstractOidcProvider.AUTHORIZATION_URI, "https://oidc-ver2.difi.no/authorize"); + cfg.put(AbstractOidcProvider.TOKEN_URI, "https://oidc-ver2.difi.no/token"); + cfg.put(AbstractOidcProvider.USERINFO_URI, "https://oidc-ver2.difi.no/userinfo"); + cfg.put(AbstractOidcProvider.JWK_URI, "https://oidc-ver2.difi.no/jwk"); + return cfg; + } } From dbdcadd45ad1aae4516545b0487454e1965c95ad Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 01:12:14 +0800 Subject: [PATCH 09/25] feat(oidc): add per-registration JWKSource cache [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhis/security/oidc/JwkSourceCache.java | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java new file mode 100644 index 000000000000..e826a61c8ed8 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java @@ -0,0 +1,80 @@ +/* + * 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.security.oidc; + +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.stereotype.Component; + +/** + * Caches one Nimbus {@link JWKSource} per OIDC registration id. Building a {@link JWKSource} + * triggers an HTTPS fetch of the IdP's JWKS document; reusing the source preserves Nimbus's + * built-in remote-key cache and refresh policy across logins. + * + *

Sources are constructed lazily on first call to {@link #get(String, String)}. + * + * @author Morten Svanæs + */ +@Component +public class JwkSourceCache { + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 5_000; + + private final ConcurrentMap> sources = + new ConcurrentHashMap<>(); + + /** + * @param registrationId the OIDC registration id to cache under + * @param jwkSetUri the IdP's JWKS endpoint URL + * @return a cached or freshly built {@link JWKSource} + * @throws IllegalArgumentException when {@code jwkSetUri} is malformed + */ + public JWKSource get(String registrationId, String jwkSetUri) { + return sources.computeIfAbsent(registrationId, id -> build(jwkSetUri)); + } + + private JWKSource build(String jwkSetUri) { + try { + DefaultResourceRetriever retriever = + new DefaultResourceRetriever( + CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS, JWKSourceBuilder.DEFAULT_HTTP_SIZE_LIMIT); + return JWKSourceBuilder.create(new URL(jwkSetUri), retriever).build(); + } catch (MalformedURLException ex) { + throw new IllegalArgumentException("Invalid JWKS URL: " + jwkSetUri, ex); + } + } +} From e946e6301a2e43434f4738eb179b2e14e7d1d75a Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 03:05:30 +0800 Subject: [PATCH 10/25] build: enable ByteBuddy experimental flag for Java 21 test runs [DHIS2-20043] Mockito's bundled ByteBuddy (1.14.x) only formally supports Java up to 20; running on Java 21 needs the experimental flag to inline-mock final classes. Adding it to surefireArgLine + threading argLine through the surefire pluginManagement keeps Java 17 builds unaffected and unblocks local Java 21 dev-machine builds. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- dhis-2/pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dhis-2/pom.xml b/dhis-2/pom.xml index 7216baf6cde3..0807d963dae1 100644 --- a/dhis-2/pom.xml +++ b/dhis-2/pom.xml @@ -81,7 +81,7 @@ java:S1117 **/src/test/**/*.java - -Xmx2024m + -Xmx2024m -Dnet.bytebuddy.experimental=true 1.1.6 3.5.1 @@ -1937,6 +1937,7 @@ ${maven-surefire-plugin.version} benchmark + @{argLine} ${surefireArgLine} From dc25132ad9dd35d1be3f4497b3624a7fb213fae0 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 03:05:31 +0800 Subject: [PATCH 11/25] feat(oidc): add SignedJwtUserInfoLoader for application/jwt userinfo [DHIS2-20043] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads OIDC userinfo from providers (e.g. MOSIP eSignet) that respond with a signed JWT instead of plain JSON: GET userinfo with Accept: application/jwt → verify signature against the IdP's JWKS using the registered JWS algorithm → extract the mapping claim → resolve the local DHIS2 user. principalNameAttribute remains sub so audit logs match the JSON path. Reuses the existing restTemplate bean from dhis-support-system. Distinct OAuth2Error codes for fetch failure, JWS-verification failure, missing mapping claim, and unknown user. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/SignedJwtUserInfoLoader.java | 170 +++++++++++++ .../oidc/SignedJwtUserInfoLoaderTest.java | 232 ++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java create mode 100644 dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java new file mode 100644 index 000000000000..eb094d78eb2d --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -0,0 +1,170 @@ +/* + * 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.security.oidc; + +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import java.util.Collections; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * Loads OIDC userinfo from providers that respond with a signed JWT ({@code application/jwt}) + * instead of plain JSON. Used when the registration's {@link UserInfoResponseType} is {@link + * UserInfoResponseType#JWT} (e.g. MOSIP eSignet). + * + *

The flow is: GET userinfo with {@code Accept: application/jwt} → verify the signature against + * the IdP's JWKS using the registered JWS algorithm → extract the configured mapping claim → + * resolve the local DHIS2 user. The principal-name attribute remains {@link IdTokenClaimNames#SUB} + * so audit logs keyed off {@code sub} match the JSON path. + * + * @author Morten Svanæs + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SignedJwtUserInfoLoader { + + private final UserService userService; + private final JwkSourceCache jwkSourceCache; + private final RestTemplate restTemplate; + + /** + * Fetches, verifies and maps a signed-JWT userinfo response to a {@link DhisOidcUser}. + * + * @param userRequest the OIDC user request produced after the code-for-token exchange + * @param reg the DHIS2 client registration carrying JWT-specific metadata + * @return a {@link DhisOidcUser} bound to the resolved DHIS2 user + * @throws OAuth2AuthenticationException if the JWT cannot be fetched, verified or mapped to a + * valid DHIS2 user + */ + public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { + String userInfoUri = + userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); + String jwt = fetchJwt(userRequest, userInfoUri); + JWTClaimsSet claims = verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId()); + String mappingValue = requireMappingClaim(claims, reg); + User user = requireExternalAuthUser(mappingValue, reg); + UserDetails details = userService.createUserDetails(user); + return new DhisOidcUser( + details, claims.toJSONObject(), IdTokenClaimNames.SUB, userRequest.getIdToken()); + } + + private String fetchJwt(OidcUserRequest userRequest, String userInfoUri) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(userRequest.getAccessToken().getTokenValue()); + headers.setAccept(Collections.singletonList(MediaType.valueOf("application/jwt"))); + HttpEntity entity = new HttpEntity<>("", headers); + try { + ResponseEntity response = + restTemplate.exchange(userInfoUri, HttpMethod.GET, entity, String.class); + String body = response.getBody(); + if (body == null || body.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_user_info_response"), "Empty UserInfo JWT response"); + } + return body; + } catch (RestClientException ex) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_user_info_response"), + "Failed to fetch UserInfo response: " + ex.getMessage(), + ex); + } + } + + private JWTClaimsSet verify(String jwt, DhisOidcClientRegistration reg, String registrationId) { + try { + ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + JWKSource keySource = jwkSourceCache.get(registrationId, reg.getJwkSetUrl()); + JWSKeySelector selector = + new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); + processor.setJWSKeySelector(selector); + return processor.process(jwt, null); + } catch (Exception ex) { + log.debug("UserInfo JWT verification failed for registration {}", registrationId, ex); + throw new OAuth2AuthenticationException( + new OAuth2Error("jwt_processing_error"), + "Failed to verify UserInfo JWT: " + ex.getMessage(), + ex); + } + } + + private String requireMappingClaim(JWTClaimsSet claims, DhisOidcClientRegistration reg) { + String mappingClaimKey = reg.getMappingClaimKey(); + Map claimsMap = claims.toJSONObject(); + Object value = claimsMap.get(mappingClaimKey); + if (!(value instanceof String s) || s.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_mapping_claim"), + "Mapping claim '" + mappingClaimKey + "' missing or empty in UserInfo JWT"); + } + return s; + } + + private User requireExternalAuthUser(String mappingValue, DhisOidcClientRegistration reg) { + User user = userService.getUserByOpenId(mappingValue); + if (user == null || !user.isExternalAuth()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_not_found"), + "No external-auth DHIS2 user found for mapping claim '" + + reg.getMappingClaimKey() + + "'='" + + mappingValue + + "'"); + } + if (user.isDisabled() || !user.isAccountNonExpired()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_disabled"), "DHIS2 user is disabled or expired"); + } + return user; + } +} diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java new file mode 100644 index 000000000000..a5d34b6a72b1 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java @@ -0,0 +1,232 @@ +/* + * 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.security.oidc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.time.Instant; +import java.util.Date; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * Unit tests for {@link SignedJwtUserInfoLoader}. + * + * @author Morten Svanæs + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SignedJwtUserInfoLoaderTest { + + @Mock private UserService userService; + @Mock private RestTemplate restTemplate; + @Mock private OidcUserRequest userRequest; + @Mock private OAuth2AccessToken accessToken; + @Mock private OidcIdToken idToken; + @Mock private ClientRegistration clientRegistration; + @Mock private ClientRegistration.ProviderDetails providerDetails; + @Mock private ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint; + + /** Stub for {@link JwkSourceCache}: returns the correct public JWK source by default. */ + private JwkSourceCache jwkSourceCacheStub; + + private RSAKey rsaJwk; + private DhisOidcClientRegistration registration; + private SignedJwtUserInfoLoader loader; + + @BeforeEach + void setUp() throws Exception { + rsaJwk = new RSAKeyGenerator(2048).keyID("test-key").generate(); + JWKSource source = new ImmutableJWKSet<>(new JWKSet(rsaJwk.toPublicJWK())); + + // Hand-rolled stub: avoids Byte Buddy inline-mock limitations on Java 21 + // for concrete Spring @Component classes. + jwkSourceCacheStub = + new JwkSourceCache() { + @Override + public JWKSource get(String registrationId, String jwkSetUri) { + return source; + } + }; + + when(userRequest.getClientRegistration()).thenReturn(clientRegistration); + when(userRequest.getAccessToken()).thenReturn(accessToken); + when(userRequest.getIdToken()).thenReturn(idToken); + when(accessToken.getTokenValue()).thenReturn("at-value"); + when(clientRegistration.getRegistrationId()).thenReturn("esignet"); + when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); + when(providerDetails.getUserInfoEndpoint()).thenReturn(userInfoEndpoint); + when(userInfoEndpoint.getUri()).thenReturn("https://idp.test/userinfo"); + + registration = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JWT) + .userInfoJwsAlgorithm(JWSAlgorithm.RS256) + .jwkSetUrl("https://idp.test/jwks") + .build(); + + loader = new SignedJwtUserInfoLoader(userService, jwkSourceCacheStub, restTemplate); + } + + @Test + void happyPathReturnsDhisOidcUser() throws Exception { + String jwt = signJwt(claims("user-123")); + when(restTemplate.exchange( + eq("https://idp.test/userinfo"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + + User user = new User(); + user.setExternalAuth(true); + when(userService.getUserByOpenId("user-123")).thenReturn(user); + when(userService.createUserDetails(user)).thenReturn(UserDetails.fromUser(user)); + + OidcUser result = loader.load(userRequest, registration); + + assertNotNull(result); + assertEquals("user-123", result.getAttributes().get("sub")); + } + + @Test + void httpFailureRaisesInvalidUserInfoResponse() { + when(restTemplate.exchange( + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenThrow(new RestClientException("boom")); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("invalid_user_info_response", ex.getError().getErrorCode()); + } + + @Test + void badSignatureRaisesJwtProcessingError() throws Exception { + RSAKey other = new RSAKeyGenerator(2048).keyID("other").generate(); + String jwt = signJwt(claims("user-123"), other); + when(restTemplate.exchange( + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("jwt_processing_error", ex.getError().getErrorCode()); + } + + @Test + void missingMappingClaimRaisesError() throws Exception { + JWTClaimsSet noSub = new JWTClaimsSet.Builder().issuer("idp").build(); + String jwt = signJwt(noSub); + when(restTemplate.exchange( + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("missing_mapping_claim", ex.getError().getErrorCode()); + } + + @Test + void unknownUserRaisesError() throws Exception { + String jwt = signJwt(claims("nobody")); + when(restTemplate.exchange( + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + when(userService.getUserByOpenId("nobody")).thenReturn(null); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("user_not_found", ex.getError().getErrorCode()); + } + + // helpers + + private JWTClaimsSet claims(String sub) { + return new JWTClaimsSet.Builder() + .subject(sub) + .issuer("idp") + .issueTime(Date.from(Instant.now())) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .build(); + } + + private String signJwt(JWTClaimsSet claims) throws JOSEException { + return signJwt(claims, rsaJwk); + } + + private String signJwt(JWTClaimsSet claims, RSAKey signingKey) throws JOSEException { + SignedJWT signed = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(), claims); + signed.sign(new RSASSASigner(signingKey)); + return signed.serialize(); + } +} From cc92cfe63e513a57ab33d08e2d741a835c6f3c70 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 03:08:57 +0800 Subject: [PATCH 12/25] feat(oidc): branch DhisOidcUserService on userInfoResponseType [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../security/oidc/DhisOidcUserService.java | 78 ++++++------ .../oidc/DhisOidcUserServiceDispatchTest.java | 115 ++++++++++++++++++ 2 files changed, 149 insertions(+), 44 deletions(-) create mode 100644 dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java index 1130628eb0de..0123bdabc5ad 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java @@ -46,51 +46,47 @@ import org.springframework.stereotype.Service; /** - * DHIS2 extension of Spring Security's {@link OidcUserService} that runs after a successful - * authorization-code exchange against an OIDC Identity Provider. It reads the claim configured by - * {@code mapping_claim} on the provider (default {@code email} for external providers, {@code - * username} for the internal DHIS2 provider) from the ID token and userinfo response, then resolves - * that value to a local DHIS2 user via {@code UserService.getUserByOpenId}. + * DHIS2 extension of Spring Security's {@link OidcUserService}. Dispatches userinfo handling based + * on the provider registration's {@link UserInfoResponseType}: JSON (default; Spring's standard + * path) or JWT (eSignet-style signed JWT, handled by {@link SignedJwtUserInfoLoader}). On both + * paths it then resolves the configured {@code mapping_claim} value to a local DHIS2 user via + * {@link UserService#getUserByOpenId}. * - *

The matched DHIS2 user must have the "External authentication only (OpenID or LDAP)" flag set - * ({@code isExternalAuth()}), must not be disabled, and must not have an expired account; otherwise - * authentication fails with an {@link OAuth2AuthenticationException}. The lookup supports the - * linked-accounts feature: when a single IdP claim value maps to multiple DHIS2 users, {@code - * getUserByOpenId} returns the most recently signed-in account. - * - *

On success the method returns a {@link DhisOidcUser} wrapping the DHIS2 {@code UserDetails} - * together with the raw OIDC claims and the validated ID token. + *

The matched DHIS2 user must be flagged for external authentication, must not be disabled, and + * must not have an expired account; otherwise authentication fails with an {@link + * OAuth2AuthenticationException}. * * @author Morten Svanæs */ @Slf4j @Service public class DhisOidcUserService extends OidcUserService { - @Autowired public UserService userService; - @Autowired private DhisOidcProviderRepository clientRegistrationRepository; + @Autowired public UserService userService; + @Autowired DhisOidcProviderRepository clientRegistrationRepository; + @Autowired SignedJwtUserInfoLoader signedJwtUserInfoLoader; - /** - * Delegates to {@link OidcUserService#loadUser(OidcUserRequest)} to fetch the OIDC user and then - * maps the provider's {@code mapping_claim} value to a local DHIS2 user. Throws {@link - * OAuth2AuthenticationException} if the claim is missing, no matching DHIS2 user exists, the - * DHIS2 user is not flagged for external authentication, or the account is disabled or expired. - * - * @param userRequest the OIDC user request produced after the code-for-token exchange - * @return a {@link DhisOidcUser} bound to the resolved DHIS2 user - * @throws OAuth2AuthenticationException if the claim cannot be mapped to a valid DHIS2 user - */ @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - OidcUser oidcUser = super.loadUser(userRequest); + ClientRegistration cr = userRequest.getClientRegistration(); + DhisOidcClientRegistration reg = + clientRegistrationRepository.getDhisOidcClientRegistration(cr.getRegistrationId()); - ClientRegistration clientRegistration = userRequest.getClientRegistration(); + return switch (reg.getUserInfoResponseType()) { + case JSON -> loadFromJsonUserInfo(userRequest, reg); + case JWT -> signedJwtUserInfoLoader.load(userRequest, reg); + }; + } - DhisOidcClientRegistration oidcClientRegistration = - clientRegistrationRepository.getDhisOidcClientRegistration( - clientRegistration.getRegistrationId()); + /** + * JSON-userinfo path: delegates to Spring's {@link OidcUserService#loadUser(OidcUserRequest)}, + * then resolves the mapping claim to a local DHIS2 user. Package-private to allow direct stubbing + * in {@link DhisOidcUserServiceDispatchTest}. + */ + OidcUser loadFromJsonUserInfo(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { + OidcUser oidcUser = super.loadUser(userRequest); - String mappingClaimKey = oidcClientRegistration.getMappingClaimKey(); + String mappingClaimKey = reg.getMappingClaimKey(); Map attributes = oidcUser.getAttributes(); Object claimValue = attributes.get(mappingClaimKey); OidcUserInfo userInfo = oidcUser.getUserInfo(); @@ -100,21 +96,19 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio if (log.isDebugEnabled()) { log.debug( - String.format( - "Trying to look up DHIS2 user with OidcUser mapping mappingClaimKey='%s', claim value='%s'", - mappingClaimKey, claimValue)); + "Trying to look up DHIS2 user with OidcUser mapping mappingClaimKey='{}', claim value='{}'", + mappingClaimKey, + claimValue); } - if (claimValue != null) { - User user = userService.getUserByOpenId((String) claimValue); + if (claimValue instanceof String s && !s.isBlank()) { + User user = userService.getUserByOpenId(s); if (user != null && user.isExternalAuth()) { if (user.isDisabled() || !user.isAccountNonExpired()) { throw new OAuth2AuthenticationException( new OAuth2Error("user_disabled"), "User is disabled"); } - UserDetails userDetails = userService.createUserDetails(user); - return new DhisOidcUser( userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken()); } @@ -124,14 +118,10 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio String.format( "Failed to look up DHIS2 user with OidcUser mapping mapping; mappingClaimKey='%s', claimValue='%s'", mappingClaimKey, claimValue); - if (log.isDebugEnabled()) { log.debug(errorMessage); } - - OAuth2Error oauth2Error = - new OAuth2Error("could_not_map_oidc_user_to_dhis2_user", errorMessage, null); - - throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + OAuth2Error err = new OAuth2Error("could_not_map_oidc_user_to_dhis2_user", errorMessage, null); + throw new OAuth2AuthenticationException(err, err.toString()); } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java new file mode 100644 index 000000000000..7690ac3b4790 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java @@ -0,0 +1,115 @@ +/* + * 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.security.oidc; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.hisp.dhis.user.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * Verifies that {@link DhisOidcUserService#loadUser(OidcUserRequest)} dispatches to the JSON path + * or the JWT path based on the {@link UserInfoResponseType} configured on the provider + * registration. + * + *

Uses {@code @Spy} on the service and {@code doReturn} to short-circuit the two path methods so + * no real HTTP or Spring context is needed. + * + * @author Morten Svanæs + */ +@ExtendWith(MockitoExtension.class) +class DhisOidcUserServiceDispatchTest { + + @Mock private DhisOidcProviderRepository repo; + @Mock private UserService userService; + @Mock private SignedJwtUserInfoLoader signedJwtLoader; + @Mock private OidcUserRequest request; + @Mock private ClientRegistration clientRegistration; + @Mock private OidcUser jsonResult; + @Mock private OidcUser jwtResult; + + @Spy private DhisOidcUserService service = new DhisOidcUserService(); + + @BeforeEach + void wire() { + service.userService = userService; + service.clientRegistrationRepository = repo; + service.signedJwtUserInfoLoader = signedJwtLoader; + when(request.getClientRegistration()).thenReturn(clientRegistration); + when(clientRegistration.getRegistrationId()).thenReturn("p1"); + } + + @Test + void jsonRegistrationUsesJsonPath() { + DhisOidcClientRegistration reg = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JSON) + .build(); + when(repo.getDhisOidcClientRegistration("p1")).thenReturn(reg); + doReturn(jsonResult).when(service).loadFromJsonUserInfo(request, reg); + + OidcUser result = service.loadUser(request); + + assertSame(jsonResult, result); + verify(signedJwtLoader, never()).load(any(), any()); + } + + @Test + void jwtRegistrationUsesJwtPath() { + DhisOidcClientRegistration reg = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JWT) + .build(); + when(repo.getDhisOidcClientRegistration("p1")).thenReturn(reg); + when(signedJwtLoader.load(request, reg)).thenReturn(jwtResult); + + OidcUser result = service.loadUser(request); + + assertSame(jwtResult, result); + verify(service, never()).loadFromJsonUserInfo(any(), any()); + } +} From 1f3737f70a043c71d9b480186c936f0f2fb7ceff Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 03:11:01 +0800 Subject: [PATCH 13/25] feat(oidc): include x509 cert thumbprint in published JWK [DHIS2-20043] Additive: emits x509 SHA-256 thumbprint on the JWK only when the underlying registration's JWK carries one (returns null otherwise, which Nimbus's RSAKey builder accepts). Cherry-picked from PR #22027. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhis/webapi/controller/security/PublicKeysController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java index 40f02756da49..850433fad907 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java @@ -83,6 +83,8 @@ public class PublicKeysController { new RSAKey.Builder(dhisOidcClientRegistration.getRsaPublicKey()) .keyUse(KeyUse.SIGNATURE) .algorithm(JWSAlgorithm.parse(jwsAlgorithm.toString())) + .x509CertSHA256Thumbprint( + dhisOidcClientRegistration.getJwk().getX509CertSHA256Thumbprint()) .keyID(dhisOidcClientRegistration.getKeyId()); return new JWKSet(builder.build()).toJSONObject(); From 81342d3b2b731ba546fe5cef95bb6dbf45c897db Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Thu, 7 May 2026 17:19:14 +0800 Subject: [PATCH 14/25] fix(oidc): verify userinfo JWT against IdP's JWKS, not client_auth JWKS [DHIS2-20043] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous SignedJwtUserInfoLoader was passing DhisOidcClientRegistration.jwkSetUrl (which is DHIS2's *own* JWKS URL, used to advertise client-auth keys for private_key_jwt) to JwkSourceCache when verifying the userinfo JWT. That URL would never resolve the IdP's signing keys, so signature verification would always fail in production. Switched to ClientRegistration.providerDetails.jwkSetUri (populated from the jwk_uri config key — already required and validated). Updated the test to mock that endpoint accordingly. No extra parser validation is needed because jwk_uri is already in the required-keys map. Also addressed code-review nits in the same change: - replaced Collections.singletonList with List.of (per AGENTS.md) - added disabledUserRaisesUserDisabled test exercising the user_disabled error code path AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/SignedJwtUserInfoLoader.java | 17 ++++++++++------- .../oidc/SignedJwtUserInfoLoaderTest.java | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java index eb094d78eb2d..e4430abced03 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -36,7 +36,7 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTProcessor; -import java.util.Collections; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -88,10 +88,12 @@ public class SignedJwtUserInfoLoader { * valid DHIS2 user */ public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { - String userInfoUri = - userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); + var providerDetails = userRequest.getClientRegistration().getProviderDetails(); + String userInfoUri = providerDetails.getUserInfoEndpoint().getUri(); + String idpJwkSetUri = providerDetails.getJwkSetUri(); String jwt = fetchJwt(userRequest, userInfoUri); - JWTClaimsSet claims = verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId()); + JWTClaimsSet claims = + verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId(), idpJwkSetUri); String mappingValue = requireMappingClaim(claims, reg); User user = requireExternalAuthUser(mappingValue, reg); UserDetails details = userService.createUserDetails(user); @@ -102,7 +104,7 @@ public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg private String fetchJwt(OidcUserRequest userRequest, String userInfoUri) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(userRequest.getAccessToken().getTokenValue()); - headers.setAccept(Collections.singletonList(MediaType.valueOf("application/jwt"))); + headers.setAccept(List.of(MediaType.valueOf("application/jwt"))); HttpEntity entity = new HttpEntity<>("", headers); try { ResponseEntity response = @@ -121,10 +123,11 @@ private String fetchJwt(OidcUserRequest userRequest, String userInfoUri) { } } - private JWTClaimsSet verify(String jwt, DhisOidcClientRegistration reg, String registrationId) { + private JWTClaimsSet verify( + String jwt, DhisOidcClientRegistration reg, String registrationId, String idpJwkSetUri) { try { ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); - JWKSource keySource = jwkSourceCache.get(registrationId, reg.getJwkSetUrl()); + JWKSource keySource = jwkSourceCache.get(registrationId, idpJwkSetUri); JWSKeySelector selector = new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); processor.setJWSKeySelector(selector); diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java index a5d34b6a72b1..78083bada2a7 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java @@ -119,6 +119,7 @@ public JWKSource get(String registrationId, String jwkSetUri) { when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); when(providerDetails.getUserInfoEndpoint()).thenReturn(userInfoEndpoint); when(userInfoEndpoint.getUri()).thenReturn("https://idp.test/userinfo"); + when(providerDetails.getJwkSetUri()).thenReturn("https://idp.test/jwks"); registration = DhisOidcClientRegistration.builder() @@ -126,7 +127,6 @@ public JWKSource get(String registrationId, String jwkSetUri) { .mappingClaimKey("sub") .userInfoResponseType(UserInfoResponseType.JWT) .userInfoJwsAlgorithm(JWSAlgorithm.RS256) - .jwkSetUrl("https://idp.test/jwks") .build(); loader = new SignedJwtUserInfoLoader(userService, jwkSourceCacheStub, restTemplate); @@ -207,6 +207,23 @@ void unknownUserRaisesError() throws Exception { assertEquals("user_not_found", ex.getError().getErrorCode()); } + @Test + void disabledUserRaisesUserDisabled() throws Exception { + String jwt = signJwt(claims("user-123")); + when(restTemplate.exchange( + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(jwt)); + User user = new User(); + user.setExternalAuth(true); + user.setDisabled(true); + when(userService.getUserByOpenId("user-123")).thenReturn(user); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("user_disabled", ex.getError().getErrorCode()); + } + // helpers private JWTClaimsSet claims(String sub) { From 253ee74c694cf2cee64f0c99d53cec4c155f7db7 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 11 May 2026 22:18:07 +0800 Subject: [PATCH 15/25] test(oidc): add real-HTTP integration test for SignedJwtUserInfoLoader [DHIS2-20043] Stands up the JDK's built-in HttpServer as an in-process eSignet-style IdP and exercises the full stack (real RestTemplate, real JwkSourceCache, real Nimbus JWT verification) end to end. Zero new dependencies. Covers happy path plus three negatives: bad-key JWT -> jwt_processing_error, IdP 500 -> invalid_user_info_response, and missing mapping claim -> missing_mapping_claim. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- ...dJwtUserInfoLoaderHttpIntegrationTest.java | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java new file mode 100644 index 000000000000..410565976e5a --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java @@ -0,0 +1,257 @@ +/* + * 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.security.oidc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.web.client.RestTemplate; + +/** + * End-to-end integration test for {@link SignedJwtUserInfoLoader} that wires the real {@link + * RestTemplate}, real {@link JwkSourceCache} (which talks real HTTP to fetch the IdP's JWKS), and + * real Nimbus JWT verification against an in-process HTTP server playing the part of an + * eSignet-style IdP. + * + *

The IdP-stub uses the JDK's built-in {@link HttpServer} so the test has no extra third-party + * dependency (no WireMock). The stub serves: + * + *

    + *
  • {@code GET /jwks.json} — returns the IdP's public {@link JWKSet} + *
  • {@code GET /userinfo} — requires a {@code Bearer} access token and returns a freshly signed + * userinfo JWT with {@code Content-Type: application/jwt} + *
+ * + * Only {@link UserService} is mocked, since exercising it would require a Spring/Hibernate context. + * + * @author Morten Svanæs + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SignedJwtUserInfoLoaderHttpIntegrationTest { + + private static final String EXPECTED_BEARER = "at-value"; + + @Mock private UserService userService; + @Mock private OidcUserRequest userRequest; + @Mock private OAuth2AccessToken accessToken; + @Mock private OidcIdToken idToken; + @Mock private ClientRegistration clientRegistration; + @Mock private ClientRegistration.ProviderDetails providerDetails; + @Mock private ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint; + + private HttpServer httpServer; + private int port; + private RSAKey idpSigningKey; + private SignedJwtUserInfoLoader loader; + private DhisOidcClientRegistration registration; + + /** Per-test bumped to make each registration id unique so {@link JwkSourceCache} isn't reused. */ + private static final AtomicInteger REG_SEQ = new AtomicInteger(); + + @BeforeEach + void startStubAndWire() throws Exception { + idpSigningKey = new RSAKeyGenerator(2048).keyID("test-key").generate(); + String publicJwks = new JWKSet(idpSigningKey.toPublicJWK()).toString(); + + httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/jwks.json", ex -> respond(ex, 200, "application/json", publicJwks)); + httpServer.createContext("/userinfo", this::serveUserInfo); + httpServer.setExecutor(null); + httpServer.start(); + port = httpServer.getAddress().getPort(); + + String regId = "esignet-" + REG_SEQ.incrementAndGet(); + when(userRequest.getClientRegistration()).thenReturn(clientRegistration); + when(userRequest.getAccessToken()).thenReturn(accessToken); + when(userRequest.getIdToken()).thenReturn(idToken); + when(accessToken.getTokenValue()).thenReturn(EXPECTED_BEARER); + when(clientRegistration.getRegistrationId()).thenReturn(regId); + when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); + when(providerDetails.getUserInfoEndpoint()).thenReturn(userInfoEndpoint); + when(userInfoEndpoint.getUri()).thenReturn(baseUrl() + "/userinfo"); + when(providerDetails.getJwkSetUri()).thenReturn(baseUrl() + "/jwks.json"); + + registration = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JWT) + .userInfoJwsAlgorithm(JWSAlgorithm.RS256) + .build(); + + loader = new SignedJwtUserInfoLoader(userService, new JwkSourceCache(), new RestTemplate()); + } + + @AfterEach + void stopStub() { + httpServer.stop(0); + } + + @Test + void happyPath_fetchesJwksAndUserInfoOverRealHttp_andResolvesDhisUser() { + User user = new User(); + user.setExternalAuth(true); + when(userService.getUserByOpenId("user-42")).thenReturn(user); + when(userService.createUserDetails(user)).thenReturn(UserDetails.fromUser(user)); + + nextResponseClaims = claims("user-42"); + nextResponseSigningKey = idpSigningKey; + + OidcUser result = loader.load(userRequest, registration); + + assertNotNull(result); + assertEquals("user-42", result.getAttributes().get("sub")); + } + + @Test + void jwtSignedWithUnknownKey_isRejectedWithJwtProcessingError() throws Exception { + nextResponseClaims = claims("user-42"); + nextResponseSigningKey = new RSAKeyGenerator(2048).keyID("rogue").generate(); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("jwt_processing_error", ex.getError().getErrorCode()); + } + + @Test + void userInfoEndpointReturns500_isMappedToInvalidUserInfoResponse() { + failNextWith = 500; + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("invalid_user_info_response", ex.getError().getErrorCode()); + } + + @Test + void missingMappingClaim_isMappedToMissingMappingClaim() { + nextResponseClaims = new JWTClaimsSet.Builder().issuer("idp").build(); + nextResponseSigningKey = idpSigningKey; + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("missing_mapping_claim", ex.getError().getErrorCode()); + } + + // ---------- HttpServer-stub plumbing ---------- + + private JWTClaimsSet nextResponseClaims; + private RSAKey nextResponseSigningKey; + private Integer failNextWith; + + private void serveUserInfo(HttpExchange ex) throws IOException { + String auth = ex.getRequestHeaders().getFirst("Authorization"); + if (auth == null || !auth.equals("Bearer " + EXPECTED_BEARER)) { + respond(ex, 401, "text/plain", "unauthorized"); + return; + } + if (failNextWith != null) { + respond(ex, failNextWith, "text/plain", "server error"); + return; + } + try { + String jwt = signJwt(nextResponseClaims, nextResponseSigningKey); + respond(ex, 200, "application/jwt", jwt); + } catch (JOSEException e) { + respond(ex, 500, "text/plain", "sign-failed: " + e.getMessage()); + } + } + + private static void respond(HttpExchange ex, int status, String contentType, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", contentType); + ex.sendResponseHeaders(status, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + + private String baseUrl() { + return "http://127.0.0.1:" + port; + } + + private static JWTClaimsSet claims(String sub) { + return new JWTClaimsSet.Builder() + .subject(sub) + .issuer("idp") + .issueTime(Date.from(Instant.now())) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .build(); + } + + private static String signJwt(JWTClaimsSet claims, RSAKey signingKey) throws JOSEException { + SignedJWT signed = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(), claims); + signed.sign(new RSASSASigner(signingKey)); + return signed.serialize(); + } +} From 8b5fb77deb6c98da7a73093bfb0cdfc08d5b202f Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 11 May 2026 22:18:20 +0800 Subject: [PATCH 16/25] docs(oidc): add JWT userinfo hardening + e2e mock-IdP plan [DHIS2-20043] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up docs for the signed-JWT userinfo work: * OAUTH2_JWT_USERINFO_HARDENING.md — four defense-in-depth items surfaced by the security review (sub binding per OIDC 5.3.2, iss/aud verification, private_key_jwt parser strictness, error-description hygiene). Not exploitable today; track for hardening before eSignet prod use. * OIDC_E2E_MOCK_IDP_PLAN.md — comprehensive plan for a generic OIDC RP-side e2e test harness: small custom Java mock IdP service in the e2e module (signed-JWT userinfo is rare enough that no off-the-shelf option supports it), per-realm scenario API, test class per OIDC feature, compose overlay and CI wiring. Phase 0 is the in-process HTTP integration test landed in this branch; phases 1-5 are scoped for follow-up work. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md | 129 +++++++ dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md | 379 +++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md create mode 100644 dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md diff --git a/dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md b/dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md new file mode 100644 index 000000000000..2b7fa3a4a1a7 --- /dev/null +++ b/dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md @@ -0,0 +1,129 @@ +# OAuth2 / OIDC JWT UserInfo — Hardening Follow-ups + +Defense-in-depth items for the signed-JWT userinfo path added in PR #23839 +(`feat/DHIS2-20043-esignet-oidc-jwt-userinfo`). None of these are exploitable +vulnerabilities under the current threat model — operator-trusted dhis.conf, +operator-trusted IdP, server-to-server userinfo fetch over TLS — but they +close gaps that would matter if any of those assumptions weaken (multi-tenant +IdP, hostile RP on shared issuer, IdP compromise, lax operator config). + +Reference: +- Implementation: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java` +- Spec: `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md` +- OIDC Core §5.3.2 — UserInfo Response + +--- + +## 1. Bind UserInfo `sub` to ID-token `sub` (OIDC §5.3.2) + +OIDC Core §5.3.2 mandates: + +> "The sub Claim in the UserInfo Response MUST be verified to exactly match +> the sub Claim in the ID Token; if they do not match, the UserInfo Response +> values MUST NOT be used." + +Spring Security's stock `OidcUserService` enforces this for the JSON path. +`SignedJwtUserInfoLoader.load()` currently skips it. + +**Action.** In `SignedJwtUserInfoLoader.load()`, after `verify(...)` returns +the `JWTClaimsSet`, compare its `sub` to `userRequest.getIdToken().getSubject()` +and throw an `OAuth2AuthenticationException` with error code +`invalid_user_info_response` when they differ. + +```java +String userInfoSub = claims.getSubject(); +String idTokenSub = userRequest.getIdToken().getSubject(); +if (userInfoSub == null || !userInfoSub.equals(idTokenSub)) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_user_info_response"), + "UserInfo sub does not match ID Token sub"); +} +``` + +Why it matters when the IdP is shared: an attacker controlling a second RP on +the same IdP cannot replay a JWT into our flow today (it's a server-to-server +fetch bound to our access token), but this check makes the binding explicit +and matches Spring's behavior on the JSON path. + +## 2. Verify `iss` and `aud` on the UserInfo JWT + +`new DefaultJWTProcessor<>()` is used without an explicit +`setJWTClaimsSetVerifier(...)`. Nimbus's default verifier does enforce +`exp`/`nbf`, so expired tokens are rejected — but `iss` and `aud` are not +checked. + +**Action.** Pin issuer and audience: + +```java +DefaultJWTClaimsVerifier verifier = + new DefaultJWTClaimsVerifier<>( + new JWTClaimsSet.Builder() + .issuer(reg.getClientRegistration().getProviderDetails().getIssuerUri()) + .audience(reg.getClientRegistration().getClientId()) + .build(), + Set.of("sub", "iss", "aud", "exp")); +processor.setJWTClaimsSetVerifier(verifier); +``` + +On a multi-tenant IdP (e.g. national eSignet deployments) this prevents a JWT +minted for any other RP from being accepted here even if it leaks into the +flow somehow. + +## 3. Hard-fail `private_key_jwt` parser when keystore is partial + +`GenericOidcProviderConfigParser.isPrivateKeyJwt(...)` returns `true` only +when all four keystore properties are present and non-empty. If the operator +sets `client_authentication_method=private_key_jwt` but forgets, say, +`keystore_password`, the helper returns `false` and the parser then complains +about the missing `client_secret` instead — masking the real misconfig. The +builder can also produce a half-configured registration with no JWK and no +secret. + +**Action.** When `client_authentication_method=private_key_jwt`: + +- At parse time, reject the provider unless all four keystore properties + (`keystore_path`, `keystore_password`, `key_alias`, `key_password`) plus + `jwk_set_url` are present. +- Log the specific missing key so operators see the real cause. + +Not a vulnerability (dhis.conf is operator-trusted), but a UX trap worth +closing. + +## 4. Strip claim values and upstream messages from `OAuth2Error` descriptions + +Several error paths in `SignedJwtUserInfoLoader` embed `mappingValue`, +`mappingClaimKey`, or `ex.getMessage()` into the `OAuth2Error` description. +Spring renders those in the failure-redirect URL. + +**Action.** Keep upstream details in server-side logs (`log.debug`/`warn`); +return generic descriptions to the client: + +```java +log.warn("UserInfo JWT verification failed for registration {}", registrationId, ex); +throw new OAuth2AuthenticationException( + new OAuth2Error("jwt_processing_error"), "Failed to verify UserInfo JWT"); +``` + +Matches the JSON path's existing, terser failure responses. + +--- + +## What we already do right (worth keeping) + +- `SupportedJwsAlgorithms` allow-list excludes HMAC and `none`, blocking + algorithm-confusion attacks at config-load time. +- `JwkSourceCache` keys by `registrationId`, not by URL, so one registration + cannot poison another's cache. +- Nimbus `JWKSourceBuilder` retains its built-in remote-key refresh policy + across logins (we cache the `JWKSource`, not the keys). +- Default `UserInfoResponseType` is `JSON`, so every existing provider keeps + Spring Security's standard path unchanged. + +## Suggested rollout + +1. Land items 1, 2, 4 together as one PR — small, mechanical, fully + unit-testable against `SignedJwtUserInfoLoaderTest`. +2. Land item 3 separately — it touches the parser and warrants its own + regression tests covering each "missing one keystore key" permutation. +3. Before enabling eSignet in any production deployment, verify items 1–4 are + in place. diff --git a/dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md b/dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md new file mode 100644 index 000000000000..a5b91adeceb5 --- /dev/null +++ b/dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md @@ -0,0 +1,379 @@ +# OIDC End-to-End Testing with a Mock IdP — Implementation Plan + +**Status:** proposal — not implemented yet. +**Owner:** Morten Svanæs (`@netroms`). +**Target version:** 2.44+ (initial framework); features added incrementally. +**Related:** PR #23839 (DHIS2-20043 eSignet signed-JWT userinfo). + +--- + +## Goals + +1. Be able to run a real end-to-end test against DHIS2's **Relying-Party** OIDC + stack — i.e. `oauth2.login.enabled=on` + `oidc.provider..*` configs — + without depending on any third-party IdP (Google, Azure, eSignet sandbox). +2. Cover **every OIDC feature DHIS2 supports as an RP**, not just the + signed-JWT-userinfo path being shipped now: standard JSON userinfo, + signed-JWT userinfo, `private_key_jwt` client auth, PKCE, scope/claim + variations, logout/end-session, error paths. +3. Keep it cheap to add coverage: one mock-IdP service, multiple test classes, + per-test runtime configuration of how the IdP responds. + +This plan **does not** cover DHIS2-as-Authorization-Server testing. That is +already done by `dhis-2/dhis-test-e2e/.../oauth2/OAuth2Test.java` against the +Spring Authorization Server. + +## Non-goals + +- Running a real eSignet stack in CI — too heavy (Postgres + Kafka + mock + identity system). +- Testing real Google / Azure / Keycloak. Those are integration concerns, + out of scope for unit-level CI. +- UI/UX testing of the login button rendering — already covered separately + via the `uitests` profile. + +--- + +## Why a custom mock IdP + +No off-the-shelf mock OIDC IdP supports signed-JWT userinfo +(`application/jwt`): + +| Project | Image | Signed-JWT userinfo | private_key_jwt | License | +|---|---|---|---|---| +| navikt/mock-oauth2-server | `ghcr.io/navikt/mock-oauth2-server` | ❌ JSON only | ✅ | MIT | +| Soluto/oidc-server-mock (IS4) | `ghcr.io/soluto/oidc-server-mock` | ❌ (upstream IS4 lacks it) | ✅ | Apache-2 | +| oauth2-proxy/mockoidc | — | ❌ | partial | MIT | +| MOSIP eSignet | `mosipid/esignet` | ✅ (signed-then-encrypted) | ✅ | MPL-2.0 | +| WireMock + init script | `wiremock/wiremock` | ✅ (DIY) | ✅ (DIY) | Apache-2 | + +Real eSignet works but pulls in Postgres + Kafka + the mock-identity-system — +two orders of magnitude too heavy for a CI job. + +WireMock works but cannot sign at request time, so still needs an init +script and shell glue. Net cost is similar to writing it in Java. + +**Decision: write a small standalone Java service.** It reuses Nimbus +JOSE+JWT 10.9 (already on the e2e classpath, same lib DHIS2 itself verifies +with — so signing semantics match exactly). About 150–250 lines. + +--- + +## Mock IdP design + +### Surface + +The service exposes the standard OIDC endpoints: + +| Path | Method | Purpose | +|---|---|---| +| `/.well-known/openid-configuration` | GET | Discovery document | +| `/jwks` | GET | Public keys (auto-rotated optionally) | +| `/authorize` | GET | 302-redirects to `redirect_uri` with `?code=…&state=…` | +| `/token` | POST | Exchanges code → access_token + id_token (+ refresh_token) | +| `/userinfo` | GET | Returns JSON **or** signed-JWT based on `Accept` header / per-realm config | +| `/end_session` | GET | Spec-compliant end-session endpoint | +| `/admin/scenario` | POST | **Test-only** — set the next response shape per realm | +| `/admin/keys/rotate` | POST | **Test-only** — force key rotation | + +The `/admin/*` endpoints are how tests reconfigure the IdP at runtime +without restarting the container. Each scenario covers one realm (a +provider id), so multiple tests can run against the same image with +isolated config. + +### Realms / providers + +The mock IdP supports multiple realms in one process. Each test class +"owns" a realm name (e.g. `esignet`, `azure-fake`, `google-fake`) and POSTs +a scenario to `/admin/scenario/{realm}` in `@BeforeEach`. DHIS2 sees them as +independent providers via separate `oidc.provider..*` configs. + +Realm config controls: + +- `userinfo_response_type`: `json` or `jwt` +- `userinfo_signing_alg`: `RS256` / `PS256` / `ES256` / … +- `userinfo_claims`: the JSON object the userinfo returns (so tests pick + the mapping value) +- `id_token_claims`: extra claims merged into the id_token (so tests can + validate `sub`, `email_verified`, `aud`, `iss`, custom claims) +- `id_token_signing_alg`: keep the id_token alg the same as userinfo by + default; allow override for negative tests +- `require_pkce`: true/false +- `require_private_key_jwt`: true/false. When true, `/token` rejects + `client_secret_*` and verifies the client's `private_key_jwt` assertion + against the client's registered public JWK. +- `client_secret`: optional shared secret for `client_secret_basic` / + `client_secret_post` +- `client_jwks_uri`: optional URL to fetch the client's public JWKS from + (so the IdP can verify `private_key_jwt` assertions issued by DHIS2) +- `next_error`: inject a specific error response (HTTP 400/500, malformed + JWT, expired JWT, bad-key-signed JWT, missing claim …) to drive the + negative-path tests + +### Keys + +- The mock IdP generates RSA-2048 keys on boot. JWKS exposes the public + key. +- Test code that needs to issue *its own* keys (e.g. to validate + DHIS2's `private_key_jwt`) does so in the e2e module and registers the + public JWK with the mock IdP via `/admin/scenario`. +- A `/admin/keys/rotate` endpoint generates a new keypair and serves the + union of old+new from `/jwks` for one minute, then drops the old one — + lets tests assert DHIS2 handles JWKS rotation correctly (Nimbus's + built-in remote-key cache should pick it up without restart). + +### Why test-only admin endpoints over startup config + +Easier to write fast, hermetic tests. A scenario PUT per test class is +~10 ms and keeps the IdP container reused across the suite. The +alternative (env var per realm, restart container per test class) is +slow and brittle. + +--- + +## Test framework + +### Base class + +```java +abstract class OidcRpE2ETest extends BaseE2ETest { + + protected MockIdpClient idp; // talks to /admin/* on the mock-idp service + protected String realm; // unique per test class + + @BeforeEach void setUpRealm() { + realm = "realm-" + getClass().getSimpleName().toLowerCase(); + idp.resetRealm(realm); + } + + protected void givenUserInfoJwt(String sub, Map claims) { ... } + protected void givenUserInfoJson(Map claims) { ... } + protected void givenIdToken(Map claims) { ... } + protected void rotateJwks() { idp.post("/admin/keys/rotate"); } + + protected String performAuthCodeFlow(String dhis2Username) { ... } + // ↑ returns the bearer cookie / JWT for /api/me asserts +} +``` + +`performAuthCodeFlow` follows the redirects without rendering a UI: hit +`/oauth2/authorization/` → follow 302 to mock-idp → mock-idp +auto-redirects back with a code (no login UI needed since the scenario +already says "who" is logging in) → DHIS2 completes the code-for-token +exchange, calls `/userinfo`, resolves the local user. + +### Test classes + +One class per OIDC feature. Each owns its realm and config. + +| Test class | Realm | Asserts | +|---|---|---| +| `OidcJsonUserInfoTest` | `realm-jsonuserinfo` | Standard `application/json` userinfo path. Sub-class verification, mapping claim, disabled-user error. | +| `OidcSignedJwtUserInfoTest` | `realm-jwtuserinfo` | This PR's path. `Accept: application/jwt`, signature verified, mapping claim resolved. Includes bad-key / expired-JWT / missing-claim negatives. | +| `OidcPrivateKeyJwtTest` | `realm-pkj` | `client_authentication_method=private_key_jwt`. DHIS2 issues a signed client assertion, mock IdP verifies it against DHIS2's `/api/publicKeys/.../jwks.json`. | +| `OidcPkceTest` | `realm-pkce` | `enable_pkce=on`. Mock IdP rejects the `/token` call without a `code_verifier`. | +| `OidcJwksRotationTest` | `realm-rotate` | Rotate IdP keys mid-session; assert next login succeeds without restarting DHIS2. | +| `OidcEndSessionTest` | `realm-logout` | Login, hit DHIS2 logout, assert browser redirects to `end_session_endpoint` and cookies are cleared. | +| `OidcLinkedAccountsTest` | `realm-linked` | Single IdP sub → two DHIS2 accounts (linked_accounts feature). | +| `OidcMappingClaimVariationsTest` | `realm-mapping` | `mapping_claim=email`, `=sub`, `=preferred_username`. Asserts the right local lookup for each. | +| `OidcErrorPathsTest` | `realm-errors` | Mock IdP responds 500 / malformed JWT / missing iss / wrong aud. Each maps to the documented DHIS2 error code. | + +All tagged `@Tag("oauth2tests")` so they run as part of the default +api-tests job. `@Tag("oauth2tests")` is currently not gated by any +surefire profile, so registering the tests is enough — no pom changes. + +--- + +## Repository layout + +``` +dhis-2/ +├── dhis-test-e2e/ +│ ├── mock-idp/ # NEW +│ │ ├── pom.xml # tiny module, Jib for image build +│ │ ├── Dockerfile # (or Jib-only) +│ │ └── src/main/java/org/hisp/dhis/mockidp/ +│ │ ├── MockIdpApplication.java # main() + HttpServer wiring +│ │ ├── Realm.java # per-realm state + keys +│ │ ├── ScenarioStore.java # concurrent-safe in-memory store +│ │ ├── handler/AuthorizeHandler.java +│ │ ├── handler/TokenHandler.java +│ │ ├── handler/UserInfoHandler.java # JSON vs JWT branch +│ │ ├── handler/JwksHandler.java +│ │ ├── handler/DiscoveryHandler.java +│ │ ├── handler/EndSessionHandler.java +│ │ └── handler/AdminHandler.java # /admin/scenario, /admin/keys/rotate +│ ├── docker-compose.e2e-oidc.yml # NEW — adds mock-idp service + alt dhis.conf +│ ├── config/dhis2_home/dhis-oidc.conf # NEW — oidc.provider.* for every realm +│ └── src/test/java/org/hisp/dhis/oidc/ # NEW package +│ ├── OidcRpE2ETest.java # base class +│ ├── MockIdpClient.java # talks to /admin/* +│ ├── OidcJsonUserInfoTest.java +│ ├── OidcSignedJwtUserInfoTest.java +│ ├── OidcPrivateKeyJwtTest.java +│ ├── OidcPkceTest.java +│ ├── OidcJwksRotationTest.java +│ ├── OidcEndSessionTest.java +│ ├── OidcLinkedAccountsTest.java +│ ├── OidcMappingClaimVariationsTest.java +│ └── OidcErrorPathsTest.java +└── .github/workflows/run-api-tests.yml # MODIFIED — add OIDC overlay job +``` + +--- + +## CI wiring + +Two options: + +1. **Reuse the existing `api-test` job** by always adding the OIDC compose + overlay. Cost: one extra container in every api-test run (~30 s image + build + ~5 s startup + ~30 s mock-idp tests). Net delta on a 12-minute + suite: small. +2. **Separate job** that reuses the built `CORE_IMAGE_NAME` and only adds + the mock-idp container. Slightly faster feedback per job; doubles the + matrix complexity. + +**Recommendation: option 1.** Simpler, no matrix changes, and the new +tests are useful as a smoke test for every PR anyway (they touch +authentication). + +```yaml +# .github/workflows/run-api-tests.yml — modified step +- name: Run api e2e tests with OIDC overlay + run: | + docker compose \ + -f dhis-2/dhis-test-e2e/docker-compose.yml \ + -f dhis-2/dhis-test-e2e/docker-compose.e2e.yml \ + -f dhis-2/dhis-test-e2e/docker-compose.e2e-oidc.yml \ + up --exit-code-from test +``` + +Mock-idp image is built via Jib at the same time as the core image +(parallel goals in the Maven reactor). + +--- + +## `dhis.conf` fragment + +Realm-per-provider, one block per test class. Excerpt: + +```properties +oidc.oauth2.login.enabled = on + +# Standard JSON userinfo realm +oidc.provider.realm-jsonuserinfo.client_id = dhis2-rp +oidc.provider.realm-jsonuserinfo.client_secret = test-secret +oidc.provider.realm-jsonuserinfo.mapping_claim = email +oidc.provider.realm-jsonuserinfo.authorization_uri = http://mock-idp:9000/realm-jsonuserinfo/authorize +oidc.provider.realm-jsonuserinfo.token_uri = http://mock-idp:9000/realm-jsonuserinfo/token +oidc.provider.realm-jsonuserinfo.user_info_uri = http://mock-idp:9000/realm-jsonuserinfo/userinfo +oidc.provider.realm-jsonuserinfo.jwk_uri = http://mock-idp:9000/realm-jsonuserinfo/jwks +oidc.provider.realm-jsonuserinfo.issuer_uri = http://mock-idp:9000/realm-jsonuserinfo/ +oidc.provider.realm-jsonuserinfo.scopes = openid,email + +# Signed-JWT userinfo realm (this PR's path) +oidc.provider.realm-jwtuserinfo.client_id = dhis2-rp +oidc.provider.realm-jwtuserinfo.client_secret = test-secret +oidc.provider.realm-jwtuserinfo.mapping_claim = sub +oidc.provider.realm-jwtuserinfo.authorization_uri = http://mock-idp:9000/realm-jwtuserinfo/authorize +oidc.provider.realm-jwtuserinfo.token_uri = http://mock-idp:9000/realm-jwtuserinfo/token +oidc.provider.realm-jwtuserinfo.user_info_uri = http://mock-idp:9000/realm-jwtuserinfo/userinfo +oidc.provider.realm-jwtuserinfo.jwk_uri = http://mock-idp:9000/realm-jwtuserinfo/jwks +oidc.provider.realm-jwtuserinfo.issuer_uri = http://mock-idp:9000/realm-jwtuserinfo/ +oidc.provider.realm-jwtuserinfo.scopes = openid +oidc.provider.realm-jwtuserinfo.user_info_response_type = jwt +oidc.provider.realm-jwtuserinfo.user_info_jws_algorithm = RS256 + +# private_key_jwt realm +oidc.provider.realm-pkj.client_id = dhis2-rp +oidc.provider.realm-pkj.client_authentication_method = private_key_jwt +oidc.provider.realm-pkj.keystore_path = /opt/dhis2/test-keystores/realm-pkj.p12 +oidc.provider.realm-pkj.keystore_password = test +oidc.provider.realm-pkj.key_alias = realm-pkj +oidc.provider.realm-pkj.key_password = test +oidc.provider.realm-pkj.jwk_set_url = http://web:8080/api/publicKeys/dhis2-rp/jwks.json +# ... etc +``` + +Test keystore generation lives in `dhis-test-e2e/scripts/gen-test-keys.sh` +and runs at image-build time (so the keystore is baked into the `web` +image alongside `dhis-oidc.conf`). + +--- + +## Phased rollout + +| Phase | Scope | Effort | +|---|---|---| +| **0** | Land this plan doc + the in-process WireMock-style integration test (`SignedJwtUserInfoLoaderHttpIntegrationTest`) already done in PR #23839. | done | +| **1** | Build the mock-idp module (skeleton: `/authorize`, `/token`, `/userinfo` JSON path, `/jwks`, `/admin/scenario`). Wire compose overlay. Land `OidcJsonUserInfoTest` (golden path). | ~1 day | +| **2** | Add the JWT userinfo branch to `/userinfo` + the negative-path support (bad key, expired, missing claim). Land `OidcSignedJwtUserInfoTest`. | ~½ day | +| **3** | Add `private_key_jwt` verification at `/token`. Generate test keystore at image-build. Land `OidcPrivateKeyJwtTest`. | ~½ day | +| **4** | PKCE, JWKS rotation, end-session, mapping-claim variations, linked accounts, error paths. One test class at a time. | ~2–3 days total | +| **5** | (Optional) JWE / nested signed-then-encrypted userinfo, if/when DHIS2 grows support for it. | TBD | + +Each phase is independently shippable. Phase 1 alone justifies the +infrastructure — it gives us a real RP smoke test that currently doesn't +exist anywhere. + +--- + +## Open design decisions + +1. **Per-realm path prefixing.** Above examples use + `http://mock-idp:9000//authorize`. Cleaner than query-param + routing. Discovery doc at `…//.well-known/openid-configuration` + needs to surface the realm-prefixed URLs. +2. **Selenium or no Selenium?** Mock IdP auto-redirects, no UI clicks + required. Recommend pure REST flow following 302s with + `RestTemplate(disableRedirect=true)`. Selenium only useful if we want + to assert browser-cookie state. Defer that to a later UI-test pass. +3. **State across redirects.** DHIS2's `oauth2Login` uses session cookies + to carry `state`/`nonce`. The test client needs to preserve cookies + across the 302-chain. `RestTemplate` with a `BasicCookieStore`-backed + Apache HttpClient handles this. +4. **`@Tag("oauth2tests")` vs new tag.** Reusing the existing tag keeps + things simple; the existing `OAuth2Test` is fast and runs in the same + job. If the OIDC suite grows beyond ~30 seconds, split into + `@Tag("oidc")` and add a profile. +5. **Image size / cold-start.** Aim for <50 MB image (Alpine + JRE + slim + ~250 KB jar). Mock IdP cold-start should be <2 s. + +--- + +## What this does NOT change in the existing codebase + +- `OAuth2Test.java` (DHIS2-as-AS test) is unaffected. Different flow, + different concerns; keep it as-is. +- Production code in `dhis-services/dhis-service-core/security/oidc/*` + needs no changes. The mock IdP speaks standard OIDC; DHIS2 doesn't + know it's a mock. +- The new `SignedJwtUserInfoLoaderHttpIntegrationTest` (PR #23839) stays + — it's faster per-PR feedback at the unit level. The e2e suite is + additive. + +--- + +## Acceptance criteria for the framework (phase 1 completion) + +1. `docker compose -f docker-compose.yml -f docker-compose.e2e.yml -f docker-compose.e2e-oidc.yml up` + starts cleanly on a developer laptop and in CI. +2. `OidcJsonUserInfoTest` passes against the mock IdP without using any + real external service. +3. The mock-idp container starts in <3 s, image is <50 MB. +4. Adding a new test class for a new OIDC feature requires: one test + class, one `oidc.provider..*` block, zero changes to the mock + IdP code (everything driven via `/admin/scenario`). + +--- + +## References + +- PR #23839 — DHIS2-20043 — signed-JWT userinfo (this branch) +- Plan: `dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md` +- Spec: `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md` +- Hardening follow-ups: `dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md` +- Existing RP code: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/` +- Existing AS-side test (reference style): `dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java` +- Survey notes: see PR thread #23839 comments From a3dcd776f8d66496cf3f208bced706e83c863935 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 03:41:01 +0800 Subject: [PATCH 17/25] fix(oidc): address PR review feedback for JWT userinfo [DHIS2-20043] Add RestTemplate timeouts (5s connect, 10s read), remove redundant Javadoc comment, move HTTP integration test to dhis-test-web-api with MockServer, and drop docs/superpowers/ from the PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../security/oidc/DhisOidcUserService.java | 3 +- .../oidc/SignedJwtUserInfoLoader.java | 26 +- ...dJwtUserInfoLoaderHttpIntegrationTest.java | 176 +- .../2026-05-06-esignet-oidc-jwt-userinfo.md | 1606 ----------------- ...6-05-06-esignet-oidc-integration-design.md | 263 --- 5 files changed, 115 insertions(+), 1959 deletions(-) rename dhis-2/{dhis-services/dhis-service-core => dhis-test-web-api}/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java (65%) delete mode 100644 dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md delete mode 100644 dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java index 0123bdabc5ad..48fd81f071e5 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java @@ -80,8 +80,7 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio /** * JSON-userinfo path: delegates to Spring's {@link OidcUserService#loadUser(OidcUserRequest)}, - * then resolves the mapping claim to a local DHIS2 user. Package-private to allow direct stubbing - * in {@link DhisOidcUserServiceDispatchTest}. + * then resolves the mapping claim to a local DHIS2 user. */ OidcUser loadFromJsonUserInfo(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { OidcUser oidcUser = super.loadUser(userRequest); diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java index e4430abced03..a84cbca63037 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -38,16 +38,17 @@ import com.nimbusds.jwt.proc.DefaultJWTProcessor; import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; @@ -71,13 +72,34 @@ */ @Slf4j @Component -@RequiredArgsConstructor public class SignedJwtUserInfoLoader { + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 10_000; + private final UserService userService; private final JwkSourceCache jwkSourceCache; private final RestTemplate restTemplate; + @Autowired + SignedJwtUserInfoLoader(UserService userService, JwkSourceCache jwkSourceCache) { + this(userService, jwkSourceCache, createRestTemplate()); + } + + SignedJwtUserInfoLoader( + UserService userService, JwkSourceCache jwkSourceCache, RestTemplate restTemplate) { + this.userService = userService; + this.jwkSourceCache = jwkSourceCache; + this.restTemplate = restTemplate; + } + + private static RestTemplate createRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + return new RestTemplate(factory); + } + /** * Fetches, verifies and maps a signed-JWT userinfo response to a {@link DhisOidcUser}. * diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java similarity index 65% rename from dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java rename to dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java index 410565976e5a..a38ed0f96f87 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java @@ -12,7 +12,7 @@ * 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 + * 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. * @@ -33,6 +33,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -43,58 +45,58 @@ import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.concurrent.atomic.AtomicInteger; import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserService; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.mockserver.client.MockServerClient; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.web.client.RestTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; /** - * End-to-end integration test for {@link SignedJwtUserInfoLoader} that wires the real {@link - * RestTemplate}, real {@link JwkSourceCache} (which talks real HTTP to fetch the IdP's JWKS), and - * real Nimbus JWT verification against an in-process HTTP server playing the part of an - * eSignet-style IdP. + * Integration test for {@link SignedJwtUserInfoLoader} that exercises real HTTP, real JWKS fetching + * via {@link JwkSourceCache}, and real Nimbus JWT verification against a MockServer container + * playing the part of an eSignet-style IdP. * - *

The IdP-stub uses the JDK's built-in {@link HttpServer} so the test has no extra third-party - * dependency (no WireMock). The stub serves: + *

The MockServer stub serves: * *

    - *
  • {@code GET /jwks.json} — returns the IdP's public {@link JWKSet} - *
  • {@code GET /userinfo} — requires a {@code Bearer} access token and returns a freshly signed - * userinfo JWT with {@code Content-Type: application/jwt} + *
  • {@code GET /jwks.json} - the IdP's public JWK set + *
  • {@code GET /userinfo} - a signed userinfo JWT with {@code Content-Type: application/jwt} *
* - * Only {@link UserService} is mocked, since exercising it would require a Spring/Hibernate context. + * Only {@link UserService} is mocked since exercising it would require a Spring/Hibernate context. * - * @author Morten Svanæs + * @author Morten Svanaes */ +@Tag("integration") @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class SignedJwtUserInfoLoaderHttpIntegrationTest { private static final String EXPECTED_BEARER = "at-value"; + private static final AtomicInteger REG_SEQ = new AtomicInteger(); + + private static GenericContainer mockServerContainer; + private static MockServerClient mockServerClient; @Mock private UserService userService; @Mock private OidcUserRequest userRequest; @@ -104,26 +106,41 @@ class SignedJwtUserInfoLoaderHttpIntegrationTest { @Mock private ClientRegistration.ProviderDetails providerDetails; @Mock private ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint; - private HttpServer httpServer; - private int port; private RSAKey idpSigningKey; private SignedJwtUserInfoLoader loader; private DhisOidcClientRegistration registration; - /** Per-test bumped to make each registration id unique so {@link JwkSourceCache} isn't reused. */ - private static final AtomicInteger REG_SEQ = new AtomicInteger(); + @BeforeAll + static void startMockServer() { + mockServerContainer = + new GenericContainer<>("mockserver/mockserver:5.15.0") + .waitingFor(new HttpWaitStrategy().forStatusCode(404)) + .withExposedPorts(1080); + mockServerContainer.start(); + mockServerClient = + new MockServerClient("localhost", mockServerContainer.getFirstMappedPort()); + } + + @AfterAll + static void stopMockServer() { + mockServerContainer.stop(); + } @BeforeEach - void startStubAndWire() throws Exception { + void setUp() throws Exception { + mockServerClient.reset(); + idpSigningKey = new RSAKeyGenerator(2048).keyID("test-key").generate(); String publicJwks = new JWKSet(idpSigningKey.toPublicJWK()).toString(); + String baseUrl = "http://localhost:" + mockServerContainer.getFirstMappedPort(); - httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - httpServer.createContext("/jwks.json", ex -> respond(ex, 200, "application/json", publicJwks)); - httpServer.createContext("/userinfo", this::serveUserInfo); - httpServer.setExecutor(null); - httpServer.start(); - port = httpServer.getAddress().getPort(); + mockServerClient + .when(request().withPath("/jwks.json")) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(publicJwks)); String regId = "esignet-" + REG_SEQ.incrementAndGet(); when(userRequest.getClientRegistration()).thenReturn(clientRegistration); @@ -133,8 +150,8 @@ void startStubAndWire() throws Exception { when(clientRegistration.getRegistrationId()).thenReturn(regId); when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); when(providerDetails.getUserInfoEndpoint()).thenReturn(userInfoEndpoint); - when(userInfoEndpoint.getUri()).thenReturn(baseUrl() + "/userinfo"); - when(providerDetails.getJwkSetUri()).thenReturn(baseUrl() + "/jwks.json"); + when(userInfoEndpoint.getUri()).thenReturn(baseUrl + "/userinfo"); + when(providerDetails.getJwkSetUri()).thenReturn(baseUrl + "/jwks.json"); registration = DhisOidcClientRegistration.builder() @@ -144,23 +161,26 @@ void startStubAndWire() throws Exception { .userInfoJwsAlgorithm(JWSAlgorithm.RS256) .build(); - loader = new SignedJwtUserInfoLoader(userService, new JwkSourceCache(), new RestTemplate()); - } - - @AfterEach - void stopStub() { - httpServer.stop(0); + loader = new SignedJwtUserInfoLoader(userService, new JwkSourceCache()); } @Test - void happyPath_fetchesJwksAndUserInfoOverRealHttp_andResolvesDhisUser() { + void happyPath_fetchesJwksAndUserInfoOverRealHttp_andResolvesDhisUser() throws Exception { User user = new User(); user.setExternalAuth(true); when(userService.getUserByOpenId("user-42")).thenReturn(user); when(userService.createUserDetails(user)).thenReturn(UserDetails.fromUser(user)); - nextResponseClaims = claims("user-42"); - nextResponseSigningKey = idpSigningKey; + mockServerClient + .when( + request() + .withPath("/userinfo") + .withHeader("Authorization", "Bearer " + EXPECTED_BEARER)) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/jwt") + .withBody(signJwt(claims("user-42"), idpSigningKey))); OidcUser result = loader.load(userRequest, registration); @@ -170,8 +190,18 @@ void happyPath_fetchesJwksAndUserInfoOverRealHttp_andResolvesDhisUser() { @Test void jwtSignedWithUnknownKey_isRejectedWithJwtProcessingError() throws Exception { - nextResponseClaims = claims("user-42"); - nextResponseSigningKey = new RSAKeyGenerator(2048).keyID("rogue").generate(); + RSAKey rogueKey = new RSAKeyGenerator(2048).keyID("rogue").generate(); + + mockServerClient + .when( + request() + .withPath("/userinfo") + .withHeader("Authorization", "Bearer " + EXPECTED_BEARER)) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/jwt") + .withBody(signJwt(claims("user-42"), rogueKey))); OAuth2AuthenticationException ex = assertThrows( @@ -181,7 +211,9 @@ void jwtSignedWithUnknownKey_isRejectedWithJwtProcessingError() throws Exception @Test void userInfoEndpointReturns500_isMappedToInvalidUserInfoResponse() { - failNextWith = 500; + mockServerClient + .when(request().withPath("/userinfo")) + .respond(response().withStatusCode(500).withBody("server error")); OAuth2AuthenticationException ex = assertThrows( @@ -190,9 +222,19 @@ void userInfoEndpointReturns500_isMappedToInvalidUserInfoResponse() { } @Test - void missingMappingClaim_isMappedToMissingMappingClaim() { - nextResponseClaims = new JWTClaimsSet.Builder().issuer("idp").build(); - nextResponseSigningKey = idpSigningKey; + void missingMappingClaim_isMappedToMissingMappingClaim() throws Exception { + JWTClaimsSet noSub = new JWTClaimsSet.Builder().issuer("idp").build(); + + mockServerClient + .when( + request() + .withPath("/userinfo") + .withHeader("Authorization", "Bearer " + EXPECTED_BEARER)) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/jwt") + .withBody(signJwt(noSub, idpSigningKey))); OAuth2AuthenticationException ex = assertThrows( @@ -200,44 +242,6 @@ void missingMappingClaim_isMappedToMissingMappingClaim() { assertEquals("missing_mapping_claim", ex.getError().getErrorCode()); } - // ---------- HttpServer-stub plumbing ---------- - - private JWTClaimsSet nextResponseClaims; - private RSAKey nextResponseSigningKey; - private Integer failNextWith; - - private void serveUserInfo(HttpExchange ex) throws IOException { - String auth = ex.getRequestHeaders().getFirst("Authorization"); - if (auth == null || !auth.equals("Bearer " + EXPECTED_BEARER)) { - respond(ex, 401, "text/plain", "unauthorized"); - return; - } - if (failNextWith != null) { - respond(ex, failNextWith, "text/plain", "server error"); - return; - } - try { - String jwt = signJwt(nextResponseClaims, nextResponseSigningKey); - respond(ex, 200, "application/jwt", jwt); - } catch (JOSEException e) { - respond(ex, 500, "text/plain", "sign-failed: " + e.getMessage()); - } - } - - private static void respond(HttpExchange ex, int status, String contentType, String body) - throws IOException { - byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - ex.getResponseHeaders().set("Content-Type", contentType); - ex.sendResponseHeaders(status, bytes.length); - try (OutputStream os = ex.getResponseBody()) { - os.write(bytes); - } - } - - private String baseUrl() { - return "http://127.0.0.1:" + port; - } - private static JWTClaimsSet claims(String sub) { return new JWTClaimsSet.Builder() .subject(sub) diff --git a/dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md b/dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md deleted file mode 100644 index ded5e7b5bbda..000000000000 --- a/dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md +++ /dev/null @@ -1,1606 +0,0 @@ -# eSignet OIDC JWT Userinfo Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add per-provider opt-in support for JWT-encoded OIDC userinfo responses (eSignet style) without changing default JSON-userinfo behaviour for any existing provider. - -**Architecture:** Two new generic-provider config keys (`user_info_response_type`, `user_info_jws_algorithm`). `DhisOidcUserService` regains `extends OidcUserService` and dispatches to either the existing JSON path (`super.loadUser`) or a new `SignedJwtUserInfoLoader` based on the registration's `userInfoResponseType`. Generic parser conditionally relaxes `client_secret` only under `private_key_jwt`. - -**Tech Stack:** Java 17, Spring Security OAuth2 Client 6.x, Nimbus JOSE+JWT (already on the classpath), JUnit 5, Mockito. - -**Spec:** `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md` - ---- - -## File Map - -**Create:** - -| Path | Responsibility | -|---|---| -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java` | Enum: `JSON`, `JWT` | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java` | Static allow-list + `parse(String)` returning `JWSAlgorithm` | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java` | Per-registration `JWKSource` cache | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java` | Fetches `application/jwt` userinfo, verifies, maps to DHIS2 user | -| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java` | Unit tests for allow-list parsing | -| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java` | Unit tests for the JWT path | -| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java` | Verifies JSON vs JWT branch dispatch | - -**Modify:** - -| Path | Change | -|---|---| -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java` | Add `USER_INFO_RESPONSE_TYPE` and `USER_INFO_JWS_ALGORITHM` constants | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java` | Add `userInfoResponseType` (default `JSON`) and `userInfoJwsAlgorithm` fields | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java` | Read new keys, populate new fields, relax `clientSecret` requirement under `private_key_jwt` | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java` | Register new keys, validate enum/algorithm values, conditionally skip `client_secret` required-check | -| `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java` | Restore `extends OidcUserService`, branch on `userInfoResponseType` | -| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java` | New cases for the new keys and conditional client_secret relaxation | -| `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java` | New cases for `userInfoResponseType` / `userInfoJwsAlgorithm` round-trip and `private_key_jwt` allowing missing secret | -| `dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java` | Remove `@Disabled` | - -**License header:** Every new `.java` file uses the standard DHIS2 BSD header with `$YEAR` placeholder. Spotless will fill it in. Author tag: `@author Morten Svanæs ` per CLAUDE.md ("Always add me as author when you create a new file"). - ---- - -## Conventions - -- Use Java 17. Confirm with `java -version` before any test/format step. -- After **every** code change run `mvn spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core` (or the relevant module) before committing. -- Stage files explicitly with their full path. Never `git add -A` / `git add .`. -- `git status --short` before each commit; `git diff --cached --stat | tail -1` before any amend. -- Module build prerequisite for test runs: `mvn install -pl dhis-2/dhis-services/dhis-service-core -am -DskipTests -q` once at the start of a working session. - ---- - -## Task 1: `UserInfoResponseType` enum - -**Files:** -- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java` - -- [ ] **Step 1: Create the enum** - -```java -/* - * Copyright (c) 2004-$YEAR, 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.security.oidc; - -import javax.annotation.CheckForNull; - -/** - * Selects how DHIS2 should consume the OIDC userinfo response for a given provider. - * - *
    - *
  • {@link #JSON} — Spring Security's default: userinfo endpoint returns - * {@code application/json}. - *
  • {@link #JWT} — userinfo endpoint returns a signed JWT - * ({@code application/jwt}); used by MOSIP eSignet and similar IdPs. - *
- * - * @author Morten Svanæs - */ -public enum UserInfoResponseType { - JSON, - JWT; - - /** - * @param value config string from {@code dhis.conf}; case-insensitive - * @return matching enum, defaulting to {@link #JSON} when {@code value} is - * {@code null} or blank - * @throws IllegalArgumentException for unknown values - */ - public static UserInfoResponseType fromConfig(@CheckForNull String value) { - if (value == null || value.isBlank()) { - return JSON; - } - return UserInfoResponseType.valueOf(value.trim().toUpperCase()); - } -} -``` - -- [ ] **Step 2: Compile** - -Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` -Expected: BUILD SUCCESS. - -- [ ] **Step 3: Format** - -Run: `mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core` - -- [ ] **Step 4: Commit** - -```bash -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/UserInfoResponseType.java -git commit -m "feat(oidc): add UserInfoResponseType enum [DHIS2-20043]" -``` - ---- - -## Task 2: `SupportedJwsAlgorithms` allow-list (TDD) - -**Files:** -- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java` -- Test: `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java` - -- [ ] **Step 1: Write the failing test** - -```java -/* (standard $YEAR header) */ -package org.hisp.dhis.security.oidc; - -import static org.junit.jupiter.api.Assertions.*; - -import com.nimbusds.jose.JWSAlgorithm; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class SupportedJwsAlgorithmsTest { - - @Test - void parsesNullAsRs256Default() { - assertEquals(JWSAlgorithm.RS256, SupportedJwsAlgorithms.parseOrDefault(null)); - } - - @Test - void parsesBlankAsRs256Default() { - assertEquals(JWSAlgorithm.RS256, SupportedJwsAlgorithms.parseOrDefault(" ")); - } - - @ParameterizedTest - @ValueSource(strings = {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}) - void parsesAllSupportedAlgorithms(String name) { - assertEquals(name, SupportedJwsAlgorithms.parseOrDefault(name).getName()); - } - - @Test - void rejectsUnsupportedAlgorithm() { - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> SupportedJwsAlgorithms.parseOrDefault("HS256")); - assertTrue(ex.getMessage().contains("HS256")); - } - - @Test - void rejectsNonsense() { - assertThrows(IllegalArgumentException.class, () -> SupportedJwsAlgorithms.parseOrDefault("nope")); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=SupportedJwsAlgorithmsTest -``` -Expected: COMPILATION FAILURE (`SupportedJwsAlgorithms` does not exist). - -- [ ] **Step 3: Implement** - -Create `SupportedJwsAlgorithms.java`: - -```java -/* (standard $YEAR header) */ -package org.hisp.dhis.security.oidc; - -import com.nimbusds.jose.JWSAlgorithm; -import java.util.Set; -import javax.annotation.CheckForNull; - -/** - * Allow-list of JWS algorithms accepted for OIDC userinfo JWT verification. Failing closed at parse - * time prevents accidental acceptance of unexpected signature algorithms (e.g. HMAC) configured in - * {@code dhis.conf}. - * - * @author Morten Svanæs - */ -public final class SupportedJwsAlgorithms { - - public static final JWSAlgorithm DEFAULT = JWSAlgorithm.RS256; - - private static final Set ALLOWED = - Set.of( - JWSAlgorithm.RS256, JWSAlgorithm.RS384, JWSAlgorithm.RS512, - JWSAlgorithm.PS256, JWSAlgorithm.PS384, JWSAlgorithm.PS512, - JWSAlgorithm.ES256, JWSAlgorithm.ES384, JWSAlgorithm.ES512); - - private SupportedJwsAlgorithms() {} - - /** - * Parses a configured JWS algorithm name against the allow-list. - * - * @param value config string from {@code dhis.conf}; case-sensitive (Nimbus algorithm names) - * @return the matching {@link JWSAlgorithm}, or {@link #DEFAULT} when {@code value} is null/blank - * @throws IllegalArgumentException when the algorithm is not in the allow-list - */ - public static JWSAlgorithm parseOrDefault(@CheckForNull String value) { - if (value == null || value.isBlank()) { - return DEFAULT; - } - JWSAlgorithm parsed = JWSAlgorithm.parse(value.trim()); - if (!ALLOWED.contains(parsed)) { - throw new IllegalArgumentException( - "Unsupported user_info_jws_algorithm: '" + value + "'. Allowed: " + ALLOWED); - } - return parsed; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=SupportedJwsAlgorithmsTest -``` -Expected: BUILD SUCCESS, all tests green. - -- [ ] **Step 5: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithms.java \ - dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SupportedJwsAlgorithmsTest.java -git commit -m "feat(oidc): add JWS algorithm allow-list for userinfo verification [DHIS2-20043]" -``` - ---- - -## Task 3: New constants on `AbstractOidcProvider` - -**Files:** -- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java` (add after line 121, before closing brace) - -- [ ] **Step 1: Add the two constants** - -Append the following after the existing `JWK_SET_URL` constant (currently line 121): - -```java - /** - * Selects userinfo response handling: {@code json} (default; Spring Security's normal path) or - * {@code jwt} (eSignet-style signed JWT). See {@link - * org.hisp.dhis.security.oidc.UserInfoResponseType}. - */ - public static final String USER_INFO_RESPONSE_TYPE = "user_info_response_type"; - - /** - * JWS algorithm used to verify the userinfo JWT when {@link #USER_INFO_RESPONSE_TYPE} is - * {@code jwt}. Defaults to {@code RS256}. See {@link - * org.hisp.dhis.security.oidc.SupportedJwsAlgorithms}. - */ - public static final String USER_INFO_JWS_ALGORITHM = "user_info_jws_algorithm"; -``` - -- [ ] **Step 2: Compile** - -Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` -Expected: BUILD SUCCESS. - -- [ ] **Step 3: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/AbstractOidcProvider.java -git commit -m "feat(oidc): add user_info_response_type and user_info_jws_algorithm config keys [DHIS2-20043]" -``` - ---- - -## Task 4: Extend `DhisOidcClientRegistration` with the two new fields - -**Files:** -- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java` - -- [ ] **Step 1: Add imports** - -Add (preserving existing import order, alphabetical within sections): - -```java -import com.nimbusds.jose.JWSAlgorithm; -import javax.annotation.CheckForNull; -``` - -- [ ] **Step 2: Add the two fields** - -Place inside the `@Data @Builder` class body, after the existing `private final String jwkSetUrl;` field and before `@Builder.Default private final boolean visibleOnLoginPage = true;`: - -```java - /** - * Selects how DHIS2 consumes this provider's userinfo response. Defaults to - * {@link UserInfoResponseType#JSON}, preserving the historical Spring Security - * behaviour for every existing provider. - */ - @Builder.Default - private final UserInfoResponseType userInfoResponseType = UserInfoResponseType.JSON; - - /** - * JWS algorithm used to verify the signed userinfo JWT. Only consulted when - * {@link #userInfoResponseType} is {@link UserInfoResponseType#JWT}. - */ - @CheckForNull private final JWSAlgorithm userInfoJwsAlgorithm; -``` - -- [ ] **Step 3: Compile** - -Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` -Expected: BUILD SUCCESS. - -- [ ] **Step 4: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcClientRegistration.java -git commit -m "feat(oidc): track userInfo response type and JWS algorithm on registration [DHIS2-20043]" -``` - ---- - -## Task 5: `GenericOidcProviderConfigParser` — register keys, validate values, conditionally skip `client_secret` - -**Files:** -- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java` -- Modify (tests): `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java` - -- [ ] **Step 1: Write failing tests** - -Append to `GenericOidcProviderBuilderConfigParserTest`: - -```java - // --- new keys: user_info_response_type / user_info_jws_algorithm --- - - @Test - void parseAcceptsUserInfoResponseTypeJwt() { - Properties p = baseValidProvider("idporten"); - p.put("oidc.provider.idporten.user_info_response_type", "jwt"); - p.put("oidc.provider.idporten.user_info_jws_algorithm", "PS256"); - List parse = GenericOidcProviderConfigParser.parse(p); - assertThat(parse, hasSize(1)); - assertEquals(UserInfoResponseType.JWT, parse.get(0).getUserInfoResponseType()); - assertEquals("PS256", parse.get(0).getUserInfoJwsAlgorithm().getName()); - } - - @Test - void parseRejectsUnknownUserInfoResponseType() { - Properties p = baseValidProvider("idporten"); - p.put("oidc.provider.idporten.user_info_response_type", "yaml"); - assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); - } - - @Test - void parseRejectsUnsupportedJwsAlgorithm() { - Properties p = baseValidProvider("idporten"); - p.put("oidc.provider.idporten.user_info_response_type", "jwt"); - p.put("oidc.provider.idporten.user_info_jws_algorithm", "HS256"); - assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); - } - - // --- conditional client_secret relaxation --- - - @Test - void parseRejectsMissingClientSecretWithoutPrivateKeyJwt() { - Properties p = baseValidProvider("idporten"); - p.remove("oidc.provider.idporten.client_secret"); - assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); - } - - @Test - void parseAcceptsMissingClientSecretWithPrivateKeyJwt() { - Properties p = baseValidProvider("idporten"); - p.remove("oidc.provider.idporten.client_secret"); - p.put("oidc.provider.idporten.client_authentication_method", "private_key_jwt"); - p.put("oidc.provider.idporten.keystore_path", "/tmp/does-not-need-to-exist.p12"); - p.put("oidc.provider.idporten.keystore_password", "x"); - p.put("oidc.provider.idporten.key_alias", "x"); - p.put("oidc.provider.idporten.key_password", "x"); - p.put("oidc.provider.idporten.jwk_set_url", "https://example.test/jwks"); - // Builder may still fail to load the keystore from disk; we only assert the - // *parser* accepts the config. Wrap to ignore loader-time IO errors. - try { - GenericOidcProviderConfigParser.parse(p); - } catch (IllegalStateException expected) { - // builder can throw when reading non-existent keystore — that's fine for - // this parser-level assertion: validation passed before construction. - return; - } - } - - @Test - void parseStillRequiresUserInfoUriEvenInJwtMode() { - Properties p = baseValidProvider("idporten"); - p.remove("oidc.provider.idporten.user_info_uri"); - p.put("oidc.provider.idporten.user_info_response_type", "jwt"); - assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(0)); - } - - private static Properties baseValidProvider(String id) { - Properties p = new Properties(); - String pre = "oidc.provider." + id + "."; - p.put(pre + "client_id", "testClientId"); - p.put(pre + "client_secret", "testClientSecret"); - p.put(pre + "authorization_uri", "https://oidc.test/authorize"); - p.put(pre + "token_uri", "https://oidc.test/token"); - p.put(pre + "user_info_uri", "https://oidc.test/userinfo"); - p.put(pre + "jwk_uri", "https://oidc.test/jwk"); - return p; - } -``` - -Add the corresponding imports at the top of the test class: - -```java -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasSize; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.hisp.dhis.security.oidc.UserInfoResponseType; -``` - -- [ ] **Step 2: Run tests, verify they fail** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=GenericOidcProviderBuilderConfigParserTest -``` -Expected: failures on the five new tests. - -- [ ] **Step 3: Implement parser changes** - -In `GenericOidcProviderConfigParser.java`: - -a. Add imports: - -```java -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_KEYSTORE_PATH; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_KEYSTORE_PASSWORD; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_ALIAS; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.JWT_PRIVATE_KEY_PASSWORD; - -import org.hisp.dhis.security.oidc.SupportedJwsAlgorithms; -import org.hisp.dhis.security.oidc.UserInfoResponseType; -import org.springframework.security.oauth2.core.ClientAuthenticationMethod; -``` - -(Some are already imported; only add the missing ones.) - -b. Register the new keys in `KEY_REQUIRED_MAP` (inside the static block, alongside the existing `Boolean.FALSE` entries): - -```java - builder.put(USER_INFO_RESPONSE_TYPE, Boolean.FALSE); - builder.put(USER_INFO_JWS_ALGORITHM, Boolean.FALSE); -``` - -c. Replace the body of `validateConfig` so it (1) skips the `client_secret` required-check under `private_key_jwt`, and (2) validates enum and algorithm values. Replace the existing method body with: - -```java - private static boolean validateConfig(Map providerConfig) { - Objects.requireNonNull(providerConfig); - - String providerId = providerConfig.get(PROVIDER_ID); - boolean privateKeyJwt = isPrivateKeyJwt(providerConfig); - - for (Map.Entry entry : KEY_REQUIRED_MAP.entrySet()) { - String key = entry.getKey(); - boolean isRequired = entry.getValue(); - String value = providerConfig.get(key); - - if (CLIENT_SECRET.equals(key) && privateKeyJwt) { - // client_secret is not used when authenticating with private_key_jwt - continue; - } - - if (isRequired && Strings.isNullOrEmpty(value)) { - log.error( - "OpenId Connect (OIDC) configuration for provider: '{}' is missing a required property: '{}'. " - + "Failed to configure the provider successfully!", - providerId, - key); - return false; - } - - UrlValidator urlValidator = new UrlValidator(UrlValidator.ALLOW_LOCAL_URLS); - if (value != null && key.endsWith("uri") && !urlValidator.isValid(value)) { - log.error( - "OpenId Connect (OIDC) configuration for provider: '{}' has a URI property: '{}', " - + "with a malformed value: '{}'. Failed to configure the provider successfully!", - providerId, - key, - value); - return false; - } - } - - return validateUserInfoResponseType(providerId, providerConfig); - } - - private static boolean isPrivateKeyJwt(Map providerConfig) { - String method = providerConfig.get(CLIENT_AUTHENTICATION_METHOD); - if (!ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equalsIgnoreCase(method)) { - return false; - } - return !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_KEYSTORE_PATH)) - && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_KEYSTORE_PASSWORD)) - && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_ALIAS)) - && !Strings.isNullOrEmpty(providerConfig.get(JWT_PRIVATE_KEY_PASSWORD)); - } - - private static boolean validateUserInfoResponseType( - String providerId, Map providerConfig) { - String type = providerConfig.get(USER_INFO_RESPONSE_TYPE); - UserInfoResponseType resolved; - try { - resolved = UserInfoResponseType.fromConfig(type); - } catch (IllegalArgumentException ex) { - log.error( - "OIDC provider '{}' has invalid user_info_response_type='{}'. Allowed: json, jwt.", - providerId, - type); - return false; - } - - if (resolved == UserInfoResponseType.JWT) { - String alg = providerConfig.get(USER_INFO_JWS_ALGORITHM); - try { - SupportedJwsAlgorithms.parseOrDefault(alg); - } catch (IllegalArgumentException ex) { - log.error( - "OIDC provider '{}' has unsupported user_info_jws_algorithm='{}'. {}", - providerId, - alg, - ex.getMessage()); - return false; - } - } - return true; - } -``` - -- [ ] **Step 4: Run tests, verify pass** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=GenericOidcProviderBuilderConfigParserTest -``` -Expected: all tests pass (existing + new). - -- [ ] **Step 5: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/GenericOidcProviderConfigParser.java \ - dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java -git commit -m "feat(oidc): validate userinfo response type and relax client_secret under private_key_jwt [DHIS2-20043]" -``` - ---- - -## Task 6: `GenericOidcProviderBuilder` — read new keys, allow null `clientSecret` under `private_key_jwt` - -**Files:** -- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java` -- Modify (tests): `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java` - -- [ ] **Step 1: Write failing tests** - -Append to `GenericOidcProviderBuilderTest`: - -```java - @Test - void buildPropagatesUserInfoResponseTypeAndAlgorithm() { - Map cfg = baseConfig(); - cfg.put(USER_INFO_RESPONSE_TYPE, "jwt"); - cfg.put(USER_INFO_JWS_ALGORITHM, "ES256"); - DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(cfg, Map.of()); - assertEquals(UserInfoResponseType.JWT, reg.getUserInfoResponseType()); - assertEquals("ES256", reg.getUserInfoJwsAlgorithm().getName()); - } - - @Test - void buildDefaultsToJsonAndNoAlgorithm() { - DhisOidcClientRegistration reg = GenericOidcProviderBuilder.build(baseConfig(), Map.of()); - assertEquals(UserInfoResponseType.JSON, reg.getUserInfoResponseType()); - assertNull(reg.getUserInfoJwsAlgorithm()); - } - - @Test - void buildAcceptsMissingSecretUnderPrivateKeyJwt() { - Map cfg = baseConfig(); - cfg.put(CLIENT_SECRET, ""); - cfg.put(CLIENT_AUTHENTICATION_METHOD, "private_key_jwt"); - // Don't load a real keystore in this test — getJWK only loads it when - // PRIVATE_KEY_JWT is set, so we guard against that throwing by using a path - // that getJWK rejects benignly. Easiest: keep the keystore_path absent so - // getJWK takes the "not private_key_jwt" early branch — but we want - // PRIVATE_KEY_JWT to skip the secret check too. So: stub by setting - // keystore_path to null and assert IllegalStateException is NOT thrown for - // the missing-secret reason but for the keystore-load reason instead. - assertThrows(IllegalStateException.class, () -> GenericOidcProviderBuilder.build(cfg, Map.of())); - } -``` - -Add imports: - -```java -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_AUTHENTICATION_METHOD; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.CLIENT_SECRET; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.hisp.dhis.security.oidc.DhisOidcClientRegistration; -import org.hisp.dhis.security.oidc.UserInfoResponseType; -``` - -If `baseConfig()` does not exist in the test, add a private helper: - -```java - private static Map baseConfig() { - Map cfg = new HashMap<>(); - cfg.put(AbstractOidcProvider.PROVIDER_ID, "idporten"); - cfg.put(AbstractOidcProvider.CLIENT_ID, "test-client"); - cfg.put(AbstractOidcProvider.CLIENT_SECRET, "test-secret"); - cfg.put(AbstractOidcProvider.AUTHORIZATION_URI, "https://oidc.test/authorize"); - cfg.put(AbstractOidcProvider.TOKEN_URI, "https://oidc.test/token"); - cfg.put(AbstractOidcProvider.USERINFO_URI, "https://oidc.test/userinfo"); - cfg.put(AbstractOidcProvider.JWK_URI, "https://oidc.test/jwk"); - return cfg; - } -``` - -- [ ] **Step 2: Run, verify failure** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=GenericOidcProviderBuilderTest -``` -Expected: compile or assertion failures on the new tests. - -- [ ] **Step 3: Implement builder changes** - -Replace the existing `build(...)` method body in `GenericOidcProviderBuilder.java` with: - -```java - public static DhisOidcClientRegistration build( - Map config, Map> externalClients) { - Objects.requireNonNull(config, "DhisConfigurationProvider is missing!"); - - String providerId = config.get(PROVIDER_ID); - String clientId = config.get(CLIENT_ID); - String clientSecret = config.get(CLIENT_SECRET); - - if (providerId == null || providerId.isEmpty() || clientId == null || clientId.isEmpty()) { - return null; - } - - boolean privateKeyJwt = isPrivateKeyJwt(config); - if ((clientSecret == null || clientSecret.isEmpty()) && !privateKeyJwt) { - throw new IllegalArgumentException(providerId + " client secret is missing!"); - } - - return DhisOidcClientRegistration.builder() - .clientRegistration(buildClientRegistration(config, providerId, clientId, clientSecret)) - .mappingClaimKey( - StringUtils.defaultIfEmpty(config.get(MAPPING_CLAIM), DEFAULT_MAPPING_CLAIM)) - .loginIcon(StringUtils.defaultIfEmpty(config.get(LOGIN_IMAGE), "")) - .loginIconPadding(StringUtils.defaultIfEmpty(config.get(LOGIN_IMAGE_PADDING), "0px 0px")) - .loginText(StringUtils.defaultIfEmpty(config.get(DISPLAY_ALIAS), providerId)) - .externalClients(externalClients) - .jwk(getJWK(config)) - .rsaPublicKey(getPublicKey(config)) - .keyId(config.get(JWT_PRIVATE_KEY_ALIAS)) - .jwkSetUrl(config.get(JWK_SET_URL)) - .userInfoResponseType(UserInfoResponseType.fromConfig(config.get(USER_INFO_RESPONSE_TYPE))) - .userInfoJwsAlgorithm( - UserInfoResponseType.fromConfig(config.get(USER_INFO_RESPONSE_TYPE)) - == UserInfoResponseType.JWT - ? SupportedJwsAlgorithms.parseOrDefault(config.get(USER_INFO_JWS_ALGORITHM)) - : null) - .build(); - } - - private static boolean isPrivateKeyJwt(Map config) { - String method = config.get(CLIENT_AUTHENTICATION_METHOD); - return ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equalsIgnoreCase(method); - } -``` - -Add imports: - -```java -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_JWS_ALGORITHM; -import static org.hisp.dhis.security.oidc.provider.AbstractOidcProvider.USER_INFO_RESPONSE_TYPE; - -import org.hisp.dhis.security.oidc.SupportedJwsAlgorithms; -import org.hisp.dhis.security.oidc.UserInfoResponseType; -``` - -(`ClientAuthenticationMethod` is already imported.) - -Additionally, in `buildClientRegistration`, change the `clientSecret` line to allow null: - -```java - if (clientSecret != null && !clientSecret.isEmpty()) { - builder.clientSecret(clientSecret); - } -``` - -(The current unconditional `builder.clientSecret(clientSecret);` would set null when secret is absent — Spring's builder accepts that, but skipping the call is cleaner.) - -- [ ] **Step 4: Run, verify pass** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=GenericOidcProviderBuilderTest -``` -Expected: all tests pass. - -- [ ] **Step 5: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilder.java \ - dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java -git commit -m "feat(oidc): wire userInfo response type and JWS algorithm into generic builder [DHIS2-20043]" -``` - ---- - -## Task 7: `JwkSourceCache` (per-registration JWKSource cache) - -**Files:** -- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java` - -This component is small and tested via `SignedJwtUserInfoLoaderTest` in Task 8, so we don't add a separate unit test for it. - -- [ ] **Step 1: Implement** - -```java -/* (standard $YEAR header, author Morten Svanæs) */ -package org.hisp.dhis.security.oidc; - -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.JWKSourceBuilder; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jose.util.DefaultResourceRetriever; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import org.springframework.stereotype.Component; - -/** - * Caches one Nimbus {@link JWKSource} per OIDC registration id. Building a {@link JWKSource} - * triggers an HTTPS fetch of the IdP's JWKS document; reusing the source preserves Nimbus's - * built-in remote-key cache and refresh policy across logins. - * - *

Sources are constructed lazily on first call to {@link #get(String, String)}. - * - * @author Morten Svanæs - */ -@Component -public class JwkSourceCache { - - private static final int CONNECT_TIMEOUT_MS = 5_000; - private static final int READ_TIMEOUT_MS = 5_000; - - private final ConcurrentMap> sources = new ConcurrentHashMap<>(); - - /** - * @param registrationId the OIDC registration id to cache under - * @param jwkSetUri the IdP's JWKS endpoint URL - * @return a cached or freshly built {@link JWKSource} - * @throws IllegalArgumentException when {@code jwkSetUri} is malformed - */ - public JWKSource get(String registrationId, String jwkSetUri) { - return sources.computeIfAbsent(registrationId, id -> build(jwkSetUri)); - } - - private JWKSource build(String jwkSetUri) { - try { - DefaultResourceRetriever retriever = - new DefaultResourceRetriever( - CONNECT_TIMEOUT_MS, - READ_TIMEOUT_MS, - JWKSourceBuilder.DEFAULT_HTTP_SIZE_LIMIT); - return JWKSourceBuilder.create(new URL(jwkSetUri), retriever).build(); - } catch (MalformedURLException ex) { - throw new IllegalArgumentException("Invalid JWKS URL: " + jwkSetUri, ex); - } - } -} -``` - -- [ ] **Step 2: Compile** - -Run: `mvn -q -pl dhis-2/dhis-services/dhis-service-core compile` -Expected: BUILD SUCCESS. - -- [ ] **Step 3: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/JwkSourceCache.java -git commit -m "feat(oidc): add per-registration JWKSource cache [DHIS2-20043]" -``` - ---- - -## Task 8: `SignedJwtUserInfoLoader` (TDD) - -**Files:** -- Create: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java` -- Test: `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java` - -The test signs a JWT with a generated RSA keypair, stubs `RestTemplate.exchange` to return that JWT, and stubs the cache to return a JWKSource backed by the matching public JWK. - -- [ ] **Step 1: Write the failing test** - -```java -/* (standard $YEAR header, author Morten Svanæs) */ -package org.hisp.dhis.security.oidc; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.JWSHeader; -import com.nimbusds.jose.crypto.RSASSASigner; -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; -import com.nimbusds.jose.jwk.source.ImmutableJWKSet; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import java.time.Instant; -import java.util.Date; -import java.util.Set; -import org.hisp.dhis.security.oidc.provider.AbstractOidcProvider; -import org.hisp.dhis.user.User; -import org.hisp.dhis.user.UserDetails; -import org.hisp.dhis.user.UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.OAuth2AccessToken; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; - -@ExtendWith(MockitoExtension.class) -class SignedJwtUserInfoLoaderTest { - - @Mock private UserService userService; - @Mock private JwkSourceCache jwkSourceCache; - @Mock private RestTemplate restTemplate; - @Mock private OidcUserRequest userRequest; - @Mock private OAuth2AccessToken accessToken; - @Mock private OidcIdToken idToken; - @Mock private ClientRegistration clientRegistration; - @Mock private ClientRegistration.ProviderDetails providerDetails; - @Mock private ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint; - - private RSAKey rsaJwk; - private DhisOidcClientRegistration registration; - private SignedJwtUserInfoLoader loader; - - @BeforeEach - void setUp() throws Exception { - rsaJwk = new RSAKeyGenerator(2048).keyID("test-key").generate(); - JWKSource source = new ImmutableJWKSet<>(new JWKSet(rsaJwk.toPublicJWK())); - - when(userRequest.getClientRegistration()).thenReturn(clientRegistration); - when(userRequest.getAccessToken()).thenReturn(accessToken); - when(userRequest.getIdToken()).thenReturn(idToken); - when(accessToken.getTokenValue()).thenReturn("at-value"); - when(clientRegistration.getRegistrationId()).thenReturn("esignet"); - when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); - when(providerDetails.getUserInfoEndpoint()).thenReturn(userInfoEndpoint); - when(userInfoEndpoint.getUri()).thenReturn("https://idp.test/userinfo"); - when(jwkSourceCache.get(eq("esignet"), anyString())).thenReturn(source); - - registration = - DhisOidcClientRegistration.builder() - .clientRegistration(clientRegistration) - .mappingClaimKey("sub") - .userInfoResponseType(UserInfoResponseType.JWT) - .userInfoJwsAlgorithm(JWSAlgorithm.RS256) - .jwkSetUrl("https://idp.test/jwks") - .build(); - - loader = new SignedJwtUserInfoLoader(userService, jwkSourceCache, restTemplate); - } - - @Test - void happyPathReturnsDhisOidcUser() throws Exception { - String jwt = signJwt(claims("user-123")); - when(restTemplate.exchange(eq("https://idp.test/userinfo"), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(ResponseEntity.ok(jwt)); - - User user = new User(); - user.setExternalAuth(true); - when(userService.getUserByOpenId("user-123")).thenReturn(user); - when(userService.createUserDetails(user)) - .thenReturn(UserDetails.fromUser(user, Set.of())); - - OidcUser result = loader.load(userRequest, registration); - - assertNotNull(result); - assertEquals("user-123", result.getAttributes().get("sub")); - } - - @Test - void httpFailureRaisesInvalidUserInfoResponse() { - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenThrow(new RestClientException("boom")); - - OAuth2AuthenticationException ex = - assertThrows( - OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); - assertEquals("invalid_user_info_response", ex.getError().getErrorCode()); - } - - @Test - void badSignatureRaisesJwtProcessingError() throws Exception { - RSAKey other = new RSAKeyGenerator(2048).keyID("other").generate(); - String jwt = signJwt(claims("user-123"), other); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(ResponseEntity.ok(jwt)); - - OAuth2AuthenticationException ex = - assertThrows( - OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); - assertEquals("jwt_processing_error", ex.getError().getErrorCode()); - } - - @Test - void missingMappingClaimRaisesError() throws Exception { - JWTClaimsSet noSub = new JWTClaimsSet.Builder().issuer("idp").build(); - String jwt = signJwt(noSub); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(ResponseEntity.ok(jwt)); - - OAuth2AuthenticationException ex = - assertThrows( - OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); - assertEquals("missing_mapping_claim", ex.getError().getErrorCode()); - } - - @Test - void unknownUserRaisesError() throws Exception { - String jwt = signJwt(claims("nobody")); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(ResponseEntity.ok(jwt)); - when(userService.getUserByOpenId("nobody")).thenReturn(null); - - OAuth2AuthenticationException ex = - assertThrows( - OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); - assertEquals("user_not_found", ex.getError().getErrorCode()); - } - - // --- helpers --- - - private JWTClaimsSet claims(String sub) { - return new JWTClaimsSet.Builder() - .subject(sub) - .issuer("idp") - .issueTime(Date.from(Instant.now())) - .expirationTime(Date.from(Instant.now().plusSeconds(60))) - .build(); - } - - private String signJwt(JWTClaimsSet claims) throws JOSEException { - return signJwt(claims, rsaJwk); - } - - private String signJwt(JWTClaimsSet claims, RSAKey signingKey) throws JOSEException { - SignedJWT signed = - new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(), - claims); - signed.sign(new RSASSASigner(signingKey)); - return signed.serialize(); - } -} -``` - -- [ ] **Step 2: Run, verify failure** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=SignedJwtUserInfoLoaderTest -``` -Expected: COMPILATION FAILURE (`SignedJwtUserInfoLoader` not yet defined). - -- [ ] **Step 3: Implement** - -```java -/* (standard $YEAR header, author Morten Svanæs) */ -package org.hisp.dhis.security.oidc; - -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.JWSKeySelector; -import com.nimbusds.jose.proc.JWSVerificationKeySelector; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; -import com.nimbusds.jwt.proc.DefaultJWTProcessor; -import java.util.Collections; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.hisp.dhis.user.User; -import org.hisp.dhis.user.UserDetails; -import org.hisp.dhis.user.UserService; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; - -/** - * Loads OIDC userinfo from providers that respond with a signed JWT - * ({@code application/jwt}) instead of JSON. Used when the registration's - * {@link UserInfoResponseType} is {@link UserInfoResponseType#JWT} (e.g. MOSIP eSignet). - * - *

The flow is: GET userinfo with {@code Accept: application/jwt} → verify the - * signature against the IdP's JWKS using the registered {@link JWSAlgorithm} → - * extract the configured mapping claim → resolve the local DHIS2 user. The - * principal-name attribute remains {@link IdTokenClaimNames#SUB} so audit logs - * keyed off {@code sub} match the JSON path. - * - * @author Morten Svanæs - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class SignedJwtUserInfoLoader { - - private final UserService userService; - private final JwkSourceCache jwkSourceCache; - private final RestTemplate restTemplate; - - public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { - String userInfoUri = - userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); - String jwt = fetchJwt(userRequest, userInfoUri); - JWTClaimsSet claims = verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId()); - String mappingValue = requireMappingClaim(claims, reg); - User user = requireExternalAuthUser(mappingValue, reg); - UserDetails details = userService.createUserDetails(user); - return new DhisOidcUser( - details, claims.toJSONObject(), IdTokenClaimNames.SUB, userRequest.getIdToken()); - } - - private String fetchJwt(OidcUserRequest userRequest, String userInfoUri) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(userRequest.getAccessToken().getTokenValue()); - headers.setAccept(Collections.singletonList(MediaType.valueOf("application/jwt"))); - HttpEntity entity = new HttpEntity<>("", headers); - try { - ResponseEntity response = - restTemplate.exchange(userInfoUri, HttpMethod.GET, entity, String.class); - String body = response.getBody(); - if (body == null || body.isBlank()) { - throw new OAuth2AuthenticationException( - new OAuth2Error("invalid_user_info_response"), "Empty UserInfo JWT response"); - } - return body; - } catch (RestClientException ex) { - throw new OAuth2AuthenticationException( - new OAuth2Error("invalid_user_info_response"), - "Failed to fetch UserInfo response: " + ex.getMessage(), - ex); - } - } - - private JWTClaimsSet verify(String jwt, DhisOidcClientRegistration reg, String registrationId) { - try { - ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); - JWKSource keySource = - jwkSourceCache.get(registrationId, reg.getJwkSetUrl()); - JWSKeySelector selector = - new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); - processor.setJWSKeySelector(selector); - return processor.process(jwt, null); - } catch (Exception ex) { - log.debug("UserInfo JWT verification failed for registration {}", registrationId, ex); - throw new OAuth2AuthenticationException( - new OAuth2Error("jwt_processing_error"), - "Failed to verify UserInfo JWT: " + ex.getMessage(), - ex); - } - } - - private String requireMappingClaim(JWTClaimsSet claims, DhisOidcClientRegistration reg) { - String mappingClaimKey = reg.getMappingClaimKey(); - Map claimsMap = claims.toJSONObject(); - Object value = claimsMap.get(mappingClaimKey); - if (!(value instanceof String s) || s.isBlank()) { - throw new OAuth2AuthenticationException( - new OAuth2Error("missing_mapping_claim"), - "Mapping claim '" + mappingClaimKey + "' missing or empty in UserInfo JWT"); - } - return s; - } - - private User requireExternalAuthUser(String mappingValue, DhisOidcClientRegistration reg) { - User user = userService.getUserByOpenId(mappingValue); - if (user == null || !user.isExternalAuth()) { - throw new OAuth2AuthenticationException( - new OAuth2Error("user_not_found"), - "No external-auth DHIS2 user found for mapping claim '" - + reg.getMappingClaimKey() - + "'='" - + mappingValue - + "'"); - } - if (user.isDisabled() || !user.isAccountNonExpired()) { - throw new OAuth2AuthenticationException( - new OAuth2Error("user_disabled"), "DHIS2 user is disabled or expired"); - } - return user; - } -} -``` - -Add a `RestTemplate` bean wiring. The simplest path: configure as a `@Bean` if no shared one exists. Check first: - -```bash -grep -rn "RestTemplate" dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/ | head -5 -``` - -If no shared bean exists, add one in `DhisWebApiWebSecurityConfig.java` (or the closest existing config class adjacent to OIDC configuration): - -```java - @Bean - public RestTemplate oidcRestTemplate() { - return new RestTemplate(); - } -``` - -If a shared OIDC `RestTemplate` already exists, reuse it via `@Qualifier`. Document the choice in the commit message. - -- [ ] **Step 4: Run tests, verify pass** - -Run: -``` -mvn -q install -pl dhis-2/dhis-services/dhis-service-core -am -DskipTests -q -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=SignedJwtUserInfoLoaderTest -``` -Expected: BUILD SUCCESS, all tests green. - -- [ ] **Step 5: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java \ - dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java -# Plus the security config file if a RestTemplate bean was added there. -git commit -m "feat(oidc): add SignedJwtUserInfoLoader for application/jwt userinfo [DHIS2-20043]" -``` - ---- - -## Task 9: Restore inheritance and branch in `DhisOidcUserService` - -**Files:** -- Modify: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java` -- Test: `dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java` - -The dispatch test uses Mockito to stub a package-private `loadFromJsonUserInfo` seam (so we don't need real HTTP for the JSON branch). - -- [ ] **Step 1: Write failing test** - -```java -/* (standard $YEAR header, author Morten Svanæs) */ -package org.hisp.dhis.security.oidc; - -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import org.hisp.dhis.user.UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; - -@ExtendWith(MockitoExtension.class) -class DhisOidcUserServiceDispatchTest { - - @Mock private DhisOidcProviderRepository repo; - @Mock private UserService userService; - @Mock private SignedJwtUserInfoLoader signedJwtLoader; - @Mock private OidcUserRequest request; - @Mock private ClientRegistration clientRegistration; - @Mock private OidcUser jsonResult; - @Mock private OidcUser jwtResult; - - @Spy private DhisOidcUserService service = new DhisOidcUserService(); - - @BeforeEach - void wire() { - service.userService = userService; - service.clientRegistrationRepository = repo; - service.signedJwtUserInfoLoader = signedJwtLoader; - when(request.getClientRegistration()).thenReturn(clientRegistration); - when(clientRegistration.getRegistrationId()).thenReturn("p1"); - } - - @Test - void jsonRegistrationUsesJsonPath() { - DhisOidcClientRegistration reg = - DhisOidcClientRegistration.builder() - .clientRegistration(clientRegistration) - .mappingClaimKey("sub") - .userInfoResponseType(UserInfoResponseType.JSON) - .build(); - when(repo.getDhisOidcClientRegistration("p1")).thenReturn(reg); - doReturn(jsonResult).when(service).loadFromJsonUserInfo(request, reg); - - OidcUser result = service.loadUser(request); - - assertSame(jsonResult, result); - verify(signedJwtLoader, never()).load(any(), any()); - } - - @Test - void jwtRegistrationUsesJwtPath() { - DhisOidcClientRegistration reg = - DhisOidcClientRegistration.builder() - .clientRegistration(clientRegistration) - .mappingClaimKey("sub") - .userInfoResponseType(UserInfoResponseType.JWT) - .build(); - when(repo.getDhisOidcClientRegistration("p1")).thenReturn(reg); - when(signedJwtLoader.load(request, reg)).thenReturn(jwtResult); - - OidcUser result = service.loadUser(request); - - assertSame(jwtResult, result); - verify(service, never()).loadFromJsonUserInfo(any(), any()); - } -} -``` - -- [ ] **Step 2: Run, verify failure** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=DhisOidcUserServiceDispatchTest -``` -Expected: COMPILATION FAILURE. - -- [ ] **Step 3: Replace `DhisOidcUserService` body** - -```java -/* (keep existing $YEAR header, author tag, package) */ -package org.hisp.dhis.security.oidc; - -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.hisp.dhis.user.User; -import org.hisp.dhis.user.UserDetails; -import org.hisp.dhis.user.UserService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; -import org.springframework.security.oauth2.core.oidc.OidcUserInfo; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.stereotype.Service; - -/** - * DHIS2 extension of Spring Security's {@link OidcUserService}. Dispatches userinfo handling based - * on the provider registration's {@link UserInfoResponseType}: JSON (default; Spring's standard - * path) or JWT (eSignet-style signed JWT, handled by {@link SignedJwtUserInfoLoader}). On both - * paths it then resolves the configured {@code mapping_claim} value to a local DHIS2 user via - * {@link UserService#getUserByOpenId}. - * - *

The matched DHIS2 user must be flagged for external authentication, must not be disabled, and - * must not have an expired account; otherwise authentication fails with an - * {@link OAuth2AuthenticationException}. - * - * @author Morten Svanæs - */ -@Slf4j -@Service -public class DhisOidcUserService extends OidcUserService { - - @Autowired public UserService userService; - @Autowired DhisOidcProviderRepository clientRegistrationRepository; - @Autowired SignedJwtUserInfoLoader signedJwtUserInfoLoader; - - @Override - public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - ClientRegistration cr = userRequest.getClientRegistration(); - DhisOidcClientRegistration reg = - clientRegistrationRepository.getDhisOidcClientRegistration(cr.getRegistrationId()); - - return switch (reg.getUserInfoResponseType()) { - case JSON -> loadFromJsonUserInfo(userRequest, reg); - case JWT -> signedJwtUserInfoLoader.load(userRequest, reg); - }; - } - - /** - * JSON-userinfo path: delegates to Spring's {@link OidcUserService#loadUser(OidcUserRequest)}, - * then resolves the mapping claim to a local DHIS2 user. Package-private to allow direct - * stubbing in {@link DhisOidcUserServiceDispatchTest}. - */ - OidcUser loadFromJsonUserInfo(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { - OidcUser oidcUser = super.loadUser(userRequest); - - String mappingClaimKey = reg.getMappingClaimKey(); - Map attributes = oidcUser.getAttributes(); - Object claimValue = attributes.get(mappingClaimKey); - OidcUserInfo userInfo = oidcUser.getUserInfo(); - if (claimValue == null && userInfo != null) { - claimValue = userInfo.getClaim(mappingClaimKey); - } - - if (log.isDebugEnabled()) { - log.debug( - "Trying to look up DHIS2 user with OidcUser mapping mappingClaimKey='{}', claim value='{}'", - mappingClaimKey, - claimValue); - } - - if (claimValue instanceof String s && !s.isBlank()) { - User user = userService.getUserByOpenId(s); - if (user != null && user.isExternalAuth()) { - if (user.isDisabled() || !user.isAccountNonExpired()) { - throw new OAuth2AuthenticationException( - new OAuth2Error("user_disabled"), "User is disabled"); - } - UserDetails userDetails = userService.createUserDetails(user); - return new DhisOidcUser( - userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken()); - } - } - - String errorMessage = - String.format( - "Failed to look up DHIS2 user with OidcUser mapping mapping; mappingClaimKey='%s', claimValue='%s'", - mappingClaimKey, claimValue); - if (log.isDebugEnabled()) { - log.debug(errorMessage); - } - OAuth2Error err = new OAuth2Error("could_not_map_oidc_user_to_dhis2_user", errorMessage, null); - throw new OAuth2AuthenticationException(err, err.toString()); - } -} -``` - -(The fields are package-private intentionally so the dispatch test can wire them. They're still `@Autowired` for production. `@Spy` requires Mockito to be able to write them; with `@Autowired` + package-private fields this works without reflection trickery.) - -- [ ] **Step 4: Run, verify pass** - -Run: -``` -mvn -q test -pl dhis-2/dhis-services/dhis-service-core \ - -Dtest=DhisOidcUserServiceDispatchTest -``` -Expected: BUILD SUCCESS, all tests green. - -- [ ] **Step 5: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-services/dhis-service-core -git add dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java \ - dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java -git commit -m "feat(oidc): branch DhisOidcUserService on userInfoResponseType [DHIS2-20043]" -``` - ---- - -## Task 10: Re-enable `OAuth2Test` - -**Files:** -- Modify: `dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java` - -The PR (PR #22027) added `@Disabled` here. Since we've restored the JSON path, the test should run again. (Reminder: the test ran in a tagged e2e profile only — it will not run by accident in the unit/integration build.) - -- [ ] **Step 1: Remove `@Disabled` and the now-unused import** - -Edit: - -```java -@Tag("oauth2tests") -@Slf4j -class OAuth2Test extends BaseE2ETest { -``` - -Drop the line `@Disabled` from the class annotations. Drop `import org.junit.jupiter.api.Disabled;`. - -- [ ] **Step 2: Compile** - -Run: `mvn -q -pl dhis-2/dhis-test-e2e compile -DskipTests` -Expected: BUILD SUCCESS. - -- [ ] **Step 3: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-test-e2e -git add dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java -git commit -m "test(oidc): re-enable OAuth2Test now that JSON userinfo path is preserved [DHIS2-20043]" -``` - ---- - -## Task 11: Whole-module test sweep + final verification - -- [ ] **Step 1: Build the whole core module with tests** - -Run: -``` -mvn -q -pl dhis-2/dhis-services/dhis-service-core -am test -``` -Expected: BUILD SUCCESS, no regressions. - -- [ ] **Step 2: Build dhis-web-api (downstream of core) just to be safe** - -Run: -``` -mvn -q install -pl dhis-2/dhis-web-api -am -DskipTests -``` -Expected: BUILD SUCCESS. - -- [ ] **Step 3: Spotless full-pass** - -Run: -``` -mvn -q spotless:apply -f dhis-2/pom.xml -mvn -q spotless:check -f dhis-2/pom.xml -``` -Expected: BUILD SUCCESS on the check. - -- [ ] **Step 4: Confirm git status clean** - -Run: `git status --short` -Expected: clean working tree on `feat/DHIS2-20043-esignet-oidc-jwt-userinfo`. - -- [ ] **Step 5: Open PR (manual — do NOT push without user confirmation)** - -PR description should include: -- One-paragraph summary: per-provider opt-in for JWT userinfo, no behaviour change for existing providers. -- Bullet list of new config keys with values and defaults. -- Note that **dhis2-docs needs a follow-up PR** (the OIDC reference chapter is in a separate repo) with the eSignet example block and key docs. Link the spec file: `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md`. -- Reference: closes [DHIS2-20043]. -- "AI Assisted" line per project rule. - ---- - -## Out-of-scope follow-ups - -- **JWE (encrypted) userinfo** — explicitly deferred per spec §3. -- **dhis2-docs reference chapter update** — `oauth.md` lives in the external `dhis2-docs` repo (no local copy). Follow-up PR there with the §4 example block from the spec. -- **Token-endpoint / JWKS HTTP timeouts via `dhis.conf`** — current values are hard-coded constants on `JwkSourceCache`. If field experience shows IdPs needing different timeouts, expose them. - ---- - -## Self-review notes - -- Spec §4 (config keys, allow-list) → Tasks 1, 2, 3, 5. -- Spec §5.3 (registration fields) → Task 4. -- Spec §5.4 (generic builder wiring) → Task 6. -- Spec §5.5 (parser relaxation + value validation) → Task 5. -- Spec §5.6 + §5.7 (user service branch + JWT loader) → Tasks 8, 9. -- Spec §5.8 (JWKSource cache) → Task 7. -- Spec §5.9 (re-enable OAuth2Test) → Task 10. -- Spec §5.10 (PublicKeysController thumbprint) — already on master? No: check. **Action:** the PR includes a small additive change in `PublicKeysController.java` (adds `x509CertSHA256Thumbprint` to the JWK output). Cherry-pick that hunk in Task 10 or as a small Task 10b. *Adding now as Task 10b.* -- Spec §6 (tests) → covered by Tasks 2, 5, 6, 8, 9 with parameterized + happy/edge coverage. -- Spec §7 (docs) → out of scope locally; PR description note in Task 11. -- Spec §8 (no Flyway) → confirmed. - ---- - -## Task 10b: Cherry-pick `PublicKeysController` thumbprint addition - -**Files:** -- Modify: `dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java` - -This is the additive `x509CertSHA256Thumbprint` change from PR #22027. - -- [ ] **Step 1: Apply the change** - -Find the existing `RSAKey.Builder(...)` chain (around line 80-87) and add `.x509CertSHA256Thumbprint(...)` to the chain: - -```java - new RSAKey.Builder(dhisOidcClientRegistration.getRsaPublicKey()) - .keyUse(KeyUse.SIGNATURE) - .algorithm(JWSAlgorithm.parse(jwsAlgorithm.toString())) - .x509CertSHA256Thumbprint( - dhisOidcClientRegistration.getJwk().getX509CertSHA256Thumbprint()) - .keyID(dhisOidcClientRegistration.getKeyId()); -``` - -This is safe: `getX509CertSHA256Thumbprint()` returns null when the underlying JWK has none, and Nimbus's builder accepts null. - -- [ ] **Step 2: Compile** - -Run: `mvn -q install -pl dhis-2/dhis-web-api -am -DskipTests` -Expected: BUILD SUCCESS. - -- [ ] **Step 3: Format and commit** - -```bash -mvn -q spotless:apply -f dhis-2/pom.xml -pl dhis-web-api -git add dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java -git commit -m "feat(oidc): include x509 cert thumbprint in published JWK [DHIS2-20043]" -``` diff --git a/dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md b/dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md deleted file mode 100644 index 8434525d241b..000000000000 --- a/dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md +++ /dev/null @@ -1,263 +0,0 @@ -# eSignet OIDC Integration — Design Spec - -- Date: 2026-05-06 -- Author: Morten Svanæs -- Status: Approved -- Related: PR [#22027](https://github.com/dhis2/dhis2-core/pull/22027), Spring Security issue [#9583](https://github.com/spring-projects/spring-security/issues/9583) - -## 1. Background - -PR #22027 was a POC integration of MOSIP eSignet for HISP Sri Lanka. eSignet -returns its OIDC userinfo response as a signed JWT (`Content-Type: application/jwt`). -Spring Security's `OidcUserService` only handles `application/json` userinfo; -the open Spring issue confirms this has not been addressed upstream. - -The POC patched DHIS2's `DhisOidcUserService` to **always** treat userinfo as -a signed JWT. This works for eSignet but breaks every other configured OIDC -provider (Google, Azure AD, WSO2, generic OIDC, internal DHIS2-as-IdP), all -of which return JSON userinfo. The PR also `@Disabled`-ed the e2e -`OAuth2Test` to hide that regression and blanket-relaxed two required keys -in the generic-provider parser. - -## 2. Goal - -Land eSignet support on `master` so that: - -1. The default behaviour (JSON userinfo) is preserved byte-for-byte for - every existing provider. -2. The eSignet JWT-userinfo flow is reachable via `dhis.conf` configuration - only — no code changes to switch a provider over. -3. The generic provider builder is the integration point — no bespoke - `EsignetProvider` class — so future IdPs with the same userinfo pattern - are configured the same way. - -## 3. Non-goals - -- **JWE (encrypted) userinfo responses.** eSignet does not use them. If a - future IdP requires JWE, add `user_info_jwe_algorithm` + a key reference - pointing at the existing private-key keystore the registration already - loads. Out of scope here. -- **Signed authorization requests.** Not in PR #22027; separate feature. -- **Per-provider override of authorization-grant or scopes** beyond what the - generic builder already supports. - -## 4. Configuration - -Two new keys in the existing `oidc.provider..*` namespace, declared as -constants on `AbstractOidcProvider` and consumed by -`GenericOidcProviderBuilder` and `GenericOidcProviderConfigParser`. - -| Key | Values | Default | Required | Notes | -|---|---|---|---|---| -| `user_info_response_type` | `json`, `jwt` | `json` | no | `jwt` selects the eSignet path. | -| `user_info_jws_algorithm` | `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512` | `RS256` | no | Consulted only when `user_info_response_type=jwt`. Validated at parse time against a fixed allow-list. | - -Per-provider scope (no global flag) — confirmed. - -### Sample `dhis.conf` block (eSignet via generic provider) - -```properties -oidc.provider.esignet.client_id = dhis2-client -oidc.provider.esignet.authorization_uri = https://esignet.example/authorize -oidc.provider.esignet.token_uri = https://esignet.example/oauth/token -oidc.provider.esignet.user_info_uri = https://esignet.example/oidc/userinfo -oidc.provider.esignet.jwk_uri = https://esignet.example/.well-known/jwks.json -oidc.provider.esignet.client_authentication_method = private_key_jwt -oidc.provider.esignet.keystore_path = /opt/dhis2/esignet.p12 -oidc.provider.esignet.keystore_password = ... -oidc.provider.esignet.key_alias = esignet -oidc.provider.esignet.key_password = ... -oidc.provider.esignet.user_info_response_type = jwt -oidc.provider.esignet.user_info_jws_algorithm = RS256 -oidc.provider.esignet.mapping_claim = sub -``` - -## 5. Component changes - -### 5.1 `AbstractOidcProvider` - -Add two `String` constants: - -```java -public static final String USER_INFO_RESPONSE_TYPE = "user_info_response_type"; -public static final String USER_INFO_JWS_ALGORITHM = "user_info_jws_algorithm"; -``` - -### 5.2 `UserInfoResponseType` enum (new) - -```java -public enum UserInfoResponseType { JSON, JWT } -``` - -Lives next to `DhisOidcClientRegistration` in `org.hisp.dhis.security.oidc`. - -### 5.3 `DhisOidcClientRegistration` - -Two new fields: - -```java -@Builder.Default -private final UserInfoResponseType userInfoResponseType = UserInfoResponseType.JSON; - -private final JWSAlgorithm userInfoJwsAlgorithm; // null when JSON -``` - -### 5.4 `GenericOidcProviderBuilder` - -Read the two new keys from the provider config, default `JSON` / `RS256`, -populate the new `DhisOidcClientRegistration` fields. JWSAlgorithm parsed -from the validated allow-list. - -### 5.5 `GenericOidcProviderConfigParser` - -- Register `USER_INFO_RESPONSE_TYPE` and `USER_INFO_JWS_ALGORITHM` in - `KEY_REQUIRED_MAP` as `Boolean.FALSE`. -- Keep `CLIENT_SECRET` and `USERINFO_URI` as `Boolean.TRUE` in the static - map. -- Add a post-validation step in `validateConfig` that relaxes `CLIENT_SECRET` - to optional **only when** `client_authentication_method = private_key_jwt` - and the four `keystore_*` keys are present. Keeps "did you mean…" - diagnostics for ordinary misconfig and only loosens rules where the - alternative is genuinely satisfied. -- Validate `user_info_response_type` against the enum and - `user_info_jws_algorithm` against the fixed algorithm allow-list at parse - time. - -`USERINFO_URI` stays required in both modes — the JWT path still calls it, -only the `Accept` header changes. - -### 5.6 `DhisOidcUserService` — restore inheritance, branch on type - -```java -@Service -public class DhisOidcUserService extends OidcUserService { - - @Autowired private DhisOidcProviderRepository repo; - @Autowired private UserService userService; - @Autowired private SignedJwtUserInfoLoader signedJwtLoader; - - @Override - public OidcUser loadUser(OidcUserRequest req) throws OAuth2AuthenticationException { - DhisOidcClientRegistration reg = - repo.getDhisOidcClientRegistration(req.getClientRegistration().getRegistrationId()); - - return switch (reg.getUserInfoResponseType()) { - case JSON -> loadFromJsonUserInfo(req, reg); - case JWT -> signedJwtLoader.load(req, reg); - }; - } - - // loadFromJsonUserInfo == today's logic, unchanged: super.loadUser() - // -> resolve mappingClaimKey -> userService.getUserByOpenId -> DhisOidcUser -} -``` - -The JSON branch is the existing method body verbatim. **No behaviour change -for any existing provider.** - -### 5.7 `SignedJwtUserInfoLoader` (new collaborator) - -Encapsulates the eSignet-style fetch + verify + map-to-user. Roughly: - -```java -@Component -@RequiredArgsConstructor -public class SignedJwtUserInfoLoader { - - private final UserService userService; - private final RestTemplate restTemplate; // shared bean - private final JwkSourceCache jwkSourceCache; // per-registration - - public OidcUser load(OidcUserRequest req, DhisOidcClientRegistration reg) { - String jwt = fetchJwtUserInfo(req); // GET userinfo, Accept: application/jwt - JWTClaimsSet claims = verify(jwt, reg); // Nimbus, alg from reg - String mappingValue = requireMappingClaim(claims, reg); - User user = requireExistingExternalAuthUser(mappingValue, reg); - UserDetails details = userService.createUserDetails(user); - return new DhisOidcUser(details, claims.toJSONObject(), - IdTokenClaimNames.SUB, req.getIdToken()); - } -} -``` - -Notes: - -- `principalNameAttribute` stays `IdTokenClaimNames.SUB` in **both** JSON - and JWT modes. The mapping claim is used only for DHIS2 user lookup, not - as the principal name. This preserves audit-log behaviour. -- The `User` lookup applies the same `isExternalAuth() / isDisabled() / - isAccountNonExpired()` checks the JSON path applies today. -- Fetch failure, JWS-verification failure, missing mapping claim, and - user-not-found each raise `OAuth2AuthenticationException` with distinct - `OAuth2Error` codes (`invalid_user_info_response`, `jwt_processing_error`, - `missing_mapping_claim`, `user_not_found`). -- `RestTemplate` is the shared OIDC HTTP client (or a new properly - configured bean wired into `OidcConfig`) — not `new RestTemplate()` per - call. -- Algorithm is taken from `reg.getUserInfoJwsAlgorithm()`; not hard-coded. - -### 5.8 `JwkSourceCache` (new) - -Builds a Nimbus `JWKSource` once per registration id and -caches it in a `ConcurrentHashMap`. Constructed lazily on first JWT-mode -login (or eagerly when the registration is built — implementation choice -during the writing-plans pass). Nimbus's built-in remote-key cache and -refresh policy handles JWKS rotation; we just don't rebuild the source on -every call. - -### 5.9 `OAuth2Test` - -Remove `@Disabled`. The JSON e2e path is restored once `DhisOidcUserService` -keeps its `extends OidcUserService` contract; the test should pass without -further change. - -### 5.10 `PublicKeysController` - -Keep the PR's `x509CertSHA256Thumbprint` addition. Additive — only emits a -thumbprint when the underlying JWK has one. - -## 6. Tests - -- **Unit — `SignedJwtUserInfoLoaderTest`**: happy path, bad signature, - algorithm mismatch (config says RS256, token signed with PS256), missing - mapping claim, HTTP fetch failure, user-not-found. Each asserts the - correct `OAuth2Error.errorCode`. -- **Unit — `GenericOidcProviderConfigParserTest`** (extend existing): - - `user_info_response_type=jwt` round-trips to `UserInfoResponseType.JWT`. - - `user_info_jws_algorithm=PS256` round-trips to `JWSAlgorithm.PS256`. - - Invalid algorithm fails parse-time validation. - - `client_secret` missing without `private_key_jwt` → invalid. - - `client_secret` missing with `private_key_jwt` + keystore keys → valid. - - `user_info_uri` always required regardless of `user_info_response_type`. -- **Unit — `DhisOidcUserServiceTest`**: dispatch test that JSON-mode - registrations call the existing path and JWT-mode registrations call - `SignedJwtUserInfoLoader`. -- **E2E — `OAuth2Test`**: re-enabled, no other change. - -## 7. Documentation - -Short addition to `oauth.md` (the OIDC reference chapter): - -- Two-row table for the new keys. -- The eSignet `dhis.conf` example block from §4. -- One-paragraph note that JSON userinfo remains the default and existing - provider configs need no changes. - -## 8. Migration / coordination - -- No Flyway migration. -- No backwards-incompatible config change (new keys are optional with - defaults that match prior behaviour). -- No WOW coordination entry needed. - -## 9. Risks - -- **Algorithm allow-list drift.** If Nimbus introduces new JWS algorithms, - the allow-list won't include them until updated. Acceptable — failing - closed at parse time is preferable to surprising algorithm acceptance. -- **JWKS rotation latency.** Nimbus's default refresh policy applies; if an - IdP rotates keys aggressively we may need to expose its cache TTL as - config later. Not addressed in this spec. -- **`UserService.getUserByOpenId` mapping.** The JWT mode looks up the user - by the configured mapping claim's value, same as the JSON mode. Confirmed - no audit-log breakage because `principalNameAttribute` stays `sub`. From 391eb6f658fd93d45a1d7fbb2c9b39e9ee133334 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 03:46:37 +0800 Subject: [PATCH 18/25] style(oidc): apply spotless formatting to integration test [DHIS2-20043] Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java index a38ed0f96f87..c94ad05bc6e3 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java @@ -12,7 +12,7 @@ * 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 + * 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. * @@ -117,8 +117,7 @@ static void startMockServer() { .waitingFor(new HttpWaitStrategy().forStatusCode(404)) .withExposedPorts(1080); mockServerContainer.start(); - mockServerClient = - new MockServerClient("localhost", mockServerContainer.getFirstMappedPort()); + mockServerClient = new MockServerClient("localhost", mockServerContainer.getFirstMappedPort()); } @AfterAll From 63be0691de2ac18740e5c4c85c0ba0ee919102a7 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 04:00:01 +0800 Subject: [PATCH 19/25] fix(oidc): resolve SonarCloud blocker and code smell in tests [DHIS2-20043] Add missing assertion in parseAcceptsMissingClientSecretWithPrivateKeyJwt and extract Map.of() from assertThrows lambda in buildThrowsWhenSecretMissingAndNotPrivateKeyJwt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oidc/GenericOidcProviderBuilderConfigParserTest.java | 6 ++---- .../oidc/provider/GenericOidcProviderBuilderTest.java | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java index ceecd857e13f..b4aa0c9b08ff 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/GenericOidcProviderBuilderConfigParserTest.java @@ -218,11 +218,9 @@ void parseAcceptsMissingClientSecretWithPrivateKeyJwt() { // Builder may still fail to load the keystore from disk; we only assert the // *parser* accepts the config. Wrap to ignore loader-time IO errors. try { - GenericOidcProviderConfigParser.parse(p); + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(1)); } catch (IllegalStateException expected) { - // builder can throw when reading non-existent keystore — that's fine for - // this parser-level assertion: validation passed before construction. - return; + // builder can throw when reading non-existent keystore } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java index a3d3dbabd8bc..31be2c4f3435 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/provider/GenericOidcProviderBuilderTest.java @@ -156,8 +156,10 @@ void buildDefaultsToJsonAndNoAlgorithm() { void buildThrowsWhenSecretMissingAndNotPrivateKeyJwt() { Map cfg = baseConfig(); cfg.remove(CLIENT_SECRET); + Map> noExternalClients = Map.of(); assertThrows( - IllegalArgumentException.class, () -> GenericOidcProviderBuilder.build(cfg, Map.of())); + IllegalArgumentException.class, + () -> GenericOidcProviderBuilder.build(cfg, noExternalClients)); } @Test From 3cf6818e392beee12241a664a36343e7d122dea4 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 04:35:31 +0800 Subject: [PATCH 20/25] chore(oidc): remove design docs from PR [DHIS2-20043] Co-Authored-By: Claude Opus 4.7 (1M context) --- dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md | 129 ------- dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md | 379 ------------------- 2 files changed, 508 deletions(-) delete mode 100644 dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md delete mode 100644 dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md diff --git a/dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md b/dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md deleted file mode 100644 index 2b7fa3a4a1a7..000000000000 --- a/dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md +++ /dev/null @@ -1,129 +0,0 @@ -# OAuth2 / OIDC JWT UserInfo — Hardening Follow-ups - -Defense-in-depth items for the signed-JWT userinfo path added in PR #23839 -(`feat/DHIS2-20043-esignet-oidc-jwt-userinfo`). None of these are exploitable -vulnerabilities under the current threat model — operator-trusted dhis.conf, -operator-trusted IdP, server-to-server userinfo fetch over TLS — but they -close gaps that would matter if any of those assumptions weaken (multi-tenant -IdP, hostile RP on shared issuer, IdP compromise, lax operator config). - -Reference: -- Implementation: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java` -- Spec: `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md` -- OIDC Core §5.3.2 — UserInfo Response - ---- - -## 1. Bind UserInfo `sub` to ID-token `sub` (OIDC §5.3.2) - -OIDC Core §5.3.2 mandates: - -> "The sub Claim in the UserInfo Response MUST be verified to exactly match -> the sub Claim in the ID Token; if they do not match, the UserInfo Response -> values MUST NOT be used." - -Spring Security's stock `OidcUserService` enforces this for the JSON path. -`SignedJwtUserInfoLoader.load()` currently skips it. - -**Action.** In `SignedJwtUserInfoLoader.load()`, after `verify(...)` returns -the `JWTClaimsSet`, compare its `sub` to `userRequest.getIdToken().getSubject()` -and throw an `OAuth2AuthenticationException` with error code -`invalid_user_info_response` when they differ. - -```java -String userInfoSub = claims.getSubject(); -String idTokenSub = userRequest.getIdToken().getSubject(); -if (userInfoSub == null || !userInfoSub.equals(idTokenSub)) { - throw new OAuth2AuthenticationException( - new OAuth2Error("invalid_user_info_response"), - "UserInfo sub does not match ID Token sub"); -} -``` - -Why it matters when the IdP is shared: an attacker controlling a second RP on -the same IdP cannot replay a JWT into our flow today (it's a server-to-server -fetch bound to our access token), but this check makes the binding explicit -and matches Spring's behavior on the JSON path. - -## 2. Verify `iss` and `aud` on the UserInfo JWT - -`new DefaultJWTProcessor<>()` is used without an explicit -`setJWTClaimsSetVerifier(...)`. Nimbus's default verifier does enforce -`exp`/`nbf`, so expired tokens are rejected — but `iss` and `aud` are not -checked. - -**Action.** Pin issuer and audience: - -```java -DefaultJWTClaimsVerifier verifier = - new DefaultJWTClaimsVerifier<>( - new JWTClaimsSet.Builder() - .issuer(reg.getClientRegistration().getProviderDetails().getIssuerUri()) - .audience(reg.getClientRegistration().getClientId()) - .build(), - Set.of("sub", "iss", "aud", "exp")); -processor.setJWTClaimsSetVerifier(verifier); -``` - -On a multi-tenant IdP (e.g. national eSignet deployments) this prevents a JWT -minted for any other RP from being accepted here even if it leaks into the -flow somehow. - -## 3. Hard-fail `private_key_jwt` parser when keystore is partial - -`GenericOidcProviderConfigParser.isPrivateKeyJwt(...)` returns `true` only -when all four keystore properties are present and non-empty. If the operator -sets `client_authentication_method=private_key_jwt` but forgets, say, -`keystore_password`, the helper returns `false` and the parser then complains -about the missing `client_secret` instead — masking the real misconfig. The -builder can also produce a half-configured registration with no JWK and no -secret. - -**Action.** When `client_authentication_method=private_key_jwt`: - -- At parse time, reject the provider unless all four keystore properties - (`keystore_path`, `keystore_password`, `key_alias`, `key_password`) plus - `jwk_set_url` are present. -- Log the specific missing key so operators see the real cause. - -Not a vulnerability (dhis.conf is operator-trusted), but a UX trap worth -closing. - -## 4. Strip claim values and upstream messages from `OAuth2Error` descriptions - -Several error paths in `SignedJwtUserInfoLoader` embed `mappingValue`, -`mappingClaimKey`, or `ex.getMessage()` into the `OAuth2Error` description. -Spring renders those in the failure-redirect URL. - -**Action.** Keep upstream details in server-side logs (`log.debug`/`warn`); -return generic descriptions to the client: - -```java -log.warn("UserInfo JWT verification failed for registration {}", registrationId, ex); -throw new OAuth2AuthenticationException( - new OAuth2Error("jwt_processing_error"), "Failed to verify UserInfo JWT"); -``` - -Matches the JSON path's existing, terser failure responses. - ---- - -## What we already do right (worth keeping) - -- `SupportedJwsAlgorithms` allow-list excludes HMAC and `none`, blocking - algorithm-confusion attacks at config-load time. -- `JwkSourceCache` keys by `registrationId`, not by URL, so one registration - cannot poison another's cache. -- Nimbus `JWKSourceBuilder` retains its built-in remote-key refresh policy - across logins (we cache the `JWKSource`, not the keys). -- Default `UserInfoResponseType` is `JSON`, so every existing provider keeps - Spring Security's standard path unchanged. - -## Suggested rollout - -1. Land items 1, 2, 4 together as one PR — small, mechanical, fully - unit-testable against `SignedJwtUserInfoLoaderTest`. -2. Land item 3 separately — it touches the parser and warrants its own - regression tests covering each "missing one keystore key" permutation. -3. Before enabling eSignet in any production deployment, verify items 1–4 are - in place. diff --git a/dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md b/dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md deleted file mode 100644 index a5b91adeceb5..000000000000 --- a/dhis-2/docs/OIDC_E2E_MOCK_IDP_PLAN.md +++ /dev/null @@ -1,379 +0,0 @@ -# OIDC End-to-End Testing with a Mock IdP — Implementation Plan - -**Status:** proposal — not implemented yet. -**Owner:** Morten Svanæs (`@netroms`). -**Target version:** 2.44+ (initial framework); features added incrementally. -**Related:** PR #23839 (DHIS2-20043 eSignet signed-JWT userinfo). - ---- - -## Goals - -1. Be able to run a real end-to-end test against DHIS2's **Relying-Party** OIDC - stack — i.e. `oauth2.login.enabled=on` + `oidc.provider..*` configs — - without depending on any third-party IdP (Google, Azure, eSignet sandbox). -2. Cover **every OIDC feature DHIS2 supports as an RP**, not just the - signed-JWT-userinfo path being shipped now: standard JSON userinfo, - signed-JWT userinfo, `private_key_jwt` client auth, PKCE, scope/claim - variations, logout/end-session, error paths. -3. Keep it cheap to add coverage: one mock-IdP service, multiple test classes, - per-test runtime configuration of how the IdP responds. - -This plan **does not** cover DHIS2-as-Authorization-Server testing. That is -already done by `dhis-2/dhis-test-e2e/.../oauth2/OAuth2Test.java` against the -Spring Authorization Server. - -## Non-goals - -- Running a real eSignet stack in CI — too heavy (Postgres + Kafka + mock - identity system). -- Testing real Google / Azure / Keycloak. Those are integration concerns, - out of scope for unit-level CI. -- UI/UX testing of the login button rendering — already covered separately - via the `uitests` profile. - ---- - -## Why a custom mock IdP - -No off-the-shelf mock OIDC IdP supports signed-JWT userinfo -(`application/jwt`): - -| Project | Image | Signed-JWT userinfo | private_key_jwt | License | -|---|---|---|---|---| -| navikt/mock-oauth2-server | `ghcr.io/navikt/mock-oauth2-server` | ❌ JSON only | ✅ | MIT | -| Soluto/oidc-server-mock (IS4) | `ghcr.io/soluto/oidc-server-mock` | ❌ (upstream IS4 lacks it) | ✅ | Apache-2 | -| oauth2-proxy/mockoidc | — | ❌ | partial | MIT | -| MOSIP eSignet | `mosipid/esignet` | ✅ (signed-then-encrypted) | ✅ | MPL-2.0 | -| WireMock + init script | `wiremock/wiremock` | ✅ (DIY) | ✅ (DIY) | Apache-2 | - -Real eSignet works but pulls in Postgres + Kafka + the mock-identity-system — -two orders of magnitude too heavy for a CI job. - -WireMock works but cannot sign at request time, so still needs an init -script and shell glue. Net cost is similar to writing it in Java. - -**Decision: write a small standalone Java service.** It reuses Nimbus -JOSE+JWT 10.9 (already on the e2e classpath, same lib DHIS2 itself verifies -with — so signing semantics match exactly). About 150–250 lines. - ---- - -## Mock IdP design - -### Surface - -The service exposes the standard OIDC endpoints: - -| Path | Method | Purpose | -|---|---|---| -| `/.well-known/openid-configuration` | GET | Discovery document | -| `/jwks` | GET | Public keys (auto-rotated optionally) | -| `/authorize` | GET | 302-redirects to `redirect_uri` with `?code=…&state=…` | -| `/token` | POST | Exchanges code → access_token + id_token (+ refresh_token) | -| `/userinfo` | GET | Returns JSON **or** signed-JWT based on `Accept` header / per-realm config | -| `/end_session` | GET | Spec-compliant end-session endpoint | -| `/admin/scenario` | POST | **Test-only** — set the next response shape per realm | -| `/admin/keys/rotate` | POST | **Test-only** — force key rotation | - -The `/admin/*` endpoints are how tests reconfigure the IdP at runtime -without restarting the container. Each scenario covers one realm (a -provider id), so multiple tests can run against the same image with -isolated config. - -### Realms / providers - -The mock IdP supports multiple realms in one process. Each test class -"owns" a realm name (e.g. `esignet`, `azure-fake`, `google-fake`) and POSTs -a scenario to `/admin/scenario/{realm}` in `@BeforeEach`. DHIS2 sees them as -independent providers via separate `oidc.provider..*` configs. - -Realm config controls: - -- `userinfo_response_type`: `json` or `jwt` -- `userinfo_signing_alg`: `RS256` / `PS256` / `ES256` / … -- `userinfo_claims`: the JSON object the userinfo returns (so tests pick - the mapping value) -- `id_token_claims`: extra claims merged into the id_token (so tests can - validate `sub`, `email_verified`, `aud`, `iss`, custom claims) -- `id_token_signing_alg`: keep the id_token alg the same as userinfo by - default; allow override for negative tests -- `require_pkce`: true/false -- `require_private_key_jwt`: true/false. When true, `/token` rejects - `client_secret_*` and verifies the client's `private_key_jwt` assertion - against the client's registered public JWK. -- `client_secret`: optional shared secret for `client_secret_basic` / - `client_secret_post` -- `client_jwks_uri`: optional URL to fetch the client's public JWKS from - (so the IdP can verify `private_key_jwt` assertions issued by DHIS2) -- `next_error`: inject a specific error response (HTTP 400/500, malformed - JWT, expired JWT, bad-key-signed JWT, missing claim …) to drive the - negative-path tests - -### Keys - -- The mock IdP generates RSA-2048 keys on boot. JWKS exposes the public - key. -- Test code that needs to issue *its own* keys (e.g. to validate - DHIS2's `private_key_jwt`) does so in the e2e module and registers the - public JWK with the mock IdP via `/admin/scenario`. -- A `/admin/keys/rotate` endpoint generates a new keypair and serves the - union of old+new from `/jwks` for one minute, then drops the old one — - lets tests assert DHIS2 handles JWKS rotation correctly (Nimbus's - built-in remote-key cache should pick it up without restart). - -### Why test-only admin endpoints over startup config - -Easier to write fast, hermetic tests. A scenario PUT per test class is -~10 ms and keeps the IdP container reused across the suite. The -alternative (env var per realm, restart container per test class) is -slow and brittle. - ---- - -## Test framework - -### Base class - -```java -abstract class OidcRpE2ETest extends BaseE2ETest { - - protected MockIdpClient idp; // talks to /admin/* on the mock-idp service - protected String realm; // unique per test class - - @BeforeEach void setUpRealm() { - realm = "realm-" + getClass().getSimpleName().toLowerCase(); - idp.resetRealm(realm); - } - - protected void givenUserInfoJwt(String sub, Map claims) { ... } - protected void givenUserInfoJson(Map claims) { ... } - protected void givenIdToken(Map claims) { ... } - protected void rotateJwks() { idp.post("/admin/keys/rotate"); } - - protected String performAuthCodeFlow(String dhis2Username) { ... } - // ↑ returns the bearer cookie / JWT for /api/me asserts -} -``` - -`performAuthCodeFlow` follows the redirects without rendering a UI: hit -`/oauth2/authorization/` → follow 302 to mock-idp → mock-idp -auto-redirects back with a code (no login UI needed since the scenario -already says "who" is logging in) → DHIS2 completes the code-for-token -exchange, calls `/userinfo`, resolves the local user. - -### Test classes - -One class per OIDC feature. Each owns its realm and config. - -| Test class | Realm | Asserts | -|---|---|---| -| `OidcJsonUserInfoTest` | `realm-jsonuserinfo` | Standard `application/json` userinfo path. Sub-class verification, mapping claim, disabled-user error. | -| `OidcSignedJwtUserInfoTest` | `realm-jwtuserinfo` | This PR's path. `Accept: application/jwt`, signature verified, mapping claim resolved. Includes bad-key / expired-JWT / missing-claim negatives. | -| `OidcPrivateKeyJwtTest` | `realm-pkj` | `client_authentication_method=private_key_jwt`. DHIS2 issues a signed client assertion, mock IdP verifies it against DHIS2's `/api/publicKeys/.../jwks.json`. | -| `OidcPkceTest` | `realm-pkce` | `enable_pkce=on`. Mock IdP rejects the `/token` call without a `code_verifier`. | -| `OidcJwksRotationTest` | `realm-rotate` | Rotate IdP keys mid-session; assert next login succeeds without restarting DHIS2. | -| `OidcEndSessionTest` | `realm-logout` | Login, hit DHIS2 logout, assert browser redirects to `end_session_endpoint` and cookies are cleared. | -| `OidcLinkedAccountsTest` | `realm-linked` | Single IdP sub → two DHIS2 accounts (linked_accounts feature). | -| `OidcMappingClaimVariationsTest` | `realm-mapping` | `mapping_claim=email`, `=sub`, `=preferred_username`. Asserts the right local lookup for each. | -| `OidcErrorPathsTest` | `realm-errors` | Mock IdP responds 500 / malformed JWT / missing iss / wrong aud. Each maps to the documented DHIS2 error code. | - -All tagged `@Tag("oauth2tests")` so they run as part of the default -api-tests job. `@Tag("oauth2tests")` is currently not gated by any -surefire profile, so registering the tests is enough — no pom changes. - ---- - -## Repository layout - -``` -dhis-2/ -├── dhis-test-e2e/ -│ ├── mock-idp/ # NEW -│ │ ├── pom.xml # tiny module, Jib for image build -│ │ ├── Dockerfile # (or Jib-only) -│ │ └── src/main/java/org/hisp/dhis/mockidp/ -│ │ ├── MockIdpApplication.java # main() + HttpServer wiring -│ │ ├── Realm.java # per-realm state + keys -│ │ ├── ScenarioStore.java # concurrent-safe in-memory store -│ │ ├── handler/AuthorizeHandler.java -│ │ ├── handler/TokenHandler.java -│ │ ├── handler/UserInfoHandler.java # JSON vs JWT branch -│ │ ├── handler/JwksHandler.java -│ │ ├── handler/DiscoveryHandler.java -│ │ ├── handler/EndSessionHandler.java -│ │ └── handler/AdminHandler.java # /admin/scenario, /admin/keys/rotate -│ ├── docker-compose.e2e-oidc.yml # NEW — adds mock-idp service + alt dhis.conf -│ ├── config/dhis2_home/dhis-oidc.conf # NEW — oidc.provider.* for every realm -│ └── src/test/java/org/hisp/dhis/oidc/ # NEW package -│ ├── OidcRpE2ETest.java # base class -│ ├── MockIdpClient.java # talks to /admin/* -│ ├── OidcJsonUserInfoTest.java -│ ├── OidcSignedJwtUserInfoTest.java -│ ├── OidcPrivateKeyJwtTest.java -│ ├── OidcPkceTest.java -│ ├── OidcJwksRotationTest.java -│ ├── OidcEndSessionTest.java -│ ├── OidcLinkedAccountsTest.java -│ ├── OidcMappingClaimVariationsTest.java -│ └── OidcErrorPathsTest.java -└── .github/workflows/run-api-tests.yml # MODIFIED — add OIDC overlay job -``` - ---- - -## CI wiring - -Two options: - -1. **Reuse the existing `api-test` job** by always adding the OIDC compose - overlay. Cost: one extra container in every api-test run (~30 s image - build + ~5 s startup + ~30 s mock-idp tests). Net delta on a 12-minute - suite: small. -2. **Separate job** that reuses the built `CORE_IMAGE_NAME` and only adds - the mock-idp container. Slightly faster feedback per job; doubles the - matrix complexity. - -**Recommendation: option 1.** Simpler, no matrix changes, and the new -tests are useful as a smoke test for every PR anyway (they touch -authentication). - -```yaml -# .github/workflows/run-api-tests.yml — modified step -- name: Run api e2e tests with OIDC overlay - run: | - docker compose \ - -f dhis-2/dhis-test-e2e/docker-compose.yml \ - -f dhis-2/dhis-test-e2e/docker-compose.e2e.yml \ - -f dhis-2/dhis-test-e2e/docker-compose.e2e-oidc.yml \ - up --exit-code-from test -``` - -Mock-idp image is built via Jib at the same time as the core image -(parallel goals in the Maven reactor). - ---- - -## `dhis.conf` fragment - -Realm-per-provider, one block per test class. Excerpt: - -```properties -oidc.oauth2.login.enabled = on - -# Standard JSON userinfo realm -oidc.provider.realm-jsonuserinfo.client_id = dhis2-rp -oidc.provider.realm-jsonuserinfo.client_secret = test-secret -oidc.provider.realm-jsonuserinfo.mapping_claim = email -oidc.provider.realm-jsonuserinfo.authorization_uri = http://mock-idp:9000/realm-jsonuserinfo/authorize -oidc.provider.realm-jsonuserinfo.token_uri = http://mock-idp:9000/realm-jsonuserinfo/token -oidc.provider.realm-jsonuserinfo.user_info_uri = http://mock-idp:9000/realm-jsonuserinfo/userinfo -oidc.provider.realm-jsonuserinfo.jwk_uri = http://mock-idp:9000/realm-jsonuserinfo/jwks -oidc.provider.realm-jsonuserinfo.issuer_uri = http://mock-idp:9000/realm-jsonuserinfo/ -oidc.provider.realm-jsonuserinfo.scopes = openid,email - -# Signed-JWT userinfo realm (this PR's path) -oidc.provider.realm-jwtuserinfo.client_id = dhis2-rp -oidc.provider.realm-jwtuserinfo.client_secret = test-secret -oidc.provider.realm-jwtuserinfo.mapping_claim = sub -oidc.provider.realm-jwtuserinfo.authorization_uri = http://mock-idp:9000/realm-jwtuserinfo/authorize -oidc.provider.realm-jwtuserinfo.token_uri = http://mock-idp:9000/realm-jwtuserinfo/token -oidc.provider.realm-jwtuserinfo.user_info_uri = http://mock-idp:9000/realm-jwtuserinfo/userinfo -oidc.provider.realm-jwtuserinfo.jwk_uri = http://mock-idp:9000/realm-jwtuserinfo/jwks -oidc.provider.realm-jwtuserinfo.issuer_uri = http://mock-idp:9000/realm-jwtuserinfo/ -oidc.provider.realm-jwtuserinfo.scopes = openid -oidc.provider.realm-jwtuserinfo.user_info_response_type = jwt -oidc.provider.realm-jwtuserinfo.user_info_jws_algorithm = RS256 - -# private_key_jwt realm -oidc.provider.realm-pkj.client_id = dhis2-rp -oidc.provider.realm-pkj.client_authentication_method = private_key_jwt -oidc.provider.realm-pkj.keystore_path = /opt/dhis2/test-keystores/realm-pkj.p12 -oidc.provider.realm-pkj.keystore_password = test -oidc.provider.realm-pkj.key_alias = realm-pkj -oidc.provider.realm-pkj.key_password = test -oidc.provider.realm-pkj.jwk_set_url = http://web:8080/api/publicKeys/dhis2-rp/jwks.json -# ... etc -``` - -Test keystore generation lives in `dhis-test-e2e/scripts/gen-test-keys.sh` -and runs at image-build time (so the keystore is baked into the `web` -image alongside `dhis-oidc.conf`). - ---- - -## Phased rollout - -| Phase | Scope | Effort | -|---|---|---| -| **0** | Land this plan doc + the in-process WireMock-style integration test (`SignedJwtUserInfoLoaderHttpIntegrationTest`) already done in PR #23839. | done | -| **1** | Build the mock-idp module (skeleton: `/authorize`, `/token`, `/userinfo` JSON path, `/jwks`, `/admin/scenario`). Wire compose overlay. Land `OidcJsonUserInfoTest` (golden path). | ~1 day | -| **2** | Add the JWT userinfo branch to `/userinfo` + the negative-path support (bad key, expired, missing claim). Land `OidcSignedJwtUserInfoTest`. | ~½ day | -| **3** | Add `private_key_jwt` verification at `/token`. Generate test keystore at image-build. Land `OidcPrivateKeyJwtTest`. | ~½ day | -| **4** | PKCE, JWKS rotation, end-session, mapping-claim variations, linked accounts, error paths. One test class at a time. | ~2–3 days total | -| **5** | (Optional) JWE / nested signed-then-encrypted userinfo, if/when DHIS2 grows support for it. | TBD | - -Each phase is independently shippable. Phase 1 alone justifies the -infrastructure — it gives us a real RP smoke test that currently doesn't -exist anywhere. - ---- - -## Open design decisions - -1. **Per-realm path prefixing.** Above examples use - `http://mock-idp:9000//authorize`. Cleaner than query-param - routing. Discovery doc at `…//.well-known/openid-configuration` - needs to surface the realm-prefixed URLs. -2. **Selenium or no Selenium?** Mock IdP auto-redirects, no UI clicks - required. Recommend pure REST flow following 302s with - `RestTemplate(disableRedirect=true)`. Selenium only useful if we want - to assert browser-cookie state. Defer that to a later UI-test pass. -3. **State across redirects.** DHIS2's `oauth2Login` uses session cookies - to carry `state`/`nonce`. The test client needs to preserve cookies - across the 302-chain. `RestTemplate` with a `BasicCookieStore`-backed - Apache HttpClient handles this. -4. **`@Tag("oauth2tests")` vs new tag.** Reusing the existing tag keeps - things simple; the existing `OAuth2Test` is fast and runs in the same - job. If the OIDC suite grows beyond ~30 seconds, split into - `@Tag("oidc")` and add a profile. -5. **Image size / cold-start.** Aim for <50 MB image (Alpine + JRE - slim + ~250 KB jar). Mock IdP cold-start should be <2 s. - ---- - -## What this does NOT change in the existing codebase - -- `OAuth2Test.java` (DHIS2-as-AS test) is unaffected. Different flow, - different concerns; keep it as-is. -- Production code in `dhis-services/dhis-service-core/security/oidc/*` - needs no changes. The mock IdP speaks standard OIDC; DHIS2 doesn't - know it's a mock. -- The new `SignedJwtUserInfoLoaderHttpIntegrationTest` (PR #23839) stays - — it's faster per-PR feedback at the unit level. The e2e suite is - additive. - ---- - -## Acceptance criteria for the framework (phase 1 completion) - -1. `docker compose -f docker-compose.yml -f docker-compose.e2e.yml -f docker-compose.e2e-oidc.yml up` - starts cleanly on a developer laptop and in CI. -2. `OidcJsonUserInfoTest` passes against the mock IdP without using any - real external service. -3. The mock-idp container starts in <3 s, image is <50 MB. -4. Adding a new test class for a new OIDC feature requires: one test - class, one `oidc.provider..*` block, zero changes to the mock - IdP code (everything driven via `/admin/scenario`). - ---- - -## References - -- PR #23839 — DHIS2-20043 — signed-JWT userinfo (this branch) -- Plan: `dhis-2/docs/superpowers/plans/2026-05-06-esignet-oidc-jwt-userinfo.md` -- Spec: `dhis-2/docs/superpowers/specs/2026-05-06-esignet-oidc-integration-design.md` -- Hardening follow-ups: `dhis-2/docs/OAUTH2_JWT_USERINFO_HARDENING.md` -- Existing RP code: `dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/` -- Existing AS-side test (reference style): `dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/oauth2/OAuth2Test.java` -- Survey notes: see PR thread #23839 comments From 5b2fba824d3ce397cd2c9d69e4e45636d5c66b1e Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 05:28:37 +0800 Subject: [PATCH 21/25] fix(oidc): fix NPE in PublicKeysController and harden JWT verification [DHIS2-20043] Guard against null JWK in PublicKeysController.getKeys() which would NPE for non-private_key_jwt providers. Narrow catch(Exception) to catch(BadJOSEException | JOSEException | ParseException) so programming errors are not masked. Add null guard for userInfoJwsAlgorithm at the top of SignedJwtUserInfoLoader.load(). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhis/security/oidc/SignedJwtUserInfoLoader.java | 10 +++++++++- .../controller/security/PublicKeysController.java | 10 +++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java index a84cbca63037..5c94a9ed979d 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -29,13 +29,16 @@ */ package org.hisp.dhis.security.oidc; +import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.BadJOSEException; import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import java.text.ParseException; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -110,6 +113,11 @@ private static RestTemplate createRestTemplate() { * valid DHIS2 user */ public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg) { + if (reg.getUserInfoJwsAlgorithm() == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("configuration_error"), + "userInfoJwsAlgorithm is required when userInfoResponseType is JWT"); + } var providerDetails = userRequest.getClientRegistration().getProviderDetails(); String userInfoUri = providerDetails.getUserInfoEndpoint().getUri(); String idpJwkSetUri = providerDetails.getJwkSetUri(); @@ -154,7 +162,7 @@ private JWTClaimsSet verify( new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); processor.setJWSKeySelector(selector); return processor.process(jwt, null); - } catch (Exception ex) { + } catch (BadJOSEException | JOSEException | ParseException ex) { log.debug("UserInfo JWT verification failed for registration {}", registrationId, ex); throw new OAuth2AuthenticationException( new OAuth2Error("jwt_processing_error"), diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java index 850433fad907..aede76073e8e 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java @@ -74,7 +74,12 @@ public class PublicKeysController { DhisOidcClientRegistration dhisOidcClientRegistration = clientRegistrationRepository.getDhisOidcClientRegistration(clientId); - JwsAlgorithm jwsAlgorithm = resolveAlgorithm(dhisOidcClientRegistration.getJwk()); + JWK jwk = dhisOidcClientRegistration.getJwk(); + if (jwk == null) { + throw new WebMessageException(conflict(ErrorCode.E3040.getMessage(), ErrorCode.E3040)); + } + + JwsAlgorithm jwsAlgorithm = resolveAlgorithm(jwk); if (jwsAlgorithm == null) { throw new WebMessageException(conflict(ErrorCode.E3040.getMessage(), ErrorCode.E3040)); } @@ -83,8 +88,7 @@ public class PublicKeysController { new RSAKey.Builder(dhisOidcClientRegistration.getRsaPublicKey()) .keyUse(KeyUse.SIGNATURE) .algorithm(JWSAlgorithm.parse(jwsAlgorithm.toString())) - .x509CertSHA256Thumbprint( - dhisOidcClientRegistration.getJwk().getX509CertSHA256Thumbprint()) + .x509CertSHA256Thumbprint(jwk.getX509CertSHA256Thumbprint()) .keyID(dhisOidcClientRegistration.getKeyId()); return new JWKSet(builder.build()).toJSONObject(); From 9f2bfd2f3a2b5c10542937a70636f730fd33c13d Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 05:47:49 +0800 Subject: [PATCH 22/25] fix(oidc): guard against null JWK in PublicKeysController and tighten exception handling [DHIS2-20043] Make resolveAlgorithm null-safe to prevent NPE when getJwk() is null for non-private_key_jwt providers. Narrow catch clause in SignedJwtUserInfoLoader.verify() from Exception to specific JOSE types. Add fail-fast check for null userInfoJwsAlgorithm in JWT mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhis/security/oidc/SignedJwtUserInfoLoader.java | 3 +-- .../controller/security/PublicKeysController.java | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java index 5c94a9ed979d..a753789411bb 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -38,7 +38,6 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTProcessor; -import java.text.ParseException; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -162,7 +161,7 @@ private JWTClaimsSet verify( new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); processor.setJWSKeySelector(selector); return processor.process(jwt, null); - } catch (BadJOSEException | JOSEException | ParseException ex) { + } catch (BadJOSEException | JOSEException | java.text.ParseException ex) { log.debug("UserInfo JWT verification failed for registration {}", registrationId, ex); throw new OAuth2AuthenticationException( new OAuth2Error("jwt_processing_error"), diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java index aede76073e8e..4f981a98fb0b 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/PublicKeysController.java @@ -74,12 +74,7 @@ public class PublicKeysController { DhisOidcClientRegistration dhisOidcClientRegistration = clientRegistrationRepository.getDhisOidcClientRegistration(clientId); - JWK jwk = dhisOidcClientRegistration.getJwk(); - if (jwk == null) { - throw new WebMessageException(conflict(ErrorCode.E3040.getMessage(), ErrorCode.E3040)); - } - - JwsAlgorithm jwsAlgorithm = resolveAlgorithm(jwk); + JwsAlgorithm jwsAlgorithm = resolveAlgorithm(dhisOidcClientRegistration.getJwk()); if (jwsAlgorithm == null) { throw new WebMessageException(conflict(ErrorCode.E3040.getMessage(), ErrorCode.E3040)); } @@ -88,13 +83,16 @@ public class PublicKeysController { new RSAKey.Builder(dhisOidcClientRegistration.getRsaPublicKey()) .keyUse(KeyUse.SIGNATURE) .algorithm(JWSAlgorithm.parse(jwsAlgorithm.toString())) - .x509CertSHA256Thumbprint(jwk.getX509CertSHA256Thumbprint()) + .x509CertSHA256Thumbprint( + dhisOidcClientRegistration.getJwk().getX509CertSHA256Thumbprint()) .keyID(dhisOidcClientRegistration.getKeyId()); return new JWKSet(builder.build()).toJSONObject(); } private static JwsAlgorithm resolveAlgorithm(JWK jwk) { + if (jwk == null) return null; + JwsAlgorithm jwsAlgorithm = null; if (jwk.getAlgorithm() != null) { From 77bb17aa85b4754f1eae9fcc66d6d2b9af92c042 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 25 May 2026 06:09:09 +0800 Subject: [PATCH 23/25] refactor(oidc): unify user-resolution logic between JSON and JWT paths [DHIS2-20043] Extract resolveExternalAuthUser as a shared package-private static method on SignedJwtUserInfoLoader. Both JSON and JWT paths now use the same user lookup, externalAuth check, and disabled/expired validation with consistent error codes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhis/security/oidc/DhisOidcUserService.java | 14 ++++---------- .../security/oidc/SignedJwtUserInfoLoader.java | 7 ++++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java index 48fd81f071e5..c2865b0efcd1 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java @@ -101,16 +101,10 @@ OidcUser loadFromJsonUserInfo(OidcUserRequest userRequest, DhisOidcClientRegistr } if (claimValue instanceof String s && !s.isBlank()) { - User user = userService.getUserByOpenId(s); - if (user != null && user.isExternalAuth()) { - if (user.isDisabled() || !user.isAccountNonExpired()) { - throw new OAuth2AuthenticationException( - new OAuth2Error("user_disabled"), "User is disabled"); - } - UserDetails userDetails = userService.createUserDetails(user); - return new DhisOidcUser( - userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken()); - } + User user = SignedJwtUserInfoLoader.resolveExternalAuthUser(userService, s, mappingClaimKey); + UserDetails userDetails = userService.createUserDetails(user); + return new DhisOidcUser( + userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken()); } String errorMessage = diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java index a753789411bb..d538d6495eaf 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -124,7 +124,7 @@ public OidcUser load(OidcUserRequest userRequest, DhisOidcClientRegistration reg JWTClaimsSet claims = verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId(), idpJwkSetUri); String mappingValue = requireMappingClaim(claims, reg); - User user = requireExternalAuthUser(mappingValue, reg); + User user = resolveExternalAuthUser(userService, mappingValue, reg.getMappingClaimKey()); UserDetails details = userService.createUserDetails(user); return new DhisOidcUser( details, claims.toJSONObject(), IdTokenClaimNames.SUB, userRequest.getIdToken()); @@ -182,13 +182,14 @@ private String requireMappingClaim(JWTClaimsSet claims, DhisOidcClientRegistrati return s; } - private User requireExternalAuthUser(String mappingValue, DhisOidcClientRegistration reg) { + static User resolveExternalAuthUser( + UserService userService, String mappingValue, String mappingClaimKey) { User user = userService.getUserByOpenId(mappingValue); if (user == null || !user.isExternalAuth()) { throw new OAuth2AuthenticationException( new OAuth2Error("user_not_found"), "No external-auth DHIS2 user found for mapping claim '" - + reg.getMappingClaimKey() + + mappingClaimKey + "'='" + mappingValue + "'"); From 985b56499174ae8da812e14786de44ea5ee6dffa Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Tue, 26 May 2026 03:38:36 +0800 Subject: [PATCH 24/25] refactor(oidc): constructor injection in DhisOidcUserService and move integration test [DHIS2-20043] Replace @Autowired field injection with constructor injection using private final fields in DhisOidcUserService. Move HTTP integration test from dhis-test-web-api to dhis-test-integration where it belongs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhis/security/oidc/DhisOidcUserService.java | 16 ++++++++++++---- .../oidc/DhisOidcUserServiceDispatchTest.java | 8 +++----- dhis-2/dhis-test-integration/pom.xml | 6 ++++++ ...gnedJwtUserInfoLoaderHttpIntegrationTest.java | 0 4 files changed, 21 insertions(+), 9 deletions(-) rename dhis-2/{dhis-test-web-api => dhis-test-integration}/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java (100%) diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java index c2865b0efcd1..ec4e8e338da6 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUserService.java @@ -34,7 +34,6 @@ import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -62,9 +61,18 @@ @Service public class DhisOidcUserService extends OidcUserService { - @Autowired public UserService userService; - @Autowired DhisOidcProviderRepository clientRegistrationRepository; - @Autowired SignedJwtUserInfoLoader signedJwtUserInfoLoader; + private final UserService userService; + private final DhisOidcProviderRepository clientRegistrationRepository; + private final SignedJwtUserInfoLoader signedJwtUserInfoLoader; + + DhisOidcUserService( + UserService userService, + DhisOidcProviderRepository clientRegistrationRepository, + SignedJwtUserInfoLoader signedJwtUserInfoLoader) { + this.userService = userService; + this.clientRegistrationRepository = clientRegistrationRepository; + this.signedJwtUserInfoLoader = signedJwtUserInfoLoader; + } @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java index 7690ac3b4790..6109946c80f3 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java @@ -33,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,7 +42,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -68,13 +68,11 @@ class DhisOidcUserServiceDispatchTest { @Mock private OidcUser jsonResult; @Mock private OidcUser jwtResult; - @Spy private DhisOidcUserService service = new DhisOidcUserService(); + private DhisOidcUserService service; @BeforeEach void wire() { - service.userService = userService; - service.clientRegistrationRepository = repo; - service.signedJwtUserInfoLoader = signedJwtLoader; + service = spy(new DhisOidcUserService(userService, repo, signedJwtLoader)); when(request.getClientRegistration()).thenReturn(clientRegistration); when(clientRegistration.getRegistrationId()).thenReturn("p1"); } diff --git a/dhis-2/dhis-test-integration/pom.xml b/dhis-2/dhis-test-integration/pom.xml index 94a8f668c0b6..cf22382bca9a 100644 --- a/dhis-2/dhis-test-integration/pom.xml +++ b/dhis-2/dhis-test-integration/pom.xml @@ -357,6 +357,12 @@ hypersistence-utils-hibernate-55 test + + org.mock-server + mockserver-client-java + 5.15.0 + test + diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java similarity index 100% rename from dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java rename to dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java From 902bb871cd810d22b190db291d8d8763bdd9955d Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Tue, 26 May 2026 03:46:35 +0800 Subject: [PATCH 25/25] fix(oidc): add test dependencies for integration test in dhis-test-integration [DHIS2-20043] Add mockserver-client-java, testcontainers, spring-security-oauth2-client, and nimbus-jose-jwt as explicit test dependencies to satisfy the dependency analysis plugin. Co-Authored-By: Claude Opus 4.7 (1M context) --- dhis-2/dhis-test-integration/pom.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dhis-2/dhis-test-integration/pom.xml b/dhis-2/dhis-test-integration/pom.xml index cf22382bca9a..df041e1fde01 100644 --- a/dhis-2/dhis-test-integration/pom.xml +++ b/dhis-2/dhis-test-integration/pom.xml @@ -363,6 +363,21 @@ 5.15.0 test + + org.testcontainers + testcontainers + test + + + org.springframework.security + spring-security-oauth2-client + test + + + com.nimbusds + nimbus-jose-jwt + test +