Map organization metadata when issuing tokens for OIDC clients acting on behalf of an organization member

Closes #27993

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik 2024-03-26 16:30:54 +01:00 committed by Pedro Igor
parent e7bc796553
commit fa1571f231
13 changed files with 209 additions and 17 deletions

View file

@ -92,6 +92,8 @@ public interface OAuth2Constants {
String SCOPE_ADDRESS = "address";
String SCOPE_PHONE = "phone";
String ORGANIZATION = "organization";
String UI_LOCALES_PARAM = "ui_locales";
String PROMPT = "prompt";

View file

@ -29,7 +29,7 @@ import jakarta.persistence.Table;
@Table(name="ORGANIZATION")
@Entity
@NamedQueries({
@NamedQuery(name="getByRealm", query="select o.id from OrganizationEntity o where o.realmId = :realmId")
@NamedQuery(name="getByRealm", query="select o from OrganizationEntity o where o.realmId = :realmId")
})
public class OrganizationEntity {

View file

@ -41,11 +41,9 @@ public class JpaOrganizationProvider implements OrganizationProvider {
private final EntityManager em;
private final GroupProvider groupProvider;
private final KeycloakSession session;
private final UserProvider userProvider;
public JpaOrganizationProvider(KeycloakSession session) {
this.session = session;
em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
groupProvider = session.groups();
userProvider = session.users();
@ -63,7 +61,7 @@ public class JpaOrganizationProvider implements OrganizationProvider {
em.persist(entity);
return new OrganizationAdapter(entity, session);
return new OrganizationAdapter(realm, entity);
}
@Override
@ -118,11 +116,11 @@ public class JpaOrganizationProvider implements OrganizationProvider {
@Override
public Stream<OrganizationModel> getOrganizationsStream(RealmModel realm) {
throwExceptionIfRealmIsNull(realm);
TypedQuery<String> query = em.createNamedQuery("getByRealm", String.class);
TypedQuery<OrganizationEntity> query = em.createNamedQuery("getByRealm", OrganizationEntity.class);
query.setParameter("realmId", realm.getId());
return closing(query.getResultStream().map(id -> getAdapter(realm, id)));
return closing(query.getResultStream().map(entity -> new OrganizationAdapter(realm, entity)));
}
@Override
@ -193,7 +191,7 @@ public class JpaOrganizationProvider implements OrganizationProvider {
throw new ModelException("Organization [" + entity.getId() + " does not belong to realm [" + realm.getId() + "]");
}
return new OrganizationAdapter(entity, session);
return new OrganizationAdapter(realm, entity);
}
private GroupModel createOrganizationGroup(RealmModel realm, String name) {
@ -209,7 +207,7 @@ public class JpaOrganizationProvider implements OrganizationProvider {
throw new ModelException("A group with the same name already exist and it is bound to different organization");
}
return groupProvider.createGroup(realm, KeycloakModelUtils.generateId(), groupName);
return groupProvider.createGroup(realm, groupName);
}
private String getCanonicalGroupName(String name) {

View file

@ -17,19 +17,19 @@
package org.keycloak.organization.jpa;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.jpa.JpaModel;
import org.keycloak.models.jpa.entities.OrganizationEntity;
public final class OrganizationAdapter implements OrganizationModel, JpaModel<OrganizationEntity> {
private final RealmModel realm;
private final OrganizationEntity entity;
private final KeycloakSession session;
public OrganizationAdapter(OrganizationEntity entity, KeycloakSession session) {
public OrganizationAdapter(RealmModel realm, OrganizationEntity entity) {
this.realm = realm;
this.entity = entity;
this.session = session;
}
@Override
@ -37,8 +37,9 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
return entity.getId();
}
String getRealm() {
return entity.getRealmId();
@Override
public RealmModel getRealm() {
return realm;
}
String getGroupId() {
@ -69,6 +70,9 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
.append("name=")
.append(getName())
.append(",")
.append("realm=")
.append(getRealm().getName())
.append(",")
.append("groupId=")
.append(getGroupId()).toString();
}

View file

@ -26,4 +26,6 @@ public interface OrganizationModel {
void setName(String name);
String getName();
RealmModel getRealm();
}

View file

@ -39,6 +39,7 @@ import org.keycloak.protocol.oidc.mappers.AddressMapper;
import org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper;
import org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper;
import org.keycloak.protocol.oidc.mappers.FullNameMapper;
import org.keycloak.protocol.oidc.mappers.OrganizationMembershipMapper;
import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
import org.keycloak.protocol.oidc.mappers.UserClientRoleMappingMapper;
import org.keycloak.protocol.oidc.mappers.UserPropertyMapper;
@ -87,6 +88,7 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
public static final String AUDIENCE_RESOLVE = "audience resolve";
public static final String ALLOWED_WEB_ORIGINS = "allowed web origins";
public static final String ACR = "acr loa level";
public static final String ORGANIZATION = "organization";
// microprofile-jwt claims
public static final String UPN = "upn";
public static final String GROUPS = "groups";
@ -102,6 +104,7 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
public static final String PHONE_SCOPE_CONSENT_TEXT = "${phoneScopeConsentText}";
public static final String OFFLINE_ACCESS_SCOPE_CONSENT_TEXT = Constants.OFFLINE_ACCESS_SCOPE_CONSENT_TEXT;
public static final String ROLES_SCOPE_CONSENT_TEXT = "${rolesScopeConsentText}";
public static final String ORGANIZATION_SCOPE_CONSENT_TEXT = "${organizationScopeConsentText}";
public static final String CONFIG_LEGACY_LOGOUT_REDIRECT_URI = "legacy-logout-redirect-uri";
public static final String SUPPRESS_LOGOUT_CONFIRMATION_SCREEN = "suppress-logout-confirmation-screen";
@ -295,6 +298,17 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
addWebOriginsClientScope(newRealm);
addMicroprofileJWTClientScope(newRealm);
addAcrClientScope(newRealm);
if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
ClientScopeModel organizationScope = newRealm.addClientScope(OAuth2Constants.ORGANIZATION);
organizationScope.setDescription("Additional claims about the organization a subject belongs to");
organizationScope.setDisplayOnConsentScreen(true);
organizationScope.setConsentScreenText(ORGANIZATION_SCOPE_CONSENT_TEXT);
organizationScope.setIncludeInTokenScope(true);
organizationScope.setProtocol(getId());
organizationScope.addProtocolMapper(OrganizationMembershipMapper.create(ORGANIZATION, true, true, true));
newRealm.addDefaultClientScope(organizationScope, false);
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.mappers;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.Config;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper, EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "oidc-organization-membership-mapper";
@Override
public List<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> properties = new ArrayList<>();
OIDCAttributeMapperHelper.addIncludeInTokensConfig(properties, OrganizationMembershipMapper.class);
return properties;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Organization Membership";
}
@Override
public String getDisplayCategory() {
return TOKEN_MAPPER_CATEGORY;
}
@Override
public String getHelpText() {
return "Map user Organization membership";
}
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) {
RealmModel realm = keycloakSession.getContext().getRealm();
UserModel user = userSession.getUser();
OrganizationProvider organizationProvider = keycloakSession.getProvider(OrganizationProvider.class);
OrganizationModel organization = organizationProvider.getOrganizationByMember(realm, user);
if (organization != null) {
Map<String, Map<String, Object>> claim = new HashMap<>();
claim.put(organization.getName(), Map.of());
token.getOtherClaims().put(OAuth2Constants.ORGANIZATION, claim);
}
}
public static ProtocolMapperModel create(String name, boolean accessToken, boolean idToken, boolean introspectionEndpoint) {
ProtocolMapperModel mapper = new ProtocolMapperModel();
mapper.setName(name);
mapper.setProtocolMapper(PROVIDER_ID);
mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> config = new HashMap<>();
if (accessToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
if (introspectionEndpoint) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true");
mapper.setConfig(config);
return mapper;
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION);
}
}

View file

@ -25,6 +25,7 @@ org.keycloak.protocol.oidc.mappers.RoleNameMapper
org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper
org.keycloak.protocol.oidc.mappers.GroupMembershipMapper
org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper
org.keycloak.protocol.oidc.mappers.OrganizationMembershipMapper
org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper
org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper
org.keycloak.protocol.oidc.mappers.AcrProtocolMapper

View file

@ -27,14 +27,19 @@ import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.admin.Users;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public abstract class AbstractOrganizationTest extends AbstractAdminTest {
protected String organizationName = "neworg";
protected String memberEmail = "jdoe@neworg.org";
protected String memberPassword = "password";
protected OrganizationRepresentation createOrganization() {
return createOrganization("neworg");
return createOrganization(organizationName);
}
protected OrganizationRepresentation createOrganization(String name) {
@ -56,7 +61,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
}
protected UserRepresentation addMember(OrganizationResource organization) {
return addMember(organization, "jdoe@neworg.org");
return addMember(organization, memberEmail);
}
protected UserRepresentation addMember(OrganizationResource organization, String email) {
@ -64,6 +69,8 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
expected.setEmail(email);
expected.setUsername(expected.getEmail());
expected.setEnabled(true);
Users.setPasswordFor(expected, memberPassword);
try (Response response = organization.members().addMember(expected)) {
assertEquals(Status.CREATED.getStatusCode(), response.getStatus());

View file

@ -0,0 +1,57 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.notNullValue;
import java.util.Map;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.common.Profile.Feature;
import org.keycloak.representations.AccessToken;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
@EnableFeature(Feature.ORGANIZATION)
public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest {
@Test
public void testClaim() throws Exception {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
addMember(organization);
oauth.clientId("direct-grant");
oauth.scope("openid organization");
AccessTokenResponse response = oauth.doGrantAccessTokenRequest("password", memberEmail, memberPassword);
assertThat(response.getScope(), containsString("organization"));
AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
@SuppressWarnings("unchecked")
Map<String, Object> claim = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(claim, notNullValue());
assertThat(claim.get(organizationName), notNullValue());
}
}

View file

@ -43,7 +43,7 @@ public class OrganizationTest extends AbstractOrganizationTest {
public void testUpdate() {
OrganizationRepresentation expected = createOrganization();
assertEquals("neworg", expected.getName());
assertEquals(organizationName, expected.getName());
expected.setName("acme");
OrganizationResource organization = testRealm().organizations().get(expected.getId());

View file

@ -76,6 +76,7 @@ phoneScopeConsentText=Phone number
offlineAccessScopeConsentText=Offline Access
samlRoleListScopeConsentText=My Roles
rolesScopeConsentText=User roles
organizationScopeConsentText=Organization
role_admin=Admin
role_realm-admin=Realm Admin

View file

@ -116,6 +116,7 @@ phoneScopeConsentText=Phone number
offlineAccessScopeConsentText=Offline Access
samlRoleListScopeConsentText=My Roles
rolesScopeConsentText=User roles
organizationScopeConsentText=Organization
restartLoginTooltip=Restart login