IdentityProviderBean.java

/*
 * Copyright 2016 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.forms.login.freemarker.model;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.common.Profile;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderStorageProvider;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrderedModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.services.Urls;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.theme.Theme;

import java.io.IOException;
import java.net.URI;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
 * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
 * @author Vlastimil Elias (velias at redhat dot com)
 */
public class IdentityProviderBean {

    public static OrderedModel.OrderedModelComparator<IdentityProvider> IDP_COMPARATOR_INSTANCE = new OrderedModel.OrderedModelComparator<>();
    private static final String ICON_THEME_PREFIX = "kcLogoIdP-";

    protected AuthenticationFlowContext context;
    protected List<IdentityProvider> providers;
    protected KeycloakSession session;
    protected RealmModel realm;
    protected URI baseURI;

    public IdentityProviderBean(KeycloakSession session, RealmModel realm, URI baseURI, AuthenticationFlowContext context) {
        this.session = session;
        this.realm = realm;
        this.baseURI = baseURI;
        this.context = context;
    }

    public List<IdentityProvider> getProviders() {
        if (this.providers == null) {
            String existingIDP = this.getExistingIDP(session, context);
            Set<String> federatedIdentities = this.getLinkedBrokerAliases(session, realm, context);
            if (federatedIdentities != null) {
                this.providers = getFederatedIdentityProviders(federatedIdentities, existingIDP);
            } else {
                this.providers = searchForIdentityProviders(existingIDP);
            }
        }
        return this.providers;
    }

    public KeycloakSession getSession() {
        return this.session;
    }

    public RealmModel getRealm() {
        return this.realm;
    }

    public URI getBaseURI() {
        return this.baseURI;
    }

    public AuthenticationFlowContext getFlowContext() {
        return this.context;
    }

