Organization member onboarding using the organization identity provider

Closes #28273

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-04-15 16:22:11 -03:00
parent e7dd5c1991
commit 1e3837421e
21 changed files with 767 additions and 56 deletions

View file

@ -226,6 +226,11 @@ public class JpaOrganizationProvider implements OrganizationProvider {
return true; return true;
} }
@Override
public boolean isEnabled() {
return getAllStream().findAny().isPresent();
}
@Override @Override
public void close() { public void close() {
} }

View file

@ -18,9 +18,14 @@
package org.keycloak.organization.jpa; package org.keycloak.organization.jpa;
import org.keycloak.Config.Scope; import org.keycloak.Config.Scope;
import org.keycloak.organization.authentication.authenticators.broker.IdpOrganizationAuthenticatorFactory;
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmModel.RealmPostCreateEvent;
import org.keycloak.models.RealmModel.RealmRemovedEvent; import org.keycloak.models.RealmModel.RealmRemovedEvent;
import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.OrganizationProviderFactory; import org.keycloak.organization.OrganizationProviderFactory;
@ -40,7 +45,7 @@ public class JpaOrganizationProviderFactory implements OrganizationProviderFacto
@Override @Override
public void postInit(KeycloakSessionFactory factory) { public void postInit(KeycloakSessionFactory factory) {
factory.register(this::handleRealmRemovedEvent); factory.register(this::handleEvents);
} }
@Override @Override
@ -53,11 +58,66 @@ public class JpaOrganizationProviderFactory implements OrganizationProviderFacto
return "jpa"; return "jpa";
} }
private void handleRealmRemovedEvent(ProviderEvent event) { private void handleEvents(ProviderEvent event) {
if (event instanceof RealmPostCreateEvent) {
RealmModel realm = ((RealmPostCreateEvent) event).getCreatedRealm();
configureAuthenticationFlows(realm);
}
if (event instanceof RealmRemovedEvent) { if (event instanceof RealmRemovedEvent) {
KeycloakSession session = ((RealmRemovedEvent) event).getKeycloakSession(); KeycloakSession session = ((RealmRemovedEvent) event).getKeycloakSession();
OrganizationProvider provider = session.getProvider(OrganizationProvider.class); OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
provider.removeAll(); provider.removeAll();
} }
} }
private void configureAuthenticationFlows(RealmModel realm) {
addOrganizationFirstBrokerFlowStep(realm);
addOrganizationBrowserFlowStep(realm);
}
private void addOrganizationFirstBrokerFlowStep(RealmModel realm) {
AuthenticationFlowModel firstBrokerLoginFlow = realm.getFirstBrokerLoginFlow();
if (firstBrokerLoginFlow == null) {
return;
}
if (realm.getAuthenticationExecutionsStream(firstBrokerLoginFlow.getId())
.map(AuthenticationExecutionModel::getAuthenticator)
.anyMatch(IdpOrganizationAuthenticatorFactory.ID::equals)) {
return;
}
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
execution.setParentFlow(firstBrokerLoginFlow.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator(IdpOrganizationAuthenticatorFactory.ID);
execution.setPriority(50);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
public void addOrganizationBrowserFlowStep(RealmModel realm) {
AuthenticationFlowModel browserFlow = realm.getBrowserFlow();
if (browserFlow == null) {
return;
}
if (realm.getAuthenticationExecutionsStream(browserFlow.getId())
.map(AuthenticationExecutionModel::getAuthenticator)
.anyMatch(OrganizationAuthenticatorFactory.ID::equals)) {
return;
}
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
execution.setParentFlow(browserFlow.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
execution.setAuthenticator(OrganizationAuthenticatorFactory.ID);
execution.setPriority(26);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
} }

View file

@ -27,6 +27,7 @@ import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.ModelValidationException; import org.keycloak.models.ModelValidationException;
import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationDomainModel;
import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel;
@ -128,6 +129,11 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
} }
} }
@Override
public IdentityProviderModel getIdentityProvider() {
return provider.getIdentityProvider(this);
}
@Override @Override
public OrganizationEntity getEntity() { public OrganizationEntity getEntity() {
return entity; return entity;

View file

@ -28,6 +28,8 @@ import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Function;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -162,4 +164,6 @@ public interface LoginFormsProvider extends Provider {
LoginFormsProvider setExecution(String execution); LoginFormsProvider setExecution(String execution);
LoginFormsProvider setAuthContext(AuthenticationFlowContext context); LoginFormsProvider setAuthContext(AuthenticationFlowContext context);
LoginFormsProvider setAttributeMapper(Function<Map<String, Object>, Map<String, Object>> configurer);
} }

View file

@ -132,4 +132,11 @@ public interface OrganizationProvider extends Provider {
* @return {@code true} if the link was removed, {@code false} otherwise * @return {@code true} if the link was removed, {@code false} otherwise
*/ */
boolean removeIdentityProvider(OrganizationModel organization); boolean removeIdentityProvider(OrganizationModel organization);
/**
* Indicates if the current realm supports organization.
*
* @return {@code true} if organization is supported. Otherwise, returns {@code false}
*/
boolean isEnabled();
} }

