Skip to content

Commit 3e96b56

Browse files
GH-15201 Introduce ExpressionJwtGrantedAuthoritiesConverter to extract nested authorities with an expression
This helps to reduce custom code necessary to extract roles from deeply nested claims. Fixes #15201 Signed-off-by: Thomas Darimont <[email protected]>
1 parent 3acd2c6 commit 3e96b56

File tree

2 files changed

+219
-0
lines changed

2 files changed

+219
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.oauth2.server.resource.authentication;
18+
19+
import java.util.ArrayList;
20+
import java.util.Collection;
21+
import java.util.Collections;
22+
23+
import org.apache.commons.logging.Log;
24+
import org.apache.commons.logging.LogFactory;
25+
26+
import org.springframework.core.convert.converter.Converter;
27+
import org.springframework.core.log.LogMessage;
28+
import org.springframework.expression.Expression;
29+
import org.springframework.expression.ExpressionException;
30+
import org.springframework.security.core.GrantedAuthority;
31+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
32+
import org.springframework.security.oauth2.jwt.Jwt;
33+
import org.springframework.util.Assert;
34+
35+
/**
36+
* Uses an expression for extracting the token claim value to use for mapping
37+
* {@link GrantedAuthority authorities}.
38+
*
39+
* Note this can be used in combination with a
40+
* {@link DelegatingJwtGrantedAuthoritiesConverter}.
41+
*
42+
* @author Thomas Darimont
43+
* @since 6.4
44+
*/
45+
public final class ExpressionJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
46+
47+
private final Log logger = LogFactory.getLog(getClass());
48+
49+
private String authorityPrefix = "SCOPE_";
50+
51+
private final Expression authoritiesClaimExpression;
52+
53+
/**
54+
* Constructs a {@link ExpressionJwtGrantedAuthoritiesConverter} using the provided
55+
* {@code authoritiesClaimExpression}.
56+
* @param authoritiesClaimExpression The token claim SpEL Expression to map
57+
* authorities from.
58+
*/
59+
public ExpressionJwtGrantedAuthoritiesConverter(Expression authoritiesClaimExpression) {
60+
Assert.notNull(authoritiesClaimExpression, "authoritiesClaimExpression must not be null");
61+
this.authoritiesClaimExpression = authoritiesClaimExpression;
62+
}
63+
64+
/**
65+
* Sets the prefix to use for {@link GrantedAuthority authorities} mapped by this
66+
* converter. Defaults to {@code "SCOPE_"}.
67+
* @param authorityPrefix The authority prefix
68+
*/
69+
public void setAuthorityPrefix(String authorityPrefix) {
70+
Assert.notNull(authorityPrefix, "authorityPrefix cannot be null");
71+
this.authorityPrefix = authorityPrefix;
72+
}
73+
74+
/**
75+
* Extract {@link GrantedAuthority}s from the given {@link Jwt}.
76+
* @param jwt The {@link Jwt} token
77+
* @return The {@link GrantedAuthority authorities} read from the token scopes
78+
*/
79+
@Override
80+
public Collection<GrantedAuthority> convert(Jwt jwt) {
81+
Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
82+
for (String authority : getAuthorities(jwt)) {
83+
grantedAuthorities.add(new SimpleGrantedAuthority(this.authorityPrefix + authority));
84+
}
85+
return grantedAuthorities;
86+
}
87+
88+
private Collection<String> getAuthorities(Jwt jwt) {
89+
Object authorities;
90+
try {
91+
if (this.logger.isTraceEnabled()) {
92+
this.logger.trace(LogMessage.format("Looking for authorities with expression. expression=%s",
93+
this.authoritiesClaimExpression.getExpressionString()));
94+
}
95+
authorities = this.authoritiesClaimExpression.getValue(jwt.getClaims(), Collection.class);
96+
if (this.logger.isTraceEnabled()) {
97+
this.logger.trace(LogMessage.format("Found authorities with expression. authorities=%s", authorities));
98+
}
99+
}
100+
catch (ExpressionException ee) {
101+
if (this.logger.isTraceEnabled()) {
102+
this.logger.trace(LogMessage.format("Failed to evaluate expression. error=%s", ee.getMessage()));
103+
}
104+
authorities = Collections.emptyList();
105+
}
106+
107+
if (authorities != null) {
108+
return castAuthoritiesToCollection(authorities);
109+
}
110+
return Collections.emptyList();
111+
}
112+
113+
@SuppressWarnings("unchecked")
114+
private Collection<String> castAuthoritiesToCollection(Object authorities) {
115+
return (Collection<String>) authorities;
116+
}
117+
118+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.oauth2.server.resource.authentication;
18+
19+
import java.util.Arrays;
20+
import java.util.Collection;
21+
import java.util.Collections;
22+
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.expression.spel.standard.SpelExpression;
26+
import org.springframework.expression.spel.standard.SpelExpressionParser;
27+
import org.springframework.security.core.GrantedAuthority;
28+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
29+
import org.springframework.security.oauth2.jwt.Jwt;
30+
import org.springframework.security.oauth2.jwt.TestJwts;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
34+
/**
35+
* Tests for {@link ExpressionJwtGrantedAuthoritiesConverter}
36+
*
37+
* @author Thomas Darimont
38+
* @since 6.4
39+
*/
40+
public class ExpressionJwtGrantedAuthoritiesConverterTests {
41+
42+
@Test
43+
public void convertWhenTokenHasCustomClaimNameExpressionThenCustomClaimNameAttributeIsTranslatedToAuthorities() {
44+
// @formatter:off
45+
Jwt jwt = TestJwts.jwt()
46+
.claim("nested", Collections.singletonMap("roles", Arrays.asList("role1", "role2")))
47+
.build();
48+
// @formatter:on
49+
SpelExpression expression = new SpelExpressionParser().parseRaw("[nested][roles]");
50+
ExpressionJwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new ExpressionJwtGrantedAuthoritiesConverter(
51+
expression);
52+
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
53+
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("SCOPE_role1"),
54+
new SimpleGrantedAuthority("SCOPE_role2"));
55+
}
56+
57+
@Test
58+
public void convertToEmptyListWhenTokenClaimExpressionYieldsNull() {
59+
// @formatter:off
60+
Jwt jwt = TestJwts.jwt()
61+
.claim("nested", Collections.singletonMap("roles", null))
62+
.build();
63+
// @formatter:on
64+
SpelExpression expression = new SpelExpressionParser().parseRaw("[nested][roles]");
65+
ExpressionJwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new ExpressionJwtGrantedAuthoritiesConverter(
66+
expression);
67+
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
68+
assertThat(authorities).isEmpty();
69+
}
70+
71+
@Test
72+
public void convertWhenTokenHasCustomClaimNameExpressionThenCustomClaimNameAttributeIsTranslatedToAuthoritiesWithPrefix() {
73+
// @formatter:off
74+
Jwt jwt = TestJwts.jwt()
75+
.claim("nested", Collections.singletonMap("roles", Arrays.asList("role1", "role2")))
76+
.build();
77+
// @formatter:on
78+
SpelExpression expression = new SpelExpressionParser().parseRaw("[nested][roles]");
79+
ExpressionJwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new ExpressionJwtGrantedAuthoritiesConverter(
80+
expression);
81+
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("CUSTOM_");
82+
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
83+
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("CUSTOM_role1"),
84+
new SimpleGrantedAuthority("CUSTOM_role2"));
85+
}
86+
87+
@Test
88+
public void convertWhenTokenHasCustomInvalidClaimNameExpressionThenCustomClaimNameAttributeIsTranslatedToEmptyAuthorities() {
89+
// @formatter:off
90+
Jwt jwt = TestJwts.jwt()
91+
.claim("other", Collections.singletonMap("roles", Arrays.asList("role1", "role2")))
92+
.build();
93+
// @formatter:on
94+
SpelExpression expression = new SpelExpressionParser().parseRaw("[nested][roles]");
95+
ExpressionJwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new ExpressionJwtGrantedAuthoritiesConverter(
96+
expression);
97+
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
98+
assertThat(authorities).isEmpty();
99+
}
100+
101+
}

0 commit comments

Comments
 (0)