Include entitlements in ID token and UserInfo response #8
@ -91,7 +91,9 @@ public class AuthorizationServer extends SpringBootServletInitializer {
|
|||||||
.authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint
|
.authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint
|
||||||
.consentPage("/oauth2/consent"))
|
.consentPage("/oauth2/consent"))
|
||||||
.withObjectPostProcessor(enableGivingConsentWithNoScopes())
|
.withObjectPostProcessor(enableGivingConsentWithNoScopes())
|
||||||
.oidc(Customizer.withDefaults()))
|
.oidc(oidc -> oidc
|
||||||
|
.userInfoEndpoint(userInfo -> userInfo
|
||||||
|
.userInfoMapper(new OidcUserInfoMapper()))))
|
||||||
.with(new ShibbolethConfigurer(), Customizer.withDefaults())
|
.with(new ShibbolethConfigurer(), Customizer.withDefaults())
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
// Never use the session and always rely on the Shibboleth authentication
|
// Never use the session and always rely on the Shibboleth authentication
|
||||||
|
|||||||
84
src/main/java/se/su/dsv/oauth2/OidcUserInfoMapper.java
Normal file
84
src/main/java/se/su/dsv/oauth2/OidcUserInfoMapper.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import java.util.Set;
|
|||||||
|
|
||||||
/// Populate the tokens with Shibboleth attributes, if available.
|
/// Populate the tokens with Shibboleth attributes, if available.
|
||||||
public final class ShibbolethTokenPopulator implements OAuth2TokenCustomizer<JwtEncodingContext> {
|
public final class ShibbolethTokenPopulator implements OAuth2TokenCustomizer<JwtEncodingContext> {
|
||||||
|
public static final String ENTITLEMENTS_CLAIM = "entitlements";
|
||||||
private static final Set<String> EMAIL_CLAIMS = Set.of(
|
private static final Set<String> EMAIL_CLAIMS = Set.of(
|
||||||
StandardClaimNames.EMAIL,
|
StandardClaimNames.EMAIL,
|
||||||
StandardClaimNames.EMAIL_VERIFIED
|
StandardClaimNames.EMAIL_VERIFIED
|
||||||
@ -42,12 +43,12 @@ public final class ShibbolethTokenPopulator implements OAuth2TokenCustomizer<Jwt
|
|||||||
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
|
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
|
||||||
List<String> entitlements = getEntitlements(context);
|
List<String> entitlements = getEntitlements(context);
|
||||||
|
|
||||||
context.getClaims().claim("entitlements", entitlements);
|
context.getClaims().claim(ENTITLEMENTS_CLAIM, entitlements);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
|
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
|
||||||
List<String> entitlements = getEntitlements(context);
|
List<String> entitlements = getEntitlements(context);
|
||||||
context.getClaims().claim("entitlements", entitlements);
|
context.getClaims().claim(ENTITLEMENTS_CLAIM, entitlements);
|
||||||
|
|
||||||
if (context.getPrincipal().getDetails() instanceof ShibbolethAuthenticationDetails details) {
|
if (context.getPrincipal().getDetails() instanceof ShibbolethAuthenticationDetails details) {
|
||||||
OidcUserInfo oidcUserInfo = getOidcUserInfo(details);
|
OidcUserInfo oidcUserInfo = getOidcUserInfo(details);
|
||||||
|
|||||||
@ -37,7 +37,7 @@ public class AbstractMetadataTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
// 1. Get metadata
|
// 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())
|
.andExpect(status().isOk())
|
||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
@ -69,6 +69,10 @@ public class AbstractMetadataTest {
|
|||||||
return metadata.get("introspection_endpoint").asText();
|
return metadata.get("introspection_endpoint").asText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected String getUserInfoEndpoint() {
|
||||||
|
return metadata.get("userinfo_endpoint").asText();
|
||||||
|
}
|
||||||
|
|
||||||
protected JWTClaimsSet verifyToken(String token) throws Exception {
|
protected JWTClaimsSet verifyToken(String token) throws Exception {
|
||||||
return processor.process(token, null);
|
return processor.process(token, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,18 @@ package se.su.dsv.oauth2;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
|
||||||
import java.net.URI;
|
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.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.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 org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static se.su.dsv.oauth2.ShibbolethRequestProcessor.remoteUser;
|
||||||
|
|
||||||
@SpringBootTest(classes = TestRegisteredClientConfiguration.class)
|
@SpringBootTest(classes = TestRegisteredClientConfiguration.class)
|
||||||
public class UserInfoEndpointTest extends AbstractMetadataCodeFlowTest {
|
public class UserInfoEndpointTest extends AbstractMetadataCodeFlowTest {
|
||||||
@ -26,4 +31,24 @@ public class UserInfoEndpointTest extends AbstractMetadataCodeFlowTest {
|
|||||||
|
|
||||||
assertEquals("/oidc/userinfo", userInfoUri.getPath());
|
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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user