8000 Make OrganizationGroupMembershipMapper claim name configurable by sventorben · Pull Request #47852 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
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 @@ -160,6 +160,7 @@ Notice:

- Groups appear within the organization claim, not as a separate top-level claim
- Group paths are relative to the organization
- Make sure you have a single `organization-membership` mapper so that groups are included in the same claim as the organization data
- Multiple organizations can be included if the user is a member of multiple organizations and uses `scope=organization:*` or multiple specific scopes like `scope=organization:org-a organization:org-b`

=== SAML: Organization groups in assertions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import java.util.stream.Stream;

import org.keycloak.Config;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
Expand All @@ -46,6 +45,8 @@
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;

import static org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME;

public class OrganizationGroupMembershipMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper, EnvironmentDependentProviderFactory {

public static final String PROVIDER_ID = "oidc-organization-group-membership-mapper";
Expand Down Expand Up @@ -93,9 +94,19 @@ protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionMod

UserModel user = userSession.getUser();
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
ProtocolMapperModel organizationMapperModel = getOrganizationMapperModel(clientSessionCtx);

if (organizationMapperModel == null) {
// this mapper requires the organization scope and its mapper set to the request
return;
}

String orgClaimName = organizationMapperModel.getConfig().get(TOKEN_CLAIM_NAME);

// Get or create organization claims map for composition
final Map<String, Object> orgClaims = OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap(token, OAuth2Constants.ORGANIZATION);
model = getEffectiveModel(session, userSession.getRealm(), model);
model.getConfig().put(TOKEN_CLAIM_NAME, orgClaimName);

Map<String, Object> orgClaims = OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap(token, model);

// Add groups to each organization
organizations.forEach(org -> {
Expand Down Expand Up @@ -153,4 +164,11 @@ public int getPriority() {
// Run after OrganizationMembershipMapper (higher number = later execution)
return 10;
}

private ProtocolMapperModel getOrganizationMapperModel(ClientSessionContext clientSessionContext) {
return clientSessionContext.getProtocolMappersStream()
.filter(m -> OrganizationMembershipMapper.PROVIDER_ID.equals(m.getProtocolMapper()))
.findAny()
.orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,12 @@ private static JsonNode getJsonNode(Object attributeValue) {
* (String type), existing Map, or creates empty Map if null.
*
* @param token the token
* @param claimName the name of the organization claim
* @param effectiveModel the effective model of the protocol mapper to retrieve the name of the organization claim
* @return a mutable Map that can be manipulated; changes will be reflected in the token
*/
public static Map<String, Object> getOrInitializeOrganizationClaimAsMap(IDToken token, String claimName) {
Object existingClaim = token.getOtherClaims().get(claimName);
public static Map<String, Object> getOrInitializeOrganizationClaimAsMap(IDToken token, ProtocolMapperModel effectiveModel) {
List<String> claimPath = splitClaimPath(effectiveModel.getConfig().get(TOKEN_CLAIM_NAME));
Object existingClaim = getNestedClaimValue(token.getOtherClaims(), claimPath);
Map<String, Object> result;

if (existingClaim instanceof ObjectNode) {
Expand All @@ -317,10 +318,25 @@ public static Map<String, Object> getOrInitializeOrganizationClaimAsMap(IDToken
result = new HashMap<>();
}

token.setOtherClaims(claimName, result);
OIDCAttributeMapperHelper.mapClaim(token, effectiveModel, result);
return result;
}

private static Object getNestedClaimValue(Map<String, Object> claims, List<String> path) {
if (path.isEmpty()) return null;
Map<String, Object> current = claims;
for (int i = 0; i < path.size() - 1; i++) {
Object next = current.get(path.get(i));
if (!(next instanceof Map)) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> nested = (Map<String, Object>) next;
current = nested;
}
return current.get(path.get(path.size() - 1));
}

public static void mapClaim(IDToken token, ProtocolMapperModel mappingModel, Object attributeValue) {
mapClaim(token, mappingModel, attributeValue, tokenPropertySetters, token.getOtherClaims());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,23 @@ public void addGroupMapper() {
// Reset to defaults
setMapperConfig(ProtocolMapperUtils.MULTIVALUED, null);
setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null);
setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, null);
setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_ATTRIBUTES, null);
setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_ID, null);

// Add the organization group membership mapper to the organization scope
ClientScopeRepresentation orgScope = managedRealm.admin().clientScopes().findAll().stream()
.filter(s -> OIDCLoginProtocolFactory.ORGANIZATION.equals(s.getName()))
.findAny()
.orElseThrow();
List<ProtocolMapperRepresentation> protocolMappers = orgScope.getProtocolMappers();

if (protocolMappers.stream().anyMatch(m -> "organization-groups".equals(m.getName()))) {
setGroupMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
setGroupMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
setGroupMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true");
setGroupMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION);
return;
}

ClientScopeResource orgScopeResource = managedRealm.admin().clientScopes().get(orgScope.getId());

Expand All @@ -85,11 +94,34 @@ public void addGroupMapper() {
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true");
config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION);
groupMapper.setConfig(config);

orgScopeResource.getProtocolMappers().createMapper(groupMapper).close();
}

private void setGroupMapperConfig(String key, String value) {
ClientScopeRepresentation orgScope = managedRealm.admin().clientScopes().findAll().stream()
.filter(s -> OIDCLoginProtocolFactory.ORGANIZATION.equals(s.getName()))
.findAny()
.orElseThrow();
ClientScopeResource orgScopeResource = managedRealm.admin().clientScopes().get(orgScope.getId());

ProtocolMapperRepresentation groupMapper = orgScopeResource.getProtocolMappers().getMappers().stream()
.filter(m -> "organization-groups".equals(m.getName()))
.findAny()
.orElseThrow();

Map<String, String> config = groupMapper.getConfig();
if (value == null) {
config.remove(key);
} else {
config.put(key, value);
}

orgScopeResource.getProtocolMappers().update(groupMapper.getId(), groupMapper);
}

@Test
@SuppressWarnings("unchecked")
public void testNestedGroupsWithRelativePaths() throws Exception {
Expand Down Expand Up @@ -291,4 +323,76 @@ public void testCompositionWithStringTypeOrgMapper() throws Exception {
List<String> orgCGroups = (List<String>) orgCData.get("groups");
assertThat(orgCGroups, empty());
}

@Test
@SuppressWarnings("unchecked")
public void testCustomClaimName() throws Exception {
OrganizationRepresentation orgRep = createOrganization("acme");
OrganizationResource org = managedRealm.admin().organizations().get(orgRep.getId());
MemberRepresentation member = addMember(org);

GroupRepresentation engineering = new GroupRepresentation();
engineering.setName("engineering");
String engineeringId;
try (Response response = org.groups().addTopLevelGroup(engineering)) {
engineeringId = ApiUtil.getCreatedId(response);
}
org.groups().group(engineeringId).addMember(member.getId());

setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "my_orgs");

oauth.client("direct-grant", "password");
oauth.scope("openid organization");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);

AccessToken token = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();

assertThat(token.getOtherClaims(), not(hasKey(OAuth2Constants.ORGANIZATION)));
assertThat(token.getOtherClaims(), hasKey("my_orgs"));

Map<String, Object> orgClaims = (Map<String, Object>) token.getOtherClaims().get("my_orgs");
Map<String, Object> acmeData = (Map<String, Object>) orgClaims.get("acme");
assertThat(acmeData, notNullValue());
List<String> groups = (List<String>) acmeData.get("groups");
assertThat(groups, hasSize(1));
assertThat(groups, hasItem("/engineering"));
}

@Test
@SuppressWarnings("unchecked")
public void testDottedClaimName() throws Exception {
OrganizationRepresentation orgRep = createOrganization("acme");
OrganizationResource org = managedRealm.admin().organizations().get(orgRep.getId());
MemberRepresentation member = addMember(org);

GroupRepresentation engineering = new GroupRepresentation();
engineering.setName("engineering");
String engineeringId;
try (Response response = org.groups().addTopLevelGroup(engineering)) {
engineeringId = ApiUtil.getCreatedId(response);
}
org.groups().group(engineeringId).addMember(member.getId());

setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "custom.org");

oauth.client("direct-grant", "password");
oauth.scope("openid organization");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);

AccessToken token = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();

assertThat(token.getOtherClaims(), not(hasKey(OAuth2Constants.ORGANIZATION)));
assertThat(token.getOtherClaims(), not(hasKey("custom.org")));
assertThat(token.getOtherClaims(), hasKey("custom"));

Map<String, Object> customClaims = (Map<String, Object>) token.getOtherClaims().get("custom");
assertThat(customClaims, hasKey("org"));

Map<String, Object> orgClaims = (Map<String, Object>) customClaims.get("org");
Map<String, Object> acmeData = (Map<String, Object>) orgClaims.get("acme");
assertThat(acmeData, notNullValue());
List<String> groups = (List<String>) acmeData.get("groups");
assertThat(groups, hasSize(1));
assertThat(groups, hasItem("/engineering"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
Expand All @@ -88,6 +89,7 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
public void onBefore() {
setMapperConfig(ProtocolMapperUtils.MULTIVALUED, null);
setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null);
setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, null);
}

@Test
Expand Down Expand Up @@ -1691,6 +1693,25 @@ private void assertScopeAndClaims(String orgScope, OrganizationRepresentation or
assertThat(refreshToken.getScope(), containsString(orgScope));
}

@Test
@SuppressWarnings("unchecked")
public void testCustomClaimName() throws Exception {
OrganizationResource org = managedRealm.admin().organizations().get(createOrganization("acme").getId());
addMember(org);

setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "my_orgs");

oauth.client("direct-grant", "password");
oauth.scope("openid organization");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);

AccessToken token = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();

assertThat(token.getOtherClaims(), not(hasKey(OAuth2Constants.ORGANIZATION)));
assertThat(token.getOtherClaims(), hasKey("my_orgs"));
assertThat(token.getOtherClaims().get("my_orgs"), notNullValue());
}

private void assertResponseMissingOrganizationScopeAndClaims(AccessTokenResponse response) {
assertThat(response.getScope(), not(containsString("organization")));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
Expand Down
Loading
0