Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3771,6 +3771,8 @@ addOrganizationId.label=Add organization id
addOrganizationId.help=If enabled, the organization id will be available for each organization mapped to the token.
addOrganizationDomain.label=Add organization domain
addOrganizationDomain.help=If enabled, the organization domain matching the user's email domain will be available for each organization mapped to the token.
includeEmptyClaim.label=Include empty claim
includeEmptyClaim.help=If enabled, the organization claim will be included as an empty value ([] or {}) when no organizations are mapped to the token.
addGroupRoleMappings.label=Add group role mappings
addGroupRoleMappings.help=If enabled, realm and client roles assigned to the user's organization groups will be included in the token for each organization.
identityProviderUnlink=Unlink identity provider?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
import org.keycloak.utils.JsonUtils;

import static org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper.JSON_TYPE;
import static org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME;
Expand All @@ -60,6 +61,7 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
public static final String ADD_ORGANIZATION_ATTRIBUTES = "addOrganizationAttributes";
public static final String ADD_ORGANIZATION_ID = "addOrganizationId";
public static final String ADD_ORGANIZATION_DOMAIN = "addOrganizationDomain";
public static final String INCLUDE_EMPTY_CLAIM = "includeEmptyClaim";

@Override
public List<ProviderConfigProperty> getConfigProperties() {
Expand Down Expand Up @@ -95,6 +97,13 @@ public List<ProviderConfigProperty> getConfigProperties() {
property.setDefaultValue(Boolean.FALSE.toString());
property.setHelpText(ADD_ORGANIZATION_DOMAIN + ".help");
properties.add(property);
property = new ProviderConfigProperty();
property.setName(INCLUDE_EMPTY_CLAIM);
property.setLabel(INCLUDE_EMPTY_CLAIM + ".label");
property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
property.setDefaultValue(Boolean.FALSE.toString());
property.setHelpText(INCLUDE_EMPTY_CLAIM + ".help");
properties.add(property);
return properties;
}

Expand Down Expand Up @@ -144,6 +153,11 @@ protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionMod
return;
}

if (isEmptyClaim(claim)) {
mapEmptyClaim(token, effectiveModel, claim);
return;
}

OIDCAttributeMapperHelper.mapClaim(token, effectiveModel, claim);
}

