Include entitlements in ID token and UserInfo response #8

Manually merged
ansv7779 merged 2 commits from entitlements-in-id-token into main 2025-05-12 15:11:34 +02:00
5 changed files with 120 additions and 4 deletions
Showing only changes of commit 7c132395a2 - Show all commits

View File

@ -91,7 +91,9 @@ public class AuthorizationServer extends SpringBootServletInitializer {
.authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint
.consentPage("/oauth2/consent"))
.withObjectPostProcessor(enableGivingConsentWithNoScopes())
.oidc(Customizer.withDefaults()))
.oidc(oidc -> oidc
.userInfoEndpoint(userInfo -> userInfo
.userInfoMapper(new OidcUserInfoMapper()))))
.with(new ShibbolethConfigurer(), Customizer.withDefaults())
.sessionManagement(session -> session
// Never use the session and always rely on the Shibboleth authentication

View File

@ -0,0 +1,84 @@
package se.su.dsv.oauth2;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationProvider;
import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
/// Straight up copied from [OidcUserInfoAuthenticationProvider.DefaultOidcUserInfoMapper]
/// with the addition of always including the [ShibbolethTokenPopulator#ENTITLEMENTS_CLAIM] claim
public class OidcUserInfoMapper implements Function<OidcUserInfoAuthenticationContext, OidcUserInfo> {
private static final List<String> EMAIL_CLAIMS = Arrays.asList(
StandardClaimNames.EMAIL,
StandardClaimNames.EMAIL_VERIFIED
);
private static final List<String> PHONE_CLAIMS = Arrays.asList(
StandardClaimNames.PHONE_NUMBER,
StandardClaimNames.PHONE_NUMBER_VERIFIED
);
private static final List<String> PROFILE_CLAIMS = Arrays.asList(
StandardClaimNames.NAME,
StandardClaimNames.FAMILY_NAME,
StandardClaimNames.GIVEN_NAME,
StandardClaimNames.MIDDLE_NAME,
StandardClaimNames.NICKNAME,
StandardClaimNames.PREFERRED_USERNAME,
StandardClaimNames.PROFILE,
StandardClaimNames.PICTURE,
StandardClaimNames.WEBSITE,
StandardClaimNames.GENDER,
StandardClaimNames.BIRTHDATE,
StandardClaimNames.ZONEINFO,
StandardClaimNames.LOCALE,
StandardClaimNames.UPDATED_AT
);
@Override
public OidcUserInfo apply(OidcUserInfoAuthenticationContext authenticationContext) {
OAuth2Authorization authorization = authenticationContext.getAuthorization();
OidcIdToken idToken = authorization.getToken(OidcIdToken.class).getToken();
OAuth2AccessToken accessToken = authenticationContext.getAccessToken();
Map<String, Object> scopeRequestedClaims = getClaimsRequestedByScope(idToken.getClaims(),
accessToken.getScopes());
return new OidcUserInfo(scopeRequestedClaims);
}
private static Map<String, Object> getClaimsRequestedByScope(Map<String, Object> claims,
Set<String> requestedScopes) {
Set<String> scopeRequestedClaimNames = new HashSet<>(32);
scopeRequestedClaimNames.add(StandardClaimNames.SUB);
scopeRequestedClaimNames.add(ShibbolethTokenPopulator.ENTITLEMENTS_CLAIM);
if (requestedScopes.contains(OidcScopes.ADDRESS)) {
scopeRequestedClaimNames.add(StandardClaimNames.ADDRESS);
}
if (requestedScopes.contains(OidcScopes.EMAIL)) {
scopeRequestedClaimNames.addAll(EMAIL_CLAIMS);
}
if (requestedScopes.contains(OidcScopes.PHONE)) {
scopeRequestedClaimNames.addAll(PHONE_CLAIMS);
}
if (requestedScopes.contains(OidcScopes.PROFILE)) {
scopeRequestedClaimNames.addAll(PROFILE_CLAIMS);
}
Map<String, Object> requestedClaims = new HashMap<>(claims);
requestedClaims.keySet().removeIf((claimName) -> !scopeRequestedClaimNames.contains(claimName));
return requestedClaims;
}
}

View File

@ -16,6 +16,7 @@ import java.util.Set;
/// Populate the tokens with Shibboleth attributes, if available.
public final class ShibbolethTokenPopulator implements OAuth2TokenCustomizer<JwtEncodingContext> {
public static final String ENTITLEMENTS_CLAIM = "entitlements";
private static final Set<String> EMAIL_CLAIMS = Set.of(
StandardClaimNames.EMAIL,
StandardClaimNames.EMAIL_VERIFIED
@ -42,12 +43,12 @@ public final class ShibbolethTokenPopulator implements OAuth2TokenCustomizer<Jwt
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
List<String> entitlements = getEntitlements(context);
context.getClaims().claim("entitlements", entitlements);
context.getClaims().claim(ENTITLEMENTS_CLAIM, entitlements);
}
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
List<String> entitlements = getEntitlements(context);
context.getClaims().claim("entitlements", entitlements);
context.getClaims().claim(ENTITLEMENTS_CLAIM, entitlements);
if (context.getPrincipal().getDetails() instanceof ShibbolethAuthenticationDetails details) {
OidcUserInfo oidcUserInfo = getOidcUserInfo(details);

View File

@ -37,7 +37,7 @@ public class AbstractMetadataTest {
@BeforeEach
public void setUp() throws Exception {
// 1. Get metadata
MvcResult metadataResult = mockMvc.perform(get("/.well-known/oauth-authorization-server"))
MvcResult metadataResult = mockMvc.perform(get("/.well-known/openid-configuration"))
.andExpect(status().isOk())
.andReturn();
@ -69,6 +69,10 @@ public class AbstractMetadataTest {
return metadata.get("introspection_endpoint").asText();
}
protected String getUserInfoEndpoint() {
return metadata.get("userinfo_endpoint").asText();
}
protected JWTClaimsSet verifyToken(String token) throws Exception {
return processor.process(token, null);
}

View File

@ -3,13 +3,18 @@ package se.su.dsv.oauth2;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.test.web.servlet.MvcResult;
import java.net.URI;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static se.su.dsv.oauth2.ShibbolethRequestProcessor.remoteUser;
@SpringBootTest(classes = TestRegisteredClientConfiguration.class)
public class UserInfoEndpointTest extends AbstractMetadataCodeFlowTest {
@ -26,4 +31,24 @@ public class UserInfoEndpointTest extends AbstractMetadataCodeFlowTest {
assertEquals("/oidc/userinfo", userInfoUri.getPath());
}
@Test
public void includes_entitlements_in_userinfo() throws Exception {
TokenResponse tokenResponse = authorize(request -> request
.queryParam("scope", OidcScopes.OPENID)
.with(remoteUser("someone@university")
.entitlement("gdpr")
.entitlement("hr")));
String accessToken = tokenResponse.accessToken();
assertNotNull(accessToken);
mockMvc.perform(get(getUserInfoEndpoint())
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpectAll(
jsonPath("$.entitlements").isArray(),
jsonPath("$.entitlements").value(hasItem("gdpr")),
jsonPath("$.entitlements").value(hasItem("hr")));
}
}