Skip to content

Commit

Permalink
Allow enforce that users are members of organizations when authentica…
Browse files Browse the repository at this point in the history
…ting

Closes keycloak#34275

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
  • Loading branch information
pedroigor committed Jan 17, 2025
1 parent 76a9408 commit 06eb33f
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ Change the *first broker login* flow by following these steps:
.Organizations first broker flow
image:images/organizations-first-broker-flow.png[alt="Organizations first broker flow"]


You should now be able to authenticate using any identity provider associated with an organization
and have the user joining the organization as a member as soon as they complete the first browser login flow.

== Configuring how users authenticate

If the flow supports organizations, you can configure some of the steps to change how users authenticate to the realm.

For example, some use cases will require users to authenticate to a realm only if they are a member of any or a specific organization in the realm.

To enable this behavior, you need to enable the `Requires user membership` setting on the `Organization Identity-First Login` execution step by clicking on its settings.

If enabled, and after the user provides the username or email in the identity-first login page, the server will
try to resolve a organization where the user is a member by looking at any existing membership or based on the semantics of the <<_mapping_organization_claims_,organization>> scope,
if requested by the client. If not a member of an organization, an error page will be shown.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[id="mapping-organization-claims_{context}"]

[[_mapping_organization_claims_]]
= Mapping organization claims
[role="_abstract"]
To map organization-specific claims into tokens, a client needs to request the *organization* scope when sending
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1376,7 +1376,11 @@ public void updateAuthenticationFlow(AuthenticationFlowModel model) {
@Override
public Stream<AuthenticationExecutionModel> getAuthenticationExecutionsStream(String flowId) {
if (isUpdated()) return updated.getAuthenticationExecutionsStream(flowId);
return cached.getAuthenticationExecutions().get(flowId).stream();
List<AuthenticationExecutionModel> executions = cached.getAuthenticationExecutions().get(flowId);
if (executions == null) {
return Stream.empty();
}
return executions.stream();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import java.util.stream.Stream;

import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
Expand All @@ -39,6 +40,7 @@
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
Expand Down Expand Up @@ -104,6 +106,17 @@ public void action(AuthenticationFlowContext context) {
return;
}

if (user != null && isRequiresMembership(context) && !organization.isMember(user)) {
String errorMessage = "notMemberOfOrganization";
// do not show try another way
context.setAuthenticationSelections(List.of());
Response challenge = context.form()
.setError(errorMessage, organization.getName())
.createErrorPage(Response.Status.FORBIDDEN);
context.failure(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, challenge, "User " + user.getUsername() + " not a member of organization " + organization.getAlias(), errorMessage);
return;
}

// make sure the organization is set to the session to make it available to templates
session.getContext().setOrganization(organization);

Expand Down Expand Up @@ -329,4 +342,12 @@ private boolean hasPublicBrokers(OrganizationModel organization) {
private OrganizationProvider getOrganizationProvider() {
return session.getProvider(OrganizationProvider.class);
}

private boolean isRequiresMembership(AuthenticationFlowContext context) {
return Boolean.parseBoolean(getConfig(context).getOrDefault(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.FALSE.toString()));
}

private Map<String, String> getConfig(AuthenticationFlowContext context) {
return Optional.ofNullable(context.getAuthenticatorConfig()).map(AuthenticatorConfigModel::getConfig).orElse(Map.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

package org.keycloak.organization.authentication.authenticators.browser;

import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE;

import java.util.Collections;
import java.util.List;

import org.keycloak.Config.Scope;
Expand All @@ -34,6 +37,7 @@
public class OrganizationAuthenticatorFactory extends IdentityProviderAuthenticatorFactory implements EnvironmentDependentProviderFactory {

public static final String ID = "organization";
public static final String REQUIRES_USER_MEMBERSHIP = "requiresUserMembership";

@Override
public String getId() {
Expand Down Expand Up @@ -61,7 +65,7 @@ public boolean isSupported(Scope config) {
}

@Override
public List<ProviderConfigProperty> getConfigProperties() { // org identity-first login
return List.of();
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.singletonList(new ProviderConfigProperty(REQUIRES_USER_MEMBERSHIP, "Requires user membership", "Enforces that users authenticating in the scope of an organization are members. If not a member, the user won't be able to proceed authenticating to the realm", BOOLEAN_TYPE, null));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration;
import org.keycloak.testsuite.organization.broker.BrokerConfigurationWrapper;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.SelectOrganizationPage;
Expand All @@ -72,6 +73,9 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
@Page
protected LoginPage loginPage;

@Page
protected ErrorPage errorPage;

@Page
protected SelectOrganizationPage selectOrganizationPage;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@
import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import org.keycloak.testsuite.runonserver.RunOnServer;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.FlowUtil;

public class OrganizationAuthenticationTest extends AbstractOrganizationTest {

Expand Down Expand Up @@ -134,4 +140,43 @@ public void testForceReAuthenticationBeforeRequiredAction() {
oauth.maxAge(null);
}
}

@Test
public void testRequiresUserMembership() {
runOnServer(setAuthenticatorConfig(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.TRUE.toString()));

try {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
UserRepresentation member = addMember(organization);
organization.members().member(member.getId()).delete().close();
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
// user is not a member of any organization
assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization " + organization.toRepresentation().getName()));

organization.members().addMember(member.getId()).close();
OrganizationRepresentation orgB = createOrganization("org-b");
oauth.clientId("broker-app");
oauth.scope("organization:org-b");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
// user is not a member of the organization selected by the client
assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization " + orgB.getName()));
errorPage.assertTryAnotherWayLinkAvailability(false);
} finally {
runOnServer(setAuthenticatorConfig(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.FALSE.toString()));
}
}

private void runOnServer(RunOnServer function) {
testingClient.server(bc.consumerRealmName()).run(function);
}

public static RunOnServer setAuthenticatorConfig(String key, String value) {
return session -> {
RealmModel realm = session.getContext().getRealm();
FlowUtil.setAuthenticatorConfig(session, realm.getFlowByAlias(DefaultAuthenticationFlows.BROWSER_FLOW).getId(), OrganizationAuthenticatorFactory.ID, key, value);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.keycloak.models.utils.DefaultAuthenticationFlows.BROWSER_FLOW;
import static org.keycloak.models.utils.DefaultAuthenticationFlows.DIRECT_GRANT_FLOW;
Expand Down Expand Up @@ -329,4 +331,46 @@ private void checkAndRestoreDefaultFlow(Supplier<AuthenticationFlowModel> getFlo
setFlow.accept(realm.getFlowByAlias(defaultFlowAlias));
}
}

/**
* <p>Sets the given {@code key} and {@code value} to an execution that maps to the given {@code authenticatorId}.
*
* <p>This method will try to find the given {@code authenticatorId} recursively by going through all the subflows, if there are any.
*
* @param session the session
* @param flowId the parent flow
* @param authenticatorId the authenticator id
* @param key the key
* @param value the value
*/
public static void setAuthenticatorConfig(KeycloakSession session, String flowId, String authenticatorId, String key, String value) {
RealmModel realm = session.getContext().getRealm();

for (AuthenticationExecutionModel execution : Optional.ofNullable(realm.getAuthenticationExecutionsStream(flowId)).orElse(Stream.empty()).toList()) {
if (execution.isAuthenticatorFlow()) {
setAuthenticatorConfig(session, execution.getFlowId(), authenticatorId, key, value);
} else if (authenticatorId.equals(execution.getAuthenticator())) {
AuthenticatorConfigModel configModel;
String configId = execution.getAuthenticatorConfig();

if (configId == null) {
configModel = new AuthenticatorConfigModel();
configModel.setAlias(authenticatorId + flowId);
configModel = realm.addAuthenticatorConfig(configModel);
execution.setAuthenticatorConfig(configModel.getId());
realm.updateAuthenticatorExecution(execution);
} else {
configModel = realm.getAuthenticatorConfigById(configId);
}

Map<String, String> config = new HashMap<>(configModel.getConfig());

configModel.setConfig(config);
config.put(key, value);
configModel.setConfig(config);

realm.updateAuthenticatorConfig(configModel);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,4 @@ organization.confirm-membership.title=You are about to join organization ${kc.or
organization.confirm-membership=By clicking on the link below, you will become a member of the {0} organization:
organization.member.register.title=Create an account to join the ${kc.org.name} organization
organization.select=Select an organization to proceed:
notMemberOfOrganization=User is not a member of the organization {0}

0 comments on commit 06eb33f

Please sign in to comment.