Skip to content

Commit cd398b5

Browse files
authored
feat: support federated OIDC login through the OAuth2 Authorization Server (#24050)
1 parent 7051ccf commit cd398b5

7 files changed

Lines changed: 475 additions & 71 deletions

File tree

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

Lines changed: 0 additions & 70 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,74 +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("dataViewMaxOrganisationUnitLevel") int dataViewMaxOrganisationUnitLevel,
75-
@JsonProperty("authorities") Collection<GrantedAuthority> authorities,
76-
@JsonProperty("allAuthorities") Set<String> allAuthorities,
77-
@JsonProperty("allRestrictions") Set<String> allRestrictions,
78-
@JsonProperty("userGroupIds") Set<String> userGroupIds,
79-
@JsonProperty("userOrgUnitIds") Set<String> userOrgUnitIds,
80-
@JsonProperty("userDataOrgUnitIds") Set<String> userDataOrgUnitIds,
81-
@JsonProperty("userSearchOrgUnitIds") Set<String> userSearchOrgUnitIds,
82-
@JsonProperty("userEffectiveSearchOrgUnitIds") Set<String> userEffectiveSearchOrgUnitIds,
83-
@JsonProperty("isSuper") boolean isSuper,
84-
@JsonProperty("userRoleIds") Set<String> userRoleIds,
85-
@JsonProperty("managedGroupLongIds") Set<Long> managedGroupLongIds,
86-
@JsonProperty("userRoleLongIds") Set<Long> userRoleLongIds) {
87-
return UserDetailsImpl.builder()
88-
.uid(uid)
89-
.code(code)
90-
.username(username)
91-
.firstName(firstName)
92-
.surname(surname)
93-
.password(password)
94-
.externalAuth(externalAuth)
95-
.isTwoFactorEnabled(isTwoFactorEnabled)
96-
.twoFactorType(twoFactorType)
97-
.secret(secret)
98-
.email(email)
99-
.isEmailVerified(isEmailVerified)
100-
.enabled(enabled)
101-
.accountNonExpired(accountNonExpired)
102-
.accountNonLocked(accountNonLocked)
103-
.credentialsNonExpired(credentialsNonExpired)
104-
.dataViewMaxOrganisationUnitLevel(dataViewMaxOrganisationUnitLevel)
105-
.authorities(authorities)
106-
.allAuthorities(allAuthorities)
107-
.allRestrictions(allRestrictions)
108-
.userGroupIds(userGroupIds)
109-
.userOrgUnitIds(userOrgUnitIds)
110-
.userDataOrgUnitIds(userDataOrgUnitIds)
111-
.userSearchOrgUnitIds(userSearchOrgUnitIds)
112-
.userEffectiveSearchOrgUnitIds(userEffectiveSearchOrgUnitIds)
113-
.isSuper(isSuper)
114-
.userRoleIds(userRoleIds)
115-
.managedGroupLongIds(managedGroupLongIds)
116-
.userRoleLongIds(userRoleLongIds)
117-
.build();
118-
}
119-
12050
private final String uid;
12151
@Setter private Long id;
12252
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;
@@ -381,7 +384,7 @@ private Dhis2OAuth2Authorization toEntity(OAuth2Authorization authorization) {
381384
entity.setAuthorizationGrantType(authorization.getAuthorizationGrantType().getValue());
382385
entity.setAuthorizedScopes(
383386
StringUtils.collectionToCommaDelimitedString(authorization.getAuthorizedScopes()));
384-
entity.setAttributes(writeMap(authorization.getAttributes()));
387+
entity.setAttributes(writeMap(leanPrincipal(authorization.getAttributes())));
385388
entity.setState(authorization.getAttribute(OAuth2ParameterNames.STATE));
386389

387390
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =
@@ -483,6 +486,41 @@ private Map<String, Object> parseMap(String data) {
483486
}
484487
}
485488

489+
/**
490+
* Replaces a heavyweight DHIS2 principal in the authorization attributes with a lean,
491+
* Spring-native one before persistence.
492+
*
493+
* <p>After a federated OIDC login the {@code java.security.Principal} attribute is an {@link
494+
* org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken} whose
495+
* principal is a {@link org.hisp.dhis.security.oidc.DhisOidcUser} wrapping a {@link
496+
* org.hisp.dhis.user.UserDetailsImpl}; a form login carries a {@code UserDetailsImpl} directly.
497+
* Spring Authorization Server only ever reads the principal's {@link Authentication#getName()
498+
* name} (the JWT/token customizer) and re-loads the user from the database on token validation,
499+
* so the heavyweight object graph is dead weight in the row and the reason those DHIS2-custom
500+
* types tripped {@link SecurityJackson2Modules}' deserialization allowlist. Swapping it for a
501+
* {@link UsernamePasswordAuthenticationToken} carrying just the DHIS2 username and authorities
502+
* keeps the persisted attributes entirely Spring-native, so they round-trip without any custom
503+
* Jackson mixins. {@code principalName} is left untouched (the consent store keys on it). The
504+
* token's {@code authorizedClientRegistrationId} (e.g. {@code "google"}) and the authentication
505+
* {@code details} are intentionally not retained — nothing in the token-issuance or validation
506+
* path reads them once the user is identified by name.
507+
*
508+
* <p>Package-private (rather than {@code private}) so the branch logic can be unit-tested
509+
* directly.
510+
*/
511+
static Map<String, Object> leanPrincipal(Map<String, Object> attributes) {
512+
if (attributes.get(Principal.class.getName()) instanceof Authentication authentication
513+
&& authentication.getPrincipal() instanceof UserDetails userDetails) {
514+
Map<String, Object> lean = new LinkedHashMap<>(attributes);
515+
lean.put(
516+
Principal.class.getName(),
517+
new UsernamePasswordAuthenticationToken(
518+
userDetails.getUsername(), null, userDetails.getAuthorities()));
519+
return lean;
520+
}
521+
return attributes;
522+
}
523+
486524
/**
487525
* Converts a Map to a JSON string.
488526
*
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
@@ -352,6 +352,11 @@
352352
<artifactId>quick</artifactId>
353353
<scope>test</scope>
354354
</dependency>
355+
<dependency>
356+
<groupId>org.springframework.security</groupId>
357+
<artifactId>spring-security-oauth2-client</artifactId>
358+
<scope>test</scope>
359+
</dependency>
355360
<dependency>
356361
<groupId>org.springframework.security</groupId>
357362
<artifactId>spring-security-oauth2-core</artifactId>

0 commit comments

Comments
 (0)