Fix OIDC service inconsistencies with OAuth2 service#278
Conversation
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
- 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
There was a problem hiding this comment.
Pull request overview
This PR aligns the OIDC authentication flow (DSOidcUserService) with the existing OAuth2 flow (DSOAuth2UserService) to ensure consistent user lookup, auditing, transactional behavior, and post-login side effects (e.g., activity updates / lockout checks).
Changes:
- Normalize OIDC email lookup to lowercase and add an OIDC registration success audit event publication.
- Route OIDC
loadUser()throughLoginHelperService.userLoginHelper()and add transactional boundaries to match OAuth2 behavior. - Add setters on
DSUserDetailsfor OIDC token/userInfo so they can be attached afterLoginHelperServiceconstructs the principal, and update tests to inject the new dependencies.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java |
Aligns OIDC behavior with OAuth2 (email normalization, audit event publish, transactional annotation, and login helper usage). |
src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetails.java |
Allows OIDC token/userInfo to be set post-construction to support the login helper flow. |
src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java |
Updates tests to mock new collaborators and verifies the LoginHelperService path is used. |
src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java |
Updates test wiring to include new mocks required by the service constructor. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| when(loginHelperService.userLoginHelper(any(User.class))).thenAnswer(invocation -> { | ||
| User user = invocation.getArgument(0); | ||
| return new DSUserDetails(user, new ArrayList<>()); | ||
| }); |
There was a problem hiding this comment.
This test stubs loginHelperService.userLoginHelper(...) to return a DSUserDetails with an empty authorities list, and it no longer asserts anything about authorities. Since the production path derives authorities from the DB user roles, consider returning a non-empty authorities collection here and asserting that the resulting DSUserDetails contains the expected authorities so the test continues validating authorization behavior after routing through LoginHelperService.
| /** The Oidc user properties. */ | ||
| @Setter | ||
| private OidcUserInfo oidcUserInfo; | ||
|
|
||
| /** The Oidc user token. */ | ||
| @Setter | ||
| private OidcIdToken oidcIdToken; |
There was a problem hiding this comment.
Exposing public setters on the authentication principal for oidcUserInfo / oidcIdToken makes DSUserDetails mutable after creation, which can lead to partially-initialized principals or accidental overwrites later in the request lifecycle. Consider keeping DSUserDetails immutable by extending LoginHelperService.userLoginHelper(...) (or adding an overload/factory) to accept the OIDC token/userInfo and construct the principal in one step.
| log.debug("handleOidcLoginSuccess: looking up user with email: {}", user.getEmail()); | ||
| User existingUser = userRepository.findByEmail(user.getEmail()); | ||
| User existingUser = userRepository.findByEmail(user.getEmail().toLowerCase()); | ||
| log.debug("handleOidcLoginSuccess: existingUser: {}", existingUser); |
There was a problem hiding this comment.
Email normalization here uses toLowerCase() without trimming or specifying a locale. Using the default locale can produce incorrect results in some environments (e.g., Turkish locale), and leading/trailing whitespace would still cause lookup misses. Consider normalizing with trim() and toLowerCase(Locale.ROOT) once into a local variable and using that for both lookup and guard evaluation/logging.
| @Slf4j | ||
| @Service | ||
| @Transactional | ||
| @RequiredArgsConstructor | ||
| public class DSOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> { |
There was a problem hiding this comment.
Adding @Transactional at the class level means the transaction starts before defaultOidcUserService.loadUser(userRequest) runs, so the DB transaction/connection can be held open while making a network call to the OIDC provider. To reduce contention and improve reliability, consider narrowing the transaction scope to only the DB interaction portion (e.g., using a separate transactional component/method invoked via proxy, or TransactionTemplate around handleOidcLoginSuccess + login helper persistence work).
| AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(user).action("OIDC Registration Success").actionStatus("Success") | ||
| .message("Registration Confirmed. User logged in.").build(); | ||
|
|
||
| eventPublisher.publishEvent(registrationAuditEvent); | ||
| return userRepository.save(user); |
There was a problem hiding this comment.
This new audit event publication is an observable behavior change, but the unit tests don’t currently assert that eventPublisher.publishEvent(...) is called (or that the action/message are correct) during a new OIDC registration. Adding an assertion in the new-user registration tests would prevent regressions in audit logging behavior.
- 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
PR Review: Fix OIDC service inconsistencies with OAuth2 serviceOverall, this is a well-scoped and well-motivated PR. The four changes are clearly described and align DSOIdcUserService correctly with DSOAuth2UserService. The approach is sound. I have a few observations ranging from minor mits to one correctness issue worth addressing. Correctness / Potential Bugs1. @transactional on a private method has no effect (DSOidcUserService line 128) Spring's proxy-based AOP cannot intercept private methods, so this annotation is silently ignored. The same pattern already exists in DSOAuth2UserService line 130, so this is a pre-existing issue that was copied rather than introduced here. Since this PR is explicitly aligning the two classes, it is worth noting. The class-level @transactional already covers this method when called from the public handleOidcLoginSuccess, so the per-method annotation is redundant and misleading. Consider removing it here and in DSOAuth2UserService. 2. Audit event published before userRepository.save() (DSOidcUserService lines 135-139) The audit event fires before the user is actually persisted. If userRepository.save() throws (e.g. a constraint violation on a race condition), the audit log will record a successful registration that never actually happened. DSOAuth2UserService has the same ordering. Since this PR is a deliberate port, it would be cleaner to fix the ordering here (publish after save) rather than faithfully copying a latent bug. Code Quality / Design3. Hard-coded "ROLE_USER" string (DSOidcUserService line 132) DSOAuth2UserService uses the constant USER_ROLE_NAME = "ROLE_USER". The OIDC service should do the same to avoid diverging again in the future. 4. Commented-out dead code (DSOidcUserService lines 170-172) The commented block in getUserFromKeycloakOidc2User is pre-existing but worth cleaning up since this method was touched. Dead code in an actively modified method raises questions for future reviewers. Test Coverage5. assertThat(dsUserDetails.getAuthorities()).isNotEmpty() was removed The mock loginHelperService.userLoginHelper() returns new DSUserDetails(user, new ArrayList<>()) so authorities are always empty, making the assertion impossible without changing the mock. Consider making the mock return a non-empty authorities list and re-adding the assertion to maintain that coverage. 6. No state assertion for lastActivityDate update The PR description calls out updating lastActivityDate as a key behavioral fix. The test verifies loginHelperService.userLoginHelper() is called (good), but there is no assertion that lastActivityDate was actually set on the user passed to it. A verify with an ArgumentCaptor would give stronger confidence. Minor / Style7. Stale log message (DSOidcUserService line 192) The log.debug message says "Loading user from OAuth2 provider" but this is the OIDC service. Should read "Loading user from OIDC provider...". Summary
The core fixes (email normalization, audit event, @transactional, loginHelperService routing) are all correct and necessary. The most actionable items before merge are the removed authority assertion (item 5) and the audit-event-before-save ordering (item 2). |
- Extract USER_ROLE_NAME constant to match DSOAuth2UserService - Fix stale log message: "OAuth2 provider" → "OIDC provider"
PR Review: Fix OIDC service inconsistencies with OAuth2 serviceThis is a solid, well-scoped PR. The four stated fixes (email normalization, audit event, Bug:
|
| Category | Item | Severity |
|---|---|---|
| Bug | attributes is null for OIDC DSUserDetails instances |
Medium |
| Code quality | Double @Builder on two constructors |
Low |
| Consistency | OAuth2 service still uses .toLowerCase() without Locale.ROOT |
Low (follow-up) |
The attributes initialization gap is the most actionable item before this code is exercised in an environment that calls getAttributes() on the OIDC principal (e.g., attribute-based authorization rules).
Summary
Aligns
DSOidcUserServicebehavior withDSOAuth2UserServiceby fixing four inconsistencies:.toLowerCase()to email lookup beforefindByEmail(), preventing case-sensitive mismatches"OIDC Registration Success"audit event on new user registration, matching OAuth2's"OAuth2 Registration Success"event@Transactional: Add@Transactionalat class and method level for database operationsloginHelperServicecall: Route throughloginHelperService.userLoginHelper()inloadUser()solastActivityDateis updated and lockout checks runAlso adds
@SettertoDSUserDetails.oidcUserInfoandoidcIdTokento support setting OIDC tokens afterloginHelperServicecreates the instance.Closes #276
Test plan
DSOidcUserServiceTestandDSOidcUserServiceRegistrationGuardTestto inject new mock dependenciesloadUserintegration test verifiesloginHelperService.userLoginHelper()is called