Rework logic to fetch IDPs for the login page so that IDPs are fetched from the provider and not filtered in code.

Closes #32090

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2024-08-13 09:58:06 -03:00 committed by Pedro Igor
parent b6a82964ed
commit f82159cf65
29 changed files with 607 additions and 304 deletions

View file

@ -54,8 +54,10 @@ public class IdentityProviderRepresentation {
protected boolean addReadTokenRoleOnCreate;
protected boolean authenticateByDefault;
protected boolean linkOnly;
protected boolean hideOnLogin;
protected String firstBrokerLoginFlowAlias;
protected String postBrokerLoginFlowAlias;
protected String organizationId;
protected Map<String, String> config = new HashMap<>();
public String getInternalId() {
@ -106,6 +108,14 @@ public class IdentityProviderRepresentation {
this.linkOnly = linkOnly;
}
public boolean isHideOnLogin() {
return this.hideOnLogin;
}
public void setHideOnLogin(boolean hideOnLogin) {
this.hideOnLogin = hideOnLogin;
}
/**
*
* Deprecated because replaced by {@link #updateProfileFirstLoginMode}. Kept here to allow import of old realms.
@ -194,4 +204,12 @@ public class IdentityProviderRepresentation {
this.displayName = displayName;
}
public String getOrganizationId() {
return this.organizationId;
}
public void setOrganizationId(String organizationId) {
this.organizationId = organizationId;
}
}

View file

@ -147,3 +147,11 @@ If you wish to disable placeholder replacement for the `import` command, add the
You can still override automatic detection by specifying the `https-key-store-type` and `https-trust-store-type` explicitly. The same applies to the management interface and its `https-management-key-store-type`. Restrictions for the FIPS strict mode stay unchanged.
NOTE: The `+spi-truststore-file-*+` options and the truststore related options `+https-trust-store-*+` are deprecated, we strongly recommend to use System Truststore. For more details refer to the relevant https://www.keycloak.org/server/keycloak-truststore[guide].
= Improving performance for selection of identity providers
New indexes were added to the `IDENTITY_PROVIDER` table to improve the performance of queries that fetch the IDPs associated with an organization, and fetch IDPs that are available for login (those that are `enabled`, not `link_only`, not marked as `hide_on_login`).
If the table currently contains more than 300.000 entries,
{project_name} will skip the creation of the indexes by default during the automatic schema migration, and will instead log the SQL statements
on the console during migration. In this case, the statements must be run manually in the DB after {project_name}'s startup.

View file

@ -76,7 +76,7 @@ const OrganizationLink = (identityProvider: IdentityProviderRepresentation) => {
const { t } = useTranslation();
const { realm } = useRealm();
if (!identityProvider.config?.["kc.org"]) {
if (!identityProvider?.organizationId) {
return "—";
}
@ -85,7 +85,7 @@ const OrganizationLink = (identityProvider: IdentityProviderRepresentation) => {
key={identityProvider.providerId}
to={toEditOrganization({
realm,
id: identityProvider.config["kc.org"],
id: identityProvider.organizationId,
tab: "identityProviders",
})}
>
@ -299,7 +299,7 @@ export default function IdentityProvidersSection() {
cellFormatters: [upperCaseFormatter()],
},
{
name: "config['kc.org']",
name: "organizationId",
displayKey: "linkedOrganization",
cellRenderer: OrganizationLink,
},

View file

@ -150,7 +150,11 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
label="accountLinkingOnly"
fieldType="boolean"
/>
<SwitchField field="config.hideOnLoginPage" label="hideOnLoginPage" />
<SwitchField
field="hideOnLogin"
label="hideOnLoginPage"
fieldType="boolean"
/>
{(!isSAML || isOIDC) && (
<FormGroupField label="filteredByClaim">

View file

@ -11,8 +11,10 @@ export default interface IdentityProviderRepresentation {
firstBrokerLoginFlowAlias?: string;
internalId?: string;
linkOnly?: boolean;
hideOnLogin?: boolean;
postBrokerLoginFlowAlias?: string;
providerId?: string;
storeToken?: boolean;
trustEmail?: boolean;
organizationId?: string;
}

View file

@ -143,7 +143,7 @@ public class InfinispanIDPProvider implements IDPProvider {
}
@Override
public Stream<IdentityProviderModel> getAllStream(Map<String, String> attrs, Integer first, Integer max) {
public Stream<IdentityProviderModel> getAllStream(Map<String, Object> attrs, Integer first, Integer max) {
return idpDelegate.getAllStream(attrs, first, max);
}

View file

@ -58,10 +58,10 @@ public class InfinispanOrganizationProviderFactory implements OrganizationProvid
}
private void registerOrganizationInvalidation(KeycloakSession session, IdentityProviderModel idp) {
if (idp.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE) != null) {
if (idp.getOrganizationId() != null) {
InfinispanOrganizationProvider orgProvider = (InfinispanOrganizationProvider) session.getProvider(OrganizationProvider.class, getId());
if (orgProvider != null) {
OrganizationModel organization = orgProvider.getById(idp.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE));
OrganizationModel organization = orgProvider.getById(idp.getOrganizationId());
orgProvider.registerOrganizationInvalidation(organization);
}
}

View file

@ -0,0 +1,85 @@
/*
* 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.connections.jpa.updater.liquibase.custom;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import liquibase.exception.CustomChangeException;
import liquibase.statement.core.DeleteStatement;
import liquibase.statement.core.UpdateStatement;
import liquibase.structure.core.Table;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.OrganizationModel;
/**
* Custom SQL change to migrate the organization ID and the hide on login page config from the IDP config table to the
* IDP table.
*/
public class JpaUpdate26_0_0_IdentityProviderAttributesMigration extends CustomKeycloakTask {
@Override
protected void generateStatementsImpl() throws CustomChangeException {
// move the organization id from the config to the IDP.
try (PreparedStatement ps = connection.prepareStatement("SELECT c.IDENTITY_PROVIDER_ID, c.VALUE" +
" FROM " + getTableName("IDENTITY_PROVIDER_CONFIG") + " c WHERE c.NAME = '" + OrganizationModel.ORGANIZATION_ATTRIBUTE + "'");
ResultSet resultSet = ps.executeQuery()
) {
while (resultSet.next()) {
String id = resultSet.getString(1);
String value = resultSet.getString(2);
statements.add(new UpdateStatement(null, null, database.correctObjectName("IDENTITY_PROVIDER", Table.class))
.addNewColumnValue("ORGANIZATION_ID", value)
.setWhereClause("INTERNAL_ID=?")
.addWhereParameter(id));
}
statements.add(new DeleteStatement(null, null, database.correctObjectName("IDENTITY_PROVIDER_CONFIG", Table.class))
.setWhere("NAME=?")
.addWhereParameter(OrganizationModel.ORGANIZATION_ATTRIBUTE));
} catch (Exception e) {
throw new CustomChangeException(getTaskId() + ": Exception when updating data from previous version", e);
}
// move hide on login page from the config to the IDP.
try (PreparedStatement ps = connection.prepareStatement("SELECT c.IDENTITY_PROVIDER_ID, c.VALUE" +
" FROM " + getTableName("IDENTITY_PROVIDER_CONFIG") + " c WHERE c.NAME = '" + IdentityProviderModel.LEGACY_HIDE_ON_LOGIN_ATTR + "'");
ResultSet resultSet = ps.executeQuery()
) {
while (resultSet.next()) {
String id = resultSet.getString(1);
String value = resultSet.getString(2);
statements.add(new UpdateStatement(null, null, database.correctObjectName("IDENTITY_PROVIDER", Table.class))
.addNewColumnValue("HIDE_ON_LOGIN", Boolean.parseBoolean(value))
.setWhereClause("INTERNAL_ID=?")
.addWhereParameter(id));
}
statements.add(new DeleteStatement(null, null, database.correctObjectName("IDENTITY_PROVIDER_CONFIG", Table.class))
.setWhere("NAME=?")
.addWhereParameter(IdentityProviderModel.LEGACY_HIDE_ON_LOGIN_ATTR));
} catch (Exception e) {
throw new CustomChangeException(getTaskId() + ": Exception when updating data from previous version", e);
}
}
@Override
protected String getTaskId() {
return "Migrate kc.org and hideOnLoginPage from the IDP config to the IDP itself";
}
}

View file

@ -31,7 +31,6 @@ import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.MapJoin;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.Session;
import org.jboss.logging.Logger;
import org.keycloak.broker.provider.IdentityProvider;
@ -51,6 +50,9 @@ import static org.keycloak.models.IdentityProviderModel.ALIAS;
import static org.keycloak.models.IdentityProviderModel.AUTHENTICATE_BY_DEFAULT;
import static org.keycloak.models.IdentityProviderModel.ENABLED;
import static org.keycloak.models.IdentityProviderModel.FIRST_BROKER_LOGIN_FLOW_ID;
import static org.keycloak.models.IdentityProviderModel.HIDE_ON_LOGIN;
import static org.keycloak.models.IdentityProviderModel.LINK_ONLY;
import static org.keycloak.models.IdentityProviderModel.ORGANIZATION_ID;
import static org.keycloak.models.IdentityProviderModel.POST_BROKER_LOGIN_FLOW_ID;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing;
@ -92,8 +94,10 @@ public class JpaIDPProvider implements IDPProvider {
entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault());
entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId());
entity.setPostBrokerLoginFlowId(identityProvider.getPostBrokerLoginFlowId());
entity.setOrganizationId(identityProvider.getOrganizationId());
entity.setConfig(identityProvider.getConfig());
entity.setLinkOnly(identityProvider.isLinkOnly());
entity.setHideOnLogin(identityProvider.isHideOnLogin());
em.persist(entity);
// flush so that constraint violations are flagged and converted into model exception now rather than at the end of the tx.
em.flush();
@ -113,10 +117,12 @@ public class JpaIDPProvider implements IDPProvider {
entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault());
entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId());
entity.setPostBrokerLoginFlowId(identityProvider.getPostBrokerLoginFlowId());
entity.setOrganizationId(identityProvider.getOrganizationId());
entity.setAddReadTokenRoleOnCreate(identityProvider.isAddReadTokenRoleOnCreate());
entity.setStoreToken(identityProvider.isStoreToken());
entity.setConfig(identityProvider.getConfig());
entity.setLinkOnly(identityProvider.isLinkOnly());
entity.setHideOnLogin(identityProvider.isHideOnLogin());
// flush so that constraint violations are flagged and converted into model exception now rather than at the end of the tx.
em.flush();
@ -218,7 +224,7 @@ public class JpaIDPProvider implements IDPProvider {
}
@Override
public Stream<IdentityProviderModel> getAllStream(Map<String, String> attrs, Integer first, Integer max) {
public Stream<IdentityProviderModel> getAllStream(Map<String, Object> attrs, Integer first, Integer max) {
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<IdentityProviderEntity> query = builder.createQuery(IdentityProviderEntity.class);
Root<IdentityProviderEntity> idp = query.from(IdentityProviderEntity.class);
@ -227,19 +233,27 @@ public class JpaIDPProvider implements IDPProvider {
predicates.add(builder.equal(idp.get("realmId"), getRealm().getId()));
if (attrs != null) {
for (Map.Entry<String, String> entry : attrs.entrySet()) {
for (Map.Entry<String, Object> entry : attrs.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
Object value = entry.getValue();
if (StringUtil.isBlank(key)) {
continue;
}
switch(key) {
case AUTHENTICATE_BY_DEFAULT:
case ENABLED:
case AUTHENTICATE_BY_DEFAULT: {
predicates.add(builder.equal(idp.get(key), Boolean.valueOf(value)));
case HIDE_ON_LOGIN:
case LINK_ONLY: {
if (Boolean.parseBoolean(value.toString())) {
predicates.add(builder.isTrue(idp.get(key)));
} else {
predicates.add(builder.isFalse(idp.get(key)));
}
break;
} case FIRST_BROKER_LOGIN_FLOW_ID: {
if (StringUtils.isBlank(value)) {
}
case FIRST_BROKER_LOGIN_FLOW_ID:
case ORGANIZATION_ID: {
if (value == null || value.toString().isEmpty()) {
predicates.add(builder.isNull(idp.get(key)));
} else {
predicates.add(builder.equal(idp.get(key), value));
@ -377,10 +391,12 @@ public class JpaIDPProvider implements IDPProvider {
identityProviderModel.setConfig(config);
identityProviderModel.setEnabled(entity.isEnabled());
identityProviderModel.setLinkOnly(entity.isLinkOnly());
identityProviderModel.setHideOnLogin(entity.isHideOnLogin());
identityProviderModel.setTrustEmail(entity.isTrustEmail());
identityProviderModel.setAuthenticateByDefault(entity.isAuthenticateByDefault());
identityProviderModel.setFirstBrokerLoginFlowId(entity.getFirstBrokerLoginFlowId());
identityProviderModel.setPostBrokerLoginFlowId(entity.getPostBrokerLoginFlowId());
identityProviderModel.setOrganizationId(entity.getOrganizationId());
identityProviderModel.setStoreToken(entity.isStoreToken());
identityProviderModel.setAddReadTokenRoleOnCreate(entity.isAddReadTokenRoleOnCreate());

View file

@ -65,6 +65,9 @@ public class IdentityProviderEntity {
@Column(name="LINK_ONLY")
private boolean linkOnly;
@Column(name="HIDE_ON_LOGIN")
private boolean hideOnLogin;
@Column(name="ADD_TOKEN_ROLE")
protected boolean addReadTokenRoleOnCreate;
@ -77,6 +80,9 @@ public class IdentityProviderEntity {
@Column(name="POST_BROKER_LOGIN_FLOW_ID")
private String postBrokerLoginFlowId;
@Column(name="ORGANIZATION_ID")
private String organizationId;
@ElementCollection
@MapKeyColumn(name="NAME")
@Column(name="VALUE", columnDefinition = "TEXT")
@ -163,6 +169,22 @@ public class IdentityProviderEntity {
this.postBrokerLoginFlowId = postBrokerLoginFlowId;
}
public String getOrganizationId() {
return this.organizationId;
}
public void setOrganizationId(String organizationId) {
this.organizationId = organizationId;
}
public boolean isHideOnLogin() {
return this.hideOnLogin;
}
public void setHideOnLogin(boolean hideOnLogin) {
this.hideOnLogin = hideOnLogin;
}
public Map<String, String> getConfig() {
return this.config;
}

View file

@ -73,4 +73,23 @@
</createIndex>
</changeSet>
<changeSet author="keycloak" id="26.0.0-idps-for-login">
<addColumn tableName="IDENTITY_PROVIDER">
<column name="ORGANIZATION_ID" type="VARCHAR(255)"/>
<column name="HIDE_ON_LOGIN" type="BOOLEAN" defaultValueBoolean="false"/>
</addColumn>
<createIndex indexName="IDX_IDP_REALM_ORG" tableName="IDENTITY_PROVIDER">
<column name="REALM_ID"/>
<column name="ORGANIZATION_ID"/>
</createIndex>
<createIndex indexName="IDX_IDP_FOR_LOGIN" tableName="IDENTITY_PROVIDER">
<column name="REALM_ID"/>
<column name="ENABLED"/>
<column name="LINK_ONLY"/>
<column name="HIDE_ON_LOGIN"/>
<column name="ORGANIZATION_ID"/>
</createIndex>
<customChange class="org.keycloak.connections.jpa.updater.liquibase.custom.JpaUpdate26_0_0_IdentityProviderAttributesMigration"/>
</changeSet>
</databaseChangeLog>

View file

@ -819,6 +819,7 @@ public class ModelToRepresentation {
IdentityProviderRepresentation providerRep = toBriefRepresentation(realm, identityProviderModel);
providerRep.setLinkOnly(identityProviderModel.isLinkOnly());
providerRep.setHideOnLogin(identityProviderModel.isHideOnLogin());
providerRep.setStoreToken(identityProviderModel.isStoreToken());
providerRep.setTrustEmail(identityProviderModel.isTrustEmail());
providerRep.setAuthenticateByDefault(identityProviderModel.isAuthenticateByDefault());
@ -849,8 +850,8 @@ public class ModelToRepresentation {
providerRep.setPostBrokerLoginFlowAlias(flow.getAlias());
}
if (export) {
providerRep.getConfig().remove(OrganizationModel.ORGANIZATION_ATTRIBUTE);
if (!export) {
providerRep.setOrganizationId(identityProviderModel.getOrganizationId());
}
return providerRep;

View file

@ -78,7 +78,6 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.OrganizationDomainModel;
@ -867,15 +866,20 @@ public class RepresentationToModel {
identityProviderModel.setProviderId(representation.getProviderId());
identityProviderModel.setEnabled(representation.isEnabled());
identityProviderModel.setLinkOnly(representation.isLinkOnly());
identityProviderModel.setHideOnLogin(representation.isHideOnLogin());
// check if the legacy hide on login attribute is present.
String hideOnLoginAttr = representation.getConfig().remove(IdentityProviderModel.LEGACY_HIDE_ON_LOGIN_ATTR);
if (hideOnLoginAttr != null) identityProviderModel.setHideOnLogin(Boolean.parseBoolean(hideOnLoginAttr));
identityProviderModel.setTrustEmail(representation.isTrustEmail());
identityProviderModel.setAuthenticateByDefault(representation.isAuthenticateByDefault());
identityProviderModel.setStoreToken(representation.isStoreToken());
identityProviderModel.setAddReadTokenRoleOnCreate(representation.isAddReadTokenRoleOnCreate());
updateOrganizationBroker(realm, representation, session);
updateOrganizationBroker(representation, session);
identityProviderModel.setOrganizationId(representation.getOrganizationId());
identityProviderModel.setConfig(removeEmptyString(representation.getConfig()));
String flowAlias = representation.getFirstBrokerLoginFlowAlias();
if (flowAlias == null || flowAlias.trim().length() == 0) {
if (flowAlias == null || flowAlias.trim().isEmpty()) {
identityProviderModel.setFirstBrokerLoginFlowId(null);
} else {
AuthenticationFlowModel flowModel = realm.getFlowByAlias(flowAlias);
@ -886,7 +890,7 @@ public class RepresentationToModel {
}
flowAlias = representation.getPostBrokerLoginFlowAlias();
if (flowAlias == null || flowAlias.trim().length() == 0) {
if (flowAlias == null || flowAlias.trim().isEmpty()) {
identityProviderModel.setPostBrokerLoginFlowId(null);
} else {
AuthenticationFlowModel flowModel = realm.getFlowByAlias(flowAlias);
@ -1639,21 +1643,22 @@ public class RepresentationToModel {
return toModel(representation, authorization, client);
}
private static void updateOrganizationBroker(RealmModel realm, IdentityProviderRepresentation representation, KeycloakSession session) {
private static void updateOrganizationBroker(IdentityProviderRepresentation representation, KeycloakSession session) {
if (!Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
return;
}
IdentityProviderModel existing = Optional.ofNullable(session.identityProviders().getByAlias(representation.getAlias()))
.orElse(session.identityProviders().getById(representation.getInternalId()));
String orgId = existing == null ? representation.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE) : existing.getOrganizationId();
String repOrgId = representation.getOrganizationId() != null ? representation.getOrganizationId() :
representation.getConfig().remove(OrganizationModel.ORGANIZATION_ATTRIBUTE);
String orgId = existing != null ? existing.getOrganizationId() : repOrgId;
if (orgId != null) {
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel org = provider.getById(orgId);
String newOrgId = representation.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE);
if (org == null || (newOrgId != null && provider.getById(newOrgId) == null)) {
if (org == null || (repOrgId != null && provider.getById(repOrgId) == null)) {
throw new IllegalArgumentException("Organization associated with broker does not exist");
}
@ -1666,7 +1671,7 @@ public class RepresentationToModel {
}
// make sure the link to an organization does not change
representation.getConfig().put(OrganizationModel.ORGANIZATION_ATTRIBUTE, orgId);
representation.setOrganizationId(orgId);
}
}
}

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.models;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Stream;
@ -118,7 +119,7 @@ public interface IDPProvider extends Provider {
* @param max the maximum number of results to be returned. Ignored if negative or {@code null}.
* @return a non-null stream of {@link IdentityProviderModel}s that match the search criteria.
*/
Stream<IdentityProviderModel> getAllStream(Map<String, String> attrs, Integer first, Integer max);
Stream<IdentityProviderModel> getAllStream(Map<String, Object> attrs, Integer first, Integer max);
/**
* Returns all identity providers associated with the organization with the provided id.
@ -129,7 +130,7 @@ public interface IDPProvider extends Provider {
* @return a non-null stream of {@link IdentityProviderModel}s that match the search criteria.
*/
default Stream<IdentityProviderModel> getByOrganization(String orgId, Integer first, Integer max) {
return getAllStream(Map.of(OrganizationModel.ORGANIZATION_ATTRIBUTE, orgId), first, max);
return getAllStream(Map.of(IdentityProviderModel.ORGANIZATION_ID, orgId), first, max);
}
/**
@ -147,6 +148,51 @@ public interface IDPProvider extends Provider {
*/
Stream<String> getByFlow(String flowId, String search, Integer first, Integer max);
/**
* Returns all identity providers available for login, according to the specified mode. An IDP can be used for login
* if it is enabled, is not a link-only IDP, and is not configured to be hidden on login page.
* </p>
* The mode parameter may narrow the list of IDPs that are available. {@code FETCH_MODE.REALM_ONLY} fetches only realm-level
* IDPs (i.e. those not associated with any org). {@code FETCH_MODE.ORG_ONLY} will work together with the {@code organizationId}
* parameter. If the latter is set, only the IDPs associated with that org will be returned. Otherwise, the method returns
* the IDPs associated with any org. {@code FETCH_MODE.ALL} combines both approaches, returning both the realm-level
* IDPs with those associated with organizations (or a specific organization as per the {@code organizationId} param).
*
* @param mode the fetch mode to be used. Can be {@code REALM_ONLY}, {@code ORG_ONLY}, or {@code ALL}.
* @param organizationId an optional organization ID. If present and the mode is not {@code REALM_ONLY}, the param indicates
* that only IDPs associated with the specified organization are to be returned.
* @return a non-null stream of {@link IdentityProviderModel}s that are suitable for being displayed in the login pages.
*/
default Stream<IdentityProviderModel> getForLogin(FETCH_MODE mode, String organizationId) {
Stream<IdentityProviderModel> result = Stream.of();
if (mode == FETCH_MODE.REALM_ONLY || mode == FETCH_MODE.ALL) {
// fetch all realm-only IDPs - i.e. those not associated with orgs.
Map<String, Object> searchOptions = getBasicSearchOptionsForLogin();
searchOptions.put(IdentityProviderModel.ORGANIZATION_ID, null);
result = Stream.concat(result, getAllStream(searchOptions, null, null));
}
if (mode == FETCH_MODE.ORG_ONLY || mode == FETCH_MODE.ALL) {
// fetch IDPs associated with organizations.
Map<String, Object> searchOptions = getBasicSearchOptionsForLogin();
if (organizationId != null) {
// we want the IDPs associated with a specific org.
searchOptions.put(IdentityProviderModel.ORGANIZATION_ID, organizationId);
}
searchOptions.put(OrganizationModel.BROKER_PUBLIC, "true");
result = Stream.concat(result, getAllStream(searchOptions, null, null));
}
return result;
}
private static Map<String, Object> getBasicSearchOptionsForLogin() {
Map<String, Object> searchOptions = new LinkedHashMap<>();
searchOptions.put(IdentityProviderModel.ENABLED, "true");
searchOptions.put(IdentityProviderModel.LINK_ONLY, "false");
searchOptions.put(IdentityProviderModel.HIDE_ON_LOGIN, "false");
return searchOptions;
}
/**
* Returns the number of IDPs in the realm.
*
@ -163,4 +209,6 @@ public interface IDPProvider extends Provider {
default boolean isIdentityFederationEnabled() {
return count() > 0;
}
enum FETCH_MODE {REALM_ONLY, ORG_ONLY, ALL}
}

View file

@ -41,9 +41,12 @@ public class IdentityProviderModel implements Serializable {
public static final String ENABLED = "enabled";
public static final String FILTERED_BY_CLAIMS = "filteredByClaim";
public static final String FIRST_BROKER_LOGIN_FLOW_ID = "firstBrokerLoginFlowId";
public static final String HIDE_ON_LOGIN = "hideOnLoginPage";
public static final String HIDE_ON_LOGIN = "hideOnLogin";
public static final String LEGACY_HIDE_ON_LOGIN_ATTR = "hideOnLoginPage";
public static final String LINK_ONLY = "linkOnly";
public static final String LOGIN_HINT = "loginHint";
public static final String METADATA_DESCRIPTOR_URL = "metadataDescriptorUrl";
public static final String ORGANIZATION_ID = "organizationId";
public static final String PASS_MAX_AGE = "passMaxAge";
public static final String POST_BROKER_LOGIN_FLOW_ID = "postBrokerLoginFlowId";
public static final String SYNC_MODE = "syncMode";
@ -80,11 +83,13 @@ public class IdentityProviderModel implements Serializable {
private String postBrokerLoginFlowId;
private String organizationId;
private String displayName;
private String displayIconClasses;
private IdentityProviderSyncMode syncMode;
private boolean hideOnLogin;
/**
* <p>A map containing the configuration and properties for a specific identity provider instance and implementation. The items
@ -110,7 +115,9 @@ public class IdentityProviderModel implements Serializable {
this.addReadTokenRoleOnCreate = model.addReadTokenRoleOnCreate;
this.firstBrokerLoginFlowId = model.getFirstBrokerLoginFlowId();
this.postBrokerLoginFlowId = model.getPostBrokerLoginFlowId();
this.organizationId = model.getOrganizationId();
this.displayIconClasses = model.getDisplayIconClasses();
this.hideOnLogin = model.isHideOnLogin();
}
}
@ -225,11 +232,11 @@ public class IdentityProviderModel implements Serializable {
}
public String getOrganizationId() {
return getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE);
return this.organizationId;
}
public void setOrganizationId(String organizationId) {
getConfig().put(OrganizationModel.ORGANIZATION_ATTRIBUTE, organizationId);
this.organizationId = organizationId;
}
/**
@ -268,11 +275,11 @@ public class IdentityProviderModel implements Serializable {
public boolean isHideOnLogin() {
return Boolean.valueOf(getConfig().get(HIDE_ON_LOGIN));
return this.hideOnLogin;
}
public void setHideOnLogin(boolean hideOnLogin) {
getConfig().put(HIDE_ON_LOGIN, String.valueOf(hideOnLogin));
this.hideOnLogin = hideOnLogin;
}
/**

View file

@ -17,16 +17,20 @@
package org.keycloak.authentication.authenticators.browser;
import java.util.stream.Stream;
import java.util.Objects;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.LoginFormsUtil;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.sessions.AuthenticationSessionModel;
public final class UsernameForm extends UsernamePasswordForm {
@ -34,9 +38,7 @@ public final class UsernameForm extends UsernamePasswordForm {
public void authenticate(AuthenticationFlowContext context) {
if (context.getUser() != null) {
// We can skip the form when user is re-authenticating. Unless current user has some IDP set, so he can re-authenticate with that IDP
Stream<IdentityProviderModel> identityProviders = LoginFormsUtil
.filterIdentityProviders(context.getRealm().getIdentityProvidersStream(), context.getSession(), context);
if (identityProviders.findAny().isEmpty()) {
if (!this.contextUserHasFederatedIDPs(context)) {
context.success();
return;
}
@ -69,4 +71,28 @@ public final class UsernameForm extends UsernamePasswordForm {
return Messages.INVALID_USERNAME_OR_EMAIL;
return Messages.INVALID_USERNAME;
}
/**
* Checks if the context user, if it has been set, is currently linked to any IDPs they could use to authenticate.
* If the auth session has an existing IDP in the brokered context, it is filtered out.
*
* @param context a reference to the {@link AuthenticationFlowContext}
* @return {@code true} if the context user has federated IDPs that can be used for authentication; {@code false} otherwise.
*/
private boolean contextUserHasFederatedIDPs(AuthenticationFlowContext context) {
KeycloakSession session = context.getSession();
UserModel user = context.getUser();
if (user == null) {
return false;
}
AuthenticationSessionModel authSession = context.getAuthenticationSession();
SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromAuthenticationSession(authSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE);
final IdentityProviderModel existingIdp = (serializedCtx == null) ? null : serializedCtx.deserialize(session, authSession).getIdpConfig();
return session.users().getFederatedIdentitiesStream(session.getContext().getRealm(), user)
.map(fedIdentity -> session.identityProviders().getByAlias(fedIdentity.getIdentityProvider()))
.filter(Objects::nonNull)
.anyMatch(idpModel -> existingIdp == null || !Objects.equals(existingIdp.getAlias(), idpModel.getAlias()));
}
}

View file

@ -160,7 +160,7 @@ public class SAMLIdentityProviderFactory extends AbstractIdentityProviderFactory
for (AttributeType attribute : entityType.getExtensions().getEntityAttributes().getAttribute()) {
if (MACEDIR_ENTITY_CATEGORY.equals(attribute.getName())
&& attribute.getAttributeValue().contains(REFEDS_HIDE_FROM_DISCOVERY)) {
samlIdentityProviderConfig.setHideOnLogin(true);
samlIdentityProviderConfig.getConfig().put(IdentityProviderModel.LEGACY_HIDE_ON_LOGIN_ATTR, Boolean.TRUE.toString());
}
}

View file

@ -62,7 +62,6 @@ import org.keycloak.forms.login.freemarker.model.VerifyProfileBean;
import org.keycloak.forms.login.freemarker.model.X509ConfirmBean;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
@ -101,7 +100,6 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.function.Function;
import java.util.stream.Stream;
import static org.keycloak.models.UserModel.RequiredAction.UPDATE_PASSWORD;
import static org.keycloak.organization.utils.Organizations.resolveOrganization;
@ -480,12 +478,10 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
if (realm != null) {
attributes.put("realm", new RealmBean(realm));
Stream<IdentityProviderModel> identityProviders = LoginFormsUtil
.filterIdentityProvidersForTheme(realm.getIdentityProvidersStream(), session, context);
IdentityProviderBean idpBean = new IdentityProviderBean(realm, session, identityProviders, baseUriWithCodeAndClientId);
IdentityProviderBean idpBean = new IdentityProviderBean(session, realm, baseUriWithCodeAndClientId, context);
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
idpBean = new OrganizationAwareIdentityProviderBean(idpBean, session);
if (Profile.isFeatureEnabled(Feature.ORGANIZATION) && realm.isOrganizationsEnabled()) {
idpBean = new OrganizationAwareIdentityProviderBean(idpBean);
}
attributes.put("social", idpBean);

View file

@ -1,115 +0,0 @@
/*
* 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;
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.common.Profile.Feature;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Various util methods, so the logic is not hardcoded in freemarker beans
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class LoginFormsUtil {
public static Stream<IdentityProviderModel> filterIdentityProvidersForTheme(Stream<IdentityProviderModel> providers, KeycloakSession session, AuthenticationFlowContext context) {
if (context != null) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
String currentFlowPath = authSession.getAuthNote(AuthenticationProcessor.CURRENT_FLOW_PATH);
UserModel currentUser = context.getUser();
// Fixing #14173
// If the current user is not null, then it's a re-auth, and we should filter the possible options with the pre-14173 logic
// If the current user is null, then it's one of the following cases:
// - either connecting a new IdP to the user's account.
// - in this case the currentUser is null AND the current flow is the FIRST_BROKER_LOGIN_PATH
// - so we should filter out the one they just used for login, as they need to re-auth themselves with an already linked IdP account
// - or we're on the Login page
// - in this case the current user is null AND the current flow is NOT the FIRST_BROKER_LOGIN_PATH
// - so we should show all the possible IdPs to the user trying to log in (this is the bug in #14173)
// - so we're skipping this branch, and returning everything at the end of the method
if (currentUser != null || Objects.equals(LoginActionsService.FIRST_BROKER_LOGIN_PATH, currentFlowPath)) {
return filterIdentityProviders(providers, session, context);
}
}
return filterOrganizationBrokers(providers);
}
public static Stream<IdentityProviderModel> filterIdentityProviders(Stream<IdentityProviderModel> providers, KeycloakSession session, AuthenticationFlowContext context) {
if (context != null) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromAuthenticationSession(authSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE);
final IdentityProviderModel existingIdp = (serializedCtx == null) ? null : serializedCtx.deserialize(session, authSession).getIdpConfig();
final Set<String> federatedIdentities;
UserModel user = context.getUser();
if (user != null) {
federatedIdentities = session.users().getFederatedIdentitiesStream(session.getContext().getRealm(), user)
.map(FederatedIdentityModel::getIdentityProvider)
.collect(Collectors.toSet());
} else {
federatedIdentities = Set.of();
}
return filterOrganizationBrokers(providers)
.filter(p -> { // Filter current IDP during first-broker-login flow. Re-authentication with the "linked" broker should not be possible
if (existingIdp == null) return true;
return !Objects.equals(p.getAlias(), existingIdp.getAlias());
})
.filter(idp -> {
// user not established in authentication session, he can choose to authenticate using any broker
if (user == null) {
return true;
}
if (federatedIdentities.isEmpty()) {
// user established but not linked to any broker, he can choose to authenticate using any organization broker
return idp.getOrganizationId() != null;
}
// user established, we show just providers already linked to this user
return federatedIdentities.contains(idp.getAlias());
});
}
return filterOrganizationBrokers(providers);
}
private static Stream<IdentityProviderModel> filterOrganizationBrokers(Stream<IdentityProviderModel> providers) {
if (!Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
providers = providers.filter(identityProviderModel -> identityProviderModel.getOrganizationId() == null);
}
return providers;
}
}

View file

@ -16,22 +16,34 @@
*/
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.IDPProvider;
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.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.stream.Stream;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -42,44 +54,63 @@ public class IdentityProviderBean {
public static OrderedModel.OrderedModelComparator<IdentityProvider> IDP_COMPARATOR_INSTANCE = new OrderedModel.OrderedModelComparator<>();
private static final String ICON_THEME_PREFIX = "kcLogoIdP-";
private boolean displaySocial;
private List<IdentityProvider> providers;
private RealmModel realm;
private final KeycloakSession session;
protected AuthenticationFlowContext context;
protected List<IdentityProvider> providers;
protected KeycloakSession session;
protected RealmModel realm;
protected URI baseURI;
public IdentityProviderBean() {
this.session = null;
}
public IdentityProviderBean(RealmModel realm, KeycloakSession session, Stream<IdentityProviderModel> identityProviders, URI baseURI) {
this.realm = realm;
public IdentityProviderBean(KeycloakSession session, RealmModel realm, URI baseURI, AuthenticationFlowContext context) {
this.session = session;
List<IdentityProvider> orderedList = new ArrayList<>();
identityProviders.forEach(identityProvider -> {
if (identityProvider.isEnabled() && !identityProvider.isLinkOnly()) {
addIdentityProvider(orderedList, realm, baseURI, identityProvider);
}
});
if (!orderedList.isEmpty()) {
orderedList.sort(IDP_COMPARATOR_INSTANCE);
providers = orderedList;
displaySocial = true;
}
this.realm = realm;
this.baseURI = baseURI;
this.context = context;
}
private void addIdentityProvider(List<IdentityProvider> orderedSet, RealmModel realm, URI baseURI, IdentityProviderModel identityProvider) {
public List<IdentityProvider> getProviders() {
if (this.providers == null) {
String existingIDP = this.getExistingIDP(session, context);
Set<String> federatedIdentities = this.getFederatedIdentities(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);
Map<String, String> config = identityProvider.getConfig();
boolean hideOnLoginPage = config != null && Boolean.parseBoolean(config.get("hideOnLoginPage"));
if (!hideOnLoginPage) {
orderedSet.add(new IdentityProvider(identityProvider.getAlias(),
displayName, identityProvider.getProviderId(), loginUrl,
config != null ? config.get("guiOrder") : null, getLoginIconClasses(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}'
@ -97,12 +128,121 @@ public class IdentityProviderBean {
return "";
}
public List<IdentityProvider> getProviders() {
return providers;
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;
}
public boolean isDisplayInfo() {
return realm.isRegistrationAllowed() || displaySocial;
/**
* 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 associated 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> getFederatedIdentities(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 idp -> Objects.nonNull(idp) && idp.isEnabled() && !idp.isLinkOnly() && !idp.isHideOnLogin();
}
/**
* Builds and returns a list of {@link IdentityProvider} instances that will be available for login. This method goes
* to the {@link IDPProvider} 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(IDPProvider.FETCH_MODE.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 {
@ -153,13 +293,5 @@ public class IdentityProviderBean {
}
}
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;
}
}

View file

@ -180,7 +180,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
.setAttributeMapper(attributes -> {
if (hasPublicBrokers(organization)) {
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, true)
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, true)
);
// do not show the self-registration link if there are public brokers available from the organization to force the user to register using a broker
attributes.computeIfPresent("realm",
@ -188,7 +188,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
);
} else {
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, false, true)
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true)
);
}
@ -208,7 +208,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
LoginFormsProvider form = context.form()
.setAttributeMapper(attributes -> {
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, false, true)
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true)
);
attributes.computeIfPresent("auth",
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)

View file

@ -18,73 +18,88 @@
package org.keycloak.organization.forms.login.freemarker.model;
import java.util.List;
import java.util.Optional;
import java.util.Objects;
import java.util.function.Predicate;
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
import org.keycloak.models.IDPProvider;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.organization.utils.Organizations;
public class OrganizationAwareIdentityProviderBean extends IdentityProviderBean {
private final KeycloakSession session;
private final List<IdentityProvider> providers;
private final OrganizationModel organization;
private final boolean onlyRealmBrokers;
private final boolean onlyOrganizationBrokers;
public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers) {
this(delegate, session, onlyOrganizationBrokers, false);
public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate) {
this(delegate, false);
}
public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers, boolean onlyRealmBrokers) {
this.session = session;
public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, boolean onlyOrganizationBrokers) {
this(delegate, onlyOrganizationBrokers, false);
}
public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, boolean onlyOrganizationBrokers, boolean onlyRealmBrokers) {
super(delegate.getSession(), delegate.getRealm(), delegate.getBaseURI(), delegate.getFlowContext());
this.organization = Organizations.resolveOrganization(super.session);
this.onlyRealmBrokers = onlyRealmBrokers;
this.onlyOrganizationBrokers = onlyOrganizationBrokers;
}
@Override
protected List<IdentityProvider> searchForIdentityProviders(String existingIDP) {
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();
} else {
providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream()
.filter(p -> isRealmBroker(p) || isPublicOrganizationBroker(p))
.toList();
// we only want the realm-level IDPs - i.e. those not associated with any orgs.
return session.identityProviders().getForLogin(IDPProvider.FETCH_MODE.REALM_ONLY, null)
.filter(idp -> !Objects.equals(existingIDP, idp.getAlias()))
.map(idp -> createIdentityProvider(this.realm, this.baseURI, idp))
.sorted(IDP_COMPARATOR_INSTANCE).toList();
}
}
public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session) {
this(delegate, session, false);
if (onlyOrganizationBrokers) {
// we already have the organization, just fetch the organization's public enabled IDPs.
if (this.organization != null) {
return organization.getIdentityProviders()
.filter(idp -> idp.isEnabled() && !idp.isLinkOnly() && !idp.isHideOnLogin()
&& Boolean.parseBoolean(idp.getConfig().get(OrganizationModel.BROKER_PUBLIC)))
.map(idp -> createIdentityProvider(super.realm, super.baseURI, idp))
.toList();
}
// we don't have a specific organization - fetch public enabled IDPs linked to any org.
return session.identityProviders().getForLogin(IDPProvider.FETCH_MODE.ORG_ONLY, null)
.filter(idp -> !Objects.equals(existingIDP, idp.getAlias()))
.map(idp -> createIdentityProvider(this.realm, this.baseURI, idp))
.sorted(IDP_COMPARATOR_INSTANCE).toList();
}
return session.identityProviders().getForLogin(IDPProvider.FETCH_MODE.ALL, this.organization != null ? this.organization.getId() : null)
.filter(idp -> !Objects.equals(existingIDP, idp.getAlias()))
.map(idp -> createIdentityProvider(this.realm, this.baseURI, idp))
.sorted(IDP_COMPARATOR_INSTANCE).toList();
}
@Override
public List<IdentityProvider> getProviders() {
return providers;
protected Predicate<IdentityProviderModel> federatedProviderPredicate() {
// use the predicate from the superclass combined with the organization filter.
return super.federatedProviderPredicate().and(idp -> {
if (onlyRealmBrokers) {
return idp.getOrganizationId() == null;
} else if (onlyOrganizationBrokers) {
return isPublicOrganizationBroker(idp);
} else {
return idp.getOrganizationId() == null || isPublicOrganizationBroker(idp);
}
});
}
@Override
public boolean isDisplayInfo() {
return !providers.isEmpty();
}
private boolean isPublicOrganizationBroker(IdentityProviderModel idp) {
private boolean isPublicOrganizationBroker(IdentityProvider idp) {
IdentityProviderModel model = session.identityProviders().getByAlias(idp.getAlias());
if (model.getOrganizationId() == null) {
if (idp.getOrganizationId() == null) {
return false;
}
OrganizationModel organization = Organizations.resolveOrganization(session);
if (organization != null && !organization.getId().equals(model.getOrganizationId())) {
if (organization != null && !Objects.equals(organization.getId(),idp.getOrganizationId())) {
return false;
}
return Boolean.parseBoolean(model.getConfig().getOrDefault(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString()));
}
private boolean isRealmBroker(IdentityProvider idp) {
IdentityProviderModel model = session.identityProviders().getByAlias(idp.getAlias());
return model.getOrganizationId() == null;
return Boolean.parseBoolean(idp.getConfig().getOrDefault(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString()));
}
}

View file

@ -197,6 +197,8 @@ public class IdentityProviderResource {
}
session.identityProviders().update(updated);
// update in case of legacy hide on login attr was used.
providerRep.setHideOnLogin(updated.isHideOnLogin());
if (oldProviderAlias != null && !oldProviderAlias.equals(newProviderAlias)) {

View file

@ -215,6 +215,7 @@ public class IdentityProvidersResource {
session.identityProviders().create(identityProvider);
representation.setInternalId(identityProvider.getInternalId());
representation.setHideOnLogin(identityProvider.isHideOnLogin()); // update in case of legacy hide on login attr was used.
adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri(), identityProvider.getAlias())
.representation(StripSecretsUtils.stripSecrets(session, representation)).success();

View file

@ -47,6 +47,7 @@ import java.nio.file.Paths;
import java.security.PublicKey;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
@ -587,6 +588,8 @@ public class IdentityProviderTest extends AbstractAdminTest {
String secret = idpRep.getConfig() != null ? idpRep.getConfig().get("clientSecret") : null;
idpRep = StripSecretsUtils.stripSecrets(null, idpRep);
// if legacy hide on login page attribute was used, the attr will be removed when converted to model
idpRep.setHideOnLogin(Boolean.parseBoolean(idpRep.getConfig().remove(IdentityProviderModel.LEGACY_HIDE_ON_LOGIN_ATTR)));
assertAdminEvents.assertEvent(realmId, OperationType.CREATE, AdminEventPaths.identityProviderPath(idpRep.getAlias()), idpRep, ResourceType.IDENTITY_PROVIDER);
@ -1044,6 +1047,7 @@ public class IdentityProviderTest extends AbstractAdminTest {
Assert.assertEquals("alias", expected.getAlias(), actual.getAlias());
Assert.assertEquals("providerId", expected.getProviderId(), actual.getProviderId());
Assert.assertEquals("enabled", expected.isEnabled(), actual.isEnabled());
Assert.assertEquals("hideOnLogin", expected.isHideOnLogin(), actual.isHideOnLogin());
Assert.assertEquals("firstBrokerLoginFlowAlias", expected.getFirstBrokerLoginFlowAlias(), actual.getFirstBrokerLoginFlowAlias());
Assert.assertEquals("config", expected.getConfig(), actual.getConfig());
}
@ -1055,32 +1059,36 @@ public class IdentityProviderTest extends AbstractAdminTest {
Assert.assertEquals("alias", "saml", idp.getAlias());
Assert.assertEquals("providerId", "saml", idp.getProviderId());
Assert.assertEquals("enabled",enabled, idp.isEnabled());
Assert.assertTrue("hideOnLogin", idp.isHideOnLogin());
Assert.assertNull("firstBrokerLoginFlowAlias", idp.getFirstBrokerLoginFlowAlias());
assertSamlConfig(idp.getConfig(), postBindingResponse);
assertSamlConfig(idp.getConfig(), postBindingResponse, false);
}
private void assertSamlConfig(Map<String, String> config, boolean postBindingResponse) {
private void assertSamlConfig(Map<String, String> config, boolean postBindingResponse, boolean hasHideOnLoginPage) {
// import endpoint simply converts IDPSSODescriptor into key value pairs.
// check that saml-idp-metadata.xml was properly converted into key value pairs
//System.out.println(config);
assertThat(config.keySet(), containsInAnyOrder(
"syncMode",
"validateSignature",
"singleLogoutServiceUrl",
"postBindingLogout",
"postBindingResponse",
"artifactBindingResponse",
"postBindingAuthnRequest",
"singleSignOnServiceUrl",
"artifactResolutionServiceUrl",
"wantAuthnRequestsSigned",
"nameIDPolicyFormat",
"signingCertificate",
"addExtensionsElementWithKeyInfo",
"loginHint",
"hideOnLoginPage",
"idpEntityId"
List<String> configKeys = new ArrayList<>(List.of(
"syncMode",
"validateSignature",
"singleLogoutServiceUrl",
"postBindingLogout",
"postBindingResponse",
"artifactBindingResponse",
"postBindingAuthnRequest",
"singleSignOnServiceUrl",
"artifactResolutionServiceUrl",
"wantAuthnRequestsSigned",
"nameIDPolicyFormat",
"signingCertificate",
"addExtensionsElementWithKeyInfo",
"loginHint",
"idpEntityId"
));
if (hasHideOnLoginPage) {
configKeys.add("hideOnLoginPage");
}
assertThat(config.keySet(), containsInAnyOrder(configKeys.toArray()));
assertThat(config, hasEntry("validateSignature", "true"));
assertThat(config, hasEntry("singleLogoutServiceUrl", "http://localhost:8080/auth/realms/master/protocol/saml"));
assertThat(config, hasEntry("artifactResolutionServiceUrl", "http://localhost:8080/auth/realms/master/protocol/saml/resolve"));
@ -1091,9 +1099,11 @@ public class IdentityProviderTest extends AbstractAdminTest {
assertThat(config, hasEntry("wantAuthnRequestsSigned", "true"));
assertThat(config, hasEntry("addExtensionsElementWithKeyInfo", "false"));
assertThat(config, hasEntry("nameIDPolicyFormat", "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"));
assertThat(config, hasEntry("hideOnLoginPage", "true"));
assertThat(config, hasEntry("idpEntityId", "http://localhost:8080/auth/realms/master"));
assertThat(config, hasEntry(is("signingCertificate"), notNullValue()));
if (hasHideOnLoginPage) {
assertThat(config, hasEntry("hideOnLoginPage", "true"));
}
}
private void assertSamlImport(Map<String, String> config, String expectedSigningCertificates, boolean enabled, boolean postBindingResponse) {
@ -1101,7 +1111,7 @@ public class IdentityProviderTest extends AbstractAdminTest {
boolean enabledFromMetadata = Boolean.valueOf(config.get(SAMLIdentityProviderConfig.ENABLED_FROM_METADATA));
config.remove(SAMLIdentityProviderConfig.ENABLED_FROM_METADATA);
Assert.assertEquals(enabledFromMetadata,enabled);
assertSamlConfig(config, postBindingResponse);
assertSamlConfig(config, postBindingResponse, true);
assertThat(config, hasEntry("signingCertificate", expectedSigningCertificates));
}

View file

@ -49,7 +49,7 @@ public class KcOidcBrokerHiddenIdpHintTest extends AbstractInitializedBaseBroker
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
config.put("hideOnLoginPage", "true");
idp.setHideOnLogin(true);
return idp;
}
}

View file

@ -56,12 +56,12 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
IdentityProviderRepresentation expected = orgIdPResource.toRepresentation();
// organization link set
Assert.assertEquals(expected.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE), organization.getId());
Assert.assertEquals(expected.getOrganizationId(), organization.getId());
IdentityProviderResource idpResource = testRealm().identityProviders().get(expected.getAlias());
IdentityProviderRepresentation actual = idpResource.toRepresentation();
Assert.assertEquals(actual.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE), organization.getId());
actual.getConfig().put(OrganizationModel.ORGANIZATION_ATTRIBUTE, "somethingelse");
Assert.assertEquals(actual.getOrganizationId(), organization.getId());
actual.setOrganizationId("somethingelse");
try {
idpResource.update(actual);
Assert.fail("Should fail because it maps to an invalid org");
@ -69,19 +69,19 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
}
OrganizationRepresentation secondOrg = createOrganization("secondorg");
actual.getConfig().put(OrganizationModel.ORGANIZATION_ATTRIBUTE, secondOrg.getId());
actual.setOrganizationId(secondOrg.getId());
idpResource.update(actual);
actual = idpResource.toRepresentation();
Assert.assertEquals(actual.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE), organization.getId());
Assert.assertEquals(actual.getOrganizationId(), organization.getId());
actual = idpResource.toRepresentation();
// the link to the organization should not change
Assert.assertEquals(actual.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE), organization.getId());
actual.getConfig().remove(OrganizationModel.ORGANIZATION_ATTRIBUTE);
Assert.assertEquals(actual.getOrganizationId(), organization.getId());
actual.setOrganizationId(null);
idpResource.update(actual);
actual = idpResource.toRepresentation();
// the link to the organization should not change
Assert.assertEquals(actual.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE), organization.getId());
Assert.assertEquals(actual.getOrganizationId(), organization.getId());
String domain = actual.getConfig().get(ORGANIZATION_DOMAIN_ATTRIBUTE);
@ -112,18 +112,19 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
.identityProviders().get(bc.getIDPAlias()).toRepresentation();
//remove Org related stuff from the template
idpTemplate.getConfig().remove(OrganizationModel.ORGANIZATION_ATTRIBUTE);
idpTemplate.setOrganizationId(null);
idpTemplate.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE);
idpTemplate.getConfig().remove(OrganizationModel.BROKER_PUBLIC);
idpTemplate.getConfig().remove(OrganizationModel.IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
for (int i = 0; i < 5; i++) {
idpTemplate.setAlias("idp-" + i);
idpTemplate.setInternalId(null);
try (Response response = testRealm().identityProviders().create(idpTemplate)) {
assertThat("Falied to create idp-" + i, response.getStatus(), equalTo(Status.CREATED.getStatusCode()));
assertThat("Failed to create idp-" + i, response.getStatus(), equalTo(Status.CREATED.getStatusCode()));
}
try (Response response = organization.identityProviders().addIdentityProvider(idpTemplate.getAlias())) {
assertThat("Falied to add idp-" + i, response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode()));
assertThat("Failed to add idp-" + i, response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode()));
}
}
@ -191,7 +192,7 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
// broker not removed from realm
IdentityProviderRepresentation idpRep = testRealm().identityProviders().get(bc.getIDPAlias()).toRepresentation();
// broker no longer linked to the org
Assert.assertNull(idpRep.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE));
Assert.assertNull(idpRep.getOrganizationId());
Assert.assertNull(idpRep.getConfig().get(ORGANIZATION_DOMAIN_ATTRIBUTE));
Assert.assertNull(idpRep.getConfig().get(BROKER_PUBLIC));
}

View file

@ -27,8 +27,8 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Predicate;
import jakarta.ws.rs.core.Response;
import org.hamcrest.Matchers;
@ -194,7 +194,7 @@ public class OrganizationExportTest extends AbstractOrganizationTest {
RealmRepresentation export = testRealm().partialExport(exportGroupsAndRoles, exportClients);
assertTrue(Optional.ofNullable(export.getGroups()).orElse(List.of()).stream().noneMatch(g -> g.getAttributes().containsKey(OrganizationModel.ORGANIZATION_ATTRIBUTE)));
assertTrue(Optional.ofNullable(export.getOrganizations()).orElse(List.of()).isEmpty());
assertTrue(Optional.ofNullable(export.getIdentityProviders()).orElse(List.of()).stream().noneMatch(g -> g.getConfig().containsKey(OrganizationModel.ORGANIZATION_ATTRIBUTE)));
assertTrue(Optional.ofNullable(export.getIdentityProviders()).orElse(List.of()).stream().noneMatch(idp -> Objects.nonNull(idp.getOrganizationId())));
PartialImportRepresentation rep = new PartialImportRepresentation();
rep.setUsers(export.getUsers());
rep.setClients(export.getClients());

View file

@ -52,7 +52,7 @@ public class IdentityProviderBuilder {
}
public IdentityProviderBuilder hideOnLoginPage() {
setAttribute("hideOnLoginPage", "true");
rep.setHideOnLogin(true);
return this;
}