Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
904d313
docs(oidc): add design spec for eSignet JWT userinfo support [DHIS2-2…
netroms May 6, 2026
4f5c50a
docs(oidc): add implementation plan for eSignet JWT userinfo support …
netroms May 6, 2026
a24c627
feat(oidc): add UserInfoResponseType enum [DHIS2-20043]
netroms May 6, 2026
734e26c
feat(oidc): add JWS algorithm allow-list for userinfo verification [D…
netroms May 6, 2026
5e5850d
feat(oidc): add user_info_response_type and user_info_jws_algorithm c…
netroms May 6, 2026
85e5026
feat(oidc): track userInfo response type and JWS algorithm on registr…
netroms May 6, 2026
3121f65
feat(oidc): validate userinfo response type and relax client_secret u…
netroms May 6, 2026
43cc0f1
feat(oidc): tighten missing-secret check and add direct builder tests…
netroms May 6, 2026
dbdcadd
feat(oidc): add per-registration JWKSource cache [DHIS2-20043]
netroms May 6, 2026
e946e63
build: enable ByteBuddy experimental flag for Java 21 test runs [DHIS…
netroms May 6, 2026
dc25132
feat(oidc): add SignedJwtUserInfoLoader for application/jwt userinfo …
netroms May 6, 2026
cc92cfe
feat(oidc): branch DhisOidcUserService on userInfoResponseType [DHIS2…
netroms May 6, 2026
1f3737f
feat(oidc): include x509 cert thumbprint in published JWK [DHIS2-20043]
netroms May 6, 2026
81342d3
fix(oidc): verify userinfo JWT against IdP's JWKS, not client_auth JW…
netroms May 7, 2026
ac93224
Merge branch 'master' into feat/DHIS2-20043-esignet-oidc-jwt-userinfo
netroms May 7, 2026
e685361
Merge branch 'master' of github.com:dhis2/dhis2-core into feat/DHIS2-…
netroms May 11, 2026
253ee74
test(oidc): add real-HTTP integration test for SignedJwtUserInfoLoade…
netroms May 11, 2026
8b5fb77
docs(oidc): add JWT userinfo hardening + e2e mock-IdP plan [DHIS2-20043]
netroms May 11, 2026
a3dcd77
fix(oidc): address PR review feedback for JWT userinfo [DHIS2-20043]
netroms May 24, 2026
391eb6f
style(oidc): apply spotless formatting to integration test [DHIS2-20043]
netroms May 24, 2026
63be069
fix(oidc): resolve SonarCloud blocker and code smell in tests [DHIS2-…
netroms May 24, 2026
3cf6818
chore(oidc): remove design docs from PR [DHIS2-20043]
netroms May 24, 2026
5b2fba8
fix(oidc): fix NPE in PublicKeysController and harden JWT verificatio…
netroms May 24, 2026
9f2bfd2
fix(oidc): guard against null JWK in PublicKeysController and tighten…
netroms May 24, 2026
77bb17a
refactor(oidc): unify user-resolution logic between JSON and JWT path…
netroms May 24, 2026
985b564
refactor(oidc): constructor injection in DhisOidcUserService and move…
netroms May 25, 2026
902bb87
fix(oidc): add test dependencies for integration test in dhis-test-in…
netroms May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, Map<String, String>> externalClients = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,51 +45,55 @@
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}.
*
* <p>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.
*
* <p>On success the method returns a {@link DhisOidcUser} wrapping the DHIS2 {@code UserDetails}
* together with the raw OIDC claims and the validated ID token.
* <p>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 <msvanaes@dhis2.org>
*/
@Slf4j
@Service
public class DhisOidcUserService extends OidcUserService {
@Autowired public UserService userService;

@Autowired private DhisOidcProviderRepository clientRegistrationRepository;
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;
}

/**
* 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.
*/
OidcUser loadFromJsonUserInfo(OidcUserRequest userRequest, DhisOidcClientRegistration reg) {
OidcUser oidcUser = super.loadUser(userRequest);

String mappingClaimKey = oidcClientRegistration.getMappingClaimKey();
String mappingClaimKey = reg.getMappingClaimKey();
Map<String, Object> attributes = oidcUser.getAttributes();
Object claimValue = attributes.get(mappingClaimKey);
OidcUserInfo userInfo = oidcUser.getUserInfo();
Expand All @@ -100,38 +103,26 @@ 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 (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());
}
if (claimValue instanceof String s && !s.isBlank()) {
User user = SignedJwtUserInfoLoader.resolveExternalAuthUser(userService, s, mappingClaimKey);
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 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -405,20 +412,24 @@ private static boolean validateConfig(Map<String, String> providerConfig) {
Objects.requireNonNull(providerConfig);

String providerId = providerConfig.get(PROVIDER_ID);
boolean privateKeyJwt = isPrivateKeyJwt(providerConfig);

for (Map.Entry<String, Boolean> 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;
}

Expand All @@ -430,11 +441,51 @@ private static boolean validateConfig(Map<String, String> providerConfig) {
providerId,
key,
value);

return false;
}
}

return validateUserInfoResponseType(providerId, providerConfig);
}

private static boolean isPrivateKeyJwt(Map<String, String> 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<String, String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Sources are constructed lazily on first call to {@link #get(String, String)}.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@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<String, JWKSource<SecurityContext>> 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<SecurityContext> get(String registrationId, String jwkSetUri) {
return sources.computeIfAbsent(registrationId, id -> build(jwkSetUri));
}

private JWKSource<SecurityContext> 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);
}
}
}
Loading
Loading