Expand All @@ -160,7 +174,7 @@ private Stream<OrganizationModel> resolveFromRequestedScopes(KeycloakSession ses

private Object resolveValue(ProtocolMapperModel model, UserModel user, List<OrganizationModel> organizations) {
if (organizations.isEmpty()) {
return null;
return resolveEmptyValue(model);
}

if (!OIDCAttributeMapperHelper.isMultivalued(model)) {
Expand Down Expand Up @@ -195,7 +209,7 @@ private Object resolveValue(ProtocolMapperModel model, UserModel user, List<Orga
}

if (value.isEmpty()) {
return null;
return resolveEmptyValue(model);
}

if (isJsonType(model)) {
Expand All @@ -205,6 +219,32 @@ private Object resolveValue(ProtocolMapperModel model, UserModel user, List<Orga
return value.keySet();
}

private Object resolveEmptyValue(ProtocolMapperModel model) {
if (!isIncludeEmptyClaim(model)) {
return null;
}

if (isJsonType(model)) {
return Map.of();
}

if (OIDCAttributeMapperHelper.isMultivalued(model)) {
return List.of();
}

return null;
Comment on lines +231 to +235
}

private boolean isEmptyClaim(Object claim) {
return claim instanceof Map && ((Map<?, ?>) claim).isEmpty()
|| claim instanceof List && ((List<?>) claim).isEmpty();
}

private void mapEmptyClaim(IDToken token, ProtocolMapperModel model, Object claim) {
JsonUtils.mapClaim(JsonUtils.splitClaimPath(model.getConfig().get(TOKEN_CLAIM_NAME)), claim, token.getOtherClaims(),
OIDCAttributeMapperHelper.isMultivalued(model));
}

private static boolean isJsonType(ProtocolMapperModel model) {
return "JSON".equals(model.getConfig().getOrDefault(JSON_TYPE, "JSON"));
}
Expand Down Expand Up @@ -255,6 +295,10 @@ private boolean isAddOrganizationDomain(ProtocolMapperModel model) {
return Boolean.parseBoolean(model.getConfig().getOrDefault(ADD_ORGANIZATION_DOMAIN, Boolean.FALSE.toString()));
}

private boolean isIncludeEmptyClaim(ProtocolMapperModel model) {
return Boolean.parseBoolean(model.getConfig().getOrDefault(INCLUDE_EMPTY_CLAIM, Boolean.FALSE.toString()));
}

public static ProtocolMapperModel create(String name, boolean accessToken, boolean idToken, boolean introspectionEndpoint) {
ProtocolMapperModel mapper = new ProtocolMapperModel();
mapper.setName(name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.UserInfo;
import org.keycloak.representations.idm.ClientRepresentation;
Expand Down Expand Up @@ -78,6 +79,7 @@
import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anEmptyMap;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
Expand All @@ -100,6 +102,7 @@ public void onBefore() {
setMapperConfig(ProtocolMapperUtils.MULTIVALUED, null);
setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null);
setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, null);
setMapperConfig(OrganizationMembershipMapper.INCLUDE_EMPTY_CLAIM, null);
}

@Test
Expand Down Expand Up @@ -1595,6 +1598,60 @@ public void testClaimNotMappedIfUserNotMemberWhenScopeOrgRequested() {
assertClaimNotMapped("organization", createOrganization("orga", true), true);
}

@Test
@SuppressWarnings("unchecked")
public void testEmptyOrganizationClaimMappedWhenConfigured() throws Exception {
OrganizationRepresentation orgARep = createOrganization("orga", true);
OrganizationResource orgA = managedRealm.admin().organizations().get(orgARep.getId());
MemberRepresentation member = addMember(orgA, "member@" + orgARep.getDomains().iterator().next().getName());
orgA.members().member(member.getId()).delete().close();

setMapperConfig(OrganizationMembershipMapper.INCLUDE_EMPTY_CLAIM, Boolean.TRUE.toString());
setMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, Boolean.TRUE.toString());
addOrganizationDefaultClientScope("direct-grant");

oauth.client("direct-grant", "password");
oauth.scope("openid");
AccessTokenResponse response = oauth.doPasswordGrantRequest(member.getEmail(), memberPassword);
assertThat(response.getStatusCode(), is(Response.Status.OK.getStatusCode()));

AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();
assertThat(accessToken.getOtherClaims(), hasKey(OAuth2Constants.ORGANIZATION));
assertThat((List<String>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION), hasSize(0));

IDToken idToken = TokenVerifier.create(response.getIdToken(), IDToken.class).getToken();
assertThat(idToken.getOtherClaims(), hasKey(OAuth2Constants.ORGANIZATION));
assertThat((List<String>) idToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION), hasSize(0));

UserInfoResponse userInfoResponse = oauth.userInfoRequest(response.getAccessToken()).send();
assertThat(userInfoResponse.getStatusCode(), is(Response.Status.OK.getStatusCode()));
assertThat(userInfoResponse.getUserInfo().getOtherClaims(), hasKey(OAuth2Constants.ORGANIZATION));
assertThat((List<String>) userInfoResponse.getUserInfo().getOtherClaims().get(OAuth2Constants.ORGANIZATION),
hasSize(0));
}

@Test
@SuppressWarnings("unchecked")
public void testEmptyOrganizationClaimMappedAsJsonWhenConfigured() throws Exception {
OrganizationRepresentation orgARep = createOrganization("orga", true);
OrganizationResource orgA = managedRealm.admin().organizations().get(orgARep.getId());
MemberRepresentation member = addMember(orgA, "member@" + orgARep.getDomains().iterator().next().getName());
orgA.members().member(member.getId()).delete().close();

setMapperConfig(OrganizationMembershipMapper.INCLUDE_EMPTY_CLAIM, Boolean.TRUE.toString());
setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, "JSON");
addOrganizationDefaultClientScope("direct-grant");

oauth.client("direct-grant", "password");
oauth.scope("openid");
AccessTokenResponse response = oauth.doPasswordGrantRequest(member.getEmail(), memberPassword);
assertThat(response.getStatusCode(), is(Response.Status.OK.getStatusCode()));

AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();
assertThat(accessToken.getOtherClaims(), hasKey(OAuth2Constants.ORGANIZATION));
assertThat((Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION), anEmptyMap());
}

@Test
public void testOrganizationsClaimMappedIfScopeInTokenDisabled() throws Exception {
OrganizationRepresentation orgA = createOrganization("orga", true);
Expand Down Expand Up @@ -1803,6 +1860,22 @@ public void testCustomClaimName() throws Exception {
assertThat(token.getOtherClaims().get("my_orgs"), notNullValue());
}

private void addOrganizationDefaultClientScope(String clientId) {
ClientRepresentation clientRep = managedRealm.admin().clients().findByClientId(clientId).get(0);
ClientResource client = managedRealm.admin().clients().get(clientRep.getId());
ClientScopeRepresentation orgScopeRep = client.getOptionalClientScopes().stream()
.filter(scope -> "organization".equals(scope.getName()))
.findAny()
.orElseThrow();

client.removeOptionalClientScope(orgScopeRep.getId());
client.addDefaultClientScope(orgScopeRep.getId());
getCleanup().addCleanup(() -> {
client.removeDefaultClientScope(orgScopeRep.getId());
client.addOptionalClientScope(orgScopeRep.getId());
});
}

@Test
public void testCustomOrganizationClaimDoesNotTriggerOrganizationValidation() throws Exception {
// Create a plain user – not a member of any organization.
Expand Down