diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java index 02a337a243f3..8ccaa01e3118 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java @@ -29,10 +29,6 @@ */ package org.hisp.dhis.user; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonTypeInfo; import java.util.Collection; import java.util.Set; import javax.annotation.Nonnull; @@ -49,74 +45,8 @@ @Builder @EqualsAndHashCode(onlyExplicitlyIncluded = true) @Slf4j -@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) -@JsonIgnoreProperties(ignoreUnknown = true) public class UserDetailsImpl implements UserDetails { - @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) - public static UserDetailsImpl userDetailsMixin( - @JsonProperty("id") String uid, - @JsonProperty("code") String code, - @JsonProperty("username") String username, - @JsonProperty("firstName") String firstName, - @JsonProperty("surname") String surname, - @JsonProperty("password") String password, - @JsonProperty("externalAuth") boolean externalAuth, - @JsonProperty("isTwoFactorEnabled") boolean isTwoFactorEnabled, - @JsonProperty("twoFactorType") TwoFactorType twoFactorType, - @JsonProperty("secret") String secret, - @JsonProperty("email") String email, - @JsonProperty("isEmailVerified") boolean isEmailVerified, - @JsonProperty("enabled") boolean enabled, - @JsonProperty("accountNonExpired") boolean accountNonExpired, - @JsonProperty("accountNonLocked") boolean accountNonLocked, - @JsonProperty("credentialsNonExpired") boolean credentialsNonExpired, - @JsonProperty("dataViewMaxOrganisationUnitLevel") int dataViewMaxOrganisationUnitLevel, - @JsonProperty("authorities") Collection authorities, - @JsonProperty("allAuthorities") Set allAuthorities, - @JsonProperty("allRestrictions") Set allRestrictions, - @JsonProperty("userGroupIds") Set userGroupIds, - @JsonProperty("userOrgUnitIds") Set userOrgUnitIds, - @JsonProperty("userDataOrgUnitIds") Set userDataOrgUnitIds, - @JsonProperty("userSearchOrgUnitIds") Set userSearchOrgUnitIds, - @JsonProperty("userEffectiveSearchOrgUnitIds") Set userEffectiveSearchOrgUnitIds, - @JsonProperty("isSuper") boolean isSuper, - @JsonProperty("userRoleIds") Set userRoleIds, - @JsonProperty("managedGroupLongIds") Set managedGroupLongIds, - @JsonProperty("userRoleLongIds") Set userRoleLongIds) { - return UserDetailsImpl.builder() - .uid(uid) - .code(code) - .username(username) - .firstName(firstName) - .surname(surname) - .password(password) - .externalAuth(externalAuth) - .isTwoFactorEnabled(isTwoFactorEnabled) - .twoFactorType(twoFactorType) - .secret(secret) - .email(email) - .isEmailVerified(isEmailVerified) - .enabled(enabled) - .accountNonExpired(accountNonExpired) - .accountNonLocked(accountNonLocked) - .credentialsNonExpired(credentialsNonExpired) - .dataViewMaxOrganisationUnitLevel(dataViewMaxOrganisationUnitLevel) - .authorities(authorities) - .allAuthorities(allAuthorities) - .allRestrictions(allRestrictions) - .userGroupIds(userGroupIds) - .userOrgUnitIds(userOrgUnitIds) - .userDataOrgUnitIds(userDataOrgUnitIds) - .userSearchOrgUnitIds(userSearchOrgUnitIds) - .userEffectiveSearchOrgUnitIds(userEffectiveSearchOrgUnitIds) - .isSuper(isSuper) - .userRoleIds(userRoleIds) - .managedGroupLongIds(managedGroupLongIds) - .userRoleLongIds(userRoleLongIds) - .build(); - } - private final String uid; @Setter private Long id; private final String code; diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceImpl.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceImpl.java index 95dd03358341..3281713d543c 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceImpl.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceImpl.java @@ -32,7 +32,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import java.security.Principal; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -48,6 +50,7 @@ import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserService; import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.oauth2.core.OAuth2AccessToken; @@ -381,7 +384,7 @@ private Dhis2OAuth2Authorization toEntity(OAuth2Authorization authorization) { entity.setAuthorizationGrantType(authorization.getAuthorizationGrantType().getValue()); entity.setAuthorizedScopes( StringUtils.collectionToCommaDelimitedString(authorization.getAuthorizedScopes())); - entity.setAttributes(writeMap(authorization.getAttributes())); + entity.setAttributes(writeMap(leanPrincipal(authorization.getAttributes()))); entity.setState(authorization.getAttribute(OAuth2ParameterNames.STATE)); OAuth2Authorization.Token authorizationCode = @@ -483,6 +486,41 @@ private Map parseMap(String data) { } } + /** + * Replaces a heavyweight DHIS2 principal in the authorization attributes with a lean, + * Spring-native one before persistence. + * + *

After a federated OIDC login the {@code java.security.Principal} attribute is an {@link + * org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken} whose + * principal is a {@link org.hisp.dhis.security.oidc.DhisOidcUser} wrapping a {@link + * org.hisp.dhis.user.UserDetailsImpl}; a form login carries a {@code UserDetailsImpl} directly. + * Spring Authorization Server only ever reads the principal's {@link Authentication#getName() + * name} (the JWT/token customizer) and re-loads the user from the database on token validation, + * so the heavyweight object graph is dead weight in the row and the reason those DHIS2-custom + * types tripped {@link SecurityJackson2Modules}' deserialization allowlist. Swapping it for a + * {@link UsernamePasswordAuthenticationToken} carrying just the DHIS2 username and authorities + * keeps the persisted attributes entirely Spring-native, so they round-trip without any custom + * Jackson mixins. {@code principalName} is left untouched (the consent store keys on it). The + * token's {@code authorizedClientRegistrationId} (e.g. {@code "google"}) and the authentication + * {@code details} are intentionally not retained — nothing in the token-issuance or validation + * path reads them once the user is identified by name. + * + *

Package-private (rather than {@code private}) so the branch logic can be unit-tested + * directly. + */ + static Map leanPrincipal(Map attributes) { + if (attributes.get(Principal.class.getName()) instanceof Authentication authentication + && authentication.getPrincipal() instanceof UserDetails userDetails) { + Map lean = new LinkedHashMap<>(attributes); + lean.put( + Principal.class.getName(), + new UsernamePasswordAuthenticationToken( + userDetails.getUsername(), null, userDetails.getAuthorities())); + return lean; + } + return attributes; + } + /** * Converts a Map to a JSON string. * diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oauth2/authorization/LeanPrincipalTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oauth2/authorization/LeanPrincipalTest.java new file mode 100644 index 000000000000..84f4c254cde2 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/security/oauth2/authorization/LeanPrincipalTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2004-2025, 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.oauth2.authorization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.Principal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.hisp.dhis.security.oidc.DhisOidcUser; +import org.hisp.dhis.user.UserDetailsImpl; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; + +/** + * Unit tests for {@link Dhis2OAuth2AuthorizationServiceImpl#leanPrincipal} — the swap that keeps + * the persisted OAuth2 authorization principal Spring-native (so it never trips the Jackson + * allowlist). + */ +class LeanPrincipalTest { + + @Test + void noPrincipalAttribute_isUnchanged() { + Map attributes = new LinkedHashMap<>(); + attributes.put("state", "xyz"); + assertSame(attributes, Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes)); + } + + @Test + void nonUserDetailsPrincipal_isUnchanged() { + // client_credentials-style: an Authentication whose principal is the client id (a String). + Map attributes = new LinkedHashMap<>(); + attributes.put( + Principal.class.getName(), + new UsernamePasswordAuthenticationToken("client-id", null, List.of())); + assertSame(attributes, Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes)); + } + + @Test + void formLoginUserDetailsImplPrincipal_isLeaned() { + UserDetailsImpl userDetails = userDetails("formuser"); + Map attributes = new LinkedHashMap<>(); + attributes.put( + Principal.class.getName(), + new UsernamePasswordAuthenticationToken( + userDetails, "secret", userDetails.getAuthorities())); + + assertLean(Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes), "formuser"); + } + + @Test + void federatedDhisOidcUserPrincipal_isLeaned() { + UserDetailsImpl userDetails = userDetails("oidcuser"); + Map oidcClaims = Map.of(IdTokenClaimNames.SUB, "google-sub-1"); + OidcIdToken idToken = + OidcIdToken.withTokenValue("id-token") + .subject("google-sub-1") + .claims(c -> c.putAll(oidcClaims)) + .build(); + DhisOidcUser oidcPrincipal = + new DhisOidcUser(userDetails, oidcClaims, IdTokenClaimNames.SUB, idToken); + Map attributes = new LinkedHashMap<>(); + attributes.put( + Principal.class.getName(), + new OAuth2AuthenticationToken(oidcPrincipal, oidcPrincipal.getAuthorities(), "google")); + + // The DhisOidcUser's getName() is the IdP sub; the leaned principal must carry the DHIS2 + // username. + assertLean(Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes), "oidcuser"); + } + + private static void assertLean(Map result, String expectedUsername) { + Object principal = result.get(Principal.class.getName()); + assertInstanceOf(UsernamePasswordAuthenticationToken.class, principal); + UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) principal; + assertEquals(expectedUsername, token.getName()); + assertTrue(token.isAuthenticated()); + assertEquals( + Set.of("ALL"), + token.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet())); + } + + private static UserDetailsImpl userDetails(String username) { + return UserDetailsImpl.builder() + .uid("uid-" + username) + .username(username) + .authorities(new ArrayList<>(List.of(new SimpleGrantedAuthority("ALL")))) + .allAuthorities(new HashSet<>(Set.of("ALL"))) + .allRestrictions(new HashSet<>()) + .userGroupIds(new HashSet<>()) + .userOrgUnitIds(new HashSet<>()) + .userDataOrgUnitIds(new HashSet<>()) + .userSearchOrgUnitIds(new HashSet<>()) + .userEffectiveSearchOrgUnitIds(new HashSet<>()) + .userRoleIds(new HashSet<>()) + .managedGroupLongIds(new HashSet<>()) + .userRoleLongIds(new HashSet<>()) + .build(); + } +} diff --git a/dhis-2/dhis-test-integration/pom.xml b/dhis-2/dhis-test-integration/pom.xml index 5cea0a0faf64..74ca1d86d3ce 100644 --- a/dhis-2/dhis-test-integration/pom.xml +++ b/dhis-2/dhis-test-integration/pom.xml @@ -347,6 +347,11 @@ quick test + + org.springframework.security + spring-security-oauth2-client + test + org.springframework.security spring-security-oauth2-core diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceIntegrationTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceIntegrationTest.java index 4174242f8a7e..86cb04c77a81 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceIntegrationTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceIntegrationTest.java @@ -30,24 +30,36 @@ package org.hisp.dhis.security.oauth2.authorization; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.security.Principal; import java.time.Instant; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.hisp.dhis.common.CodeGenerator; import org.hisp.dhis.security.oauth2.client.Dhis2OAuth2ClientStore; +import org.hisp.dhis.security.oidc.DhisOidcUser; import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.hisp.dhis.user.CurrentUserUtil; +import org.hisp.dhis.user.UserDetails; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; @@ -331,4 +343,66 @@ void testRemoveAuthorization() { assertNotNull(foundBeforeRemove); assertNull(foundAfterRemove); } + + @Test + void testFindByAuthorizationCodeWithOidcUserPrincipal() { + // Regression test for the /oauth2/token 500: an external-OIDC login presents an + // OAuth2AuthenticationToken whose principal is a DhisOidcUser (wrapping a UserDetailsImpl). The + // persistence layer must store a lean, Spring-native principal so the read side (the token + // exchange) never trips Spring Security's Jackson deserialization allowlist. + UserDetails currentUser = CurrentUserUtil.getCurrentUserDetails(); + Instant now = Instant.now(); + Instant expiresAt = now.plusSeconds(300); + + Map claims = + Map.of(IdTokenClaimNames.SUB, currentUser.getUsername(), "email", "oidc@example.com"); + OidcIdToken idToken = + OidcIdToken.withTokenValue("oidc-id-token-value") + .issuedAt(now) + .expiresAt(expiresAt) + .subject(currentUser.getUsername()) + .claims(c -> c.putAll(claims)) + .build(); + DhisOidcUser oidcPrincipal = + new DhisOidcUser(currentUser, claims, IdTokenClaimNames.SUB, idToken); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oidcPrincipal, oidcPrincipal.getAuthorities(), "google"); + + OAuth2AuthorizationCode authorizationCode = + new OAuth2AuthorizationCode("oidc-code-value", now, expiresAt); + OAuth2Authorization authorization = + OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(currentUser.getUsername()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .token(authorizationCode) + .attribute(Principal.class.getName(), authentication) + .id(CodeGenerator.generateUid()) + .build(); + + // When + authorizationService.save(authorization); + OAuth2Authorization found = + authorizationService.findByToken( + "oidc-code-value", new OAuth2TokenType(OAuth2ParameterNames.CODE)); + + // Then: the heavyweight OIDC principal was persisted as a lean, Spring-native + // UsernamePasswordAuthenticationToken (no DHIS2-custom types in the row), carrying the DHIS2 + // username and authorities. getName() is the DHIS2 username — what the token customizer emits + // as + // the username claim and what the resource server resolves the user by. + assertNotNull(found); + Object principalAttribute = found.getAttribute(Principal.class.getName()); + assertInstanceOf(UsernamePasswordAuthenticationToken.class, principalAttribute); + UsernamePasswordAuthenticationToken roundTripped = + (UsernamePasswordAuthenticationToken) principalAttribute; + assertEquals(currentUser.getUsername(), roundTripped.getName()); + assertTrue(roundTripped.isAuthenticated()); + assertEquals( + currentUser.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()), + roundTripped.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet())); + } } diff --git a/dhis-2/dhis-test-web-api/pom.xml b/dhis-2/dhis-test-web-api/pom.xml index 53c163d9b3cc..0bf94d12bf0c 100644 --- a/dhis-2/dhis-test-web-api/pom.xml +++ b/dhis-2/dhis-test-web-api/pom.xml @@ -260,6 +260,11 @@ spring-security-core test + + org.springframework.security + spring-security-crypto + test + org.springframework.security spring-security-config diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/FederatedOidcTokenControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/FederatedOidcTokenControllerTest.java new file mode 100644 index 000000000000..bd5c3296254f --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/FederatedOidcTokenControllerTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2004-2025, 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.webapi.controller.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Map; +import java.util.Set; +import org.hisp.dhis.common.CodeGenerator; +import org.hisp.dhis.jsontree.JsonValue; +import org.hisp.dhis.security.jwt.Dhis2JwtAuthenticationManagerResolver; +import org.hisp.dhis.security.oauth2.authorization.Dhis2OAuth2AuthorizationService; +import org.hisp.dhis.security.oauth2.client.Dhis2OAuth2ClientService; +import org.hisp.dhis.security.oidc.DhisOidcUser; +import org.hisp.dhis.test.webapi.ControllerWithJwtTokenAuthTestBase; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.test.context.ActiveProfiles; + +/** + * End-to-end test for FEDERATED OIDC login through the OAuth2 Authorization Server. + * + *

Simulates the "log in with Google" path where the user is authenticated as a {@link + * DhisOidcUser} (whose {@code getName()} is the IdP {@code sub}), then exchanges the resulting + * authorization code at {@code /oauth2/token} and calls the API with the issued DHIS2 JWT. Verifies + * that the lean-principal persistence makes the issued token carry the DHIS2 username (not + * the IdP {@code sub}) and that the resource server resolves it to the correct DHIS2 user. + * + * @author Morten Svanæs + */ +@ActiveProfiles("oauth2-authorization-server-test") +class FederatedOidcTokenControllerTest extends ControllerWithJwtTokenAuthTestBase { + + @Autowired private Dhis2OAuth2ClientService oAuth2ClientService; + @Autowired private Dhis2OAuth2AuthorizationService authorizationService; + @Autowired private AuthorizationServerSettings authorizationServerSettings; + @Autowired private Dhis2JwtAuthenticationManagerResolver jwtAuthenticationManagerResolver; + @Autowired private JwtDecoder jwtDecoder; + @Autowired private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + // Make the resource-server resolver validate tokens with the AS's own signing keys. + jwtAuthenticationManagerResolver.setJwtDecoder(jwtDecoder); + } + + @Test + @DisplayName("Federated OIDC login: token carries the DHIS2 username and resolves the user") + void federatedOidcLoginIssuesTokenThatResolvesToDhis2User() throws Exception { + // Given: a local DHIS2 user matched by an external OIDC login (external auth). + User user = createUserWithAuth("feduser", "ALL"); + UserDetails userDetails = userService.createUserDetails(user); + + // And: the Spring principal that the OIDC login produces — a DhisOidcUser whose getName() is + // the + // IdP 'sub', not the DHIS2 username. + Instant now = Instant.now(); + Map claims = + Map.of(IdTokenClaimNames.SUB, "google-sub-0001", "email", "feduser@dhis2.org"); + OidcIdToken idToken = + OidcIdToken.withTokenValue("id-token") + .issuedAt(now) + .expiresAt(now.plus(5, ChronoUnit.MINUTES)) + .subject("google-sub-0001") + .claims(c -> c.putAll(claims)) + .build(); + DhisOidcUser oidcPrincipal = + new DhisOidcUser(userDetails, claims, IdTokenClaimNames.SUB, idToken); + OAuth2AuthenticationToken oidcAuthentication = + new OAuth2AuthenticationToken(oidcPrincipal, oidcPrincipal.getAuthorities(), "google"); + + // And: a confidential authorization_code client (the native app's server-side equivalent). + String clientId = "federated-test-client"; + String clientSecret = "federated-secret"; + String redirectUri = "https://app.example.org/callback"; + RegisteredClient client = + RegisteredClient.withId(CodeGenerator.generateUid()) + .clientId(clientId) + .clientSecret(passwordEncoder.encode(clientSecret)) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(redirectUri) + .scope("openid") + .scope("profile") + .scope("username") + .scope("email") + .build(); + oAuth2ClientService.save(client); + + // And: the authorization the /oauth2/authorize step would have stored after the OIDC login. + String issuer = authorizationServerSettings.getIssuer(); + String codeValue = "fed-code-value"; + OAuth2AuthorizationCode authorizationCode = + new OAuth2AuthorizationCode(codeValue, now, now.plus(5, ChronoUnit.MINUTES)); + OAuth2AuthorizationRequest authorizationRequest = + OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(issuer + "oauth2/authorize") + .clientId(clientId) + .redirectUri(redirectUri) + .scopes(Set.of("openid", "profile", "username", "email")) + .state("state-0001") + .build(); + OAuth2Authorization authorization = + OAuth2Authorization.withRegisteredClient(client) + .id(CodeGenerator.generateUid()) + .principalName(oidcPrincipal.getName()) // the IdP sub, as Spring AS would set it + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizedScopes(Set.of("openid", "profile", "username", "email")) + .token(authorizationCode) + .attribute(java.security.Principal.class.getName(), oidcAuthentication) + .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest) + .build(); + authorizationService.save(authorization); // <-- lean-principal swap happens here + + // When: the client exchanges the code for a token. + String basicAuth = + Base64.getEncoder() + .encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8)); + String tokenResponse = + mvc.perform( + post("/oauth2/token") + .header(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", "authorization_code") + .param("code", codeValue) + .param("redirect_uri", redirectUri)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").exists()) + .andReturn() + .getResponse() + .getContentAsString(); + String accessToken = JsonValue.of(tokenResponse).asObject().getString("access_token").string(); + assertNotNull(accessToken); + + // Then: the issued JWT carries the DHIS2 username (NOT the IdP sub) as the username claim. The + // sub and the DHIS2 username deliberately differ, so this is a real regression guard: if the + // lean-principal swap were removed, getName() would be the sub and these would fail (or + // /oauth2/token would 500 on the Jackson allowlist). + assertNotEquals( + oidcPrincipal.getName(), + user.getUsername(), + "the IdP sub must differ from the DHIS2 username, else this test proves nothing"); + Jwt decoded = jwtDecoder.decode(accessToken); + assertEquals(user.getUsername(), decoded.getClaimAsString("username")); + assertNotEquals("google-sub-0001", decoded.getClaimAsString("username")); + // And the email-scope branch of the token customizer also resolves off the lean username. + assertEquals(user.getEmail(), decoded.getClaimAsString("email")); + + // And: an API call with that token resolves to the correct DHIS2 user. + mvc.perform(get("/api/me").header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(user.getUsername())); + } +}