Provided the `D4ScienceContextMapper` that maps the D4S context requested in a customizable HTTP header as token's claim having the configured name (defaulting to the 'aud' claim). Can also shrink the `resource access` token claim to have only the requested context entry.

This commit is contained in:
Mauro Mugnaini 2023-09-12 13:21:50 +02:00
parent 2f586b695a
commit cb4d0e881c
7 changed files with 105 additions and 39 deletions

View File

@ -3,7 +3,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
# Changelog for "keycloak-d4science-spi-parent" # Changelog for "keycloak-d4science-spi-parent"
## [v2.1.0-SNAPSHOT] ## [v2.1.0-SNAPSHOT]
- Added new [protocol-mapper](protocol-mapper/README.md) module to make the custom protocol mappers available
- Revised terms, EU links, D4Science and Blue-Cloud logo updated (#25444)
## [v2.0.0] ## [v2.0.0]
- Updated to be compiled/used with Keycloak v19.0.2 - Updated to be compiled/used with Keycloak v19.0.2

View File

@ -15,6 +15,7 @@ The project is a Maven master POM project and it is composed of several modules:
* [keycloak-d4science-theme](keycloak-d4science-theme/README.md) * [keycloak-d4science-theme](keycloak-d4science-theme/README.md)
* [keycloak-d4science-script](keycloak-d4science-script/README.md) * [keycloak-d4science-script](keycloak-d4science-script/README.md)
* [ldap-storage-mapper](ldap-storage-mapper/README.md) * [ldap-storage-mapper](ldap-storage-mapper/README.md)
* [protocol-mapper](protocol-mapper/README.md)
## Built With ## Built With

View File

@ -42,6 +42,7 @@
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-parent</artifactId> <artifactId>keycloak-parent</artifactId>
<version>19.0.2</version> <version>19.0.2</version>
<!-- <version>22.0.0</version>-->
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
@ -95,6 +96,7 @@
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId> <artifactId>slf4j-log4j12</artifactId>
<scope>test</scope> <scope>test</scope>
<!-- <version>2.0.6</version>-->
</dependency> </dependency>
<dependency> <dependency>
<groupId>log4j</groupId> <groupId>log4j</groupId>

View File

@ -3,4 +3,4 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
# Changelog for "identity-provider-mapper" # Changelog for "identity-provider-mapper"
## [v2.1.0-SNAPSHOT] ## [v2.1.0-SNAPSHOT]
- Added new module to make the custom protocol mappers available - Provided the `D4ScienceContextMapper` that maps the D4S context requested in a customizable HTTP header as token's claim having the configured name (defaulting to the 'aud' claim). Can also shrink the `resource access` token claim to have only the requested context entry.

View File

@ -1,4 +1,6 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
@ -12,15 +14,19 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<scm> <scm>
<connection>scm:git:https://code-repo.d4science.org/gCubeSystem/${project.parent.artifactId}.git</connection> <connection>
<developerConnection>scm:git:https://code-repo.d4science.org/gCubeSystem/${project.parent.artifactId}.git</developerConnection> scm:git:https://code-repo.d4science.org/gCubeSystem/${project.parent.artifactId}.git</connection>
<url>https://code-repo.d4science.org/gCubeSystem/${project.parent.artifactId}</url> <developerConnection>
scm:git:https://code-repo.d4science.org/gCubeSystem/${project.parent.artifactId}.git</developerConnection>
<url>
https://code-repo.d4science.org/gCubeSystem/${project.parent.artifactId}</url>
</scm> </scm>
<properties> <properties>
<junit-jupiter.version>5.8.2</junit-jupiter.version> <junit-jupiter.version>5.8.2</junit-jupiter.version>
<assertj-core.version>3.22.0</assertj-core.version> <assertj-core.version>3.22.0</assertj-core.version>
<org-mockito.version>4.5.1</org-mockito.version> <org-mockito.version>4.5.1</org-mockito.version>
<javax.ws.rs.version>2.1.1</javax.ws.rs.version>
</properties> </properties>
<dependencies> <dependencies>
@ -41,6 +47,12 @@
<version>${org-mockito.version}</version> <version>${org-mockito.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- <dependency>-->
<!-- <groupId>javax.ws.rs</groupId>-->
<!-- <artifactId>javax.ws.rs-api</artifactId>-->
<!-- <version>${javax.ws.rs.version}</version>-->
<!-- <scope>test</scope>-->
<!-- </dependency>-->
</dependencies> </dependencies>
<build /> <build />

View File

@ -13,27 +13,51 @@ import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessToken.Access;
import org.keycloak.representations.IDToken; import org.keycloak.representations.IDToken;
public class D4ScienceContextMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper { public class D4ScienceContextMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper {
private static final Logger logger = Logger.getLogger(D4ScienceContextMapper.class); private static final Logger logger = Logger.getLogger(D4ScienceContextMapper.class);
private static final List<ProviderConfigProperty> configProperties = new ArrayList<>(); public static final String HTTP_REQUEST_HEADER_NAME = "d4scm.header-name";
public static final String NARROW_RESOURCE_ACCESS = "d4scm.narrow-ra";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
// Assuring that the mapper is executed as last // Assuring that the mapper is executed as last
private static final int PRIORITY = Integer.MAX_VALUE; private static final int PRIORITY = Integer.MAX_VALUE;
private static final String DISPLAY_TYPE = "OIDC D4Science Context Mapper"; private static final String DISPLAY_TYPE = "OIDC D4Science Context Mapper";
private static final String PROVIDER_ID = "oidc-d4scince-context-mapper"; private static final String PROVIDER_ID = "oidc-d4scince-context-mapper";
public static final String HEADER_NAME = "X-D4Science-Context"; public static final String DEFAULT_HEADER_NAME = "X-D4Science-Context";
// public static final String HEADER_NAME = "X-Infrastructure-Context"; public static final String DEFAULT_TOKEN_CLAIM = "aud";
// public static final String HEADER_NAME = "X-Infra-Context";
static { static {
OIDCAttributeMapperHelper.addTokenClaimNameConfig(configProperties); OIDCAttributeMapperHelper.addTokenClaimNameConfig(CONFIG_PROPERTIES);
OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, D4ScienceContextMapper.class); CONFIG_PROPERTIES.forEach(conf -> {
if (OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME.equals(conf.getName()))
conf.setDefaultValue(DEFAULT_TOKEN_CLAIM);
conf.setReadOnly(true);
});
OIDCAttributeMapperHelper.addIncludeInTokensConfig(CONFIG_PROPERTIES, D4ScienceContextMapper.class);
ProviderConfigProperty headerProperty = new ProviderConfigProperty();
headerProperty.setName(HTTP_REQUEST_HEADER_NAME);
headerProperty.setLabel("HTTP request header name with the requested context");
headerProperty.setType(ProviderConfigProperty.STRING_TYPE);
headerProperty.setHelpText("The HTTP header that contains the requested context to be mapped as the requested in the configured claim");
headerProperty.setDefaultValue(DEFAULT_HEADER_NAME);
headerProperty.setReadOnly(true);
CONFIG_PROPERTIES.add(headerProperty);
ProviderConfigProperty narrowProperty = new ProviderConfigProperty();
narrowProperty.setName(NARROW_RESOURCE_ACCESS);
narrowProperty.setLabel("Narrow down resource access array?");
narrowProperty.setType(ProviderConfigProperty.BOOLEAN_TYPE);
narrowProperty.setHelpText("Narrow down resource access claim to contain only the requested context entry");
CONFIG_PROPERTIES.add(narrowProperty);
} }
@Override @Override
@ -53,12 +77,12 @@ public class D4ScienceContextMapper extends AbstractOIDCProtocolMapper implement
@Override @Override
public String getHelpText() { public String getHelpText() {
return "Maps the D4Science context audience by reading the '" + HEADER_NAME + "' header and sets it as the configured token claim"; return "Maps the D4Science context audience by reading the configured header's value and sets it as the configured token claim, if it is in scope";
} }
@Override @Override
public List<ProviderConfigProperty> getConfigProperties() { public List<ProviderConfigProperty> getConfigProperties() {
return configProperties; return CONFIG_PROPERTIES;
} }
@Override @Override
@ -76,17 +100,28 @@ public class D4ScienceContextMapper extends AbstractOIDCProtocolMapper implement
// Since only the OIDCAccessTokenMapper interface is implemented, we are almost sure that // Since only the OIDCAccessTokenMapper interface is implemented, we are almost sure that
// the token object is an AccessToken but adding a specific check anyway // the token object is an AccessToken but adding a specific check anyway
if (token instanceof AccessToken) { if (token instanceof AccessToken) {
logger.debugf("Looking for the '%s' header", HEADER_NAME); AccessToken accessToken = ((AccessToken) token);
String requestedD4SContext = keycloakSession.getContext().getRequestHeaders().getHeaderString(HEADER_NAME); String headerName = mappingModel.getConfig().get(HTTP_REQUEST_HEADER_NAME);
if (headerName == null || "".equals(headerName)) {
headerName = DEFAULT_HEADER_NAME;
}
logger.debugf("Looking for the '%s' header", headerName);
String requestedD4SContext = keycloakSession.getContext().getRequestHeaders().getHeaderString(headerName);
if (requestedD4SContext != null && !"".equals(requestedD4SContext)) { if (requestedD4SContext != null && !"".equals(requestedD4SContext)) {
logger.debugf("Checking resource access for the requested context: %s", requestedD4SContext); logger.debugf("Checking resource access for the requested context: %s", requestedD4SContext);
Access contextAccessInResourceAccess = accessToken.getResourceAccess(requestedD4SContext);
if (((AccessToken) token).getResourceAccess().containsKey(requestedD4SContext)) { if (contextAccessInResourceAccess != null) {
logger.debugf("Mapping it as the configured claim: %s", logger.debugf("Mapping it as the configured claim: %s",
mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME)); mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME));
OIDCAttributeMapperHelper.mapClaim(token, mappingModel, requestedD4SContext); OIDCAttributeMapperHelper.mapClaim(token, mappingModel, requestedD4SContext);
if (Boolean.parseBoolean(mappingModel.getConfig().get(NARROW_RESOURCE_ACCESS))) {
// Removing all access details but the requested context
accessToken.getResourceAccess().clear();
accessToken.getResourceAccess().put(requestedD4SContext, contextAccessInResourceAccess);
}
} else { } else {
logger.warnf("Requested context '%s' is not accessible to the client: %s", requestedD4SContext, logger.warnf("Requested context '%s' is not accessible to the client: %s", requestedD4SContext,
clientSessionCtx.getClientSession().getClient().getName()); clientSessionCtx.getClientSession().getClient().getName());

View File

@ -6,11 +6,11 @@ import static org.mockito.Mockito.when;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.HttpHeaders;
import org.assertj.core.util.Maps;
import org.junit.Test; import org.junit.Test;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
@ -31,7 +31,6 @@ import org.mockito.Mockito;
*/ */
public class D4ScienceContextMapperTest { public class D4ScienceContextMapperTest {
static final String CLAIM_NAME = "haandlerIdClaimNameExample";
static final String HEADER_VALUE = "ginostilla"; static final String HEADER_VALUE = "ginostilla";
@Test @Test
@ -62,31 +61,44 @@ public class D4ScienceContextMapperTest {
.collect(Collectors.toList()); .collect(Collectors.toList());
assertThat(configPropertyNames).containsExactly(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, assertThat(configPropertyNames).containsExactly(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME,
OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN); OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, D4ScienceContextMapper.HTTP_REQUEST_HEADER_NAME,
D4ScienceContextMapper.NARROW_RESOURCE_ACCESS);
} }
@Test @Test
public void shouldAddClaim() { public void shouldAddClaimAndNotNarrow() {
final UserSessionModel session = givenUserSession(); final UserSessionModel session = givenUserSession();
final KeycloakSession keycloakSession = givenKeycloakSession(true); final KeycloakSession keycloakSession = givenKeycloakSession(true);
final AccessToken accessToken = transformAccessToken(session, keycloakSession, true); final AccessToken accessToken = transformAccessToken(session, keycloakSession, true, false);
assertThat(accessToken.getOtherClaims().get(CLAIM_NAME)).isEqualTo(HEADER_VALUE); assertThat(accessToken.getAudience()[0]).isEqualTo(HEADER_VALUE);
assertThat(accessToken.getResourceAccess().size()).isEqualTo(2);
assertThat(accessToken.getResourceAccess().keySet()).contains(HEADER_VALUE);
}
@Test
public void shouldAddClaimAndNarrow() {
final UserSessionModel session = givenUserSession();
final KeycloakSession keycloakSession = givenKeycloakSession(true);
final AccessToken accessToken = transformAccessToken(session, keycloakSession, true, true);
assertThat(accessToken.getAudience()[0]).isEqualTo(HEADER_VALUE);
assertThat(accessToken.getResourceAccess().size()).isEqualTo(1);
assertThat(accessToken.getResourceAccess().keySet().iterator().next()).isEqualTo(HEADER_VALUE);
} }
@Test @Test
public void shouldNotAddClaim() { public void shouldNotAddClaim() {
final UserSessionModel session = givenUserSession(); final UserSessionModel session = givenUserSession();
final KeycloakSession keycloakSession = givenKeycloakSession(false); final KeycloakSession keycloakSession = givenKeycloakSession(false);
final AccessToken accessToken = transformAccessToken(session, keycloakSession, true); final AccessToken accessToken = transformAccessToken(session, keycloakSession, true, false);
assertThat(accessToken.getOtherClaims().get(CLAIM_NAME)).isNull(); assertThat(accessToken.getAudience()).isNull();
} }
@Test @Test
public void shouldNotAddClaimAndLogWarning() { public void shouldNotAddClaimAndLogWarning() {
final UserSessionModel session = givenUserSession(); final UserSessionModel session = givenUserSession();
final KeycloakSession keycloakSession = givenKeycloakSession(true); final KeycloakSession keycloakSession = givenKeycloakSession(true);
final AccessToken accessToken = transformAccessToken(session, keycloakSession, false); final AccessToken accessToken = transformAccessToken(session, keycloakSession, false, false);
assertThat(accessToken.getOtherClaims().get(CLAIM_NAME)).isNull(); assertThat(accessToken.getAudience()).isNull();
} }
private UserSessionModel givenUserSession() { private UserSessionModel givenUserSession() {
@ -104,21 +116,22 @@ public class D4ScienceContextMapperTest {
when(context.getRequestHeaders()).thenReturn(headers); when(context.getRequestHeaders()).thenReturn(headers);
if (withHeader) { if (withHeader) {
when(headers.getHeaderString(D4ScienceContextMapper.HEADER_NAME)).thenReturn(HEADER_VALUE); when(headers.getHeaderString(D4ScienceContextMapper.DEFAULT_HEADER_NAME)).thenReturn(HEADER_VALUE);
} else { } else {
when(headers.getHeaderString(D4ScienceContextMapper.HEADER_NAME)).thenReturn(""); when(headers.getHeaderString(D4ScienceContextMapper.DEFAULT_HEADER_NAME)).thenReturn("");
} }
return keycloakSession; return keycloakSession;
} }
private AccessToken transformAccessToken(UserSessionModel userSessionModel, KeycloakSession keycloakSession, private AccessToken transformAccessToken(UserSessionModel userSessionModel, KeycloakSession keycloakSession,
boolean withResourceAccess) { boolean withResourceAccess, boolean withNarrowRA) {
final ProtocolMapperModel mappingModel = new ProtocolMapperModel(); final ProtocolMapperModel mappingModel = new ProtocolMapperModel();
mappingModel.setConfig(createConfig()); mappingModel.setConfig(createConfig(withNarrowRA));
AccessToken at = new AccessToken(); AccessToken at = new AccessToken();
if (withResourceAccess) { if (withResourceAccess) {
at.setResourceAccess(Maps.newHashMap(HEADER_VALUE, null)); at.addAccess(HEADER_VALUE);
at.addAccess(UUID.randomUUID().toString());
} }
return new D4ScienceContextMapper().transformAccessToken(at, mappingModel, keycloakSession, return new D4ScienceContextMapper().transformAccessToken(at, mappingModel, keycloakSession,
@ -135,11 +148,13 @@ public class D4ScienceContextMapperTest {
return csc; return csc;
} }
private Map<String, String> createConfig() { private Map<String, String> createConfig(boolean narrowRA) {
final Map<String, String> result = new HashMap<>(); final Map<String, String> config = new HashMap<>();
result.put("access.token.claim", "true"); config.put(D4ScienceContextMapper.HTTP_REQUEST_HEADER_NAME, D4ScienceContextMapper.DEFAULT_HEADER_NAME);
result.put("claim.name", CLAIM_NAME); config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, D4ScienceContextMapper.DEFAULT_TOKEN_CLAIM);
return result; config.put(D4ScienceContextMapper.NARROW_RESOURCE_ACCESS, Boolean.toString(narrowRA));
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
return config;
} }
} }