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:
parent
e7bc796553
commit
fa1571f231
13 changed files with 209 additions and 17 deletions
|
@ -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";
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -26,4 +26,6 @@ public interface OrganizationModel {
|
|||
void setName(String name);
|
||||
|
||||
String getName();
|
||||
|
||||
RealmModel getRealm();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -116,6 +116,7 @@ phoneScopeConsentText=Phone number
|
|||
offlineAccessScopeConsentText=Offline Access
|
||||
samlRoleListScopeConsentText=My Roles
|
||||
rolesScopeConsentText=User roles
|
||||
organizationScopeConsentText=Organization
|
||||
|
||||
restartLoginTooltip=Restart login
|
||||
|
||||
|
|
Loading…
Reference in a new issue