    /**
     * Creates an {@link IdentityProvider} instance from the specified {@link IdentityProviderModel}.
     *
     * @param realm a reference to the realm.
     * @param baseURI the base URI.
     * @param identityProvider the {@link IdentityProviderModel} from which the freemarker {@link IdentityProvider} is
     *                         to be built.
     * @return the constructed {@link IdentityProvider}.
     */
    protected IdentityProvider createIdentityProvider(RealmModel realm, URI baseURI, IdentityProviderModel identityProvider) {
        String loginUrl = Urls.identityProviderAuthnRequest(baseURI, identityProvider.getAlias(), realm.getName()).toString();
        String displayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, identityProvider);
        return new IdentityProvider(identityProvider.getAlias(),
                displayName, identityProvider.getProviderId(), loginUrl,
                identityProvider.getConfig().get("guiOrder"), getLoginIconClasses(identityProvider));
    }

    // Get icon classes defined in properties of current theme with key 'kcLogoIdP-{alias}'
    // OR from IdentityProviderModel.getDisplayIconClasses if not defined in theme (for third-party IDPs like Sign-In-With-Apple)
    // f.e. kcLogoIdP-github = fa fa-github
    private String getLoginIconClasses(IdentityProviderModel identityProvider) {
        try {
            Theme theme = session.theme().getTheme(Theme.Type.LOGIN);
            Optional<String> classesFromTheme = Optional.ofNullable(getLogoIconClass(identityProvider, theme.getProperties()));
            Optional<String> classesFromModel = Optional.ofNullable(identityProvider.getDisplayIconClasses());
            return classesFromTheme.orElse(classesFromModel.orElse(""));
        } catch (IOException e) {
            //NOP
        }
        return "";
    }

    private String getLogoIconClass(IdentityProviderModel identityProvider, Properties themeProperties) throws IOException {
        String iconClass = themeProperties.getProperty(ICON_THEME_PREFIX + identityProvider.getAlias());

        if (iconClass == null) {
            return themeProperties.getProperty(ICON_THEME_PREFIX + identityProvider.getProviderId());
        }

        return iconClass;
    }

    /**
     * Checks if an IDP is being connected to the user's account. In this case the currentUser is {@code null} and the current flow
     * is the {@code FIRST_BROKER_LOGIN_PATH}, so we should retrieve the IDP they used for login and filter it out of the list
     * of IDPs that are available for login. (GHI #14173).
     *
     * @param session a reference to the {@link KeycloakSession}.
     * @param context a reference to the {@link AuthenticationFlowContext}.
     * @return the alias of the IDP used for login before linking a new IDP to the user's account (if any).
     */
    protected String getExistingIDP(KeycloakSession session, AuthenticationFlowContext context) {

        String existingIDPAlias = null;
        if (context != null) {
            AuthenticationSessionModel authSession = context.getAuthenticationSession();
            String currentFlowPath = authSession.getAuthNote(AuthenticationProcessor.CURRENT_FLOW_PATH);
            UserModel currentUser = context.getUser();

            if (currentUser == null && Objects.equals(LoginActionsService.FIRST_BROKER_LOGIN_PATH, currentFlowPath)) {
                SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromAuthenticationSession(authSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE);
                final IdentityProviderModel existingIdp = (serializedCtx == null) ? null : serializedCtx.deserialize(session, authSession).getIdpConfig();
                if (existingIdp != null) {
                    existingIDPAlias = existingIdp.getAlias();
                }
            }
        }
        return existingIDPAlias;
    }

    /**
     * Returns the list of IDPs linked with the user's federated identities, if any. In case these IDPs exist, the login
     * page should show only the IDPs already linked to the user. Returning {@code null} indicates that all public enabled IDPs
     * should be available.
     * </p>
     * Returning an empty set essentially narrows the list of available IDPs to zero, so no IDPs will be shown for login.
     *
     * @param session a reference to the {@link KeycloakSession}.
     * @param realm a reference to the realm.
     * @param context a reference to the {@link AuthenticationFlowContext}.
     * @return a {@link Set} containing the aliases of the IDPs that should be available for login. An empty set indicates
     * that no IDPs should be available.
     */
    protected Set<String> getLinkedBrokerAliases(KeycloakSession session, RealmModel realm, AuthenticationFlowContext context) {
        Set<String> result = null;
        if (context != null) {
            UserModel currentUser = context.getUser();
            if (currentUser != null) {
                Set<String> federatedIdentities = session.users().getFederatedIdentitiesStream(session.getContext().getRealm(), currentUser)
                        .map(FederatedIdentityModel::getIdentityProvider)
                        .collect(Collectors.toSet());

                if (!federatedIdentities.isEmpty() || organizationsDisabled(realm))
                    // if orgs are enabled, we don't want to return an empty set - we want the organization IDPs to be shown if those are available.
                    result = new HashSet<>(federatedIdentities);

            }
        }
        return result;
    }

    /**
     * Builds and returns a list of {@link IdentityProvider} instances from the specified set of federated IDPs. The IDPs
     * must be enabled, not link-only, and not set to be hidden on login page. If any IDP has an alias that matches the
     * {@code existingIDP} parameter, it must be filtered out.
     *
     * @param federatedProviders a {@link Set} containing the aliases of the federated IDPs that should be considered for login.
     * @param existingIDP the alias of the IDP that must be filtered out from the result (used when linking a new IDP to a user's account).
     * @return a {@link List} containing the constructed {@link IdentityProvider}s.
     */
    protected List<IdentityProvider> getFederatedIdentityProviders(Set<String> federatedProviders, String existingIDP) {
        return federatedProviders.stream()
                .filter(alias -> !Objects.equals(existingIDP, alias))
                .map(alias -> session.identityProviders().getByAlias(alias))
                .filter(federatedProviderPredicate())
                .map(idp -> createIdentityProvider(this.realm, this.baseURI, idp))
                .sorted(IDP_COMPARATOR_INSTANCE).toList();
    }

    /**
     * Returns a predicate that can filter out IDPs associated with the current user's federated identities before those
     * are converted into {@link IdentityProvider}s. Subclasses may use this as a way to further refine the IDPs that are
     * to be returned.
     *
     * @return the custom {@link Predicate} used as a last filter before conversion into {@link IdentityProvider}
     */
    protected Predicate<IdentityProviderModel> federatedProviderPredicate() {
        return IdentityProviderStorageProvider.LoginFilter.getLoginPredicate();
    }

    /**
     * Builds and returns a list of {@link IdentityProvider} instances that will be available for login. This method goes
     * to the {@link IdentityProviderStorageProvider} to fetch the IDPs that can be used for login (enabled, not link-only and not set to be
     * hidden on login page).
     *
     * @param existingIDP the alias of the IDP that must be filtered out from the result (used when linking a new IDP to a user's account).
     * @return a {@link List} containing the constructed {@link IdentityProvider}s.
     */
    protected List<IdentityProvider> searchForIdentityProviders(String existingIDP) {
        return session.identityProviders().getForLogin(IdentityProviderStorageProvider.FetchMode.REALM_ONLY, null)
                .filter(idp -> !Objects.equals(existingIDP, idp.getAlias()))
                .map(idp -> createIdentityProvider(this.realm, this.baseURI, idp))
                .sorted(IDP_COMPARATOR_INSTANCE).toList();
    }

    private static boolean organizationsDisabled(RealmModel realm) {
        return !Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION) || !realm.isOrganizationsEnabled();
    }

    public static class IdentityProvider implements OrderedModel {

        private final String alias;
        private final String providerId; // This refers to providerType (facebook, google, etc.)
        private final String loginUrl;
        private final String guiOrder;
        private final String displayName;
        private final String iconClasses;

        public IdentityProvider(String alias, String displayName, String providerId, String loginUrl, String guiOrder) {
            this(alias, displayName, providerId, loginUrl, guiOrder, "");
        }

        public IdentityProvider(String alias, String displayName, String providerId, String loginUrl, String guiOrder, String iconClasses) {
            this.alias = alias;
            this.displayName = displayName;
            this.providerId = providerId;
            this.loginUrl = loginUrl;
            this.guiOrder = guiOrder;
            this.iconClasses = iconClasses;
        }

        public String getAlias() {
            return alias;
        }

        public String getLoginUrl() {
            return loginUrl;
        }

        public String getProviderId() {
            return providerId;
        }

        @Override
        public String getGuiOrder() {
            return guiOrder;
        }

        public String getDisplayName() {
            return displayName;
        }

        public String getIconClasses() {
            return iconClasses;
        }
    }


}