Skip to content

Commit c028bcd

Browse files
committed
feat(oidc): enrich user profile claims and token subject
1 parent 4d0ce5c commit c028bcd

5 files changed

Lines changed: 327 additions & 34 deletions

File tree

iam/src/main/java/com/workastra/iam/configuration/AuthorizationServerConfiguration.java

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package com.workastra.iam.configuration;
22

3-
import java.util.Map;
4-
import java.util.Objects;
53
import java.util.function.Function;
64
import org.springframework.context.annotation.Bean;
75
import org.springframework.context.annotation.Configuration;
@@ -10,12 +8,10 @@
108
import org.springframework.jdbc.core.JdbcTemplate;
119
import org.springframework.security.config.Customizer;
1210
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
13-
import org.springframework.security.core.Authentication;
1411
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
1512
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
1613
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
1714
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
18-
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
1915
import org.springframework.security.web.SecurityFilterChain;
2016
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
2117
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
@@ -30,35 +26,21 @@ RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate)
3026

3127
@Bean
3228
@Order(1)
33-
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
34-
Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper = (context) -> {
35-
Authentication auth = context.getAuthentication();
36-
Object principal = Objects.requireNonNull(auth.getPrincipal(), "Authentication principal must not be null");
37-
38-
// Only one principal type for now, but switch is intentional.
39-
// Adding a new type later means just appending a new case, without touching or risking the existing logic.
40-
@SuppressWarnings("SwitchStatementWithTooFewBranches")
41-
Map<String, Object> claims = switch (principal) {
42-
case JwtAuthenticationToken jwt -> jwt.getToken().getClaims();
43-
default -> throw new IllegalStateException(
44-
"Unsupported principal type: " + principal.getClass().getName()
45-
);
46-
};
47-
48-
return new OidcUserInfo(claims);
49-
};
50-
29+
SecurityFilterChain authorizationServerSecurityFilterChain(
30+
HttpSecurity http,
31+
Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper
32+
) throws Exception {
5133
http
52-
.oauth2AuthorizationServer((authorizationServer) -> {
34+
.oauth2AuthorizationServer(authorizationServer -> {
5335
http.securityMatcher(authorizationServer.getEndpointsMatcher());
5436

55-
authorizationServer.oidc((oidc) ->
56-
oidc.userInfoEndpoint((userInfo) -> userInfo.userInfoMapper(userInfoMapper))
37+
authorizationServer.oidc(oidc ->
38+
oidc.userInfoEndpoint(userInfo -> userInfo.userInfoMapper(userInfoMapper))
5739
);
5840
})
59-
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
41+
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
6042
// Redirect to the login page when not authenticated from the authorization endpoint
61-
.exceptionHandling((exceptions) ->
43+
.exceptionHandling(exceptions ->
6244
exceptions.defaultAuthenticationEntryPointFor(
6345
new LoginUrlAuthenticationEntryPoint("/login"),
6446
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
@@ -72,7 +54,7 @@ SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) th
7254
@Order(2)
7355
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
7456
http
75-
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
57+
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
7658
// Enable form login with default settings
7759
.formLogin(Customizer.withDefaults());
7860
// Enable OAuth2 federated identity login with default settings
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.workastra.iam.configuration;
2+
3+
import com.workastra.iam.entity.User;
4+
import org.springframework.security.core.Authentication;
5+
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
6+
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
7+
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
8+
import org.springframework.stereotype.Component;
9+
10+
@Component
11+
class TokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
12+
13+
@Override
14+
public void customize(JwtEncodingContext context) {
15+
Authentication principal = context.getPrincipal();
16+
JwtClaimsSet.Builder claims = context.getClaims();
17+
18+
if (principal.getPrincipal() instanceof User userDetails) {
19+
// Override the default "sub" claim with the user's ID instead of username, which is more stable and less likely to change.
20+
claims.subject(userDetails.getId().toString());
21+
}
22+
}
23+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.workastra.iam.configuration;
2+
3+
import com.workastra.iam.entity.User;
4+
import com.workastra.iam.repository.UserRepository;
5+
import java.util.HashMap;
6+
import java.util.Map;
7+
import java.util.Objects;
8+
import java.util.UUID;
9+
import java.util.function.Function;
10+
import org.springframework.security.core.Authentication;
11+
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
12+
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
13+
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
14+
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
15+
import org.springframework.stereotype.Component;
16+
17+
@Component
18+
class UserInfoMapper implements Function<OidcUserInfoAuthenticationContext, OidcUserInfo> {
19+
20+
private final UserRepository userRepository;
21+
22+
UserInfoMapper(UserRepository userRepository) {
23+
this.userRepository = userRepository;
24+
}
25+
26+
@Override
27+
public OidcUserInfo apply(OidcUserInfoAuthenticationContext context) {
28+
Authentication auth = context.getAuthentication();
29+
Object principal = Objects.requireNonNull(auth.getPrincipal(), "Authentication principal must not be null");
30+
31+
// Only one principal type for now, but switch is intentional.
32+
// Adding a new type later means just appending a new case, without touching or risking the existing logic.
33+
@SuppressWarnings("SwitchStatementWithTooFewBranches")
34+
Map<String, Object> info = switch (principal) {
35+
case JwtAuthenticationToken jwt -> this.buildUserInfoClaims(jwt);
36+
default -> throw new IllegalStateException("Unsupported principal type: " + principal.getClass().getName());
37+
};
38+
39+
return new OidcUserInfo(info);
40+
}
41+
42+
private Map<String, Object> buildUserInfoClaims(JwtAuthenticationToken authentication) {
43+
User user = this.userRepository.findById(UUID.fromString(authentication.getName())).orElseThrow();
44+
Map<String, Object> customClaims = new HashMap<>();
45+
46+
customClaims.put(StandardClaimNames.SUB, user.getId());
47+
customClaims.put(StandardClaimNames.PREFERRED_USERNAME, user.getUsername());
48+
customClaims.put(StandardClaimNames.GENDER, user.getGender());
49+
customClaims.put(StandardClaimNames.EMAIL, user.getEmail());
50+
customClaims.put(StandardClaimNames.EMAIL_VERIFIED, user.getEmailVerified());
51+
customClaims.put(StandardClaimNames.NAME, user.getFullName());
52+
53+
if (user.getFamilyName() != null) {
54+
customClaims.put(StandardClaimNames.FAMILY_NAME, user.getFamilyName());
55+
}
56+
57+
if (user.getMiddleName() != null) {
58+
customClaims.put(StandardClaimNames.MIDDLE_NAME, user.getMiddleName());
59+
}
60+
61+
customClaims.put(StandardClaimNames.GIVEN_NAME, user.getGivenName());
62+
customClaims.put(StandardClaimNames.LOCALE, user.getLocale().toLanguageTag());
63+
customClaims.put(StandardClaimNames.ZONEINFO, user.getTimezoneId().getId());
64+
customClaims.put(StandardClaimNames.UPDATED_AT, user.getUpdatedAt().getEpochSecond());
65+
66+
return customClaims;
67+
}
68+
}

iam/src/main/java/com/workastra/iam/entity/User.java

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
package com.workastra.iam.entity;
22

3+
import com.ibm.icu.text.PersonName;
4+
import com.ibm.icu.text.PersonNameFormatter;
5+
import com.ibm.icu.text.PersonNameFormatter.Formality;
6+
import com.ibm.icu.text.PersonNameFormatter.Length;
7+
import com.ibm.icu.text.PersonNameFormatter.Usage;
8+
import com.ibm.icu.text.SimplePersonName;
9+
import jakarta.persistence.Column;
310
import jakarta.persistence.Entity;
411
import jakarta.persistence.GeneratedValue;
512
import jakarta.persistence.Id;
613
import jakarta.persistence.Table;
714
import java.io.Serial;
15+
import java.time.Instant;
16+
import java.time.ZoneId;
817
import java.util.Collection;
918
import java.util.List;
19+
import java.util.Locale;
1020
import java.util.UUID;
21+
import lombok.AccessLevel;
1122
import lombok.AllArgsConstructor;
1223
import lombok.Builder;
24+
import lombok.Getter;
1325
import lombok.NoArgsConstructor;
1426
import org.jspecify.annotations.Nullable;
1527
import org.springframework.security.core.CredentialsContainer;
@@ -21,19 +33,79 @@
2133
@Builder
2234
@NoArgsConstructor
2335
@AllArgsConstructor
36+
@Getter
2437
public class User implements UserDetails, CredentialsContainer {
2538

2639
@Serial
2740
private static final long serialVersionUID = 1L;
2841

2942
@Id
3043
@GeneratedValue
44+
@Column
3145
private UUID id;
3246

47+
@Column
3348
private String username;
3449

50+
@Getter(AccessLevel.NONE)
51+
@Column
3552
private @Nullable String password;
3653

54+
@Column
55+
private @Nullable String familyName;
56+
57+
@Column
58+
private @Nullable String middleName;
59+
60+
@Column
61+
private String givenName;
62+
63+
@Column
64+
private String gender;
65+
66+
@Column
67+
private String email;
68+
69+
@Column
70+
private Boolean emailVerified;
71+
72+
@Column
73+
private Instant emailVerifiedAt;
74+
75+
@Column
76+
private Locale locale;
77+
78+
@Getter(AccessLevel.NONE)
79+
@Column
80+
private Boolean accountNonExpired;
81+
82+
@Getter(AccessLevel.NONE)
83+
@Column
84+
private Boolean accountNonLocked;
85+
86+
@Getter(AccessLevel.NONE)
87+
@Column
88+
private Boolean credentialsNonExpired;
89+
90+
@Getter(AccessLevel.NONE)
91+
@Column
92+
private Boolean enabled;
93+
94+
@Column
95+
private ZoneId timezoneId;
96+
97+
@Column(updatable = false)
98+
private Instant createdAt;
99+
100+
@Column(updatable = false)
101+
private String createdBy;
102+
103+
@Column(updatable = false)
104+
private Instant updatedAt;
105+
106+
@Column
107+
private String updatedBy;
108+
37109
@Override
38110
public Collection<? extends GrantedAuthority> getAuthorities() {
39111
return List.of();
@@ -53,4 +125,47 @@ public String getUsername() {
53125
public void eraseCredentials() {
54126
this.password = null;
55127
}
128+
129+
@Override
130+
public boolean isAccountNonExpired() {
131+
return this.accountNonExpired;
132+
}
133+
134+
@Override
135+
public boolean isAccountNonLocked() {
136+
return this.accountNonLocked;
137+
}
138+
139+
@Override
140+
public boolean isCredentialsNonExpired() {
141+
return this.credentialsNonExpired;
142+
}
143+
144+
@Override
145+
public boolean isEnabled() {
146+
return this.enabled;
147+
}
148+
149+
public String getFullName() {
150+
SimplePersonName.Builder personNameBuilder = SimplePersonName.builder()
151+
.setLocale(this.locale)
152+
.addField(PersonName.NameField.GIVEN, null, this.givenName);
153+
154+
if (this.familyName != null) {
155+
personNameBuilder.addField(PersonName.NameField.SURNAME, null, this.familyName);
156+
}
157+
158+
if (this.middleName != null) {
159+
personNameBuilder.addField(PersonName.NameField.GIVEN2, null, this.middleName);
160+
}
161+
162+
PersonNameFormatter formatter = PersonNameFormatter.builder()
163+
.setLocale(this.locale)
164+
.setLength(Length.LONG)
165+
.setUsage(Usage.REFERRING)
166+
.setFormality(Formality.FORMAL)
167+
.build();
168+
169+
return formatter.formatToString(personNameBuilder.build());
170+
}
56171
}

0 commit comments

Comments
 (0)