Skip to content

Commit

Permalink
Remember the organization once selected when reloading pages
Browse files Browse the repository at this point in the history
Closes keycloak#36629

Signed-off-by: Pedro Igor <[email protected]>
  • Loading branch information
pedroigor committed Jan 22, 2025
1 parent 70da08e commit 87e8fbc
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -103,25 +103,27 @@ public void action(AuthenticationFlowContext context) {
if (shouldUserSelectOrganization(context, user)) {
return;
}

if (isMembershipRequired(context, null, user)) {
return;
}

clearAuthenticationSession(context);
// request does not map to any organization, go to the next step/sub-flow
context.attempted();
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;
}

// remember the organization during the lifetime of the authentication session
AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
authenticationSession.setAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId());
// make sure the organization is set to the session to make it available to templates
session.getContext().setOrganization(organization);

if (isMembershipRequired(context, organization, user)) {
return;
}

if (tryRedirectBroker(context, organization, user, username, domain)) {
return;
}
Expand All @@ -137,7 +139,7 @@ public void action(AuthenticationFlowContext context) {
return;
}

if (isSSOAuthentication(context.getAuthenticationSession())) {
if (isSSOAuthentication(authenticationSession)) {
// if re-authenticating in the scope of an organization
context.success();
} else {
Expand All @@ -153,9 +155,10 @@ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserMode
private OrganizationModel resolveOrganization(UserModel user, String domain) {
KeycloakContext context = session.getContext();
HttpRequest request = context.getHttpRequest();
AuthenticationSessionModel authSession = context.getAuthenticationSession();
MultivaluedMap<String, String> parameters = request.getDecodedFormParameters();
// parameter from the organization selection page
List<String> alias = parameters.getOrDefault(OrganizationModel.ORGANIZATION_ATTRIBUTE, List.of());
AuthenticationSessionModel authSession = context.getAuthenticationSession();

if (alias.isEmpty()) {
OrganizationModel organization = Organizations.resolveOrganization(session, user, domain);
Expand Down Expand Up @@ -210,6 +213,7 @@ public Map<String, Object> apply(Map<String, Object> attributes) {
return attributes;
}
});
clearAuthenticationSession(context);
context.challenge(form.createForm("select-organization.ftl"));
return true;
}
Expand Down Expand Up @@ -269,6 +273,8 @@ private UserModel resolveUser(AuthenticationFlowContext context, String username
RealmModel realm = session.getContext().getRealm();
UserModel user = Optional.ofNullable(users.getUserByEmail(realm, username)).orElseGet(() -> users.getUserByUsername(realm, username));

// make sure the organization will be resolved based on the username provided
clearAuthenticationSession(context);
context.setUser(user);

return user;
Expand Down Expand Up @@ -359,4 +365,47 @@ private boolean isRequiresMembership(AuthenticationFlowContext context) {
private Map<String, String> getConfig(AuthenticationFlowContext context) {
return Optional.ofNullable(context.getAuthenticatorConfig()).map(AuthenticatorConfigModel::getConfig).orElse(Map.of());
}

private void clearAuthenticationSession(AuthenticationFlowContext context) {
AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
authenticationSession.removeAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
}

private boolean isMembershipRequired(AuthenticationFlowContext context, OrganizationModel organization, UserModel user) {
if (user == null || !isRequiresMembership(context)) {
return false;
}

if (organization == null) {
String rawScopes = context.getAuthenticationSession().getClientNote(OAuth2Constants.SCOPE);
OrganizationScope scope = OrganizationScope.valueOfScope(session, rawScopes);

if (scope != null) {
organization = scope.resolveOrganizations(session, rawScopes).findAny().orElse(null);
}
}

if (organization != null && organization.isMember(user)) {
return false;
}

// do not show try another way
context.setAuthenticationSelections(List.of());

if (organization == null) {
String errorMessage = "notMemberOfAnyOrganization";
Response challenge = context.form()
.setError(errorMessage)
.createErrorPage(Response.Status.FORBIDDEN);
context.failure(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, challenge, "User " + user.getUsername() + " not a member of any organization ", errorMessage);
} else {
String errorMessage = "notMemberOfOrganization";
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 true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -63,7 +64,10 @@ public enum OrganizationScope {
return getProvider(session).getByMember(user);
},
(organizations) -> true,
(session, current, previous) -> valueOfScope(session, current) == null ? previous : current),
(session, current, previous) -> {
return valueOfScope(session, current) == null ? previous : current;
},
(scopes, session) -> Stream.empty()),

/**
* Maps to a specific organization the user is a member. When this scope is requested by clients, only the
Expand Down Expand Up @@ -99,7 +103,11 @@ public enum OrganizationScope {
}

return null;
}),
},
(scopes, session) -> parseScopeParameter(session, scopes)
.map((String scope) -> parseScopeValue(session, scope))
.map(alias -> getProvider(session).getByAlias(alias))
.filter(Objects::nonNull)),

/**
* Maps to a single organization if the user is a member of a single organization. When this scope is requested by clients,
Expand Down Expand Up @@ -144,7 +152,8 @@ public enum OrganizationScope {
}

return null;
});
},
((scopes, session) -> Stream.empty()));

private static final String ORGANIZATION_SCOPES_SESSION_ATTRIBUTE = "kc.org.client.scope";
private static final String UNSUPPORTED_ORGANIZATION_SCOPES_ATTRIBUTE = "kc.org.client.scope.unsupported";
Expand All @@ -159,7 +168,7 @@ public enum OrganizationScope {
private final Predicate<String> valueMatcher;

/**
* Resolves the organizations based on the values of the scope.
* Resolves the organizations of the user based on the values of the scope.
*/
private final TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver;

Expand All @@ -173,11 +182,17 @@ public enum OrganizationScope {
*/
private final TriFunction<KeycloakSession, String, String, String> nameResolver;

OrganizationScope(Predicate<String> valueMatcher, TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver, Predicate<Stream<OrganizationModel>> valueValidator, TriFunction<KeycloakSession, String, String, String> nameResolver) {
/**
* Resolves the organizations from the scope.
*/
private final BiFunction<String, KeycloakSession, Stream<OrganizationModel>> rawValueResolver;

OrganizationScope(Predicate<String> valueMatcher, TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver, Predicate<Stream<OrganizationModel>> valueValidator, TriFunction<KeycloakSession, String, String, String> nameResolver, BiFunction<String, KeycloakSession, Stream<OrganizationModel>> rawValueResolver) {
this.valueMatcher = valueMatcher;
this.valueResolver = valueResolver;
this.valueValidator = valueValidator;
this.nameResolver = nameResolver;
this.rawValueResolver = rawValueResolver;
}

/**
Expand All @@ -186,7 +201,7 @@ public enum OrganizationScope {
* @param user the user. Can be {@code null} depending on how the scope resolves its value.
* @param scope the string referencing the scope
* @param session the session
* @return the organizations mapped to the given {@code user}. Or an empty stream if no organizations were mapped from the {@code scope} parameter.
* @return the organizations mapped from the {@code scope} parameter. Or an empty stream if no organizations were mapped from the parameter.
*/
public Stream<OrganizationModel> resolveOrganizations(UserModel user, String scope, KeycloakSession session) {
if (isBlank(scope)) {
Expand All @@ -195,6 +210,17 @@ public Stream<OrganizationModel> resolveOrganizations(UserModel user, String sco
return valueResolver.apply(user, scope, session).filter(OrganizationModel::isEnabled);
}

/**
* Returns the organizations mapped from the {@code scope}.
*
* @param scope the string referencing the scope
* @param session the session
* @return the organizations mapped from the {@code scope} parameter. Or an empty stream if no organizations were mapped from the parameter.
*/
public Stream<OrganizationModel> resolveOrganizations(KeycloakSession session, String scope) {
return rawValueResolver.apply(scope, session);
}

/**
* Returns a {@link ClientScopeModel} with the given {@code name} for this scope.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Stream;

import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
Expand All @@ -41,6 +42,7 @@
import org.keycloak.models.GroupModel.Type;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationDomainModel;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
Expand Down Expand Up @@ -209,7 +211,18 @@ public static OrganizationModel resolveOrganization(KeycloakSession session, Use

if (organizations.size() == 1) {
// single organization mapped from authentication session
return organizations.get(0);
OrganizationModel resolved = organizations.get(0);

if (user == null) {
return resolved;
}

// make sure the user still maps to the organization from the authentication session
if (matchesOrganization(resolved, user)) {
return resolved;
}

return null;
} else if (scope != null && user != null) {
// organization scope requested but no user and no single organization mapped from the scope
return null;
Expand Down Expand Up @@ -262,4 +275,16 @@ public static boolean isReadOnlyOrganizationMember(KeycloakSession session, User
.anyMatch((org) -> (organizationProvider.isEnabled() && org.isManaged(delegate) && !org.isEnabled()) ||
(!organizationProvider.isEnabled() && org.isManaged(delegate)));
}

private static boolean matchesOrganization(OrganizationModel organization, UserModel user) {
if (organization == null || user == null) {
return false;
}

String emailDomain = Optional.ofNullable(getEmailDomain(user.getEmail())).orElse("");
Stream<OrganizationDomainModel> domains = organization.getDomains();
Stream<String> domainNames = domains.map(OrganizationDomainModel::getName);

return organization.isMember(user) || domainNames.anyMatch(emailDomain::equals);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,15 @@ public void testRequiresUserMembership() {
runOnServer(setAuthenticatorConfig(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.TRUE.toString()));

try {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
OrganizationRepresentation org = createOrganization();
OrganizationResource organization = testRealm().organizations().get(org.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()));
assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization " + org.getName()));

organization.members().addMember(member.getId()).close();
OrganizationRepresentation orgB = createOrganization("org-b");
Expand All @@ -169,6 +170,26 @@ public void testRequiresUserMembership() {
// 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);

organization.members().member(member.getId()).delete().close();
oauth.clientId("broker-app");
oauth.scope("organization:*");
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 any organization"));

organization.members().addMember(member.getId()).close();
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
oauth.clientId("broker-app");
oauth.scope("organization");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
selectOrganizationPage.assertCurrent();
organization.members().member(member.getId()).delete().close();
selectOrganizationPage.selectOrganization(org.getAlias());
// user is not a member of any organization
assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization " + org.getName()));
} finally {
runOnServer(setAuthenticatorConfig(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.FALSE.toString()));
}
Expand Down
Loading

0 comments on commit 87e8fbc

Please sign in to comment.