Skip to content

Commit

Permalink
Improvements to the organization authentication flow
Browse files Browse the repository at this point in the history
Closes #29416
Closes #29417
Closes #29418

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
  • Loading branch information
pedroigor committed May 9, 2024
1 parent 2055cf6 commit 02d7d0d
Show file tree
Hide file tree
Showing 12 changed files with 420 additions and 115 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.keycloak.models.utils;

import static java.util.Optional.ofNullable;
import static org.keycloak.models.utils.StripSecretsUtils.stripSecrets;

import org.jboss.logging.Logger;
Expand Down Expand Up @@ -55,13 +56,13 @@

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -463,7 +464,7 @@ public static RealmRepresentation toRepresentation(KeycloakSession session, Real
rep.setWebAuthnPolicyPasswordlessExtraOrigins(webAuthnPolicy.getExtraOrigins());

CibaConfig cibaPolicy = realm.getCibaPolicy();
Map<String, String> attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>());
Map<String, String> attrMap = ofNullable(rep.getAttributes()).orElse(new HashMap<>());
attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, cibaPolicy.getBackchannelTokenDeliveryMode());
attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(cibaPolicy.getExpiresIn()));
attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(cibaPolicy.getPoolingInterval()));
Expand Down Expand Up @@ -826,7 +827,7 @@ public static ProtocolMapperRepresentation toRepresentation(ProtocolMapperModel
ProtocolMapperRepresentation rep = new ProtocolMapperRepresentation();
rep.setId(model.getId());
rep.setProtocol(model.getProtocol());
Map<String, String> config = new HashMap<>(model.getConfig());
Map<String, String> config = new HashMap<>(ofNullable(model.getConfig()).orElse(Collections.emptyMap()));
rep.setConfig(config);
rep.setName(model.getName());
rep.setProtocolMapper(model.getProtocolMapper());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

package org.keycloak.organization.admin.resource;

import java.util.Comparator;
import java.util.Optional;
import static java.util.Optional.ofNullable;

import java.util.Set;
import java.util.Objects;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -78,7 +78,7 @@ public Response create(OrganizationRepresentation organization) {
throw ErrorResponse.error("Organization cannot be null.", Response.Status.BAD_REQUEST);
}

Set<String> domains = organization.getDomains().stream().map(OrganizationDomainRepresentation::getName).collect(Collectors.toSet());
Set<String> domains = ofNullable(organization.getDomains()).orElse(Set.of()).stream().map(OrganizationDomainRepresentation::getName).collect(Collectors.toSet());
OrganizationModel model = provider.create(organization.getName(), domains);

toModel(organization, model);
Expand Down Expand Up @@ -198,7 +198,7 @@ private OrganizationModel toModel(OrganizationRepresentation rep, OrganizationMo
model.setEnabled(rep.isEnabled());
model.setDescription(rep.getDescription());
model.setAttributes(rep.getAttributes());
model.setDomains(Optional.ofNullable(rep.getDomains()).orElse(Set.of()).stream()
model.setDomains(ofNullable(rep.getDomains()).orElse(Set.of()).stream()
.filter(Objects::nonNull)
.map(this::toModel)
.collect(Collectors.toSet()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
import org.keycloak.forms.login.LoginFormsProvider;
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.FederatedIdentityModel;
Expand All @@ -32,8 +34,13 @@
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareAuthenticationContextBean;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareRealmBean;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;

public class OrganizationAuthenticator extends IdentityProviderAuthenticator {

Expand Down Expand Up @@ -68,8 +75,6 @@ public void action(AuthenticationFlowContext context) {
return;
}

OrganizationProvider provider = getOrganizationProvider();
OrganizationModel organization = null;
RealmModel realm = context.getRealm();
UserModel user = session.users().getUserByEmail(realm, username);

Expand All @@ -80,67 +85,31 @@ public void action(AuthenticationFlowContext context) {
return;
}

organization = provider.getByMember(user);

if (organization != null) {
if (provider.isManagedMember(organization, user)) {
// user is a managed member, try to resolve the origin broker and redirect automatically
List<IdentityProviderModel> organizationBrokers = organization.getIdentityProviders().toList();
List<IdentityProviderModel> originBrokers = session.users().getFederatedIdentitiesStream(realm, user)
.map(f -> {
IdentityProviderModel broker = realm.getIdentityProviderByAlias(f.getIdentityProvider());

if (!organizationBrokers.contains(broker)) {
return null;
}

FederatedIdentityModel identity = session.users().getFederatedIdentity(realm, user, broker.getAlias());

if (identity != null) {
return broker;
}

return null;
}).filter(Objects::nonNull)
.toList();
IdentityProviderModel broker = resolveBroker(user);


if (originBrokers.size() == 1) {
redirect(context, originBrokers.get(0).getAlias());
return;
}
} else {
context.attempted();
return;
}
if (broker == null) {
// not a managed member, continue with the regular flow
context.attempted();
} else {
// user is a managed member and associated with a broker, redirect automatically
redirect(context, broker.getAlias(), user.getEmail());
}
}

if (organization == null) {
organization = provider.getByDomainName(emailDomain);
}

if (organization == null) {
// request does not map to any organization, go to the next step/sub-flow
context.attempted();
return;
}

List<IdentityProviderModel> domainBrokers = organization.getIdentityProviders().toList();
OrganizationProvider provider = getOrganizationProvider();
OrganizationModel organization = provider.getByDomainName(emailDomain);

if (domainBrokers.isEmpty()) {
// no organization brokers to automatically redirect the user, go to the next step/sub-flow
if (organization == null || !organization.isEnabled()) {
// request does not map to any organization, go to the next step/sub-flow
context.attempted();
return;
}

if (domainBrokers.size() == 1) {
// there is a single broker, redirect the user to authenticate
redirect(context, domainBrokers.get(0).getAlias(), username);
return;
}
List<IdentityProviderModel> brokers = organization.getIdentityProviders().toList();

for (IdentityProviderModel broker : domainBrokers) {
for (IdentityProviderModel broker : brokers) {
String idpDomain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE);

if (emailDomain.equals(idpDomain)) {
Expand All @@ -150,31 +119,98 @@ public void action(AuthenticationFlowContext context) {
}
}

// the user is authenticating in the scope of the organization, show the identity-first login page and the
if (!hasPublicBrokers(brokers)) {
// the user does not exist, and there is no broker available for selection, redirect the user to the identity-first login page at the realm
challenge(username, context);
return;
}

// the user does not exist and is authenticating in the scope of the organization, show the identity-first login page and the
// public organization brokers for selection
context.challenge(context.form()
LoginFormsProvider form = context.form()
.setAttributeMapper(attributes -> {
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, true)
);
attributes.computeIfPresent("auth",
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
);
attributes.computeIfPresent("realm",
(key, bean) -> new OrganizationAwareRealmBean(realm)
);
return attributes;
})
});
form.addError(new FormMessage("Your email domain matches the " + organization.getName() + " organization but you don't have an account yet."));
context.challenge(form
.createLoginUsername());
}

private static boolean hasPublicBrokers(List<IdentityProviderModel> brokers) {
return brokers.stream().anyMatch(p -> Boolean.parseBoolean(p.getConfig().getOrDefault(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString())));
}

private IdentityProviderModel resolveBroker(UserModel user) {
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
RealmModel realm = session.getContext().getRealm();
OrganizationModel organization = provider.getByMember(user);

if (organization == null || !organization.isEnabled()) {
return null;
}

if (provider.isManagedMember(organization, user)) {
// user is a managed member, try to resolve the origin broker and redirect automatically
List<IdentityProviderModel> organizationBrokers = organization.getIdentityProviders().toList();
List<IdentityProviderModel> brokers = session.users().getFederatedIdentitiesStream(realm, user)
.map(f -> {
IdentityProviderModel broker = realm.getIdentityProviderByAlias(f.getIdentityProvider());

if (!organizationBrokers.contains(broker)) {
return null;
}

FederatedIdentityModel identity = session.users().getFederatedIdentity(realm, user, broker.getAlias());

if (identity != null) {
return broker;
}

return null;
}).filter(Objects::nonNull)
.toList();

return brokers.size() == 1 ? brokers.get(0) : null;
}

return null;
}

private OrganizationProvider getOrganizationProvider() {
return session.getProvider(OrganizationProvider.class);
}

private void challenge(AuthenticationFlowContext context){
private void challenge(AuthenticationFlowContext context) {
challenge(null, context);
}

private void challenge(String username, AuthenticationFlowContext context){
// the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism
context.challenge(context.form()
LoginFormsProvider form = context.form()
.setAttributeMapper(attributes -> {
// removes identity provider related attributes from forms
attributes.remove("social");
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, false, true)
);
attributes.computeIfPresent("auth",
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
);
return attributes;
})
.createLoginUsername());
});

if (username != null) {
form.addError(new FormMessage(Validation.FIELD_USERNAME, Messages.INVALID_USER));
}

context.challenge(form.createLoginUsername());
}

private String getEmailDomain(String email) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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.organization.forms.login.freemarker.model;

import java.util.List;

import org.keycloak.authentication.AuthenticationSelectionOption;
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;

public class OrganizationAwareAuthenticationContextBean extends AuthenticationContextBean {

private final AuthenticationContextBean delegate;
private final boolean showTryAnotherWayLink;

public OrganizationAwareAuthenticationContextBean(AuthenticationContextBean delegate, boolean showTryAnotherWayLink) {
super(null, null);
this.delegate = delegate;
this.showTryAnotherWayLink = showTryAnotherWayLink;
}

@Override
public List<AuthenticationSelectionOption> getAuthenticationSelections() {
return delegate.getAuthenticationSelections();
}

public boolean showTryAnotherWayLink() {
if (showTryAnotherWayLink) {
return delegate.showTryAnotherWayLink();
}
return false;
}

public boolean showUsername() {
return delegate.showUsername();
}

public boolean showResetCredentials() {
return delegate.showResetCredentials();
}

public String getAttemptedUsername() {
return delegate.getAttemptedUsername();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;

import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
import org.keycloak.models.IdentityProviderModel;
Expand All @@ -32,8 +33,16 @@ public class OrganizationAwareIdentityProviderBean extends IdentityProviderBean
private final List<IdentityProvider> providers;

public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers) {
this(delegate, session, onlyOrganizationBrokers, false);
}

public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers, boolean onlyRealmBrokers) {
this.session = session;
if (onlyOrganizationBrokers) {
if (onlyRealmBrokers) {
providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream()
.filter(Predicate.not(this::isPublicOrganizationBroker))
.toList();
} else if (onlyOrganizationBrokers) {
providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream()
.filter(this::isPublicOrganizationBroker)
.toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.keycloak.organization.forms.login.freemarker.model;

import org.keycloak.forms.login.freemarker.model.RealmBean;
import org.keycloak.models.RealmModel;

public class OrganizationAwareRealmBean extends RealmBean {

public OrganizationAwareRealmBean(RealmModel realmModel) {
super(realmModel);
}

@Override
public boolean isRegistrationAllowed() {
return false;
}
}

0 comments on commit 02d7d0d

Please sign in to comment.