fix(oauth2): populate DSUserDetails.getAttributes() with provider attributes#285
fix(oauth2): populate DSUserDetails.getAttributes() with provider attributes#285devondragon merged 2 commits intomainfrom
Conversation
… OAuth2/OIDC users Fixes #284. DSUserDetails implemented OAuth2User/OidcUser but getAttributes() always returned an empty map for OAuth2 users and null for OIDC users, silently breaking the standard getAttribute("email") access pattern. Changes: - DSUserDetails: all constructors now initialize attributes correctly. New 3-arg simple constructor accepts provider attributes. OIDC constructor falls back to idToken claims, then User entity fields when no attributes are provided. Added buildFallbackAttributes() helper that maps User entity fields to standard OAuth2 claim names (email, given_name, family_name, name) so getAttributes() is never null on any code path. - LoginHelperService: added userLoginHelper(User, Map) and userLoginHelper(User, OidcUserInfo, OidcIdToken, Map) overloads that forward provider attributes to DSUserDetails. Original overloads kept for backward compatibility (local/password login path). - DSOAuth2UserService.loadUser(): passes original OAuth2User.getAttributes() through to loginHelperService. - DSOidcUserService.loadUser(): passes original OidcUser.getAttributes() through to loginHelperService. Tests: new DSUserDetailsTest (13 tests) covers all constructor paths, fallback behavior, defensive copying, builder, and the getAttribute("email") pattern. LoginHelperServiceTest and DSOAuth2/OidcUserServiceTest updated.
PR Review: fix(oauth2): populate DSUserDetails.getAttributes() with provider attributesThis is a well-structured fix for a real bug. The layered fallback strategy (provider attrs → idToken claims → User entity fields) is sound, backward compatibility is preserved throughout, and the test coverage is solid. A few things worth considering before merge: Issues1. When only if (user.getFirstName() != null || user.getLastName() != null) {
attrs.put("name", user.getFullName()); // ← getFullName() may concatenate a null half
}Recommend checking what String fullName = Stream.of(user.getFirstName(), user.getLastName())
.filter(Objects::nonNull)
.collect(Collectors.joining(" "));
if (!fullName.isEmpty()) {
attrs.put("name", fullName);
}There's currently no test covering the "first name only" or "last name only" scenario. 2. The constructor defensively copies the input ( details.getAttributes().put("injected", "malicious"); // modifies internal stateThe 3. Raw type warning in test matchers when(loginHelperService.userLoginHelper(any(User.class), any(Map.class))) // raw Map.class
verify(loginHelperService).userLoginHelper(any(User.class), any(Map.class))Prefer the type-safe form to suppress the unchecked warning: any(User.class), ArgumentMatchers.<Map<String, Object>>any()Same applies in 4. Per CLAUDE.md conventions, service-layer unit tests should use Minor notes
What's well done
Overall a clean, targeted fix. Resolving items 1 and 2 above before merge would be my main recommendation. |
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #284 by ensuring DSUserDetails.getAttributes() reflects the original OAuth2/OIDC provider attributes (instead of being empty or null), improving Spring Security interoperability with the standard getAttribute(...) pattern.
Changes:
- Propagate provider attribute maps from
DSOAuth2UserService/DSOidcUserServiceintoLoginHelperService, and intoDSUserDetails. - Update
DSUserDetailsconstructors to always initializeattributes, with fallbacks to ID token claims (OIDC) orUserentity fields (local login). - Add/adjust unit tests to cover attribute passthrough and fallback behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetails.java | Adds attribute-aware constructors and fallback attribute building. |
| src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java | Adds overloads to accept provider attributes and pass them into DSUserDetails. |
| src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java | Passes OAuth2User.getAttributes() through to LoginHelperService. |
| src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java | Passes OidcUser.getAttributes() through to LoginHelperService. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java | New unit tests covering constructor paths, fallbacks, and defensive copying. |
| src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java | Adds tests validating attribute passthrough in OAuth2 and OIDC helper flows. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java | Updates mocks to match new LoginHelperService OAuth2 overload. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java | Updates mocks to match new LoginHelperService OIDC overload and asserts attributes present. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| providerAttrs.put("picture", "https://example.com/photo.jpg"); | ||
|
|
||
| when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(testUser); | ||
| when(authorityService.getAuthoritiesFromUser(testUser)).thenReturn((Collection) testAuthorities); |
There was a problem hiding this comment.
These stubbings use raw casts ((Collection) testAuthorities) which will produce unchecked warnings and can hide type mismatches. Prefer declaring testAuthorities with the proper generic type (e.g., Collection<? extends GrantedAuthority>) or using a typed thenReturn without raw casts.
| when(authorityService.getAuthoritiesFromUser(testUser)).thenReturn((Collection) testAuthorities); | |
| when(authorityService.getAuthoritiesFromUser(testUser)).thenReturn(testAuthorities); |
| public DSUserDetails(User user, Collection<? extends GrantedAuthority> grantedAuthorities, Map<String, Object> attributes) { | ||
| this.user = user; | ||
| this.grantedAuthorities = grantedAuthorities != null ? grantedAuthorities : new ArrayList<>(); | ||
| this.attributes = new HashMap<>(); | ||
| this.attributes = attributes != null ? new HashMap<>(attributes) : buildFallbackAttributes(user); | ||
| } |
There was a problem hiding this comment.
attributes is stored as a mutable HashMap and getAttributes() returns the backing map. That makes the authentication principal externally mutable (callers can do getAttributes().put(...)), which contradicts the “immutable” intent and can lead to hard-to-debug security/context issues. Consider storing an unmodifiable map (e.g., Map.copyOf(...)) and/or returning an unmodifiable/copy from getAttributes().
| if (user.getFirstName() != null || user.getLastName() != null) { | ||
| attrs.put("name", user.getFullName()); | ||
| } |
There was a problem hiding this comment.
buildFallbackAttributes() uses user.getFullName() for the standard name claim, but User#getFullName() currently concatenates firstName + " " + lastName and will yield values like "Test null" or "null Smith" when one part is missing. Consider constructing name defensively here (only include non-null parts and trim) so the fallback claim doesn’t contain the literal string "null".
| if (user.getFirstName() != null || user.getLastName() != null) { | |
| attrs.put("name", user.getFullName()); | |
| } | |
| String firstName = user.getFirstName(); | |
| String lastName = user.getLastName(); | |
| StringBuilder fullNameBuilder = new StringBuilder(); | |
| if (firstName != null && !firstName.trim().isEmpty()) { | |
| fullNameBuilder.append(firstName.trim()); | |
| } | |
| if (lastName != null && !lastName.trim().isEmpty()) { | |
| if (fullNameBuilder.length() > 0) { | |
| fullNameBuilder.append(' '); | |
| } | |
| fullNameBuilder.append(lastName.trim()); | |
| } | |
| if (fullNameBuilder.length() > 0) { | |
| attrs.put("name", fullNameBuilder.toString()); | |
| } |
- DSUserDetails.getAttributes(): return Collections.unmodifiableMap so callers cannot mutate the internal map, preventing hard-to-debug security issues - buildFallbackAttributes(): build 'name' claim without calling getFullName() to avoid "Test null" / "null User" values when only one name part is set; now safely skips missing parts and only adds the claim when non-empty - DSUserDetailsTest: add @ExtendWith(MockitoExtension.class) per project conventions; add tests for first-name-only, last-name-only, and unmodifiable-map scenarios - LoginHelperServiceTest: replace raw (Collection) cast with typed field declaration (Collection<? extends GrantedAuthority>) and doReturn() stubs to eliminate unchecked warnings - DSOAuth2UserServiceTest / DSOidcUserServiceTest: replace raw any(Map.class) matchers with typed ArgumentMatchers.<Map<String,Object>>any()
PR Review: fix(oauth2): populate DSUserDetails.getAttributes() with provider attributesThis is a clean, well-structured fix for a real bug. The changes are backward compatible, well-tested, and the overall approach is sound. A few items worth flagging: Issues1. Per CLAUDE.md, the project has custom test annotations that should be used instead of raw Spring/Mockito annotations:
// Current
@ExtendWith(MockitoExtension.class)
@DisplayName("DSUserDetails Tests")
class DSUserDetailsTest {
// Should be (per project conventions)
@ServiceTest
@DisplayName("DSUserDetails Tests")
class DSUserDetailsTest {Minor Observations2. In 3. Existing code using What's Done Well
SummaryThe bug fix itself is correct and the implementation is solid. The main actionable item is aligning |
Summary
Fixes #284.
DSUserDetails.getAttributes()now returns the actual OAuth2/OIDC provider attributes instead of an empty map (OAuth2 path) ornull(OIDC path).attributes. NewbuildFallbackAttributes()maps User entity fields to standard OAuth2 claim names when no provider attributes are available (local login). OIDC constructor falls back to idToken claims when explicit attributes aren't provided.getAttributes()is nevernull.Map<String, Object> attributesfor both OAuth2 and OIDC paths. Original overloads preserved for backward compatibility (local login).loadUser()passes originalOAuth2User.getAttributes()through toLoginHelperService.loadUser()passes originalOidcUser.getAttributes()through toLoginHelperService.Test plan
DSUserDetailsTest(13 tests) covers all constructor paths, fallback behavior, defensive copying, builder, and thegetAttribute("email")patternLoginHelperServiceTest— 4 new tests for attribute passthrough in OAuth2 and OIDC pathsDSOAuth2UserServiceTestandDSOidcUserServiceTestmock stubs for new method signatures./gradlew test)./gradlew checkclean