Skip to content

Commit 4ef2ead

Browse files
committed
feat: OIDC extract claims from OIDC IdToken, UserInfo Endpoint and User attributes
1 parent d309a78 commit 4ef2ead

File tree

5 files changed

+170
-12
lines changed

5 files changed

+170
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
* Backend: Spring Boot 3.4.3, Spring Modulith 1.3.2, Hibernate 6.6.8.Final
1313
* Backend: Gradle Plugins: Spring Dependency Plugin 1.1.7, Spotless 7.0.2, CycloneDX 2.1.0, Ben Names Update Plugin 0.52.0
1414
* Backend: Build tool Gradle 8.12, BouncyCastle 1.80
15+
* Backend: OIDC: Support extraction of claims from IdToken, EndUser Endpoint and end user attributes. Claims are converted to Granted Authorities (roles) thart can natively be used in Spring for authorizing access
1516
* Frontend: Angular 19.1.1
1617
* Container: Remove JDK parameter for generational ZGC as it will be anyway the [only possible in upcoming JDKs](https://openjdk.org/jeps/474).
1718

backend/docs/CONFIGURE.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,18 +261,26 @@ Example:
261261
application:
262262
saml2:
263263
samlRoleAttributeName: "groups" # the SAML Assertation Attribute that contains the role(s)
264-
samlRoleAttributeSeparator: "," # if the SAML Asseration Attribute value contains multiple roles then you can specify the separator (if the roles are in multiple attributes then you can ignore it)
264+
samlRoleAttributeSeparator: "," # if the SAML Asseration Attribute value contains multiple roles then you can specify the separator (if the roles are in multiple attributes then you can ignore it)
265265
```
266266

267267
You can find a complete example in [](../../config/config-saml2.yml).
268268

269-
## OIDC Role Mapping
270-
By default OIDC claims are made available as a Spring Authority with the prefix "SCOPE_". You can configure any other prefix as follows.
269+
## OIDC Claims to Role Mapping
270+
By default OIDC claims "scope, scp" are made available as a Spring Authority with the prefix "SCOPE_". These come from the [OIDC IdToken](https://openid.net/specs/openid-connect-core-1_0-final.html#StandardClaims). However, often additional claims are needed for Spring Security Authorities (roles), e.g. "groups" in a user directory. Those usually do not come from the OIDC IdToken, but only from the [UserInfo Endpoint](https://openid.net/specs/openid-connect-core-1_0-final.html#UserInfoResponse). You can configure here for both, IdToken and UserInfo endpoint, which claims should be mapped to Spring Security Authorities. Furthermore, you can configure for each claim how they are mapped to authorities. By default, it is assumed that the claims are JSON String arrays, but in case they are string you can define how they are extracted from the String using the claimsSeparatorMap. For example, lets assume the claim "groups" is returned by the UserInfo Endpoint as one String representing a comma-separated list groups. You can define as a separator the "," and the claim is then split accordingly so that you do not have the list of groups as one Spring Security Authority, but multiple representing each one of the groups.
271271

272+
Independent of this you can also map user attributes to Spring Security Authorities.
273+
274+
Finally, you can optionally define a prefix for each claim in the Spring Security Authorities. If you do not want any prefix just specify an empty String.
275+
```
272276
oidc:
273277
mapper: # map jwt claims to Spring Security authorities
274-
jwtRoleClaims: ["scope","scp"] # JWT claims that contain authorities
275-
authoritiesPrefix: "SCOPE_" # Prefix for Spring Security Authorities
278+
jwtIdTokenClaims: ["scope","scp"] # claims from a standard OIDC IdToken https://openid.net/specs/openid-connect-core-1_0-final.html#StandardClaims
279+
userClaims: ["groups"] # claims from the UserInfo OIDC Endpoint (https://openid.net/specs/openid-connect-core-1_0-final.html#UserInfoResponse)
280+
userAttributes: [] # user attributes to be mapped to Spring Security Authorities
281+
claimsSeparatorMap: {"scope": " ", "scp": " ", "groups": ","} # separator if claims are one string, otherwise a JSON array is assumed
282+
authoritiesPrefix: "ROLE_" # Prefix for Spring Security Authorities
283+
```
276284
## Web Security Headers
277285
Web Security Headers are an additional line of defense to enable specific protection mechanisms against attacks (e.g. cross-site scripting) in the browser of the user.
278286

backend/src/main/java/eu/zuinnote/example/springwebdemo/configuration/SecurityConfigurationOidc.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,27 @@
33
import static org.springframework.security.config.Customizer.withDefaults;
44

55
import eu.zuinnote.example.springwebdemo.configuration.application.ApplicationConfig;
6+
import java.util.Arrays;
7+
import java.util.Collection;
8+
import java.util.HashMap;
9+
import java.util.HashSet;
10+
import java.util.Map;
11+
import java.util.Set;
12+
import java.util.stream.Collectors;
613
import lombok.extern.log4j.Log4j2;
714
import org.springframework.beans.factory.annotation.Autowired;
815
import org.springframework.context.annotation.Bean;
916
import org.springframework.context.annotation.Configuration;
1017
import org.springframework.context.annotation.Profile;
1118
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1219
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
20+
import org.springframework.security.core.GrantedAuthority;
21+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
22+
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
23+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
24+
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
25+
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
26+
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
1327
import org.springframework.security.web.SecurityFilterChain;
1428

1529
@Configuration
@@ -37,4 +51,108 @@ SecurityFilterChain app(HttpSecurity http) throws Exception {
3751
this.generalSecurityConfiguration.setRequireSecure(http);
3852
return http.build();
3953
}
54+
55+
/*
56+
* Custom OIDC claim to Spring GrantedAuthority mapper so that they can be used natively in Spring.
57+
*
58+
* See: https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-map-authorities
59+
*
60+
*/
61+
@Bean
62+
public GrantedAuthoritiesMapper userAuthoritiesMapper() {
63+
return (authorities) -> {
64+
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
65+
66+
authorities.forEach(
67+
authority -> {
68+
if (OidcUserAuthority.class.isInstance(authority)) {
69+
OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
70+
71+
OidcIdToken idToken = oidcUserAuthority.getIdToken();
72+
OidcUserInfo userInfo = oidcUserAuthority.getUserInfo();
73+
74+
// map all claims from the IdToken
75+
for (String idTokenClaim :
76+
this.config.getOidc().getMapper().getJwtIdTokenClaims()) {
77+
Object claim = idToken.getClaim(idTokenClaim);
78+
mappedAuthorities.addAll(
79+
this.parseClaim("IdToken", idTokenClaim, claim));
80+
}
81+
// map all claims from the EndUser Endpoint
82+
for (String endUserEndpointClaim :
83+
this.config.getOidc().getMapper().getUserClaims()) {
84+
Object claim = userInfo.getClaim(endUserEndpointClaim);
85+
mappedAuthorities.addAll(
86+
this.parseClaim(
87+
"EndUser Endpoint", endUserEndpointClaim, claim));
88+
}
89+
90+
} else if (OAuth2UserAuthority.class.isInstance(authority)) {
91+
OAuth2UserAuthority oauth2UserAuthority =
92+
(OAuth2UserAuthority) authority;
93+
94+
Map<String, Object> userAttributes =
95+
oauth2UserAuthority.getAttributes();
96+
97+
// Map the attributes found in userAttributes
98+
for (String userAttribute :
99+
this.config.getOidc().getMapper().getUserAttributes()) {
100+
Object claim = userAttributes.get(userAttribute);
101+
mappedAuthorities.addAll(
102+
this.parseClaim(
103+
"EndUser Attributes", userAttribute, claim));
104+
}
105+
}
106+
});
107+
108+
return mappedAuthorities;
109+
};
110+
}
111+
112+
/* Parses a claim and converts it to a set of GrantedAuthority
113+
*
114+
* @type from where claim comes from (e.g. IdToken, UserInfoEndpoint, UserAtttribute)
115+
* @claim name of the claim (e.g. scope)
116+
* @claimValue value of the claim
117+
*
118+
*/
119+
private Set<GrantedAuthority> parseClaim(String type, String claim, Object claimValue) {
120+
Set<GrantedAuthority> result = new HashSet<>();
121+
String authorityPrefix = this.config.getOidc().getMapper().getAuthoritiesPrefix();
122+
if (claimValue != null) {
123+
if (claimValue instanceof String) {
124+
HashMap<String, String> separatorMap =
125+
this.config.getOidc().getMapper().getClaimsSeparatorMap();
126+
if ((separatorMap != null)
127+
&& separatorMap.containsKey(
128+
claim)) { // check if we should parse the a list from the claim
129+
String separator =
130+
this.config.getOidc().getMapper().getClaimsSeparatorMap().get(claim);
131+
132+
result.addAll(
133+
Arrays.asList(claimValue.toString().split(separator)).stream()
134+
.map(s -> authorityPrefix + s)
135+
.map(SimpleGrantedAuthority::new)
136+
.collect(Collectors.toCollection(HashSet::new)));
137+
} else {
138+
result.add(new SimpleGrantedAuthority(authorityPrefix + claimValue.toString()));
139+
}
140+
141+
} else if (claimValue
142+
instanceof Collection) { // claim is already a list so simply converted them to
143+
// GrantedAuthority
144+
result.addAll(
145+
((Collection<?>) claimValue)
146+
.stream()
147+
.map(Object::toString)
148+
.map(s -> authorityPrefix + s)
149+
.map(SimpleGrantedAuthority::new)
150+
.collect(Collectors.toCollection(HashSet::new)));
151+
} else { // unknown type of claim cannot be processed
152+
this.log.error(
153+
String.format("Error: Claim %s in %type has an unknown type", claim, type));
154+
}
155+
}
156+
return result;
157+
}
40158
}
Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package eu.zuinnote.example.springwebdemo.configuration.application.oidc;
22

33
import java.util.ArrayList;
4+
import java.util.HashMap;
45
import org.springframework.validation.annotation.Validated;
56

67
@Validated
78
public class OidcMapper {
89
private String authoritiesPrefix;
9-
private ArrayList<String> jwtRoleClaims;
10+
private ArrayList<String> jwtIdTokenClaims;
11+
private ArrayList<String> userClaims;
12+
private ArrayList<String> userAttributes;
13+
private HashMap<String, String> claimsSeparatorMap;
1014

1115
public String getAuthoritiesPrefix() {
1216
return this.authoritiesPrefix;
@@ -16,11 +20,35 @@ public void setAuthoritiesPrefix(String authoritiesPrefix) {
1620
this.authoritiesPrefix = authoritiesPrefix;
1721
}
1822

19-
public ArrayList<String> getJwtRoleClaims() {
20-
return this.jwtRoleClaims;
23+
public HashMap<String, String> getClaimsSeparatorMap() {
24+
return this.claimsSeparatorMap;
2125
}
2226

23-
public void setJwtRoleClaims(ArrayList<String> jwtRoleClaims) {
24-
this.jwtRoleClaims = jwtRoleClaims;
27+
public void setClaimsSeparatorMap(HashMap<String, String> claimsSeparatorMap) {
28+
this.claimsSeparatorMap = claimsSeparatorMap;
29+
}
30+
31+
public ArrayList<String> getJwtIdTokenClaims() {
32+
return this.jwtIdTokenClaims;
33+
}
34+
35+
public void setJwtIdTokenClaims(ArrayList<String> jwtIdTokenClaims) {
36+
this.jwtIdTokenClaims = jwtIdTokenClaims;
37+
}
38+
39+
public ArrayList<String> getUserClaims() {
40+
return this.userClaims;
41+
}
42+
43+
public void setUserClaims(ArrayList<String> userClaims) {
44+
this.userClaims = userClaims;
45+
}
46+
47+
public ArrayList<String> getUserAttributes() {
48+
return this.userAttributes;
49+
}
50+
51+
public void setUserAttributes(ArrayList<String> userAttributes) {
52+
this.userAttributes = userAttributes;
2553
}
2654
}

config/config-oidc.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ h2: # only for testing purposes
4747
application:
4848
oidc:
4949
mapper: # map jwt claims to Spring Security authorities
50-
jwtRoleClaims: ["scope","scp"] # JWT claims that contain authorities
51-
authoritiesPrefix: "SCOPE_" # Prefix for Spring Security Authorities
50+
jwtIdTokenClaims: ["scope","scp"] # claims from a standard OIDC IdToken https://openid.net/specs/openid-connect-core-1_0-final.html#StandardClaims
51+
userClaims: ["groups"] # claims from the UserInfo OIDC Endpoint (https://openid.net/specs/openid-connect-core-1_0-final.html#UserInfoResponse)
52+
userAttributes: [] # user attributes to be mapped to Spring Security Authorities
53+
claimsSeparatorMap: {"scope": " ", "scp": " ", "groups": ","} # separator if claims are one string, otherwise a JSON array is assumed
54+
authoritiesPrefix: "ROLE_" # Prefix for Spring Security Authorities
5255
https:
5356
headers:
5457
permissionPolicy: "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), serial=(), window-placement=()"

0 commit comments

Comments
 (0)