Skip to content

Commit 93e3871

Browse files
authored
feat: support federated OIDC login through the OAuth2 Authorization Server (#24050) (#24354)
(cherry picked from commit cd398b5)
1 parent 750043f commit 93e3871

7 files changed

Lines changed: 475 additions & 69 deletions

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@
2929
*/
3030
package org.hisp.dhis.user;
3131

32-
import com.fasterxml.jackson.annotation.JsonCreator;
33-
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
34-
import com.fasterxml.jackson.annotation.JsonProperty;
35-
import com.fasterxml.jackson.annotation.JsonTypeInfo;
3632
import java.util.Collection;
3733
import java.util.Set;
3834
import javax.annotation.Nonnull;
@@ -49,72 +45,8 @@
4945
@Builder
5046
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
5147
@Slf4j
52-
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
53-
@JsonIgnoreProperties(ignoreUnknown = true)
5448
public class UserDetailsImpl implements UserDetails {
5549

56-
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
57-
public static UserDetailsImpl userDetailsMixin(
58-
@JsonProperty("id") String uid,
59-
@JsonProperty("code") String code,
60-
@JsonProperty("username") String username,
61-
@JsonProperty("firstName") String firstName,
62-
@JsonProperty("surname") String surname,
63-
@JsonProperty("password") String password,
64-
@JsonProperty("externalAuth") boolean externalAuth,
65-
@JsonProperty("isTwoFactorEnabled") boolean isTwoFactorEnabled,
66-
@JsonProperty("twoFactorType") TwoFactorType twoFactorType,
67-
@JsonProperty("secret") String secret,
68-
@JsonProperty("email") String email,
69-
@JsonProperty("isEmailVerified") boolean isEmailVerified,
70-
@JsonProperty("enabled") boolean enabled,
71-
@JsonProperty("accountNonExpired") boolean accountNonExpired,
72-
@JsonProperty("accountNonLocked") boolean accountNonLocked,
73-
@JsonProperty("credentialsNonExpired") boolean credentialsNonExpired,
74-
@JsonProperty("authorities") Collection<GrantedAuthority> authorities,
75-
@JsonProperty("allAuthorities") Set<String> allAuthorities,
76-
@JsonProperty("allRestrictions") Set<String> allRestrictions,
77-
@JsonProperty("userGroupIds") Set<String> userGroupIds,
78-
@JsonProperty("userOrgUnitIds") Set<String> userOrgUnitIds,
79-
@JsonProperty("userDataOrgUnitIds") Set<String> userDataOrgUnitIds,
80-
@JsonProperty("userSearchOrgUnitIds") Set<String> userSearchOrgUnitIds,
81-
@JsonProperty("userEffectiveSearchOrgUnitIds") Set<String> userEffectiveSearchOrgUnitIds,
82-
@JsonProperty("isSuper") boolean isSuper,
83-
@JsonProperty("userRoleIds") Set<String> userRoleIds,
84-
@JsonProperty("managedGroupLongIds") Set<Long> managedGroupLongIds,
85-
@JsonProperty("userRoleLongIds") Set<Long> userRoleLongIds) {
86-
return UserDetailsImpl.builder()
87-
.uid(uid)
88-
.code(code)
89-
.username(username)
90-
.firstName(firstName)
91-
.surname(surname)
92-
.password(password)
93-
.externalAuth(externalAuth)
94-
.isTwoFactorEnabled(isTwoFactorEnabled)
95-
.twoFactorType(twoFactorType)
96-
.secret(secret)
97-
.email(email)
98-
.isEmailVerified(isEmailVerified)
99-
.enabled(enabled)
100-
.accountNonExpired(accountNonExpired)
101-
.accountNonLocked(accountNonLocked)
102-
.credentialsNonExpired(credentialsNonExpired)
103-
.authorities(authorities)
104-
.allAuthorities(allAuthorities)
105-
.allRestrictions(allRestrictions)
106-
.userGroupIds(userGroupIds)
107-
.userOrgUnitIds(userOrgUnitIds)
108-
.userDataOrgUnitIds(userDataOrgUnitIds)
109-
.userSearchOrgUnitIds(userSearchOrgUnitIds)
110-
.userEffectiveSearchOrgUnitIds(userEffectiveSearchOrgUnitIds)
111-
.isSuper(isSuper)
112-
.userRoleIds(userRoleIds)
113-
.managedGroupLongIds(managedGroupLongIds)
114-
.userRoleLongIds(userRoleLongIds)
115-
.build();
116-
}
117-
11850
private final String uid;
11951
@Setter private Long id;
12052
private final String code;

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/authorization/Dhis2OAuth2AuthorizationServiceImpl.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
import com.fasterxml.jackson.core.type.TypeReference;
3333
import com.fasterxml.jackson.databind.DeserializationFeature;
3434
import com.fasterxml.jackson.databind.ObjectMapper;
35+
import java.security.Principal;
3536
import java.util.Date;
37+
import java.util.LinkedHashMap;
3638
import java.util.List;
3739
import java.util.Map;
3840
import java.util.function.Consumer;
@@ -48,6 +50,7 @@
4850
import org.hisp.dhis.user.UserDetails;
4951
import org.hisp.dhis.user.UserService;
5052
import org.springframework.dao.DataRetrievalFailureException;
53+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
5154
import org.springframework.security.core.Authentication;
5255
import org.springframework.security.jackson2.SecurityJackson2Modules;
5356
import org.springframework.security.oauth2.core.OAuth2AccessToken;
@@ -353,7 +356,7 @@ private Dhis2OAuth2Authorization toEntity(OAuth2Authorization authorization) {
353356
entity.setAuthorizationGrantType(authorization.getAuthorizationGrantType().getValue());
354357
entity.setAuthorizedScopes(
355358
StringUtils.collectionToCommaDelimitedString(authorization.getAuthorizedScopes()));
356-
entity.setAttributes(writeMap(authorization.getAttributes()));
359+
entity.setAttributes(writeMap(leanPrincipal(authorization.getAttributes())));
357360
entity.setState(authorization.getAttribute(OAuth2ParameterNames.STATE));
358361

359362
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =
@@ -455,6 +458,41 @@ private Map<String, Object> parseMap(String data) {
455458
}
456459
}
457460

461+
/**
462+
* Replaces a heavyweight DHIS2 principal in the authorization attributes with a lean,
463+
* Spring-native one before persistence.
464+
*
465+
* <p>After a federated OIDC login the {@code java.security.Principal} attribute is an {@link
466+
* org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken} whose
467+
* principal is a {@link org.hisp.dhis.security.oidc.DhisOidcUser} wrapping a {@link
468+
* org.hisp.dhis.user.UserDetailsImpl}; a form login carries a {@code UserDetailsImpl} directly.
469+
* Spring Authorization Server only ever reads the principal's {@link Authentication#getName()
470+
* name} (the JWT/token customizer) and re-loads the user from the database on token validation,
471+
* so the heavyweight object graph is dead weight in the row and the reason those DHIS2-custom
472+
* types tripped {@link SecurityJackson2Modules}' deserialization allowlist. Swapping it for a
473+
* {@link UsernamePasswordAuthenticationToken} carrying just the DHIS2 username and authorities
474+
* keeps the persisted attributes entirely Spring-native, so they round-trip without any custom
475+
* Jackson mixins. {@code principalName} is left untouched (the consent store keys on it). The
476+
* token's {@code authorizedClientRegistrationId} (e.g. {@code "google"}) and the authentication
477+
* {@code details} are intentionally not retained — nothing in the token-issuance or validation
478+
* path reads them once the user is identified by name.
479+
*
480+
* <p>Package-private (rather than {@code private}) so the branch logic can be unit-tested
481+
* directly.
482+
*/
483+
static Map<String, Object> leanPrincipal(Map<String, Object> attributes) {
484+
if (attributes.get(Principal.class.getName()) instanceof Authentication authentication
485+
&& authentication.getPrincipal() instanceof UserDetails userDetails) {
486+
Map<String, Object> lean = new LinkedHashMap<>(attributes);
487+
lean.put(
488+
Principal.class.getName(),
489+
new UsernamePasswordAuthenticationToken(
490+
userDetails.getUsername(), null, userDetails.getAuthorities()));
491+
return lean;
492+
}
493+
return attributes;
494+
}
495+
458496
/**
459497
* Converts a Map to a JSON string.
460498
*
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright (c) 2004-2025, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.security.oauth2.authorization;
31+
32+
import static org.junit.jupiter.api.Assertions.assertEquals;
33+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
34+
import static org.junit.jupiter.api.Assertions.assertSame;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
36+
37+
import java.security.Principal;
38+
import java.util.ArrayList;
39+
import java.util.HashSet;
40+
import java.util.LinkedHashMap;
41+
import java.util.List;
42+
import java.util.Map;
43+
import java.util.Set;
44+
import java.util.stream.Collectors;
45+
import org.hisp.dhis.security.oidc.DhisOidcUser;
46+
import org.hisp.dhis.user.UserDetailsImpl;
47+
import org.junit.jupiter.api.Test;
48+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
49+
import org.springframework.security.core.GrantedAuthority;
50+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
51+
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
52+
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
53+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
54+
55+
/**
56+
* Unit tests for {@link Dhis2OAuth2AuthorizationServiceImpl#leanPrincipal} — the swap that keeps
57+
* the persisted OAuth2 authorization principal Spring-native (so it never trips the Jackson
58+
* allowlist).
59+
*/
60+
class LeanPrincipalTest {
61+
62+
@Test
63+
void noPrincipalAttribute_isUnchanged() {
64+
Map<String, Object> attributes = new LinkedHashMap<>();
65+
attributes.put("state", "xyz");
66+
assertSame(attributes, Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes));
67+
}
68+
69+
@Test
70+
void nonUserDetailsPrincipal_isUnchanged() {
71+
// client_credentials-style: an Authentication whose principal is the client id (a String).
72+
Map<String, Object> attributes = new LinkedHashMap<>();
73+
attributes.put(
74+
Principal.class.getName(),
75+
new UsernamePasswordAuthenticationToken("client-id", null, List.of()));
76+
assertSame(attributes, Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes));
77+
}
78+
79+
@Test
80+
void formLoginUserDetailsImplPrincipal_isLeaned() {
81+
UserDetailsImpl userDetails = userDetails("formuser");
82+
Map<String, Object> attributes = new LinkedHashMap<>();
83+
attributes.put(
84+
Principal.class.getName(),
85+
new UsernamePasswordAuthenticationToken(
86+
userDetails, "secret", userDetails.getAuthorities()));
87+
88+
assertLean(Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes), "formuser");
89+
}
90+
91+
@Test
92+
void federatedDhisOidcUserPrincipal_isLeaned() {
93+
UserDetailsImpl userDetails = userDetails("oidcuser");
94+
Map<String, Object> oidcClaims = Map.of(IdTokenClaimNames.SUB, "google-sub-1");
95+
OidcIdToken idToken =
96+
OidcIdToken.withTokenValue("id-token")
97+
.subject("google-sub-1")
98+
.claims(c -> c.putAll(oidcClaims))
99+
.build();
100+
DhisOidcUser oidcPrincipal =
101+
new DhisOidcUser(userDetails, oidcClaims, IdTokenClaimNames.SUB, idToken);
102+
Map<String, Object> attributes = new LinkedHashMap<>();
103+
attributes.put(
104+
Principal.class.getName(),
105+
new OAuth2AuthenticationToken(oidcPrincipal, oidcPrincipal.getAuthorities(), "google"));
106+
107+
// The DhisOidcUser's getName() is the IdP sub; the leaned principal must carry the DHIS2
108+
// username.
109+
assertLean(Dhis2OAuth2AuthorizationServiceImpl.leanPrincipal(attributes), "oidcuser");
110+
}
111+
112+
private static void assertLean(Map<String, Object> result, String expectedUsername) {
113+
Object principal = result.get(Principal.class.getName());
114+
assertInstanceOf(UsernamePasswordAuthenticationToken.class, principal);
115+
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) principal;
116+
assertEquals(expectedUsername, token.getName());
117+
assertTrue(token.isAuthenticated());
118+
assertEquals(
119+
Set.of("ALL"),
120+
token.getAuthorities().stream()
121+
.map(GrantedAuthority::getAuthority)
122+
.collect(Collectors.toSet()));
123+
}
124+
125+
private static UserDetailsImpl userDetails(String username) {
126+
return UserDetailsImpl.builder()
127+
.uid("uid-" + username)
128+
.username(username)
129+
.authorities(new ArrayList<>(List.of(new SimpleGrantedAuthority("ALL"))))
130+
.allAuthorities(new HashSet<>(Set.of("ALL")))
131+
.allRestrictions(new HashSet<>())
132+
.userGroupIds(new HashSet<>())
133+
.userOrgUnitIds(new HashSet<>())
134+
.userDataOrgUnitIds(new HashSet<>())
135+
.userSearchOrgUnitIds(new HashSet<>())
136+
.userEffectiveSearchOrgUnitIds(new HashSet<>())
137+
.userRoleIds(new HashSet<>())
138+
.managedGroupLongIds(new HashSet<>())
139+
.userRoleLongIds(new HashSet<>())
140+
.build();
141+
}
142+
}

dhis-2/dhis-test-integration/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,11 @@
339339
<artifactId>quick</artifactId>
340340
<scope>test</scope>
341341
</dependency>
342+
<dependency>
343+
<groupId>org.springframework.security</groupId>
344+
<artifactId>spring-security-oauth2-client</artifactId>
345+
<scope>test</scope>
346+
</dependency>
342347
<dependency>
343348
<groupId>org.springframework.security</groupId>
344349
<artifactId>spring-security-oauth2-core</artifactId>

0 commit comments

Comments
 (0)