View file

@ -39,4 +39,6 @@ public interface OrganizationModel {
Stream<OrganizationDomainModel> getDomains(); Stream<OrganizationDomainModel> getDomains();
void setDomains(Set<OrganizationDomainModel> domains); void setDomains(Set<OrganizationDomainModel> domains);
IdentityProviderModel getIdentityProvider();
} }

View file

@ -74,7 +74,7 @@ public class IdentityProviderAuthenticator implements Authenticator {
} }
} }
private void redirect(AuthenticationFlowContext context, String providerId) { protected void redirect(AuthenticationFlowContext context, String providerId) {
Optional<IdentityProviderModel> idp = context.getRealm().getIdentityProvidersStream() Optional<IdentityProviderModel> idp = context.getRealm().getIdentityProvidersStream()
.filter(IdentityProviderModel::isEnabled) .filter(IdentityProviderModel::isEnabled)
.filter(identityProvider -> Objects.equals(providerId, identityProvider.getAlias())) .filter(identityProvider -> Objects.equals(providerId, identityProvider.getAlias()))

View file

@ -94,7 +94,9 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.Properties; import java.util.Properties;
import java.util.function.Function;
import static org.keycloak.models.UserModel.RequiredAction.UPDATE_PASSWORD; import static org.keycloak.models.UserModel.RequiredAction.UPDATE_PASSWORD;
@ -133,6 +135,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
protected UserModel user; protected UserModel user;
protected final Map<String, Object> attributes = new HashMap<>(); protected final Map<String, Object> attributes = new HashMap<>();
private Function<Map<String, Object>, Map<String, Object>> attributeMapper;
public FreeMarkerLoginFormsProvider(KeycloakSession session) { public FreeMarkerLoginFormsProvider(KeycloakSession session) {
this.session = session; this.session = session;
@ -547,6 +550,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
*/ */
protected Response processTemplate(Theme theme, String templateName, Locale locale) { protected Response processTemplate(Theme theme, String templateName, Locale locale) {
try { try {
Map<String, Object> attributes = Optional.ofNullable(attributeMapper).orElse(Function.identity()).apply(this.attributes);
String result = freeMarker.processTemplate(attributes, templateName, theme); String result = freeMarker.processTemplate(attributes, templateName, theme);
Response.ResponseBuilder builder = Response.status(status == null ? Response.Status.OK : status).type(MediaType.TEXT_HTML_UTF_8_TYPE).language(locale).entity(result); Response.ResponseBuilder builder = Response.status(status == null ? Response.Status.OK : status).type(MediaType.TEXT_HTML_UTF_8_TYPE).language(locale).entity(result);
for (Map.Entry<String, String> entry : httpResponseHeaders.entrySet()) { for (Map.Entry<String, String> entry : httpResponseHeaders.entrySet()) {
@ -903,11 +907,18 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
return this; return this;
} }
@Override
public LoginFormsProvider setAuthContext(AuthenticationFlowContext context) { public LoginFormsProvider setAuthContext(AuthenticationFlowContext context) {
this.context = context; this.context = context;
return this; return this;
} }
@Override
public LoginFormsProvider setAttributeMapper(Function<Map<String, Object>, Map<String, Object>> mapper) {
this.attributeMapper = mapper;
return this;
}
@Override @Override
public void close() { public void close() {
} }

View file

@ -28,6 +28,8 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException; import org.keycloak.models.ModelException;
@ -70,7 +72,7 @@ public class OrganizationIdentityProviderResource {
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public Response addIdentityProvider(IdentityProviderRepresentation providerRep) { public Response addIdentityProvider(IdentityProviderRepresentation providerRep) {
IdentityProviderModel identityProvider = organizationProvider.getIdentityProvider(organization); IdentityProviderModel identityProvider = organization.getIdentityProvider();
if (identityProvider != null) { if (identityProvider != null) {
throw ErrorResponse.error("Organization already assigned with an identity provider.", Status.BAD_REQUEST); throw ErrorResponse.error("Organization already assigned with an identity provider.", Status.BAD_REQUEST);
} }
@ -101,8 +103,7 @@ public class OrganizationIdentityProviderResource {
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public IdentityProviderRepresentation getIdentityProvider() { public IdentityProviderRepresentation getIdentityProvider() {
IdentityProviderModel identityProvider = organizationProvider.getIdentityProvider(organization); return Optional.ofNullable(organization.getIdentityProvider()).map(this::toRepresentation).orElse(null);
return identityProvider == null ? null : toRepresentation(identityProvider);
} }
@DELETE @DELETE
@ -130,17 +131,21 @@ public class OrganizationIdentityProviderResource {
@PUT @PUT
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public Response update(IdentityProviderRepresentation providerRep) { public Response update(IdentityProviderRepresentation rep) {
IdentityProviderModel identityProvider = getIdentityProviderModel(); IdentityProviderModel identityProvider = getIdentityProviderModel();
Response response = getIdentityProviderResource(identityProvider).update(providerRep); if (!rep.getAlias().equals(identityProvider.getAlias())) {
throw ErrorResponse.error("Identity provider not assigned to the organization.", Status.NOT_FOUND);
}
Response response = getIdentityProviderResource(identityProvider).update(rep);
//update link between IdP and the organization if the update of IdP was successful and the IdP alias differs //update link between IdP and the organization if the update of IdP was successful and the IdP alias differs
if (Status.NO_CONTENT.getStatusCode() == response.getStatus() && if (Status.NO_CONTENT.getStatusCode() == response.getStatus() &&
! Objects.equals(identityProvider.getAlias(), providerRep.getAlias())) { ! Objects.equals(identityProvider.getAlias(), rep.getAlias())) {
//get the updated IdP from session //get the updated IdP from session
identityProvider = realm.getIdentityProviderByAlias(providerRep.getAlias()); identityProvider = realm.getIdentityProviderByAlias(rep.getAlias());
String errorMessage; String errorMessage;
try { try {
@ -167,10 +172,12 @@ public class OrganizationIdentityProviderResource {
} }
private IdentityProviderModel getIdentityProviderModel() { private IdentityProviderModel getIdentityProviderModel() {
IdentityProviderModel identityProvider = organizationProvider.getIdentityProvider(organization); IdentityProviderModel identityProvider = organization.getIdentityProvider();
if (identityProvider == null) { if (identityProvider == null) {
throw ErrorResponse.error("Organization doesn't have assigned an identity provider.", Status.NOT_FOUND); throw ErrorResponse.error("Organization doesn't have assigned an identity provider.", Status.NOT_FOUND);
} }
return identityProvider; return identityProvider;
} }
} }

View file

@ -0,0 +1,101 @@
/*
* 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.authentication.authenticators.broker;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
public class IdpOrganizationAuthenticator extends AbstractIdpAuthenticator {
@Override
protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
}
@Override
protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
OrganizationProvider provider = context.getSession().getProvider(OrganizationProvider.class);
UserModel user = context.getUser();
OrganizationModel organization = (OrganizationModel) context.getSession().getAttribute(OrganizationModel.class.getName());
if (organization == null) {
context.attempted();
return;
}
IdentityProviderModel expectedBroker = organization.getIdentityProvider();
IdentityProviderModel currentBroker = brokerContext.getIdpConfig();
if (!expectedBroker.getAlias().equals(currentBroker.getAlias())) {
context.failure(AuthenticationFlowError.ACCESS_DENIED);
return;
}
provider.addMember(organization, user);
context.success();
}
@Override
public boolean requiresUser() {
return true;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
if (!provider.isEnabled()) {
return false;
}
String domain = getEmailDomain(user.getEmail());
if (domain == null) {
return false;
}
OrganizationModel organization = provider.getByDomainName(domain);
if (organization == null || provider.getIdentityProvider(organization) == null) {
return false;
}
session.setAttribute(OrganizationModel.class.getName(), organization);
return true;
}
private String getEmailDomain(String email) {
int domainSeparator = email.indexOf('@');
if (domainSeparator == -1) {
return null;
}
return email.substring(domainSeparator + 1);
}
}

View file

@ -0,0 +1,102 @@
/*
* 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.authentication.authenticators.broker;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.Config.Scope;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
public class IdpOrganizationAuthenticatorFactory implements AuthenticatorFactory, EnvironmentDependentProviderFactory {
public static final String ID = "organization-broker";
@Override
public Authenticator create(KeycloakSession session) {
return new IdpOrganizationAuthenticator();
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return ID;
}
@Override
public String getReferenceCategory() {
return "organization";
}
@Override
public boolean isConfigurable() {
return false;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public String getDisplayType() {
return "Organization Member Link";
}
@Override
public String getHelpText() {
return "Adds a federated user as a member of an organization";
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
@Override
public boolean isSupported(Scope config) {
return Profile.isFeatureEnabled(Feature.ORGANIZATION);
}
}

View file

@ -0,0 +1,103 @@
/*
* 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.authentication.authenticators.browser;
import jakarta.ws.rs.core.MultivaluedMap;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
private final KeycloakSession session;
public OrganizationAuthenticator(KeycloakSession session) {
this.session = session;
}
@Override
public void authenticate(AuthenticationFlowContext context) {
OrganizationProvider provider = getOrganizationProvider();
if (!provider.isEnabled()) {
context.attempted();
return;
}
challenge(context);
}
@Override
public void action(AuthenticationFlowContext context) {
HttpRequest request = context.getHttpRequest();
MultivaluedMap<String, String> parameters = request.getDecodedFormParameters();
String username = parameters.getFirst(UserModel.USERNAME);
if (username == null) {
challenge(context);
return;
}
String domain = getEmailDomain(username);
OrganizationProvider provider = getOrganizationProvider();
OrganizationModel organization = provider.getByDomainName(domain);
if (organization == null) {
context.attempted();
return;
}
IdentityProviderModel identityProvider = organization.getIdentityProvider();
if (identityProvider == null) {
context.attempted();
return;
}
redirect(context, identityProvider.getAlias());
}
private OrganizationProvider getOrganizationProvider() {
return session.getProvider(OrganizationProvider.class);
}
private void challenge (AuthenticationFlowContext context){
context.challenge(context.form()
.setAttributeMapper(attributes -> {
// removes identity provider related attributes from forms
attributes.remove("social");
return attributes;
})
.createLoginUsername());
}
private String getEmailDomain(String email) {
int domainSeparator = email.indexOf('@');
if (domainSeparator == -1) {
return null;
}
return email.substring(domainSeparator + 1);
}
}

View file

@ -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.authentication.authenticators.browser;
import org.keycloak.Config.Scope;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class OrganizationAuthenticatorFactory extends IdentityProviderAuthenticatorFactory implements EnvironmentDependentProviderFactory {
public static final String ID = "organization";
@Override
public String getId() {
return ID;
}
@Override
public String getDisplayType() {
return "Organization Identity Provider Redirector";
}
@Override
public String getHelpText() {
return "If organizations are enabled, automatically redirects users to the corresponding identity provider.";
}
@Override
public Authenticator create(KeycloakSession session) {
return new OrganizationAuthenticator(session);
}
@Override
public boolean isSupported(Scope config) {
return Profile.isFeatureEnabled(Feature.ORGANIZATION);
}
}

View file

@ -40,6 +40,7 @@ org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFac
org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticatorFactory org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticatorFactory
org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory
org.keycloak.authentication.authenticators.broker.IdpAutoLinkAuthenticatorFactory org.keycloak.authentication.authenticators.broker.IdpAutoLinkAuthenticatorFactory
org.keycloak.organization.authentication.authenticators.broker.IdpOrganizationAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory
org.keycloak.protocol.saml.profile.ecp.authenticator.HttpBasicAuthenticatorFactory org.keycloak.protocol.saml.profile.ecp.authenticator.HttpBasicAuthenticatorFactory
org.keycloak.authentication.authenticators.x509.X509ClientCertificateAuthenticatorFactory org.keycloak.authentication.authenticators.x509.X509ClientCertificateAuthenticatorFactory
@ -51,3 +52,4 @@ org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory
org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory

View file

@ -87,6 +87,12 @@ public class LoginPage extends LanguageComboboxAwarePage {
clickLink(submitButton); clickLink(submitButton);
} }
public void loginUsername(String username) {
clearUsernameInputAndWaitIfNecessary();
usernameInput.sendKeys(username);
clickLink(submitButton);
}
private void clearUsernameInputAndWaitIfNecessary() { private void clearUsernameInputAndWaitIfNecessary() {
try { try {
usernameInput.clear(); usernameInput.clear();
@ -145,6 +151,10 @@ public class LoginPage extends LanguageComboboxAwarePage {
return passwordInput.getAttribute("value"); return passwordInput.getAttribute("value");
} }
public boolean isPasswordInputPresent() {
return !driver.findElements(By.id("password")).isEmpty();
}
public void cancel() { public void cancel() {
cancelButton.click(); cancelButton.click();
} }

View file

@ -75,10 +75,10 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
client.setSecret(CLIENT_SECRET); client.setSecret(CLIENT_SECRET);
client.setRedirectUris(Collections.singletonList(getConsumerRoot() + client.setRedirectUris(Collections.singletonList(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + getIDPAlias() + "/endpoint/*")); "/auth/realms/" + consumerRealmName() + "/broker/" + getIDPAlias() + "/endpoint/*"));
client.setAdminUrl(getConsumerRoot() + client.setAdminUrl(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + getIDPAlias() + "/endpoint"); "/auth/realms/" + consumerRealmName() + "/broker/" + getIDPAlias() + "/endpoint");
OIDCAdvancedConfigWrapper.fromClientRepresentation(client).setPostLogoutRedirectUris(Collections.singletonList("+")); OIDCAdvancedConfigWrapper.fromClientRepresentation(client).setPostLogoutRedirectUris(Collections.singletonList("+"));
@ -188,7 +188,7 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
@Override @Override
public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) { public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID); IdentityProviderRepresentation idp = createIdentityProvider(getIDPAlias(), IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig(); Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode); applyDefaultConfiguration(config, syncMode);

View file

@ -20,15 +20,20 @@ package org.keycloak.testsuite.organization.admin;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import java.util.List;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.Response.Status;
import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.AbstractAdminTest; import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.admin.Users; import org.keycloak.testsuite.admin.Users;
import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration;
import org.keycloak.testsuite.util.UserBuilder;
/** /**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a> * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -39,6 +44,52 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
protected String memberEmail = "jdoe@neworg.org"; protected String memberEmail = "jdoe@neworg.org";
protected String memberPassword = "password"; protected String memberPassword = "password";
protected KcOidcBrokerConfiguration bc = new KcOidcBrokerConfiguration() {
@Override
public String consumerRealmName() {
return TEST_REALM_NAME;
}
@Override
public RealmRepresentation createProviderRealm() {
RealmRepresentation providerRealm = super.createProviderRealm();
providerRealm.setClients(createProviderClients());
providerRealm.setUsers(List.of(
UserBuilder.create()
.username(getUserLogin())
.email(getUserEmail())
.password(getUserPassword())
.enabled(true).build())
);
return providerRealm;
}
@Override
public String getUserEmail() {
return getUserLogin() + "@" + organizationName + ".org";
}
@Override
public String getIDPAlias() {
return "org-identity-provider";
}
};
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
testRealm.getClients().addAll(bc.createConsumerClients());
testRealm.setSmtpServer(null);
super.configureTestRealm(testRealm);
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
testRealms.add(bc.createProviderRealm());
super.addTestRealms(testRealms);
}
protected OrganizationRepresentation createOrganization() { protected OrganizationRepresentation createOrganization() {
return createOrganization(organizationName); return createOrganization(organizationName);
} }
@ -63,7 +114,10 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
id = ApiUtil.getCreatedId(response); id = ApiUtil.getCreatedId(response);
} }
org.setId(id); testRealm().organizations().get(id).identityProvider().create(bc.setUpIdentityProvider()).close();
org = testRealm().organizations().get(id).toRepresentation();
getCleanup().addCleanup(() -> testRealm().organizations().get(id).delete().close()); getCleanup().addCleanup(() -> testRealm().organizations().get(id).delete().close());
return org; return org;

View file

@ -0,0 +1,183 @@
/*
* 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.testsuite.organization.admin;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
import java.util.List;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.common.Profile.Feature;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.UpdateAccountInformationPage;
import org.keycloak.testsuite.util.UserBuilder;
@EnableFeature(Feature.ORGANIZATION)
public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganizationTest {
@Page
protected LoginPage loginPage;
@Page
protected IdpConfirmLinkPage idpConfirmLinkPage;
@Page
protected UpdateAccountInformationPage updateAccountInformationPage;
@Page
protected AppPage appPage;
@Test
public void testBrokerRegistration() {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
assertBrokerRegistration(organization);
}
@Test
public void testDefaultAuthenticationMechanismIfNotOrganizationMember() {
testRealm().organizations().get(createOrganization().getId());
oauth.clientId("broker-app");
// login with email only
loginPage.open(bc.consumerRealmName());
log.debug("Logging in");
Assert.assertFalse(loginPage.isPasswordInputPresent());
loginPage.loginUsername("user@noorg.org");
// check if the login page is shown
Assert.assertTrue(loginPage.isUsernameInputPresent());
Assert.assertTrue(loginPage.isPasswordInputPresent());
}
@Test
public void testLinkExistingAccount() {
// create a realm user in the consumer realm
realmsResouce().realm(bc.consumerRealmName()).users()
.create(UserBuilder.create()
.username(bc.getUserLogin())
.email(bc.getUserEmail())
.password(bc.getUserPassword())
.enabled(true).build()
).close();
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
oauth.clientId("broker-app");
// login with email only
loginPage.open(bc.consumerRealmName());
log.debug("Logging in");
loginPage.loginUsername(bc.getUserEmail());
// user automatically redirected to the organization identity provider
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
// login to the organization identity provider and run the configured first broker login flow
loginPage.login(bc.getUserEmail(), bc.getUserPassword());
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
Assert.assertTrue("We must be on correct realm right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
log.debug("Updating info on updateAccount page");
updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), bc.getUserEmail(), "Firstname", "Lastname");
// account with the same email exists in the realm, execute account linking
waitForPage(driver, "account already exists", false);
idpConfirmLinkPage.assertCurrent();
idpConfirmLinkPage.clickLinkAccount();
// confirm the link by authenticating
loginPage.login(bc.getUserEmail(), bc.getUserPassword());
assertIsMember(bc.getUserEmail(), organization);
}
@Test
public void testMemberAlreadyExists() {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
// add the member for the first time
assertBrokerRegistration(organization);
// logout to force the user to authenticate again
UserRepresentation account = getUserRepresentation(bc.getUserEmail());
realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout();
// login with email only
loginPage.open(bc.consumerRealmName());
log.debug("Logging in");
loginPage.loginUsername(bc.getUserEmail());
// user automatically redirected to the organization identity provider
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
// login to the organization identity provider and automatically redirects to the app as the account already exists
loginPage.login(bc.getUserEmail(), bc.getUserPassword());
appPage.assertCurrent();
assertIsMember(bc.getUserEmail(), organization);
}
private void assertBrokerRegistration(OrganizationResource organization) {
// login with email only
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
log.debug("Logging in");
Assert.assertFalse(loginPage.isPasswordInputPresent());
Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias()));
loginPage.loginUsername(bc.getUserEmail());
// user automatically redirected to the organization identity provider
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
// login to the organization identity provider and run the configured first broker login flow
loginPage.login(bc.getUserEmail(), bc.getUserPassword());
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
Assert.assertTrue("We must be on correct realm right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
log.debug("Updating info on updateAccount page");
updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), bc.getUserEmail(), "Firstname", "Lastname");
assertIsMember(bc.getUserEmail(), organization);
}
private void assertIsMember(String userEmail, OrganizationResource organization) {
UserRepresentation account = getUserRepresentation(userEmail);
UserRepresentation member = organization.members().member(account.getId()).toRepresentation();
Assert.assertEquals(account.getId(), member.getId());
}
private UserRepresentation getUserRepresentation(String userEmail) {
UsersResource users = adminClient.realm(bc.consumerRealmName()).users();
List<UserRepresentation> reps = users.searchByEmail(userEmail, true);
Assert.assertFalse(reps.isEmpty());
Assert.assertEquals(1, reps.size());
return reps.get(0);
}
}

View file

@ -22,7 +22,7 @@ import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.nullValue;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.junit.Before; import jakarta.ws.rs.core.Response.Status;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationIdentityProviderResource; import org.keycloak.admin.client.resource.OrganizationIdentityProviderResource;
import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.admin.client.resource.OrganizationResource;
@ -34,35 +34,41 @@ import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
@EnableFeature(Feature.ORGANIZATION) @EnableFeature(Feature.ORGANIZATION)
public class OrganizationIdentityProviderTest extends AbstractOrganizationTest { public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
private final String idpAlias = "org-identity-provider"; @Test
public void testUpdate() {
OrganizationRepresentation organization = createOrganization();
OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()).identityProvider();
IdentityProviderRepresentation idpRepresentation = orgIdPResource.toRepresentation();
assertThat(idpRepresentation.getAlias(), equalTo(bc.getIDPAlias()));
@Before String displayName = "My Org Broker";
public void addCleanups() { //update
addCleanupIdP(idpAlias); idpRepresentation.setDisplayName(displayName);
try (Response response = orgIdPResource.update(idpRepresentation)) {
assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode()));
}
assertThat(orgIdPResource.toRepresentation().getDisplayName(), equalTo(displayName));
} }
@Test @Test
public void testCRUD() { public void testFailUpdateAlias() {
OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(createOrganization().getId()).identityProvider(); OrganizationRepresentation organization = createOrganization();
OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()).identityProvider();
IdentityProviderRepresentation idpRepresentation = orgIdPResource.toRepresentation();
assertThat(idpRepresentation.getAlias(), equalTo(bc.getIDPAlias()));
//create, read
IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc");
try (Response response = orgIdPResource.create(idpRepresentation)) {
assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode()));
}
idpRepresentation = orgIdPResource.toRepresentation();
assertThat(idpRepresentation.getAlias(), equalTo(idpAlias));
String updatedIdpAlias = "updated-org-identity-provider";
//update //update
idpRepresentation.setAlias(updatedIdpAlias); idpRepresentation.setAlias("should-fail");
try (Response response = orgIdPResource.update(idpRepresentation)) { try (Response response = orgIdPResource.update(idpRepresentation)) {
assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); assertThat(response.getStatus(), equalTo(Status.NOT_FOUND.getStatusCode()));
addCleanupIdP(updatedIdpAlias);
} }
assertThat(orgIdPResource.toRepresentation().getAlias(), equalTo(updatedIdpAlias)); }
@Test
public void testDelete() {
OrganizationRepresentation organization = createOrganization();
OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()).identityProvider();
//delete
try (Response response = orgIdPResource.delete()) { try (Response response = orgIdPResource.delete()) {
assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode()));
} }
@ -73,10 +79,7 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
public void tryCreateSecondIdp() { public void tryCreateSecondIdp() {
OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(createOrganization().getId()).identityProvider(); OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(createOrganization().getId()).identityProvider();
IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc"); IdentityProviderRepresentation idpRepresentation = orgIdPResource.toRepresentation();
try (Response response = orgIdPResource.create(idpRepresentation)) {
assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode()));
}
idpRepresentation.setAlias("another-idp"); idpRepresentation.setAlias("another-idp");
try (Response response = orgIdPResource.create(idpRepresentation)) { try (Response response = orgIdPResource.create(idpRepresentation)) {
@ -89,18 +92,11 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
OrganizationRepresentation orgRep = createOrganization(); OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider();
IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc");
try (Response response = orgIdPResource.create(idpRepresentation)) {
assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode()));
}
try (Response response = orgResource.delete()) { try (Response response = orgResource.delete()) {
assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode()));
} }
testRealm().identityProviders().get(idpAlias).toRepresentation(); testRealm().identityProviders().get(bc.getIDPAlias()).toRepresentation();
} }
@Test @Test
@ -110,7 +106,7 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider(); OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider();
IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc"); IdentityProviderRepresentation idpRepresentation = createRep("some-broker", "oidc");
//create IdP in realm not bound to Org //create IdP in realm not bound to Org
testRealm().identityProviders().create(idpRepresentation).close(); testRealm().identityProviders().create(idpRepresentation).close();
@ -118,7 +114,10 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode())); assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode()));
} }
try (Response response = orgIdPResource.delete()) { try (Response response = orgIdPResource.delete()) {
assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode())); assertThat(response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode()));
}
try (Response response = orgIdPResource.delete()) {
assertThat(response.getStatus(), equalTo(Status.NOT_FOUND.getStatusCode()));
} }
} }
@ -131,8 +130,4 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
idp.setEnabled(true); idp.setEnabled(true);
return idp; return idp;
} }
private void addCleanupIdP(String alias) {
getCleanup().addCleanup(() -> testRealm().identityProviders().get(alias).remove());
}
} }

View file

@ -62,7 +62,7 @@
</div> </div>
</#if> </#if>
<#elseif section = "socialProviders" > <#elseif section = "socialProviders" >
<#if realm.password && social.providers??> <#if realm.password && social?? && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}"> <div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/> <hr/>
<h4>${msg("identity-provider-login-label")}</h4> <h4>${msg("identity-provider-login-label")}</h4>

View file

@ -88,7 +88,7 @@
</div> </div>
</#if> </#if>
<#elseif section = "socialProviders" > <#elseif section = "socialProviders" >
<#if realm.password && social.providers??> <#if realm.password && social?? && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}"> <div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/> <hr/>
<h2>${msg("identity-provider-login-label")}</h2> <h2>${msg("identity-provider-login-label")}</h2>