Skip to content

Commit edabaa7

Browse files
authored
Fix OIDC service inconsistencies with OAuth2 service (#278)
* fix(oidc): align DSOidcUserService behavior with DSOAuth2UserService Fixes four inconsistencies identified in issue #276: - Add .toLowerCase() to email lookup in handleOidcLoginSuccess() to normalize email case before findByEmail(), matching OAuth2 behavior - Add @transactional at class level and on registerNewOidcUser() to ensure database operations are transactional, matching OAuth2 behavior - Publish AuditEvent on new OIDC user registration (\"OIDC Registration Success\"), matching the OAuth2 \"OAuth2 Registration Success\" event - Call loginHelperService.userLoginHelper() in loadUser() to update lastActivityDate and run lockout checks; set OIDC-specific tokens (idToken, userInfo) via new @Setter fields on DSUserDetails Add @Setter to DSUserDetails.oidcUserInfo and oidcUserInfo to support setting OIDC tokens after loginHelperService creates the instance. Update DSOidcUserServiceTest and DSOidcUserServiceRegistrationGuardTest to inject @mock LoginHelperService and ApplicationEventPublisher. Closes #276 * fix: address PR review feedback from RegistrationGuard SPI (#271) - Include denial reason in OAuth2Error description field so AuthenticationFailureHandlers can access it via getError().getDescription() in both DSOAuth2UserService and DSOidcUserService - Replace String.trim().isEmpty() with String.isBlank() in RegistrationDecision.deny() (Java 17+ idiomatic) - Extract DEFAULT_DENIAL_REASON constant in RegistrationDecision - Add @FunctionalInterface to RegistrationGuard interface - Add JavaDoc clarifying RegistrationDecision reason is only meaningful when allowed is false - Add RegistrationContextTest covering null source validation * fix: address PR #278 review feedback - Add LoginHelperService.userLoginHelper(User, OidcUserInfo, OidcIdToken) overload so DSOidcUserService can build an immutable DSUserDetails with OIDC tokens in one step, eliminating the need for @Setter on DSUserDetails fields - Remove @Setter from DSUserDetails.oidcUserInfo / oidcIdToken to restore immutability - Remove @transactional from private registerNewOidcUser/registerNewOAuthUser methods (silently ignored by Spring AOP proxy; class-level @transactional covers them) - Swap audit event publication to after save() in both services to prevent false-positive audit records when save throws (e.g. unique-constraint violation) - Use trim() + toLowerCase(Locale.ROOT) for email normalization to handle locale edge cases and leading/trailing whitespace in both extraction and lookup - Fix import ordering in DSOidcUserService.java to alphabetical per CLAUDE.md - Add JavaDoc on loginHelperService field in DSOidcUserService - Update DSOidcUserServiceTest: use 3-arg userLoginHelper mock, assert non-empty authorities, assert eventPublisher.publishEvent() called on new registration, update shouldPreserveOidcTokensInDSUserDetails to use the new constructor path * chore: minor cleanups in DSOidcUserService - Extract USER_ROLE_NAME constant to match DSOAuth2UserService - Fix stale log message: "OAuth2 provider" → "OIDC provider"
1 parent 1f1c917 commit edabaa7

File tree

8 files changed

+155
-51
lines changed

8 files changed

+155
-51
lines changed

src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDecision.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@
44
* The result of a {@link RegistrationGuard} evaluation indicating whether
55
* a registration attempt should be allowed or denied.
66
*
7+
* <p>Use the static factory methods {@link #allow()} and {@link #deny(String)}
8+
* rather than the canonical constructor. The {@code reason} parameter is only
9+
* meaningful when {@code allowed} is {@code false}.</p>
10+
*
711
* @param allowed whether the registration is permitted
8-
* @param reason a human-readable denial reason (may be {@code null} when allowed)
12+
* @param reason a human-readable denial reason; only meaningful when {@code allowed}
13+
* is {@code false}, may be {@code null} when allowed
914
*/
1015
public record RegistrationDecision(boolean allowed, String reason) {
1116

17+
/** Default reason used when {@link #deny(String)} is called with a blank or null reason. */
18+
private static final String DEFAULT_DENIAL_REASON = "Registration denied.";
19+
1220
/**
1321
* Creates a decision that allows the registration to proceed.
1422
*
@@ -25,8 +33,8 @@ public static RegistrationDecision allow() {
2533
* @return a denying decision with the given reason
2634
*/
2735
public static RegistrationDecision deny(String reason) {
28-
if (reason == null || reason.trim().isEmpty()) {
29-
reason = "Registration denied.";
36+
if (reason == null || reason.isBlank()) {
37+
reason = DEFAULT_DENIAL_REASON;
3038
}
3139
return new RegistrationDecision(false, reason);
3240
}

src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuard.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
* @see RegistrationDecision
4141
* @see DefaultRegistrationGuard
4242
*/
43+
@FunctionalInterface
4344
public interface RegistrationGuard {
4445

4546
/**

src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ public User handleOAuthLoginSuccess(String registrationId, OAuth2User oAuth2User
110110
log.info("Registration denied for email: {} source: OAUTH2 provider: {} reason: {}",
111111
user.getEmail(), registrationId, decision.reason());
112112
throw new OAuth2AuthenticationException(
113-
new OAuth2Error("registration_denied"), decision.reason());
113+
new OAuth2Error("registration_denied", decision.reason(), null), decision.reason());
114114
}
115115
user = registerNewOAuthUser(registrationId, user);
116116
return user;
@@ -126,18 +126,17 @@ public User handleOAuthLoginSuccess(String registrationId, OAuth2User oAuth2User
126126
* @param user The User object representing the authenticated user.
127127
* @return A User object representing the newly registered user.
128128
*/
129-
@Transactional
130129
private User registerNewOAuthUser(String registrationId, User user) {
131130
User.Provider provider = User.Provider.valueOf(registrationId.toUpperCase());
132131
user.setProvider(provider);
133132
user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME)));
134133
// We will trust OAuth2 providers to provide us with a verified email address.
135134
user.setEnabled(true);
136-
AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(user).action("OAuth2 Registration Success").actionStatus("Success")
135+
User savedUser = userRepository.save(user);
136+
AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(savedUser).action("OAuth2 Registration Success").actionStatus("Success")
137137
.message("Registration Confirmed. User logged in.").build();
138-
139138
eventPublisher.publishEvent(registrationAuditEvent);
140-
return userRepository.save(user);
139+
return savedUser;
141140
}
142141

143142
/**

src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
package com.digitalsanctuary.spring.user.service;
22

33
import java.util.Arrays;
4-
import com.digitalsanctuary.spring.user.persistence.model.User;
5-
import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository;
6-
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
7-
import com.digitalsanctuary.spring.user.registration.RegistrationContext;
8-
import com.digitalsanctuary.spring.user.registration.RegistrationDecision;
9-
import com.digitalsanctuary.spring.user.registration.RegistrationGuard;
10-
import com.digitalsanctuary.spring.user.registration.RegistrationSource;
11-
import lombok.RequiredArgsConstructor;
12-
import lombok.extern.slf4j.Slf4j;
4+
import java.util.Locale;
5+
import org.springframework.context.ApplicationEventPublisher;
136
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
147
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
158
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
@@ -19,6 +12,17 @@
1912
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
2013
import org.springframework.security.oauth2.core.user.OAuth2User;
2114
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
import com.digitalsanctuary.spring.user.audit.AuditEvent;
17+
import com.digitalsanctuary.spring.user.persistence.model.User;
18+
import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository;
19+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
20+
import com.digitalsanctuary.spring.user.registration.RegistrationContext;
21+
import com.digitalsanctuary.spring.user.registration.RegistrationDecision;
22+
import com.digitalsanctuary.spring.user.registration.RegistrationGuard;
23+
import com.digitalsanctuary.spring.user.registration.RegistrationSource;
24+
import lombok.RequiredArgsConstructor;
25+
import lombok.extern.slf4j.Slf4j;
2226

2327
/**
2428
* OIDC user service implementation for handling OpenID Connect authentication (Keycloak).
@@ -36,17 +40,27 @@
3640
*/
3741
@Slf4j
3842
@Service
43+
@Transactional
3944
@RequiredArgsConstructor
4045
public class DSOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
4146

4247
/** The user repository. */
4348
private final UserRepository userRepository;
44-
49+
4550
/** The role repository. */
4651
private final RoleRepository roleRepository;
4752

53+
/** The login helper service. */
54+
private final LoginHelperService loginHelperService;
55+
4856
private final RegistrationGuard registrationGuard;
4957

58+
/** The Event Publisher. */
59+
private final ApplicationEventPublisher eventPublisher;
60+
61+
/** The user role name. */
62+
private static final String USER_ROLE_NAME = "ROLE_USER";
63+
5064
OidcUserService defaultOidcUserService = new OidcUserService();
5165

5266
/**
@@ -78,8 +92,11 @@ public User handleOidcLoginSuccess(String registrationId, OidcUser oidcUser) {
7892
throw new OAuth2AuthenticationException(new OAuth2Error("Missing Email"),
7993
"Unable to retrieve email address from " + registrationId + ". Please ensure you have granted email permissions.");
8094
}
81-
log.debug("handleOidcLoginSuccess: looking up user with email: {}", user.getEmail());
82-
User existingUser = userRepository.findByEmail(user.getEmail());
95+
// Normalize email for consistent lookup — getUserFromKeycloakOidc2User already lowercases,
96+
// but we normalize again here defensively in case additional sources are added.
97+
String normalizedEmail = user.getEmail().trim().toLowerCase(Locale.ROOT);
98+
log.debug("handleOidcLoginSuccess: looking up user with email: {}", normalizedEmail);
99+
User existingUser = userRepository.findByEmail(normalizedEmail);
83100
log.debug("handleOidcLoginSuccess: existingUser: {}", existingUser);
84101
if (existingUser != null && registrationId != null) {
85102
log.debug("handleOidcLoginSuccess: existingUser.getProvider(): {}", existingUser.getProvider());
@@ -93,14 +110,14 @@ public User handleOidcLoginSuccess(String registrationId, OidcUser oidcUser) {
93110
existingUser = updateExistingUser(existingUser, user);
94111
return userRepository.save(existingUser);
95112
} else {
96-
log.debug("handleOidcLoginSuccess: registering new user with email: {}", user.getEmail());
113+
log.debug("handleOidcLoginSuccess: registering new user with email: {}", normalizedEmail);
97114
RegistrationDecision decision = registrationGuard.evaluate(
98-
new RegistrationContext(user.getEmail(), RegistrationSource.OIDC, registrationId));
115+
new RegistrationContext(normalizedEmail, RegistrationSource.OIDC, registrationId));
99116
if (!decision.allowed()) {
100117
log.info("Registration denied for email: {} source: OIDC provider: {} reason: {}",
101-
user.getEmail(), registrationId, decision.reason());
118+
normalizedEmail, registrationId, decision.reason());
102119
throw new OAuth2AuthenticationException(
103-
new OAuth2Error("registration_denied"), decision.reason());
120+
new OAuth2Error("registration_denied", decision.reason(), null), decision.reason());
104121
}
105122
user = registerNewOidcUser(registrationId, user);
106123
return user;
@@ -119,10 +136,14 @@ public User handleOidcLoginSuccess(String registrationId, OidcUser oidcUser) {
119136
private User registerNewOidcUser(String registrationId, User user) {
120137
User.Provider provider = User.Provider.valueOf(registrationId.toUpperCase());
121138
user.setProvider(provider);
122-
user.setRoles(Arrays.asList(roleRepository.findByName("ROLE_USER")));
123-
// We will trust OAuth2 providers to provide us with a verified email address.
139+
user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME)));
140+
// We will trust OIDC providers to provide us with a verified email address.
124141
user.setEnabled(true);
125-
return userRepository.save(user);
142+
User savedUser = userRepository.save(user);
143+
AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(savedUser).action("OIDC Registration Success").actionStatus("Success")
144+
.message("Registration Confirmed. User logged in.").build();
145+
eventPublisher.publishEvent(registrationAuditEvent);
146+
return savedUser;
126147
}
127148

128149
/**
@@ -153,11 +174,8 @@ public User getUserFromKeycloakOidc2User(OidcUser principal) {
153174
}
154175
log.debug("Principal attributes: {}", principal.getAttributes());
155176
User user = new User();
156-
/* user.setEmail(principal.getAttribute("email"));
157-
user.setFirstName(principal.getAttribute("given_name"));
158-
user.setLastName(principal.getAttribute("family_name"));*/
159177
String email = principal.getEmail();
160-
user.setEmail(email != null ? email.toLowerCase() : null);
178+
user.setEmail(email != null ? email.trim().toLowerCase(Locale.ROOT) : null);
161179
user.setFirstName(principal.getGivenName());
162180
user.setLastName(principal.getFamilyName());
163181
user.setProvider(User.Provider.KEYCLOAK);
@@ -175,17 +193,11 @@ public User getUserFromKeycloakOidc2User(OidcUser principal) {
175193
*/
176194
@Override
177195
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
178-
log.debug("Loading user from OAuth2 provider with userRequest: {}", userRequest);
196+
log.debug("Loading user from OIDC provider with userRequest: {}", userRequest);
179197
OidcUser user = defaultOidcUserService.loadUser(userRequest);
180198
String registrationId = userRequest.getClientRegistration().getRegistrationId();
181199
log.debug("registrationId: {}", registrationId);
182200
User dbUser = handleOidcLoginSuccess(registrationId, user);
183-
DSUserDetails dsUserDetails = DSUserDetails.builder()
184-
.user(dbUser)
185-
.oidcUserInfo(user.getUserInfo())
186-
.oidcIdToken(user.getIdToken())
187-
.grantedAuthorities(user.getAuthorities())
188-
.build();
189-
return dsUserDetails;
201+
return loginHelperService.userLoginHelper(dbUser, user.getUserInfo(), user.getIdToken());
190202
}
191203
}

src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import java.util.Collection;
44
import java.util.Date;
55
import org.springframework.security.core.GrantedAuthority;
6+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
7+
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
68
import org.springframework.stereotype.Service;
79
import org.springframework.transaction.annotation.Transactional;
810
import com.digitalsanctuary.spring.user.persistence.model.User;
@@ -49,4 +51,24 @@ public DSUserDetails userLoginHelper(User dbUser) {
4951
DSUserDetails userDetails = new DSUserDetails(dbUser, authorities);
5052
return userDetails;
5153
}
54+
55+
/**
56+
* Helper method to authenticate an OIDC user after login, attaching the OIDC-specific tokens
57+
* and claims to the principal while keeping {@link DSUserDetails} immutable.
58+
*
59+
* @param dbUser The user to authenticate.
60+
* @param oidcUserInfo The OIDC user info claims.
61+
* @param oidcIdToken The OIDC ID token.
62+
* @return The user details object with OIDC tokens set.
63+
*/
64+
public DSUserDetails userLoginHelper(User dbUser, OidcUserInfo oidcUserInfo, OidcIdToken oidcIdToken) {
65+
// Updating lastActivity date for this login
66+
dbUser.setLastActivityDate(new Date());
67+
68+
// Check if the user account is locked, but should be unlocked now, and unlock it
69+
dbUser = loginAttemptService.checkIfUserShouldBeUnlocked(dbUser);
70+
71+
Collection<? extends GrantedAuthority> authorities = authorityService.getAuthoritiesFromUser(dbUser);
72+
return new DSUserDetails(dbUser, oidcUserInfo, oidcIdToken, authorities);
73+
}
5274
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import org.junit.jupiter.api.DisplayName;
7+
import org.junit.jupiter.api.Test;
8+
9+
@DisplayName("RegistrationContext Tests")
10+
class RegistrationContextTest {
11+
12+
@Test
13+
@DisplayName("Should reject null source in RegistrationContext")
14+
void shouldRejectNullSource() {
15+
assertThatThrownBy(() -> new RegistrationContext("user@example.com", null, null))
16+
.isInstanceOf(NullPointerException.class)
17+
.hasMessageContaining("source must not be null");
18+
}
19+
20+
@Test
21+
@DisplayName("Should accept valid context with all fields")
22+
void shouldAcceptValidContext() {
23+
RegistrationContext context = new RegistrationContext("user@example.com", RegistrationSource.OAUTH2, "google");
24+
25+
assertThat(context.email()).isEqualTo("user@example.com");
26+
assertThat(context.source()).isEqualTo(RegistrationSource.OAUTH2);
27+
assertThat(context.providerName()).isEqualTo("google");
28+
}
29+
30+
@Test
31+
@DisplayName("Should accept null email and null providerName")
32+
void shouldAcceptNullEmailAndProvider() {
33+
RegistrationContext context = new RegistrationContext(null, RegistrationSource.FORM, null);
34+
35+
assertThat(context.email()).isNull();
36+
assertThat(context.source()).isEqualTo(RegistrationSource.FORM);
37+
assertThat(context.providerName()).isNull();
38+
}
39+
}

src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.mockito.InjectMocks;
1616
import org.mockito.Mock;
1717
import org.mockito.junit.jupiter.MockitoExtension;
18+
import org.springframework.context.ApplicationEventPublisher;
1819
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
1920
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
2021

@@ -37,9 +38,15 @@ class DSOidcUserServiceRegistrationGuardTest {
3738
@Mock
3839
private RoleRepository roleRepository;
3940

41+
@Mock
42+
private LoginHelperService loginHelperService;
43+
4044
@Mock
4145
private RegistrationGuard registrationGuard;
4246

47+
@Mock
48+
private ApplicationEventPublisher eventPublisher;
49+
4350
@InjectMocks
4451
private DSOidcUserService service;
4552

0 commit comments

Comments
 (0)