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<>(); 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..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; @@ -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}. * - *

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; + 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 attributes = oidcUser.getAttributes(); Object claimValue = attributes.get(mappingClaimKey); OidcUserInfo userInfo = oidcUser.getUserInfo(); @@ -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()); } } 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/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); + } + } +} 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..d538d6495eaf --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoader.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.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.util.List; +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.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; +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 +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}. + * + * @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) { + 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(); + String jwt = fetchJwt(userRequest, userInfoUri); + JWTClaimsSet claims = + verify(jwt, reg, userRequest.getClientRegistration().getRegistrationId(), idpJwkSetUri); + String mappingValue = requireMappingClaim(claims, reg); + User user = resolveExternalAuthUser(userService, mappingValue, reg.getMappingClaimKey()); + 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(List.of(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, String idpJwkSetUri) { + try { + ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + JWKSource keySource = jwkSourceCache.get(registrationId, idpJwkSetUri); + JWSKeySelector selector = + new JWSVerificationKeySelector<>(reg.getUserInfoJwsAlgorithm(), keySource); + processor.setJWSKeySelector(selector); + return processor.process(jwt, null); + } 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"), + "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; + } + + 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 '" + + mappingClaimKey + + "'='" + + 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/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/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. + * + *

+ * + * @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()); + } +} 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"; } 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..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 @@ -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()) && !isPrivateKeyJwt(config)) { 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(); } @@ -170,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, @@ -192,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/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..6109946c80f3 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/DhisOidcUserServiceDispatchTest.java @@ -0,0 +1,113 @@ +/* + * 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.spy; +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.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; + + private DhisOidcUserService service; + + @BeforeEach + void wire() { + service = spy(new DhisOidcUserService(userService, repo, 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()); + } +} 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..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 @@ -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,80 @@ 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 { + assertThat(GenericOidcProviderConfigParser.parse(p), hasSize(1)); + } catch (IllegalStateException expected) { + // builder can throw when reading non-existent keystore + } + } + + @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; + } } 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..78083bada2a7 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderTest.java @@ -0,0 +1,249 @@ +/* + * 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"); + when(providerDetails.getJwkSetUri()).thenReturn("https://idp.test/jwks"); + + registration = + DhisOidcClientRegistration.builder() + .clientRegistration(clientRegistration) + .mappingClaimKey("sub") + .userInfoResponseType(UserInfoResponseType.JWT) + .userInfoJwsAlgorithm(JWSAlgorithm.RS256) + .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()); + } + + @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) { + 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(); + } +} 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")); + } +} 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..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 @@ -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,57 @@ 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); + Map> noExternalClients = Map.of(); + assertThrows( + IllegalArgumentException.class, + () -> GenericOidcProviderBuilder.build(cfg, noExternalClients)); + } + + @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; + } } diff --git a/dhis-2/dhis-test-integration/pom.xml b/dhis-2/dhis-test-integration/pom.xml index 94a8f668c0b6..df041e1fde01 100644 --- a/dhis-2/dhis-test-integration/pom.xml +++ b/dhis-2/dhis-test-integration/pom.xml @@ -357,6 +357,27 @@ hypersistence-utils-hibernate-55 test + + org.mock-server + mockserver-client-java + 5.15.0 + test + + + org.testcontainers + testcontainers + test + + + org.springframework.security + spring-security-oauth2-client + test + + + com.nimbusds + nimbus-jose-jwt + test + diff --git a/dhis-2/dhis-test-integration/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 new file mode 100644 index 000000000000..c94ad05bc6e3 --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oidc/SignedJwtUserInfoLoaderHttpIntegrationTest.java @@ -0,0 +1,260 @@ +/* + * 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 static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +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 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.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.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; + +/** + * 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 MockServer stub serves: + * + *

    + *
  • {@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. + * + * @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; + @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 idpSigningKey; + private SignedJwtUserInfoLoader loader; + private DhisOidcClientRegistration registration; + + @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 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(); + + 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); + 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()); + } + + @Test + 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)); + + 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); + + assertNotNull(result); + assertEquals("user-42", result.getAttributes().get("sub")); + } + + @Test + void jwtSignedWithUnknownKey_isRejectedWithJwtProcessingError() throws Exception { + 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( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("jwt_processing_error", ex.getError().getErrorCode()); + } + + @Test + void userInfoEndpointReturns500_isMappedToInvalidUserInfoResponse() { + mockServerClient + .when(request().withPath("/userinfo")) + .respond(response().withStatusCode(500).withBody("server error")); + + OAuth2AuthenticationException ex = + assertThrows( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("invalid_user_info_response", ex.getError().getErrorCode()); + } + + @Test + 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( + OAuth2AuthenticationException.class, () -> loader.load(userRequest, registration)); + assertEquals("missing_mapping_claim", ex.getError().getErrorCode()); + } + + 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(); + } +} 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..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 @@ -83,12 +83,16 @@ 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(); } private static JwsAlgorithm resolveAlgorithm(JWK jwk) { + if (jwk == null) return null; + JwsAlgorithm jwsAlgorithm = null; if (jwk.getAlgorithm() != null) { diff --git a/dhis-2/pom.xml b/dhis-2/pom.xml index 580d8dd72a81..9f8c3ed23fb0 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}